Compare commits

...
Author SHA1 Message Date
jackandClaude Fable 5 10ba71b1f8 Fix panic-wipe window, frame byte bound, and delivery-side test waits
Three confirmed defects from adversarial review of the per-relay inbound
pipeline (PR #1352):

- MEDIUM-2: a detached gift-wrap decrypt spawned just before a panic wipe
  strongly captures the pre-wipe Nostr private key and could deliver
  plaintext into ChatViewModel after the wipe. Add a per-pipeline
  monotonic wipe generation (NostrInboundPipeline.wipeGeneration), bumped
  from panicClearAllData via invalidateInFlightDecrypts(); spawn sites
  capture it and every main-actor hop drops the task's result on
  mismatch. The account-mailbox path (processNostrMessage) had the same
  pre-existing hazard and gets the identical guard, capturing the
  generation atomically with the identity fetch.

- MEDIUM-1: the per-relay stream cap bounds FRAMES (256) but not BYTES;
  with the URLSession default of 1 MiB per WebSocket frame a hostile
  relay could pile up ~256 MiB. Set URLSessionWebSocketTask
  .maximumMessageSize to TransportConfig.nostrInboundMaxFrameBytes
  (512 KiB — an order of magnitude above any legitimate Nostr event or
  gift wrap we produce or expect), halving the worst case to 128 MiB,
  and correct the "cannot exhaust memory" comments to state the actual
  cap × maxFrameBytes bound.

- LOW-5: three NostrRelayManagerTests gated on messagesReceived (first
  main hop) then immediately asserted delivery-side state that only
  lands after off-main verification plus a second main hop. Wait on the
  delivery-side state (receivedIDs / duplicate-drop counts) directly,
  keeping all assertions.

Also: brief comment documenting pendingGiftWrapIDs growth (LOW-6) and a
regression test that a panic wipe issued after spawn drops the decrypted
result while leaving the pipeline usable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 11:08:29 +02:00
jackandClaude Fable 5 2d5250e49a Verify Nostr inbound per relay to avoid head-of-line blocking
The single global inbound consumer serialized ALL relay traffic behind
one Schnorr-verification queue, so a burst of EVENT frames from one
busy/malicious relay stalled DMs, OKs, EOSEs, and events from every other
relay (codex P2). Give each relay connection its own bounded AsyncStream +
detached serial consumer instead: N relays verify in parallel while each
relay's frames stay in arrival order (per-relay ordering preserves the
per-subscription ordering that actually matters, since a subscription's
events for a relay all arrive on that relay's socket).

Continuations live in a lock-guarded Sendable router so the non-isolated
socket receive callback can route a frame to the right relay stream with
no per-frame main hop; the main actor owns pipeline start/teardown, wired
into connect, disconnect, panic wipe, per-relay disconnect, retry, and the
default-relay revoke path. Streams use .bufferingNewest so a relay flooding
faster than it verifies sheds its OWN oldest frames — it can neither exhaust
memory nor starve other relays.

Security invariants are unchanged: signature verified exactly once, off
main; dedup pre-check-before / record-after-verify (forged copies still
can't poison the dedup set); the atomic main-actor check-and-record in
deliverVerifiedInboundEvent; the inner NIP-17 seal check untouched.

Tests: existing per-relay in-order-delivery and tampered-signature
dedup-poison tests still pass; add a cross-relay non-blocking test proving
a large backlog on relay A does not delay a later frame on relay B.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 00:08:55 +02:00
jackandGitHub fef775bae2 Merge branch 'main' into perf/nostr-inbound-offmain-verify-once 2026-07-01 23:52:20 +02:00
68eeba97ff Use Xcode-bundled Swift in CI instead of a standalone toolchain (#1353)
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: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:51:58 +02:00
jackandClaude Fable 5 8af6fc2d5f Verify Nostr inbound signatures once, off the main actor
Every inbound relay event was Schnorr-verified TWICE, both times on the
main actor: once in NostrRelayManager.handleParsedMessage and again in
each NostrInboundPipeline / GeoPresenceTracker handler. Each
verification re-serializes the event JSON, hashes it, and runs a
secp256k1 Schnorr verify, so busy geohashes paid double crypto on the
UI thread. Geohash gift-wrap NIP-17 decryption (two ECDH+ChaCha layers)
also ran on the main actor.

Changes:
- NostrRelayManager now owns a serial off-main inbound pipeline
  (AsyncStream + single consumer task): frames are parsed and verified
  in arrival order off the main actor, preserving per-subscription
  delivery order. This is the single verification point for the whole
  inbound path; downstream handlers only ever see verified events.
- Dedup stays two-phase and unpoisonable: a cheap main-actor duplicate
  LOOKUP runs before verification (duplicate fan-in from several relays
  never pays for crypto — previously every duplicate was verified), and
  events are RECORDED as seen only after the signature verifies, so a
  forged-signature copy can never suppress the genuine event.
- NostrInboundPipeline and GeoPresenceTracker drop their redundant
  re-verification; geohash gift-wrap decryption moves off the main
  actor following the existing account-mailbox Task.detached pattern
  (atomic main-actor check-and-record, then decrypt off-main, then hop
  back for state updates).
- Tests: relay-level coverage for tampered gift wraps and for in-order
  delivery of back-to-back frames; pipeline-level tampered-signature
  tests move to the relay boundary where the invariant now lives; the
  mock relay connection queues frames emitted before receive re-arms.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-01 23:40:23 +02:00
GitHub Action f688e529f6 Automated update of relay data - Sun Jun 21 07:37:42 UTC 2026 2026-06-21 07:37:42 +00:00
jackandGitHub 96e32ba990 Merge pull request #1345 from permissionlesstech/fix/security-audit-critical-high-batch
Fix security audit findings: 3 critical, 7 high
2026-06-17 09:44:26 +02:00
jack 0a2f4d9c9d Tighten panic wipe and NIP-17 regressions 2026-06-17 09:27:13 +02:00
jack 914135adb0 Fix panic wipe relay and geohash state 2026-06-16 13:56:15 +02:00
jackandGitHub cd7ffa0df9 Merge branch 'main' into fix/security-audit-critical-high-batch 2026-06-16 13:52:10 +02:00
GitHub Action bbe1ed0652 Automated update of relay data - Sun Jun 14 07:34:58 UTC 2026 2026-06-14 07:34:58 +00:00
jackandClaude Fable 5 f07b032b99 Fix CI exit hang: sign reassembled public packets in FragmentationTests
Bisecting (base was 4/4 clean, branch 3/3 hung, reliably reproducible)
pinned the parallel-suite exit hang to the public-message signature
requirement (security fix #2), via FragmentationTests:

reassemblyFromFragmentsDeliversPublicMessage and
duplicateFragmentDoesNotBreakReassembly send fragments of an UNSIGNED
public message and `await capture.waitForPublicMessages(...)`. With #2 the
reassembled unsigned message is now (correctly) dropped, so
didReceivePublicMessage never fires. The helper then trips a latent bug:
on timeout it cancels the waiter task but never resumes its
CheckedContinuation, so the throwing task group's teardown awaits a child
that never completes and the whole test process hangs at exit (SIGKILL'd
by CI). Base never hit it because the message always arrived in time.

Fix matches the security model — real public broadcasts are signed: sign
the reassembled packet with a NoiseEncryptionService and preseed the
sender's signing key (same pattern as duplicatePacket_isDeduped), so #2
verifies and delivers it. Full parallel suite now exits cleanly 5/5 locally
(branch was 3/3 hung before).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:44:12 +02:00
jackandClaude Fable 5 2cbcb290f7 Remove lingering save timer from SecureIdentityStateManager (CI exit hang)
The app test job hung at process exit (all tests pass, then SIGKILL at the
CI timeout). Root cause: fix #5 replaced the dead Timer.scheduledTimer with
a real DispatchSourceTimer, created per manager instance, resumed and never
cancelled. Those live timer sources kept the dispatch machinery alive so the
swift-testing process never exited. The earlier `isRunningTests` guard was
fragile (it does not reliably detect the swift-testing-only runner on CI).

Drop the debounce timer entirely. Mutations now persist via the same
serialized `queue` barrier their callers already run on (saveIdentityCache ->
performSave directly); forceSave is a direct, non-blocking call (no
queue.sync, which is unsafe on the cooperative pool). No timer is left
scheduled, so nothing keeps the process alive. The original bug is still
fixed — saves now actually happen, unlike the never-firing Timer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:55:51 +02:00
jackandClaude Fable 5 9cf7c80518 Reduce test-process churn to fix flaky CI exit hang
The app test job intermittently hung at process exit. The suite is
load-sensitive and historically prone to cooperative-pool/teardown
deadlocks; the security changes added background work to the test process
that pushed it over the edge. Make the unit-test BLEService/identity
manager quiescent and remove blocking sync:

- forceSave() no longer does queue.sync(.barrier). It is reachable from
  deinit and from async tests on the swift-concurrency cooperative pool,
  where a blocking barrier-sync can starve/deadlock the pool. It now
  cancels the debounce timer and persists directly. (Removed the
  now-unneeded queue-specific-key re-entrancy machinery.)
- SecureIdentityStateManager persists synchronously under tests instead of
  scheduling a DispatchSourceTimer that lingers past process exit.
- Gate gossip-sync start (in addition to the maintenance timer) behind
  real Bluetooth init, so the test BLEService runs no periodic
  sign/broadcast/sync churn.
- Skip the panic Nostr reconnect under tests (connecting the shared relay
  singleton starts network/reconnect work that never completes).

Production behavior is unchanged: real Bluetooth builds run all timers and
the debounced save as before; the debounce save now actually fires
(previously a Timer on a GCD queue that never ran).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:40:39 +02:00
jackandClaude Fable 5 f76fd8a538 Fix CI hang: gate maintenance timer to Bluetooth-enabled; sign dedup test packet
Root cause of the CI app-test hang was a pre-existing bleQueue<->collectionsQueue
lock inversion driven by the periodic maintenance timer (performMaintenance ->
drainAllPendingWrites takes collectionsQueue while another path holds it and
sync-waits on bleQueue via readLinkState). The timer is created unconditionally
in init, so it also ran in the unit-test process (initializeBluetoothManagers:
false), where it only churns BLE writes/notifications/announces that don't exist.
Recent timing changes made the latent deadlock surface reliably.

- Only start the maintenance timer when real CoreBluetooth managers were
  initialized (maintenanceTimerEnabled). Production behavior is unchanged; the
  unit-test process no longer runs the timer and cannot hit the inversion.

Also fix BLEServiceCoreTests.duplicatePacket_isDeduped, which sent an unsigned
public packet that the new signature requirement (security fix #2) correctly
drops. The test now signs the packet and preseeds the sender's signing key
(production sendMessage signs public broadcasts), exercising the dedup path
(security fix #7) end to end. _test_handlePacket gains an optional
signingPublicKey to seed the registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:09:06 +02:00
jackandClaude Fable 5 09c2c12838 Fix deadlock in identity-cache forceSave (CI hang)
The forceSave() rewrite used queue.sync(flags: .barrier), but forceSave
is also called from deinit. The debounce timer's barrier hop captured
self strongly, so when that block dropped the last reference the manager
deallocated *on* the identity queue — deinit -> forceSave -> queue.sync
then deadlocked synchronizing onto the queue it was already running on.
This hung the test process at exit (CI SIGKILL / exit 137).

- forceSave() now detects (via a queue-specific key) when it is already
  executing on the queue and runs the save directly instead of sync-ing
  onto itself.
- The timer's barrier hop now captures self weakly, so it can no longer
  trigger a deallocation on the queue in the first place.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 17:46:24 +02:00
jackandClaude Fable 5 8378ff949a Address Codex review: verify public messages against registry signing key
The public-message signature check fell back to signedSenderDisplayName,
which only searches the asynchronously-persisted identity cache. Because
the peer registry is updated synchronously on a verified announce, a
message arriving immediately after that announce could have a valid
signature and a verified registry entry yet still be dropped (cache not
caught up).

Verify the packet signature against the signing key already present in
the synchronously-updated peer registry first; fall back to the
persisted-identity lookup only for peers not yet in the registry. The
security property is unchanged: a spoofed senderID claiming a registry
peer still fails registry verification and the persisted fallback, and
is dropped.

Adds tests for the race (delivered via registry key before cache
persists) and the spoof case (invalid signature falls back and drops).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:18:44 +02:00
jackandClaude Fable 5 ca63893197 Fix security audit findings: 3 critical, 7 high
A broad audit surfaced ten critical/high issues across the crypto,
transport, identity, and panic-wipe layers. This fixes all ten.

Critical:
- Nostr DMs were unauthenticated. The NIP-17 seal was signed with a
  throwaway ephemeral key and the receiver never verified it, so anyone
  who knows a recipient's npub could forge messages (and delivery/read
  receipts) into an existing trusted conversation. The seal is now
  signed with the sender's real identity key, and the receiver verifies
  the seal signature and that seal.pubkey == rumor.pubkey.
  NOTE: this is a breaking wire-protocol change (see PR).
- Public BLE messages trusted registry membership instead of the packet
  signature. Since senderID is attacker-controlled, any verified peer
  could be impersonated in public chat. A valid signature from the
  claimed sender is now required before any registry identity is used.
- Unverified announces still persisted the announced identity, letting a
  replayed noisePublicKey overwrite a victim's stored signing key and
  nickname. persistIdentity is now gated on verification.

High:
- Noise decrypt trapped on a 16-19 byte ciphertext (negative prefix
  length after nonce extraction) — a remote crash. Now validated.
- Identity-cache debounce save used Timer.scheduledTimer on a GCD queue
  with no run loop, so it never fired; block/verify/favorite changes
  only persisted on explicit forceSave. Replaced with a
  DispatchSourceTimer on the queue; forceSave is now serialized.
- Identity-cache key load couldn't tell "missing" from a transient
  keychain failure and would regenerate (deleting) the key, orphaning
  the cache. Now uses getIdentityKeyWithResult and falls back to a
  session-only ephemeral key without clobbering the persisted key/cache.
- BLE receive-dedup key lacked a payload digest, so post-handshake
  flushes (queued msgs + delivery/read acks in the same ms) were dropped
  as duplicates. Digest added, matching the ingress registry.
- Maintenance timer was created only in init and never recreated after a
  panic stop/start, silently degrading the mesh until app restart. Now
  recreated in startServices.
- Panic wipe left persisted location state (selected channel, teleport
  set, bookmarks) and cached per-geohash Nostr private keys behind. Both
  are now cleared.
- Panic spawned an orphan NostrRelayManager instead of reusing .shared,
  splitting relay state from every other component. Now reuses .shared.

Tests updated to assert the fixed behavior (announce no longer persists
unverified identities; public messages require a signature; receive
dedup ID includes the payload digest).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 14:07:28 +02:00
fdf28aa5bb Fix launch crash: recursive dispatch_once between NostrRelayManager and NetworkActivationService (#1343)
* Fix launch crash: recursive dispatch_once between NostrRelayManager and NetworkActivationService

NostrRelayManager.init() runs applyDefaultRelayPolicy(force: true), which
calls dependencies.activationAllowed() when the user has location
permission or a mutual favorite. That closure resolves
NetworkActivationService.shared, whose init captured
NostrRelayManager.shared — re-entering the still-running dispatch_once on
the same thread. libdispatch traps on recursive dispatch_once
(EXC_BREAKPOINT in _dispatch_once_wait), killing the app ~50ms after
launch, before the first frame.

Fresh installs were unaffected (no permission, no favorites, so the
policy path never touched NetworkActivationService during init), which is
why this passed local testing but crashed established TestFlight users on
every launch. Two independent TestFlight crash reports on 1.5.2 (1)
show the identical stack.

Break the cycle by resolving the relay controller lazily: store a
provider closure in init and dereference NostrRelayManager.shared on
first use (start()/reevaluate()), after both singletons have finished
initializing. The injectable test initializer keeps its signature.

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

* Bump version to 1.5.3

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-06-12 12:48:53 +02:00
266827ceff Extend BLE mesh range: relax RSSI gates, lift sparse TTL clamps, faster walk-back reconnects (#1338)
* Extend mesh range: relax RSSI gates, lift sparse TTL clamps, drain connection queue

Range improvements to the BLE mesh, all policy-level (no wire/protocol
changes):

- Drain the connection candidate queue from the maintenance tick.
  Weak-RSSI discoveries are enqueued rather than connected, but the
  queue was only drained on disconnect/failure/timeout events — an
  isolated node surrounded only by weak (distant) peers queued them
  all and never connected to anyone.
- Relax isolated RSSI floors from -90/-92 to -95/-100 and relax after
  30s instead of 60s. When isolated, a fringe connection beats no
  connection; CoreBluetooth rarely reports below -100 so prolonged
  isolation now effectively accepts any decodable peer.
- Drop the global high-timeout RSSI escalation (-80 after 3 timeouts
  in 60s). One flaky distant peer could blind the node to every other
  edge-of-range peer; per-peripheral cooldown, the discovery ignore
  window, and score bias already contain flaky links individually.
- Relay at full incoming TTL in thin chains (degree <= 2). Sparse
  line topologies are exactly where every hop counts and where flood
  cost is minimal; previously messages lost a hop to the clamp.
- Raise the fragment relay TTL cap from 5 to 7 in sparse graphs so
  media reaches as far as text; dense graphs keep the 5-hop clamp to
  contain full-fanout fragment floods.
- Extend peer reachability retention from 21s to 60s (verified) /
  45s (unverified) so duty-cycled nodes (worst-case dense announce
  interval 38s) don't forget peers between announces.
- Extend the directed store-and-forward spool window from 15s to 60s
  so brief link gaps heal via the periodic flush.

957 tests pass.

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

* Reconnect quickly after walk-away disconnects

Field test (walk away + return between two devices) showed reconnect
landing exactly 15.0s after the supervision-timeout disconnect: the
scheduler records a dropped established connection via
recordDisconnectError into the same map as connect timeouts, and
handleDiscovery hard-ignores rediscoveries for 15s.

Those are different situations. A connect attempt that timed out means
the peer likely isn't reachable, so backing off is right. A dropped
established connection usually means the peer walked out of range and
will return — track it separately and only ignore rediscoveries for 3s
(enough for CoreBluetooth to settle), so walking back into range
reconnects ~12s sooner.

Disconnect errors also no longer feed the weak-link cooldown or the
candidate-score timeout bias; those penalties now apply only to peers
that never answered a connect attempt.

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

* Honor the disconnect settle window on the queue drain path

Codex review caught that the 3s settle window was only enforced in
handleDiscovery. A candidate can already be sitting in the queue when
its peripheral drops (weak-RSSI adverts are enqueued even while
connected, since the RSSI check precedes the existing-state check),
and didDisconnectPeripheral immediately drains the queue — so the
stale entry could reconnect right through the window, recreating the
reconnect/cancel thrash it exists to prevent.

nextCandidate now defers such candidates with retryAfter for the
window's remainder, mirroring the weak-link cooldown pattern.

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

* Relay on one link per bound peer instead of both dual-role links

Three-device field test (star topology) showed every relayed fragment
arriving twice at the leaf: dual-role pairs hold two live links (we as
central writing to their peripheral, they as central subscribed to
ours) and broadcast/relay fanout sent the same packet down both — 2x
airtime on exactly the pairs that talk most, with the receiver just
discarding the duplicate.

The fanout selector now collapses link selection to one link per bound
peer, preferring the peripheral (write) side since it has per-link
flow control via canSendWriteWithoutResponse, while notifications
share the peripheral manager's update queue across all centrals.
Links with no bound peer yet (pre-announce) pass through untouched.

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

* Only notify "bitchatters nearby" on the empty-to-populated transition

Devices sitting idle and connected kept re-firing the notification.
Two bugs in handleNetworkAvailability:

- Peers first sighted during the 5-minute cooldown were never added to
  recentlySeenPeers (the formUnion only ran when a notification
  fired), so they stayed "new" forever and re-triggered on the next
  routine peer-list event once the cooldown lapsed.
- There was no went-from-zero gate at all: any unseen peer notified,
  even while already meshed with others who are visible in the app.

Every sighted peer is now recorded regardless of cooldown, and the
notification only fires when the mesh transitions from confirmed-empty
to populated with genuinely new peers. meshWasEmpty resets only via
the existing confirmed-empty paths (30s empty confirmation, 10-minute
quiet reset), so brief link flaps stay silent. The cooldown becomes
injectable so tests can prove the transition gate independently.

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

* Bump version to 1.5.2; Xcode 26.5 project settings update

Marketing version 1.5.1 -> 1.5.2 (pbxproj + Release.xcconfig).
Project settings refresh from Xcode 26.5: upgrade-check stamp, drop
redundant DEVELOPMENT_TEAM self-references, scheme version stamps.

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

* Disable string catalog symbol generation

The Xcode 26.5 settings refresh enabled STRING_CATALOG_GENERATE_SYMBOLS
(the new default), which fails on the literal "%@" key in
Localizable.xcstrings — a pure format placeholder can't become a Swift
identifier. Nothing in the codebase references generated catalog
symbols, so turn the feature off rather than renaming keys around it.

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

* Guard _PreviewHelpers references for archive builds

Archiving for TestFlight failed: _PreviewHelpers is a development
asset, so its sources (PreviewKeychainManager, BitchatMessage.preview)
are excluded from Release/archive builds, and two call sites
referenced them unconditionally:

- TextMessageView's #Preview block — now wrapped in #if DEBUG
- FavoritesPersistenceService.makeDefaultKeychain's test branch — the
  in-memory-keychain-under-test path is now #if DEBUG; tests always
  run Debug so behavior is unchanged, and Release always gets the real
  KeychainManager

Verified with an iOS Release arm64 build (the archive configuration).

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-06-12 10:38:39 +02:00
97bc3f53bc Raise positive waitUntil timeouts to 5s for loaded CI runners (#1340)
NoiseCoverageTests' session-callback test failed on the first CI run
that actually executed it (every run since it landed had hung and been
killed before completion): onSessionEstablished fires via
DispatchQueue.global().async, and the test waited only 0.5s — fine on
a dev machine, too tight on a loaded CI runner saturated by parallel
test workers.

Raise every positive-wait timeout from 0.5s to 5s (matching
TestConstants.defaultTimeout) across the suites that poll for async
callbacks. waitUntil returns as soon as the condition holds, so
passing runs are unaffected; only genuine failures wait longer. The
two negative waits in BLEServiceCoreTests ("expect nothing arrives")
deliberately keep their short windows.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 10:08:42 +02:00
9dc0ba6991 Fix CI app-suite deadlock: cooperative-pool-blocking thread-safety tests (#1339)
* Exclude perf baselines from parallel CI via --skip; sample hung tests

Every app-suite CI run since the perf baselines landed (#1335) has
timed out at the 15-minute job limit — main has been red for five
consecutive runs. The job logs show the PerformanceBaselineTests
fixtures dispatched into the parallel phase despite the
BITCHAT_SKIP_PERF_BASELINES env guard from #1336, followed by ~11
minutes of silence until the timeout kills swiftpm-testing. The suite
passes locally in seconds with identical flags, so the hang is
specific to the CI toolchain/runners — consistent with the known
XCTest-measure-under-parallel-workers hang the serial step was
created to avoid.

Two changes:
- Exclude the baselines from the parallel phase with --skip at the
  SPM level, which removes them from the worker processes entirely
  instead of relying on the env guard reaching setUpWithError. They
  still run (and gate) in the dedicated serial step.
- Wrap the parallel run in a 10-minute watchdog that samples any
  still-running test processes before killing them, so if anything
  else ever hangs, the run fails fast with thread stacks in the log
  instead of a silent 15-minute timeout.

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

* Arm the test-hang watchdog only for the test execution phase

Codex review: swift test builds before running, so the watchdog timer
included dependency resolution and compilation — a cold-cache coverage
build on a slow runner could be killed before tests ever started.
Build the tests in their own step (bounded by the 15-minute job
timeout like any build) and run the watchdog around swift test
--skip-build, tightened to 5 minutes now that it times only test
execution.

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

* Hammer transport thread-safety tests from the dispatch pool, not the cooperative pool

The watchdog added in this PR captured the actual CI hang twice, with
identical stacks both times: NostrTransportTests' 100-task groups park
every Swift Concurrency cooperative thread in a blocking queue.sync
(the pool has one thread per core — 3 on CI runners, 10+ on dev
machines, which is why this never reproduced locally). Blocking the
entire cooperative pool violates the forward-progress contract, and
the runners' dispatch wedges under the resulting asyncAndWait flood —
taking concurrently running tests down with it (the panic-reset test
deadlocked in a serviceQueue barrier that never got scheduled).

Run the same 100 concurrent hammer iterations via
DispatchQueue.concurrentPerform from a single global-queue hop instead:
identical thread-safety coverage, executed on dispatch worker threads
where blocking is legal, zero cooperative threads parked.

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-06-12 09:50:48 +02:00
af954b05ea Liquid Glass theme with in-app appearance switcher (#1337)
* Centralize UI theme colors into semantic ThemePalette tokens

Introduce AppTheme/ThemePalette (Utils/Theme.swift) with an environment
key and @ThemedPalette property wrapper, persisted via @AppStorage.
Views now resolve background/primary/secondary/accentBlue/alertRed/
divider tokens from the environment instead of computing colors inline,
removing the backgroundColor/textColor/secondaryTextColor prop-drilling
through the header, composer, and people-sheet hierarchy.

Matrix theme output is pixel-identical; this is groundwork for a
user-selectable theme switcher.

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

* Add liquid glass theme with in-app appearance switcher

Add a .liquidGlass AppTheme alongside the matrix terminal theme,
selectable from a one-line APPEARANCE row in the app info sheet
(persisted via @AppStorage, applies live).

Liquid glass renders system fonts and colors over a subtle gradient
backdrop, with the header and composer floating as Liquid Glass panels
(real .glassEffect() on iOS/macOS 26, compiler-gated with an
ultraThinMaterial fallback for older SDKs). The message list scrolls
underneath the chrome via safe-area insets, and all sheets share the
same backdrop and surface language. The matrix theme is unchanged.

Details:
- Theme is threaded through ChatMessageFormatter so message
  AttributedStrings switch font design per theme; the per-message
  format cache gains a variant key so themes never serve each other's
  cached strings
- New palette tokens: accent (interactive tint) and locationAccent
  (geohash green), replacing scattered hardcoded greens/blues in the
  voice note, waveform, verification, and people-sheet views
- Header controls get full-height tap targets and the people count
  becomes a real Button (previously a tap gesture on the cluster)
- CommandSuggestionsView renders nothing when empty instead of a
  zero-height view that pushed the composer input off-center
- New appearance strings localized for all 29 catalog languages

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-06-12 01:13:04 +02:00
jackandGitHub c8381737bb Final re-grade fixes: parked EOSE fallback, panic atomicity, perf-gate calibration, serial benchmarks (#1336)
Final re-grade fixes: parked EOSE fallback, panic-reset atomicity, overflow visibility
2026-06-11 22:25:26 +02:00
jackandClaude Fable 5 fa136d8973 Run perf benchmarks serially in their own CI step
The intermittent CI hang was caught by the new job timeout: the run
froze on the last remaining parallel test slot, an XCTest measure
benchmark (testNostrInboundEventHandling_freshEvents), after 4 hangs
in 5 runs - while the same suite completes in seconds locally and the
test itself is bounded. Independent of the micro-cause, benchmarks
do not belong inside the parallel suite: measuring while test processes
contend for cores is where our 2x CI variance came from. The parallel
run now skips benchmarks (BITCHAT_SKIP_PERF_BASELINES=1) and a
dedicated serial step runs them on an otherwise idle runner with a
6-minute step timeout, feeding the floor gate as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:18:55 +02:00
jackandClaude Fable 5 7fbe6a4a7e Fail hung CI jobs fast with a 15-minute timeout
Two app-test jobs hung intermittently (40+ minutes against a normal
~4-5), holding macOS runners against GitHub's 360-minute default and
starving the queue - subsequent runs sat pending, which read as "CI now
takes 10+ minutes". Jobs now time out at 15 minutes (3x the normal
duration) so a hang fails loudly instead of silently consuming the
runner pool. The intermittent hang itself is under investigation
separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 22:00:34 +02:00
jackandClaude Fable 5 4791114406 Recalibrate perf floors to slowest-observed-CI basis
The gate tripped on a uniformly slow runner: every benchmark ran at
~2/3 of the previous CI run and nostrInbound.duplicate fell to 87% of
its floor. Root cause: floors were derived from local numbers, but CI
slowdown is benchmark-dependent - sub-millisecond passes amplify runner
overhead (the duplicate path runs at ~20% of local speed on CI while
most benchmarks run at 40-60%). Floors are now ~50% of the slowest
observed CI run, recorded alongside the local references. Every floor
remains 10-200x above known regression values (the pre-optimization
duplicate path measured 2.2k/sec against the 250k floor), so order-of-
magnitude regressions still fail loudly. Verified against the slow
run's numbers: all 11 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:45:59 +02:00
jackandClaude Fable 5 1a6a08f92a Make Noise-service replacement atomic with the identity swap
During a panic reset, the new NoiseEncryptionService was assigned
before the identity barrier ran, so a previously queued send block
could observe the new crypto service alongside the old peer ID -
signing with the new identity while carrying the old sender. The
service teardown, replacement, callback configuration, and derived
identity swap now run inside one messageQueue barrier
(refreshPeerIdentity executes inline via its re-entrancy check), so
queued sends see either the complete old identity or the complete new
one, never a mix.

Found by Codex review on #1336.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:36:12 +02:00
jackandClaude Fable 5 0b007eee1a Close the final re-grade findings: parked EOSE fallback, panic atomicity, overflow visibility
EOSE callbacks parked while Tor is bootstrapping now get a fallback
unblock at the standard 10s EOSE timeout (via the injected scheduler,
generation-guarded, single-fire) instead of waiting up to ~225s for
Tor-readiness retry exhaustion. The identity swap in
refreshPeerIdentity runs inside a messageQueue barrier with re-entrancy
guard, so a panic reset can no longer race in-flight packet builds
(deadlock analysis documented; both call paths verified off-queue).
Relay send-queue overflow drops now log a sampled warning.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 21:29:08 +02:00
jackandGitHub 1480b51c76 Close architecture re-grade gaps: visible failures, relay bounds, crypto encapsulation, perf gate, field diagnostics, resurrected Noise vectors (#1335)
Close architecture re-grade gaps: visible send failures, relay bounds, crypto encapsulation, perf gate, resurrected Noise vector tests
2026-06-11 20:10:28 +01:00
jackandClaude Fable 5 0e38ccfb3b Snapshot delivery status in message rows so read receipts render immediately
The publish chain was healthy: the store mutates the shared
BitchatMessage and republishes, the .statusChanged fan-out reaches both
mirrored conversations, and PrivateInboxModel fires objectWillChange for
the selected DM under either key. The break was at the row view:
TextMessageView/MediaMessageView stored the reference-typed message and
read deliveryStatus in body, so SwiftUI's structural diff compared the
field by identity - same instance, mutated in place, row body skipped.
The blue tick waited for an unrelated invalidation (proven empirically
with a hosting-view probe).

Rows now snapshot deliveryStatus as a value at init; every republish
rebuilds row values with a fresh enum, the diff sees the change, and
the row re-renders immediately. Also fixes in-place send-progress
updates in media rows. Regression tests cover both mirrored selection
keyings at the feature-model level and the snapshot mechanic itself.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:54:39 +02:00
jackandClaude Fable 5 e74f36927f Sample BLE notification backpressure logs
The enqueue/drain/still-full logs fire per fragment during media
transfers (~150 lines for one 35KB image in field captures). They now
sample first + every 25th with a running event count, the sent/pending
lines merge into one, and the redundant peripheral-ready line is gone -
same treatment the relay event logs received. The drop-after-exhaustion
error stays unsampled.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:40:51 +02:00
jackandClaude Fable 5 cf3b85f65c Compile all logging out of release builds
Production builds previously still emitted info/warning/error entries
via os_log (content private-redacted, but entries, categories, and
timing metadata were visible, and message strings were constructed).
For a privacy-first app the right posture is silence: every SecureLogger
wrapper and both cores are now gated behind #if DEBUG, so release
builds construct no log strings and emit nothing. Debug builds are
unchanged (public formatting, level threshold via BITCHAT_LOG_LEVEL).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:31:28 +02:00
jackandClaude Fable 5 8899cb7f9e Add field correctness diagnostics: store invariant audit, drop and bounds proofs
ConversationStore.auditInvariants() verifies the per-conversation and
store-level message-ID indexes, caps, timestamp ordering, unread-set
membership, and selection validity - wired to the existing read-receipt
cleanup cadence, loud (.error) on violation, sampled heartbeat when
healthy (~2.8ms per audit at 5k messages, benchmarked and floored).
Router drops log both outcomes (marked failed / skipped by no-downgrade
guard); relay cap evictions, age sweeps, and jittered reconnect delays
log their counts; mirrored republishes get a sampled proof line. 11 new
invariant tests corrupt store state through DEBUG-only hooks since the
single-writer lockdown makes those states unreachable via intents.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 19:28:47 +02:00
jackandClaude Fable 5 22be3d6392 Resurrect dead Noise vector tests; add CI perf floors; make tests hermetic
Package.swift's .process("Noise") resource claim silently excluded all
of bitchatTests/Noise/ from compilation since Oct 2025 - including a
complete official-vector runner (cacophony + snow XX transcripts,
transport messages, handshake hash, byte-identical to upstream).
Narrowing the resource to the JSON file and loading via Bundle.module
brings 51 Noise tests back to life, with a guard asserting each
vector's protocol name matches the app's.

CI gains a performance floor gate: perf-floors.json carries deliberately
generous floors (~25% of measured throughput) that catch algorithmic
regressions without flaking on runner variance; PERF lines reach the
gate via an O_APPEND side-channel file since swift test --parallel
swallows passing tests' stdout.

Tests are now hermetic: FavoritesPersistenceService uses an in-memory
keychain under test (fixes the securityd hang that blocked pipeline
benchmarks locally) and read-receipt persistence uses a wiped scratch
UserDefaults suite instead of .standard.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:53:34 +02:00
jackandClaude Fable 5 8a867a17a1 Remove noise service exposure; single-owner selection state
Transport callers no longer reach the raw NoiseEncryptionService:
getNoiseService() is deleted in favor of narrow purpose-named Transport
methods (session public key, identity fingerprint, static/signing keys,
sign/verify, callback installation). VerificationService now reaches
crypto through the transport, so it can no longer pin a stale service
across a panic reset. myPeerID/myNickname become private(set); the
existing setNickname mutator is the sole nickname path.

ConversationStore is now the sole owner of private-chat selection:
PrivateChatManager.selectedPeer is a published read-only mirror, and
startChat/endChat mutate through the store intent. The bridge method
and its five call sites are deleted, removing a latent bug where a
stale manager selection pushed back into the store could resurrect a
just-removed conversation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:29:07 +02:00
jackandClaude Fable 5 ed86ed1065 Surface router drops as failed status; bound relay pending subscriptions; jitter backoff
MessageRouter outbox drops (attempt cap, TTL expiry in flush and
cleanup, per-peer overflow eviction) now invoke onMessageDropped, wired
to mark the message .failed in the ConversationStore - guarded so a
late failure never downgrades an already delivered/read status.

NostrRelayManager pending subscriptions gain a per-relay cap (64,
oldest-by-sequence eviction; durable intent still replays from
subscriptionRequestState) and a 10-minute age sweep on the existing
connect path. Reconnect backoff gets injectable +/-20% jitter so
recovering relays don't thundering-herd.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:05:44 +02:00
jackandGitHub e74d9a7937 Single-source-of-truth ConversationStore: 2-2.5x ingest, four stores and three sync bridges deleted (#1334)
Single-source-of-truth ConversationStore: 2-2.5x ingest, four stores and three sync bridges deleted
2026-06-11 13:19:24 +01:00
jackandClaude Fable 5 8289a6d05e Republish mirrored conversations on shared-instance status changes
When a private message is mirrored into stable-key and ephemeral-peer
conversations as one shared BitchatMessage instance, the first
conversation's status apply mutated the shared object and the second
skipped as already-equal - state stayed correct but the mirrored
conversation never republished, so a view observing it rendered stale
delivery/read status. The ID-only fan-out now republishes and emits
.statusChanged for every skipped conversation whose message holds the
applied status; genuinely-rejected distinct copies (downgrades) stay
untouched, and duplicate acks still publish nothing.

Found by Codex review on #1334.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 14:09:43 +02:00
jackandClaude Fable 5 38331e62f1 Cut views over to ConversationStore; delete legacy store, bridge, resolver
Feature models observe per-conversation objects directly: PublicChatModel
forwards the active Conversation's objectWillChange, PrivateInboxModel
republishes only for the selected peer's conversation - background
appends no longer invalidate foreground views. LegacyConversationStore,
the coalescing bridge, and IdentityResolver are deleted (resolver
canonicalization proved display-invisible: nothing enumerates direct
conversations, lookups are by exact peer ID, and raw keying is strictly
more robust - documented as a design deviation). Selection state moves
into the store. ChatViewModel.messages/privateChats survive as derived
read views for coordinators that genuinely need them; hot paths use a
new store-direct privateMessages(for:) witness.

Final numbers vs pre-migration baselines:
pipeline.privateIngest 9.7k -> 24.0k msg/s (2.5x)
pipeline.publicIngest  6.8k -> 13.7k msg/s (2.0x)
delivery updates       38k  -> 117-133k/s

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:57:12 +02:00
jackandClaude Fable 5 7fb1f4a219 Route delivery status through the store; delete the location index
ConversationStore maintains an exact messageID -> Set<ConversationID>
map at every mutation point (append/upsert/remove/migrate/trim/clear),
so delivery updates are ID-only lookups that fan out to mirrored
ephemeral/stable copies. ChatDeliveryCoordinator shrinks 327 -> 119
lines: the positional location index, its growth-detection/rebuild
machinery, and the duplicate no-downgrade check are deleted - the rule
now lives in exactly one place. The middle-insertion regression tests
are rewritten against the store since stale positional locations are
structurally impossible now.

delivery updates: 38k -> 262k/s (~6.9x); ingest pipelines unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:26:00 +02:00
jackandClaude Fable 5 99d1d1dccd Cut public message path over to ConversationStore; delete PublicTimelineStore
Mesh and geohash timelines are now store conversations. All public
mutation sites flow through store intents; PublicMessagePipeline keeps
its 80ms UI batching but commits batches via store appends with each
buffered entry carrying its destination conversation (a mid-batch
channel switch now flushes instead of dropping the buffer).
ChatViewModel.messages becomes a cached get-only view of the active
conversation, invalidated through the change subject. The mesh
late-insert threshold is consciously removed: it only ever ordered the
non-rendered messages copy, so strict timestamp insertion makes the
working set agree with rendered order. PublicTimelineStore and the
per-message full-array legacy sync are deleted; the coalescing bridge
mirrors public conversations for the remaining legacy readers.

pipeline.publicIngest: 6.6k -> 9.5k msg/s (+45%); private steady;
store.append 237k/s.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 13:03:07 +02:00
jackandClaude Fable 5 879d8cba12 Cut private message path over to ConversationStore
All private-message mutations now flow through store intents:
coordinators, PrivateChatManager (its @Published dicts deleted - now
read-only views over the store), outbound sends, delivery status, and
chat migration. The O(1) store dedup replaces the full-scan duplicate
check; insertion order is maintained by the store so sanitizeChat's
re-sort is a documented no-op. Both bootstrapper Combine bridges and
the Task.yield store synchronization are deleted.

ChatViewModel.privateChats/unreadPrivateMessages become get-only derived
views (measured: naive rebuild equals a change-invalidated cache within
noise, so the simpler form stays). Feature models still read the legacy
store, fed by a coalescing LegacyConversationStoreBridge (one mirror
per burst, marked for step-5 deletion).

pipeline.privateIngest: 9.6k -> 14.7k msg/s (+53%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 12:19:11 +02:00
jackandClaude Fable 5 ac3a2f2d34 Add single-writer ConversationStore core (additive)
Per-conversation ObservableObjects with O(1) dedup via an incrementally
maintained message-ID index, binary-search timestamp insertion, folded
cap policies, a no-downgrade delivery rule, and a typed change subject.
All mutation flows through store intents (conversation mutators are
fileprivate). The previous store is renamed LegacyConversationStore
pending deletion in step 5. 16 behavioral tests including per-
conversation publish isolation; store.append benchmarks at ~144k
messages/sec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 11:02:27 +02:00
jackandClaude Fable 5 45650854e7 Add conversation-store design doc and end-to-end ingest baselines
docs/CONVERSATION-STORE-DESIGN.md records the approved design: a
single-writer ConversationStore of per-conversation ObservableObjects
(per-conversation publishing, incremental ID index, folded caps, typed
change subject) replacing today's four-store/three-bridge topology,
with a five-step migration plan and explicit deletions/non-goals.

New pipeline benchmarks measure the CURRENT architecture end-to-end so
every migration step is judged against real before-numbers:
pipeline.privateIngest ~9.7k msg/s, pipeline.publicIngest ~6.8k msg/s
(200-message passes, stable within 1.5%).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:51:24 +02:00
75dd83d9cc Make location notes robust: durable relay subscriptions, failure decay, auto-recovery (#1333)
* Make location notes robust: durable relay subscriptions, failure decay, auto-recovery

Location notes (and geohash chat / DMs) intermittently stopped showing
events because the Nostr relay layer lost subscriptions and blacklisted
relays:

- Replay active subscriptions on every relay (re)connect. Relays drop
  REQs with the socket; previously a drop silently killed the
  subscription on that relay for the rest of the session. Durable
  subscription intent now also survives disconnect()/resetAllConnections
  (background -> foreground).
- Keep failed REQ sends queued instead of dropping them.
- Raise the EOSE fallback from a fixed 2s Timer to a 10s injected
  schedule (Tor needs more than 2s), and settle EOSE trackers when a
  relay disconnects before answering so initial load doesn't stall.
- Decay "permanently failed" relay markings after a 10-minute cooldown;
  previously ~9 minutes of outage (or one DNS hiccup) excluded a relay
  until app restart, with nothing resetting it on macOS.
- Make geo relay selection deterministic (distance, then host) so
  publishers and subscribers with the same directory agree on relays.
- Post .geoRelayDirectoryDidRefresh after a directory fetch and let
  LocationNotesManager auto-resubscribe out of the "no relays" state
  instead of requiring a manual retry.

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

* Guard subscription activation on connection identity

A REQ send completion from a dead socket could land after
handleDisconnection cleared the relay's subscriptions and re-mark the
subscription active, making the next connection skip the durable replay
and leave that relay silent. Only mark a subscription active if the
completing socket is still the relay's live connection.

Regression test defers send completions in the mock so the stale
completion deterministically interleaves between disconnect and
reconnect.

Addresses Codex review on #1333.

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-06-11 09:28:23 +01:00
jackandGitHub 93d01b8fa6 Performance & architecture: 640x hot-path win, zero coordinator back-refs, measured baselines (#1332)
Performance & architecture: 640x hot-path win, zero coordinator back-refs, measured baselines
2026-06-11 09:25:47 +01:00
jackandClaude Fable 5 6c0dbbbd0d Make lifecycle delayed read-pass deterministic in tests
The coordinator scheduled its delayed owner-level read pass via
DispatchQueue.main.asyncAfter, which a busy CI runner's main queue can
delay past any reasonable polling deadline. Scheduling is now an
injected context member (scheduleOnMainAfter); the ChatViewModel witness
keeps the exact asyncAfter behavior while the test mock runs the work
synchronously, eliminating the wall-clock poll entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:50:47 +02:00
jackandClaude Fable 5 09087b74cc Add coverage reporting to CI
swift test runs with --enable-code-coverage and each matrix job prints
an llvm-cov per-file + total summary (informational only - no
thresholds, so coverage can never be the reason a build goes red).
Local baseline at introduction: 69.7% lines.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:07:04 +02:00
jackandClaude Fable 5 cc76086615 Inject notification/favorites singletons through coordinator contexts
NotificationService.shared and FavoritesPersistenceService.shared
accesses across nine coordinators/components consolidate into
ChatViewModel witnesses behind intent-named context members
(notifyPrivateMessage, notifyMention, favoriteRelationship(forNoiseKey:),
allFavoriteRelationships, postLocalNotification, ...). Singleton reach
from coordinators is now zero; nine new tests cover previously
untestable notification/favorites flows.

Also root-causes the long-flaky gift-wrap dedup test: parallel tests
share LocationChannelManager.shared, so channel-switch tests trigger
clearProcessedNostrEvents() on every live ChatViewModel, wiping the
dedup record mid-test. The invariant (forged-signature copies never
poison dedup) now lives as a deterministic NostrInboundPipeline
mock-context test; two Schnorr concurrency probes added along the way
stay as regression guards for the P256K shared-context assumptions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 23:04:20 +02:00
jackandClaude Fable 5 638f3f5005 Split ChatNostrCoordinator into owned components along domain boundaries
The 1,109-line coordinator becomes a 187-line facade wiring three
components, each with its own narrow context protocol:
GeohashSubscriptionManager (384 lines - subscription IDs + relay
lifecycle, the only NostrRelayManager toucher), NostrInboundPipeline
(490 lines - the hot event path, dedup-before-verify ordering preserved
verbatim), and GeoPresenceTracker (192 lines - teleport detection,
sampling LRU, notification cooldowns, now directly tested).

Perf baselines confirm the hot path is unchanged: fresh events
2,131 -> 2,138/sec, duplicates ~1.41M/sec.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:28:08 +02:00
jackandClaude Fable 5 6091ee83ad Finish coordinator migration: zero ChatViewModel back-references remain
ChatPeerListCoordinator, ChatComposerCoordinator, ChatOutgoingCoordinator,
and GeoChannelCoordinator complete the migration; every coordinator now
depends on a narrow @MainActor context protocol. GeoChannelCoordinator's
three injected closures collapse into a weak context. New intent op
recordPublicActivity(forChannelKey:) keeps lastPublicActivityAt
single-writer. 15 new mock-context tests; flaky-poll deadline in the
gift-wrap dedup test raised for parallel load.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 22:13:09 +02:00
jackandClaude Fable 5 82736c4991 Migrate five more coordinators to narrow context protocols
ChatTransportEventCoordinator, ChatPeerIdentityCoordinator,
ChatMediaTransferCoordinator, ChatVerificationCoordinator, and
ChatLifecycleCoordinator drop their unowned ChatViewModel back-refs for
narrow @MainActor contexts (20-36 members each), reusing shared
witnesses across protocols. The two remaining raw writers of
sentReadReceipts now route through owner intent ops
(unmarkReadReceiptsSent, syncReadReceiptsForSentMessages), closing the
gaps noted in the previous commit. NoiseEncryptionService stays fully
out of the verification coordinator via installNoiseSessionCallbacks.
26 new mock-context tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:54:54 +02:00
jackandClaude Fable 5 707b22878d Convert shared coordinator state to owner-side intent operations
nostrKeyMapping, sentGeoDeliveryAcks/sentReadReceipts dedupe,
isBatchingPublic, geo subscription lifecycle, and private-chat selection
hand-off now mutate through single intent operations on ChatViewModel,
with backing storage locked down via private(set) so the single-writer
property is compiler-enforced. Context protocols downgrade to read-only
access where reads remain. 8 new contract tests.

Known remaining writers outside the protocols: sentReadReceipts is
passed inout to PrivateChatManager.syncReadReceiptsForSentMessages and
un-marked by ChatTransportEventCoordinator on disconnect.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:26:21 +02:00
jackandClaude Fable 5 80bed1f395 Add performance baselines; check dedup before verifying Nostr signatures
New PerformanceBaselineTests measure the hot paths (Nostr inbound, BLE
packet pipeline, GCS filters, delivery index, message formatting) with
deterministic fixtures and logged PERF metrics - baselines, not
assertions, so they cannot flake CI.

The suite immediately exposed that every inbound handler ran Schnorr
verification before the dedup lookup, so duplicate events - which
dominate real multi-relay traffic - each paid ~0.5ms of main-actor
crypto for nothing. All five handlers now do cheap rejects (kind, dedup
lookup) first and only record an event as processed AFTER its signature
verifies, so a forged-signature copy can never poison the dedup set and
suppress the genuine event. Gift-wrap verification also moves entirely
off the main actor with an atomic main-actor check-and-record.

Measured: duplicate-event handling 2.2k -> 1.39M events/sec (~640x);
fresh events unchanged (crypto-bound).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 21:11:06 +02:00
jackandClaude Fable 5 4b287f7490 Stop paying for filtered log messages; sample per-event hot-path logs
SecureLogger's public wrappers evaluated their message autoclosure before
the level check inside log(), so every filtered debug message across the
codebase still paid for string interpolation - hundreds of call sites on
the per-packet/per-event hot paths. The wrappers now guard the level
first, and debug() compiles out of release builds entirely. Regression
tests verify filtered messages are never constructed.

The two heaviest per-event debug logs (NostrRelayManager inbound events,
ChatNostrCoordinator geo events) are now sampled every 100th with a
running count, so debug-enabled dev builds stop emitting hundreds of
lines per minute in busy geohashes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:55:25 +02:00
jackandGitHub 3caf2d7663 Architecture hardening: Tor reliability, supply-chain CI gate, BLE handler extraction, coordinator contexts (#1331)
Architecture hardening: Tor reliability, supply-chain CI gate, BLE handler extraction, coordinator contexts
2026-06-10 20:24:39 +02:00
jack 14d025cc2a Merge remote-tracking branch 'origin/architecture-hardening' into architecture-hardening 2026-06-10 16:33:02 +01:00
jackandClaude Fable 5 19b28cf49d Rebuild message location index on non-append growth
The incremental index refresh assumed growth meant appended messages,
but PublicMessagePipeline inserts out-of-order arrivals by timestamp:
the count grows while the tail ID stays put, so the inserted message
never entered the index and later delivery updates for it silently
no-op'd (and, with retain-until-ack routing, left it queued for
resend). Detect non-append growth by checking the previously indexed
tail kept its position, and rebuild when it hasn't. Same check on the
per-peer private chat arrays, which re-sort by timestamp.

Found by Codex review on #1331.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:32:36 +01:00
jackandGitHub a2825ca288 Merge branch 'main' into architecture-hardening 2026-06-10 17:29:16 +02:00
jackandClaude Fable 5 3a995e20b6 Extract BLE packet handlers and migrate coordinators to narrow contexts
BLEService's per-packet-type orchestration moves into owned, tested
components (BLEAnnounceHandler, BLEPublicMessageHandler,
BLENoisePacketHandler, BLEFileTransferHandler, BLEFragmentHandler),
each taking an environment struct of closures so every queue hop stays
in BLEService and the handlers are synchronously testable. Behavior is
preserved verbatim, including Noise session recovery on decrypt failure
and single-block UI event ordering. handleLeave/handleRequestSync stay
in place as already-thin delegations. BLEService drops to 3393 lines.

Four coordinators (delivery, private conversation, Nostr, public
conversation) drop their unowned/weak ChatViewModel back-references for
narrow @MainActor context protocols, with ChatViewModel conformances as
single shared witnesses for overlapping members. Their true coupling is
now an explicit, reviewable surface, and each gains a mock-context test
suite covering flows previously testable only through the full view
model. Delivery/read acks now also clear the router's retained-send
outbox via the delivery context.

New LargeTopologyTests exercise production-shaped meshes with the
in-memory harness: an 8-peer relay chain with per-hop TTL decay, a
14-peer cyclic mesh with exactly-once delivery, partition/heal, and
topology churn.

App-layer runtime/model files updated alongside.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:22:52 +01:00
jackandClaude Fable 5 6bda919dd4 Fix transport reliability gaps: Tor stalls, weak-signal sends, GCS input validation
NostrRelayManager no longer strands work when Tor is slow to bootstrap:
failed readiness waits retry (bounded by nostrTorReadyMaxWaitAttempts)
instead of dropping queued relay connections, parked EOSE callbacks fire
after exhaustion so callers never hang, and sends made before Tor is
ready are queued locally (capped) instead of being dropped on a failed
wait - still strictly fail-closed.

MessageRouter now prefers a connected transport over a merely
window-reachable one, and sends made on a weak reachability signal are
retained in the outbox until a delivery/read ack confirms receipt
(receivers dedup by message ID), with resends bounded by attempt count.

GCS sync filters from the wire are bounds-checked (p in 1...32, m > 1)
at both the packet decode and filter decode layers; oversized Golomb
parameters previously decoded to garbage via silent shift overflow.

BLELinkStateStore is now explicitly pinned to bleQueue: debug builds
trap any access from another queue, enforcing the ownership discipline
the surrounding code already relied on by convention.

CI gains an iOS simulator build job (arm64 only; the vendored Arti
xcframework has no x86_64 simulator slice) so iOS-conditional code
paths are compile-checked - SPM tests only cover the macOS slice.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:22:35 +01:00
jackandClaude Fable 5 4093ee6733 Rebuild Arti from audited source with enforced provenance
Vendored arti.xcframework rebuilt from source (Rust 1.96.0, normalized
archive metadata for reproducible hashes). New ARTI-BINARY-PROVENANCE.md
records toolchain, rebuild steps, and a SHA256 manifest for every file
in the xcframework. A new CI workflow turns that policy into a gate:
PRs must keep the binary matching the manifest, and binary changes must
ship with source/lockfile/build-script evidence.

Also raises TorManager.awaitReady's default timeout from 25s to 75s to
match the bootstrap monitor deadline - a shorter wait reported "not
ready" while Arti was still legitimately bootstrapping, silently
stranding queued relay work.

Privacy policy, Tor integration doc, and privacy assessment updated to
match the current implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 16:22:15 +01:00
GitHub Action eb2c128cab Automated update of relay data - Sun Jun 7 07:23:10 UTC 2026 2026-06-07 07:23:10 +00:00
163 changed files with 20854 additions and 4954 deletions
+85
View File
@@ -0,0 +1,85 @@
name: Arti Binary Provenance
# The Arti xcframework is a vendored binary; these checks turn the policy in
# docs/ARTI-BINARY-PROVENANCE.md into an enforced gate:
# 1. The checked-in binary must match the hash manifest in the provenance doc.
# 2. A PR that changes the binary must also change at least one provenance
# input (Rust source, lockfile, build script, or the doc itself).
on:
push:
branches:
- main
paths:
- "localPackages/Arti/**"
- "docs/ARTI-BINARY-PROVENANCE.md"
pull_request:
paths:
- "localPackages/Arti/**"
- "docs/ARTI-BINARY-PROVENANCE.md"
jobs:
verify-hashes:
name: Verify xcframework hashes against provenance doc
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Compare artifact hashes with manifest
run: |
set -euo pipefail
doc="docs/ARTI-BINARY-PROVENANCE.md"
# Extract the manifest: lines of "<sha256> <path>" from the doc.
grep -E '^[0-9a-f]{64} localPackages/Arti/Frameworks/arti\.xcframework/' "$doc" \
| sort -k2 > expected.txt
if [ ! -s expected.txt ]; then
echo "::error::No hash manifest found in $doc"
exit 1
fi
# Hash the same file set the doc documents.
find localPackages/Arti/Frameworks/arti.xcframework -maxdepth 3 -type f -print0 \
| sort -z | xargs -0 sha256sum | sed 's/ \.\// /' | sort -k2 > actual.txt
if ! diff -u expected.txt actual.txt; then
echo "::error::Checked-in arti.xcframework does not match the manifest in $doc. If the binary change is intentional, rebuild per the doc and update the manifest in the same PR."
exit 1
fi
echo "All $(wc -l < actual.txt) artifact hashes match the provenance manifest."
require-provenance-evidence:
name: Binary changes must ship with provenance inputs
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Checkout code
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Check changed files
run: |
set -euo pipefail
base="origin/${{ github.base_ref }}"
git fetch --no-tags --depth=1 origin "${{ github.base_ref }}"
changed=$(git diff --name-only "$base"...HEAD)
echo "Changed files:"
echo "$changed"
if ! echo "$changed" | grep -q '^localPackages/Arti/Frameworks/arti\.xcframework/'; then
echo "No binary artifact changes; nothing to verify."
exit 0
fi
if echo "$changed" | grep -Eq '^(localPackages/Arti/(Cargo\.(toml|lock)|build-ios\.sh|arti-bitchat/)|docs/ARTI-BINARY-PROVENANCE\.md)'; then
echo "Binary change is accompanied by provenance inputs."
exit 0
fi
echo "::error::arti.xcframework changed without matching source, lockfile, build-script, or provenance-doc changes. See docs/ARTI-BINARY-PROVENANCE.md (\"Do not accept an xcframework-only update\")."
exit 1
+111 -6
View File
@@ -10,6 +10,9 @@ jobs:
test:
name: Run Swift Tests (${{ matrix.name }})
runs-on: macos-latest
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
# default (observed: intermittent app-suite hangs starving the queue).
timeout-minutes: 15
strategy:
fail-fast: false # Don't cancel other matrix jobs when one fails
@@ -26,17 +29,119 @@ jobs:
- name: Checkout code
uses: actions/checkout@v5
- name: Set up Swift
uses: swift-actions/setup-swift@v2
# Use the Xcode-bundled Swift toolchain: it always matches the SDK on
# the runner image. A standalone swift.org toolchain (setup-swift) broke
# whenever the image's Xcode moved ahead of it ("this SDK is not
# supported by the compiler").
- name: Note toolchain version (cache key)
id: swift-version
run: echo "version=$(swift --version 2>/dev/null | head -1 | shasum | cut -c1-12)" >> "$GITHUB_OUTPUT"
- name: Cache build artifacts
uses: actions/cache@v4
with:
path: ${{ matrix.path }}/.build
key: ${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
key: ${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/*.swift', matrix.path), format('{0}/**/Package.resolved', matrix.path)) }}
restore-keys: |
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ matrix.name }}-
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
${{ runner.os }}-${{ steps.swift-version.outputs.version }}-${{ matrix.name }}-
- name: Build tests
# Built separately so the hang watchdog below times only test
# execution: a cold-cache coverage build on a slow runner can
# legitimately take several minutes, and is already bounded by the
# 15-minute job timeout.
run: swift build --build-tests --enable-code-coverage --package-path ${{ matrix.path }}
- name: Run Tests
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
# Perf benchmarks are excluded here and run in their own serial step
# below: measuring while parallel test processes contend for cores
# produces noisy numbers, and the XCTest measure machinery has hung
# intermittently under parallel workers on loaded runners. Excluded
# via --skip (not just the env guard): every app run since the
# baselines landed timed out at the 15-minute job limit with the
# baseline tests dispatched into the parallel phase.
#
# The watchdog samples any still-running test processes after 5
# minutes (the suite passes in seconds when healthy; the build is
# done by this step) and kills the run, so a hang fails fast with
# stacks in the log instead of a silent timeout.
env:
BITCHAT_SKIP_PERF_BASELINES: "1"
run: |
swift test --skip-build --parallel --quiet --enable-code-coverage \
--skip PerformanceBaselineTests \
--package-path ${{ matrix.path }} &
test_pid=$!
(
sleep 300
if kill -0 "$test_pid" 2>/dev/null; then
echo "::group::Tests still running after 5 minutes — sampling before kill"
for pid in $(pgrep -if 'swiftpm-testing|xctest|PackageTests' || true); do
echo "--- sample of pid $pid ---"
sample "$pid" 5 2>/dev/null || true
done
echo "::endgroup::"
pkill -KILL -P "$test_pid" 2>/dev/null || true
kill -KILL "$test_pid" 2>/dev/null || true
fi
) &
watchdog_pid=$!
wait "$test_pid" && status=0 || status=$?
kill "$watchdog_pid" 2>/dev/null || true
exit "$status"
# Benchmarks run serially on an otherwise idle runner for stable
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
- name: Run performance benchmarks (serial)
if: matrix.name == 'app'
timeout-minutes: 6
env:
BITCHAT_PERF_LOG: ${{ github.workspace }}/perf-output.log
run: swift test --quiet --filter PerformanceBaselineTests
# Order-of-magnitude performance regression gate. Floors are deliberately
# generous (see bitchatTests/Performance/perf-floors.json) so this
# catches algorithmic regressions, never runner variance.
- name: Performance floor gate
if: matrix.name == 'app'
run: ./scripts/check-perf-floors.sh perf-output.log
# Informational only: surfaces per-file and total line coverage in the
# job log so coverage trends are visible on every PR. No thresholds —
# this must never be the reason a build goes red.
- name: Coverage summary
run: |
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
PROF="$BIN_PATH/codecov/default.profdata"
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
if [ -f "$PROF" ] && [ -f "$BINARY" ]; then
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true
else
echo "No coverage data found; skipping summary."
fi
# SPM tests above only compile the macOS slice; this job covers the
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
ios-build:
name: Build iOS app (simulator)
runs-on: macos-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v5
- name: Build iOS (simulator, no signing)
# arm64 only: the vendored arti.xcframework has no x86_64 simulator slice.
run: |
set -o pipefail
xcodebuild -project bitchat.xcodeproj \
-scheme "bitchat (iOS)" \
-sdk iphonesimulator \
-destination 'generic/platform=iOS Simulator' \
ARCHS=arm64 \
CODE_SIGNING_ALLOWED=NO \
build
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.5.1
MARKETING_VERSION = 1.5.3
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
+42 -16
View File
@@ -1,6 +1,6 @@
# bitchat Privacy Policy
*Last updated: January 2025*
*Last updated: June 2026*
## Our Commitment
@@ -9,7 +9,7 @@ bitchat is designed with privacy as its foundation. We believe private communica
## Summary
- **No personal data collection** - We don't collect names, emails, or phone numbers
- **No servers** - Everything happens on your device and through peer-to-peer connections
- **No accounts or company servers** - Mesh chat works peer-to-peer; optional Nostr features use public or user-selected relays
- **No tracking** - We have no analytics, telemetry, or user tracking
- **Open source** - You can verify these claims by reading our code
@@ -17,11 +17,11 @@ bitchat is designed with privacy as its foundation. We believe private communica
### On Your Device Only
1. **Identity Key**
- A cryptographic key generated on first launch
1. **Identity Keys**
- Cryptographic private keys generated on first launch or when optional Nostr identities are created
- Stored locally in your device's secure storage
- Allows you to maintain "favorite" relationships across app restarts
- Never leaves your device
- Private keys never leave your device; public keys are shared when needed for messaging
2. **Nickname**
- The display name you choose (or auto-generated)
@@ -38,12 +38,19 @@ bitchat is designed with privacy as its foundation. We believe private communica
- Stored only on your device
- Allows you to recognize these peers in future sessions
5. **Optional Location Channel State**
- Your selected geohash channel, bookmarked geohashes, teleport flags, and bookmark display names
- Stored locally on your device so the location-channel UI can restore your choices
- Per-geohash Nostr identities are derived locally from a device seed stored in secure storage
- Exact latitude and longitude are not persisted by bitchat
### Temporary Session Data
During each session, bitchat temporarily maintains:
- Active peer connections (forgotten when app closes)
- Routing information for message delivery
- Cached messages for offline peers (12 hours max)
- Your current location while optional location channels are enabled, used locally to compute geohash channels and friendly place names
## What Information is Shared
@@ -62,13 +69,21 @@ When you join a password-protected room:
- Your nickname appears in the member list
- Room owners can see you've joined
### With Nostr Relays (Optional Features)
If you enable Nostr-backed features:
- Private fallback messages to mutual favorites are sent as encrypted NIP-17 gift wraps. Relays can see event metadata, but not message content.
- Public location-channel messages, location notes, and presence are scoped with geohash tags. Relays and other participants can see the geohash tag, event kind, timestamp, and public key used for that geohash.
- Exact GPS coordinates are not included in Nostr events by bitchat. The geohash precision you choose can still reveal an approximate area, from region-level to building-level.
- Automatic presence heartbeats are limited to low-precision geohashes (region, province, and city). More precise geohash posts happen only when you use those channels or location notes.
## What We DON'T Do
bitchat **never**:
- Collects personal information
- Tracks your location
- Stores data on servers
- Shares data with third parties
- Sells or shares your exact GPS location
- Stores data on servers we operate
- Sells your data to advertisers or data brokers
- Uses analytics or telemetry
- Creates user profiles
- Requires registration
@@ -84,19 +99,27 @@ All private messages use end-to-end encryption:
## Your Rights
You have complete control:
- **Delete Everything**: Triple-tap the logo to instantly wipe all data
- **Leave Anytime**: Close the app and your presence disappears
- **No Account**: Nothing to delete from servers because there are none
- **Portability**: Your data never leaves your device unless you export it
- **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences
- **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out
- **No Account**: No account record exists for you to delete from us
- **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it
## Bluetooth & Permissions
bitchat requires Bluetooth permission to function:
- Used only for peer-to-peer communication
- No location data is accessed or stored
- Bluetooth is not used for tracking
- You can revoke this permission at any time in system settings
## Location Permission
Location permission is optional and is used only for location channels:
- Used to compute local geohash channels and display names
- Requested as when-in-use permission
- Exact coordinates are not shared in messages or stored by bitchat
- Selected and bookmarked geohashes may persist locally until you remove them, use panic wipe, or delete the app
- You can revoke this permission at any time in system settings
## Children's Privacy
bitchat does not knowingly collect information from children. The app has no age verification because it collects no personal information from anyone.
@@ -106,12 +129,15 @@ bitchat does not knowingly collect information from children. The app has no age
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
- **Identity Key**: Persists until you delete the app
- **Favorites**: Persist until you remove them or delete the app
- **Location channel choices**: Selected/bookmarked geohashes persist locally until removed, panic-wiped, or the app is deleted
- **Nostr relay data**: Public geohash events and encrypted gift wraps may be retained by relays according to each relay's policy
- **Everything Else**: Exists only during active sessions
## Security Measures
- All communication is encrypted
- No data transmitted to servers (there are none)
- No accounts or company servers
- Optional Nostr relays receive only the events needed for Nostr-backed private fallback or public location channels
- Open source code for public audit
- Regular security updates
- Cryptographic signatures prevent tampering
@@ -121,7 +147,7 @@ bitchat does not knowingly collect information from children. The app has no age
If we update this policy:
- The "Last updated" date will change
- The updated policy will be included in the app
- No retroactive changes can affect data (since we don't collect any)
- No retroactive changes can make us collect data already held only in your app
## Contact
@@ -132,7 +158,7 @@ bitchat is an open source project. For privacy questions:
## Philosophy
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely.
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no company servers, no analytics. Just people talking freely.
---
+8 -2
View File
@@ -53,11 +53,17 @@ let package = Package(
path: "bitchatTests",
exclude: [
"Info.plist",
"README.md"
"README.md",
// CI perf gate data (read by scripts/check-perf-floors.sh),
// not a test resource.
"Performance/perf-floors.json"
],
resources: [
.process("Localization"),
.process("Noise")
// Only the vector fixture: declaring the whole "Noise"
// directory would claim its .swift test files as resources
// and silently drop them from compilation.
.process("Noise/NoiseTestVectors.json")
]
)
]
+11 -15
View File
@@ -321,7 +321,7 @@
isa = PBXProject;
attributes = {
BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 1640;
LastUpgradeCheck = 2650;
};
buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */;
developmentRegion = en;
@@ -446,7 +446,6 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
LD_RUNPATH_SEARCH_PATHS = (
@@ -471,7 +470,6 @@
CODE_SIGNING_ALLOWED = YES;
CODE_SIGNING_REQUIRED = YES;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
LD_RUNPATH_SEARCH_PATHS = (
@@ -498,7 +496,6 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -523,7 +520,6 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
@@ -556,7 +552,6 @@
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -566,7 +561,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -590,7 +585,6 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatTests/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
@@ -617,7 +611,6 @@
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -627,7 +620,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -653,7 +646,6 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -663,7 +655,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -676,6 +668,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -709,6 +702,7 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_NS_ASSERTIONS = NO;
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -726,6 +720,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = "$(SWIFT_VERSION)";
@@ -745,7 +740,6 @@
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
COMBINE_HIDPI_IMAGES = YES;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_PREVIEWS = NO;
INFOPLIST_FILE = bitchat/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
@@ -755,7 +749,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
MARKETING_VERSION = 1.5.1;
MARKETING_VERSION = 1.5.3;
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
PRODUCT_NAME = bitchat;
REGISTER_APP_GROUPS = YES;
@@ -768,6 +762,7 @@
isa = XCBuildConfiguration;
buildSettings = {
ALWAYS_SEARCH_USER_PATHS = NO;
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
CLANG_ANALYZER_NONNULL = YES;
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
@@ -801,6 +796,7 @@
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
ENABLE_STRICT_OBJC_MSGSEND = YES;
ENABLE_TESTABILITY = YES;
ENABLE_USER_SCRIPT_SANDBOXING = YES;
@@ -825,6 +821,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)";
STRING_CATALOG_GENERATE_SYMBOLS = NO;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = "$(SWIFT_VERSION)";
@@ -841,7 +838,6 @@
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
LastUpgradeVersion = "2650"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1640"
LastUpgradeVersion = "2650"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
+3 -264
View File
@@ -66,12 +66,12 @@ actor AppEventStream {
}
}
/// Identity key for a direct conversation. Equality and hashing use the
/// canonical `id` only; `routingPeerID` carries the transport-level peer ID
/// the conversation is keyed under (see `ConversationID.directPeer`).
struct PeerHandle: Sendable, Identifiable {
let id: String
let routingPeerID: PeerID
let displayName: String?
let noisePublicKeyHex: String?
let nostrPublicKey: String?
}
extension PeerHandle: Equatable {
@@ -100,264 +100,3 @@ enum ConversationID: Hashable, Sendable {
}
}
}
@MainActor
final class IdentityResolver {
private var handlesByRoutingPeerID: [PeerID: PeerHandle] = [:]
private var handlesByNoiseKey: [String: PeerHandle] = [:]
private var handlesByNostrKey: [String: PeerHandle] = [:]
func register(peers: [BitchatPeer]) {
for peer in peers {
_ = register(peer: peer)
}
}
@discardableResult
func register(peer: BitchatPeer) -> PeerHandle {
let handle = buildHandle(
routingPeerID: peer.peerID,
displayName: peer.displayName,
noisePublicKeyHex: peer.noisePublicKey.isEmpty ? nil : peer.noisePublicKey.hexEncodedString().lowercased(),
nostrPublicKey: normalizedNostrKey(peer.nostrPublicKey)
)
cache(handle)
return handle
}
func canonicalHandle(for peerID: PeerID, displayName: String? = nil) -> PeerHandle {
if let handle = handlesByRoutingPeerID[peerID] {
return handle
}
if peerID.isNoiseKeyHex, let handle = handlesByNoiseKey[peerID.bare] {
return handle
}
if (peerID.isGeoDM || peerID.isGeoChat), let handle = handlesByNostrKey[peerID.bare] {
return handle
}
let handle = buildHandle(
routingPeerID: peerID,
displayName: displayName,
noisePublicKeyHex: peerID.isNoiseKeyHex ? peerID.bare : nil,
nostrPublicKey: (peerID.isGeoDM || peerID.isGeoChat) ? peerID.bare : nil
)
cache(handle)
return handle
}
private func buildHandle(
routingPeerID: PeerID,
displayName: String?,
noisePublicKeyHex: String?,
nostrPublicKey: String?
) -> PeerHandle {
let canonicalID: String
if let noisePublicKeyHex {
canonicalID = "noise:\(noisePublicKeyHex)"
} else if let nostrPublicKey {
canonicalID = "nostr:\(nostrPublicKey)"
} else {
canonicalID = "mesh:\(routingPeerID.id)"
}
let normalizedDisplayName: String?
if let displayName, !displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
normalizedDisplayName = displayName
} else {
normalizedDisplayName = nil
}
return PeerHandle(
id: canonicalID,
routingPeerID: routingPeerID,
displayName: normalizedDisplayName,
noisePublicKeyHex: noisePublicKeyHex,
nostrPublicKey: nostrPublicKey
)
}
private func normalizedNostrKey(_ nostrPublicKey: String?) -> String? {
guard let nostrPublicKey else { return nil }
let trimmed = nostrPublicKey.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed.isEmpty ? nil : trimmed
}
private func cache(_ handle: PeerHandle) {
handlesByRoutingPeerID[handle.routingPeerID] = handle
if let noisePublicKeyHex = handle.noisePublicKeyHex {
handlesByNoiseKey[noisePublicKeyHex] = handle
}
if let nostrPublicKey = handle.nostrPublicKey {
handlesByNostrKey[nostrPublicKey] = handle
}
}
}
@MainActor
final class ConversationStore: ObservableObject {
@Published private(set) var activeChannel: ChannelID = .mesh
@Published private(set) var selectedPrivatePeerID: PeerID?
@Published private(set) var selectedConversationID: ConversationID = .mesh
@Published private(set) var unreadConversations: Set<ConversationID> = []
@Published private(set) var messagesByConversation: [ConversationID: [BitchatMessage]] = [:]
private var directHandlesByConversation: [ConversationID: PeerHandle] = [:]
func setActiveChannel(_ channelID: ChannelID) {
activeChannel = channelID
if selectedPrivatePeerID == nil {
selectedConversationID = ConversationID(channelID: channelID)
}
}
func setSelectedPeerID(
_ peerID: PeerID?,
activeChannel: ChannelID,
identityResolver: IdentityResolver
) {
self.activeChannel = activeChannel
selectedPrivatePeerID = peerID
if let peerID {
selectedConversationID = directConversationID(
for: peerID,
identityResolver: identityResolver
)
} else {
selectedConversationID = ConversationID(channelID: activeChannel)
}
}
func replaceMessages(_ messages: [BitchatMessage], for conversationID: ConversationID) {
messagesByConversation[conversationID] = normalized(messages)
}
func replaceMessages(_ messages: [BitchatMessage], for channelID: ChannelID) {
replaceMessages(messages, for: ConversationID(channelID: channelID))
}
func synchronizePublicConversation(_ messages: [BitchatMessage], activeChannel: ChannelID) {
setActiveChannel(activeChannel)
replaceMessages(messages, for: activeChannel)
}
func messages(for conversationID: ConversationID) -> [BitchatMessage] {
messagesByConversation[conversationID] ?? []
}
func directMessages(
for peerID: PeerID,
identityResolver: IdentityResolver
) -> [BitchatMessage] {
messages(for: directConversationID(for: peerID, identityResolver: identityResolver))
}
func directMessagesByPeerID() -> [PeerID: [BitchatMessage]] {
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
for (conversationID, handle) in directHandlesByConversation {
messagesByPeerID[handle.routingPeerID] = messages(for: conversationID)
}
return messagesByPeerID
}
func unreadDirectPeerIDs() -> Set<PeerID> {
unreadConversations.reduce(into: Set<PeerID>()) { result, conversationID in
guard case .direct(let handle) = conversationID else { return }
result.insert(directHandlesByConversation[conversationID]?.routingPeerID ?? handle.routingPeerID)
}
}
func synchronizeSelection(
activeChannel: ChannelID,
selectedPeerID: PeerID?,
identityResolver: IdentityResolver
) {
setSelectedPeerID(
selectedPeerID,
activeChannel: activeChannel,
identityResolver: identityResolver
)
}
func synchronizePrivateChats(
_ privateChats: [PeerID: [BitchatMessage]],
unreadPeerIDs: Set<PeerID>,
identityResolver: IdentityResolver
) {
var liveConversations = Set<ConversationID>()
for (peerID, messages) in privateChats {
let handle = identityResolver.canonicalHandle(for: peerID, displayName: messages.last?.sender)
let conversationID = ConversationID.direct(handle)
liveConversations.insert(conversationID)
directHandlesByConversation[conversationID] = handle
messagesByConversation[conversationID] = normalized(messages)
}
let staleDirectConversations = messagesByConversation.keys.filter { conversationID in
guard case .direct = conversationID else { return false }
return !liveConversations.contains(conversationID)
}
for conversationID in staleDirectConversations {
messagesByConversation.removeValue(forKey: conversationID)
unreadConversations.remove(conversationID)
directHandlesByConversation.removeValue(forKey: conversationID)
}
let publicUnread = unreadConversations.filter { conversationID in
switch conversationID {
case .mesh, .geohash:
return true
case .direct:
return false
}
}
unreadConversations = unreadPeerIDs.reduce(into: publicUnread) { result, peerID in
let handle = identityResolver.canonicalHandle(for: peerID)
result.insert(.direct(handle))
}
}
func markRead(_ conversationID: ConversationID) {
unreadConversations.remove(conversationID)
}
func markRead(
peerID: PeerID,
identityResolver: IdentityResolver
) {
markRead(directConversationID(for: peerID, identityResolver: identityResolver))
}
private func normalized(_ messages: [BitchatMessage]) -> [BitchatMessage] {
var uniqueMessages: [String: BitchatMessage] = [:]
for message in messages {
uniqueMessages[message.id] = message
}
return uniqueMessages.values.sorted { lhs, rhs in
if lhs.timestamp != rhs.timestamp {
return lhs.timestamp < rhs.timestamp
}
return lhs.id < rhs.id
}
}
private func directConversationID(
for peerID: PeerID,
identityResolver: IdentityResolver
) -> ConversationID {
let handle = identityResolver.canonicalHandle(for: peerID)
let conversationID = ConversationID.direct(handle)
directHandlesByConversation[conversationID] = handle
return conversationID
}
}
+14 -13
View File
@@ -14,7 +14,10 @@ import AppKit
final class AppRuntime: ObservableObject {
let chatViewModel: ChatViewModel
let events = AppEventStream()
let conversationStore: ConversationStore
/// Single source of truth for conversation message state and selection
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned here; the feature models
/// and `ChatViewModel` observe and mutate it through its intent API.
let conversations: ConversationStore
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
let publicChatModel: PublicChatModel
@@ -42,30 +45,28 @@ final class AppRuntime: ObservableObject {
idBridge: NostrIdentityBridge = NostrIdentityBridge()
) {
self.idBridge = idBridge
let identityResolver = IdentityResolver()
let conversationStore = ConversationStore()
let conversations = ConversationStore()
let peerIdentityStore = PeerIdentityStore()
let locationPresenceStore = LocationPresenceStore()
let locationManager = LocationChannelManager.shared
self.conversationStore = conversationStore
self.conversations = conversations
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
self.chatViewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: SecureIdentityStateManager(keychain),
conversationStore: conversationStore,
identityResolver: identityResolver,
conversations: conversations,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore,
locationManager: locationManager
)
self.publicChatModel = PublicChatModel(conversationStore: conversationStore)
self.privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
self.publicChatModel = PublicChatModel(conversations: conversations)
self.privateInboxModel = PrivateInboxModel(conversations: conversations)
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
self.privateConversationModel = PrivateConversationModel(
chatViewModel: self.chatViewModel,
conversationStore: conversationStore,
conversations: conversations,
locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore
)
@@ -77,11 +78,11 @@ final class AppRuntime: ObservableObject {
self.conversationUIModel = ConversationUIModel(
chatViewModel: self.chatViewModel,
privateConversationModel: self.privateConversationModel,
conversationStore: conversationStore
conversations: conversations
)
self.peerListModel = PeerListModel(
chatViewModel: self.chatViewModel,
conversationStore: conversationStore,
conversations: conversations,
locationChannelsModel: self.locationChannelsModel,
peerIdentityStore: peerIdentityStore,
locationPresenceStore: locationPresenceStore
@@ -104,7 +105,7 @@ final class AppRuntime: ObservableObject {
started = true
NotificationDelegate.shared.runtime = self
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
VerificationService.shared.configure(with: chatViewModel.meshService)
announceInitialTorStatusIfNeeded()
Task(priority: .utility) { [weak self] in
@@ -218,7 +219,7 @@ final class AppRuntime: ObservableObject {
userInfo: [AnyHashable: Any]
) async -> UNNotificationPresentationOptions {
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
if conversationStore.selectedPrivatePeerID == peerID {
if conversations.selectedPrivatePeerID == peerID {
return []
}
return [.banner, .sound]
+918
View File
@@ -0,0 +1,918 @@
//
// ConversationStore.swift
// bitchat
//
// Single source of truth for conversation message state (see
// docs/CONVERSATION-STORE-DESIGN.md). One `Conversation` object per
// `ConversationID`; all mutations flow through the store's intent API and
// every mutation emits a `ConversationChange` after state is consistent.
//
// The store also owns conversation selection: the active public channel and
// the selected private peer (the two UI selection axes) plus the derived
// `selectedConversationID`.
//
// 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
// MARK: - Conversation
/// A single conversation timeline (`.mesh`, `.geohash`, or `.direct`).
///
/// Publishing granularity is per conversation: views observe ONE
/// `Conversation` object, so an append to chat A never invalidates observers
/// of chat B.
///
/// Mutations are `fileprivate` by design only `ConversationStore`'s intent
/// API may mutate a conversation, keeping the store the sole writer.
@MainActor
final class Conversation: ObservableObject, Identifiable {
let id: ConversationID
/// Maximum retained messages; oldest are trimmed on overflow.
let cap: Int
@Published private(set) var messages: [BitchatMessage] = []
@Published private(set) var isUnread: Bool = false
/// Incrementally-maintained message-ID index map for O(1) dedup and
/// delivery-status lookup. Kept in sync on every mutation:
/// - tail append: single insert
/// - out-of-order insert: suffix reindex from the insertion point
/// - trim: full rebuild `removeFirst(k)` is already O(n), so the
/// rebuild does not change the asymptotics, and trim only happens once
/// the cap (1337) is reached. Simple and correct beats the
/// offset-tracking alternative here.
private var indexByMessageID: [String: Int] = [:]
fileprivate init(id: ConversationID, cap: Int) {
self.id = id
self.cap = max(1, cap)
}
// MARK: Reads
func containsMessage(withID messageID: String) -> Bool {
indexByMessageID[messageID] != nil
}
func message(withID messageID: String) -> BitchatMessage? {
guard let index = indexByMessageID[messageID] else { return nil }
return messages[index]
}
/// All message IDs currently in this conversation (unordered).
var messageIDs: Dictionary<String, Int>.Keys {
indexByMessageID.keys
}
// MARK: Store-internal mutations
/// Result of an ordered insert. `trimmedMessageIDs` reports messages
/// evicted by the cap so the store can keep its message-ID
/// conversation map exact.
fileprivate struct InsertResult {
let inserted: Bool
let trimmedMessageIDs: [String]
static let duplicate = InsertResult(inserted: false, trimmedMessageIDs: [])
}
fileprivate enum UpsertOutcome {
case appended(trimmedMessageIDs: [String])
case updated
}
/// Inserts a message in timestamp order, deduplicating by message ID.
/// Fast path appends when the timestamp is >= the current tail;
/// otherwise a binary search finds the upper-bound insertion point so
/// arrival order is preserved among equal timestamps.
/// Reports `inserted: false` if a message with the same ID already exists.
fileprivate func insert(_ message: BitchatMessage) -> InsertResult {
guard indexByMessageID[message.id] == nil else { return .duplicate }
if let last = messages.last, message.timestamp < last.timestamp {
let index = insertionIndex(for: message.timestamp)
messages.insert(message, at: index)
reindex(from: index)
} else {
messages.append(message)
indexByMessageID[message.id] = messages.count - 1
}
return InsertResult(inserted: true, trimmedMessageIDs: trimIfNeeded())
}
/// Replace-or-append by message ID. An existing message keeps its
/// timeline position (in-place updates like media progress reuse the
/// original timestamp); a new message goes through ordered insertion.
fileprivate func upsert(_ message: BitchatMessage) -> UpsertOutcome {
if let index = indexByMessageID[message.id] {
messages[index] = message
return .updated
}
let result = insert(message)
return .appended(trimmedMessageIDs: result.trimmedMessageIDs)
}
/// Applies a delivery status keyed by message ID, honoring the
/// no-downgrade rule (the SOLE enforcement point every delivery
/// update flows through the store): equal statuses are skipped, and
/// `.read` is never downgraded to `.delivered` or `.sent`.
/// Returns `true` when the status was applied.
fileprivate func applyDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let index = indexByMessageID[messageID] else { return false }
let message = messages[index]
guard !Self.shouldSkipStatusUpdate(current: message.deliveryStatus, new: status) else { return false }
message.deliveryStatus = status
// BitchatMessage is a reference type; write back through the
// subscript so the @Published wrapper emits.
messages[index] = message
return true
}
/// Republishes a message without changing state. Used for mirrored
/// copies that share a BitchatMessage instance: the first conversation's
/// status apply mutated the shared object, so this conversation's
/// observers still need an @Published emission to re-render.
@discardableResult
fileprivate func republishMessage(withID messageID: String) -> Bool {
guard let index = indexByMessageID[messageID] else { return false }
messages[index] = messages[index]
return true
}
@discardableResult
fileprivate func setUnread(_ unread: Bool) -> Bool {
guard isUnread != unread else { return false }
isUnread = unread
return true
}
/// Removes a single message by ID. Returns the removed message, or
/// `nil` when no message with that ID exists.
fileprivate func remove(messageID: String) -> BitchatMessage? {
guard let index = indexByMessageID[messageID] else { return nil }
let removed = messages.remove(at: index)
indexByMessageID.removeValue(forKey: messageID)
reindex(from: index)
return removed
}
/// Removes every message matching `predicate`. Returns the removed
/// message IDs (empty when nothing matched).
fileprivate func removeAll(where predicate: (BitchatMessage) -> Bool) -> [String] {
var removedIDs: [String] = []
messages.removeAll { message in
guard predicate(message) else { return false }
removedIDs.append(message.id)
return true
}
guard !removedIDs.isEmpty else { return [] }
for id in removedIDs {
indexByMessageID.removeValue(forKey: id)
}
reindex(from: 0)
return removedIDs
}
fileprivate func clearMessages() {
messages.removeAll()
indexByMessageID.removeAll()
}
// MARK: Diagnostics
/// Appends human-readable invariant violations for this conversation
/// (empty when healthy): the ID index must be the exact inverse of the
/// messages array, the cap must hold, and timestamps must be
/// non-decreasing (equal timestamps keep arrival order, so only strict
/// inversions are violations). O(messages); allocates only on violation.
fileprivate func collectInvariantViolations(into violations: inout [String], label: String) {
if indexByMessageID.count != messages.count {
violations.append("\(label): index has \(indexByMessageID.count) entries for \(messages.count) messages")
}
if messages.count > cap {
violations.append("\(label): \(messages.count) messages exceeds cap \(cap)")
}
var previousTimestamp: Date?
for position in messages.indices {
let message = messages[position]
// Count equality + every message resolving to its own position
// proves the index is exactly the inverse map (no stale extras).
if let index = indexByMessageID[message.id] {
if index != position {
violations.append("\(label): message \(message.id.prefix(8))… at \(position) indexed at \(index)")
}
} else {
violations.append("\(label): message \(message.id.prefix(8))… at \(position) missing from index")
}
if let previousTimestamp, message.timestamp < previousTimestamp {
violations.append("\(label): timestamp order violated at \(position)")
}
previousTimestamp = message.timestamp
}
}
// MARK: Internals
static func shouldSkipStatusUpdate(current: DeliveryStatus?, new: DeliveryStatus) -> Bool {
guard let current else { return false }
if current == new { return true }
switch (current, new) {
case (.read, .delivered), (.read, .sent):
return true
default:
return false
}
}
/// Upper-bound binary search: first index whose timestamp is strictly
/// greater than `timestamp`, so equal-timestamp messages keep arrival
/// order.
private func insertionIndex(for timestamp: Date) -> Int {
var low = 0
var high = messages.count
while low < high {
let mid = (low + high) / 2
if messages[mid].timestamp <= timestamp {
low = mid + 1
} else {
high = mid
}
}
return low
}
private func reindex(from start: Int) {
for index in start..<messages.count {
indexByMessageID[messages[index].id] = index
}
}
/// Trims oldest messages over the cap; returns the trimmed message IDs.
private func trimIfNeeded() -> [String] {
guard messages.count > cap else { return [] }
let overflow = messages.count - cap
let trimmedIDs = messages.prefix(overflow).map(\.id)
for id in trimmedIDs {
indexByMessageID.removeValue(forKey: id)
}
messages.removeFirst(overflow)
reindex(from: 0)
return trimmedIDs
}
}
// MARK: - ConversationChange
/// Typed mutation events for non-UI consumers (delivery tracking,
/// notifications, sync) that need "something changed in conversation X"
/// without subscribing to whole message arrays. Emitted on the store's
/// `changes` subject AFTER the corresponding state is consistent.
enum ConversationChange {
case appended(ConversationID, BitchatMessage)
case updated(ConversationID, messageID: String)
case statusChanged(ConversationID, messageID: String, DeliveryStatus)
case messageRemoved(ConversationID, messageID: String)
case cleared(ConversationID)
case removed(ConversationID)
case migrated(from: ConversationID, to: ConversationID)
case unreadChanged(ConversationID, isUnread: Bool)
}
// MARK: - ConversationStore
/// Sole writer and sole holder of conversation message state. All mutations
/// go through the intent API below; backing collections are `private(set)`.
/// Reads are synchronous writers and readers share the main actor, so
/// after an intent returns every observer sees the result.
@MainActor
final class ConversationStore: ObservableObject {
/// Conversation creation order; published so list-style consumers can
/// observe conversations appearing/disappearing without rebuilding from
/// the dictionary.
@Published private(set) var conversationIDs: [ConversationID] = []
@Published private(set) var selectedConversationID: ConversationID?
@Published private(set) var unreadConversations: Set<ConversationID> = []
// MARK: Selection state
// The two UI selection axes: which public channel is active, and which
// private chat (if any) is open on top of it. `selectedConversationID`
// is derived: the open private chat wins, otherwise the active public
// channel's conversation. Mutate via `setActiveChannel` /
// `setSelectedPrivatePeer` only.
@Published private(set) var activeChannel: ChannelID = .mesh
@Published private(set) var selectedPrivatePeerID: PeerID?
private(set) var conversationsByID: [ConversationID: Conversation] = [:]
/// Store-level message-ID conversation-membership map for ID-only
/// lookups (delivery receipts arrive with a message ID, not a
/// conversation). Maintained incrementally at every mutation point
/// all mutation is centralized in the intent API below, so the map is
/// exact, never scanned or rebuilt.
///
/// The value is a `Set` because a private message can legitimately live
/// in TWO direct conversations: step 2's raw per-peer keying mirrors a
/// message into both the stable-key and ephemeral-peer chats
/// (`mirrorToEphemeralIfNeeded`). A delivery update must reach both
/// copies.
private var conversationIDsByMessageID: [String: Set<ConversationID>] = [:]
/// Monotonic count of messages inserted into any conversation (appends,
/// upsert-appends, migration inserts). Field-observability only: the
/// periodic store audit folds the delta into its heartbeat line so logs
/// carry throughput context. Never read on a hot path.
private(set) var appendCount: Int = 0
/// Sample counter for the mirrored-republish debug log in the ID-only
/// `setDeliveryStatus` fan-out (first + every Nth occurrence).
private var mirroredRepublishLogCount = 0
let changes = PassthroughSubject<ConversationChange, Never>()
// MARK: Intent API
/// Returns the conversation for `id`, creating it (with the cap policy
/// for its kind) on first access.
@discardableResult
func conversation(for id: ConversationID) -> Conversation {
if let existing = conversationsByID[id] {
return existing
}
let conversation = Conversation(id: id, cap: Self.cap(for: id))
conversationsByID[id] = conversation
conversationIDs.append(id)
return conversation
}
/// Appends a message in timestamp order. Returns `false` (and emits
/// nothing) if a message with the same ID is already present.
@discardableResult
func append(_ message: BitchatMessage, to id: ConversationID) -> Bool {
let conversation = conversation(for: id)
let result = conversation.insert(message)
guard result.inserted else { return false }
registerMessageID(message.id, in: id)
unregisterMessageIDs(result.trimmedMessageIDs, from: id)
changes.send(.appended(id, message))
return true
}
/// Replace-or-append by message ID (media progress, edits).
func upsertByID(_ message: BitchatMessage, in id: ConversationID) {
let conversation = conversation(for: id)
switch conversation.upsert(message) {
case .appended(let trimmedMessageIDs):
registerMessageID(message.id, in: id)
unregisterMessageIDs(trimmedMessageIDs, from: id)
changes.send(.appended(id, message))
case .updated:
changes.send(.updated(id, messageID: message.id))
}
}
/// Applies a delivery status keyed by message ID. Returns `false` when
/// the message is unknown or the update would downgrade the status
/// (read beats delivered beats sent).
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, in id: ConversationID) -> Bool {
guard let conversation = conversationsByID[id],
conversation.applyDeliveryStatus(status, forMessageID: messageID) else {
return false
}
changes.send(.statusChanged(id, messageID: messageID, status))
return true
}
/// Applies a delivery status to EVERY conversation containing
/// `messageID` (ID-only delivery receipts don't know conversations;
/// mirrored private copies live in two direct chats). Returns `false`
/// when the message is unknown or no copy changed (equal status or
/// downgrade read beats delivered beats sent).
///
/// `BitchatMessage` is a reference type, so mirrored copies sharing one
/// instance are mutated by the first conversation's apply. The skipped
/// conversations still hold the changed message, so they get an explicit
/// republish and `.statusChanged` event - otherwise a view observing the
/// mirrored conversation would render stale status. Distinct copies whose
/// update was genuinely rejected (downgrade) are left untouched, guarded
/// by status equality.
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
guard let ids = conversationIDsByMessageID[messageID] else { return false }
var applied = false
var skipped: [ConversationID] = []
for id in ids {
if setDeliveryStatus(status, forMessageID: messageID, in: id) {
applied = true
} else {
skipped.append(id)
}
}
guard applied else { return false }
for id in skipped {
guard let conversation = conversationsByID[id],
conversation.message(withID: messageID)?.deliveryStatus == status,
conversation.republishMessage(withID: messageID) else { continue }
// Field proof the mirrored-copy republish path actually fires;
// sampled (first + every Nth) so mirrored chats can't spam logs.
mirroredRepublishLogCount += 1
if mirroredRepublishLogCount == 1
|| mirroredRepublishLogCount.isMultiple(of: TransportConfig.conversationStoreMirroredRepublishLogInterval) {
SecureLogger.debug(
"mirrored republish #\(mirroredRepublishLogCount) for \(messageID.prefix(8))… in \(id.auditDescription)",
category: .session
)
}
changes.send(.statusChanged(id, messageID: messageID, status))
}
return true
}
/// Current delivery status of `messageID` in whichever conversation
/// holds it (mirrored copies share status see `setDeliveryStatus`).
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
guard let ids = conversationIDsByMessageID[messageID] else { return nil }
for id in ids {
if let status = conversationsByID[id]?.message(withID: messageID)?.deliveryStatus {
return status
}
}
return nil
}
/// Every conversation currently containing `messageID` (empty when the
/// message is unknown).
func conversationIDs(forMessageID messageID: String) -> Set<ConversationID> {
conversationIDsByMessageID[messageID] ?? []
}
func markRead(_ id: ConversationID) {
guard unreadConversations.contains(id) else { return }
unreadConversations.remove(id)
conversationsByID[id]?.setUnread(false)
changes.send(.unreadChanged(id, isUnread: false))
}
func markUnread(_ id: ConversationID) {
guard !unreadConversations.contains(id) else { return }
let conversation = conversation(for: id)
unreadConversations.insert(id)
conversation.setUnread(true)
changes.send(.unreadChanged(id, isUnread: true))
}
/// Selects a conversation (creating it if needed) or clears the
/// selection with `nil`.
func select(_ id: ConversationID?) {
if let id {
conversation(for: id)
}
guard selectedConversationID != id else { return }
selectedConversationID = id
}
/// Switches the active public channel. While no private chat is open
/// the selection follows the channel.
func setActiveChannel(_ channelID: ChannelID) {
if activeChannel != channelID {
activeChannel = channelID
}
refreshDerivedSelection()
}
/// Opens a private chat (`nil` closes it, returning the selection to the
/// active public channel's conversation).
func setSelectedPrivatePeer(_ peerID: PeerID?) {
if selectedPrivatePeerID != peerID {
selectedPrivatePeerID = peerID
}
refreshDerivedSelection()
}
private func refreshDerivedSelection() {
if let peerID = selectedPrivatePeerID {
select(.directPeer(peerID))
} else {
select(ConversationID(channelID: activeChannel))
}
}
/// Moves all messages from `source` into `destination` (the
/// ephemeralstable peer-ID handoff): dedups by message ID, preserves
/// timestamp order, carries unread state over, and hands off the
/// selection mirroring `ChatPrivateConversationCoordinator`'s
/// migration semantics. The source conversation is removed. Emits a
/// single `.migrated(from:to:)` once the whole move is consistent.
func migrateConversation(from source: ConversationID, to destination: ConversationID) {
guard source != destination, let sourceConversation = conversationsByID[source] else { return }
let destinationConversation = conversation(for: destination)
for message in sourceConversation.messages {
let result = destinationConversation.insert(message)
guard result.inserted else { continue }
registerMessageID(message.id, in: destination)
unregisterMessageIDs(result.trimmedMessageIDs, from: destination)
}
for messageID in sourceConversation.messageIDs {
unregisterMessageID(messageID, from: source)
}
let wasUnread = unreadConversations.contains(source)
let wasSelected = selectedConversationID == source
conversationsByID.removeValue(forKey: source)
conversationIDs.removeAll { $0 == source }
unreadConversations.remove(source)
if wasUnread, !unreadConversations.contains(destination) {
unreadConversations.insert(destination)
destinationConversation.setUnread(true)
}
if wasSelected {
selectedConversationID = destination
// Keep the private-peer selection axis consistent with the
// handed-off selection.
if let peerID = selectedPrivatePeerID,
source == .directPeer(peerID),
case .direct(let destinationHandle) = destination {
selectedPrivatePeerID = destinationHandle.routingPeerID
}
}
changes.send(.migrated(from: source, to: destination))
}
/// Removes a single message by ID from a conversation. Returns the
/// removed message, or `nil` (emitting nothing) when the conversation or
/// message is unknown.
@discardableResult
func removeMessage(withID messageID: String, from id: ConversationID) -> BitchatMessage? {
guard let conversation = conversationsByID[id],
let removed = conversation.remove(messageID: messageID) else {
return nil
}
unregisterMessageID(messageID, from: id)
changes.send(.messageRemoved(id, messageID: messageID))
return removed
}
/// Removes every message matching `predicate` from a conversation,
/// emitting one `.messageRemoved` per removed message after the
/// conversation is consistent. No-op for unknown conversations.
func removeMessages(from id: ConversationID, where predicate: (BitchatMessage) -> Bool) {
guard let conversation = conversationsByID[id] else { return }
let removedIDs = conversation.removeAll(where: predicate)
unregisterMessageIDs(removedIDs, from: id)
for messageID in removedIDs {
changes.send(.messageRemoved(id, messageID: messageID))
}
}
/// Empties a conversation's timeline but keeps the conversation (and
/// its unread/selection state) alive.
func clear(_ id: ConversationID) {
guard let conversation = conversationsByID[id] else { return }
for messageID in conversation.messageIDs {
unregisterMessageID(messageID, from: id)
}
conversation.clearMessages()
changes.send(.cleared(id))
}
/// Removes a conversation entirely, including unread state; clears the
/// selection if it pointed at the removed conversation.
func removeConversation(_ id: ConversationID) {
guard let conversation = conversationsByID.removeValue(forKey: id) else { return }
for messageID in conversation.messageIDs {
unregisterMessageID(messageID, from: id)
}
conversationIDs.removeAll { $0 == id }
unreadConversations.remove(id)
if selectedConversationID == id {
selectedConversationID = nil
}
changes.send(.removed(id))
}
func clearAll() {
let removedIDs = conversationIDs
guard !removedIDs.isEmpty || selectedConversationID != nil else { return }
conversationsByID.removeAll()
conversationIDs.removeAll()
unreadConversations.removeAll()
conversationIDsByMessageID.removeAll()
if selectedConversationID != nil {
selectedConversationID = nil
}
for id in removedIDs {
changes.send(.removed(id))
}
}
// MARK: Diagnostics
/// Total messages across all conversations. O(#conversations) heartbeat
/// logging only, never a hot path.
var totalMessageCount: Int {
conversationsByID.values.reduce(0) { $0 + $1.messages.count }
}
/// Number of distinct message IDs in the store-level membership map.
var messageIDMapCount: Int {
conversationIDsByMessageID.count
}
/// Verifies the store's correctness invariants and returns human-readable
/// violations (empty = healthy). Intended for a periodic field audit:
/// O(total messages) and allocation-free while healthy. Checks:
/// - the `conversationIDs` ordering array matches `conversationsByID`
/// - per conversation: ID index exact, cap held, timestamp order
/// (see `Conversation.collectInvariantViolations`)
/// - the message-ID conversation map matches reality exactly: every
/// mapped membership points at a live conversation actually holding
/// the message, and total memberships equal total messages (with the
/// forward check, equality proves no conversation message is missing
/// from the map)
/// - `unreadConversations` only references existing conversations
/// - `selectedConversationID`, when set, references an existing
/// conversation (`select(_:)` creates on selection and
/// `removeConversation`/`clearAll` clear it, so existence is the
/// invariant for both the channel-derived and direct-peer cases)
func auditInvariants() -> [String] {
var violations: [String] = []
if conversationIDs.count != conversationsByID.count {
violations.append("conversationIDs lists \(conversationIDs.count) conversations but dictionary holds \(conversationsByID.count)")
}
for id in conversationIDs where conversationsByID[id] == nil {
violations.append("conversationIDs lists \(id.auditDescription) but no conversation exists")
}
var totalMessages = 0
for (id, conversation) in conversationsByID {
totalMessages += conversation.messages.count
conversation.collectInvariantViolations(into: &violations, label: id.auditDescription)
}
var totalMappedMemberships = 0
for (messageID, ids) in conversationIDsByMessageID {
totalMappedMemberships += ids.count
if ids.isEmpty {
violations.append("message map: \(messageID.prefix(8))… has an empty membership set")
}
for id in ids {
guard let conversation = conversationsByID[id] else {
violations.append("message map: \(messageID.prefix(8))… claims unknown conversation \(id.auditDescription)")
continue
}
if !conversation.containsMessage(withID: messageID) {
violations.append("message map: \(messageID.prefix(8))… not present in claimed conversation \(id.auditDescription)")
}
}
}
if totalMappedMemberships != totalMessages {
violations.append("message map holds \(totalMappedMemberships) memberships but conversations hold \(totalMessages) messages")
}
for id in unreadConversations where conversationsByID[id] == nil {
violations.append("unreadConversations contains unknown conversation \(id.auditDescription)")
}
if let selected = selectedConversationID, conversationsByID[selected] == nil {
violations.append("selectedConversationID \(selected.auditDescription) has no conversation")
}
return violations
}
// MARK: Internals
private func registerMessageID(_ messageID: String, in id: ConversationID) {
conversationIDsByMessageID[messageID, default: []].insert(id)
// Single choke point for every successful insertion (append, upsert
// append, migration insert) the audit heartbeat's throughput delta.
appendCount += 1
}
private func unregisterMessageID(_ messageID: String, from id: ConversationID) {
guard var ids = conversationIDsByMessageID[messageID] else { return }
ids.remove(id)
if ids.isEmpty {
conversationIDsByMessageID.removeValue(forKey: messageID)
} else {
conversationIDsByMessageID[messageID] = ids
}
}
private func unregisterMessageIDs(_ messageIDs: [String], from id: ConversationID) {
for messageID in messageIDs {
unregisterMessageID(messageID, from: id)
}
}
private static func cap(for id: ConversationID) -> Int {
switch id {
case .mesh:
return TransportConfig.meshTimelineCap
case .geohash:
return TransportConfig.geoTimelineCap
case .direct:
return TransportConfig.privateChatCap
}
}
}
// MARK: - Direct-conversation keying + derived views
extension ConversationID {
/// Direct-conversation ID keyed by the *raw* routing peer ID.
///
/// Direct conversations are deliberately keyed per `PeerID`, not per
/// resolved identity: the private-chat coordinators mirror messages into
/// both the ephemeral and stable peer's conversations
/// (`mirrorToEphemeralIfNeeded`) and consolidate/migrate between them
/// explicitly, so a raw lookup by whichever peer ID is selected always
/// finds the right timeline without an identity-resolution layer.
static func directPeer(_ peerID: PeerID) -> ConversationID {
.direct(PeerHandle(id: "peer:\(peerID.id)", routingPeerID: peerID))
}
}
extension ConversationStore {
/// All direct conversations' messages keyed by routing peer ID the
/// shape `ChatViewModel.privateChats` exposes to the coordinators.
/// Values are the conversations' backing arrays (COW), so building this
/// is O(#conversations), not O(#messages).
func directMessagesByRoutingPeerID() -> [PeerID: [BitchatMessage]] {
var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
messagesByPeerID.reserveCapacity(conversationsByID.count)
for (id, conversation) in conversationsByID {
guard case .direct(let handle) = id else { continue }
messagesByPeerID[handle.routingPeerID] = conversation.messages
}
return messagesByPeerID
}
/// Unread direct conversations as routing peer IDs the shape
/// `ChatViewModel.unreadPrivateMessages` exposes to the coordinators.
func unreadDirectRoutingPeerIDs() -> Set<PeerID> {
var peerIDs = Set<PeerID>()
for id in unreadConversations {
guard case .direct(let handle) = id else { continue }
peerIDs.insert(handle.routingPeerID)
}
return peerIDs
}
/// `true` when any direct conversation contains a message with `messageID`
/// (O(1) via the store-level message-ID conversation map).
func directConversationsContainMessage(withID messageID: String) -> Bool {
conversationIDs(forMessageID: messageID).contains { id in
if case .direct = id { return true }
return false
}
}
/// Message IDs across all direct conversations (read-receipt pruning
/// keeps only receipts whose messages still exist).
func directMessageIDs() -> Set<String> {
var messageIDs = Set<String>()
for (id, conversation) in conversationsByID {
guard case .direct = id else { continue }
messageIDs.formUnion(conversation.messageIDs)
}
return messageIDs
}
/// Removes every direct conversation (panic clear).
func removeAllDirectConversations() {
let directIDs = conversationIDs.filter { id in
if case .direct = id { return true }
return false
}
for id in directIDs {
removeConversation(id)
}
}
}
// MARK: - Diagnostics support
extension ConversationID {
/// Short, log-safe description for audit/diagnostic lines. Direct
/// conversations truncate the handle so full peer keys never hit logs.
fileprivate var auditDescription: String {
switch self {
case .mesh:
return "mesh"
case .geohash(let geohash):
return "geo:\(geohash)"
case .direct(let handle):
return "direct:\(handle.id.prefix(13))"
}
}
}
#if DEBUG
// Test-only corruption hooks for `auditInvariants()` tests. The store is the
// sole writer by design `Conversation`'s mutators are fileprivate and the
// store's backing collections are private so the inconsistent states the
// audit exists to catch CANNOT be manufactured through the intent API. These
// DEBUG-only hooks deliberately bypass that lockdown to inject exactly those
// impossible states. Never call them outside tests.
extension Conversation {
/// Points an existing message's index entry at the wrong position
/// (positions 0 and 1 swap their index entries). Requires >= 2 messages.
func _testCorruptIndexEntries() {
guard messages.count >= 2 else { return }
indexByMessageID[messages[0].id] = 1
indexByMessageID[messages[1].id] = 0
}
/// Drops a message's index entry entirely (count mismatch + missing).
func _testRemoveIndexEntry(forMessageID messageID: String) {
indexByMessageID.removeValue(forKey: messageID)
}
/// Swaps the first and last messages while keeping the index consistent,
/// so ONLY the timestamp-order invariant is violated (requires the two
/// messages to have distinct timestamps).
func _testCorruptOrderingPreservingIndex() {
guard messages.count >= 2 else { return }
messages.swapAt(0, messages.count - 1)
indexByMessageID[messages[0].id] = 0
indexByMessageID[messages[messages.count - 1].id] = messages.count - 1
}
}
extension ConversationStore {
/// Adds a map membership that the conversation does not actually hold.
func _testRegisterPhantomMessageID(_ messageID: String, in id: ConversationID) {
conversationIDsByMessageID[messageID, default: []].insert(id)
}
/// Drops a real map membership (conversation message missing from map).
func _testUnregisterMessageID(_ messageID: String, from id: ConversationID) {
conversationIDsByMessageID[messageID]?.remove(id)
if conversationIDsByMessageID[messageID]?.isEmpty == true {
conversationIDsByMessageID.removeValue(forKey: messageID)
}
}
/// Appends past the conversation cap, bypassing trim (map kept exact so
/// only the cap invariant is violated).
func _testAppendBypassingCap(_ message: BitchatMessage, to id: ConversationID) {
let conversation = conversation(for: id)
conversation._testAppendBypassingTrim(message)
conversationIDsByMessageID[message.id, default: []].insert(id)
}
/// Marks a nonexistent conversation unread without creating it.
func _testInsertUnreadConversationID(_ id: ConversationID) {
unreadConversations.insert(id)
}
/// Sets the selection directly, without `select(_:)`'s create-on-select.
func _testSetSelectedConversationID(_ id: ConversationID?) {
selectedConversationID = id
}
}
extension Conversation {
fileprivate func _testAppendBypassingTrim(_ message: BitchatMessage) {
messages.append(message)
indexByMessageID[message.id] = messages.count - 1
}
}
#endif
// MARK: - Public timeline derived views
extension ConversationStore {
/// Removes a message by ID from whichever public (mesh/geohash)
/// conversation contains it. Returns the removed message, if any.
@discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
for id in conversationIDs(forMessageID: messageID) {
switch id {
case .mesh, .geohash:
return removeMessage(withID: messageID, from: id)
case .direct:
continue
}
}
return nil
}
}
+13 -9
View File
@@ -15,19 +15,19 @@ final class ConversationUIModel: ObservableObject {
private let chatViewModel: ChatViewModel
private let privateConversationModel: PrivateConversationModel
private let conversationStore: ConversationStore
private let conversations: ConversationStore
private var activeChannel: ChannelID
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
privateConversationModel: PrivateConversationModel,
conversationStore: ConversationStore
conversations: ConversationStore
) {
self.chatViewModel = chatViewModel
self.privateConversationModel = privateConversationModel
self.conversationStore = conversationStore
self.activeChannel = conversationStore.activeChannel
self.conversations = conversations
self.activeChannel = conversations.activeChannel
self.currentNickname = chatViewModel.nickname
self.isBatchingPublic = chatViewModel.isBatchingPublic
self.showAutocomplete = chatViewModel.showAutocomplete
@@ -41,6 +41,10 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.currentColorScheme = colorScheme
}
func setCurrentTheme(_ theme: AppTheme) {
chatViewModel.currentTheme = theme
}
func sendMessage(_ message: String) {
chatViewModel.sendMessage(message)
}
@@ -76,12 +80,12 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.completeNickname(nickname, in: &text)
}
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme)
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
}
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme)
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)
}
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
@@ -151,7 +155,7 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic)
conversationStore.$activeChannel
conversations.$activeChannel
.receive(on: DispatchQueue.main)
.sink { [weak self] channel in
self?.activeChannel = channel
+4 -4
View File
@@ -37,7 +37,7 @@ final class PeerListModel: ObservableObject {
@Published private(set) var renderID = ""
private let chatViewModel: ChatViewModel
private let conversationStore: ConversationStore
private let conversations: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private let locationPresenceStore: LocationPresenceStore
@@ -45,13 +45,13 @@ final class PeerListModel: ObservableObject {
init(
chatViewModel: ChatViewModel,
conversationStore: ConversationStore,
conversations: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversationStore = conversationStore
self.conversations = conversations
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
@@ -122,7 +122,7 @@ final class PeerListModel: ObservableObject {
}
.store(in: &cancellables)
conversationStore.$unreadConversations
conversations.$unreadConversations
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refresh()
+67 -42
View File
@@ -2,68 +2,93 @@ import BitFoundation
import Combine
import Foundation
/// Feature model for private (direct) conversations.
///
/// Reads the single-writer `ConversationStore` directly: `messages(for:)`
/// returns the peer's conversation backing array (no mirror dictionary), and
/// the store's typed `changes` subject drives invalidation a change in the
/// SELECTED peer's conversation republishes this model, while appends to
/// other private chats only surface through the unread set. Direct
/// conversations are keyed by raw routing peer ID; the coordinators'
/// ephemeral/stable mirroring guarantees the selected peer's key always
/// holds the full timeline (see `ConversationID.directPeer`).
@MainActor
final class PrivateInboxModel: ObservableObject {
@Published private(set) var selectedPeerID: PeerID?
@Published private(set) var unreadPeerIDs: Set<PeerID> = []
@Published private(set) var messagesByPeerID: [PeerID: [BitchatMessage]] = [:]
private let conversationStore: ConversationStore
private let conversations: ConversationStore
private var cancellables = Set<AnyCancellable>()
init(conversationStore: ConversationStore) {
self.conversationStore = conversationStore
init(conversations: ConversationStore) {
self.conversations = conversations
self.selectedPeerID = conversations.selectedPrivatePeerID
self.unreadPeerIDs = conversations.unreadDirectRoutingPeerIDs()
bind()
refreshMessages()
}
func messages(for peerID: PeerID?) -> [BitchatMessage] {
guard let peerID else { return [] }
return messagesByPeerID[peerID] ?? []
return conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
}
private func bind() {
conversationStore.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)
conversations.$selectedPrivatePeerID
.dropFirst()
.sink { [weak self] peerID in
self?.selectedPeerID = peerID
self?.refreshMessages()
guard let self, self.selectedPeerID != peerID else { return }
self.selectedPeerID = peerID
}
.store(in: &cancellables)
conversationStore.$unreadConversations
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.unreadPeerIDs = self?.conversationStore.unreadDirectPeerIDs() ?? []
self?.refreshMessages()
conversations.changes
.sink { [weak self] change in
self?.apply(change)
}
.store(in: &cancellables)
conversationStore.$messagesByConversation
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshMessages()
}
.store(in: &cancellables)
selectedPeerID = conversationStore.selectedPrivatePeerID
unreadPeerIDs = conversationStore.unreadDirectPeerIDs()
}
private func refreshMessages() {
var nextMessagesByPeerID = conversationStore.directMessagesByPeerID()
var peerIDs = Set(nextMessagesByPeerID.keys)
peerIDs.formUnion(conversationStore.unreadDirectPeerIDs())
if let selectedPeerID = conversationStore.selectedPrivatePeerID {
peerIDs.insert(selectedPeerID)
}
private func apply(_ change: ConversationChange) {
switch change {
case .appended(let id, _),
.updated(let id, _),
.statusChanged(let id, _, _),
.messageRemoved(let id, _),
.cleared(let id):
republishIfSelected(id)
for peerID in peerIDs where nextMessagesByPeerID[peerID] == nil {
nextMessagesByPeerID[peerID] = []
}
case .unreadChanged(let id, _):
guard isDirect(id) else { return }
refreshUnreadPeerIDs()
messagesByPeerID = nextMessagesByPeerID
case .removed(let id):
guard isDirect(id) else { return }
refreshUnreadPeerIDs()
republishIfSelected(id)
case .migrated(let source, let destination):
guard isDirect(source) || isDirect(destination) else { return }
refreshUnreadPeerIDs()
republishIfSelected(source)
republishIfSelected(destination)
}
}
private func republishIfSelected(_ id: ConversationID) {
guard let selectedPeerID, id == .directPeer(selectedPeerID) else { return }
objectWillChange.send()
}
private func refreshUnreadPeerIDs() {
let next = conversations.unreadDirectRoutingPeerIDs()
guard unreadPeerIDs != next else { return }
unreadPeerIDs = next
}
private func isDirect(_ id: ConversationID) -> Bool {
if case .direct = id { return true }
return false
}
}
@@ -93,22 +118,22 @@ final class PrivateConversationModel: ObservableObject {
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
private let chatViewModel: ChatViewModel
private let conversationStore: ConversationStore
private let conversations: ConversationStore
private let locationChannelsModel: LocationChannelsModel
private let peerIdentityStore: PeerIdentityStore
private var cancellables = Set<AnyCancellable>()
init(
chatViewModel: ChatViewModel,
conversationStore: ConversationStore,
conversations: ConversationStore,
locationChannelsModel: LocationChannelsModel? = nil,
peerIdentityStore: PeerIdentityStore? = nil
) {
self.chatViewModel = chatViewModel
self.conversationStore = conversationStore
self.conversations = conversations
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
let initialPeerID = conversationStore.selectedPrivatePeerID
let initialPeerID = conversations.selectedPrivatePeerID
self.selectedPeerID = initialPeerID
self.selectedHeaderState = initialPeerID.flatMap { peerID in
makeHeaderState(for: peerID)
@@ -153,7 +178,7 @@ final class PrivateConversationModel: ObservableObject {
}
private func bind() {
conversationStore.$selectedPrivatePeerID
conversations.$selectedPrivatePeerID
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshSelectedConversation()
@@ -197,7 +222,7 @@ final class PrivateConversationModel: ObservableObject {
}
private func refreshSelectedConversation() {
selectedPeerID = conversationStore.selectedPrivatePeerID
selectedPeerID = conversations.selectedPrivatePeerID
selectedHeaderState = selectedPeerID.flatMap { peerID in
makeHeaderState(for: peerID)
}
+52 -16
View File
@@ -2,40 +2,76 @@ import BitFoundation
import Combine
import SwiftUI
/// Feature model for the active public (mesh/geohash) timeline.
///
/// Observes ONE `Conversation` object in the single-writer
/// `ConversationStore` the active channel's so appends to background
/// conversations (other geohashes, private chats) never invalidate it.
/// `messages` reads the observed conversation's backing array directly;
/// there is no mirror copy.
@MainActor
final class PublicChatModel: ObservableObject {
@Published private(set) var activeChannel: ChannelID
@Published private(set) var messages: [BitchatMessage] = []
private let conversationStore: ConversationStore
/// The active public conversation's timeline.
var messages: [BitchatMessage] { activeConversation.messages }
private let conversations: ConversationStore
private var activeConversation: Conversation
private var activeConversationCancellable: AnyCancellable?
private var cancellables = Set<AnyCancellable>()
init(conversationStore: ConversationStore) {
self.activeChannel = conversationStore.activeChannel
self.conversationStore = conversationStore
init(conversations: ConversationStore) {
let channel = conversations.activeChannel
self.conversations = conversations
self.activeChannel = channel
self.activeConversation = conversations.conversation(for: ConversationID(channelID: channel))
observeActiveConversation()
bind()
refreshMessages()
}
private func bind() {
conversationStore.$activeChannel
.receive(on: DispatchQueue.main)
conversations.$activeChannel
.dropFirst()
.sink { [weak self] channel in
self?.activeChannel = channel
self?.refreshMessages()
guard let self else { return }
self.activeChannel = channel
self.retargetActiveConversation(to: channel)
}
.store(in: &cancellables)
conversationStore.$messagesByConversation
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.refreshMessages()
// The store replaces a conversation's object when it is removed
// (panic clear); retarget to the fresh instance so the observation
// never goes stale.
conversations.changes
.sink { [weak self] change in
guard let self,
case .removed(let id) = change,
id == self.activeConversation.id else { return }
self.retargetActiveConversation(to: self.activeChannel)
}
.store(in: &cancellables)
}
private func refreshMessages() {
messages = conversationStore.messages(for: ConversationID(channelID: activeChannel))
private func retargetActiveConversation(to channel: ChannelID) {
let conversation = conversations.conversation(for: ConversationID(channelID: channel))
guard conversation !== activeConversation else {
// Same object (e.g. re-selected channel): keep the existing
// observation, but `messages` may still differ from what views
// last rendered, so republish.
objectWillChange.send()
return
}
objectWillChange.send()
activeConversation = conversation
observeActiveConversation()
}
private func observeActiveConversation() {
activeConversationCancellable = activeConversation.objectWillChange
.sink { [weak self] _ in
self?.objectWillChange.send()
}
}
}
+2
View File
@@ -15,6 +15,7 @@ struct BitchatApp: App {
static let groupID = "group.\(bundleID)"
@StateObject private var runtime: AppRuntime
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
#if os(iOS)
@Environment(\.scenePhase) var scenePhase
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@@ -30,6 +31,7 @@ struct BitchatApp: App {
var body: some Scene {
WindowGroup {
ContentView()
.environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix)
.environmentObject(runtime.publicChatModel)
.environmentObject(runtime.privateInboxModel)
.environmentObject(runtime.privateConversationModel)
@@ -151,38 +151,68 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
// Thread safety
private let queue = DispatchQueue(label: "bitchat.identity.state", attributes: .concurrent)
// Debouncing for keychain saves
private var saveTimer: Timer?
private let saveDebounceInterval: TimeInterval = 2.0 // Save at most once every 2 seconds
// Pending-save coalescing flag. Reads/writes are serialized on `queue`.
// Persistence is done with a fire-and-forget `queue.async(.barrier)` rather
// than a retained DispatchSourceTimer: a lingering, never-cancelled timer
// keeps the dispatch machinery alive and prevents the unit-test process from
// exiting. (The original code used Timer.scheduledTimer on a GCD queue with
// no run loop, so saves never actually fired.)
private var pendingSave = false
// Encryption key
private let encryptionKey: SymmetricKey
/// True when `encryptionKey` is a throwaway generated this session because the
/// persisted key could not be read (device locked / access denied). In that
/// state we must NOT persist (it would overwrite the real cache with data the
/// next launch can't decrypt) and must NOT delete the existing cache.
private let encryptionKeyIsEphemeral: Bool
init(_ keychain: KeychainManagerProtocol) {
self.keychain = keychain
// Generate or retrieve encryption key from keychain
// Retrieve (or, only on genuine first run, generate) the cache
// encryption key. We MUST distinguish "key doesn't exist yet" from a
// transient failure (device locked / access denied): the legacy
// getIdentityKey(forKey:) collapses both to nil, and generating+saving a
// new key deletes the existing one first permanently orphaning the
// encrypted cache on a launch that merely couldn't read the key.
let loadedKey: SymmetricKey
// Try to load from keychain
if let keyData = keychain.getIdentityKey(forKey: encryptionKeyName) {
let keyIsEphemeral: Bool
switch keychain.getIdentityKeyWithResult(forKey: encryptionKeyName) {
case .success(let keyData):
loadedKey = SymmetricKey(data: keyData)
keyIsEphemeral = false
SecureLogger.logKeyOperation(.load, keyType: "identity cache encryption key", success: true)
}
// Generate new key if needed
else {
loadedKey = SymmetricKey(size: .bits256)
let keyData = loadedKey.withUnsafeBytes { Data($0) }
// Save to keychain
case .itemNotFound:
// Genuine first run: generate and persist a new key.
let newKey = SymmetricKey(size: .bits256)
let keyData = newKey.withUnsafeBytes { Data($0) }
let saved = keychain.saveIdentityKey(keyData, forKey: encryptionKeyName)
loadedKey = newKey
// If even the save failed, treat the key as ephemeral so we don't
// later try to persist a cache the next launch can't read.
keyIsEphemeral = !saved
SecureLogger.logKeyOperation(.generate, keyType: "identity cache encryption key", success: saved)
case .deviceLocked, .authenticationFailed, .accessDenied, .otherError:
// Transient/critical read failure. Do NOT overwrite the persisted
// key. Use a session-only ephemeral key; the real key and cache are
// left intact for a healthy launch.
SecureLogger.warning("Identity cache key unavailable; using ephemeral key for this session (not persisting)", category: .security)
loadedKey = SymmetricKey(size: .bits256)
keyIsEphemeral = true
}
self.encryptionKey = loadedKey
// Load identity cache on init
loadIdentityCache()
self.encryptionKeyIsEphemeral = keyIsEphemeral
// Only read the persisted cache when we hold the real key; with an
// ephemeral key the decrypt would fail and discard the real cache.
if !keyIsEphemeral {
loadIdentityCache()
}
}
deinit {
@@ -211,23 +241,28 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
}
}
/// Persists the cache. Always invoked on `queue` under a barrier (its callers
/// run inside `queue.async(.barrier)`), so it simply marks the cache dirty
/// and persists it on the same serialized context no timer, nothing left
/// scheduled to keep the process alive.
private func saveIdentityCache() {
// Mark that we need to save
pendingSave = true
// Cancel any existing timer
saveTimer?.invalidate()
// Schedule a new save after the debounce interval
saveTimer = Timer.scheduledTimer(withTimeInterval: saveDebounceInterval, repeats: false) { [weak self] _ in
self?.performSave()
}
performSave()
}
/// Writes the cache to the keychain. Must run on `queue` with exclusive
/// (barrier) access.
private func performSave() {
guard pendingSave else { return }
pendingSave = false
// Never persist under an ephemeral key it would overwrite the real
// cache with data the next launch cannot decrypt.
guard !encryptionKeyIsEphemeral else {
SecureLogger.debug("Skipping identity cache save (ephemeral key this session)", category: .security)
return
}
do {
let data = try JSONEncoder().encode(cache)
let sealedBox = try AES.GCM.seal(data, using: encryptionKey)
@@ -239,10 +274,14 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
SecureLogger.error(error, context: "Failed to save identity cache", category: .security)
}
}
// Force immediate save (for app termination)
// Force immediate save (for app termination / lifecycle events). Mutations
// already persist synchronously via saveIdentityCache, so this is normally a
// no-op (performSave early-returns when nothing is pending). Runs directly on
// the caller's thread deliberately NOT a `queue.sync(barrier)`, which is
// reachable from `deinit` and from async tests on the swift-concurrency
// cooperative pool where a blocking barrier-sync can starve/deadlock it.
func forceSave() {
saveTimer?.invalidate()
performSave()
}
+538 -1
View File
@@ -540,6 +540,543 @@
}
}
},
"app_info.appearance.liquid_glass" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "زجاج سائل"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "লিকুইড গ্লাস"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "זכוכית נוזלית"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "लिक्विड ग्लास"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "リキッドガラス"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "리퀴드 글래스"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "लिक्विड ग्लास"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "жидкое стекло"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "லிக்விட் கிளாஸ்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ลิควิดกลาส"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "рідке скло"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "لیکویڈ گلاس"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "liquid glass"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "液态玻璃"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "液態玻璃"
}
}
}
},
"app_info.appearance.matrix" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "ماتريكس"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "ম্যাট্রিক্স"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "מטריקס"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "मैट्रिक्स"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "マトリックス"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "매트릭스"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "म्याट्रिक्स"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "матрица"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "மேட்ரிக்ஸ்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "เมทริกซ์"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "матриця"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "میٹرکس"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "matrix"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "矩阵"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "矩陣"
}
}
}
},
"app_info.appearance.title" : {
"extractionState" : "manual",
"localizations" : {
"ar" : {
"stringUnit" : {
"state" : "translated",
"value" : "المظهر"
}
},
"bn" : {
"stringUnit" : {
"state" : "translated",
"value" : "চেহারা"
}
},
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "ERSCHEINUNGSBILD"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "APPEARANCE"
}
},
"es" : {
"stringUnit" : {
"state" : "translated",
"value" : "APARIENCIA"
}
},
"fil" : {
"stringUnit" : {
"state" : "translated",
"value" : "HITSURA"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "APPARENCE"
}
},
"he" : {
"stringUnit" : {
"state" : "translated",
"value" : "מראה"
}
},
"hi" : {
"stringUnit" : {
"state" : "translated",
"value" : "दिखावट"
}
},
"id" : {
"stringUnit" : {
"state" : "translated",
"value" : "TAMPILAN"
}
},
"it" : {
"stringUnit" : {
"state" : "translated",
"value" : "ASPETTO"
}
},
"ja" : {
"stringUnit" : {
"state" : "translated",
"value" : "外観"
}
},
"ko" : {
"stringUnit" : {
"state" : "translated",
"value" : "화면 모드"
}
},
"ms" : {
"stringUnit" : {
"state" : "translated",
"value" : "PENAMPILAN"
}
},
"ne" : {
"stringUnit" : {
"state" : "translated",
"value" : "रूप"
}
},
"nl" : {
"stringUnit" : {
"state" : "translated",
"value" : "WEERGAVE"
}
},
"pl" : {
"stringUnit" : {
"state" : "translated",
"value" : "WYGLĄD"
}
},
"pt" : {
"stringUnit" : {
"state" : "translated",
"value" : "APARÊNCIA"
}
},
"pt-BR" : {
"stringUnit" : {
"state" : "translated",
"value" : "APARÊNCIA"
}
},
"ru" : {
"stringUnit" : {
"state" : "translated",
"value" : "ОФОРМЛЕНИЕ"
}
},
"sv" : {
"stringUnit" : {
"state" : "translated",
"value" : "UTSEENDE"
}
},
"ta" : {
"stringUnit" : {
"state" : "translated",
"value" : "தோற்றம்"
}
},
"th" : {
"stringUnit" : {
"state" : "translated",
"value" : "ลักษณะที่ปรากฏ"
}
},
"tr" : {
"stringUnit" : {
"state" : "translated",
"value" : "GÖRÜNÜM"
}
},
"uk" : {
"stringUnit" : {
"state" : "translated",
"value" : "ОФОРМЛЕННЯ"
}
},
"ur" : {
"stringUnit" : {
"state" : "translated",
"value" : "ظاہری شکل"
}
},
"vi" : {
"stringUnit" : {
"state" : "translated",
"value" : "GIAO DIỆN"
}
},
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "外观"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "外觀"
}
}
}
},
"app_info.close" : {
"extractionState" : "manual",
"localizations" : {
@@ -24397,7 +24934,7 @@
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "chat with people near you using geohash channels. only a coarse geohash is shared, never exact GPS. your IP address is hidden by routing all traffic over tor."
"value" : "chat with people near you using geohash channels. the selected geohash is public and may reveal an approximate area; exact GPS is never shared. your IP address is hidden by routing all traffic over tor."
}
},
"es" : {
+1 -1
View File
@@ -96,7 +96,7 @@ struct RequestSyncPacket {
}
}
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
guard let pp = p, let mm = m, let dd = payload, pp >= 1, pp <= GCSFilter.maxP, mm > 0 else { return nil }
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
}
}
+7
View File
@@ -322,6 +322,13 @@ final class NoiseCipherState {
throw NoiseError.replayDetected
}
// The 4-byte nonce prefix has been stripped, so the remaining bytes
// must still hold at least the 16-byte Poly1305 tag. The up-front
// `ciphertext.count >= 16` guard is not sufficient here (it counts
// the nonce), and `prefix(count - 16)` would trap on a short payload.
guard actualCiphertext.count >= 16 else {
throw NoiseError.invalidCiphertext
}
// Split ciphertext and tag
encryptedData = actualCiphertext.prefix(actualCiphertext.count - 16)
tag = actualCiphertext.suffix(16)
+14 -24
View File
@@ -7,6 +7,11 @@ import UIKit
import AppKit
#endif
extension Notification.Name {
/// Posted after the geo relay directory successfully refreshes its entries.
static let geoRelayDirectoryDidRefresh = Notification.Name("bitchat.geoRelayDirectoryDidRefresh")
}
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
struct GeoRelayDirectoryDependencies {
var userDefaults: UserDefaults
@@ -165,33 +170,16 @@ final class GeoRelayDirectory {
}
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
/// Ties break by host so every device with the same directory picks the
/// same relay set publishers and subscribers must agree on relays.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty, count > 0 else { return [] }
if entries.count <= count {
return entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
}
.map { "wss://\($0.host)" }
}
var best: [(entry: Entry, distance: Double)] = []
best.reserveCapacity(count)
for entry in entries {
let distance = haversineKm(lat, lon, entry.lat, entry.lon)
if best.count < count {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
} else if let worstDistance = best.last?.distance, distance < worstDistance {
let idx = best.firstIndex { $0.distance > distance } ?? best.count
best.insert((entry, distance), at: idx)
best.removeLast()
}
}
return best.map { "wss://\($0.entry.host)" }
return entries
.map { (entry: $0, distance: haversineKm(lat, lon, $0.lat, $0.lon)) }
.sorted { ($0.distance, $0.entry.host) < ($1.distance, $1.entry.host) }
.prefix(count)
.map { "wss://\($0.entry.host)" }
}
// MARK: - Remote Fetch
@@ -289,6 +277,8 @@ final class GeoRelayDirectory {
isFetching = false
retryAttempt = 0
cancelRetry()
// Let waiters (e.g. location notes stuck in a "no relays" state) retry.
dependencies.notificationCenter.post(name: .geoRelayDirectoryDidRefresh, object: nil)
}
@MainActor
+7
View File
@@ -82,6 +82,13 @@ final class NostrIdentityBridge {
}
deviceSeedCache = nil
// Also drop the in-memory derived per-geohash identities. These hold the
// actual secp256k1 private keys; if left cached, post-panic geohash
// messages would still be signed with pre-panic keys (linkable across the
// wipe) until the app is force-quit.
cacheLock.lock()
derivedIdentityCache.removeAll()
cacheLock.unlock()
}
// MARK: - Per-Geohash Identities (Location Channels)
+77 -16
View File
@@ -39,22 +39,23 @@ struct NostrProtocol {
content: content
)
// 2. Create ephemeral key for this message
let ephemeralKey = try P256K.Schnorr.PrivateKey()
// Created ephemeral key for seal
// 3. Seal the rumor (encrypt to recipient)
// 2. Seal the rumor (encrypt to recipient) and sign it with the SENDER'S
// real identity key. NIP-17 requires the seal be signed by the sender
// so the recipient can authenticate who sent the message; signing with
// a throwaway key leaves DMs forgeable/impersonatable.
let senderKey = try senderIdentity.schnorrSigningKey()
let sealedEvent = try createSeal(
rumor: rumor,
recipientPubkey: recipientPubkey,
senderKey: ephemeralKey
senderKey: senderKey
)
// 4. Gift wrap the sealed event (encrypt to recipient again)
// 3. Gift wrap the sealed event with a throwaway ephemeral key (the wrap
// layer hides the sender's identity from relays; createGiftWrap mints
// its own ephemeral key internally).
let giftWrap = try createGiftWrap(
seal: sealedEvent,
recipientPubkey: recipientPubkey,
senderKey: ephemeralKey
recipientPubkey: recipientPubkey
)
// Created gift wrap
@@ -84,7 +85,15 @@ struct NostrProtocol {
throw error
}
// 2. Open the seal
// 2. Authenticate the seal. The seal MUST be signed by the sender's real
// identity key (NIP-17); without this check a DM is forgeable by anyone
// who knows the recipient's npub. Verify the seal's own signature.
guard seal.isValidSignature() else {
SecureLogger.error("❌ Rejecting DM: seal signature is missing or invalid", category: .session)
throw NostrError.invalidEvent
}
// 3. Open the seal
let rumor: NostrEvent
do {
rumor = try openSeal(
@@ -96,10 +105,63 @@ struct NostrProtocol {
SecureLogger.error("❌ Failed to open seal: \(error)", category: .session)
throw error
}
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
// 4. The sender claimed inside the rumor must match the key that actually
// signed the seal, otherwise the sender field is unauthenticated and
// spoofable.
guard seal.pubkey == rumor.pubkey else {
SecureLogger.error("❌ Rejecting DM: rumor pubkey does not match seal signer", category: .session)
throw NostrError.invalidEvent
}
// Return the seal signer's pubkey as the authenticated sender.
return (content: rumor.content, senderPubkey: seal.pubkey, timestamp: rumor.created_at)
}
#if DEBUG
static func createPrivateMessageWithInvalidSealSignatureForTesting(
content: String,
recipientPubkey: String,
senderIdentity: NostrIdentity
) throws -> NostrEvent {
let rumor = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm,
tags: [],
content: content
)
var seal = try createSeal(
rumor: rumor,
recipientPubkey: recipientPubkey,
senderKey: senderIdentity.schnorrSigningKey()
)
seal.sig = String(repeating: "0", count: 128)
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
}
static func createPrivateMessageWithMismatchedSealRumorPubkeyForTesting(
content: String,
recipientPubkey: String,
rumorIdentity: NostrIdentity,
sealSignerIdentity: NostrIdentity
) throws -> NostrEvent {
let rumor = NostrEvent(
pubkey: rumorIdentity.publicKeyHex,
createdAt: Date(),
kind: .dm,
tags: [],
content: content
)
let seal = try createSeal(
rumor: rumor,
recipientPubkey: recipientPubkey,
senderKey: sealSignerIdentity.schnorrSigningKey()
)
return try createGiftWrap(seal: seal, recipientPubkey: recipientPubkey)
}
#endif
/// Create a geohash-scoped ephemeral public message (kind 20000)
static func createEphemeralGeohashEvent(
content: String,
@@ -195,10 +257,9 @@ struct NostrProtocol {
private static func createGiftWrap(
seal: NostrEvent,
recipientPubkey: String,
senderKey: P256K.Schnorr.PrivateKey // This is the ephemeral key used for the seal
recipientPubkey: String
) throws -> NostrEvent {
let sealJSON = try seal.jsonString()
// Create new ephemeral key for gift wrap
+619 -88
View File
@@ -48,7 +48,14 @@ private struct URLSessionAdapter: NostrRelaySessionProtocol {
let base: URLSession
func webSocketTask(with url: URL) -> NostrRelayConnectionProtocol {
URLSessionWebSocketTaskAdapter(base: base.webSocketTask(with: url))
let task = base.webSocketTask(with: url)
// Byte bound per inbound frame; without it the per-relay buffer cap
// (nostrInboundPerRelayBufferCap) bounds FRAMES but not BYTES, and a
// hostile relay could pile up cap × 1 MiB (URLSession default) per
// connection. See TransportConfig.nostrInboundMaxFrameBytes for the
// sizing rationale. Oversized frames fail the receive with an error.
task.maximumMessageSize = TransportConfig.nostrInboundMaxFrameBytes
return URLSessionWebSocketTaskAdapter(base: task)
}
}
@@ -66,6 +73,9 @@ struct NostrRelayManagerDependencies {
var makeSession: () -> NostrRelaySessionProtocol
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
var now: () -> Date
/// Uniform random value in [0, 1) used to jitter reconnect backoff.
/// Injectable so tests can pin or sweep the jitter deterministically.
var jitterUnit: () -> Double
}
private extension NostrRelayManagerDependencies {
@@ -93,7 +103,8 @@ private extension NostrRelayManagerDependencies {
scheduleAfter: { delay, action in
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
},
now: Date.init
now: Date.init,
jitterUnit: { Double.random(in: 0..<1) }
)
}
}
@@ -102,7 +113,10 @@ private extension NostrRelayManagerDependencies {
@MainActor
final class NostrRelayManager: ObservableObject {
static let shared = NostrRelayManager()
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info.
// Entries are removed only on OK acks (or panic wipe); relays that never
// ack leave entries behind for the process lifetime. Observability-only
// state, bounded in practice by outbound DM volume.
private(set) static var pendingGiftWrapIDs = Set<String>()
static func registerPendingGiftWrap(id: String) {
pendingGiftWrapIDs.insert(id)
@@ -140,13 +154,34 @@ final class NostrRelayManager: ObservableObject {
private var hasLocationPermission: Bool = false
private var connections: [String: NostrRelayConnectionProtocol] = [:]
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
private var pendingSubscriptions: [String: [String: String]] = [:] // relay URL -> (subscription id -> encoded REQ JSON)
// Not-yet-flushed REQs per relay, bounded by a per-relay cap (oldest by
// insertion order evicted) and an age sweep on connect attempts. Dicts are
// unordered, so each entry carries an insertion sequence and queue time.
private struct PendingSubscription {
let messageString: String // encoded REQ JSON
let queuedAt: Date
let sequence: UInt64
}
private var pendingSubscriptions: [String: [String: PendingSubscription]] = [:] // relay URL -> (subscription id -> pending REQ)
private var pendingSubscriptionSequence: UInt64 = 0
private var messageHandlers: [String: (NostrEvent) -> Void] = [:]
private struct InboundEventKey: Hashable {
let subscriptionID: String
let eventID: String
}
private let recentInboundEventKeyLimit = TransportConfig.nostrInboundEventDedupCap
private let recentInboundEventKeyTrimTarget = TransportConfig.nostrInboundEventDedupTrimTarget
private var recentInboundEventKeys = Set<InboundEventKey>()
private var recentInboundEventKeyOrder: [InboundEventKey] = []
private var duplicateInboundEventDropCount = 0
private var duplicateInboundEventDropCountBySubscription: [String: Int] = [:]
private var inboundEventLogCount = 0
// Coalesce duplicate subscribe requests for the same id within a short window.
private let subscribeCoalesceInterval: TimeInterval = 1.0
private var subscribeCoalesce: [String: Date] = [:]
private var pendingTorConnectionURLs = Set<String>()
private var awaitingTorForConnections = false
private var torReadyWaitAttempts = 0
private var cancellables = Set<AnyCancellable>()
private struct SubscriptionRequestState: Equatable {
@@ -159,9 +194,10 @@ final class NostrRelayManager: ObservableObject {
private struct EOSETracker {
var pendingRelays: Set<String>
var callback: () -> Void
var timer: Timer?
let epoch: Int
}
private var eoseTrackers: [String: EOSETracker] = [:]
private var eoseTrackerEpoch = 0
private var pendingEOSECallbacks: [String: () -> Void] = [:]
// Message queue for reliability
@@ -172,6 +208,9 @@ final class NostrRelayManager: ObservableObject {
}
private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock()
// Total pending sends dropped at the queue cap; drives the sampled
// overflow warning (first + every Nth drop).
private var pendingSendDropCount = 0
private let encoder = JSONEncoder()
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
@@ -183,7 +222,33 @@ final class NostrRelayManager: ObservableObject {
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0
// Per-relay off-main inbound pipeline: raw socket frames are parsed and
// Schnorr-verified in arrival order OFF the main actor (this is the single
// signature verification for the whole inbound path downstream handlers
// receive only verified events), then hop back to the main actor for dedup
// recording and handler dispatch.
//
// Each relay connection owns its OWN AsyncStream + consumer task, so N
// relays verify in parallel while every relay's frames stay in arrival
// order (a single subscription's events for a relay all arrive on that
// relay's socket, so per-relay ordering preserves per-subscription
// ordering). A burst of EVENT frames from one busy/malicious relay only
// blocks that relay's own verification backlog DMs, OKs, EOSEs, and
// events from every other relay keep flowing on their own pipelines.
//
// Each stream is bounded (`.bufferingNewest`) so a relay flooding faster
// than its verification drains sheds its own oldest frames instead of
// growing memory without bound; it can never starve other relays.
//
// Continuations live in a lock-guarded, `Sendable` router (see
// `InboundFrameRouter` at file scope) so the raw socket receive callback
// (which is NOT main-actor isolated) can route a frame to the right relay
// stream without a per-frame main hop, while the main actor owns pipeline
// creation/teardown. The expensive work (Schnorr verify) is what runs
// off-main; the yield stays cheap.
private let inboundRouter = InboundFrameRouter()
init() {
self.dependencies = .live()
hasMutualFavorites = dependencies.hasMutualFavorites()
@@ -237,7 +302,70 @@ final class NostrRelayManager: ObservableObject {
}
.store(in: &cancellables)
}
deinit {
inboundRouter.finishAll()
}
/// Ensure a serial off-main consumer pipeline exists for a relay. Called on
/// the main actor when a socket is (re)armed for receiving. Idempotent.
///
/// Ordering within the relay is deliberate and security/performance-critical:
/// 1. `precheckInboundEvent` (main hop): per-relay stats plus a cheap
/// duplicate LOOKUP duplicate fan-in from multiple relays dominates
/// real traffic and must never pay for Schnorr verification.
/// 2. `isValidSignature()` runs here, off the main actor the ONLY
/// signature verification on the inbound path (JSON re-serialization +
/// SHA-256 + secp256k1 Schnorr per event).
/// 3. `deliverVerifiedInboundEvent` (main hop): authoritative
/// check-and-RECORD plus handler dispatch. Recording only after
/// verification means a forged-signature copy can never poison the
/// dedup cache and suppress the genuine event.
private func ensureRelayInboundPipeline(for relayUrl: String) {
let started = inboundRouter.startPipeline(for: relayUrl) { [weak self] stream in
Task.detached(priority: .userInitiated) {
for await frame in stream {
guard let parsed = ParsedInbound(frame.message) else { continue }
guard let self else { return }
switch parsed {
case .event(let subId, let event):
guard await self.precheckInboundEvent(
subscriptionID: subId,
eventID: event.id,
relayUrl: relayUrl
) else {
continue
}
guard event.isValidSignature() else {
SecureLogger.warning(
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
category: .session
)
continue
}
await self.deliverVerifiedInboundEvent(subscriptionID: subId, event: event, from: relayUrl)
case .eose, .ok, .notice:
await self.handleParsedMessage(parsed, from: relayUrl)
}
}
}
}
if started {
SecureLogger.debug("🧵 Started inbound verify pipeline for \(relayUrl)", category: .session)
}
}
/// Tear down a relay's inbound pipeline (socket gone or state wiped). The
/// consumer drains any already-buffered frames before finishing, so
/// in-flight verified events are still delivered.
private func teardownRelayInboundPipeline(for relayUrl: String) {
inboundRouter.finishPipeline(for: relayUrl)
}
private func teardownAllRelayInboundPipelines() {
inboundRouter.finishAll()
}
/// Connect to all configured relays
func connect() {
// Global network policy gate
@@ -252,19 +380,81 @@ final class NostrRelayManager: ObservableObject {
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
// Clear known subscriptions and any queued subs since connections are gone
// Sockets are gone; drop every relay's inbound verify pipeline.
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: false)
// Sockets are gone, so per-relay subscription state is cleared but
// durable intent (subscriptionRequestState, messageHandlers, parked
// EOSE callbacks) is kept so REQs replay when relays reconnect
// (e.g. background foreground).
subscriptions.removeAll()
pendingSubscriptions.removeAll()
subscriptionRequestState.removeAll()
pendingEOSECallbacks.removeAll()
for (_, tracker) in eoseTrackers {
tracker.timer?.invalidate()
}
// Settle in-flight initial loads instead of leaving callers hanging.
let trackers = eoseTrackers
eoseTrackers.removeAll()
for (_, tracker) in trackers {
tracker.callback()
}
pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false
torReadyWaitAttempts = 0
updateConnectionStatus()
}
/// Panic wipe reset: close sockets and drop every user/session-specific
/// relay intent without invoking old callbacks. Unlike `disconnect()`, this
/// must not preserve subscription replay state because geohash DM handlers
/// can capture pre-wipe Nostr private keys.
func resetForPanicWipe() {
connectionGeneration &+= 1
for (_, task) in connections {
task.cancel(with: .goingAway, reason: nil)
}
connections.removeAll()
teardownAllRelayInboundPipelines()
markRelaySocketsClosed(resetState: true)
subscriptions.removeAll()
pendingSubscriptions.removeAll()
messageHandlers.removeAll()
subscriptionRequestState.removeAll()
subscribeCoalesce.removeAll()
eoseTrackers.removeAll()
pendingEOSECallbacks.removeAll()
pendingTorConnectionURLs.removeAll()
awaitingTorForConnections = false
torReadyWaitAttempts = 0
recentInboundEventKeys.removeAll()
recentInboundEventKeyOrder.removeAll()
duplicateInboundEventDropCount = 0
duplicateInboundEventDropCountBySubscription.removeAll()
inboundEventLogCount = 0
Self.pendingGiftWrapIDs.removeAll()
messageQueueLock.lock()
messageQueue.removeAll()
pendingSendDropCount = 0
messageQueueLock.unlock()
updateConnectionStatus()
}
private func markRelaySocketsClosed(resetState: Bool) {
let now = dependencies.now()
for index in relays.indices {
relays[index].isConnected = false
relays[index].nextReconnectTime = nil
if resetState {
relays[index].lastError = nil
relays[index].lastConnectedAt = nil
relays[index].lastDisconnectedAt = nil
relays[index].messagesSent = 0
relays[index].messagesReceived = 0
relays[index].reconnectAttempts = 0
} else {
relays[index].lastDisconnectedAt = now
}
}
}
/// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) {
@@ -285,11 +475,14 @@ final class NostrRelayManager: ObservableObject {
// Global network policy gate
guard dependencies.activationAllowed() else { return }
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
// Defer sends until Tor is ready to avoid premature queueing
dependencies.awaitTorReady { [weak self] ready in
guard let self = self else { return }
if ready { self.sendEvent(event, to: relayUrls) }
}
// Fail-closed: nothing touches the network until Tor is up. Queue the
// event locally so it survives a slow bootstrap (queued sends flush
// when relays connect), then kick off connection setup, which itself
// waits for Tor readiness.
let targetRelays = allowedRelayList(from: relayUrls ?? Self.defaultRelays)
guard !targetRelays.isEmpty else { return }
enqueuePendingSend(event, pendingRelays: Set(targetRelays))
ensureConnections(to: targetRelays)
return
}
let requestedRelays = relayUrls ?? Self.defaultRelays
@@ -307,9 +500,29 @@ final class NostrRelayManager: ObservableObject {
}
}
if !stillPending.isEmpty {
messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
messageQueueLock.unlock()
enqueuePendingSend(event, pendingRelays: stillPending)
}
}
private func enqueuePendingSend(_ event: NostrEvent, pendingRelays: Set<String>) {
messageQueueLock.lock()
messageQueue.append(PendingSend(event: event, pendingRelays: pendingRelays))
let overflow = messageQueue.count - TransportConfig.nostrPendingSendQueueCap
if overflow > 0 {
messageQueue.removeFirst(overflow)
}
messageQueueLock.unlock()
guard overflow > 0 else { return }
// Dropped events are ephemeral (presence/geo), so no status surfacing
// is needed but the drops should be visible. Sampled so a sustained
// relay stall can't flood the log.
pendingSendDropCount += overflow
if pendingSendDropCount == 1 ||
pendingSendDropCount.isMultiple(of: TransportConfig.nostrPendingSendDropLogInterval) {
SecureLogger.warning(
"📤 Relay send queue full — dropped \(pendingSendDropCount) oldest event(s)",
category: .session
)
}
}
@@ -404,16 +617,14 @@ final class NostrRelayManager: ObservableObject {
existingSet.insert(url)
}
for url in urls {
var map = self.pendingSubscriptions[url] ?? [:]
map[id] = messageString
self.pendingSubscriptions[url] = map
queuePendingSubscription(id: id, messageString: messageString, for: url)
}
// Initialize EOSE tracking if requested
if let onEOSE = onEOSE {
if urls.isEmpty {
onEOSE()
} else if shouldWaitForTorBeforeConnecting {
pendingEOSECallbacks[id] = onEOSE
parkEOSECallbackUntilTorReady(id: id, callback: onEOSE)
} else {
startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE)
}
@@ -451,6 +662,7 @@ final class NostrRelayManager: ObservableObject {
connection.cancel(with: .goingAway, reason: nil)
}
connections.removeValue(forKey: url)
teardownRelayInboundPipeline(for: url)
subscriptions.removeValue(forKey: url)
pendingSubscriptions.removeValue(forKey: url)
}
@@ -486,11 +698,12 @@ final class NostrRelayManager: ObservableObject {
/// Unsubscribe from a subscription
func unsubscribe(id: String) {
messageHandlers.removeValue(forKey: id)
removeRecentInboundEvents(forSubscriptionID: id)
duplicateInboundEventDropCountBySubscription.removeValue(forKey: id)
// Allow immediate re-subscription by clearing coalescer timestamp
subscribeCoalesce.removeValue(forKey: id)
subscriptionRequestState.removeValue(forKey: id)
pendingEOSECallbacks.removeValue(forKey: id)
eoseTrackers[id]?.timer?.invalidate()
eoseTrackers.removeValue(forKey: id)
for url in Array(pendingSubscriptions.keys) {
pendingSubscriptions[url]?.removeValue(forKey: id)
@@ -521,6 +734,7 @@ final class NostrRelayManager: ObservableObject {
private func connectToRelays(_ relayUrls: [String], shouldLog: Bool = false) {
guard dependencies.activationAllowed() else { return }
sweepStalePendingSubscriptions()
let targets = allowedRelayList(from: relayUrls).filter {
connections[$0] == nil && !isPermanentlyFailed($0)
}
@@ -561,37 +775,135 @@ final class NostrRelayManager: ObservableObject {
self.awaitingTorForConnections = false
guard ready else {
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
self.torReadyWaitAttempts += 1
if self.torReadyWaitAttempts < TransportConfig.nostrTorReadyMaxWaitAttempts {
SecureLogger.warning("Tor not ready; re-queueing \(pending.count) relay connection(s) (attempt \(self.torReadyWaitAttempts))", category: .session)
self.queueConnectionsUntilTorReady(pending)
} else {
// Still fail-closed (no network), but unblock any callers
// waiting on EOSE so the UI doesn't hang indefinitely.
// Queued subscriptions/sends are kept and flush if a later
// trigger (e.g. app foreground) brings Tor up.
SecureLogger.error("❌ Tor not ready after \(self.torReadyWaitAttempts) wait(s); aborting relay connections (fail-closed)", category: .session)
self.torReadyWaitAttempts = 0
self.unblockPendingEOSECallbacks(reason: "tor-unavailable")
}
return
}
self.torReadyWaitAttempts = 0
self.connectToRelays(pending, shouldLog: true)
}
}
/// Park an EOSE callback while Tor is not yet ready, and schedule the same
/// fallback timeout `startEOSETracking` uses. Without it, a parked callback
/// would only be unblocked by Tor-readiness retry exhaustion (several
/// awaitReady timeouts, i.e. minutes), leaving callers hanging far past the
/// normal EOSE fallback. If Tor recovers first the callback is promoted to
/// a real EOSE tracker (`startPendingEOSETrackingIfNeeded`), and if retry
/// exhaustion fires first it is drained by `unblockPendingEOSECallbacks`;
/// either way it leaves `pendingEOSECallbacks` and this timer is a no-op.
private func parkEOSECallbackUntilTorReady(id: String, callback: @escaping () -> Void) {
pendingEOSECallbacks[id] = callback
let generation = connectionGeneration
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
// Stale timers from a previous connection generation are void.
guard generation == self.connectionGeneration else { return }
// Already fired (unsubscribe, retry-exhaustion unblock) or
// promoted to a real EOSE tracker: nothing to do.
guard let callback = self.pendingEOSECallbacks.removeValue(forKey: id) else { return }
SecureLogger.warning("Unblocking Tor-parked EOSE callback for \(id) after fallback timeout", category: .session)
callback()
}
}
}
/// Fire and clear all EOSE callbacks that are parked waiting for Tor.
/// Callers treat EOSE as "initial fetch finished"; firing with no data is
/// safe and prevents indefinite hangs when Tor cannot bootstrap.
private func unblockPendingEOSECallbacks(reason: String) {
guard !pendingEOSECallbacks.isEmpty else { return }
let callbacks = pendingEOSECallbacks
pendingEOSECallbacks.removeAll()
SecureLogger.warning("Unblocking \(callbacks.count) pending EOSE callback(s) without data (\(reason))", category: .session)
for (_, callback) in callbacks {
callback()
}
}
private func subscriptionStateExists(id: String, requestState: SubscriptionRequestState) -> Bool {
guard !requestState.relayURLs.isEmpty else { return true }
return requestState.relayURLs.allSatisfy { url in
pendingSubscriptions[url]?[id] == requestState.messageString ||
pendingSubscriptions[url]?[id]?.messageString == requestState.messageString ||
subscriptions[url]?.contains(id) == true
}
}
private func queuePendingSubscription(id: String, messageString: String, for url: String) {
var map = pendingSubscriptions[url] ?? [:]
pendingSubscriptionSequence &+= 1
map[id] = PendingSubscription(
messageString: messageString,
queuedAt: dependencies.now(),
sequence: pendingSubscriptionSequence
)
// Bound per-relay pending REQs; evict oldest by insertion order. The
// durable intent stays in subscriptionRequestState, so an evicted REQ
// is still replayed if its subscription is active when the relay
// (re)connects.
var evictedCount = 0
while map.count > TransportConfig.nostrPendingSubscriptionsPerRelayCap,
let oldest = map.min(by: { $0.value.sequence < $1.value.sequence }) {
map.removeValue(forKey: oldest.key)
evictedCount += 1
}
if evictedCount > 0 {
// Bounds proof: the cap eviction actually removed entries.
SecureLogger.warning(
"📋 Evicted \(evictedCount) pending sub(s) over cap for \(url)",
category: .session
)
}
pendingSubscriptions[url] = map
}
/// Drop pending REQs older than the TTL. Runs on connect attempts (the
/// natural maintenance path: connect/ensureConnections/reconnects all
/// funnel through connectToRelays) so stale entries for relays that never
/// come up cannot accumulate without bound.
private func sweepStalePendingSubscriptions() {
let now = dependencies.now()
for (url, map) in pendingSubscriptions {
let fresh = map.filter {
now.timeIntervalSince($0.value.queuedAt) <= TransportConfig.nostrPendingSubscriptionTTLSeconds
}
guard fresh.count != map.count else { continue }
// Bounds proof: the age sweep actually removed entries. Warning
// (not debug) stale pending REQs mean a relay never came up.
SecureLogger.warning(
"📋 Swept \(map.count - fresh.count) stale pending sub(s) for \(url)",
category: .session
)
pendingSubscriptions[url] = fresh.isEmpty ? nil : fresh
}
}
private func startEOSETracking(id: String, relayURLs: Set<String>, callback: @escaping () -> Void) {
eoseTrackers[id]?.timer?.invalidate()
var tracker = EOSETracker(pendingRelays: relayURLs, callback: callback, timer: nil)
eoseTrackerEpoch += 1
let epoch = eoseTrackerEpoch
eoseTrackers[id] = EOSETracker(pendingRelays: relayURLs, callback: callback, epoch: epoch)
// Fallback timeout to avoid hanging if a relay never sends EOSE.
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
Task { @MainActor in
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
Task { @MainActor [weak self] in
guard let self else { return }
if let tracker = self.eoseTrackers[id] {
tracker.timer?.invalidate()
self.eoseTrackers.removeValue(forKey: id)
callback()
}
guard let tracker = self.eoseTrackers[id], tracker.epoch == epoch else { return }
self.eoseTrackers.removeValue(forKey: id)
tracker.callback()
}
}
eoseTrackers[id] = tracker
}
private func startPendingEOSETrackingIfNeeded(id: String) {
@@ -608,6 +920,53 @@ final class NostrRelayManager: ObservableObject {
startEOSETracking(id: id, relayURLs: requestState.relayURLs, callback: callback)
}
}
private func shouldDeliverInboundEvent(subscriptionID: String, eventID: String) -> Bool {
guard !eventID.isEmpty else { return true }
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
guard recentInboundEventKeys.insert(key).inserted else {
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
return false
}
recentInboundEventKeyOrder.append(key)
if recentInboundEventKeyOrder.count > recentInboundEventKeyLimit {
let removeCount = recentInboundEventKeyOrder.count - recentInboundEventKeyTrimTarget
for staleKey in recentInboundEventKeyOrder.prefix(removeCount) {
recentInboundEventKeys.remove(staleKey)
}
recentInboundEventKeyOrder.removeFirst(removeCount)
}
return true
}
private func recordDuplicateInboundEventDrop(subscriptionID: String) {
duplicateInboundEventDropCount += 1
let subscriptionCount = (duplicateInboundEventDropCountBySubscription[subscriptionID] ?? 0) + 1
duplicateInboundEventDropCountBySubscription[subscriptionID] = subscriptionCount
if duplicateInboundEventDropCount == 1 ||
duplicateInboundEventDropCount.isMultiple(of: TransportConfig.nostrDuplicateEventLogInterval) {
SecureLogger.debug(
"Dropped duplicate Nostr event deliveries total=\(duplicateInboundEventDropCount) sub=\(subscriptionID) sub_total=\(subscriptionCount)",
category: .session
)
}
}
private func removeRecentInboundEvents(forSubscriptionID subscriptionID: String) {
guard !recentInboundEventKeyOrder.isEmpty else { return }
var retainedKeys: [InboundEventKey] = []
retainedKeys.reserveCapacity(recentInboundEventKeyOrder.count)
for key in recentInboundEventKeyOrder {
if key.subscriptionID == subscriptionID {
recentInboundEventKeys.remove(key)
} else {
retainedKeys.append(key)
}
}
recentInboundEventKeyOrder = retainedKeys
}
private func connectToRelay(_ urlString: String) {
// Global network policy gate
@@ -643,7 +1002,11 @@ final class NostrRelayManager: ObservableObject {
connections[urlString] = task
task.resume()
// Bring up this relay's own serial verify pipeline before arming the
// socket, so inbound frames have somewhere to land.
ensureRelayInboundPipeline(for: urlString)
// Start receiving messages
receiveMessage(from: task, relayUrl: urlString)
@@ -665,26 +1028,35 @@ final class NostrRelayManager: ObservableObject {
}
}
/// Send any queued subscriptions for a relay that just connected.
/// Send queued subscriptions and replay durable ones for a relay that just
/// (re)connected. Relays drop subscriptions with the socket, so every
/// active subscription targeting this relay must be re-sent.
private func flushPendingSubscriptions(for relayUrl: String) {
guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return }
guard let connection = connections[relayUrl] else { return }
for (id, messageString) in map {
var toSend = (pendingSubscriptions[relayUrl] ?? [:]).mapValues(\.messageString)
for (id, state) in subscriptionRequestState where state.relayURLs.contains(relayUrl) && toSend[id] == nil {
toSend[id] = state.messageString
}
for (id, messageString) in toSend {
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
startPendingEOSETrackingIfNeeded(id: id)
connection.send(.string(messageString)) { error in
if let error = error {
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
} else {
Task { @MainActor in
var subs = self.subscriptions[relayUrl] ?? Set<String>()
subs.insert(id)
self.subscriptions[relayUrl] = subs
connection.send(.string(messageString)) { [weak self, weak connection] error in
Task { @MainActor [weak self] in
guard let self else { return }
if let error = error {
// Keep the pending entry; the next (re)connect retries it.
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
} else {
// A stale completion from a socket that has since been
// replaced must not mark the subscription active, or
// the next connection would skip replaying it.
guard let connection, self.connections[relayUrl] === connection else { return }
self.subscriptions[relayUrl, default: []].insert(id)
self.pendingSubscriptions[relayUrl]?.removeValue(forKey: id)
}
}
}
}
pendingSubscriptions[relayUrl] = nil
}
private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {
@@ -693,14 +1065,13 @@ final class NostrRelayManager: ObservableObject {
switch result {
case .success(let message):
// Parse off-main to reduce UI jank, then hop back for state updates
Task.detached(priority: .utility) {
guard let parsed = ParsedInbound(message) else { return }
await MainActor.run {
self.handleParsedMessage(parsed, from: relayUrl)
}
}
// Hand the raw frame to this relay's serial inbound pipeline:
// parsing and signature verification run off-main, in arrival
// order, independently of every other relay's pipeline. Routing
// through the lock-guarded router keeps this off the main actor
// (no per-frame main hop).
self.inboundRouter.yield(InboundFrame(message: message), to: relayUrl)
// Continue receiving
Task { @MainActor in
self.receiveMessage(from: task, relayUrl: relayUrl)
@@ -718,26 +1089,59 @@ final class NostrRelayManager: ObservableObject {
// Note: declared at file scope below to avoid MainActor isolation inside this class
// and keep parsing off the main actor.
// Handle parsed message on MainActor (state updates and handlers)
/// First main-actor hop for an inbound EVENT: per-relay stats plus a cheap
/// duplicate LOOKUP (no recording) so duplicate fan-in from multiple
/// relays never pays for Schnorr verification. Recording happens only
/// after the signature verifies (`deliverVerifiedInboundEvent`), so a
/// forged-signature copy can never poison the dedup cache and suppress
/// the genuine event.
private func precheckInboundEvent(subscriptionID: String, eventID: String, relayUrl: String) -> Bool {
if let index = relays.firstIndex(where: { $0.url == relayUrl }) {
relays[index].messagesReceived += 1
}
guard !eventID.isEmpty else { return true }
let key = InboundEventKey(subscriptionID: subscriptionID, eventID: eventID)
if recentInboundEventKeys.contains(key) {
recordDuplicateInboundEventDrop(subscriptionID: subscriptionID)
return false
}
return true
}
/// Second main-actor hop, after off-main signature verification:
/// authoritative check-and-record (the serial pipeline means the same
/// event is never in flight twice, but the record must stay atomic with
/// delivery) and handler dispatch.
private func deliverVerifiedInboundEvent(subscriptionID subId: String, event: NostrEvent, from relayUrl: String) {
guard shouldDeliverInboundEvent(subscriptionID: subId, eventID: event.id) else {
return
}
if event.kind != 1059 {
// Per-event logging floods dev builds in busy geohashes; sample it.
inboundEventLogCount += 1
if inboundEventLogCount == 1 || inboundEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("📥 Event #\(inboundEventLogCount) kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
}
// Handle parsed non-EVENT messages on MainActor (state updates and handlers)
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
switch parsed {
case .event(let subId, let event):
if event.kind != 1059 {
SecureLogger.debug("📥 Event kind=\(event.kind) id=\(event.id.prefix(16))… relay=\(relayUrl)", category: .session)
}
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
self.relays[index].messagesReceived += 1
}
if let handler = self.messageHandlers[subId] {
handler(event)
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
case .event:
// Events flow through the serial inbound pipeline (precheck
// off-main signature verification deliverVerifiedInboundEvent)
// and never reach this fallback.
assertionFailure("inbound EVENT bypassed the verified pipeline")
case .eose(let subId):
if var tracker = eoseTrackers[subId] {
tracker.pendingRelays.remove(relayUrl)
if tracker.pendingRelays.isEmpty {
tracker.timer?.invalidate()
eoseTrackers.removeValue(forKey: subId)
tracker.callback()
} else {
@@ -811,17 +1215,31 @@ final class NostrRelayManager: ObservableObject {
isConnected = relays.contains { $0.isConnected }
}
private func handleDisconnection(relayUrl: String, error: Error) {
// If networking is disallowed, do not schedule reconnection
if !dependencies.activationAllowed() {
connections.removeValue(forKey: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
return
/// A relay that drops before sending EOSE must not stall initial-load
/// callbacks; treat it as done and let the remaining relays (or the
/// fallback timeout) drive completion.
private func settleEOSETrackers(droppingRelay relayUrl: String) {
for (id, var tracker) in eoseTrackers where tracker.pendingRelays.contains(relayUrl) {
tracker.pendingRelays.remove(relayUrl)
if tracker.pendingRelays.isEmpty {
eoseTrackers.removeValue(forKey: id)
tracker.callback()
} else {
eoseTrackers[id] = tracker
}
}
}
private func handleDisconnection(relayUrl: String, error: Error) {
connections.removeValue(forKey: relayUrl)
teardownRelayInboundPipeline(for: relayUrl)
subscriptions.removeValue(forKey: relayUrl)
updateRelayStatus(relayUrl, isConnected: false, error: error)
settleEOSETrackers(droppingRelay: relayUrl)
// If networking is disallowed, do not schedule reconnection
if !dependencies.activationAllowed() {
return
}
// Check if this is a DNS or handshake error; treat as permanent
let errorDescription = error.localizedDescription.lowercased()
@@ -852,16 +1270,26 @@ final class NostrRelayManager: ObservableObject {
return
}
// Calculate backoff interval
let backoffInterval = min(
// Calculate backoff interval with ±jitterRatio random jitter so relays
// that dropped together don't all reconnect at the same instant.
let baseBackoffInterval = min(
initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)),
maxBackoffInterval
)
let jitterRatio = TransportConfig.nostrRelayBackoffJitterRatio
let jitterFactor = 1.0 + (dependencies.jitterUnit() * 2.0 - 1.0) * jitterRatio
let backoffInterval = baseBackoffInterval * jitterFactor
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
relays[index].nextReconnectTime = nextReconnectTime
// Reconnects are bounded by maxReconnectAttempts and exponentially
// backed off, so this is low-frequency: plain debug, no sampling.
SecureLogger.debug(
"🔄 Reconnect \(relayUrl) in \(String(format: "%.1f", backoffInterval))s (base \(String(format: "%.1f", baseBackoffInterval))s, attempt \(relays[index].reconnectAttempts)/\(maxReconnectAttempts))",
category: .session
)
// Schedule reconnection with exponential backoff
let gen = connectionGeneration
dependencies.scheduleAfter(backoffInterval) { [weak self] in
@@ -893,8 +1321,9 @@ final class NostrRelayManager: ObservableObject {
if let connection = connections[normalizedRelayUrl] {
connection.cancel(with: .goingAway, reason: nil)
connections.removeValue(forKey: normalizedRelayUrl)
teardownRelayInboundPipeline(for: normalizedRelayUrl)
}
// Attempt immediate reconnection
connectToRelay(normalizedRelayUrl)
}
@@ -919,6 +1348,31 @@ final class NostrRelayManager: ObservableObject {
pendingSubscriptions[relayUrl]?.count ?? 0
}
func debugPendingSubscriptionIDs(for relayUrl: String) -> Set<String> {
guard let map = pendingSubscriptions[relayUrl] else { return [] }
return Set(map.keys)
}
var debugMessageHandlerCount: Int {
messageHandlers.count
}
var debugSubscriptionRequestCount: Int {
subscriptionRequestState.count
}
var debugPendingEOSECallbackCount: Int {
pendingEOSECallbacks.count
}
var debugDuplicateInboundEventDropCount: Int {
duplicateInboundEventDropCount
}
func debugDuplicateInboundEventDropCount(forSubscriptionID subscriptionID: String) -> Int {
duplicateInboundEventDropCountBySubscription[subscriptionID] ?? 0
}
func debugFlushMessageQueue() {
flushMessageQueue(for: nil)
}
@@ -943,6 +1397,13 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Failure classification
private func isPermanentlyFailed(_ url: String) -> Bool {
guard let r = relays.first(where: { $0.url == url }) else { return false }
// Failures decay: after a cooldown the relay gets another chance, so a
// long network outage or transient relay trouble can't blacklist it
// for the rest of the process lifetime.
if let lastDisconnect = r.lastDisconnectedAt,
dependencies.now().timeIntervalSince(lastDisconnect) >= TransportConfig.nostrRelayFailureCooldownSeconds {
return false
}
if r.reconnectAttempts >= maxReconnectAttempts { return true }
if let ns = r.lastError as NSError?, ns.domain == NSURLErrorDomain {
if ns.code == NSURLErrorBadServerResponse || ns.code == NSURLErrorCannotFindHost {
@@ -955,6 +1416,77 @@ final class NostrRelayManager: ObservableObject {
// MARK: - Off-main inbound parsing helpers (file scope, non-isolated)
/// A single raw socket frame awaiting off-main parse + Schnorr verification.
private struct InboundFrame: Sendable {
let message: URLSessionWebSocketTask.Message
}
/// Lock-guarded registry of per-relay inbound streams.
///
/// The raw WebSocket receive callback is not main-actor isolated, so it needs a
/// `Sendable` path to route a frame to the correct relay's stream without a
/// per-frame hop onto the main actor. Pipeline lifecycle (start/finish) is
/// driven from the main actor; frame delivery (`yield`) can come from any
/// thread. All access is serialized by a single lock contention is negligible
/// because the guarded critical section is only a dictionary lookup + yield.
private final class InboundFrameRouter: @unchecked Sendable {
private let lock = NSLock()
private var continuations: [String: AsyncStream<InboundFrame>.Continuation] = [:]
private var tasks: [String: Task<Void, Never>] = [:]
/// Start a relay's stream + consumer if one does not already exist.
/// Returns true when a new pipeline was created. The bounded
/// `.bufferingNewest` policy makes a single relay shed its OWN oldest
/// frames under a flood, never other relays' frames. Buffered memory per
/// relay is bounded (not eliminated) at the frame cap times the per-frame
/// byte cap (`maximumMessageSize`) see TransportConfig.
func startPipeline(
for relayUrl: String,
makeConsumer: (AsyncStream<InboundFrame>) -> Task<Void, Never>
) -> Bool {
lock.lock()
defer { lock.unlock() }
if continuations[relayUrl] != nil { return false }
let (stream, continuation) = AsyncStream<InboundFrame>.makeStream(
bufferingPolicy: .bufferingNewest(TransportConfig.nostrInboundPerRelayBufferCap)
)
continuations[relayUrl] = continuation
tasks[relayUrl] = makeConsumer(stream)
return true
}
/// Route a frame to a relay's stream. No-op if the relay has no live
/// pipeline (socket already torn down) the frame is simply dropped, which
/// is safe for best-effort Nostr inbound.
func yield(_ frame: InboundFrame, to relayUrl: String) {
lock.lock()
let continuation = continuations[relayUrl]
lock.unlock()
continuation?.yield(frame)
}
/// Finish a relay's stream. The consumer drains any already-buffered frames
/// before exiting, so in-flight verified events are still delivered.
func finishPipeline(for relayUrl: String) {
lock.lock()
let continuation = continuations.removeValue(forKey: relayUrl)
tasks.removeValue(forKey: relayUrl)
lock.unlock()
continuation?.finish()
}
func finishAll() {
lock.lock()
let allContinuations = continuations
continuations.removeAll()
tasks.removeAll()
lock.unlock()
for continuation in allContinuations.values {
continuation.finish()
}
}
}
private enum ParsedInbound {
case event(subId: String, event: NostrEvent)
case ok(eventId: String, success: Bool, reason: String)
@@ -974,8 +1506,7 @@ private enum ParsedInbound {
if array.count >= 3,
let subId = array[1] as? String,
let eventDict = array[2] as? [String: Any],
let event = try? NostrEvent(from: eventDict),
event.isValidSignature() {
let event = try? NostrEvent(from: eventDict) {
self = .event(subId: subId, event: event)
return
}
@@ -0,0 +1,214 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLEAnnounceHandler`.
///
/// All queue hops (collections barrier, BLE-queue link-state reads, main-actor
/// UI notification, delayed re-announce) live inside the closures supplied by
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
struct BLEAnnounceHandlerEnvironment {
/// Local peer identity at the time the announce is handled.
let localPeerID: () -> PeerID
/// TTL value used for direct (non-relayed) packets.
let messageTTL: UInt8
/// Current time source.
let now: () -> Date
/// Noise public key already recorded for the peer, if any (registry read).
let existingNoisePublicKey: (PeerID) -> Data?
/// Verifies the packet signature against the announced signing key.
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Direct link state for the peer (BLE-queue read).
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
/// Runs the registry mutation phase under the collections barrier.
let withRegistryBarrier: (() -> Void) -> Void
/// Upserts the verified announce into the peer registry.
/// Must only be called from inside `withRegistryBarrier`.
let upsertVerifiedAnnounce: (
_ peerID: PeerID,
_ announcement: AnnouncementPacket,
_ isConnected: Bool,
_ now: Date
) -> BLEPeerAnnounceUpdate
/// Debounced reconnect-log decision.
/// Must only be called from inside `withRegistryBarrier`.
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
/// Records verified direct-neighbor claims in the mesh topology.
let updateTopology: (_ peerID: PeerID, _ neighbors: [Data]) -> Void
/// Persists the announced cryptographic identity for offline verification.
let persistIdentity: (AnnouncementPacket) -> Void
/// Announce-back dedup check.
let dedupContains: (String) -> Bool
/// Announce-back dedup marking.
let dedupMarkProcessed: (String) -> Void
/// Delivers the announce UI events as one ordered main-actor hop:
/// `.peerConnected` (if flagged) initial gossip sync scheduling (if
/// flagged) peer-ID snapshot + data publish + `.peerListUpdated`.
/// A single closure keeps the original in-order delivery guarantee that
/// separate unstructured tasks would not provide.
let deliverAnnounceUIEvents: (
_ peerID: PeerID,
_ notifyPeerConnected: Bool,
_ scheduleInitialSync: Bool
) -> Void
/// Tracks the announce packet for gossip sync.
let trackPacketSeen: (BitchatPacket) -> Void
/// Reciprocates the announce for bidirectional discovery.
let sendAnnounceBack: () -> Void
/// Schedules a delayed re-announce (afterglow) after the given delay.
let scheduleAfterglow: (TimeInterval) -> Void
}
/// Orchestrates inbound announce packets: preflight validation, signature
/// trust, registry/topology updates, identity persistence, UI notification,
/// gossip tracking, and the reciprocal announce response.
final class BLEAnnounceHandler {
private let environment: BLEAnnounceHandlerEnvironment
init(environment: BLEAnnounceHandlerEnvironment) {
self.environment = environment
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
let now = env.now()
let preflight = BLEAnnouncePreflightPolicy.evaluate(
packet: packet,
from: peerID,
localPeerID: env.localPeerID(),
now: now
)
let announcement: AnnouncementPacket
switch preflight {
case .accept(let acceptance):
announcement = acceptance.announcement
case .reject(.malformed):
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))", category: .session)
return
case .reject(.senderMismatch(let derivedFromKey)):
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))", category: .security)
return
case .reject(.selfAnnounce):
return
case .reject(.stale(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return
}
// Suppress announce logs to reduce noise
// Precompute signature verification outside barrier to reduce contention
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
let hasSignature = packet.signature != nil
let signatureValid: Bool
if hasSignature {
signatureValid = env.verifySignature(packet, announcement.signingPublicKey)
if !signatureValid {
SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security)
}
} else {
signatureValid = false
}
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
hasSignature: hasSignature,
signatureValid: signatureValid,
existingNoisePublicKey: existingNoisePublicKey,
announcedNoisePublicKey: announcement.noisePublicKey
)
if case .reject(.keyMismatch) = trustDecision {
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
}
let verifiedAnnounce = trustDecision.isVerified
var isNewPeer = false
var isReconnectedPeer = false
let directLinkState = env.linkState(peerID)
let isDirectAnnounce = packet.ttl == env.messageTTL
env.withRegistryBarrier {
let hasPeripheralConnection = directLinkState.hasPeripheral
let hasCentralSubscription = directLinkState.hasCentral
// Require verified announce; ignore otherwise (no backward compatibility)
if !verifiedAnnounce {
SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))", category: .security)
// Reset flags to prevent post-barrier code from acting on unverified announces
isNewPeer = false
isReconnectedPeer = false
return
}
let update = env.upsertVerifiedAnnounce(
peerID,
announcement,
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
now
)
isNewPeer = update.isNewPeer
isReconnectedPeer = update.wasDisconnected
// Log connection status only for direct connectivity changes; debounce to reduce spam
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
let now = env.now()
if update.isNewPeer {
SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session)
} else if update.wasDisconnected {
if env.shouldEmitReconnectLog(peerID, now) {
SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
}
} else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname {
SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session)
}
}
}
// Update topology with verified neighbor claims (only for authenticated announces)
if verifiedAnnounce, let neighbors = announcement.directNeighbors {
env.updateTopology(peerID, neighbors)
}
// Persist cryptographic identity and signing key for robust offline
// verification only for verified announces. Persisting unverified
// announces would let an attacker who replays a victim's noisePublicKey
// overwrite the victim's stored signing key/nickname (identity poisoning).
if verifiedAnnounce {
env.persistIdentity(announcement)
}
let announceBackID = "announce-back-\(peerID)"
let shouldSendBack = !env.dedupContains(announceBackID)
if shouldSendBack {
env.dedupMarkProcessed(announceBackID)
}
let responsePlan = BLEAnnounceResponsePolicy.plan(
isDirectAnnounce: isDirectAnnounce,
isNewPeer: isNewPeer,
isReconnectedPeer: isReconnectedPeer,
shouldSendAnnounceBack: shouldSendBack
)
// Only notify of connection for new or reconnected peers when it is a
// direct announce; the list update always follows in the same hop.
env.deliverAnnounceUIEvents(
peerID,
responsePlan.shouldNotifyPeerConnected,
responsePlan.shouldNotifyPeerConnected && responsePlan.shouldScheduleInitialSync
)
// Track for sync (include our own and others' announces)
env.trackPacketSeen(packet)
if responsePlan.shouldSendAnnounceBack {
// Reciprocate announce for bidirectional discovery
// Force send to ensure the peer receives our announce
env.sendAnnounceBack()
}
// Afterglow: on first-seen peers, schedule a short re-announce to push presence one more hop
if responsePlan.shouldScheduleAfterglow {
let delay = Double.random(in: 0.3...0.6)
env.scheduleAfterglow(delay)
}
}
}
@@ -41,13 +41,16 @@ final class BLEConnectionScheduler<Peripheral> {
private let candidateCap: Int
private let weakLinkCooldownSeconds: TimeInterval
private let weakLinkRSSICutoff: Int
private let recentTimeoutWindowSeconds: TimeInterval
private let recentTimeoutCountThreshold: Int
private var lastGlobalConnectAttempt: Date = .distantPast
private var candidates: [BLEConnectionCandidate<Peripheral>] = []
private var failureCounts: [String: Int] = [:]
private var recentConnectTimeouts: [String: Date] = [:]
// Tracked separately from connect timeouts: a peer we held a connection
// with and lost (walked out of range) usually comes back, so it only gets
// a brief rediscovery ignore not the timeout backoff/cooldown treatment
// reserved for peers that never answered a connect attempt.
private var recentDisconnects: [String: Date] = [:]
private var lastIsolatedAt: Date?
private let initialDynamicRSSIThreshold: Int
@@ -63,8 +66,6 @@ final class BLEConnectionScheduler<Peripheral> {
candidateCap: Int = TransportConfig.bleConnectionCandidatesMax,
weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds,
weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff,
recentTimeoutWindowSeconds: TimeInterval = TransportConfig.bleRecentTimeoutWindowSeconds,
recentTimeoutCountThreshold: Int = TransportConfig.bleRecentTimeoutCountThreshold,
dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
) {
self.maxCentralLinks = maxCentralLinks
@@ -72,8 +73,6 @@ final class BLEConnectionScheduler<Peripheral> {
self.candidateCap = candidateCap
self.weakLinkCooldownSeconds = weakLinkCooldownSeconds
self.weakLinkRSSICutoff = weakLinkRSSICutoff
self.recentTimeoutWindowSeconds = recentTimeoutWindowSeconds
self.recentTimeoutCountThreshold = recentTimeoutCountThreshold
self.initialDynamicRSSIThreshold = dynamicRSSIThreshold
self.dynamicRSSIThreshold = dynamicRSSIThreshold
}
@@ -114,7 +113,12 @@ final class BLEConnectionScheduler<Peripheral> {
}
if let lastTimeout = recentConnectTimeouts[candidate.peripheralID],
now.timeIntervalSince(lastTimeout) < 15 {
now.timeIntervalSince(lastTimeout) < TransportConfig.bleTimeoutDiscoveryIgnoreSeconds {
return .ignore
}
if let lastDisconnect = recentDisconnects[candidate.peripheralID],
now.timeIntervalSince(lastDisconnect) < TransportConfig.bleDisconnectDiscoveryIgnoreSeconds {
return .ignore
}
@@ -163,6 +167,11 @@ final class BLEConnectionScheduler<Peripheral> {
return .retryAfter(delay)
}
if let delay = disconnectSettleDelay(for: candidate, now: now) {
enqueue(candidate)
return .retryAfter(delay)
}
if isAlreadyConnectingOrConnected(candidate.peripheralID) {
continue
}
@@ -180,6 +189,7 @@ final class BLEConnectionScheduler<Peripheral> {
func recordConnectionSuccess(peripheralID: String) {
failureCounts[peripheralID] = 0
recentConnectTimeouts.removeValue(forKey: peripheralID)
recentDisconnects.removeValue(forKey: peripheralID)
}
func recordConnectionFailure(peripheralID: String) {
@@ -187,7 +197,7 @@ final class BLEConnectionScheduler<Peripheral> {
}
func recordDisconnectError(peripheralID: String, at now: Date) {
recentConnectTimeouts[peripheralID] = now
recentDisconnects[peripheralID] = now
}
func recordConnectionTimeout(peripheralID: String, at now: Date) {
@@ -197,6 +207,7 @@ final class BLEConnectionScheduler<Peripheral> {
func pruneConnectionTimeouts(before cutoff: Date) {
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff }
recentDisconnects = recentDisconnects.filter { $0.value >= cutoff }
}
func reset() {
@@ -204,6 +215,7 @@ final class BLEConnectionScheduler<Peripheral> {
candidates.removeAll()
failureCounts.removeAll()
recentConnectTimeouts.removeAll()
recentDisconnects.removeAll()
lastIsolatedAt = nil
dynamicRSSIThreshold = initialDynamicRSSIThreshold
}
@@ -225,18 +237,14 @@ final class BLEConnectionScheduler<Peripheral> {
}
lastIsolatedAt = nil
// Flaky links are handled per-peripheral (weak-link cooldown, discovery
// ignore window, score bias) never globally, so one flaky distant peer
// can't blind us to every other edge-of-range peer.
var threshold = TransportConfig.bleDynamicRSSIThresholdDefault
if connectedOrConnectingLinkCount >= maxCentralLinks || candidates.count >= candidateCap {
threshold = TransportConfig.bleRSSIConnectedThreshold
}
let recentTimeouts = recentConnectTimeouts.filter {
now.timeIntervalSince($0.value) < recentTimeoutWindowSeconds
}.count
if recentTimeouts >= recentTimeoutCountThreshold {
threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold)
}
dynamicRSSIThreshold = threshold
return threshold
}
@@ -258,6 +266,20 @@ final class BLEConnectionScheduler<Peripheral> {
return min(max(2.0, remaining), 15.0)
}
// The disconnect settle window must hold on the queue path too: a stale
// candidate enqueued while the peripheral was still connected would
// otherwise reconnect immediately via the post-disconnect queue drain,
// bypassing the window and recreating reconnect/cancel thrash.
private func disconnectSettleDelay(
for candidate: BLEConnectionCandidate<Peripheral>,
now: Date
) -> TimeInterval? {
guard let lastDisconnect = recentDisconnects[candidate.peripheralID] else { return nil }
let remaining = TransportConfig.bleDisconnectDiscoveryIgnoreSeconds - now.timeIntervalSince(lastDisconnect)
guard remaining > 0 else { return nil }
return remaining + 0.05
}
private func score(_ candidate: BLEConnectionCandidate<Peripheral>, now: Date) -> Int {
let failures = failureCounts[candidate.peripheralID] ?? 0
let penalty = min(20, 1 << min(4, failures))
+48 -5
View File
@@ -13,15 +13,21 @@ enum BLEFanoutSelector {
centralIDs: [String],
ingressLink: BLEIngressLinkID?,
excludedLinks: Set<BLEIngressLinkID> = [],
peripheralPeerBindings: [String: PeerID] = [:],
centralPeerBindings: [String: PeerID] = [:],
directedPeerHint: PeerID?,
packetType: UInt8,
messageID: String
) -> BLEFanoutSelection {
let allowed = allowedLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
let allowed = collapseDuplicateLinksPerPeer(
allowedLinks(
peripheralIDs: peripheralIDs,
centralIDs: centralIDs,
ingressLink: ingressLink,
excludedLinks: excludedLinks
),
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings
)
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
@@ -65,6 +71,43 @@ enum BLEFanoutSelector {
return (allowedPeripheralIDs, allowedCentralIDs)
}
// Dual-role pairs hold two live links (we-as-central writing to their
// peripheral, and they-as-central subscribed to ours). Sending the same
// packet down both doubles airtime for nothing the receiver's assembler
// and deduplicator just discard the copy. Keep one link per bound peer,
// preferring the peripheral (write) side: it has per-link flow control
// via canSendWriteWithoutResponse, while notifications share the
// peripheral manager's update queue across all centrals. Links with no
// bound peer yet (pre-announce) pass through untouched.
private static func collapseDuplicateLinksPerPeer(
_ links: (peripheralIDs: [String], centralIDs: [String]),
peripheralPeerBindings: [String: PeerID],
centralPeerBindings: [String: PeerID]
) -> (peripheralIDs: [String], centralIDs: [String]) {
guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else {
return links
}
var seenPeers = Set<PeerID>()
var keptPeripheralIDs: [String] = []
for id in links.peripheralIDs {
if let peer = peripheralPeerBindings[id], !seenPeers.insert(peer).inserted {
continue
}
keptPeripheralIDs.append(id)
}
var keptCentralIDs: [String] = []
for id in links.centralIDs {
if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted {
continue
}
keptCentralIDs.append(id)
}
return (keptPeripheralIDs, keptCentralIDs)
}
private static func shouldSubset(packetType: UInt8, directedPeerHint: PeerID?) -> Bool {
directedPeerHint == nil
&& packetType != MessageType.fragment.rawValue
@@ -0,0 +1,123 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLEFileTransferHandler`.
///
/// All queue hops (collections registry reads/writes, main-actor UI
/// notification) live inside the closures supplied by `BLEService`, keeping
/// the handler queue-agnostic and synchronously testable.
struct BLEFileTransferHandlerEnvironment {
/// Local peer identity at the time the transfer is handled.
let localPeerID: () -> PeerID
/// Local nickname used for sender resolution and collision checks.
let localNickname: () -> String
/// Snapshot of known peers keyed by ID (registry read).
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
/// Resolves a display name from a verified packet signature for peers missing from the registry.
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
/// Tracks the broadcast file packet for gossip sync.
let trackPacketSeen: (BitchatPacket) -> Void
/// Enforces the incoming-media storage quota before saving (BCH-01-002).
let enforceStorageQuota: (_ reservingBytes: Int) -> Void
/// Persists the validated file to the incoming-media store; returns the destination URL.
let saveIncomingFile: (
_ data: Data,
_ preferredName: String?,
_ subdirectory: String,
_ fallbackExtension: String?,
_ defaultPrefix: String
) -> URL?
/// Updates the registry last-seen timestamp for the peer (async barrier write).
let updatePeerLastSeen: (PeerID) -> Void
/// Delivers `.messageReceived` to the UI as one main-actor hop.
let deliverMessage: (BitchatMessage) -> Void
}
/// Orchestrates inbound file transfers: self-echo policy, sender display-name
/// resolution, delivery planning, payload validation, quota-checked storage,
/// and UI delivery.
final class BLEFileTransferHandler {
private let environment: BLEFileTransferHandlerEnvironment
init(environment: BLEFileTransferHandlerEnvironment) {
self.environment = environment
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
if BLEFileTransferPolicy.isSelfEcho(packet: packet, from: peerID, localPeerID: env.localPeerID()) { return }
let peersSnapshot = env.peersSnapshot()
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peersSnapshot,
allowConnectedUnverified: true
) ?? env.signedSenderDisplayName(packet, peerID) else {
SecureLogger.warning("🚫 Dropping file transfer from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
guard let deliveryPlan = BLEFileTransferPolicy.deliveryPlan(packet: packet, localPeerID: env.localPeerID()) else {
return
}
if deliveryPlan.shouldTrackForSync {
env.trackPacketSeen(packet)
}
let filePacket: BitchatFilePacket
let mime: MimeType
switch BLEIncomingFileValidator.validate(payload: packet.payload) {
case .success(let acceptance):
filePacket = acceptance.filePacket
mime = acceptance.mime
case .failure(.malformedPayload):
SecureLogger.error("❌ Failed to decode file transfer payload", category: .session)
return
case .failure(.payloadTooLarge(let bytes)):
SecureLogger.warning("🚫 Dropping file transfer exceeding size cap (\(bytes) bytes)", category: .security)
return
case .failure(.unsupportedMime(let mimeType, let bytes)):
SecureLogger.warning("🚫 MIME REJECT: '\(mimeType ?? "<empty>")' not supported. Size=\(bytes)b from \(peerID.id.prefix(8))...", category: .security)
return
case .failure(.magicMismatch(let mime, let bytes, let prefixHex)):
SecureLogger.warning("🚫 MAGIC REJECT: MIME='\(mime)' size=\(bytes)b prefix=[\(prefixHex)] from \(peerID.id.prefix(8))...", category: .security)
return
}
// BCH-01-002: Enforce storage quota before saving
env.enforceStorageQuota(filePacket.content.count)
guard let destination = env.saveIncomingFile(
filePacket.content,
filePacket.fileName,
"\(mime.category.mediaDir)/incoming",
mime.defaultExtension,
mime.category.rawValue
) else {
return
}
if deliveryPlan.isPrivateMessage {
env.updatePeerLastSeen(peerID)
}
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
let message = BitchatMessage(
sender: senderNickname,
content: "\(mime.category.messagePrefix)\(destination.lastPathComponent)",
timestamp: ts,
isRelay: false,
originalSender: nil,
isPrivate: deliveryPlan.isPrivateMessage,
recipientNickname: nil,
senderPeerID: peerID
)
SecureLogger.debug("📁 Stored incoming media from \(peerID.id.prefix(8))… -> \(destination.lastPathComponent)", category: .session)
env.deliverMessage(message)
}
}
@@ -0,0 +1,94 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLEFragmentHandler`.
///
/// All queue hops (the message-queue entry hop and the collections barrier
/// around the assembly buffer) live on the `BLEService` side the entry hop
/// in `BLEService.handleFragment`, the barrier inside the supplied closures
/// keeping the handler queue-agnostic and synchronously testable.
struct BLEFragmentHandlerEnvironment {
/// Local peer identity at the time the fragment is handled.
let localPeerID: () -> PeerID
/// Tracks broadcast fragments for gossip sync.
let trackPacketSeen: (BitchatPacket) -> Void
/// Appends the fragment to the assembly buffer (collections barrier write).
let appendFragment: (BLEFragmentHeader) -> BLEFragmentAssemblyBuffer.AppendResult
/// Ingress acceptance check for the reassembled inner packet.
let isAcceptedIngressPayload: (_ packet: BitchatPacket, _ innerSender: PeerID) -> Bool
/// Re-enters the receive pipeline with the reassembled packet (TTL already zeroed).
let processReassembledPacket: (_ packet: BitchatPacket, _ from: PeerID) -> Void
}
/// Orchestrates inbound fragments: self-fragment suppression, gossip tracking,
/// assembly-buffer appends, and reassembled-packet validation and re-injection
/// into the receive pipeline.
final class BLEFragmentHandler {
private let environment: BLEFragmentHandlerEnvironment
init(environment: BLEFragmentHandlerEnvironment) {
self.environment = environment
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
// Don't process our own fragments
if peerID == env.localPeerID() {
return
}
guard let header = BLEFragmentHeader(packet: packet) else { return }
if header.isBroadcastFragment {
env.trackPacketSeen(packet)
}
let assemblyResult = env.appendFragment(header)
logFragmentAssemblyResult(assemblyResult)
guard case let .complete(completedHeader, reassembled, _) = assemblyResult else { return }
// Decode the original packet bytes we reassembled, so flags/compression are preserved
if var originalPacket = BinaryProtocol.decode(reassembled) {
// Reassembled packet validation
let innerSender = PeerID(hexData: originalPacket.senderID)
if !env.isAcceptedIngressPayload(originalPacket, innerSender) {
// Cleanup below
} else {
SecureLogger.debug("✅ Reassembled packet id=\(completedHeader.idLogString) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
originalPacket.ttl = 0
env.processReassembledPacket(originalPacket, peerID)
}
} else {
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(completedHeader.originalType), total=\(completedHeader.total))", category: .session)
}
}
private func logFragmentAssemblyResult(_ result: BLEFragmentAssemblyBuffer.AppendResult) {
func logStartedIfNeeded(header: BLEFragmentHeader, started: Bool) {
if started {
SecureLogger.debug("📦 Started fragment assembly id=\(header.idLogString) total=\(header.total)", category: .session)
}
}
switch result {
case let .stored(header, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
case let .complete(header, _, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.debug("📦 Fragment \(header.index + 1)/\(header.total) (len=\(header.fragmentData.count)) for id=\(header.idLogString)", category: .session)
case let .oversized(header, projectedSize, limit, started):
logStartedIfNeeded(header: header, started: started)
SecureLogger.warning(
"🚫 Fragment assembly exceeds size limit (\(projectedSize) bytes > \(limit)), evicting. Type=\(header.originalType) Index=\(header.index)/\(header.total)",
category: .security
)
}
}
}
+55 -8
View File
@@ -26,36 +26,69 @@ struct BLESubscribedCentralSnapshot {
}
}
/// Owns all BLE link state (peripheral connections we hold as central, and
/// central subscriptions we serve as peripheral). The store has no internal
/// locking: every access must happen on the single owning queue (the BLE
/// queue). Other queues must go through BLEService's `readLinkState`, which
/// hops to that queue. Call `assumeOwnership(of:)` to have debug builds trap
/// any access from the wrong queue.
final class BLELinkStateStore {
private(set) var peripherals: [String: BLEPeripheralLinkState] = [:]
private(set) var peerToPeripheralUUID: [PeerID: String] = [:]
private(set) var subscribedCentrals: [CBCentral] = []
private(set) var centralToPeerID: [String: PeerID] = [:]
#if DEBUG
private var ownerQueue: DispatchQueue?
#endif
/// Pin the store to its owning queue. Debug-only enforcement; release
/// builds are unchanged.
func assumeOwnership(of queue: DispatchQueue) {
#if DEBUG
ownerQueue = queue
#endif
}
@inline(__always)
private func assertOwned() {
#if DEBUG
if let queue = ownerQueue {
dispatchPrecondition(condition: .onQueue(queue))
}
#endif
}
var peripheralStates: [BLEPeripheralLinkState] {
Array(peripherals.values)
assertOwned()
return Array(peripherals.values)
}
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
BLESubscribedCentralSnapshot(
assertOwned()
return BLESubscribedCentralSnapshot(
centrals: subscribedCentrals,
peerIDsByCentralUUID: centralToPeerID
)
}
var subscribedCentralCount: Int {
subscribedCentrals.count
assertOwned()
return subscribedCentrals.count
}
var connectedOrConnectingPeripheralCount: Int {
peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
assertOwned()
return peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
}
func state(forPeripheralID peripheralID: String) -> BLEPeripheralLinkState? {
peripherals[peripheralID]
assertOwned()
return peripherals[peripheralID]
}
func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) {
assertOwned()
peripherals[peripheralID] = state
}
@@ -64,6 +97,7 @@ final class BLELinkStateStore {
_ peripheralID: String,
_ update: (inout BLEPeripheralLinkState) -> Void
) -> BLEPeripheralLinkState? {
assertOwned()
guard var state = peripherals[peripheralID] else { return nil }
update(&state)
peripherals[peripheralID] = state
@@ -113,10 +147,12 @@ final class BLELinkStateStore {
}
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
assertOwned()
return peerToPeripheralUUID[peerID].flatMap { peripherals[$0] }
}
func directLinkState(for peerID: PeerID) -> BLEDirectLinkState {
assertOwned()
let peripheralUUID = peerToPeripheralUUID[peerID]
let hasPeripheral = peripheralUUID.flatMap { peripherals[$0]?.isConnected } ?? false
let hasCentral = centralToPeerID.values.contains(peerID)
@@ -124,6 +160,7 @@ final class BLELinkStateStore {
}
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
assertOwned()
guard let peerID else { return [] }
var links: Set<BLEIngressLinkID> = []
@@ -137,35 +174,42 @@ final class BLELinkStateStore {
}
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
peripherals[peripheralID]?.peerID
assertOwned()
return peripherals[peripheralID]?.peerID
}
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
centralToPeerID[centralUUID]
assertOwned()
return centralToPeerID[centralUUID]
}
func addSubscribedCentral(_ central: CBCentral) {
assertOwned()
guard !subscribedCentrals.contains(central) else { return }
subscribedCentrals.append(central)
}
func removeSubscribedCentral(_ central: CBCentral) -> PeerID? {
assertOwned()
let centralUUID = central.identifier.uuidString
subscribedCentrals.removeAll { $0.identifier == central.identifier }
return centralToPeerID.removeValue(forKey: centralUUID)
}
func bindCentral(_ centralUUID: String, to peerID: PeerID) {
assertOwned()
centralToPeerID[centralUUID] = peerID
}
func bindPeripheral(_ peripheralUUID: String, to peerID: PeerID) {
assertOwned()
if updatePeripheral(peripheralUUID, { $0.peerID = peerID }) != nil {
peerToPeripheralUUID[peerID] = peripheralUUID
}
}
func removePeripheral(_ peripheralID: String) -> PeerID? {
assertOwned()
let peerID = peripherals.removeValue(forKey: peripheralID)?.peerID
if let peerID {
peerToPeripheralUUID.removeValue(forKey: peerID)
@@ -174,6 +218,7 @@ final class BLELinkStateStore {
}
func clearPeripherals() -> [PeerID] {
assertOwned()
let peerIDs = peripherals.compactMap { $0.value.peerID }
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
@@ -181,6 +226,7 @@ final class BLELinkStateStore {
}
func clearCentrals() -> [PeerID] {
assertOwned()
let peerIDs = Array(centralToPeerID.values)
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
@@ -188,6 +234,7 @@ final class BLELinkStateStore {
}
func clearAll() {
assertOwned()
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll()
@@ -0,0 +1,132 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLENoisePacketHandler`.
///
/// All queue hops (collections barrier writes, main-actor UI notification)
/// and every `noiseService.*` crypto call live inside the closures supplied by
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
struct BLENoisePacketHandlerEnvironment {
/// Local peer identity at the time the packet is handled.
let localPeerID: () -> PeerID
/// Local peer ID bytes used as the sender of handshake responses.
let localPeerIDData: () -> Data
/// TTL value used for direct (non-relayed) packets.
let messageTTL: UInt8
/// Current time source.
let now: () -> Date
/// Processes an inbound handshake message, returning an optional response payload (crypto).
let processHandshakeMessage: (_ peerID: PeerID, _ message: Data) throws -> Data?
/// Whether any Noise session (established or pending) exists for the peer (crypto).
let hasNoiseSession: (PeerID) -> Bool
/// Initiates a fresh Noise handshake with the peer (crypto + send).
let initiateHandshake: (PeerID) -> Void
/// Broadcasts a packet on the mesh (caller is already on the message queue).
let broadcastPacket: (BitchatPacket) -> Void
/// Updates the registry last-seen timestamp for the peer (async barrier write).
let updatePeerLastSeen: (PeerID) -> Void
/// Decrypts an encrypted payload from the peer (crypto).
let decrypt: (_ payload: Data, _ peerID: PeerID) throws -> Data
/// Clears the peer's Noise session after an unrecoverable decrypt failure (crypto).
let clearSession: (PeerID) -> Void
/// Delivers `.noisePayloadReceived` to the UI as one main-actor hop.
let deliverNoisePayload: (
_ peerID: PeerID,
_ type: NoisePayloadType,
_ payload: Data,
_ timestamp: Date
) -> Void
}
/// Orchestrates the Noise session domain for inbound packets: handshake
/// processing (with response), encrypted payload decryption and dispatch,
/// and session recovery on decrypt failure.
final class BLENoisePacketHandler {
private let environment: BLENoisePacketHandlerEnvironment
init(environment: BLENoisePacketHandlerEnvironment) {
self.environment = environment
}
func handleHandshake(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
// Use NoiseEncryptionService for handshake processing
if PeerID(hexData: packet.recipientID) == env.localPeerID() {
// Handshake is for us
do {
if let response = try env.processHandshakeMessage(peerID, packet.payload) {
// Send response
let responsePacket = BitchatPacket(
type: MessageType.noiseHandshake.rawValue,
senderID: env.localPeerIDData(),
recipientID: Data(hexString: peerID.id),
timestamp: UInt64(env.now().timeIntervalSince1970 * 1000),
payload: response,
signature: nil,
ttl: env.messageTTL
)
// We're on messageQueue from delegate callback
env.broadcastPacket(responsePacket)
}
// Session establishment will trigger onPeerAuthenticated callback
// which will send any pending messages at the right time
} catch {
SecureLogger.error("Failed to process handshake: \(error)")
// Try initiating a new handshake
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
}
}
}
func handleEncrypted(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
guard let recipientID = PeerID(hexData: packet.recipientID) else {
SecureLogger.warning("⚠️ Encrypted message has no recipient ID", category: .session)
return
}
if recipientID != env.localPeerID() {
SecureLogger.debug("🔐 Encrypted message not for me (for \(recipientID.id.prefix(8))…, I am \(env.localPeerID().id.prefix(8))…)", category: .session)
return
}
// Update lastSeen for the peer we received from (important for private messages)
env.updatePeerLastSeen(peerID)
do {
let decrypted = try env.decrypt(packet.payload, peerID)
guard decrypted.count > 0 else { return }
// First byte indicates the payload type
let payloadType = decrypted[0]
let payloadData = decrypted.dropFirst()
guard let noisePayloadType = NoisePayloadType(rawValue: payloadType) else {
SecureLogger.warning("⚠️ Unknown noise payload type: \(payloadType)")
return
}
SecureLogger.debug("🔐 Decrypted noise payload type \(noisePayloadType.description) from \(peerID.id.prefix(8))", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
env.deliverNoisePayload(peerID, noisePayloadType, Data(payloadData), ts)
} catch NoiseEncryptionError.sessionNotEstablished {
// We received an encrypted message before establishing a session with this peer.
// Trigger a handshake so future messages can be decrypted.
SecureLogger.debug("🔑 Encrypted message from \(peerID.id.prefix(8))… without session; initiating handshake")
if !env.hasNoiseSession(peerID) {
env.initiateHandshake(peerID)
}
} catch {
// Decryption failed - clear the corrupted session and re-initiate handshake
// This handles cases where session state got out of sync (nonce mismatch, etc.)
SecureLogger.error("❌ Failed to decrypt message from \(peerID.id.prefix(8))…: \(error) - clearing session and re-initiating handshake")
env.clearSession(peerID)
env.initiateHandshake(peerID)
}
}
}
@@ -18,6 +18,8 @@ enum BLEOutboundLinkPlanner {
centralNotifyLimits: [Int],
ingressRecord: BLEIngressLinkRecord?,
excludedLinks: Set<BLEIngressLinkID>,
peripheralPeerBindings: [String: PeerID] = [:],
centralPeerBindings: [String: PeerID] = [:],
directedOnlyPeer: PeerID?
) -> BLEOutboundLinkPlan {
if let minLimit = minimumLinkLimit(
@@ -39,6 +41,8 @@ enum BLEOutboundLinkPlanner {
centralIDs: centralIDs,
ingressLink: ingressRecord?.link,
excludedLinks: excludedLinks,
peripheralPeerBindings: peripheralPeerBindings,
centralPeerBindings: centralPeerBindings,
directedPeerHint: directedPeerHint,
packetType: packet.type,
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
@@ -0,0 +1,131 @@
import BitFoundation
import BitLogger
import Foundation
/// Narrow environment for `BLEPublicMessageHandler`.
///
/// All queue hops (collections registry reads, BLE-queue link-state reads,
/// main-actor UI notification) live inside the closures supplied by
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
struct BLEPublicMessageHandlerEnvironment {
/// Local peer identity at the time the message is handled.
let localPeerID: () -> PeerID
/// Local nickname used for sender resolution and collision checks.
let localNickname: () -> String
/// Current time source.
let now: () -> Date
/// Snapshot of known peers keyed by ID (registry read).
let peersSnapshot: () -> [PeerID: BLEPeerInfo]
/// Verifies a packet's signature against a known signing public key.
let verifyPacketSignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
/// Resolves a display name from a verified packet signature for peers missing from the registry.
let signedSenderDisplayName: (_ packet: BitchatPacket, _ peerID: PeerID) -> String?
/// Tracks the broadcast message packet for gossip sync.
let trackPacketSeen: (BitchatPacket) -> Void
/// Direct link state for the peer (BLE-queue read).
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
/// Resolves and consumes the original message ID for our own re-broadcast.
let takeSelfBroadcastMessageID: (BitchatPacket) -> String?
/// Delivers `.publicMessageReceived` to the UI as one main-actor hop.
let deliverPublicMessage: (
_ peerID: PeerID,
_ nickname: String,
_ content: String,
_ timestamp: Date,
_ messageID: String?
) -> Void
}
/// Orchestrates inbound public (broadcast) messages: freshness/self-echo
/// policy, sender display-name resolution, gossip tracking, payload decoding,
/// and UI delivery.
final class BLEPublicMessageHandler {
private let environment: BLEPublicMessageHandlerEnvironment
init(environment: BLEPublicMessageHandlerEnvironment) {
self.environment = environment
}
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
let env = environment
let now = env.now()
let messageDecision = BLEPublicMessagePolicy.evaluate(
packet: packet,
from: peerID,
localPeerID: env.localPeerID(),
now: now
)
let messagePolicy: BLEPublicMessageAcceptance
switch messageDecision {
case .accept(let acceptance):
messagePolicy = acceptance
case .reject(.selfEcho):
return
case .reject(.staleBroadcast(let ageSeconds)):
SecureLogger.debug("⏰ Ignoring stale broadcast message from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
return
}
// Snapshot peers to avoid concurrent mutation while iterating during nickname collision checks.
let peersSnapshot = env.peersSnapshot()
// Public messages are always signed by their sender. `senderID` is
// attacker-controlled, so registry membership alone is NOT proof of
// identity a peer in the registry as "verified" could be impersonated
// by anyone spoofing their senderID. Require a valid packet signature
// from the claimed sender (our own echoes are exempt; they are matched
// by self-broadcast tracking below).
//
// Verify against the signing key already in the (synchronously-updated)
// peer registry first: identity-cache persistence is asynchronous, so a
// message arriving right after a verified announce would otherwise be
// dropped because `signedSenderDisplayName` only searches the persisted
// cache. Fall back to that persisted-identity lookup for peers not (yet)
// in the registry.
let isSelf = peerID == env.localPeerID()
let registrySigningKey = peersSnapshot[peerID]?.signingPublicKey
let verifiedViaRegistry = !isSelf
&& (registrySigningKey.map { env.verifyPacketSignature(packet, $0) } ?? false)
let signedDisplayName = (isSelf || verifiedViaRegistry) ? nil : env.signedSenderDisplayName(packet, peerID)
guard isSelf || verifiedViaRegistry || signedDisplayName != nil else {
SecureLogger.warning("🚫 Dropping public message with missing/invalid signature for claimed sender \(peerID.id.prefix(8))", category: .security)
return
}
// Authenticity is established; prefer the registry's collision-resolved
// display name, then the signature-derived name.
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
peerID: peerID,
localPeerID: env.localPeerID(),
localNickname: env.localNickname(),
peers: peersSnapshot,
allowConnectedUnverified: false
) ?? signedDisplayName else {
SecureLogger.warning("🚫 Dropping public message from unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
if messagePolicy.shouldTrackForSync {
env.trackPacketSeen(packet)
}
guard let content = String(data: packet.payload, encoding: .utf8) else {
SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
return
}
// Determine if we have a direct link to the sender
let directLink = env.linkState(peerID)
let hasDirectLink = directLink.hasPeripheral || directLink.hasCentral
let pathTag = hasDirectLink ? "direct" : "mesh"
SecureLogger.debug("💬 [\(senderNickname)] TTL:\(packet.ttl) (\(pathTag)) chars=\(content.count) bytes=\(packet.payload.count)", category: .session)
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
var resolvedSelfMessageID: String? = nil
if peerID == env.localPeerID() {
resolvedSelfMessageID = env.takeSelfBroadcastMessageID(packet)
}
env.deliverPublicMessage(peerID, senderNickname, content, ts, resolvedSelfMessageID)
}
}
@@ -12,7 +12,13 @@ struct BLEReceivedPacketContext: Equatable {
struct BLEReceivePipeline {
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
let senderID = PeerID(hexData: packet.senderID)
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
// Include a payload digest so that distinct packets sharing the same
// sender/timestamp(ms)/type are not collapsed as duplicates. The
// post-handshake flush sends queued messages, delivery and read receipts
// back-to-back within a single millisecond; without the digest every
// packet after the first would be silently dropped.
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
let messageType = MessageType(rawValue: packet.type)
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -31,7 +31,6 @@ protocol CommandContextProvider: AnyObject {
var activeChannel: ChannelID { get }
var selectedPrivateChatPeer: PeerID? { get }
var blockedUsers: Set<String> { get }
var privateChats: [PeerID: [BitchatMessage]] { get set }
var idBridge: NostrIdentityBridge { get }
// MARK: - Peer Lookup
@@ -43,6 +42,8 @@ protocol CommandContextProvider: AnyObject {
func startPrivateChat(with peerID: PeerID)
func sendPrivateMessage(_ content: String, to peerID: PeerID)
func clearCurrentPublicTimeline()
/// Empties the peer's chat (single-writer store intent for `/clear`).
func clearPrivateChat(_ peerID: PeerID)
func sendPublicRaw(_ content: String)
// MARK: - System Messages
@@ -160,7 +161,7 @@ final class CommandProcessor {
private func handleClear() -> CommandResult {
if let peerID = contextProvider?.selectedPrivateChatPeer {
contextProvider?.privateChats[peerID]?.removeAll()
contextProvider?.clearPrivateChat(peerID)
} else {
contextProvider?.clearCurrentPublicTimeline()
}
@@ -34,7 +34,23 @@ final class FavoritesPersistenceService: ObservableObject {
static let shared = FavoritesPersistenceService()
init(keychain: KeychainManagerProtocol = KeychainManager()) {
/// Default keychain for the `shared` singleton. Under test this is an
/// in-memory keychain so touching `shared` never blocks on securityd
/// (`SecItemCopyMatching` can hang in test environments) and never reads
/// or writes the developer's real keychain. Production behavior is
/// unchanged. Tests that need their own instance keep injecting a mock
/// via `init(keychain:)`.
private nonisolated static func makeDefaultKeychain() -> KeychainManagerProtocol {
// PreviewKeychainManager lives in _PreviewHelpers, a development
// asset excluded from archive builds release code must not
// reference it. Tests always run Debug, so the guard is lossless.
#if DEBUG
if TestEnvironment.isRunningTests { return PreviewKeychainManager() }
#endif
return KeychainManager()
}
init(keychain: KeychainManagerProtocol = FavoritesPersistenceService.makeDefaultKeychain()) {
self.keychain = keychain
loadFavorites()
+19 -2
View File
@@ -1,4 +1,5 @@
import BitLogger
import Combine
import Foundation
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
@@ -14,7 +15,9 @@ struct LocationNotesDependencies {
var sendEvent: SendEvent
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
var now: () -> Date
// Fires when the geo relay directory refreshes; used to retry after "no relays".
var relayDirectoryUpdates: AnyPublisher<Void, Never> = Empty(completeImmediately: false).eraseToAnyPublisher()
private static let idBridge = NostrIdentityBridge()
static let live = LocationNotesDependencies(
@@ -39,7 +42,11 @@ struct LocationNotesDependencies {
deriveIdentity: { geohash in
try idBridge.deriveIdentity(forGeohash: geohash)
},
now: { Date() }
now: { Date() },
relayDirectoryUpdates: NotificationCenter.default
.publisher(for: .geoRelayDirectoryDidRefresh)
.map { _ in () }
.eraseToAnyPublisher()
)
}
@@ -77,6 +84,7 @@ final class LocationNotesManager: ObservableObject {
@Published private(set) var errorMessage: String?
private var subscriptionID: String?
private var noteIDs = Set<String>() // O(1) duplicate detection
private var directoryUpdateCancellable: AnyCancellable?
private let dependencies: LocationNotesDependencies
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
@@ -101,6 +109,15 @@ final class LocationNotesManager: ObservableObject {
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
}
subscribe()
// The relay directory may load after init (remote fetch over Tor);
// retry automatically instead of staying stuck on "no relays".
directoryUpdateCancellable = dependencies.relayDirectoryUpdates
.sink { [weak self] in
Task { @MainActor [weak self] in
guard let self, self.state == .noRelays else { return }
self.subscribe()
}
}
}
func setGeohash(_ newGeohash: String) {
@@ -594,6 +594,22 @@ final class LocationStateManager: NSObject, CLLocationManagerDelegate, Observabl
}
}
/// Removes all persisted location state and resets the in-memory view.
/// Used by the panic wipe selected channel, teleport set and bookmarks
/// (which reveal where the user has been) must not survive on device.
func panicWipe() {
storage.removeObject(forKey: selectedChannelKey)
storage.removeObject(forKey: teleportedStoreKey)
storage.removeObject(forKey: bookmarksKey)
storage.removeObject(forKey: bookmarkNamesKey)
teleportedSet.removeAll()
bookmarkMembership.removeAll()
bookmarks = []
bookmarkNames = [:]
teleported = false
selectedChannel = .mesh
}
private static func normalizeGeohash(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s
+84 -20
View File
@@ -6,6 +6,13 @@ import Foundation
@MainActor
final class MessageRouter {
private let transports: [Transport]
private let now: () -> Date
/// Invoked whenever a retained private message is dropped without a
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
/// so the UI can surface the failure instead of leaving the message in a
/// stale "sending/sent" state forever.
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
// Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage {
@@ -13,6 +20,7 @@ final class MessageRouter {
let nickname: String
let messageID: String
let timestamp: Date
var sendAttempts: Int = 0
}
private var outbox: [PeerID: [QueuedMessage]] = [:]
@@ -20,9 +28,13 @@ final class MessageRouter {
// Outbox limits to prevent unbounded memory growth
private static let maxMessagesPerPeer = 100
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
// 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).
private static let maxSendAttempts = 8
init(transports: [Transport]) {
init(transports: [Transport], now: @escaping () -> Date = Date.init) {
self.transports = transports
self.now = now
// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
@@ -61,26 +73,54 @@ final class MessageRouter {
// MARK: - Message Sending
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
if let transport = connectedTransport(for: peerID) {
// A live link is a strong delivery signal; trust it outright.
SecureLogger.debug("Routing PM via \(type(of: transport)) (connected) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
return
}
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1)
if let transport = reachableTransport(for: peerID) {
// Reachability without a connection is a freshness heuristic (e.g.
// the mesh retention window), so the send can silently go nowhere.
// Send now, but retain a copy until a delivery/read ack clears it;
// receivers dedup resends by message ID.
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)
enqueue(message, for: peerID)
} else {
// Queue for later with timestamp for TTL tracking
if outbox[peerID] == nil { outbox[peerID] = [] }
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())
outbox[peerID]?.append(message)
// Enforce per-peer size limit with FIFO eviction
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
let evicted = outbox[peerID]?.removeFirst()
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")", category: .session)
}
var unsent = message
unsent.sendAttempts = 0
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)
}
}
/// A delivery or read ack confirms receipt; stop retaining the message.
func markDelivered(_ messageID: String) {
for (peerID, queue) in outbox {
let filtered = queue.filter { $0.messageID != messageID }
guard filtered.count != queue.count else { continue }
outbox[peerID] = filtered.isEmpty ? nil : filtered
}
}
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
var queue = outbox[peerID] ?? []
// Re-sending an already-queued ID replaces the entry (keeps attempt count fresh)
queue.removeAll { $0.messageID == message.messageID }
queue.append(message)
// Enforce per-peer size limit with FIFO eviction
if queue.count > Self.maxMessagesPerPeer {
let evicted = queue.removeFirst()
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))", category: .session)
onMessageDropped?(evicted.messageID, peerID)
}
outbox[peerID] = queue
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
@@ -111,19 +151,34 @@ final class MessageRouter {
guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
let now = Date()
let now = now()
var remaining: [QueuedMessage] = []
for message in queued {
// Skip expired messages (TTL exceeded)
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
onMessageDropped?(message.messageID, peerID)
continue
}
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
if let transport = connectedTransport(for: peerID) {
// Live link: send and stop retaining.
SecureLogger.debug("Outbox -> \(type(of: transport)) (connected) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
} else if let transport = reachableTransport(for: peerID) {
// Weak signal: send but keep retaining until an ack clears it,
// bounded by attempt count for peers that never ack.
guard message.sendAttempts < Self.maxSendAttempts else {
SecureLogger.warning("📤 Dropping unacked PM for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… after \(message.sendAttempts) attempts", category: .session)
onMessageDropped?(message.messageID, peerID)
continue
}
SecureLogger.debug("Outbox -> \(type(of: transport)) (reachable) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
var retained = message
retained.sendAttempts += 1
remaining.append(retained)
} else {
remaining.append(message)
}
@@ -142,12 +197,21 @@ final class MessageRouter {
/// Periodically clean up expired messages from all outboxes
func cleanupExpiredMessages() {
let now = Date()
let now = now()
for peerID in Array(outbox.keys) {
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
var expiredMessageIDs: [String] = []
outbox[peerID]?.removeAll { message in
guard now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds else { return false }
expiredMessageIDs.append(message.messageID)
return true
}
if outbox[peerID]?.isEmpty == true {
outbox.removeValue(forKey: peerID)
}
for messageID in expiredMessageIDs {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
onMessageDropped?(messageID, peerID)
}
}
}
}
@@ -44,7 +44,11 @@ final class NetworkActivationService: ObservableObject {
private let permissionProvider: () -> LocationChannelManager.PermissionState
private let mutualFavoritesProvider: () -> Set<Data>
private let torController: NetworkActivationTorControlling
private let relayController: NetworkActivationRelayControlling
// Resolved lazily: NostrRelayManager.init() reads NetworkActivationService.shared
// (via its live dependencies), so capturing NostrRelayManager.shared here would
// re-enter whichever singleton's dispatch_once started first and trap at launch.
private lazy var relayController: NetworkActivationRelayControlling = relayControllerProvider()
private let relayControllerProvider: () -> NetworkActivationRelayControlling
private let proxyController: NetworkActivationProxyControlling
private let notificationCenter: NotificationCenter
@@ -55,7 +59,7 @@ final class NetworkActivationService: ObservableObject {
permissionProvider = { LocationChannelManager.shared.permissionState }
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
torController = TorManager.shared
relayController = NostrRelayManager.shared
relayControllerProvider = { NostrRelayManager.shared }
proxyController = TorURLSession.shared
notificationCenter = .default
}
@@ -77,7 +81,7 @@ final class NetworkActivationService: ObservableObject {
self.permissionProvider = permissionProvider
self.mutualFavoritesProvider = mutualFavoritesProvider
self.torController = torController
self.relayController = relayController
self.relayControllerProvider = { relayController }
self.proxyController = proxyController
self.notificationCenter = notificationCenter
}
+3 -11
View File
@@ -142,17 +142,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
func getFingerprint(for peerID: PeerID) -> String? { nil }
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
private static var cachedNoiseService: NoiseEncryptionService?
func getNoiseService() -> NoiseEncryptionService {
if let noiseService = Self.cachedNoiseService {
return noiseService
}
let noiseService = NoiseEncryptionService(keychain: keychain)
Self.cachedNoiseService = noiseService
return noiseService
}
// Nostr does not use Noise sessions here; the inert Transport defaults
// for the noise* identity hooks apply.
// Public broadcast not supported over Nostr here
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
+139 -125
View File
@@ -8,14 +8,25 @@
import BitLogger
import BitFoundation
import Combine
import Foundation
import SwiftUI
/// Manages all private chat functionality
/// Manages private chat session policy (selection, read receipts,
/// consolidation). Message storage lives in the single-writer
/// `ConversationStore` (docs/CONVERSATION-STORE-DESIGN.md); the
/// `privateChats` / `unreadMessages` properties below are read-only views
/// derived from it.
@MainActor
final class PrivateChatManager: ObservableObject {
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
@Published var selectedPeer: PeerID? = nil
@Published var unreadMessages: Set<PeerID> = []
/// Read-only mirror of `ConversationStore.selectedPrivatePeerID` the
/// store is the sole owner of conversation selection. Kept `@Published`
/// so existing observers (`objectWillChange` forwarding into
/// `ChatViewModel`) keep firing on selection changes. Mutate via
/// `startChat(with:)` / `endChat()`, which route through the store's
/// `setSelectedPrivatePeer` intent.
@Published private(set) var selectedPeer: PeerID? = nil
private var selectedPeerMirrorCancellable: AnyCancellable? = nil
private var selectedPeerFingerprint: String? = nil
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
@@ -25,13 +36,51 @@ final class PrivateChatManager: ObservableObject {
weak var messageRouter: MessageRouter?
// Peer service for looking up peer info during consolidation
weak var unifiedPeerService: UnifiedPeerService?
init(meshService: Transport? = nil) {
self.meshService = meshService
/// Single source of truth for message and selection state; injected by
/// the bootstrapper (`wireServiceGraph`).
var conversationStore: ConversationStore? {
didSet { bindSelectionMirror() }
}
// Cap for messages stored per private chat
private let privateChatCap = TransportConfig.privateChatCap
init(meshService: Transport? = nil, conversationStore: ConversationStore? = nil) {
self.meshService = meshService
self.conversationStore = conversationStore
bindSelectionMirror() // didSet does not fire during init
}
/// Keeps `selectedPeer` in lock-step with the store's selection axis
/// (including store-internal handoffs such as conversation migration).
private func bindSelectionMirror() {
guard let store = conversationStore else {
selectedPeerMirrorCancellable = nil
return
}
selectedPeerMirrorCancellable = store.$selectedPrivatePeerID
.sink { [weak self] peerID in
guard let self, self.selectedPeer != peerID else { return }
self.selectedPeer = peerID
}
}
// MARK: - Derived message state (read-only compat views)
/// All private chats keyed by routing peer ID, derived from the store.
/// Mutations go through the store's intent API only.
@MainActor
var privateChats: [PeerID: [BitchatMessage]] {
conversationStore?.directMessagesByRoutingPeerID() ?? [:]
}
/// Unread chats, derived from the store's unread state.
@MainActor
var unreadMessages: Set<PeerID> {
conversationStore?.unreadDirectRoutingPeerIDs() ?? []
}
@MainActor
private func messages(for peerID: PeerID) -> [BitchatMessage] {
conversationStore?.conversationsByID[.directPeer(peerID)]?.messages ?? []
}
// MARK: - Message Consolidation
@@ -44,57 +93,51 @@ final class PrivateChatManager: ObservableObject {
/// - Returns: True if any unread messages were found during consolidation
@MainActor
func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<String>) -> Bool {
guard let meshService = meshService else { return false }
guard let meshService = meshService, let store = conversationStore else { return false }
var hasUnreadMessages = false
// 1. Consolidate from stable Noise key (64-char hex)
if let peer = unifiedPeerService?.getPeer(by: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
let nostrMessages = messages(for: noiseKeyHex)
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
if noiseKeyHex != peerID, !nostrMessages.isEmpty {
for message in nostrMessages {
if !existingMessageIds.contains(message.id) {
// Update senderPeerID for correct read receipts
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
privateChats[peerID]?.append(updatedMessage)
// Update senderPeerID for correct read receipts
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
// Store append dedups by message ID (skips ones the
// target chat already has).
guard store.append(updatedMessage, to: .directPeer(peerID)) else { continue }
// Check for recent unread messages (< 60s, not sent by us, not already read)
// Use persistedReadReceipts to correctly identify already-read messages after app restart
if message.senderPeerID != meshService.myPeerID {
let messageAge = Date().timeIntervalSince(message.timestamp)
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
hasUnreadMessages = true
}
// Check for recent unread messages (< 60s, not sent by us, not already read)
// Use persistedReadReceipts to correctly identify already-read messages after app restart
if message.senderPeerID != meshService.myPeerID {
let messageAge = Date().timeIntervalSince(message.timestamp)
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
hasUnreadMessages = true
}
}
}
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
if hasUnreadMessages {
unreadMessages.insert(peerID)
} else if unreadMessages.contains(noiseKeyHex) {
unreadMessages.remove(noiseKeyHex)
store.markUnread(.directPeer(peerID))
} else {
store.markRead(.directPeer(noiseKeyHex))
}
privateChats.removeValue(forKey: noiseKeyHex)
store.removeConversation(.directPeer(noiseKeyHex))
}
}
@@ -112,52 +155,43 @@ final class PrivateChatManager: ObservableObject {
}
if !tempPeerIDsToConsolidate.isEmpty {
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
var consolidatedCount = 0
var hadUnreadTemp = false
let unreadPeerIDs = unreadMessages
for tempPeerID in tempPeerIDsToConsolidate {
if unreadMessages.contains(tempPeerID) {
if unreadPeerIDs.contains(tempPeerID) {
hadUnreadTemp = true
}
if let tempMessages = privateChats[tempPeerID] {
for message in tempMessages {
if !existingMessageIds.contains(message.id) {
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: peerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
privateChats[peerID]?.append(updatedMessage)
consolidatedCount += 1
}
for message in messages(for: tempPeerID) {
let updatedMessage = BitchatMessage(
id: message.id,
sender: message.sender,
content: message.content,
timestamp: message.timestamp,
isRelay: message.isRelay,
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: peerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
if store.append(updatedMessage, to: .directPeer(peerID)) {
consolidatedCount += 1
}
privateChats.removeValue(forKey: tempPeerID)
unreadMessages.remove(tempPeerID)
}
store.removeConversation(.directPeer(tempPeerID))
}
if hadUnreadTemp {
unreadMessages.insert(peerID)
store.markUnread(.directPeer(peerID))
hasUnreadMessages = true
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
}
if consolidatedCount > 0 {
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
}
}
@@ -168,9 +202,7 @@ final class PrivateChatManager: ObservableObject {
/// Syncs the read receipt tracking between manager and view model for sent messages
@MainActor
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
guard let messages = privateChats[peerID] else { return }
for message in messages {
for message in messages(for: peerID) {
if message.sender == nickname {
if let status = message.deliveryStatus {
switch status {
@@ -184,86 +216,68 @@ final class PrivateChatManager: ObservableObject {
}
}
}
/// Start a private chat with a peer
/// Start a private chat with a peer. Selection is mutated through the
/// store's intent (the store owns it); the manager keeps its side
/// effects (fingerprint tracking, read receipts, unread clearing).
@MainActor
func startChat(with peerID: PeerID) {
selectedPeer = peerID
// Also creates the conversation if needed and updates the derived
// `selectedConversationID`; `selectedPeer` mirrors the change.
conversationStore?.setSelectedPrivatePeer(peerID)
// Store fingerprint for persistence across reconnections
if let fingerprint = meshService?.getFingerprint(for: peerID) {
selectedPeerFingerprint = fingerprint
}
// Mark messages as read
markAsRead(from: peerID)
// Initialize chat if needed
if privateChats[peerID] == nil {
privateChats[peerID] = []
}
}
/// End the current private chat
/// End the current private chat (selection returns to the active public
/// channel's conversation).
func endChat() {
selectedPeer = nil
conversationStore?.setSelectedPrivatePeer(nil)
selectedPeerFingerprint = nil
}
/// Remove duplicate messages by ID and keep chronological order
func sanitizeChat(for peerID: PeerID) {
guard let arr = privateChats[peerID] else { return }
if arr.count <= 1 {
return
}
/// No-op since the `ConversationStore` cutover: the store maintains
/// chronological order and dedups by message ID on every insert, so the
/// per-append re-sort/dedup sweep this performed is no longer needed.
/// Kept only for API compatibility until step 5 removes the callers.
func sanitizeChat(for peerID: PeerID) {}
var indexByID: [String: Int] = [:]
indexByID.reserveCapacity(arr.count)
var deduped: [BitchatMessage] = []
deduped.reserveCapacity(arr.count)
for msg in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if let existing = indexByID[msg.id] {
deduped[existing] = msg
} else {
indexByID[msg.id] = deduped.count
deduped.append(msg)
}
}
privateChats[peerID] = deduped
}
/// Mark messages from a peer as read
@MainActor
func markAsRead(from peerID: PeerID) {
unreadMessages.remove(peerID)
conversationStore?.markRead(.directPeer(peerID))
// Send read receipts for unread messages that haven't been sent yet
if let messages = privateChats[peerID] {
for message in messages {
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
sendReadReceipt(for: message)
}
for message in messages(for: peerID) {
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
sendReadReceipt(for: message)
}
}
}
// MARK: - Private Methods
private func sendReadReceipt(for message: BitchatMessage) {
guard !sentReadReceipts.contains(message.id),
let senderPeerID = message.senderPeerID else {
return
}
sentReadReceipts.insert(message.id)
// Create read receipt using the simplified method
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: meshService?.myPeerID ?? PeerID(str: ""),
readerNickname: meshService?.myNickname ?? ""
)
// Route via MessageRouter to avoid handshakeRequired spam when session isn't established
if let router = messageRouter {
SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session)
+11 -1
View File
@@ -39,7 +39,12 @@ struct RelayController {
}
if isFragment {
let ttlLimit = min(ttlCap, TransportConfig.bleFragmentRelayTtlCap)
// Dense graphs clamp harder to contain full-fanout fragment floods;
// sparse graphs get full depth so media reaches as far as text.
let fragmentCap = degree >= highDegreeThreshold
? TransportConfig.bleFragmentRelayTtlCapDense
: TransportConfig.bleFragmentRelayTtlCap
let ttlLimit = min(ttlCap, fragmentCap)
guard ttlLimit > 1 else {
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
}
@@ -50,11 +55,16 @@ struct RelayController {
// TTL clamping for broadcast
// - Dense graphs: keep lower but still allow multi-hop bridging
// - Thin chains (degree <= 2): every hop counts and flood cost is
// minimal, so relay at full incoming depth
// - Announces get a bit more headroom
let ttlLimit: UInt8 = {
if degree >= highDegreeThreshold {
return max(UInt8(2), min(ttlCap, UInt8(5)))
}
if degree <= 2 {
return ttlCap
}
let preferred = UInt8(isAnnounce ? 7 : 6)
return max(UInt8(2), min(ttlCap, preferred))
}()
+25
View File
@@ -0,0 +1,25 @@
//
// TestEnvironment.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Process-level test-environment detection for singletons that must swap a
/// real OS-backed dependency (keychain, persistent defaults, notifications)
/// for an in-memory one under test. Mirrors the detection already used by
/// `NotificationService` and `LocationStateManager`.
enum TestEnvironment {
/// True when running under XCTest / Swift Testing or in CI.
static let isRunningTests: Bool = {
let env = ProcessInfo.processInfo.environment
return NSClassFromString("XCTestCase") != nil ||
env["XCTestConfigurationFilePath"] != nil ||
env["XCTestBundlePath"] != nil ||
env["GITHUB_ACTIONS"] != nil ||
env["CI"] != nil
}()
}
+34 -1
View File
@@ -61,7 +61,27 @@ protocol Transport: AnyObject {
func getFingerprint(for peerID: PeerID) -> String?
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
func triggerHandshake(with peerID: PeerID)
func getNoiseService() -> NoiseEncryptionService
// Noise identity/session access. Narrow, purpose-named wrappers so the
// underlying NoiseEncryptionService (and its peer-binding/session
// orchestration) is never exposed outside the transport.
/// The remote static public key of the Noise session with `peerID`, if established.
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data?
/// Fingerprint of our own Noise static identity key.
func noiseIdentityFingerprint() -> String
/// Our Noise static public key (Curve25519 key agreement).
func noiseStaticPublicKeyData() -> Data
/// Our Noise signing public key (Ed25519).
func noiseSigningPublicKeyData() -> Data
/// Signs `data` with our Noise signing key.
func noiseSignData(_ data: Data) -> Data?
/// Verifies an Ed25519 `signature` over `data` against `publicKey`.
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool
/// Registers session-lifecycle callbacks (peer authenticated / handshake required).
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
)
// Messaging
func sendMessage(_ content: String, mentions: [String])
@@ -85,6 +105,19 @@ protocol Transport: AnyObject {
}
extension Transport {
// Noise identity hooks default to inert for transports that do not carry
// Noise sessions (e.g. NostrTransport).
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
func noiseIdentityFingerprint() -> String { "" }
func noiseStaticPublicKeyData() -> Data { Data() }
func noiseSigningPublicKeyData() -> Data { Data() }
func noiseSignData(_ data: Data) -> Data? { nil }
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { false }
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
) {}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
+82 -15
View File
@@ -11,13 +11,17 @@ enum TransportConfig {
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
static let bleFragmentRelayTtlCap: UInt8 = 5 // Clamp fragment TTL to contain floods
// Fragment relay TTL in sparse graphs; matches messageTTLDefault so media
// reaches as far as text. Dense graphs clamp harder in RelayController.
static let bleFragmentRelayTtlCap: UInt8 = 7
static let bleFragmentRelayTtlCapDense: UInt8 = 5 // Contain fragment floods in dense graphs
// UI / Storage Caps
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
static let geoTimelineCap: Int = 1337
static let contentLRUCap: Int = 2000
static let geoSamplingEventLRUCap: Int = 2000
// Timers
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
@@ -40,14 +44,47 @@ enum TransportConfig {
static let blePendingNotificationsCapCount: Int = 128
static let bleNotificationRetryDelayMs: Int = 25
static let bleNotificationRetryMaxAttempts: Int = 80
// Sample interval for notification backpressure logs (fire per fragment
// during media transfers).
static let bleBackpressureLogInterval: Int = 25
// Nostr
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
static let nostrInboundEventDedupCap: Int = 4096
static let nostrInboundEventDedupTrimTarget: Int = 3072
static let nostrDuplicateEventLogInterval: Int = 50
// Sample interval for per-event debug logs on the inbound hot path.
static let nostrInboundEventLogInterval: Int = 100
// Bounded per-relay inbound frame buffer. Each relay connection owns its
// own serial verify pipeline; if a relay floods faster than its Schnorr
// verification drains, the oldest buffered frames for THAT relay are
// dropped (bufferingNewest) so one relay cannot stall other relays.
// Nostr inbound is already best-effort (relays are redundant and events
// replay), so dropping a flooding relay's backlog is safe. Together with
// nostrInboundMaxFrameBytes this caps buffered inbound bytes at
// cap × maxFrameBytes (128 MiB) per hostile relay bounded, not zero.
static let nostrInboundPerRelayBufferCap: Int = 256
// Hard per-frame byte bound, applied as URLSessionWebSocketTask
// .maximumMessageSize (oversized frames fail the receive instead of
// buffering). BitChat's legitimate Nostr traffic is small: geohash chat /
// presence events (kind 20000/20001), kind-1 notes, and NIP-17
// gift-wrapped DMs carrying text payloads or receipts are all a few KiB,
// and most public relays reject events beyond ~64256 KiB anyway. 512 KiB
// leaves an order-of-magnitude margin over anything we produce or expect
// while halving the URLSession default (1 MiB), so a hostile relay's
// worst-case buffered pile-up per connection is
// nostrInboundPerRelayBufferCap × 512 KiB = 128 MiB instead of 256 MiB.
static let nostrInboundMaxFrameBytes: Int = 512 * 1024
// Conversation store diagnostics (field observability)
// Sample interval for the periodic store-audit "OK" heartbeat line
// (first + every Nth audit); violations always log at error level.
static let conversationStoreAuditLogInterval: Int = 10
// Sample interval for the mirrored-republish debug line in the ID-only
// delivery fan-out (first + every Nth republish).
static let conversationStoreMirroredRepublishLogInterval: Int = 25
// UI thresholds
static let uiLateInsertThreshold: TimeInterval = 15.0
// Geohash public chats are more sensitive to ordering; use a tighter threshold
static let uiLateInsertThresholdGeo: TimeInterval = 0.0
static let uiProcessedNostrEventsCap: Int = 2000
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
@@ -76,19 +113,21 @@ enum TransportConfig {
// BLE maintenance & thresholds
static let bleMaintenanceInterval: TimeInterval = 5.0
static let bleMaintenanceLeewaySeconds: Int = 1
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60
static let bleRecentTimeoutWindowSeconds: TimeInterval = 60
static let bleRecentTimeoutCountThreshold: Int = 3
static let bleRSSIIsolatedBase: Int = -90
static let bleRSSIIsolatedRelaxed: Int = -92
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 30
// Isolated nodes accept the weakest usable links a fringe connection
// beats no connection. Relaxed floor sits at CoreBluetooth's practical
// reporting limit so prolonged isolation gates on nothing but decode.
static let bleRSSIIsolatedBase: Int = -95
static let bleRSSIIsolatedRelaxed: Int = -100
static let bleRSSIConnectedThreshold: Int = -85
static let bleRSSIHighTimeoutThreshold: Int = -80
// How long without seeing traffic before we sanity-check the direct link
// Lowered to make connectedreachable icon changes react faster when walking out of range
static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0
// How long to retain a peer as "reachable" (not directly connected) since lastSeen
static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified
// How long to retain a peer as "reachable" (not directly connected) since lastSeen.
// Must comfortably exceed the worst-case dense announce interval (38s) plus a
// missed cycle, so duty-cycled nodes don't forget peers between announces.
static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 60.0 // verified/favorites
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 45.0 // unknown/unverified
static let bleFragmentLifetimeSeconds: TimeInterval = 30.0
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
@@ -145,7 +184,27 @@ enum TransportConfig {
static let nostrRelayMaxBackoffSeconds: TimeInterval = 300.0
static let nostrRelayBackoffMultiplier: Double = 2.0
static let nostrRelayMaxReconnectAttempts: Int = 10
// Reconnect delays get ±20% random jitter so relays that dropped together
// (e.g. a network blip) don't thundering-herd the same reconnect instant.
static let nostrRelayBackoffJitterRatio: Double = 0.2
static let nostrRelayDefaultFetchLimit: Int = 100
// How many consecutive Tor-readiness waits (each bounded by TorManager's
// bootstrap deadline) to attempt before unblocking pending EOSE callers.
static let nostrTorReadyMaxWaitAttempts: Int = 3
static let nostrPendingSendQueueCap: Int = 200
// Sample interval for the send-queue overflow warning (first + every Nth
// dropped event). Drops are ephemeral presence/geo traffic log-only.
static let nostrPendingSendDropLogInterval: Int = 10
// Pending (not-yet-flushed) REQs are bounded per relay: oldest-by-insertion
// eviction at the cap, plus an age sweep on connect attempts. Durable
// subscription intent survives in subscriptionRequestState either way.
static let nostrPendingSubscriptionsPerRelayCap: Int = 64
static let nostrPendingSubscriptionTTLSeconds: TimeInterval = 600.0
// Fallback deadline for treating a subscription's initial fetch as complete
// when a relay never sends EOSE (generous to cover Tor circuit setup).
static let nostrSubscriptionEOSEFallbackSeconds: TimeInterval = 10.0
// After this long, a relay marked permanently failed gets another chance.
static let nostrRelayFailureCooldownSeconds: TimeInterval = 600.0
// Geo relay directory
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
@@ -169,8 +228,10 @@ enum TransportConfig {
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
// Store-and-forward for directed packets at relays
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
// Store-and-forward for directed packets at relays. Spooled packets retry
// on each maintenance flush until the window lapses; a longer window lets
// brief link gaps (walking between rooms, reconnect churn) heal themselves.
static let bleDirectedSpoolWindowSeconds: TimeInterval = 60.0
// Log/UI debounce windows
// Shorter debounce so UI reacts faster while still suppressing duplicate callbacks
@@ -180,6 +241,12 @@ enum TransportConfig {
// Weak-link cooldown after connection timeouts
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
static let bleWeakLinkRSSICutoff: Int = -90
// Rediscovery ignore windows after a failed link, by failure kind:
// a connect attempt that timed out means the peer likely isn't reachable,
// so back off; a dropped established connection (walked out of range)
// usually returns, so only pause long enough for CoreBluetooth to settle.
static let bleTimeoutDiscoveryIgnoreSeconds: TimeInterval = 15.0
static let bleDisconnectDiscoveryIgnoreSeconds: TimeInterval = 3.0
// Content hashing / formatting
static let contentKeyPrefixLength: Int = 256
+14 -12
View File
@@ -4,9 +4,11 @@ import Foundation
final class VerificationService {
static let shared = VerificationService()
// Injected Noise service from the running transport (do NOT create new BLEService)
private var noise: NoiseEncryptionService?
func configure(with noise: NoiseEncryptionService) { self.noise = noise }
// Injected running transport (do NOT create new BLEService). Noise
// identity operations go through the transport's narrow noise* wrappers
// so the raw NoiseEncryptionService is never exposed.
private var transport: Transport?
func configure(with transport: Transport) { self.transport = transport }
/// Encapsulates the data encoded into a verification QR
struct VerificationQR: Codable {
@@ -77,16 +79,16 @@ final class VerificationService {
if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
return c.value
}
guard let noise = noise else { return nil }
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
guard let transport = transport else { return nil }
let noiseKey = transport.noiseStaticPublicKeyData().hexEncodedString()
let signKey = transport.noiseSigningPublicKeyData().hexEncodedString()
let ts = Int64(Date().timeIntervalSince1970)
var nonce = Data(count: 16)
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
let msg = payload.canonicalBytes()
guard let sig = noise.signData(msg) else { return nil }
guard let sig = transport.noiseSignData(msg) else { return nil }
let signed = VerificationQR(v: payload.v,
noiseKeyHex: payload.noiseKeyHex,
signKeyHex: payload.signKeyHex,
@@ -108,8 +110,8 @@ final class VerificationService {
if now - Double(qr.ts) > maxAge { return nil }
// Verify signature using embedded ed25519 signKey
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
guard let noise = noise else { return nil }
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
guard let transport = transport else { return nil }
let ok = transport.noiseVerifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
return ok ? qr : nil
}
@@ -133,7 +135,7 @@ final class VerificationService {
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
msg.append(nonceA)
guard let noise = noise, let sig = noise.signData(msg) else { return nil }
guard let transport = transport, let sig = transport.noiseSignData(msg) else { return nil }
var tlv = Data()
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))
@@ -178,7 +180,7 @@ final class VerificationService {
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
msg.append(nonceA)
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }
return noise.verifySignature(signature, for: msg, publicKey: pub)
guard let transport = transport, let pub = Data(hexString: signerPublicKeyHex) else { return false }
return transport.noiseVerifySignature(signature, for: msg, publicKey: pub)
}
}
+8
View File
@@ -12,6 +12,11 @@ import CryptoKit
enum GCSFilter {
struct Params { let p: Int; let m: UInt32; let data: Data }
// Highest Golomb-Rice parameter we accept from the wire. P maps to an FPR
// of ~1/2^P; beyond 32 the remainder width exceeds any practical filter
// and shifts in decode would silently overflow to garbage values.
static let maxP = 32
// Derive P from FPR (~ 1 / 2^P)
static func deriveP(targetFpr: Double) -> Int {
let f = max(0.000001, min(0.25, targetFpr))
@@ -66,6 +71,9 @@ enum GCSFilter {
}
static func decodeToSortedSet(p: Int, m: UInt32, data: Data) -> [UInt64] {
// Reject out-of-range parameters rather than decoding garbage: callers
// treat the result as "peer has nothing" and fall back to sending data.
guard p >= 1, p <= maxP, m > 1 else { return [] }
var values: [UInt64] = []
let reader = BitReader(data)
var acc: UInt64 = 0
+339
View File
@@ -0,0 +1,339 @@
//
// Theme.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
/// A user-selectable app-wide visual theme. Persisted by raw value.
enum AppTheme: String, CaseIterable, Identifiable {
case matrix
case liquidGlass
var id: String { rawValue }
/// UserDefaults key backing the theme selection.
static let storageKey = "appTheme"
var displayNameKey: LocalizedStringKey {
switch self {
case .matrix: return "app_info.appearance.matrix"
case .liquidGlass: return "app_info.appearance.liquid_glass"
}
}
/// Font design used for themed text. Matrix keeps the terminal monospace;
/// liquid glass uses the system default.
var bodyFontDesign: Font.Design {
switch self {
case .matrix: return .monospaced
case .liquidGlass: return .default
}
}
/// Whether chrome surfaces (header/composer bars, input field) render as
/// translucent glass/material instead of the flat matrix background.
var usesGlassChrome: Bool {
self == .liquidGlass
}
/// Discriminator mixed into per-message formatting caches so cached
/// AttributedStrings from one theme are never served under another.
/// Empty for matrix to keep its historical cache keys.
var formatCacheVariant: String {
switch self {
case .matrix: return ""
case .liquidGlass: return "lg:"
}
}
/// Resolves the semantic color palette for this theme under the given color scheme.
func palette(for colorScheme: ColorScheme) -> ThemePalette {
switch self {
case .matrix:
return .matrix(colorScheme)
case .liquidGlass:
return .liquidGlass(colorScheme)
}
}
}
/// Semantic colors for the active theme, resolved against the current color scheme.
/// Views should consume these via `@ThemedPalette` rather than computing colors inline.
struct ThemePalette {
/// Primary window/sheet background.
let background: Color
/// Primary text color.
let primary: Color
/// De-emphasized text (timestamps, hints, captions).
let secondary: Color
/// Interactive tint (buttons, toggles, selection).
let accent: Color
/// Location/geohash channel accent (badges, counts, subtitles).
let locationAccent: Color
/// Informational accent (links, read receipts, teleport markers).
let accentBlue: Color
/// Destructive/error accent.
let alertRed: Color
/// Hairline separators.
let divider: Color
static func matrix(_ colorScheme: ColorScheme) -> ThemePalette {
let isDark = colorScheme == .dark
let green = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
return ThemePalette(
background: isDark ? Color.black : Color.white,
primary: green,
secondary: green.opacity(0.8),
accent: green,
locationAccent: green,
accentBlue: Color(red: 0.0, green: 0.478, blue: 1.0),
alertRed: Color(red: 0.75, green: 0.1, blue: 0.1),
divider: isDark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)
)
}
static func liquidGlass(_ colorScheme: ColorScheme) -> ThemePalette {
ThemePalette(
background: systemBackground,
primary: .primary,
secondary: .secondary,
accent: .blue,
locationAccent: .green,
accentBlue: .blue,
alertRed: .red,
divider: separator
)
}
private static var systemBackground: Color {
#if os(iOS)
Color(UIColor.systemBackground)
#else
Color(NSColor.windowBackgroundColor)
#endif
}
private static var separator: Color {
#if os(iOS)
Color(UIColor.separator)
#else
Color(NSColor.separatorColor)
#endif
}
}
private struct AppThemeKey: EnvironmentKey {
static let defaultValue: AppTheme = .matrix
}
extension EnvironmentValues {
var appTheme: AppTheme {
get { self[AppThemeKey.self] }
set { self[AppThemeKey.self] = newValue }
}
}
/// Resolves the active theme's palette against the view's color scheme.
///
/// @ThemedPalette private var palette
/// var body: some View { Text("hi").foregroundColor(palette.primary) }
@propertyWrapper
struct ThemedPalette: DynamicProperty {
@Environment(\.appTheme) private var theme
@Environment(\.colorScheme) private var colorScheme
var wrappedValue: ThemePalette { theme.palette(for: colorScheme) }
}
// MARK: - Themed view helpers
/// Themed replacement for `.font(.bitchatSystem(size:weight:design: .monospaced))`:
/// monospaced under matrix, system default under liquid glass.
private struct ThemedFontModifier: ViewModifier {
@Environment(\.appTheme) private var theme
let size: CGFloat
let weight: Font.Weight
func body(content: Content) -> some View {
content.font(.bitchatSystem(size: size, weight: weight, design: theme.bodyFontDesign))
}
}
/// Root backdrop. Matrix gets its flat background; glass gets a subtle static
/// gradient with a soft tinted glow glass panels need visual texture behind
/// them to refract, and collapse to flat gray over a solid color.
struct ThemedRootBackground: View {
@Environment(\.appTheme) private var theme
@Environment(\.colorScheme) private var colorScheme
@ThemedPalette private var palette
var body: some View {
if theme.usesGlassChrome {
let isDark = colorScheme == .dark
ZStack {
LinearGradient(
colors: isDark
? [Color(red: 0.09, green: 0.10, blue: 0.15), Color(red: 0.04, green: 0.04, blue: 0.07)]
: [Color(red: 0.93, green: 0.95, blue: 1.0), Color(red: 0.98, green: 0.97, blue: 0.99)],
startPoint: .top,
endPoint: .bottom
)
RadialGradient(
colors: [Color.blue.opacity(isDark ? 0.22 : 0.12), .clear],
center: .topLeading,
startRadius: 0,
endRadius: 600
)
RadialGradient(
colors: [Color.purple.opacity(isDark ? 0.14 : 0.08), .clear],
center: .bottomTrailing,
startRadius: 0,
endRadius: 500
)
}
.ignoresSafeArea()
} else {
palette.background
}
}
}
/// Wraps glass-shape content in real Liquid Glass on OS 26+, with a material
/// fallback below that keeps the frosted look.
private struct GlassPanel<S: Shape>: ViewModifier {
let shape: S
@ViewBuilder
func body(content: Content) -> some View {
#if compiler(>=6.2)
if #available(iOS 26.0, macOS 26.0, *) {
content.glassEffect(.regular, in: shape)
} else {
materialFallback(content)
}
#else
materialFallback(content)
#endif
}
private func materialFallback(_ content: Content) -> some View {
content
.background(shape.fill(.ultraThinMaterial))
.overlay(shape.stroke(Color.white.opacity(0.15), lineWidth: 0.5))
}
}
/// Chrome surface for the header and composer. Matrix keeps the original flat
/// edge-to-edge wash; glass floats the content as an inset Liquid Glass panel
/// (content is expected to scroll underneath via safe-area insets).
private struct ThemedChromePanelModifier: ViewModifier {
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
let edge: VerticalEdge
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content
.modifier(GlassPanel(shape: RoundedRectangle(cornerRadius: 18, style: .continuous)))
.padding(.horizontal, 8)
.padding(edge == .top ? .top : .bottom, 4)
} else {
content.background(palette.background.opacity(0.95))
}
}
}
/// Background for the composer input field. Matrix keeps its translucent fill;
/// glass leaves it clear the field sits inside the composer's glass panel,
/// and glass cannot sample other glass.
private struct ThemedInputBackgroundModifier: ViewModifier {
@Environment(\.appTheme) private var theme
@Environment(\.colorScheme) private var colorScheme
private var shape: RoundedRectangle {
RoundedRectangle(cornerRadius: 14, style: .continuous)
}
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content
} else {
content.background(
shape.fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7))
)
}
}
}
extension View {
func bitchatFont(size: CGFloat, weight: Font.Weight = .regular) -> some View {
modifier(ThemedFontModifier(size: size, weight: weight))
}
func themedChromePanel(edge: VerticalEdge) -> some View {
modifier(ThemedChromePanelModifier(edge: edge))
}
func themedInputBackground() -> some View {
modifier(ThemedInputBackgroundModifier())
}
/// Floating surface for popover-style boxes (autocomplete, command
/// suggestions): glass panel under liquid glass, the original flat
/// background + hairline stroke under matrix.
func themedOverlayPanel() -> some View {
modifier(ThemedOverlayPanelModifier())
}
/// Root background for sheets same backdrop as the main window so every
/// surface speaks one visual language.
func themedSheetBackground() -> some View {
background(ThemedRootBackground())
}
/// Flat background wash for bars/headers inside sheets. Matrix keeps its
/// opaque wash; glass goes transparent so the backdrop gradient shows.
func themedSurface(opacity: Double = 1.0) -> some View {
modifier(ThemedSurfaceModifier(opacity: opacity))
}
}
private struct ThemedSurfaceModifier: ViewModifier {
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
let opacity: Double
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content
} else {
content.background(palette.background.opacity(opacity))
}
}
}
private struct ThemedOverlayPanelModifier: ViewModifier {
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@ViewBuilder
func body(content: Content) -> some View {
if theme.usesGlassChrome {
content.modifier(GlassPanel(shape: RoundedRectangle(cornerRadius: 12, style: .continuous)))
} else {
content
.background(palette.background)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(palette.secondary.opacity(0.3), lineWidth: 1)
)
}
}
}
@@ -1,44 +1,105 @@
import BitFoundation
import Foundation
/// The narrow surface `ChatComposerCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatComposerCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatComposerContext: AnyObject {
// MARK: Autocomplete UI state
var autocompleteSuggestions: [String] { get set }
var autocompleteRange: NSRange? { get set }
var showAutocomplete: Bool { get set }
var selectedAutocompleteIndex: Int { get set }
/// Computes mention suggestions for the text up to the cursor.
func autocompleteQuery(
for text: String,
peers: [String],
cursorPosition: Int
) -> (suggestions: [String], range: NSRange?)
/// Replaces the matched range in `text` with the chosen suggestion.
func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String
// MARK: Identity & channel state
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
/// The transport's own nickname (excluded from autocomplete candidates).
var meshNickname: String { get }
func meshPeerNicknames() -> [PeerID: String]
// MARK: Geohash identity (shared with the other contexts)
var geoNicknames: [String: String] { get }
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
}
extension ChatViewModel: ChatComposerContext {
// `autocompleteSuggestions`, `autocompleteRange`, `showAutocomplete`,
// `selectedAutocompleteIndex`, `nickname`, `myPeerID`, `activeChannel`,
// `geoNicknames`, `meshPeerNicknames()`, and
// `deriveNostrIdentity(forGeohash:)` are shared requirements with the
// other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten nested service accesses into intent-named calls.
func autocompleteQuery(
for text: String,
peers: [String],
cursorPosition: Int
) -> (suggestions: [String], range: NSRange?) {
autocompleteService.getSuggestions(for: text, peers: peers, cursorPosition: cursorPosition)
}
func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String {
autocompleteService.applySuggestion(suggestion, to: text, range: range)
}
var meshNickname: String {
meshService.myNickname
}
}
@MainActor
final class ChatComposerCoordinator {
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatComposerContext
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(context: any ChatComposerContext) {
self.context = context
}
func updateAutocomplete(for text: String, cursorPosition: Int) {
let peerCandidates = autocompleteCandidates()
let (suggestions, range) = viewModel.autocompleteService.getSuggestions(
let (suggestions, range) = context.autocompleteQuery(
for: text,
peers: peerCandidates,
cursorPosition: cursorPosition
)
if !suggestions.isEmpty {
viewModel.autocompleteSuggestions = suggestions
viewModel.autocompleteRange = range
viewModel.showAutocomplete = true
viewModel.selectedAutocompleteIndex = 0
context.autocompleteSuggestions = suggestions
context.autocompleteRange = range
context.showAutocomplete = true
context.selectedAutocompleteIndex = 0
} else {
viewModel.autocompleteSuggestions = []
viewModel.autocompleteRange = nil
viewModel.showAutocomplete = false
viewModel.selectedAutocompleteIndex = 0
context.autocompleteSuggestions = []
context.autocompleteRange = nil
context.showAutocomplete = false
context.selectedAutocompleteIndex = 0
}
}
func completeNickname(_ nickname: String, in text: inout String) -> Int {
guard let range = viewModel.autocompleteRange else { return text.count }
guard let range = context.autocompleteRange else { return text.count }
text = viewModel.autocompleteService.applySuggestion(nickname, to: text, range: range)
text = context.applyAutocompleteSuggestion(nickname, to: text, range: range)
viewModel.showAutocomplete = false
viewModel.autocompleteSuggestions = []
viewModel.autocompleteRange = nil
viewModel.selectedAutocompleteIndex = 0
context.showAutocomplete = false
context.autocompleteSuggestions = []
context.autocompleteRange = nil
context.selectedAutocompleteIndex = 0
return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2)
}
@@ -52,10 +113,10 @@ final class ChatComposerCoordinator {
range: NSRange(location: 0, length: nsContent.length)
)
let peerNicknames = viewModel.meshService.getPeerNicknames()
let peerNicknames = context.meshPeerNicknames()
var validTokens = Set(peerNicknames.values)
validTokens.insert(viewModel.nickname)
validTokens.insert(viewModel.nickname + "#" + String(viewModel.meshService.myPeerID.id.prefix(4)))
validTokens.insert(context.nickname)
validTokens.insert(context.nickname + "#" + String(context.myPeerID.id.prefix(4)))
var mentions: [String] = []
for match in matches {
@@ -72,18 +133,18 @@ final class ChatComposerCoordinator {
private extension ChatComposerCoordinator {
func autocompleteCandidates() -> [String] {
switch viewModel.activeChannel {
switch context.activeChannel {
case .mesh:
let values = viewModel.meshService.getPeerNicknames().values
return Array(values.filter { $0 != viewModel.meshService.myNickname })
let values = context.meshPeerNicknames().values
return Array(values.filter { $0 != context.meshNickname })
case .location(let channel):
var tokens = Set<String>()
for (pubkey, nick) in viewModel.geoNicknames {
for (pubkey, nick) in context.geoNicknames {
tokens.insert("\(nick)#\(pubkey.suffix(4))")
}
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
let myToken = viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4))
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let myToken = context.nickname + "#" + String(identity.publicKeyHex.suffix(4))
tokens.remove(myToken)
}
return Array(tokens)
@@ -2,29 +2,80 @@ import BitFoundation
import BitLogger
import Foundation
final class ChatDeliveryCoordinator {
private unowned let viewModel: ChatViewModel
/// The narrow surface `ChatDeliveryCoordinator` needs from its owner.
///
/// Coordinators should depend on the minimal context they actually use rather
/// than holding an `unowned` back-reference to the whole `ChatViewModel`. This
/// keeps the coordinator independently testable (see
/// `ChatDeliveryCoordinatorContextTests`) and makes its true dependencies
/// explicit. This protocol is the exemplar for migrating the other
/// coordinators off their `unowned let viewModel: ChatViewModel` back-refs.
@MainActor
protocol ChatDeliveryContext: AnyObject {
var isStartupPhase: Bool { get }
/// Applies a delivery status to every copy of the message across
/// conversations (`ConversationStore` intent, ID-only: the store's
/// message-ID conversation map resolves which conversations hold the
/// message, including mirrored ephemeral/stable private copies). The
/// no-downgrade rule is enforced in the store. Returns `false` when the
/// message is unknown or no copy changed.
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool
/// Current delivery status of the message in whichever conversation holds it.
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus?
/// Message IDs across all direct conversations (read-receipt pruning).
func privateMessageIDs() -> Set<String>
/// Drops every recorded read receipt whose message ID is not in `validMessageIDs`.
/// Returns the number of receipts removed. (Single mutation path for the
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
/// Confirms receipt so the message router stops retaining the message for resend.
func markMessageDelivered(_ messageID: String)
}
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
extension ChatViewModel: ChatDeliveryContext {
@discardableResult
func setDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String) -> Bool {
conversations.setDeliveryStatus(status, forMessageID: messageID)
}
func deliveryStatus(forMessageID messageID: String) -> DeliveryStatus? {
conversations.deliveryStatus(forMessageID: messageID)
}
func privateMessageIDs() -> Set<String> {
conversations.directMessageIDs()
}
func notifyUIChanged() {
objectWillChange.send()
}
func markMessageDelivered(_ messageID: String) {
messageRouter.markDelivered(messageID)
}
}
/// Thin mapper from delivery events (read receipts, transport delivery
/// callbacks) onto `ConversationStore` delivery intents, plus read-receipt
/// retention cleanup. The store's message-ID conversation map replaces the
/// positional `messageLocationIndex` this coordinator used to maintain.
final class ChatDeliveryCoordinator {
private unowned let context: any ChatDeliveryContext
init(context: any ChatDeliveryContext) {
self.context = context
}
@MainActor
func cleanupOldReadReceipts() {
guard !viewModel.isStartupPhase, !viewModel.privateChats.isEmpty else {
return
}
guard !context.isStartupPhase else { return }
let validMessageIDs = context.privateMessageIDs()
guard !validMessageIDs.isEmpty else { return }
let validMessageIDs = Set(
viewModel.privateChats.values.flatMap { messages in
messages.map(\.id)
}
)
let oldCount = viewModel.sentReadReceipts.count
viewModel.sentReadReceipts = viewModel.sentReadReceipts.intersection(validMessageIDs)
let removedCount = oldCount - viewModel.sentReadReceipts.count
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
if removedCount > 0 {
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
}
@@ -45,63 +96,24 @@ final class ChatDeliveryCoordinator {
@MainActor
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
if let message = viewModel.messages.first(where: { $0.id == messageID }) {
return message.deliveryStatus
}
for messages in viewModel.privateChats.values {
if let message = messages.first(where: { $0.id == messageID }) {
return message.deliveryStatus
}
}
return nil
context.deliveryStatus(forMessageID: messageID)
}
@MainActor
@discardableResult
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
var didUpdateStatus = false
if let index = viewModel.messages.firstIndex(where: { $0.id == messageID }) {
let currentStatus = viewModel.messages[index].deliveryStatus
if !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) {
viewModel.messages[index].deliveryStatus = status
didUpdateStatus = true
}
}
var privateChats = viewModel.privateChats
for (peerID, chatMessages) in privateChats {
guard let index = chatMessages.firstIndex(where: { $0.id == messageID }) else { continue }
let currentStatus = chatMessages[index].deliveryStatus
guard !shouldSkipUpdate(currentStatus: currentStatus, newStatus: status) else { continue }
let updatedMessages = chatMessages
updatedMessages[index].deliveryStatus = status
privateChats[peerID] = updatedMessages
didUpdateStatus = true
}
if didUpdateStatus {
viewModel.privateChats = privateChats
viewModel.objectWillChange.send()
}
return didUpdateStatus
}
}
private extension ChatDeliveryCoordinator {
func shouldSkipUpdate(currentStatus: DeliveryStatus?, newStatus: DeliveryStatus) -> Bool {
guard let currentStatus else { return false }
switch (currentStatus, newStatus) {
case (.read, .delivered), (.read, .sent):
return true
switch status {
case .delivered, .read:
// Confirmed receipt stop retaining the message for resend.
context.markMessageDelivered(messageID)
default:
break
}
guard context.setDeliveryStatus(status, forMessageID: messageID) else {
return false
}
context.notifyUIChanged()
return true
}
}
+176 -73
View File
@@ -2,36 +2,149 @@ import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatLifecycleCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatLifecycleCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
final class ChatLifecycleCoordinator {
private unowned let viewModel: ChatViewModel
protocol ChatLifecycleContext: AnyObject {
// MARK: Chat & receipt state
var messages: [BitchatMessage] { get }
/// A single private chat's timeline (store-direct lookup on
/// `ChatViewModel`; no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get }
/// Appends a private message via the single-writer store intent.
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
/// Clears the peer's unread flag (store unread state only).
func markPrivateChatRead(_ peerID: PeerID)
var sentReadReceipts: Set<String> { get }
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
var nostrKeyMapping: [PeerID: String] { get }
/// Records that a read receipt is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool
/// The owner-level read pass (chat manager + receipts); used for the
/// delayed re-run after the app becomes active.
func markPrivateMessagesAsRead(from peerID: PeerID)
/// Marks the chat read in the private chat manager (sends pending mesh READ acks).
func markChatAsRead(from peerID: PeerID)
/// Schedules main-actor work after a UI-timing delay. Injected so tests
/// can run the work synchronously instead of polling wall-clock queues.
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void)
func addSystemMessage(_ content: String)
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
// MARK: Peers & sessions
func peerNickname(for peerID: PeerID) -> String?
/// The peer's current entry in the unified peer service, if known.
func unifiedPeer(for peerID: PeerID) -> BitchatPeer?
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState
func stopMeshServices()
/// Re-reads the transport's current Bluetooth state and updates the alert UI.
func refreshBluetoothState()
// MARK: Routing & receipts
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
// MARK: Nostr & geohash
var isTeleported: Bool { get }
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
func recordGeoParticipant(pubkeyHex: String)
// MARK: Favorites (shared with `ChatPrivateConversationContext`)
/// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
// MARK: Identity persistence
/// Forces the identity manager to persist its state now.
func forceSaveIdentity()
/// Confirms the Noise identity key is still present in the keychain.
@discardableResult
func verifyIdentityKeyExists() -> Bool
}
extension ChatViewModel: ChatLifecycleContext {
// `messages`, `privateMessages(for:)`, `unreadPrivateMessages`,
// `selectedPrivateChatPeer`, `sentReadReceipts`, `nickname`, `myPeerID`,
// `activeChannel`, `nostrKeyMapping`, `markReadReceiptSent(_:)`,
// `markPrivateMessagesAsRead(from:)`, `appendPrivateMessage(_:to:)`,
// `markPrivateChatRead(_:)`, `addSystemMessage(_:)`,
// `peerNickname(for:)`, `unifiedPeer(for:)`, `noiseSessionState(for:)`,
// the routing/ack members, `isTeleported`,
// `deriveNostrIdentity(forGeohash:)`, `recordGeoParticipant(pubkeyHex:)`,
// and `favoriteRelationship(forNoiseKey:)`
// are shared requirements with the other contexts or satisfied by
// existing `ChatViewModel` members. The members below flatten nested
// service accesses into intent-named calls.
func markChatAsRead(from peerID: PeerID) {
privateChatManager.markAsRead(from: peerID)
}
func handleDidBecomeActive() {
if let bleService = viewModel.meshService as? BLEService {
let currentState = bleService.getCurrentBluetoothState()
viewModel.updateBluetoothState(currentState)
}
guard let peerID = viewModel.selectedPrivateChatPeer else { return }
markPrivateMessagesAsRead(from: peerID)
let viewModel = self.viewModel
DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiAnimationMediumSeconds) { [weak viewModel] in
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) {
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
Task { @MainActor in
viewModel?.markPrivateMessagesAsRead(from: peerID)
work()
}
}
}
func handleScreenshotCaptured() {
let screenshotMessage = "* \(viewModel.nickname) took a screenshot *"
func stopMeshServices() {
meshService.stopServices()
}
if let peerID = viewModel.selectedPrivateChatPeer {
func refreshBluetoothState() {
if let bleService = meshService as? BLEService {
updateBluetoothState(bleService.getCurrentBluetoothState())
}
}
func forceSaveIdentity() {
identityManager.forceSave()
}
@discardableResult
func verifyIdentityKeyExists() -> Bool {
keychain.verifyIdentityKeyExists()
}
}
@MainActor
final class ChatLifecycleCoordinator {
private unowned let context: any ChatLifecycleContext
init(context: any ChatLifecycleContext) {
self.context = context
}
func handleDidBecomeActive() {
context.refreshBluetoothState()
guard let peerID = context.selectedPrivateChatPeer else { return }
markPrivateMessagesAsRead(from: peerID)
let context = self.context
context.scheduleOnMainAfter(TransportConfig.uiAnimationMediumSeconds) { [weak context] in
context?.markPrivateMessagesAsRead(from: peerID)
}
}
func handleScreenshotCaptured() {
let screenshotMessage = "* \(context.nickname) took a screenshot *"
if let peerID = context.selectedPrivateChatPeer {
sendPrivateScreenshotNotificationIfPossible(
screenshotMessage,
to: peerID
@@ -40,9 +153,9 @@ final class ChatLifecycleCoordinator {
return
}
switch viewModel.activeChannel {
switch context.activeChannel {
case .mesh:
viewModel.meshService.sendMessage(
context.sendMeshMessage(
screenshotMessage,
mentions: [],
messageID: UUID().uuidString,
@@ -56,43 +169,40 @@ final class ChatLifecycleCoordinator {
)
}
viewModel.addSystemMessage("you took a screenshot")
context.addSystemMessage("you took a screenshot")
}
func saveIdentityState() {
viewModel.identityManager.forceSave()
_ = viewModel.keychain.verifyIdentityKeyExists()
context.forceSaveIdentity()
context.verifyIdentityKeyExists()
}
func applicationWillTerminate() {
viewModel.meshService.stopServices()
context.stopMeshServices()
saveIdentityState()
}
func markPrivateMessagesAsRead(from peerID: PeerID) {
viewModel.privateChatManager.markAsRead(from: peerID)
viewModel.synchronizePrivateConversationStore()
context.markChatAsRead(from: peerID)
if peerID.isGeoDM,
let recipientHex = viewModel.nostrKeyMapping[peerID],
case .location(let channel) = viewModel.activeChannel,
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
let messages = viewModel.privateChats[peerID] ?? []
let recipientHex = context.nostrKeyMapping[peerID],
case .location(let channel) = context.activeChannel,
let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let messages = context.privateMessages(for: peerID)
for message in messages where message.senderPeerID == peerID && !message.isRelay {
guard !viewModel.sentReadReceipts.contains(message.id) else { continue }
guard !context.sentReadReceipts.contains(message.id) else { continue }
SecureLogger.debug(
"GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))",
category: .session
)
let nostrTransport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
nostrTransport.senderPeerID = viewModel.meshService.myPeerID
nostrTransport.sendReadReceiptGeohash(
context.sendGeohashReadReceipt(
message.id,
toRecipientHex: recipientHex,
from: identity
)
viewModel.sentReadReceipts.insert(message.id)
context.markReadReceiptSent(message.id)
}
return
}
@@ -101,16 +211,16 @@ final class ChatLifecycleCoordinator {
var peerNostrPubkey: String?
if let noiseKey = Data(hexString: peerID.id),
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) {
noiseKeyHex = peerID
peerNostrPubkey = favoriteStatus.peerNostrPublicKey
} else if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
} else if let peer = context.unifiedPeer(for: peerID) {
noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: peer.noisePublicKey)
let favoriteStatus = context.favoriteRelationship(forNoiseKey: peer.noisePublicKey)
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
if let noiseKeyHex, viewModel.unreadPrivateMessages.contains(noiseKeyHex) {
viewModel.unreadPrivateMessages.remove(noiseKeyHex)
if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) {
context.markPrivateChatRead(noiseKeyHex)
}
}
@@ -121,38 +231,36 @@ final class ChatLifecycleCoordinator {
continue
}
guard !viewModel.sentReadReceipts.contains(message.id) else { continue }
guard !context.sentReadReceipts.contains(message.id) else { continue }
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: viewModel.meshService.myPeerID,
readerNickname: viewModel.nickname
readerID: context.myPeerID,
readerNickname: context.nickname
)
let recipientPeerID = peerID.isHex
? peerID
: (viewModel.unifiedPeerService.getPeer(by: peerID)?.peerID ?? peerID)
: (context.unifiedPeer(for: peerID)?.peerID ?? peerID)
viewModel.messageRouter.sendReadReceipt(receipt, to: recipientPeerID)
viewModel.sentReadReceipts.insert(message.id)
context.routeReadReceipt(receipt, to: recipientPeerID)
context.markReadReceiptSent(message.id)
}
}
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
guard let peerID else { return viewModel.messages }
guard let peerID else { return context.messages }
return getPrivateChatMessages(for: peerID)
}
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
var combined: [BitchatMessage] = []
if let ephemeralMessages = viewModel.privateChats[peerID] {
combined.append(contentsOf: ephemeralMessages)
}
combined.append(contentsOf: context.privateMessages(for: peerID))
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
if let peer = context.unifiedPeer(for: peerID) {
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
if noiseKeyHex != peerID, let stableMessages = viewModel.privateChats[noiseKeyHex] {
combined.append(contentsOf: stableMessages)
if noiseKeyHex != peerID {
combined.append(contentsOf: context.privateMessages(for: noiseKeyHex))
}
}
@@ -175,12 +283,12 @@ final class ChatLifecycleCoordinator {
private extension ChatLifecycleCoordinator {
func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) {
guard let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) else { return }
guard let peerNickname = context.peerNickname(for: peerID) else { return }
let sessionState = viewModel.meshService.getNoiseSessionState(for: peerID)
let sessionState = context.noiseSessionState(for: peerID)
switch sessionState {
case .established:
viewModel.messageRouter.sendPrivate(
context.routePrivateMessage(
message,
to: peerID,
recipientNickname: peerNickname,
@@ -203,30 +311,25 @@ private extension ChatLifecycleCoordinator {
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: viewModel.meshService.peerNickname(peerID: peerID),
senderPeerID: viewModel.meshService.myPeerID
recipientNickname: context.peerNickname(for: peerID),
senderPeerID: context.myPeerID
)
var chats = viewModel.privateChats
if chats[peerID] == nil {
chats[peerID] = []
}
chats[peerID]?.append(notice)
viewModel.privateChats = chats
context.appendPrivateMessage(notice, to: peerID)
}
func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) {
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
Task { @MainActor [weak context = self.context] in
guard let context else { return }
do {
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: message,
geohash: channel.geohash,
senderIdentity: identity,
nickname: viewModel.nickname,
teleported: viewModel.locationManager.teleported
nickname: context.nickname,
teleported: context.isTeleported
)
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
@@ -236,10 +339,10 @@ private extension ChatLifecycleCoordinator {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
} catch {
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
viewModel.addSystemMessage(
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
}
@@ -6,26 +6,92 @@ import Foundation
import UIKit
#endif
/// The narrow surface `ChatMediaTransferCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatMediaTransferCoordinatorContextTests`) and makes its
/// true dependencies explicit.
@MainActor
protocol ChatMediaTransferContext: AnyObject {
// MARK: Composition state
var canSendMediaInCurrentContext: Bool { get }
var selectedPrivateChatPeer: PeerID? { get }
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
func nicknameForPeer(_ peerID: PeerID) -> String
func currentPublicSender() -> (name: String, peerID: PeerID)
// MARK: Message state
/// Appends a private message via the single-writer store intent.
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
/// Appends a public message via the single-writer store intent
/// (immediate: outgoing media placeholders must render without batching).
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
func removeMessage(withID messageID: String, cleanupFile: Bool)
func addSystemMessage(_ content: String)
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
// MARK: Delivery status & dedup
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus)
func normalizedContentKey(_ content: String) -> String
func recordContentKey(_ key: String, timestamp: Date)
// MARK: Mesh file transfer
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String)
func cancelTransfer(_ transferId: String)
}
extension ChatViewModel: ChatMediaTransferContext {
// `canSendMediaInCurrentContext`, `selectedPrivateChatPeer`, `nickname`,
// `myPeerID`, `activeChannel`, `nicknameForPeer(_:)`,
// `currentPublicSender()`,
// `appendPublicMessage(_:to:)`, `removeMessage(withID:cleanupFile:)`,
// `addSystemMessage(_:)`, `notifyUIChanged()`,
// `updateMessageDeliveryStatus(_:status:)`, `normalizedContentKey(_:)`,
// and `recordContentKey(_:timestamp:)` are shared requirements with the
// other contexts or satisfied by existing `ChatViewModel` members. The
// members below flatten mesh service accesses.
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
meshService.sendFileBroadcast(packet, transferId: transferId)
}
func cancelTransfer(_ transferId: String) {
meshService.cancelTransfer(transferId)
}
}
@MainActor
final class ChatMediaTransferCoordinator {
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatMediaTransferContext
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
private(set) var messageIDToTransferId: [String: String] = [:]
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(context: any ChatMediaTransferContext) {
self.context = context
}
func sendVoiceNote(at url: URL) {
guard viewModel.canSendMediaInCurrentContext else {
guard context.canSendMediaInCurrentContext else {
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
try? FileManager.default.removeItem(at: url)
viewModel.addSystemMessage("Voice notes are only available in mesh chats.")
context.addSystemMessage("Voice notes are only available in mesh chats.")
return
}
let targetPeer = viewModel.selectedPrivateChatPeer
let targetPeer = context.selectedPrivateChatPeer
let message = enqueueMediaMessage(
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
targetPeer: targetPeer
@@ -34,27 +100,29 @@ final class ChatMediaTransferCoordinator {
let transferId = makeTransferID(messageID: messageID)
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
do {
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
await MainActor.run {
await MainActor.run { [weak self] in
guard let self else { return }
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.viewModel.meshService.sendFilePrivate(packet, to: peerID, transferId: transferId)
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
} else {
self.viewModel.meshService.sendFileBroadcast(packet, transferId: transferId)
self.context.sendFileBroadcast(packet, transferId: transferId)
}
}
} catch ChatMediaPreparationError.voiceNoteTooLarge(let size) {
SecureLogger.warning("Voice note exceeds size limit (\(size) bytes)", category: .session)
try? FileManager.default.removeItem(at: url)
await MainActor.run {
await MainActor.run { [weak self] in
guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
}
} catch {
SecureLogger.error("Voice note send failed: \(error)", category: .session)
await MainActor.run {
await MainActor.run { [weak self] in
guard let self else { return }
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
}
}
@@ -65,10 +133,10 @@ final class ChatMediaTransferCoordinator {
func processThenSendImage(_ image: UIImage?) {
guard let image else { return }
Task.detached { [weak self] in
guard let self else { return }
do {
let processedURL = try ImageUtils.processImage(image)
await MainActor.run {
await MainActor.run { [weak self] in
guard let self else { return }
self.sendImage(from: processedURL)
}
} catch {
@@ -80,10 +148,10 @@ final class ChatMediaTransferCoordinator {
func processThenSendImage(from url: URL?) {
guard let url else { return }
Task.detached { [weak self] in
guard let self else { return }
do {
let processedURL = try ImageUtils.processImage(at: url)
await MainActor.run {
await MainActor.run { [weak self] in
guard let self else { return }
self.sendImage(from: processedURL)
}
} catch {
@@ -94,29 +162,29 @@ final class ChatMediaTransferCoordinator {
#endif
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
guard viewModel.canSendMediaInCurrentContext else {
guard context.canSendMediaInCurrentContext else {
SecureLogger.info("Image send blocked outside mesh/private context", category: .session)
cleanup?()
viewModel.addSystemMessage("Images are only available in mesh chats.")
context.addSystemMessage("Images are only available in mesh chats.")
return
}
let targetPeer = viewModel.selectedPrivateChatPeer
let targetPeer = context.selectedPrivateChatPeer
do {
try ImageUtils.validateImageSource(at: sourceURL)
} catch {
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
viewModel.addSystemMessage("Failed to prepare image for sending.")
context.addSystemMessage("Failed to prepare image for sending.")
return
}
Task.detached(priority: .userInitiated) { [weak self] in
guard let self else { return }
do {
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
await MainActor.run {
await MainActor.run { [weak self] in
guard let self else { return }
let message = self.enqueueMediaMessage(
content: "\(MimeType.Category.image.messagePrefix)\(prepared.outputURL.lastPathComponent)",
targetPeer: targetPeer
@@ -125,20 +193,22 @@ final class ChatMediaTransferCoordinator {
let transferId = self.makeTransferID(messageID: messageID)
self.registerTransfer(transferId: transferId, messageID: messageID)
if let peerID = targetPeer {
self.viewModel.meshService.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
} else {
self.viewModel.meshService.sendFileBroadcast(prepared.packet, transferId: transferId)
self.context.sendFileBroadcast(prepared.packet, transferId: transferId)
}
}
} catch ChatMediaPreparationError.imageTooLarge(let size) {
SecureLogger.warning("Processed image exceeds size limit (\(size) bytes)", category: .session)
await MainActor.run {
self.viewModel.addSystemMessage("Image is too large to send.")
await MainActor.run { [weak self] in
guard let self else { return }
self.context.addSystemMessage("Image is too large to send.")
}
} catch {
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
await MainActor.run {
self.viewModel.addSystemMessage("Failed to prepare image for sending.")
await MainActor.run { [weak self] in
guard let self else { return }
self.context.addSystemMessage("Failed to prepare image for sending.")
}
}
}
@@ -150,22 +220,19 @@ final class ChatMediaTransferCoordinator {
if let peerID = targetPeer {
message = BitchatMessage(
sender: viewModel.nickname,
sender: context.nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: viewModel.nicknameForPeer(peerID),
senderPeerID: viewModel.meshService.myPeerID,
recipientNickname: context.nicknameForPeer(peerID),
senderPeerID: context.myPeerID,
deliveryStatus: .sending
)
var chats = viewModel.privateChats
chats[peerID, default: []].append(message)
viewModel.privateChats = chats
viewModel.trimMessagesIfNeeded()
context.appendPrivateMessage(message, to: peerID)
} else {
let (displayName, senderPeerID) = viewModel.currentPublicSender()
let (displayName, senderPeerID) = context.currentPublicSender()
message = BitchatMessage(
sender: displayName,
content: content,
@@ -177,14 +244,12 @@ final class ChatMediaTransferCoordinator {
senderPeerID: senderPeerID,
deliveryStatus: .sending
)
viewModel.timelineStore.append(message, to: viewModel.activeChannel)
viewModel.refreshVisibleMessages(from: viewModel.activeChannel)
viewModel.trimMessagesIfNeeded()
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
}
let key = viewModel.deduplicationService.normalizedContentKey(message.content)
viewModel.deduplicationService.recordContentKey(key, timestamp: timestamp)
viewModel.objectWillChange.send()
let key = context.normalizedContentKey(message.content)
context.recordContentKey(key, timestamp: timestamp)
context.notifyUIChanged()
return message
}
@@ -213,7 +278,7 @@ final class ChatMediaTransferCoordinator {
}
func handleMediaSendFailure(messageID: String, reason: String) {
viewModel.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
context.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
clearTransferMapping(for: messageID)
}
@@ -221,18 +286,18 @@ final class ChatMediaTransferCoordinator {
switch event {
case .started(let id, let total):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
viewModel.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: 0, total: total))
case .updated(let id, let sent, let total):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
viewModel.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
case .completed(let id, _):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
viewModel.updateMessageDeliveryStatus(messageID, status: .sent)
context.updateMessageDeliveryStatus(messageID, status: .sent)
clearTransferMapping(for: messageID)
case .cancelled(let id, _, _):
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
clearTransferMapping(for: messageID)
viewModel.removeMessage(withID: messageID, cleanupFile: true)
context.removeMessage(withID: messageID, cleanupFile: true)
}
}
@@ -266,15 +331,15 @@ final class ChatMediaTransferCoordinator {
if let transferId = messageIDToTransferId[messageID],
let active = transferIdToMessageIDs[transferId]?.first,
active == messageID {
viewModel.meshService.cancelTransfer(transferId)
context.cancelTransfer(transferId)
}
clearTransferMapping(for: messageID)
viewModel.removeMessage(withID: messageID, cleanupFile: true)
context.removeMessage(withID: messageID, cleanupFile: true)
}
func deleteMediaMessage(messageID: String) {
clearTransferMapping(for: messageID)
viewModel.removeMessage(withID: messageID, cleanupFile: true)
context.removeMessage(withID: messageID, cleanupFile: true)
}
}
+24 -22
View File
@@ -14,7 +14,8 @@ final class ChatMessageFormatter {
self.viewModel = viewModel
}
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString {
let design = theme.bodyFontDesign
let isSelf: Bool = {
if let spid = message.senderPeerID {
if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat {
@@ -40,7 +41,7 @@ final class ChatMessageFormatter {
}()
let isDark = colorScheme == .dark
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) {
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant) {
return cachedText
}
@@ -52,7 +53,7 @@ final class ChatMessageFormatter {
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
let fontWeight: Font.Weight = isSelf ? .bold : .medium
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced)
senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: design)
if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") {
senderStyle.link = url
@@ -79,8 +80,8 @@ final class ChatMessageFormatter {
var plainStyle = AttributeContainer()
plainStyle.foregroundColor = baseColor
plainStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
result.append(AttributedString(content).mergingAttributes(plainStyle))
} else {
let hashtagRegex = Patterns.hashtag
@@ -197,8 +198,8 @@ final class ChatMessageFormatter {
var beforeStyle = AttributeContainer()
beforeStyle.foregroundColor = baseColor
beforeStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
if isMentioned {
beforeStyle.font = beforeStyle.font?.bold()
}
@@ -230,7 +231,7 @@ final class ChatMessageFormatter {
mentionStyle.font = .bitchatSystem(
size: 14,
weight: isSelf ? .bold : .semibold,
design: .monospaced
design: design
)
let mentionColor: Color = isMentionToMe ? .orange : baseColor
mentionStyle.foregroundColor = mentionColor
@@ -267,8 +268,8 @@ final class ChatMessageFormatter {
var tagStyle = AttributeContainer()
tagStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
tagStyle.foregroundColor = baseColor
if isGeohash && !attachedToMentionToken && standalone,
let url = URL(string: "bitchat://geohash/\(token)") {
@@ -280,15 +281,15 @@ final class ChatMessageFormatter {
var spacer = AttributeContainer()
spacer.foregroundColor = baseColor
spacer.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
result.append(AttributedString(" ").mergingAttributes(spacer))
} else {
var matchStyle = AttributeContainer()
matchStyle.font = .bitchatSystem(
size: 14,
weight: isSelf ? .bold : .semibold,
design: .monospaced
design: design
)
if type == "url" {
matchStyle.foregroundColor = isSelf ? .orange : .blue
@@ -310,8 +311,8 @@ final class ChatMessageFormatter {
var remainingStyle = AttributeContainer()
remainingStyle.foregroundColor = baseColor
remainingStyle.font = isSelf
? .bitchatSystem(size: 14, weight: .bold, design: .monospaced)
: .bitchatSystem(size: 14, design: .monospaced)
? .bitchatSystem(size: 14, weight: .bold, design: design)
: .bitchatSystem(size: 14, design: design)
if isMentioned {
remainingStyle.font = remainingStyle.font?.bold()
}
@@ -322,27 +323,28 @@ final class ChatMessageFormatter {
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
timestampStyle.font = .bitchatSystem(size: 10, design: design)
result.append(timestamp.mergingAttributes(timestampStyle))
} else {
var contentStyle = AttributeContainer()
contentStyle.foregroundColor = Color.gray
let content = AttributedString("* \(message.content) *")
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
contentStyle.font = .bitchatSystem(size: 12, design: design).italic()
result.append(content.mergingAttributes(contentStyle))
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
timestampStyle.font = .bitchatSystem(size: 10, design: design)
result.append(timestamp.mergingAttributes(timestampStyle))
}
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf)
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant)
return result
}
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString {
let design = theme.bodyFontDesign
let isSelf: Bool = {
if let spid = message.senderPeerID {
if case .location(let channel) = viewModel.activeChannel, spid.id.hasPrefix("nostr:"),
@@ -362,7 +364,7 @@ final class ChatMessageFormatter {
if message.sender == "system" {
var style = AttributeContainer()
style.foregroundColor = baseColor
style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced)
style.font = .bitchatSystem(size: 14, weight: .medium, design: design)
return AttributedString(message.sender).mergingAttributes(style)
}
@@ -370,7 +372,7 @@ final class ChatMessageFormatter {
let (baseName, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)
senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: design)
if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") {
senderStyle.link = url
+93 -752
View File
@@ -1,707 +1,66 @@
import BitFoundation
import BitLogger
import Foundation
import SwiftUI
import Tor
/// The surface `ChatNostrCoordinator` needs from its owner.
///
/// Inherits the component contexts (`GeohashSubscriptionContext`,
/// `NostrInboundPipelineContext`, `GeoPresenceContext`) so a single object
/// `ChatViewModel` in production, one mock in tests can back the whole
/// Nostr stack. The members declared here are only the residual
/// favorites/ack glue the slimmed coordinator still owns.
@MainActor
protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineContext, GeoPresenceContext {
var selectedPrivateChatPeer: PeerID? { get }
var nostrKeyMapping: [PeerID: String] { get }
func startPrivateChat(with peerID: PeerID)
func visibleGeohashPeople() -> [GeoPerson]
// MARK: Routing & acknowledgements (shared with `ChatPrivateConversationContext`)
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
func sendGeohashDeliveryAck(for 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)
/// The persisted favorite relationship for the peer's Noise static key, if any.
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 {
// All requirements including the component-context witnesses declared
// in `GeohashSubscriptionManager.swift`, `NostrInboundPipeline.swift`,
// `GeoPresenceTracker.swift`, and the favorites/notification witnesses in
// `ChatPrivateConversationCoordinator.swift`,
// `ChatPeerIdentityCoordinator.swift`, and
// `ChatVerificationCoordinator.swift` already exist on `ChatViewModel`.
}
/// Thin facade over the Nostr stack: owns and wires the three components and
/// keeps the residual favorites/ack glue that fits none of them.
///
/// - `subscriptions`: relay lifecycle and subscription IDs
/// (`GeohashSubscriptionManager`)
/// - `inbound`: the hot event -> message/payload pipeline
/// (`NostrInboundPipeline`)
/// - `presence`: teleport marking, sampling dedup, notification cooldown
/// (`GeoPresenceTracker`)
final class ChatNostrCoordinator {
private unowned let viewModel: ChatViewModel
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
}
@MainActor
func resubscribeCurrentGeohash() {
guard case .location(let channel) = viewModel.activeChannel else { return }
guard let subID = viewModel.geoSubscriptionID else {
switchLocationChannel(to: viewModel.activeChannel)
return
}
viewModel.participantTracker.startRefreshTimer()
NostrRelayManager.shared.unsubscribe(id: subID)
let filter = NostrFilter.geohashEphemeral(
channel.geohash,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
limit: TransportConfig.nostrGeohashInitialLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: channel.geohash,
count: TransportConfig.nostrGeoRelayCount
)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.subscribeNostrEvent(event)
}
}
if let dmSub = viewModel.geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
viewModel.geoDmSubscriptionID = nil
}
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
let dmSub = "geo-dm-\(channel.geohash)"
viewModel.geoDmSubscriptionID = dmSub
let dmFilter = NostrFilter.giftWrapsFor(
pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
Task { @MainActor [weak self] in
self?.subscribeGiftWrap(giftWrap, id: identity)
}
}
}
}
@MainActor
func subscribeNostrEvent(_ event: NostrEvent) {
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!viewModel.deduplicationService.hasProcessedNostrEvent(event.id)
else {
return
}
viewModel.deduplicationService.recordNostrEvent(event.id)
if let gh = viewModel.currentGeohash,
let myGeoIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 {
return
}
}
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmed
viewModel.locationPresenceStore.setNickname(nick, for: event.pubkey)
}
viewModel.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey)
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
}
let hasTeleportTag = event.tags.contains { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
}
if hasTeleportTag {
let key = event.pubkey.lowercased()
let isSelf: Bool = {
if let gh = viewModel.currentGeohash,
let myIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) {
return myIdentity.publicKeyHex.lowercased() == key
}
return false
}()
if !isSelf {
Task { @MainActor [weak viewModel] in
viewModel?.locationPresenceStore.markTeleported(key)
}
}
}
let senderName = viewModel.displayNameForNostrPubkey(event.pubkey)
let content = event.content.trimmed
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date())
let mentions = viewModel.parseMentions(from: content)
let message = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: timestamp,
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
let isBlocked = viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
viewModel.handlePublicMessage(message)
if !isBlocked {
viewModel.checkForMentions(message)
viewModel.sendHapticFeedback(for: message)
}
}
}
@MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
guard !viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) else { return }
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
),
let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let noisePayload = NoisePayload.decode(packet.payload)
else {
return
}
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
let convKey = PeerID(nostr_: senderPubkey)
viewModel.nostrKeyMapping[convKey] = senderPubkey
switch noisePayload.type {
case .privateMessage:
viewModel.handlePrivateMessage(
noisePayload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
viewModel.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
viewModel.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
}
@MainActor
func switchLocationChannel(to channel: ChannelID) {
viewModel.publicMessagePipeline.reset()
viewModel.activeChannel = channel
viewModel.publicMessagePipeline.updateActiveChannel(channel)
viewModel.deduplicationService.clearNostrCaches()
switch channel {
case .mesh:
viewModel.refreshVisibleMessages(from: .mesh)
let emptyMesh = viewModel.messages.filter { $0.content.trimmed.isEmpty }.count
if emptyMesh > 0 {
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
}
viewModel.participantTracker.stopRefreshTimer()
viewModel.participantTracker.setActiveGeohash(nil)
viewModel.locationPresenceStore.clearTeleportedGeo()
case .location:
viewModel.refreshVisibleMessages(from: channel)
}
if case .location = channel {
for content in viewModel.timelineStore.drainPendingGeohashSystemMessages() {
viewModel.addPublicSystemMessage(content)
}
}
if let sub = viewModel.geoSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub)
viewModel.geoSubscriptionID = nil
}
if let dmSub = viewModel.geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
viewModel.geoDmSubscriptionID = nil
}
viewModel.currentGeohash = nil
viewModel.participantTracker.setActiveGeohash(nil)
viewModel.locationPresenceStore.clearGeoNicknames()
guard case .location(let channel) = channel else { return }
viewModel.currentGeohash = channel.geohash
viewModel.participantTracker.setActiveGeohash(channel.geohash)
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
let hasRegional = !viewModel.locationManager.availableChannels.isEmpty
let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash }
let key = identity.publicKeyHex.lowercased()
if viewModel.locationManager.teleported && hasRegional && !inRegional {
viewModel.locationPresenceStore.markTeleported(key)
SecureLogger.info(
"GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
category: .session
)
} else {
viewModel.locationPresenceStore.clearTeleported(key)
}
}
let subID = "geo-\(channel.geohash)"
viewModel.geoSubscriptionID = subID
viewModel.participantTracker.startRefreshTimer()
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.handleNostrEvent(event)
}
}
subscribeToGeoChat(channel)
}
@MainActor
func handleNostrEvent(_ event: NostrEvent) {
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else {
return
}
if viewModel.deduplicationService.hasProcessedNostrEvent(event.id) { return }
viewModel.deduplicationService.recordNostrEvent(event.id)
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
return
}
let hasTeleportTag = event.tags.contains { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
}
let isSelf: Bool = {
if let gh = viewModel.currentGeohash,
let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
}
return false
}()
if hasTeleportTag, !isSelf {
let key = event.pubkey.lowercased()
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
viewModel.locationPresenceStore.markTeleported(key)
SecureLogger.info(
"GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
category: .session
)
}
}
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey)
if isSelf {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 {
return
}
}
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
viewModel.locationPresenceStore.setNickname(nickTag[1].trimmed, for: event.pubkey)
}
viewModel.nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
viewModel.nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
}
let senderName = viewModel.displayNameForNostrPubkey(event.pubkey)
let content = event.content
if let teleTag = event.tags.first(where: { $0.first == "t" }),
teleTag.count >= 2,
teleTag[1] == "teleport",
content.trimmed.isEmpty {
return
}
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let mentions = viewModel.parseMentions(from: content)
let message = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: min(rawTs, Date()),
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
viewModel.handlePublicMessage(message)
viewModel.checkForMentions(message)
viewModel.sendHapticFeedback(for: message)
}
}
@MainActor
func subscribeToGeoChat(_ channel: GeohashChannel) {
guard let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else { return }
let dmSub = "geo-dm-\(channel.geohash)"
viewModel.geoDmSubscriptionID = dmSub
if TorManager.shared.isReady {
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
}
let dmFilter = NostrFilter.giftWrapsFor(
pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
Task { @MainActor [weak self] in
self?.handleGiftWrap(giftWrap, id: identity)
}
}
}
@MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard giftWrap.isValidSignature() else { return }
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) {
return
}
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
) else {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
return
}
SecureLogger.debug(
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
category: .session
)
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
let convKey = PeerID(nostr_: senderPubkey)
viewModel.nostrKeyMapping[convKey] = senderPubkey
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
viewModel.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
}
@MainActor
func sendGeohash(context: ChatViewModel.GeoOutgoingContext) {
let channel = context.channel
let event = context.event
let identity = context.identity
let targetRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: channel.geohash,
count: TransportConfig.nostrGeoRelayCount
)
if targetRelays.isEmpty {
SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
viewModel.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex)
viewModel.nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex
SecureLogger.debug(
"GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)",
category: .session
)
let hasRegional = !viewModel.locationManager.availableChannels.isEmpty
let inRegional = viewModel.locationManager.availableChannels.contains { $0.geohash == channel.geohash }
if context.teleported && hasRegional && !inRegional {
let key = identity.publicKeyHex.lowercased()
viewModel.locationPresenceStore.markTeleported(key)
SecureLogger.info(
"GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(viewModel.locationPresenceStore.teleportedGeo.count)",
category: .session
)
}
viewModel.deduplicationService.recordNostrEvent(event.id)
}
@MainActor
func beginGeohashSampling(for geohashes: [String]) {
if !TorManager.shared.isForeground() {
endGeohashSampling()
return
}
let desired = Set(geohashes)
let current = Set(viewModel.geoSamplingSubs.values)
let toAdd = desired.subtracting(current)
let toRemove = current.subtracting(desired)
for (subID, gh) in viewModel.geoSamplingSubs where toRemove.contains(gh) {
NostrRelayManager.shared.unsubscribe(id: subID)
viewModel.geoSamplingSubs.removeValue(forKey: subID)
}
for gh in toAdd {
subscribe(gh)
}
}
@MainActor
func subscribe(_ gh: String) {
let subID = "geo-sample-\(gh)"
viewModel.geoSamplingSubs[subID] = gh
let filter = NostrFilter.geohashEphemeral(
gh,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
limit: TransportConfig.nostrGeohashSampleLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.subscribeNostrEvent(event, gh: gh)
}
}
}
@MainActor
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
guard event.isValidSignature() else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else {
return
}
let existingCount = viewModel.participantTracker.participantCount(for: gh)
viewModel.participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh)
guard let content = event.content.trimmedOrNilIfEmpty else { return }
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
if let my = try? viewModel.idBridge.deriveIdentity(forGeohash: gh),
my.publicKeyHex.lowercased() == event.pubkey.lowercased() {
return
}
guard existingCount == 0 else { return }
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) > 30 { return }
#if os(iOS)
guard UIApplication.shared.applicationState == .active else { return }
if case .location(let channel) = viewModel.activeChannel, channel.geohash == gh { return }
#elseif os(macOS)
guard NSApplication.shared.isActive else { return }
if case .location(let channel) = viewModel.activeChannel, channel.geohash == gh { return }
#endif
cooldownPerGeohash(gh, content: content, event: event)
}
@MainActor
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
let now = Date()
let last = viewModel.lastGeoNotificationAt[gh] ?? .distantPast
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
let preview: String = {
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
if content.count <= maxLen { return content }
let idx = content.index(content.startIndex, offsetBy: maxLen)
return String(content[..<idx]) + ""
}()
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
viewModel.lastGeoNotificationAt[gh] = now
let senderSuffix = String(event.pubkey.suffix(4))
let nick = viewModel.geoNicknames[event.pubkey.lowercased()]
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let ts = min(rawTs, Date())
let mentions = viewModel.parseMentions(from: content)
let message = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: ts,
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
if viewModel.timelineStore.appendIfAbsent(message, toGeohash: gh) {
viewModel.synchronizePublicConversationStore(forGeohash: gh)
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
}
}
}
@MainActor
func endGeohashSampling() {
for subID in viewModel.geoSamplingSubs.keys {
NostrRelayManager.shared.unsubscribe(id: subID)
}
viewModel.geoSamplingSubs.removeAll()
}
@MainActor
func setupNostrMessageHandling() {
guard let currentIdentity = try? viewModel.idBridge.getCurrentNostrIdentity() else {
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
return
}
SecureLogger.debug(
"🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...",
category: .session
)
let filter = NostrFilter.giftWrapsFor(
pubkey: currentIdentity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
viewModel.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
Task { @MainActor [weak self] in
self?.handleNostrMessage(event)
}
}
}
@MainActor
func handleNostrMessage(_ giftWrap: NostrEvent) {
if viewModel.deduplicationService.hasProcessedNostrEvent(giftWrap.id) { return }
viewModel.deduplicationService.recordNostrEvent(giftWrap.id)
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processNostrMessage(giftWrap)
}
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
guard giftWrap.isValidSignature() else { return }
let currentIdentity: NostrIdentity? = await MainActor.run {
try? viewModel.idBridge.getCurrentNostrIdentity()
}
guard let currentIdentity else { return }
do {
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: currentIdentity
)
if content.hasPrefix("verify:") {
return
}
if content.hasPrefix("bitchat1:") {
let packet: BitchatPacket? = await MainActor.run {
Self.decodeEmbeddedBitChatPacket(from: content)
}
guard let packet else {
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return
}
let actualSenderNoiseKey: Data? = await MainActor.run {
self.findNoiseKey(for: senderPubkey)
}
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
if packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
await MainActor.run {
viewModel.nostrKeyMapping[targetPeerID] = senderPubkey
switch payload.type {
case .privateMessage:
viewModel.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: targetPeerID,
id: currentIdentity,
messageTimestamp: messageTimestamp
)
case .delivered:
viewModel.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt:
viewModel.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse:
break
}
}
}
} else {
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
}
} catch {
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
}
}
@MainActor
func findNoiseKey(for nostrPubkey: String) -> Data? {
let favorites = FavoritesPersistenceService.shared.favorites.values
var npubToMatch = nostrPubkey
if !nostrPubkey.hasPrefix("npub") {
if let pubkeyData = Data(hexString: nostrPubkey),
let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) {
npubToMatch = encoded
} else {
SecureLogger.warning(
"⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...",
category: .session
)
}
}
for relationship in favorites {
if let storedNostrKey = relationship.peerNostrPublicKey {
if storedNostrKey == npubToMatch {
return relationship.peerNoisePublicKey
}
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
return relationship.peerNoisePublicKey
}
}
}
SecureLogger.debug(
"⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)",
category: .session
)
return nil
private weak var context: (any ChatNostrContext)?
let presence: GeoPresenceTracker
let inbound: NostrInboundPipeline
let subscriptions: GeohashSubscriptionManager
init(context: any ChatNostrContext) {
self.context = context
let presence = GeoPresenceTracker(context: context)
let inbound = NostrInboundPipeline(context: context, presence: presence)
self.presence = presence
self.inbound = inbound
self.subscriptions = GeohashSubscriptionManager(context: context, inbound: inbound, presence: presence)
}
@MainActor
@@ -711,33 +70,26 @@ final class ChatNostrCoordinator {
senderPubkey: String,
key: Data?
) {
guard let context else { return }
if let _ = key {
if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity)
if let identity = context.currentNostrIdentity() {
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
}
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendDeliveryAckGeohash(for: message.id, toRecipientHex: senderPubkey, from: identity)
} else if let identity = context.currentNostrIdentity() {
context.sendGeohashDeliveryAck(for: message.id, toRecipientHex: senderPubkey, from: identity)
SecureLogger.debug(
"Sent DELIVERED ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))",
category: .session
)
}
if !wasReadBefore && viewModel.selectedPrivateChatPeer == message.senderPeerID {
if !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
if let _ = key {
if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
if let identity = context.currentNostrIdentity() {
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
}
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
} else if let identity = context.currentNostrIdentity() {
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
SecureLogger.debug(
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))",
category: .session
@@ -748,16 +100,17 @@ final class ChatNostrCoordinator {
@MainActor
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
guard let senderNoiseKey = findNoiseKey(for: nostrPubkey) else { return }
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 {
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: senderNoiseKey,
peerNostrPublicKey: nostrPubkey,
peerNickname: senderNickname
context.addFavorite(
noiseKey: senderNoiseKey,
nostrPublicKey: nostrPubkey,
nickname: senderNickname
)
}
@@ -782,14 +135,14 @@ final class ChatNostrCoordinator {
"💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...",
category: .session
)
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: senderNoiseKey,
peerNostrPublicKey: extractedNostrPubkey,
peerNickname: senderNickname
context.addFavorite(
noiseKey: senderNoiseKey,
nostrPublicKey: extractedNostrPubkey,
nickname: senderNickname
)
}
NotificationService.shared.sendLocalNotification(
context.postLocalNotification(
title: isFavorite ? "New Favorite" : "Favorite Removed",
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
identifier: "fav-\(UUID().uuidString)"
@@ -798,22 +151,24 @@ final class ChatNostrCoordinator {
@MainActor
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
guard let relationship = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey),
guard let context else { return }
guard let relationship = context.favoriteRelationship(forNoiseKey: noisePublicKey),
relationship.peerNostrPublicKey != nil else {
SecureLogger.warning("⚠️ Cannot send favorite notification - no Nostr key for peer", category: .session)
return
}
let peerID = PeerID(hexData: noisePublicKey)
viewModel.messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
context.routeFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
@MainActor
func nostrPubkeyForDisplayName(_ name: String) -> String? {
for person in viewModel.visibleGeohashPeople() where person.displayName == name {
guard let context else { return nil }
for person in context.visibleGeohashPeople() where person.displayName == name {
return person.id
}
for (pub, nick) in viewModel.geoNicknames where nick == name {
for (pub, nick) in context.geoNicknames where nick == name {
return pub
}
return nil
@@ -821,38 +176,24 @@ final class ChatNostrCoordinator {
@MainActor
func startGeohashDM(withPubkeyHex hex: String) {
guard let context else { return }
let convKey = PeerID(nostr_: hex)
viewModel.nostrKeyMapping[convKey] = hex
viewModel.startPrivateChat(with: convKey)
context.registerNostrKeyMapping(hex, for: convKey)
context.startPrivateChat(with: convKey)
}
@MainActor
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
viewModel.nostrKeyMapping[senderID]
guard let context else { return nil }
return context.nostrKeyMapping[senderID]
}
@MainActor
func geohashDisplayName(for convKey: PeerID) -> String {
guard let full = viewModel.nostrKeyMapping[convKey] else {
guard let context else { return convKey.bare }
guard let full = context.nostrKeyMapping[convKey] else {
return convKey.bare
}
return viewModel.displayNameForNostrPubkey(full)
}
}
private extension ChatNostrCoordinator {
@MainActor
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
guard content.hasPrefix("bitchat1:") else { return nil }
let encoded = String(content.dropFirst("bitchat1:".count))
let maxBytes = FileTransferLimits.maxFramedFileBytes
let maxEncoded = ((maxBytes + 2) / 3) * 4
guard encoded.count <= maxEncoded else { return nil }
guard let packetData = Base64URLCoding.decode(encoded),
packetData.count <= maxBytes
else {
return nil
}
return BitchatPacket.from(packetData)
return context.displayNameForNostrPubkey(full)
}
}
@@ -2,34 +2,96 @@ import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatOutgoingCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatOutgoingCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatOutgoingContext: AnyObject {
// MARK: Identity & channel state
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
var selectedPrivateChatPeer: PeerID? { get }
var isTeleported: Bool { get }
// MARK: Commands & private messages
func handleCommand(_ command: String)
func updatePrivateChatPeerIfNeeded()
func sendPrivateMessage(_ content: String, to peerID: PeerID)
// MARK: Public timeline (local echo)
func parseMentions(from content: String) -> [String]
/// Appends a public message via the single-writer store intent
/// (immediate: the local echo must render without batching).
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
func addSystemMessage(_ content: String)
// MARK: Content dedup
func normalizedContentKey(_ content: String) -> String
func recordContentKey(_ key: String, timestamp: Date)
// MARK: Outbound routing
/// Stamps "now" as the channel's last public activity (background nudges).
/// (Single mutation path for the owner's `lastPublicActivityAt`; this
/// coordinator never reads it.)
func recordPublicActivity(forChannelKey key: String)
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
func sendGeohash(context: ChatViewModel.GeoOutgoingContext)
// MARK: Geohash identity (shared with the other contexts)
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
}
extension ChatViewModel: ChatOutgoingContext {
// `nickname`, `myPeerID`, `activeChannel`, `selectedPrivateChatPeer`,
// `isTeleported`, `handleCommand(_:)`, `updatePrivateChatPeerIfNeeded()`,
// `sendPrivateMessage(_:to:)`, `parseMentions(from:)`,
// `appendPublicMessage(_:to:)`, `addSystemMessage(_:)`,
// `normalizedContentKey(_:)`, `recordContentKey(_:timestamp:)`,
// `sendMeshMessage(_:mentions:messageID:timestamp:)`,
// `sendGeohash(context:)`, and `deriveNostrIdentity(forGeohash:)` are
// shared requirements with the other contexts or satisfied by existing
// `ChatViewModel` members. The single-writer intent op below lives next to
// its backing state's owner.
func recordPublicActivity(forChannelKey key: String) {
lastPublicActivityAt[key] = Date()
}
}
@MainActor
final class ChatOutgoingCoordinator {
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatOutgoingContext
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(context: any ChatOutgoingContext) {
self.context = context
}
func sendMessage(_ content: String) {
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
if content.hasPrefix("/") {
Task { @MainActor [weak viewModel] in
viewModel?.handleCommand(content)
Task { @MainActor [weak context = self.context] in
context?.handleCommand(content)
}
return
}
if viewModel.selectedPrivateChatPeer != nil {
viewModel.updatePrivateChatPeerIfNeeded()
if context.selectedPrivateChatPeer != nil {
context.updatePrivateChatPeerIfNeeded()
if let selectedPeer = viewModel.selectedPrivateChatPeer {
viewModel.sendPrivateMessage(content, to: selectedPeer)
if let selectedPeer = context.selectedPrivateChatPeer {
context.sendPrivateMessage(content, to: selectedPeer)
}
return
}
let mentions = viewModel.parseMentions(from: content)
let mentions = context.parseMentions(from: content)
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
guard let preparedMessage else { return }
@@ -51,28 +113,28 @@ private extension ChatOutgoingCoordinator {
mentions: [String]
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
var geoContext: ChatViewModel.GeoOutgoingContext?
var displaySender = viewModel.nickname
var localSenderPeerID = viewModel.meshService.myPeerID
var displaySender = context.nickname
var localSenderPeerID = context.myPeerID
var messageID: String?
var messageTimestamp = Date()
switch viewModel.activeChannel {
switch context.activeChannel {
case .mesh:
break
case .location(let channel):
do {
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let suffix = String(identity.publicKeyHex.suffix(4))
displaySender = viewModel.nickname + "#" + suffix
displaySender = context.nickname + "#" + suffix
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
let teleported = viewModel.locationManager.teleported
let teleported = context.isTeleported
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: trimmed,
geohash: channel.geohash,
senderIdentity: identity,
nickname: viewModel.nickname,
nickname: context.nickname,
teleported: teleported
)
@@ -86,7 +148,7 @@ private extension ChatOutgoingCoordinator {
)
} catch {
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
viewModel.addSystemMessage(
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return nil
@@ -107,12 +169,10 @@ private extension ChatOutgoingCoordinator {
}
func appendLocalEcho(_ message: BitchatMessage) {
viewModel.timelineStore.append(message, to: viewModel.activeChannel)
viewModel.refreshVisibleMessages(from: viewModel.activeChannel)
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
let contentKey = viewModel.deduplicationService.normalizedContentKey(message.content)
viewModel.deduplicationService.recordContentKey(contentKey, timestamp: message.timestamp)
viewModel.trimMessagesIfNeeded()
let contentKey = context.normalizedContentKey(message.content)
context.recordContentKey(contentKey, timestamp: message.timestamp)
}
func routePublicMessage(
@@ -122,10 +182,10 @@ private extension ChatOutgoingCoordinator {
messageID: String,
timestamp: Date
) {
switch viewModel.activeChannel {
switch context.activeChannel {
case .mesh:
viewModel.lastPublicActivityAt["mesh"] = Date()
viewModel.meshService.sendMessage(
context.recordPublicActivity(forChannelKey: "mesh")
context.sendMeshMessage(
originalContent,
mentions: mentions,
messageID: messageID,
@@ -133,18 +193,18 @@ private extension ChatOutgoingCoordinator {
)
case .location(let channel):
viewModel.lastPublicActivityAt["geo:\(channel.geohash)"] = Date()
context.recordPublicActivity(forChannelKey: "geo:\(channel.geohash)")
guard let geoContext, geoContext.channel.geohash == channel.geohash else {
SecureLogger.error("Geo: missing send context for \(channel.geohash)", category: .session)
viewModel.addSystemMessage(
context.addSystemMessage(
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
)
return
}
Task { @MainActor [weak viewModel] in
viewModel?.sendGeohash(context: geoContext)
Task { @MainActor [weak context = self.context] in
context?.sendGeohash(context: geoContext)
}
}
}
@@ -3,24 +3,247 @@ import BitLogger
import CoreBluetooth
import Foundation
final class ChatPeerIdentityCoordinator {
private unowned let viewModel: ChatViewModel
/// The narrow surface `ChatPeerIdentityCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatPeerIdentityCoordinatorContextTests`) and makes its true
/// dependencies explicit. Several members are flattened service accesses
/// this coordinator implements the `ChatViewModel`-level peer-identity API, so
/// its context members deliberately sit one level below those wrappers
/// (`unifiedIsBlocked(_:)` vs `isPeerBlocked(_:)`, `unifiedFingerprint(for:)`
/// vs `getFingerprint(for:)`, ) to avoid call cycles.
@MainActor
protocol ChatPeerIdentityContext: AnyObject {
// MARK: Conversation state
var privateChats: [PeerID: [BitchatMessage]] { get }
/// A single private chat's timeline. Witnessed by the store-direct
/// lookup on `ChatViewModel` (no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
/// Clears the peer's unread flag (single-writer store intent).
func markPrivateChatRead(_ peerID: PeerID)
/// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat
/// (dedup by ID, order preserved, unread carried, old chat removed).
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID)
var selectedPrivateChatPeer: PeerID? { get set }
var selectedPrivateChatFingerprint: String? { get set }
var nickname: String { get }
var myPeerID: PeerID { get }
var activeChannel: ChannelID { get }
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
func addSystemMessage(_ content: String)
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
// MARK: Private chat session lifecycle
/// Merges messages stored under alternate peer-ID representations into `peerID`'s chat.
/// Returns `true` when unread messages were discovered during consolidation.
@discardableResult
func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool
/// Marks read receipts as sent for own messages already delivered/read in
/// `peerID`'s chat. (Single mutation path into the owner's
/// `sentReadReceipts`; this coordinator never touches the raw set.)
func syncReadReceiptsForSentMessages(for peerID: PeerID)
/// Re-targets the private chat session: selection mutates through the
/// `ConversationStore` intent (the store owns selection).
func beginPrivateChatSession(with peerID: PeerID)
func markPrivateMessagesAsRead(from peerID: PeerID)
// MARK: Unified peer service
var connectedPeers: Set<PeerID> { get }
/// The peer's current entry in the unified peer service, if known.
func unifiedPeer(for peerID: PeerID) -> BitchatPeer?
func unifiedIsBlocked(_ peerID: PeerID) -> Bool
func unifiedToggleFavorite(_ peerID: PeerID)
func unifiedFingerprint(for peerID: PeerID) -> String?
func unifiedPeerID(forNickname nickname: String) -> PeerID?
/// Resolves the ephemeral (short) peer ID for a known Noise public key, if connected.
func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID?
// MARK: Mesh & Noise sessions
func peerNickname(for peerID: PeerID) -> String?
func meshPeerNicknames() -> [PeerID: String]
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState
func triggerHandshake(with peerID: PeerID)
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
func hasNoiseSession(with peerID: PeerID) -> Bool
/// Our own Noise identity fingerprint.
func noiseIdentityFingerprint() -> String
// MARK: Identity store (fingerprints & encryption status)
func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID)
/// Moves the stored fingerprint mapping from `oldPeerID` to `newPeerID`,
/// falling back to `fallback` when none was stored. Returns the migrated fingerprint.
func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String?
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID)
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus?
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID)
func invalidateStoredEncryptionCache(for peerID: PeerID?)
func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity?
// MARK: Favorites
/// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
/// The persisted favorite relationship for a short (ephemeral) peer ID, if any.
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship?
/// Adds (or updates) a favorite in the favorites store.
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String)
/// Removes a favorite from the favorites store.
func removeFavorite(noiseKey: Data)
// MARK: Geohash & Nostr
var geoNicknames: [String: String] { get }
func visibleGeohashPeople() -> [GeoPerson]
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID)
func bridgedNostrPublicKey(for noiseKey: Data) -> String?
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool)
}
extension ChatViewModel: ChatPeerIdentityContext {
// `privateChats`, `unreadPrivateMessages`, `selectedPrivateChatPeer`,
// `selectedPrivateChatFingerprint`, `nickname`, `myPeerID`,
// `activeChannel`, `connectedPeers`, `geoNicknames`, `notifyUIChanged()`,
// `addSystemMessage(_:)`, `peerNickname(for:)`, `meshPeerNicknames()`,
// `ephemeralPeerID(forNoiseKey:)`, `unifiedPeer(for:)`,
// `registerNostrKeyMapping(_:for:)`, `visibleGeohashPeople()`,
// `markPrivateMessagesAsRead(from:)`, `sendFavoriteNotificationViaNostr`,
// and the conversation-store sync methods are shared requirements with
// the other contexts or satisfied by existing `ChatViewModel` members.
// The single-writer intent op `syncReadReceiptsForSentMessages(for:)`
// lives next to its backing state in `ChatViewModel`. The members below
// flatten nested service accesses into intent-named calls.
@discardableResult
func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool {
privateChatManager.consolidateMessages(
for: peerID,
peerNickname: peerNickname,
persistedReadReceipts: sentReadReceipts
)
}
func beginPrivateChatSession(with peerID: PeerID) {
privateChatManager.startChat(with: peerID)
}
func unifiedIsBlocked(_ peerID: PeerID) -> Bool {
unifiedPeerService.isBlocked(peerID)
}
func unifiedToggleFavorite(_ peerID: PeerID) {
unifiedPeerService.toggleFavorite(peerID)
}
func unifiedFingerprint(for peerID: PeerID) -> String? {
unifiedPeerService.getFingerprint(for: peerID)
}
func unifiedPeerID(forNickname nickname: String) -> PeerID? {
unifiedPeerService.getPeerID(for: nickname)
}
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
meshService.getNoiseSessionState(for: peerID)
}
func triggerHandshake(with peerID: PeerID) {
meshService.triggerHandshake(with: peerID)
}
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool {
if case .established = meshService.getNoiseSessionState(for: peerID) { return true }
return false
}
func hasNoiseSession(with peerID: PeerID) -> Bool {
switch meshService.getNoiseSessionState(for: peerID) {
case .established, .handshaking: return true
case .none, .handshakeQueued, .failed: return false
}
}
func noiseIdentityFingerprint() -> String {
meshService.noiseIdentityFingerprint()
}
func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) {
peerIdentityStore.setFingerprint(fingerprint, for: peerID)
}
func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? {
peerIdentityStore.migrateFingerprintMapping(from: oldPeerID, to: newPeerID, fallback: fallback)
}
func isVerifiedFingerprint(_ fingerprint: String) -> Bool {
peerIdentityStore.isVerified(fingerprint)
}
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
peerIdentityStore.setEncryptionStatus(status, for: peerID)
}
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
peerIdentityStore.cachedEncryptionStatus(for: peerID)
}
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
peerIdentityStore.setCachedEncryptionStatus(status, for: peerID)
}
func invalidateStoredEncryptionCache(for peerID: PeerID?) {
peerIdentityStore.invalidateEncryptionCache(for: peerID)
}
func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? {
identityManager.getSocialIdentity(for: fingerprint)
}
func bridgedNostrPublicKey(for noiseKey: Data) -> String? {
idBridge.getNostrPublicKey(for: noiseKey)
}
// `favoriteRelationship(forNoiseKey:)` is shared with
// `ChatPrivateConversationContext`; its witness lives in
// `ChatPrivateConversationCoordinator.swift`.
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? {
FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)
}
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nostrPublicKey,
peerNickname: nickname
)
}
func removeFavorite(noiseKey: Data) {
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noiseKey)
}
}
final class ChatPeerIdentityCoordinator {
private unowned let context: any ChatPeerIdentityContext
init(context: any ChatPeerIdentityContext) {
self.context = context
}
@MainActor
func openMostRelevantPrivateChat() {
let unreadSorted = viewModel.unreadPrivateMessages
.map { ($0, viewModel.privateChats[$0]?.last?.timestamp ?? Date.distantPast) }
let unreadSorted = context.unreadPrivateMessages
.map { ($0, context.privateMessages(for: $0).last?.timestamp ?? Date.distantPast) }
.sorted { $0.1 > $1.1 }
if let target = unreadSorted.first?.0 {
startPrivateChat(with: target)
return
}
let recent = viewModel.privateChats
let recent = context.privateChats
.map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) }
.sorted { $0.ts > $1.ts }
if let target = recent.first?.id {
@@ -30,7 +253,7 @@ final class ChatPeerIdentityCoordinator {
@MainActor
func isPeerBlocked(_ peerID: PeerID) -> Bool {
viewModel.unifiedPeerService.isBlocked(peerID)
context.unifiedIsBlocked(peerID)
}
@MainActor
@@ -38,24 +261,24 @@ final class ChatPeerIdentityCoordinator {
var noiseKeyPeerID: PeerID?
var nostrPeerID: PeerID?
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
if let peer = context.unifiedPeer(for: peerID) {
noiseKeyPeerID = PeerID(hexData: peer.noisePublicKey)
if let nostrHex = peer.nostrPublicKey {
nostrPeerID = PeerID(nostr_: nostrHex)
}
}
let context = ChatUnreadPeerContext(
let unreadContext = ChatUnreadPeerContext(
peerID: peerID,
noiseKeyPeerID: noiseKeyPeerID,
nostrPeerID: nostrPeerID,
nickname: viewModel.meshService.peerNickname(peerID: peerID)
nickname: context.peerNickname(for: peerID)
)
return ChatUnreadStateResolver.hasUnreadMessages(
for: context,
unreadPrivateMessages: viewModel.unreadPrivateMessages,
privateChats: viewModel.privateChats
for: unreadContext,
unreadPrivateMessages: context.unreadPrivateMessages,
privateChats: context.privateChats
)
}
@@ -66,46 +289,44 @@ final class ChatPeerIdentityCoordinator {
return
}
viewModel.unifiedPeerService.toggleFavorite(peerID)
viewModel.objectWillChange.send()
context.unifiedToggleFavorite(peerID)
context.notifyUIChanged()
}
@MainActor
func isFavorite(peerID: PeerID) -> Bool {
if let noisePublicKey = peerID.noiseKey {
return FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)?.isFavorite ?? false
return context.favoriteRelationship(forNoiseKey: noisePublicKey)?.isFavorite ?? false
}
return viewModel.unifiedPeerService.getPeer(by: peerID)?.isFavorite ?? false
return context.unifiedPeer(for: peerID)?.isFavorite ?? false
}
@MainActor
func updatePrivateChatPeerIfNeeded() {
guard let chatFingerprint = viewModel.selectedPrivateChatFingerprint,
guard let chatFingerprint = context.selectedPrivateChatFingerprint,
let currentPeerID = currentPeerID(forFingerprint: chatFingerprint) else {
return
}
if let oldPeerID = viewModel.selectedPrivateChatPeer, oldPeerID != currentPeerID {
if let oldPeerID = context.selectedPrivateChatPeer, oldPeerID != currentPeerID {
migrateChatState(from: oldPeerID, to: currentPeerID)
viewModel.selectedPrivateChatPeer = currentPeerID
} else if viewModel.selectedPrivateChatPeer == nil {
viewModel.selectedPrivateChatPeer = currentPeerID
context.selectedPrivateChatPeer = currentPeerID
} else if context.selectedPrivateChatPeer == nil {
context.selectedPrivateChatPeer = currentPeerID
}
var unread = viewModel.unreadPrivateMessages
unread.remove(currentPeerID)
viewModel.unreadPrivateMessages = unread
context.markPrivateChatRead(currentPeerID)
}
@MainActor
func startPrivateChat(with peerID: PeerID) {
guard peerID != viewModel.meshService.myPeerID else { return }
guard peerID != context.myPeerID else { return }
let peerNickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "unknown"
let peerNickname = context.peerNickname(for: peerID) ?? "unknown"
if viewModel.unifiedPeerService.isBlocked(peerID) {
viewModel.addSystemMessage(
if context.unifiedIsBlocked(peerID) {
context.addSystemMessage(
String(
format: String(
localized: "system.chat.blocked",
@@ -118,9 +339,9 @@ final class ChatPeerIdentityCoordinator {
return
}
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID),
if let peer = context.unifiedPeer(for: peerID),
peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected {
viewModel.addSystemMessage(
context.addSystemMessage(
String(
format: String(
localized: "system.chat.requires_favorite",
@@ -133,16 +354,12 @@ final class ChatPeerIdentityCoordinator {
return
}
_ = viewModel.privateChatManager.consolidateMessages(
for: peerID,
peerNickname: peerNickname,
persistedReadReceipts: viewModel.sentReadReceipts
)
_ = context.consolidatePrivateMessages(for: peerID, peerNickname: peerNickname)
if !peerID.isGeoDM && !peerID.isGeoChat {
switch viewModel.meshService.getNoiseSessionState(for: peerID) {
switch context.noiseSessionState(for: peerID) {
case .none, .failed:
viewModel.meshService.triggerHandshake(with: peerID)
context.triggerHandshake(with: peerID)
case .handshakeQueued, .handshaking, .established:
break
}
@@ -150,28 +367,22 @@ final class ChatPeerIdentityCoordinator {
SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session)
}
viewModel.privateChatManager.syncReadReceiptsForSentMessages(
peerID: peerID,
nickname: viewModel.nickname,
externalReceipts: &viewModel.sentReadReceipts
)
context.syncReadReceiptsForSentMessages(for: peerID)
if let fingerprint = getFingerprint(for: peerID) {
viewModel.peerIdentityStore.setFingerprint(fingerprint, for: peerID)
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(fingerprint)
context.setStoredFingerprint(fingerprint, for: peerID)
context.selectedPrivateChatFingerprint = fingerprint
} else {
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(nil)
context.selectedPrivateChatFingerprint = nil
}
viewModel.privateChatManager.startChat(with: peerID)
viewModel.synchronizePrivateConversationStore()
viewModel.synchronizeConversationSelectionStore()
viewModel.markPrivateMessagesAsRead(from: peerID)
context.beginPrivateChatSession(with: peerID)
context.markPrivateMessagesAsRead(from: peerID)
}
@MainActor
func endPrivateChat() {
viewModel.selectedPrivateChatPeer = nil
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(nil)
context.selectedPrivateChatPeer = nil
context.selectedPrivateChatFingerprint = nil
}
@MainActor
@@ -182,8 +393,8 @@ final class ChatPeerIdentityCoordinator {
func handleFavoriteStatusChanged(_ notification: Notification) {
guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return }
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
Task { @MainActor [weak context = self.context] in
guard let context else { return }
if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool,
isKeyUpdate,
@@ -200,28 +411,26 @@ final class ChatPeerIdentityCoordinator {
let peerID = PeerID(hexData: peerPublicKey)
let action = isFavorite ? "favorited" : "unfavorited"
let peerNickname = favoriteNotificationNickname(for: peerID, peerPublicKey: peerPublicKey)
viewModel.addSystemMessage("\(peerNickname) \(action) you")
context.addSystemMessage("\(peerNickname) \(action) you")
}
}
}
@MainActor
func updateEncryptionStatusForPeers() {
for peerID in viewModel.connectedPeers {
for peerID in context.connectedPeers {
updateEncryptionStatus(for: peerID)
}
}
@MainActor
func updateEncryptionStatus(for peerID: PeerID) {
let noiseService = viewModel.meshService.getNoiseService()
if noiseService.hasEstablishedSession(with: peerID) {
viewModel.peerIdentityStore.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID)
} else if noiseService.hasSession(with: peerID) {
viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID)
if context.hasEstablishedNoiseSession(with: peerID) {
context.setEncryptionStatus(verifiedEncryptionStatus(for: peerID), for: peerID)
} else if context.hasNoiseSession(with: peerID) {
context.setEncryptionStatus(.noiseHandshaking, for: peerID)
} else {
viewModel.peerIdentityStore.setEncryptionStatus(nil, for: peerID)
context.setEncryptionStatus(nil, for: peerID)
}
invalidateEncryptionCache(for: peerID)
@@ -229,12 +438,12 @@ final class ChatPeerIdentityCoordinator {
@MainActor
func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
if let cachedStatus = viewModel.peerIdentityStore.cachedEncryptionStatus(for: peerID) {
if let cachedStatus = context.cachedEncryptionStatus(for: peerID) {
return cachedStatus
}
let hasEverEstablishedSession = getFingerprint(for: peerID) != nil
let sessionState = viewModel.meshService.getNoiseSessionState(for: peerID)
let sessionState = context.noiseSessionState(for: peerID)
let status: EncryptionStatus
switch sessionState {
@@ -248,18 +457,18 @@ final class ChatPeerIdentityCoordinator {
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none
}
viewModel.peerIdentityStore.setCachedEncryptionStatus(status, for: peerID)
context.setCachedEncryptionStatus(status, for: peerID)
return status
}
@MainActor
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
viewModel.peerIdentityStore.invalidateEncryptionCache(for: peerID)
context.invalidateStoredEncryptionCache(for: peerID)
}
@MainActor
func getFingerprint(for peerID: PeerID) -> String? {
viewModel.unifiedPeerService.getFingerprint(for: peerID)
context.unifiedFingerprint(for: peerID)
}
@MainActor
@@ -270,12 +479,12 @@ final class ChatPeerIdentityCoordinator {
return peerID.id
}
if let nickname = viewModel.meshService.getPeerNicknames()[peerID] {
if let nickname = context.meshPeerNicknames()[peerID] {
return nickname
}
if let fingerprint = getFingerprint(for: peerID),
let identity = viewModel.identityManager.getSocialIdentity(for: fingerprint) {
let identity = context.socialIdentity(forFingerprint: fingerprint) {
if let petname = identity.localPetname {
return petname
}
@@ -289,19 +498,18 @@ final class ChatPeerIdentityCoordinator {
@MainActor
func getMyFingerprint() -> String {
viewModel.meshService.getNoiseService().getIdentityFingerprint()
context.noiseIdentityFingerprint()
}
@MainActor
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
switch viewModel.activeChannel {
switch context.activeChannel {
case .location:
if nickname.contains("#"),
let person = viewModel.publicConversationCoordinator
.visibleGeohashPeople()
let person = context.visibleGeohashPeople()
.first(where: { $0.displayName == nickname }) {
let conversationKey = PeerID(nostr_: person.id)
viewModel.nostrKeyMapping[conversationKey] = person.id
context.registerNostrKeyMapping(person.id, for: conversationKey)
return conversationKey
}
@@ -310,9 +518,9 @@ final class ChatPeerIdentityCoordinator {
.first
.map(String.init)?
.lowercased() ?? nickname.lowercased()
if let pubkey = viewModel.geoNicknames.first(where: { $0.value.lowercased() == base })?.key {
if let pubkey = context.geoNicknames.first(where: { $0.value.lowercased() == base })?.key {
let conversationKey = PeerID(nostr_: pubkey)
viewModel.nostrKeyMapping[conversationKey] = pubkey
context.registerNostrKeyMapping(pubkey, for: conversationKey)
return conversationKey
}
@@ -320,20 +528,20 @@ final class ChatPeerIdentityCoordinator {
break
}
return viewModel.unifiedPeerService.getPeerID(for: nickname)
return context.unifiedPeerID(forNickname: nickname)
}
@MainActor
func nicknameForPeer(_ peerID: PeerID) -> String {
if let name = viewModel.meshService.peerNickname(peerID: peerID) {
if let name = context.peerNickname(for: peerID) {
return name
}
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
if let favorite = context.favoriteRelationship(forPeerID: peerID),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
if let noiseKey = Data(hexString: peerID.id),
let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
let favorite = context.favoriteRelationship(forNoiseKey: noiseKey),
!favorite.peerNickname.isEmpty {
return favorite.peerNickname
}
@@ -344,7 +552,7 @@ final class ChatPeerIdentityCoordinator {
private extension ChatPeerIdentityCoordinator {
@MainActor
func currentPeerID(forFingerprint fingerprint: String) -> PeerID? {
for peerID in viewModel.connectedPeers where getFingerprint(for: peerID) == fingerprint {
for peerID in context.connectedPeers where getFingerprint(for: peerID) == fingerprint {
return peerID
}
return nil
@@ -352,63 +560,46 @@ private extension ChatPeerIdentityCoordinator {
@MainActor
func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) {
if let oldMessages = viewModel.privateChats[oldPeerID] {
var chats = viewModel.privateChats
chats[newPeerID, default: []].append(contentsOf: oldMessages)
chats[newPeerID]?.sort { $0.timestamp < $1.timestamp }
var seenMessageIDs = Set<String>()
chats[newPeerID] = chats[newPeerID]?.filter { message in
if seenMessageIDs.contains(message.id) {
return false
}
seenMessageIDs.insert(message.id)
return true
}
chats.removeValue(forKey: oldPeerID)
viewModel.privateChats = chats
}
var unread = viewModel.unreadPrivateMessages
if unread.contains(oldPeerID) {
unread.remove(oldPeerID)
unread.insert(newPeerID)
viewModel.unreadPrivateMessages = unread
}
// The store migration dedups by message ID, preserves timestamp
// order, carries the unread flag, and removes the old chat.
context.migratePrivateChat(from: oldPeerID, to: newPeerID)
}
@MainActor
func migrateNoiseKeyUpdate(oldPeerID: PeerID, newPeerID: PeerID) {
if viewModel.selectedPrivateChatPeer == oldPeerID {
// Capture before the migration: the store hands its selection off to
// `newPeerID` during `migrateChatState`, and the manager's selection
// mirrors the store, so the old peer ID is no longer selected after.
let wasSelected = context.selectedPrivateChatPeer == oldPeerID
if wasSelected {
SecureLogger.info("📱 Updating private chat peer ID due to key change: \(oldPeerID) -> \(newPeerID)", category: .session)
} else if viewModel.privateChats[oldPeerID] != nil {
} else if !context.privateMessages(for: oldPeerID).isEmpty {
SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
}
migrateChatState(from: oldPeerID, to: newPeerID)
if viewModel.selectedPrivateChatPeer == oldPeerID {
viewModel.selectedPrivateChatPeer = newPeerID
if wasSelected {
context.selectedPrivateChatPeer = newPeerID
}
if let fingerprint = viewModel.peerIdentityStore.migrateFingerprintMapping(
if let fingerprint = context.migrateFingerprintMapping(
from: oldPeerID,
to: newPeerID,
fallback: getFingerprint(for: newPeerID)
) {
if viewModel.selectedPrivateChatPeer == newPeerID {
viewModel.peerIdentityStore.setSelectedPrivateChatFingerprint(fingerprint)
if context.selectedPrivateChatPeer == newPeerID {
context.selectedPrivateChatFingerprint = fingerprint
}
}
}
@MainActor
func favoriteNotificationNickname(for peerID: PeerID, peerPublicKey: Data) -> String {
if let nickname = viewModel.meshService.peerNickname(peerID: peerID) {
if let nickname = context.peerNickname(for: peerID) {
return nickname
}
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: peerPublicKey) {
if let favorite = context.favoriteRelationship(forNoiseKey: peerPublicKey) {
return favorite.peerNickname
}
return "Unknown"
@@ -417,7 +608,7 @@ private extension ChatPeerIdentityCoordinator {
@MainActor
func verifiedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
if let fingerprint = getFingerprint(for: peerID),
viewModel.peerIdentityStore.isVerified(fingerprint) {
context.isVerifiedFingerprint(fingerprint) {
return .noiseVerified
}
return .noiseSecured
@@ -425,39 +616,47 @@ private extension ChatPeerIdentityCoordinator {
@MainActor
func toggleFavoriteForNoiseKey(_ noisePublicKey: Data, peerID: PeerID) {
if let ephemeralID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == noisePublicKey })?.peerID {
viewModel.unifiedPeerService.toggleFavorite(ephemeralID)
viewModel.objectWillChange.send()
if let ephemeralID = context.ephemeralPeerID(forNoiseKey: noisePublicKey) {
context.unifiedToggleFavorite(ephemeralID)
context.notifyUIChanged()
return
}
let currentStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
let fallbackNickname = viewModel.privateChats[peerID]?.first { $0.senderPeerID == peerID }?.sender
let currentStatus = context.favoriteRelationship(forNoiseKey: noisePublicKey)
let fallbackNickname = context.privateMessages(for: peerID).first { $0.senderPeerID == peerID }?.sender
let plan = ChatFavoriteTogglePolicy.plan(
currentStatus: currentStatus.map(ChatFavoriteStatusSnapshot.init),
fallbackNickname: fallbackNickname,
bridgedNostrKey: viewModel.idBridge.getNostrPublicKey(for: noisePublicKey)
bridgedNostrKey: context.bridgedNostrPublicKey(for: noisePublicKey)
)
switch plan.persistenceAction {
case .add(let nickname, let nostrKey):
FavoritesPersistenceService.shared.addFavorite(
peerNoisePublicKey: noisePublicKey,
peerNostrPublicKey: nostrKey,
peerNickname: nickname
context.addFavorite(
noiseKey: noisePublicKey,
nostrPublicKey: nostrKey,
nickname: nickname
)
case .remove:
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
context.removeFavorite(noiseKey: noisePublicKey)
}
viewModel.objectWillChange.send()
context.notifyUIChanged()
if case .send(let isFavorite) = plan.notification {
viewModel.sendFavoriteNotificationViaNostr(
context.sendFavoriteNotificationViaNostr(
noisePublicKey: noisePublicKey,
isFavorite: isFavorite
)
}
}
}
/// Default for conforming test contexts that model chats as a dictionary;
/// `ChatViewModel` overrides with a store-direct lookup.
extension ChatPeerIdentityContext {
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
}
+106 -35
View File
@@ -2,16 +2,86 @@ import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatPeerListCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatPeerListCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatPeerListContext: AnyObject {
// MARK: Connection & chat state
var isConnected: Bool { get set }
/// A single private chat's timeline (store-direct lookup on
/// `ChatViewModel`; no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
/// Clears the peer's unread flag (single-writer store intent).
func markPrivateChatRead(_ peerID: PeerID)
var hasTrackedPrivateChatSelection: Bool { get }
func updatePrivateChatPeerIfNeeded()
func cleanupOldReadReceipts()
// MARK: Peers & sessions
var unifiedPeers: [BitchatPeer] { get }
func isPeerConnected(_ peerID: PeerID) -> Bool
func isPeerReachable(_ peerID: PeerID) -> Bool
/// Number of mesh peers currently connected or reachable, from the
/// transport's live peer snapshots.
func activeMeshPeerCount() -> Int
func registerEphemeralSession(peerID: PeerID)
func updateEncryptionStatusForPeers()
// MARK: Notifications
/// Posts the "bitchatters nearby" local notification.
func notifyNetworkAvailable(peerCount: Int)
}
extension ChatViewModel: ChatPeerListContext {
// `isConnected`, `privateMessages(for:)`, `unreadPrivateMessages`,
// `hasTrackedPrivateChatSelection`, `updatePrivateChatPeerIfNeeded()`,
// `cleanupOldReadReceipts()`, `unifiedPeers`, `isPeerConnected(_:)`,
// `isPeerReachable(_:)`, `registerEphemeralSession(peerID:)`, and
// `updateEncryptionStatusForPeers()` are shared requirements with the
// other contexts or satisfied by existing `ChatViewModel` members. The
// member below flattens the nested transport access into an intent-named
// call.
func activeMeshPeerCount() -> Int {
meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || meshService.isPeerReachable(snapshot.peerID)
}
.count
}
func notifyNetworkAvailable(peerCount: Int) {
NotificationService.shared.sendNetworkAvailableNotification(peerCount: peerCount)
}
}
final class ChatPeerListCoordinator: @unchecked Sendable {
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatPeerListContext
private var recentlySeenPeers: Set<PeerID> = []
// The "bitchatters nearby" notification only fires on the transition from
// an empty mesh to a populated one joining peers while already meshed
// are visible in the app and must not notify. Set back to true only after
// a confirmed-empty reset, so brief link flaps stay silent.
private var meshWasEmpty = true
private var lastNetworkNotificationTime = Date.distantPast
private var networkResetTimer: Timer?
private var networkEmptyTimer: Timer?
private let networkResetGraceSeconds = TransportConfig.networkResetGraceSeconds
private let notificationCooldownSeconds: TimeInterval
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(
context: any ChatPeerListContext,
notificationCooldownSeconds: TimeInterval = TransportConfig.networkNotificationCooldownSeconds
) {
self.context = context
self.notificationCooldownSeconds = notificationCooldownSeconds
}
deinit {
@@ -29,23 +99,23 @@ final class ChatPeerListCoordinator: @unchecked Sendable {
private extension ChatPeerListCoordinator {
@MainActor
func handlePeerListUpdate(_ peers: [PeerID]) {
viewModel.isConnected = !peers.isEmpty
context.isConnected = !peers.isEmpty
cleanupStaleUnreadPeerIDs()
let meshPeers = peers.filter { peerID in
viewModel.meshService.isPeerConnected(peerID) || viewModel.meshService.isPeerReachable(peerID)
context.isPeerConnected(peerID) || context.isPeerReachable(peerID)
}
handleNetworkAvailability(meshPeers)
for peerID in peers {
viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
context.registerEphemeralSession(peerID: peerID)
}
viewModel.updateEncryptionStatusForPeers()
context.updateEncryptionStatusForPeers()
if viewModel.hasTrackedPrivateChatSelection {
viewModel.updatePrivateChatPeerIfNeeded()
if context.hasTrackedPrivateChatSelection {
context.updatePrivateChatPeerIfNeeded()
}
}
@@ -61,13 +131,20 @@ private extension ChatPeerListCoordinator {
invalidateNetworkEmptyTimer()
let newPeers = meshPeerSet.subtracting(recentlySeenPeers)
guard !newPeers.isEmpty else { return }
// Record every sighted peer even when no notification fires. A peer
// first seen during the cooldown (or while already meshed) must not
// still count as "new" at some later peer-list event that re-fired
// the notification while devices sat idle and connected.
recentlySeenPeers.formUnion(meshPeerSet)
let cooldown = TransportConfig.networkNotificationCooldownSeconds
if Date().timeIntervalSince(lastNetworkNotificationTime) >= cooldown {
recentlySeenPeers.formUnion(newPeers)
let cameFromEmpty = meshWasEmpty
meshWasEmpty = false
guard cameFromEmpty, !newPeers.isEmpty else { return }
if Date().timeIntervalSince(lastNetworkNotificationTime) >= notificationCooldownSeconds {
lastNetworkNotificationTime = Date()
NotificationService.shared.sendNetworkAvailableNotification(peerCount: meshPeers.count)
context.notifyNetworkAvailable(peerCount: meshPeers.count)
SecureLogger.info(
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
category: .session
@@ -79,34 +156,34 @@ private extension ChatPeerListCoordinator {
@MainActor
func cleanupStaleUnreadPeerIDs() {
let currentPeerIDs = Set(viewModel.unifiedPeerService.peers.map(\.peerID))
let staleIDs = viewModel.unreadPrivateMessages.subtracting(currentPeerIDs)
let currentPeerIDs = Set(context.unifiedPeers.map(\.peerID))
let staleIDs = context.unreadPrivateMessages.subtracting(currentPeerIDs)
guard !staleIDs.isEmpty else {
viewModel.cleanupOldReadReceipts()
context.cleanupOldReadReceipts()
return
}
var idsToRemove: [PeerID] = []
for staleID in staleIDs {
if staleID.isGeoDM, let messages = viewModel.privateChats[staleID], !messages.isEmpty {
if staleID.isGeoDM, !context.privateMessages(for: staleID).isEmpty {
continue
}
if staleID.isNoiseKeyHex, let messages = viewModel.privateChats[staleID], !messages.isEmpty {
if staleID.isNoiseKeyHex, !context.privateMessages(for: staleID).isEmpty {
continue
}
idsToRemove.append(staleID)
viewModel.unreadPrivateMessages.remove(staleID)
context.markPrivateChatRead(staleID)
}
if !idsToRemove.isEmpty {
SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session)
}
viewModel.cleanupOldReadReceipts()
context.cleanupOldReadReceipts()
}
@MainActor
@@ -121,18 +198,15 @@ private extension ChatPeerListCoordinator {
@MainActor
func handleNetworkResetTimerFired() {
let activeMeshPeers = viewModel.meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID)
}
let activeMeshPeerCount = context.activeMeshPeerCount()
if activeMeshPeers.isEmpty {
if activeMeshPeerCount == 0 {
recentlySeenPeers.removeAll()
meshWasEmpty = true
SecureLogger.debug("⏱️ Network notification window reset after quiet period", category: .session)
} else {
SecureLogger.debug(
"⏱️ Skipped network notification reset; still seeing \(activeMeshPeers.count) mesh peers",
"⏱️ Skipped network notification reset; still seeing \(activeMeshPeerCount) mesh peers",
category: .session
)
}
@@ -165,18 +239,15 @@ private extension ChatPeerListCoordinator {
@MainActor
func handleNetworkEmptyTimerFired() {
let activeMeshPeers = viewModel.meshService
.currentPeerSnapshots()
.filter { snapshot in
snapshot.isConnected || viewModel.meshService.isPeerReachable(snapshot.peerID)
}
let activeMeshPeerCount = context.activeMeshPeerCount()
if activeMeshPeers.isEmpty {
if activeMeshPeerCount == 0 {
recentlySeenPeers.removeAll()
meshWasEmpty = true
SecureLogger.debug("⏳ Mesh empty — notification state reset after confirmation", category: .session)
} else {
SecureLogger.debug(
"⏳ Mesh empty timer cancelled; \(activeMeshPeers.count) mesh peers detected again",
"⏳ Mesh empty timer cancelled; \(activeMeshPeerCount) mesh peers detected again",
category: .session
)
}
@@ -2,20 +2,235 @@ import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `ChatPrivateConversationCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatPrivateConversationCoordinatorContextTests`) and makes
/// its true dependencies explicit. The surface is intentionally large it
/// documents the coordinator's real coupling to private-chat state, peer
/// identity, and the routing/ack transports.
@MainActor
protocol ChatPrivateConversationContext: AnyObject {
// MARK: Conversation state
var privateChats: [PeerID: [BitchatMessage]] { get }
/// A single private chat's timeline. Witnessed by the store-direct
/// lookup on `ChatViewModel` (no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var sentReadReceipts: Set<String> { get }
var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get }
var nickname: String { get }
var activeChannel: ChannelID { get }
var nostrKeyMapping: [PeerID: String] { get }
// MARK: Conversation store intents
// The sole mutation paths for private message state (single-writer
// `ConversationStore` ops; see docs/CONVERSATION-STORE-DESIGN.md).
/// Appends a private message in timestamp order; returns `false` on
/// duplicate message ID.
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
/// Replace-or-append a private message by ID, keeping its position.
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID)
/// Applies a delivery status by message ID; returns `false` when the
/// message is unknown or the update would downgrade the status.
@discardableResult
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool
func markPrivateChatUnread(_ peerID: PeerID)
func markPrivateChatRead(_ peerID: PeerID)
/// Removes the peer's chat entirely, including unread state.
func removePrivateChat(_ peerID: PeerID)
/// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat
/// (dedup by ID, order preserved, unread carried, old chat removed).
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID)
/// `true` when any private chat contains a message with `messageID`.
func privateChatsContainMessage(withID messageID: String) -> Bool
/// `true` when `peerID`'s chat contains a message with `messageID`.
func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool
/// Records that a read receipt is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool
/// Records that a GeoDM delivery ACK is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@discardableResult
func markGeoDeliveryAckSent(_ messageID: String) -> Bool
/// Moves the open private chat to `newPeerID` when the current selection is
/// one of the peer IDs being migrated away.
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID)
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
// MARK: Peers & identity
var myPeerID: PeerID { get }
func peerNickname(for peerID: PeerID) -> String?
func isPeerConnected(_ peerID: PeerID) -> Bool
func isPeerReachable(_ peerID: PeerID) -> Bool
func isPeerBlocked(_ peerID: PeerID) -> Bool
func noisePublicKey(for peerID: PeerID) -> Data?
/// Resolves the ephemeral (short) peer ID for a known Noise public key, if connected.
func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID?
func getPeerIDForNickname(_ nickname: String) -> PeerID?
func getFingerprint(for peerID: PeerID) -> String?
func storedFingerprint(for peerID: PeerID) -> String?
func clearStoredFingerprint(for peerID: PeerID)
// MARK: Nostr identity
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
func currentNostrIdentity() -> NostrIdentity?
// MARK: Routing & acknowledgements
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
func sendGeohashDeliveryAck(for 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
func addSystemMessage(_ content: String)
func addMeshOnlySystemMessage(_ content: String)
// MARK: Favorites & notifications
/// The persisted favorite relationship for the peer's Noise static key, if any.
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
/// Persists that the peer favorited/unfavorited us (favorites store write).
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)
/// Posts the incoming-private-message local notification.
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID)
}
extension ChatViewModel: ChatPrivateConversationContext {
// `privateChats` and `notifyUIChanged()` are shared requirements with
// `ChatDeliveryContext`; the single-writer intent ops (`markReadReceiptSent`,
// `markGeoDeliveryAckSent`, `handOffSelectedPrivateChat`) live next to their
// backing state in `ChatViewModel`. The remaining state members are
// satisfied by existing `ChatViewModel` properties and methods.
var myPeerID: PeerID { meshService.myPeerID }
func peerNickname(for peerID: PeerID) -> String? {
meshService.peerNickname(peerID: peerID)
}
func isPeerConnected(_ peerID: PeerID) -> Bool {
meshService.isPeerConnected(peerID)
}
func isPeerReachable(_ peerID: PeerID) -> Bool {
meshService.isPeerReachable(peerID)
}
func noisePublicKey(for peerID: PeerID) -> Data? {
unifiedPeerService.getPeer(by: peerID)?.noisePublicKey
}
func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? {
unifiedPeerService.peers.first(where: { $0.noisePublicKey == noiseKey })?.peerID
}
func storedFingerprint(for peerID: PeerID) -> String? {
peerIDToPublicKeyFingerprint[peerID]
}
func clearStoredFingerprint(for peerID: PeerID) {
peerIdentityStore.setFingerprint(nil, for: peerID)
}
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
}
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
try idBridge.deriveIdentity(forGeohash: geohash)
}
func currentNostrIdentity() -> NostrIdentity? {
try? idBridge.getCurrentNostrIdentity()
}
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
}
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
messageRouter.sendReadReceipt(receipt, to: peerID)
}
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
meshService.sendReadReceipt(receipt, to: peerID)
}
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
makeGeohashNostrTransport().sendPrivateMessageGeohash(
content: content,
toRecipientHex: recipientHex,
from: identity,
messageID: messageID
)
}
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
makeGeohashNostrTransport().sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
}
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
makeGeohashNostrTransport().sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
}
func addSystemMessage(_ content: String) {
addSystemMessage(content, timestamp: Date())
}
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
}
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: noiseKey,
favorited: favorited,
peerNickname: nickname,
peerNostrPublicKey: nostrPublicKey
)
}
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) {
NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID)
}
private func makeGeohashNostrTransport() -> NostrTransport {
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
transport.senderPeerID = meshService.myPeerID
return transport
}
}
@MainActor
final class ChatPrivateConversationCoordinator {
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatPrivateConversationContext
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(context: any ChatPrivateConversationContext) {
self.context = context
}
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
guard !content.isEmpty else { return }
if viewModel.unifiedPeerService.isBlocked(peerID) {
let nickname = viewModel.meshService.peerNickname(peerID: peerID) ?? "user"
viewModel.addSystemMessage(
if context.isPeerBlocked(peerID) {
let nickname = context.peerNickname(for: peerID) ?? "user"
context.addSystemMessage(
String(
format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"),
locale: .current,
@@ -31,13 +246,13 @@ final class ChatPrivateConversationCoordinator {
}
guard let noiseKey = Data(hexString: peerID.id) else { return }
let isConnected = viewModel.meshService.isPeerConnected(peerID)
let isReachable = viewModel.meshService.isPeerReachable(peerID)
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
let isConnected = context.isPeerConnected(peerID)
let isReachable = context.isPeerReachable(peerID)
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey)
let isMutualFavorite = favoriteStatus?.isMutual ?? false
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
var recipientNickname = viewModel.meshService.peerNickname(peerID: peerID)
var recipientNickname = context.peerNickname(for: peerID)
if recipientNickname == nil && favoriteStatus != nil {
recipientNickname = favoriteStatus?.peerNickname
}
@@ -46,42 +261,39 @@ final class ChatPrivateConversationCoordinator {
let messageID = UUID().uuidString
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
sender: context.nickname,
content: content,
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: recipientNickname,
senderPeerID: viewModel.meshService.myPeerID,
senderPeerID: context.myPeerID,
mentions: nil,
deliveryStatus: .sending
)
if viewModel.privateChats[peerID] == nil {
viewModel.privateChats[peerID] = []
}
viewModel.privateChats[peerID]?.append(message)
viewModel.objectWillChange.send()
context.appendPrivateMessage(message, to: peerID)
context.notifyUIChanged()
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
viewModel.messageRouter.sendPrivate(
context.routePrivateMessage(
content,
to: peerID,
recipientNickname: recipientNickname ?? "user",
messageID: messageID
)
if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[idx].deliveryStatus = .sent
}
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
} else {
if let index = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[index].deliveryStatus = .failed(
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable")
)
}
),
forMessageID: messageID,
peerID: peerID
)
let name = recipientNickname ?? "user"
viewModel.addSystemMessage(
context.addSystemMessage(
String(
format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"),
locale: .current,
@@ -92,8 +304,8 @@ final class ChatPrivateConversationCoordinator {
}
func sendGeohashDM(_ content: String, to peerID: PeerID) {
guard case .location(let channel) = viewModel.activeChannel else {
viewModel.addSystemMessage(
guard case .location(let channel) = context.activeChannel else {
context.addSystemMessage(
String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel")
)
return
@@ -102,52 +314,54 @@ final class ChatPrivateConversationCoordinator {
let messageID = UUID().uuidString
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
sender: context.nickname,
content: content,
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
senderPeerID: viewModel.meshService.myPeerID,
recipientNickname: context.nickname,
senderPeerID: context.myPeerID,
deliveryStatus: .sending
)
if viewModel.privateChats[peerID] == nil {
viewModel.privateChats[peerID] = []
}
context.appendPrivateMessage(message, to: peerID)
context.notifyUIChanged()
viewModel.privateChats[peerID]?.append(message)
viewModel.objectWillChange.send()
guard let recipientHex = viewModel.nostrKeyMapping[peerID] else {
if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
guard let recipientHex = context.nostrKeyMapping[peerID] else {
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
)
}
),
forMessageID: messageID,
peerID: peerID
)
return
}
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .failed(
if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked")
)
}
viewModel.addSystemMessage(
),
forMessageID: messageID,
peerID: peerID
)
context.addSystemMessage(
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
)
return
}
do {
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
if recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[idx].deliveryStatus = .failed(
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself")
)
}
),
forMessageID: messageID,
peerID: peerID
)
return
}
@@ -155,23 +369,21 @@ final class ChatPrivateConversationCoordinator {
"GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
category: .session
)
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendPrivateMessageGeohash(
content: content,
context.sendGeohashPrivateMessage(
content,
toRecipientHex: recipientHex,
from: identity,
messageID: messageID
)
if let msgIdx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[msgIdx].deliveryStatus = .sent
}
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
} catch {
if let idx = viewModel.privateChats[peerID]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[peerID]?[idx].deliveryStatus = .failed(
context.setPrivateDeliveryStatus(
.failed(
reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error")
)
}
),
forMessageID: messageID,
peerID: peerID
)
}
}
@@ -189,16 +401,13 @@ final class ChatPrivateConversationCoordinator {
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
if viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
return
}
if viewModel.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return }
for (_, arr) in viewModel.privateChats where arr.contains(where: { $0.id == messageId }) {
return
}
if context.privateChatsContainMessage(withID: messageId) { return }
let senderName = viewModel.displayNameForNostrPubkey(senderPubkey)
let senderName = context.displayNameForNostrPubkey(senderPubkey)
let message = BitchatMessage(
id: messageId,
sender: senderName,
@@ -206,22 +415,19 @@ final class ChatPrivateConversationCoordinator {
timestamp: messageTimestamp,
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
recipientNickname: context.nickname,
senderPeerID: convKey,
deliveryStatus: .delivered(to: viewModel.nickname, at: Date())
deliveryStatus: .delivered(to: context.nickname, at: Date())
)
if viewModel.privateChats[convKey] == nil {
viewModel.privateChats[convKey] = []
}
viewModel.privateChats[convKey]?.append(message)
context.appendPrivateMessage(message, to: convKey)
let isViewing = viewModel.selectedPrivateChatPeer == convKey
let wasReadBefore = viewModel.sentReadReceipts.contains(messageId)
let isViewing = context.selectedPrivateChatPeer == convKey
let wasReadBefore = context.sentReadReceipts.contains(messageId)
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
if shouldMarkUnread {
viewModel.unreadPrivateMessages.insert(convKey)
context.markPrivateChatUnread(convKey)
}
if isViewing {
@@ -229,25 +435,22 @@ final class ChatPrivateConversationCoordinator {
}
if !isViewing && shouldMarkUnread {
NotificationService.shared.sendPrivateMessageNotification(
from: senderName,
message: pm.content,
peerID: convKey
)
context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: convKey)
}
viewModel.objectWillChange.send()
context.notifyUIChanged()
}
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
if let idx = viewModel.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[convKey]?[idx].deliveryStatus = .delivered(
to: viewModel.displayNameForNostrPubkey(senderPubkey),
at: Date()
if context.privateChat(convKey, containsMessageWithID: messageID) {
context.setPrivateDeliveryStatus(
.delivered(to: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
forMessageID: messageID,
peerID: convKey
)
viewModel.objectWillChange.send()
context.notifyUIChanged()
SecureLogger.info(
"GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))",
category: .session
@@ -260,12 +463,13 @@ final class ChatPrivateConversationCoordinator {
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
if let idx = viewModel.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) {
viewModel.privateChats[convKey]?[idx].deliveryStatus = .read(
by: viewModel.displayNameForNostrPubkey(senderPubkey),
at: Date()
if context.privateChat(convKey, containsMessageWithID: messageID) {
context.setPrivateDeliveryStatus(
.read(by: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
forMessageID: messageID,
peerID: convKey
)
viewModel.objectWillChange.send()
context.notifyUIChanged()
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))", category: .session)
} else {
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
@@ -273,19 +477,13 @@ final class ChatPrivateConversationCoordinator {
}
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
guard !viewModel.sentGeoDeliveryAcks.contains(messageId) else { return }
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubKey, from: id)
viewModel.sentGeoDeliveryAcks.insert(messageId)
guard context.markGeoDeliveryAckSent(messageId) else { return }
context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id)
}
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
guard !viewModel.sentReadReceipts.contains(messageId) else { return }
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubKey, from: id)
viewModel.sentReadReceipts.insert(messageId)
guard context.markReadReceiptSent(messageId) else { return }
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
}
func handlePrivateMessage(
@@ -315,15 +513,15 @@ final class ChatPrivateConversationCoordinator {
return
}
let wasReadBefore = viewModel.sentReadReceipts.contains(messageId)
let wasReadBefore = context.sentReadReceipts.contains(messageId)
var isViewingThisChat = false
if viewModel.selectedPrivateChatPeer == targetPeerID {
if context.selectedPrivateChatPeer == targetPeerID {
isViewingThisChat = true
} else if let selectedPeer = viewModel.selectedPrivateChatPeer,
let selectedPeerData = viewModel.unifiedPeerService.getPeer(by: selectedPeer),
} else if let selectedPeer = context.selectedPrivateChatPeer,
let selectedPeerNoiseKey = context.noisePublicKey(for: selectedPeer),
let key = actualSenderNoiseKey,
selectedPeerData.noisePublicKey == key {
selectedPeerNoiseKey == key {
isViewingThisChat = true
}
@@ -337,15 +535,15 @@ final class ChatPrivateConversationCoordinator {
timestamp: messageTimestamp,
isRelay: false,
isPrivate: true,
recipientNickname: viewModel.nickname,
recipientNickname: context.nickname,
senderPeerID: targetPeerID,
deliveryStatus: .delivered(to: viewModel.nickname, at: Date())
deliveryStatus: .delivered(to: context.nickname, at: Date())
)
addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID)
mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey)
viewModel.sendDeliveryAckViaNostrEmbedded(
context.sendDeliveryAckViaNostrEmbedded(
message,
wasReadBefore: wasReadBefore,
senderPubkey: senderPubkey,
@@ -372,12 +570,12 @@ final class ChatPrivateConversationCoordinator {
)
}
viewModel.objectWillChange.send()
context.notifyUIChanged()
}
func handlePrivateMessage(_ message: BitchatMessage) {
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
let senderPeerID = message.senderPeerID ?? viewModel.getPeerIDForNickname(message.sender)
let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender)
guard let peerID = senderPeerID else {
SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session)
@@ -391,22 +589,14 @@ final class ChatPrivateConversationCoordinator {
migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender)
if peerID.id.count == 16, let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
let stableKeyHex = PeerID(hexData: peer.noisePublicKey)
if peerID.id.count == 16, let peerNoiseKey = context.noisePublicKey(for: peerID) {
let stableKeyHex = PeerID(hexData: peerNoiseKey)
let nostrMessages = context.privateMessages(for: stableKeyHex)
if stableKeyHex != peerID,
let nostrMessages = viewModel.privateChats[stableKeyHex],
!nostrMessages.isEmpty {
if viewModel.privateChats[peerID] == nil {
viewModel.privateChats[peerID] = []
}
let existingMessageIds = Set(viewModel.privateChats[peerID]?.map { $0.id } ?? [])
for nostrMessage in nostrMessages where !existingMessageIds.contains(nostrMessage.id) {
viewModel.privateChats[peerID]?.append(nostrMessage)
}
viewModel.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
viewModel.privateChats.removeValue(forKey: stableKeyHex)
// Store migration dedups by ID, keeps timestamp order, and
// removes the stable-key chat.
context.migratePrivateChat(from: stableKeyHex, to: peerID)
SecureLogger.info(
"📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)",
@@ -420,69 +610,47 @@ final class ChatPrivateConversationCoordinator {
}
addMessageToPrivateChatsIfNeeded(message, targetPeerID: peerID)
let noiseKey = peerID.noiseKey ?? viewModel.unifiedPeerService.getPeer(by: peerID)?.noisePublicKey
let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID)
mirrorToEphemeralIfNeeded(message, targetPeerID: peerID, key: noiseKey)
let isViewing = viewModel.selectedPrivateChatPeer == peerID
let isViewing = context.selectedPrivateChatPeer == peerID
if isViewing {
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: viewModel.meshService.myPeerID,
readerNickname: viewModel.nickname
readerID: context.myPeerID,
readerNickname: context.nickname
)
viewModel.meshService.sendReadReceipt(receipt, to: peerID)
viewModel.sentReadReceipts.insert(message.id)
context.sendMeshReadReceipt(receipt, to: peerID)
context.markReadReceiptSent(message.id)
} else {
viewModel.unreadPrivateMessages.insert(peerID)
NotificationService.shared.sendPrivateMessageNotification(
from: message.sender,
message: message.content,
peerID: peerID
)
context.markPrivateChatUnread(peerID)
context.notifyPrivateMessage(from: message.sender, message: message.content, peerID: peerID)
}
viewModel.objectWillChange.send()
context.notifyUIChanged()
}
/// O(1)-per-conversation dedup via the store's message-ID indexes
/// (replaces the full scan over every private chat).
func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool {
if viewModel.privateChats[targetPeerID]?.contains(where: { $0.id == messageId }) == true {
return true
}
for (_, messages) in viewModel.privateChats where messages.contains(where: { $0.id == messageId }) {
return true
}
return false
context.privateChatsContainMessage(withID: messageId)
}
func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) {
if viewModel.privateChats[targetPeerID] == nil {
viewModel.privateChats[targetPeerID] = []
}
if let idx = viewModel.privateChats[targetPeerID]?.firstIndex(where: { $0.id == message.id }) {
viewModel.privateChats[targetPeerID]?[idx] = message
} else {
viewModel.privateChats[targetPeerID]?.append(message)
}
viewModel.privateChatManager.sanitizeChat(for: targetPeerID)
// Store upsert replaces in place by message ID or inserts in
// timestamp order; the old per-append sanitize re-sort is obsolete.
context.upsertPrivateMessage(message, in: targetPeerID)
}
func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) {
guard let key,
let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID,
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key),
ephemeralPeerID != targetPeerID
else {
return
}
if viewModel.privateChats[ephemeralPeerID] == nil {
viewModel.privateChats[ephemeralPeerID] = []
}
if let idx = viewModel.privateChats[ephemeralPeerID]?.firstIndex(where: { $0.id == message.id }) {
viewModel.privateChats[ephemeralPeerID]?[idx] = message
} else {
viewModel.privateChats[ephemeralPeerID]?.append(message)
}
viewModel.privateChatManager.sanitizeChat(for: ephemeralPeerID)
context.upsertPrivateMessage(message, in: ephemeralPeerID)
}
func handleViewingThisChat(
@@ -491,27 +659,25 @@ final class ChatPrivateConversationCoordinator {
key: Data?,
senderPubkey: String
) {
viewModel.unreadPrivateMessages.remove(targetPeerID)
context.markPrivateChatRead(targetPeerID)
if let key,
let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID {
viewModel.unreadPrivateMessages.remove(ephemeralPeerID)
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key) {
context.markPrivateChatRead(ephemeralPeerID)
}
guard !viewModel.sentReadReceipts.contains(message.id) else { return }
guard !context.sentReadReceipts.contains(message.id) else { return }
if let key {
let receipt = ReadReceipt(
originalMessageID: message.id,
readerID: viewModel.meshService.myPeerID,
readerNickname: viewModel.nickname
readerID: context.myPeerID,
readerNickname: context.nickname
)
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
viewModel.messageRouter.sendReadReceipt(receipt, to: PeerID(hexData: key))
viewModel.sentReadReceipts.insert(message.id)
} else if let identity = try? viewModel.idBridge.getCurrentNostrIdentity() {
let transport = NostrTransport(keychain: viewModel.keychain, idBridge: viewModel.idBridge)
transport.senderPeerID = viewModel.meshService.myPeerID
transport.sendReadReceiptGeohash(message.id, toRecipientHex: senderPubkey, from: identity)
viewModel.sentReadReceipts.insert(message.id)
context.routeReadReceipt(receipt, to: PeerID(hexData: key))
context.markReadReceiptSent(message.id)
} else if let identity = context.currentNostrIdentity() {
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
context.markReadReceiptSent(message.id)
SecureLogger.debug(
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))",
category: .session
@@ -529,18 +695,14 @@ final class ChatPrivateConversationCoordinator {
) {
guard shouldMarkAsUnread else { return }
viewModel.unreadPrivateMessages.insert(targetPeerID)
context.markPrivateChatUnread(targetPeerID)
if let key,
let ephemeralPeerID = viewModel.unifiedPeerService.peers.first(where: { $0.noisePublicKey == key })?.peerID,
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key),
ephemeralPeerID != targetPeerID {
viewModel.unreadPrivateMessages.insert(ephemeralPeerID)
context.markPrivateChatUnread(ephemeralPeerID)
}
if isRecentMessage {
NotificationService.shared.sendPrivateMessageNotification(
from: senderNickname,
message: messageContent,
peerID: targetPeerID
)
context.notifyPrivateMessage(from: senderNickname, message: messageContent, peerID: targetPeerID)
}
}
@@ -554,18 +716,18 @@ final class ChatPrivateConversationCoordinator {
SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session)
}
let noiseKey = peerID.noiseKey ?? viewModel.unifiedPeerService.getPeer(by: peerID)?.noisePublicKey
let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID)
guard let finalNoiseKey = noiseKey else {
SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session)
return
}
let prior = FavoritesPersistenceService.shared.getFavoriteStatus(for: finalNoiseKey)?.theyFavoritedUs ?? false
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
peerNoisePublicKey: finalNoiseKey,
let prior = context.favoriteRelationship(forNoiseKey: finalNoiseKey)?.theyFavoritedUs ?? false
context.updatePeerFavoritedUs(
noiseKey: finalNoiseKey,
favorited: isFavorite,
peerNickname: senderNickname,
peerNostrPublicKey: nostrPubkey
nickname: senderNickname,
nostrPublicKey: nostrPubkey
)
if isFavorite && nostrPubkey != nil {
@@ -577,7 +739,7 @@ final class ChatPrivateConversationCoordinator {
if prior != isFavorite {
let action = isFavorite ? "favorited" : "unfavorited"
viewModel.addMeshOnlySystemMessage("\(senderNickname) \(action) you")
context.addMeshOnlySystemMessage("\(senderNickname) \(action) you")
}
}
@@ -606,25 +768,31 @@ final class ChatPrivateConversationCoordinator {
}
func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) {
let currentFingerprint = viewModel.getFingerprint(for: peerID)
let currentFingerprint = context.getFingerprint(for: peerID)
if viewModel.privateChats[peerID] == nil || viewModel.privateChats[peerID]?.isEmpty == true {
var migratedMessages: [BitchatMessage] = []
if context.privateMessages(for: peerID).isEmpty {
// Chats migrated wholesale go through the store's
// `migrateConversation` intent; partially-migrated chats keep
// their non-recent tail, so the recent messages are copied in
// via ordered append (dedup by ID) instead.
var partiallyMigratedMessages: [BitchatMessage] = []
var oldPeerIDsToRemove: [PeerID] = []
var didMigrate = false
let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds)
for (oldPeerID, messages) in viewModel.privateChats where oldPeerID != peerID {
let oldFingerprint = viewModel.peerIDToPublicKeyFingerprint[oldPeerID]
for (oldPeerID, messages) in context.privateChats where oldPeerID != peerID {
let oldFingerprint = context.storedFingerprint(for: oldPeerID)
let recentMessages = messages.filter { $0.timestamp > cutoffTime }
guard !recentMessages.isEmpty else { continue }
if let currentFp = currentFingerprint,
let oldFp = oldFingerprint,
currentFp == oldFp {
migratedMessages.append(contentsOf: recentMessages)
didMigrate = true
if recentMessages.count == messages.count {
oldPeerIDsToRemove.append(oldPeerID)
} else {
partiallyMigratedMessages.append(contentsOf: recentMessages)
SecureLogger.info(
"📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)",
category: .session
@@ -637,14 +805,16 @@ final class ChatPrivateConversationCoordinator {
)
} else if currentFingerprint == nil || oldFingerprint == nil {
let isRelevantChat = recentMessages.contains { msg in
(msg.sender == senderNickname && msg.sender != viewModel.nickname)
|| (msg.sender == viewModel.nickname && msg.recipientNickname == senderNickname)
(msg.sender == senderNickname && msg.sender != context.nickname)
|| (msg.sender == context.nickname && msg.recipientNickname == senderNickname)
}
if isRelevantChat {
migratedMessages.append(contentsOf: recentMessages)
didMigrate = true
if recentMessages.count == messages.count {
oldPeerIDsToRemove.append(oldPeerID)
} else {
partiallyMigratedMessages.append(contentsOf: recentMessages)
}
SecureLogger.warning(
@@ -656,27 +826,24 @@ final class ChatPrivateConversationCoordinator {
}
if !oldPeerIDsToRemove.isEmpty {
let needsSelectedUpdate = oldPeerIDsToRemove.contains { viewModel.selectedPrivateChatPeer == $0 }
for oldID in oldPeerIDsToRemove {
viewModel.privateChats.removeValue(forKey: oldID)
viewModel.unreadPrivateMessages.remove(oldID)
viewModel.peerIdentityStore.setFingerprint(nil, for: oldID)
// The old behavior dropped the unread flag of removed
// chats instead of transferring it; clear it before the
// migration so the store doesn't carry it over.
context.markPrivateChatRead(oldID)
context.migratePrivateChat(from: oldID, to: peerID)
context.clearStoredFingerprint(for: oldID)
}
if needsSelectedUpdate {
viewModel.selectedPrivateChatPeer = peerID
}
context.handOffSelectedPrivateChat(from: oldPeerIDsToRemove, to: peerID)
}
if !migratedMessages.isEmpty {
if viewModel.privateChats[peerID] == nil {
viewModel.privateChats[peerID] = []
}
viewModel.privateChats[peerID]?.append(contentsOf: migratedMessages)
viewModel.privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
viewModel.privateChatManager.sanitizeChat(for: peerID)
viewModel.objectWillChange.send()
for message in partiallyMigratedMessages {
context.appendPrivateMessage(message, to: peerID)
}
if didMigrate {
context.notifyUIChanged()
}
}
}
@@ -686,29 +853,37 @@ final class ChatPrivateConversationCoordinator {
if let hexKey = Data(hexString: peerID.id) {
noiseKey = hexKey
} else if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
noiseKey = peer.noisePublicKey
} else if let peerNoiseKey = context.noisePublicKey(for: peerID) {
noiseKey = peerNoiseKey
}
if viewModel.meshService.isPeerConnected(peerID) {
viewModel.messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
if context.isPeerConnected(peerID) {
context.routeFavoriteNotification(to: peerID, isFavorite: isFavorite)
SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session)
} else if let key = noiseKey {
viewModel.messageRouter.sendFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite)
context.routeFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite)
} else {
SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session)
}
}
func isMessageBlocked(_ message: BitchatMessage) -> Bool {
if let peerID = message.senderPeerID ?? viewModel.getPeerIDForNickname(message.sender) {
if viewModel.isPeerBlocked(peerID) { return true }
if let peerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender) {
if context.isPeerBlocked(peerID) { return true }
if peerID.isGeoChat || peerID.isGeoDM,
let full = viewModel.nostrKeyMapping[peerID]?.lowercased(),
viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: full) {
let full = context.nostrKeyMapping[peerID]?.lowercased(),
context.isNostrBlocked(pubkeyHexLowercased: full) {
return true
}
}
return false
}
}
/// Default for conforming test contexts that model chats as a dictionary;
/// `ChatViewModel` overrides with a store-direct lookup.
extension ChatPrivateConversationContext {
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
}
@@ -7,16 +7,175 @@ import SwiftUI
import UIKit
#endif
/// The narrow surface `ChatPublicConversationCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatPublicConversationCoordinatorContextTests`) and makes
/// its true dependencies explicit. The surface is intentionally large it
/// documents the coordinator's real coupling to the public timeline, the
/// conversation stores, geohash participants, and the inbound public message
/// pipeline.
@MainActor
protocol ChatPublicConversationContext: AnyObject {
// MARK: Channel state
var activeChannel: ChannelID { get }
var currentGeohash: String? { get }
var nickname: String { get }
var myPeerID: PeerID { get }
/// Publishes the public-timeline batching state (UI animation suppression).
/// (Single mutation path for the owner's `isBatchingPublic`; this
/// coordinator never reads it.)
func setPublicBatching(_ isBatching: Bool)
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
// MARK: Public conversation store (single-writer intents)
/// Appends a public message in timestamp order. Returns `false` when a
/// message with the same ID is already in that conversation.
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
/// Appends a geohash message if absent. Returns `true` when stored.
@discardableResult
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool
func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool
/// Removes a message by ID from whichever public conversation contains it.
@discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage?
/// Removes every matching message from a geohash conversation (block purge).
func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool)
/// Empties a public conversation's timeline (`/clear`).
func clearPublicConversation(_ conversationID: ConversationID)
/// Queues a system message for the next geohash channel visit.
func queueGeohashSystemMessage(_ content: String)
// MARK: Private chats (block cleanup & message removal)
/// Removes the peer's chat entirely, including unread state
/// (single-writer store intent; no-op for unknown peers).
func removePrivateChat(_ peerID: PeerID)
/// Removes a message by ID from every private chat containing it,
/// dropping chats that become empty. Returns the removed message.
@discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
func cleanupLocalFile(forMessage message: BitchatMessage)
// MARK: Geohash participants & presence
var geoNicknames: [String: String] { get }
var isTeleported: Bool { get }
var nostrKeyMapping: [PeerID: String] { get }
/// Drops every key mapping that resolves to the given (lowercased) Nostr pubkey.
func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String)
func visibleGeoPeople() -> [GeoPerson]
func geoParticipantCount(for geohash: String) -> Int
func removeGeoParticipant(pubkeyHex: String)
// MARK: Nostr identity & blocking (shared with the other contexts)
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool)
// MARK: Mesh transport
func meshPeerNicknames() -> [PeerID: String]
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
// MARK: Inbound public message processing
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
func isMessageBlocked(_ message: BitchatMessage) -> Bool
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool
/// Buffers a visible-channel message for the batched (~80 ms) pipeline
/// flush, which commits it to `conversationID` in the store.
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID)
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID?
// MARK: Content dedup & formatting
func normalizedContentKey(_ content: String) -> String
func contentTimestamp(forKey key: String) -> Date?
func recordContentKey(_ key: String, timestamp: Date)
/// Pre-renders the message so the formatting cache is warm before display.
func prewarmMessageFormatting(_ message: BitchatMessage)
// MARK: Notifications
/// Posts the you-were-mentioned local notification.
func notifyMention(from sender: String, message: String)
}
extension ChatViewModel: ChatPublicConversationContext {
// `unreadPrivateMessages`, `nostrKeyMapping`,
// `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`,
// `myPeerID`, `isTeleported`, `notifyUIChanged()`,
// `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`,
// `deriveNostrIdentity(forGeohash:)`, the public conversation store
// intents (`appendPublicMessage(_:to:)`,
// `appendGeohashMessageIfAbsent(_:toGeohash:)`,
// `publicConversationContainsMessage(withID:in:)`,
// `removePublicMessage(withID:)`,
// `removePublicMessages(fromGeohash:where:)`,
// `clearPublicConversation(_:)`, and `queueGeohashSystemMessage(_:)`)
// are shared requirements with `ChatDeliveryContext` /
// `ChatPrivateConversationContext` / `ChatNostrContext` or satisfied by
// existing `ChatViewModel` members. The members below flatten nested
// service accesses into intent-named calls.
func visibleGeoPeople() -> [GeoPerson] {
participantTracker.getVisiblePeople()
}
func removeGeoParticipant(pubkeyHex: String) {
participantTracker.removeParticipant(pubkeyHex: pubkeyHex)
}
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: isBlocked)
}
func meshPeerNicknames() -> [PeerID: String] {
meshService.getPeerNicknames()
}
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
}
func allowPublicMessage(senderKey: String, contentKey: String) -> Bool {
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey)
}
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
publicMessagePipeline.enqueue(message, to: conversationID)
}
func normalizedContentKey(_ content: String) -> String {
deduplicationService.normalizedContentKey(content)
}
func contentTimestamp(forKey key: String) -> Date? {
deduplicationService.contentTimestamp(forKey: key)
}
func recordContentKey(_ key: String, timestamp: Date) {
deduplicationService.recordContentKey(key, timestamp: timestamp)
}
func prewarmMessageFormatting(_ message: BitchatMessage) {
_ = formatMessageAsText(message, colorScheme: currentColorScheme)
}
func notifyMention(from sender: String, message: String) {
NotificationService.shared.sendMentionNotification(from: sender, message: message)
}
}
@MainActor
final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatPublicConversationContext
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(context: any ChatPublicConversationContext) {
self.context = context
}
func visibleGeohashPeople() -> [GeoPerson] {
viewModel.participantTracker.getVisiblePeople()
context.visibleGeoPeople()
}
func getVisibleGeoParticipants() -> [CommandGeoParticipant] {
@@ -24,7 +183,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
}
func geohashParticipantCount(for geohash: String) -> Int {
viewModel.participantTracker.participantCount(for: geohash)
context.geoParticipantCount(for: geohash)
}
func displayNameForPubkey(_ pubkeyHex: String) -> String {
@@ -32,50 +191,36 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
}
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
context.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
}
func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool {
viewModel.identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
context.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
}
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
let hex = pubkeyHexLowercased.lowercased()
viewModel.identityManager.setNostrBlocked(hex, isBlocked: true)
viewModel.participantTracker.removeParticipant(pubkeyHex: hex)
context.setNostrBlocked(hex, isBlocked: true)
context.removeGeoParticipant(pubkeyHex: hex)
if let gh = viewModel.currentGeohash {
let predicate: (BitchatMessage) -> Bool = { [unowned viewModel] message in
if let gh = context.currentGeohash {
let predicate: (BitchatMessage) -> Bool = { [unowned context] message in
guard let senderPeerID = message.senderPeerID,
senderPeerID.isGeoDM || senderPeerID.isGeoChat else {
return false
}
if let full = viewModel.nostrKeyMapping[senderPeerID]?.lowercased() {
if let full = context.nostrKeyMapping[senderPeerID]?.lowercased() {
return full == hex
}
return false
}
viewModel.timelineStore.removeMessages(in: gh, where: predicate)
synchronizePublicConversationStore(forGeohash: gh)
if case .location = viewModel.activeChannel {
viewModel.messages.removeAll(where: predicate)
}
context.removePublicMessages(fromGeohash: gh, where: predicate)
}
let conversationPeerID = PeerID(nostr_: hex)
if viewModel.privateChats[conversationPeerID] != nil {
var privateChats = viewModel.privateChats
privateChats.removeValue(forKey: conversationPeerID)
viewModel.privateChats = privateChats
// The store intent no-ops when no such chat exists.
context.removePrivateChat(PeerID(nostr_: hex))
var unread = viewModel.unreadPrivateMessages
unread.remove(conversationPeerID)
viewModel.unreadPrivateMessages = unread
}
for (key, value) in viewModel.nostrKeyMapping where value.lowercased() == hex {
viewModel.nostrKeyMapping.removeValue(forKey: key)
}
context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)
addSystemMessage(
String(
@@ -90,7 +235,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
}
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
viewModel.identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: false)
context.setNostrBlocked(pubkeyHexLowercased, isBlocked: false)
addSystemMessage(
String(
format: String(
@@ -105,104 +250,45 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
let suffix = String(pubkeyHex.suffix(4))
if let geohash = viewModel.currentGeohash,
let myGeoIdentity = try? viewModel.idBridge.deriveIdentity(forGeohash: geohash),
if let geohash = context.currentGeohash,
let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: geohash),
myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() {
return viewModel.nickname + "#" + suffix
return context.nickname + "#" + suffix
}
if let nick = viewModel.geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty {
if let nick = context.geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty {
return nick + "#" + suffix
}
return "anon#\(suffix)"
}
func currentPublicSender() -> (name: String, peerID: PeerID) {
var displaySender = viewModel.nickname
var senderPeerID = viewModel.meshService.myPeerID
if case .location(let channel) = viewModel.activeChannel,
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
var displaySender = context.nickname
var senderPeerID = context.myPeerID
if case .location(let channel) = context.activeChannel,
let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let suffix = String(identity.publicKeyHex.suffix(4))
displaySender = viewModel.nickname + "#" + suffix
displaySender = context.nickname + "#" + suffix
senderPeerID = PeerID(nostr: identity.publicKeyHex)
}
return (displaySender, senderPeerID)
}
func removeMessage(withID messageID: String, cleanupFile: Bool = false) {
var removedMessage: BitchatMessage?
var removedMessage = context.removePublicMessage(withID: messageID)
if let index = viewModel.messages.firstIndex(where: { $0.id == messageID }) {
removedMessage = viewModel.messages.remove(at: index)
if let removedPrivateMessage = context.removePrivateMessage(withID: messageID) {
removedMessage = removedMessage ?? removedPrivateMessage
}
if let storeRemoved = viewModel.timelineStore.removeMessage(withID: messageID) {
removedMessage = removedMessage ?? storeRemoved
synchronizeAllPublicConversationStores()
}
var chats = viewModel.privateChats
for (peerID, items) in chats {
let filtered = items.filter { $0.id != messageID }
if filtered.count != items.count {
if filtered.isEmpty {
chats.removeValue(forKey: peerID)
} else {
chats[peerID] = filtered
}
if removedMessage == nil {
removedMessage = items.first(where: { $0.id == messageID })
}
}
}
viewModel.privateChats = chats
if cleanupFile, let removedMessage {
viewModel.cleanupLocalFile(forMessage: removedMessage)
context.cleanupLocalFile(forMessage: removedMessage)
}
viewModel.objectWillChange.send()
}
func initializeConversationStore() {
viewModel.conversationStore.setActiveChannel(viewModel.activeChannel)
synchronizePublicConversationStore(for: viewModel.activeChannel)
viewModel.synchronizePrivateConversationStore()
viewModel.synchronizeConversationSelectionStore()
}
func synchronizePublicConversationStore(for channel: ChannelID) {
let publicMessages = viewModel.timelineStore.messages(for: channel)
viewModel.conversationStore.replaceMessages(publicMessages, for: channel)
if channel == viewModel.activeChannel {
viewModel.conversationStore.setActiveChannel(viewModel.activeChannel)
}
}
func synchronizePublicConversationStore(forGeohash geohash: String) {
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
let publicMessages = viewModel.timelineStore.messages(for: channel)
viewModel.conversationStore.replaceMessages(publicMessages, for: .geohash(geohash.lowercased()))
}
func synchronizeAllPublicConversationStores() {
synchronizePublicConversationStore(for: .mesh)
for geohash in viewModel.timelineStore.geohashKeys() {
synchronizePublicConversationStore(forGeohash: geohash)
}
}
func refreshVisibleMessages(from channel: ChannelID? = nil) {
let target = channel ?? viewModel.activeChannel
viewModel.messages = viewModel.timelineStore.messages(for: target)
viewModel.conversationStore.replaceMessages(viewModel.messages, for: target)
if target == viewModel.activeChannel {
viewModel.conversationStore.setActiveChannel(viewModel.activeChannel)
}
context.notifyUIChanged()
}
func clearCurrentPublicTimeline() {
viewModel.messages.removeAll()
viewModel.timelineStore.clear(channel: viewModel.activeChannel)
context.clearPublicConversation(ConversationID(channelID: context.activeChannel))
Task.detached(priority: .utility) {
do {
@@ -242,7 +328,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
timestamp: timestamp,
isRelay: false
)
viewModel.messages.append(systemMessage)
context.appendPublicMessage(systemMessage, to: ConversationID(channelID: context.activeChannel))
}
func addMeshOnlySystemMessage(_ content: String) {
@@ -252,11 +338,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
timestamp: Date(),
isRelay: false
)
viewModel.timelineStore.append(systemMessage, to: .mesh)
synchronizePublicConversationStore(for: .mesh)
refreshVisibleMessages()
viewModel.trimMessagesIfNeeded()
viewModel.objectWillChange.send()
context.appendPublicMessage(systemMessage, to: .mesh)
}
func addPublicSystemMessage(_ content: String) {
@@ -266,34 +348,31 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
timestamp: Date(),
isRelay: false
)
viewModel.timelineStore.append(systemMessage, to: viewModel.activeChannel)
refreshVisibleMessages(from: viewModel.activeChannel)
let contentKey = viewModel.deduplicationService.normalizedContentKey(systemMessage.content)
viewModel.deduplicationService.recordContentKey(contentKey, timestamp: systemMessage.timestamp)
viewModel.trimMessagesIfNeeded()
viewModel.objectWillChange.send()
context.appendPublicMessage(systemMessage, to: ConversationID(channelID: context.activeChannel))
let contentKey = context.normalizedContentKey(systemMessage.content)
context.recordContentKey(contentKey, timestamp: systemMessage.timestamp)
}
func addGeohashOnlySystemMessage(_ content: String) {
if case .location = viewModel.activeChannel {
if case .location = context.activeChannel {
addPublicSystemMessage(content)
} else {
viewModel.timelineStore.queueGeohashSystemMessage(content)
context.queueGeohashSystemMessage(content)
}
}
func sendPublicRaw(_ content: String) {
if case .location(let channel) = viewModel.activeChannel {
Task { @MainActor [weak viewModel] in
guard let viewModel else { return }
if case .location(let channel) = context.activeChannel {
Task { @MainActor [weak context] in
guard let context else { return }
do {
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash)
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: content,
geohash: channel.geohash,
senderIdentity: identity,
nickname: viewModel.nickname,
teleported: viewModel.locationManager.teleported
nickname: context.nickname,
teleported: context.isTeleported
)
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
if targetRelays.isEmpty {
@@ -308,7 +387,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
return
}
viewModel.meshService.sendMessage(
context.sendMeshMessage(
content,
mentions: [],
messageID: UUID().uuidString,
@@ -317,61 +396,73 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
}
func handlePublicMessage(_ message: BitchatMessage) {
let finalMessage = viewModel.processActionMessage(message)
if viewModel.isMessageBlocked(finalMessage) { return }
let finalMessage = context.processActionMessage(message)
if context.isMessageBlocked(finalMessage) { return }
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
let shouldRateLimit = finalMessage.sender != "system" || finalMessage.senderPeerID != nil
let isSystem = finalMessage.sender == "system"
let shouldRateLimit = !isSystem || finalMessage.senderPeerID != nil
if shouldRateLimit {
let senderKey = normalizedSenderKey(for: finalMessage)
let contentKey = viewModel.deduplicationService.normalizedContentKey(finalMessage.content)
if !viewModel.publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey) {
let contentKey = context.normalizedContentKey(finalMessage.content)
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) {
return
}
}
if finalMessage.sender != "system" && finalMessage.content.count > 16000 { return }
if !isSystem && finalMessage.content.count > 16000 { return }
// Empty content never rendered before (the old visible-array enqueue
// filtered it); with the store as the sole timeline it is dropped
// outright instead of lingering invisibly in a backing buffer.
guard !finalMessage.content.trimmed.isEmpty else { return }
if !isGeo && finalMessage.sender != "system" {
viewModel.timelineStore.append(finalMessage, to: .mesh)
synchronizePublicConversationStore(for: .mesh)
// Resolve the destination conversation. System messages surface on
// the active channel (matching their old visible-only routing); geo
// messages require a current geohash, mesh messages always land in
// the mesh conversation.
let destination: ConversationID?
if isSystem {
destination = ConversationID(channelID: context.activeChannel)
} else if isGeo {
destination = context.currentGeohash.map { .geohash($0.lowercased()) }
} else {
destination = .mesh
}
guard let destination else { return }
if isGeo && finalMessage.sender != "system",
let geohash = viewModel.currentGeohash,
viewModel.timelineStore.appendIfAbsent(finalMessage, toGeohash: geohash) {
synchronizePublicConversationStore(forGeohash: geohash)
}
let isSystem = finalMessage.sender == "system"
let channelMatches: Bool = {
switch viewModel.activeChannel {
switch context.activeChannel {
case .mesh: return !isGeo || isSystem
case .location: return isGeo || isSystem
}
}()
guard channelMatches else { return }
if !finalMessage.content.trimmed.isEmpty,
!viewModel.messages.contains(where: { $0.id == finalMessage.id }) {
viewModel.publicMessagePipeline.enqueue(finalMessage)
if channelMatches {
// Visible-channel arrivals are batched: the pipeline's ~80 ms
// flush commits them to the store (which dedups by ID), keeping
// the deliberate UI flush cadence.
guard !context.publicConversationContainsMessage(withID: finalMessage.id, in: destination) else { return }
context.enqueuePublicMessage(finalMessage, to: destination)
} else {
// Background-channel arrivals have no rendering observers to
// batch for; they land in the store immediately.
context.appendPublicMessage(finalMessage, to: destination)
}
}
func checkForMentions(_ message: BitchatMessage) {
var myTokens: Set<String> = [viewModel.nickname]
let meshPeers = viewModel.meshService.getPeerNicknames()
let collisions = meshPeers.values.filter { $0.hasPrefix(viewModel.nickname + "#") }
var myTokens: Set<String> = [context.nickname]
let meshPeers = context.meshPeerNicknames()
let collisions = meshPeers.values.filter { $0.hasPrefix(context.nickname + "#") }
if !collisions.isEmpty {
let suffix = "#" + String(viewModel.meshService.myPeerID.id.prefix(4))
myTokens = [viewModel.nickname + suffix]
let suffix = "#" + String(context.myPeerID.id.prefix(4))
myTokens = [context.nickname + suffix]
}
let isMentioned = message.mentions?.contains(where: myTokens.contains) ?? false
if isMentioned && message.sender != viewModel.nickname {
if isMentioned && message.sender != context.nickname {
SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)
NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)
context.notifyMention(from: message.sender, message: message.content)
}
}
@@ -379,11 +470,11 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
#if os(iOS)
guard UIApplication.shared.applicationState == .active else { return }
var tokens: [String] = [viewModel.nickname]
switch viewModel.activeChannel {
var tokens: [String] = [context.nickname]
switch context.activeChannel {
case .location(let channel):
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
tokens.append(viewModel.nickname + "#" + String(identity.publicKeyHex.suffix(4)))
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
tokens.append(context.nickname + "#" + String(identity.publicKeyHex.suffix(4)))
}
case .mesh:
break
@@ -394,7 +485,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
let isHugForMe = message.content.contains("🫂") && hugsMe
let isSlapForMe = message.content.contains("🐟") && slapsMe
if isHugForMe && message.sender != viewModel.nickname {
if isHugForMe && message.sender != context.nickname {
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
impactFeedback.prepare()
@@ -405,7 +496,7 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
impactFeedback.impactOccurred()
}
}
} else if isSlapForMe && message.sender != viewModel.nickname {
} else if isSlapForMe && message.sender != context.nickname {
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
impactFeedback.prepare()
impactFeedback.impactOccurred()
@@ -413,36 +504,28 @@ final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
#endif
}
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {
viewModel.messages
}
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {
viewModel.messages = messages
}
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
viewModel.deduplicationService.normalizedContentKey(content)
context.normalizedContentKey(content)
}
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? {
viewModel.deduplicationService.contentTimestamp(forKey: key)
context.contentTimestamp(forKey: key)
}
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
viewModel.deduplicationService.recordContentKey(key, timestamp: timestamp)
context.recordContentKey(key, timestamp: timestamp)
}
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {
viewModel.trimMessagesIfNeeded()
func pipeline(_ pipeline: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool {
context.appendPublicMessage(message, to: conversationID)
}
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {
_ = viewModel.formatMessageAsText(message, colorScheme: viewModel.currentColorScheme)
context.prewarmMessageFormatting(message)
}
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {
viewModel.isBatchingPublic = isBatching
context.setPublicBatching(isBatching)
}
}
@@ -450,10 +533,10 @@ private extension ChatPublicConversationCoordinator {
func normalizedSenderKey(for message: BitchatMessage) -> String {
if let senderPeerID = message.senderPeerID {
if senderPeerID.isGeoChat || senderPeerID.isGeoDM {
let full = (viewModel.nostrKeyMapping[senderPeerID] ?? senderPeerID.bare).lowercased()
let full = (context.nostrKeyMapping[senderPeerID] ?? senderPeerID.bare).lowercased()
return "nostr:" + full
} else if senderPeerID.id.count == 16,
let full = viewModel.cachedStablePeerID(for: senderPeerID)?.id.lowercased() {
let full = context.cachedStablePeerID(for: senderPeerID)?.id.lowercased() {
return "noise:" + full
} else {
return "mesh:" + senderPeerID.id.lowercased()
@@ -2,26 +2,149 @@ import BitFoundation
import BitLogger
import Foundation
final class ChatTransportEventCoordinator {
private unowned let viewModel: ChatViewModel
/// The narrow surface `ChatTransportEventCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatTransportEventCoordinatorContextTests`) and makes its
/// true dependencies explicit.
@MainActor
protocol ChatTransportEventContext: AnyObject {
// MARK: Connection & chat state
var isConnected: Bool { get set }
var nickname: String { get }
var myPeerID: PeerID { get }
/// A single private chat's timeline (store-direct lookup on
/// `ChatViewModel`; no `privateChats` dictionary build).
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
var unreadPrivateMessages: Set<PeerID> { get }
var selectedPrivateChatPeer: PeerID? { get set }
/// Appends a private message via the single-writer store intent;
/// returns `false` on duplicate message ID.
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
/// Removes the peer's chat entirely, including unread state.
func removePrivateChat(_ peerID: PeerID)
func markPrivateChatUnread(_ peerID: PeerID)
func markPrivateChatRead(_ peerID: PeerID)
/// Forgets that read receipts were sent for `ids` so READ acks can be
/// re-sent after the peer reconnects. (Single mutation path for the
/// owner's `sentReadReceipts`; this coordinator never reads the raw set.)
func unmarkReadReceiptsSent(_ ids: [String])
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
// MARK: Inbound message handling
func isMessageBlocked(_ message: BitchatMessage) -> Bool
func handlePrivateMessage(_ message: BitchatMessage)
func handlePublicMessage(_ message: BitchatMessage)
func checkForMentions(_ message: BitchatMessage)
func sendHapticFeedback(for message: BitchatMessage)
func parseMentions(from content: String) -> [String]
// MARK: Peer identity & sessions
func isPeerBlocked(_ peerID: PeerID) -> Bool
/// The peer's current entry in the unified peer service, if known.
func unifiedPeer(for peerID: PeerID) -> BitchatPeer?
func resolveNickname(for peerID: PeerID) -> String
func registerEphemeralSession(peerID: PeerID)
func removeEphemeralSession(peerID: PeerID)
/// Resolves the peer's Noise static key from the active Noise session, if any.
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data?
func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID)
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID?
// MARK: Routing & acknowledgements
func flushRouterOutbox(for peerID: PeerID)
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID)
// MARK: Delivery status
/// Applies the status to every known location of the message.
/// Returns `false` when no message with that ID was updated.
@discardableResult
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool
func deliveryStatus(for messageID: String) -> DeliveryStatus?
// MARK: Verification payloads
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data)
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data)
}
extension ChatViewModel: ChatTransportEventContext {
// `isConnected`, `nickname`, `myPeerID`, `privateMessages(for:)`,
// `unreadPrivateMessages`, `selectedPrivateChatPeer`, `notifyUIChanged()`,
// the inbound message handlers, `isPeerBlocked(_:)`,
// `parseMentions(from:)`, `resolveNickname(for:)`,
// `cacheStablePeerID(_:for:)`, and `cachedStablePeerID(for:)` are shared
// requirements with the other contexts or satisfied by existing
// `ChatViewModel` members. The single-writer intent op
// `unmarkReadReceiptsSent(_:)` lives next to its backing state in
// `ChatViewModel`. The members below flatten nested service accesses into
// intent-named calls.
func unifiedPeer(for peerID: PeerID) -> BitchatPeer? {
unifiedPeerService.getPeer(by: peerID)
}
func registerEphemeralSession(peerID: PeerID) {
identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
}
func removeEphemeralSession(peerID: PeerID) {
identityManager.removeEphemeralSession(peerID: peerID)
}
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? {
meshService.noiseSessionPublicKeyData(for: peerID)
}
func flushRouterOutbox(for peerID: PeerID) {
messageRouter.flushOutbox(for: peerID)
}
func sendMeshDeliveryAck(for messageID: String, to peerID: PeerID) {
meshService.sendDeliveryAck(for: messageID, to: peerID)
}
@discardableResult
func applyMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
deliveryCoordinator.updateMessageDeliveryStatus(messageID, status: status)
}
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
deliveryCoordinator.deliveryStatus(for: messageID)
}
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) {
verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload)
}
func handleVerifyResponsePayload(from peerID: PeerID, payload: Data) {
verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
}
}
final class ChatTransportEventCoordinator {
private unowned let context: any ChatTransportEventContext
init(context: any ChatTransportEventContext) {
self.context = context
}
func didReceiveMessage(_ message: BitchatMessage) {
runOnMain { viewModel in
guard !viewModel.isMessageBlocked(message) else { return }
runOnMain { context in
guard !context.isMessageBlocked(message) else { return }
guard !message.content.trimmed.isEmpty || message.isPrivate else { return }
if message.isPrivate {
viewModel.handlePrivateMessage(message)
context.handlePrivateMessage(message)
} else {
viewModel.handlePublicMessage(message)
context.handlePublicMessage(message)
}
viewModel.checkForMentions(message)
viewModel.sendHapticFeedback(for: message)
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
}
@@ -32,9 +155,9 @@ final class ChatTransportEventCoordinator {
timestamp: Date,
messageID: String?
) {
runOnMain { viewModel in
runOnMain { context in
let normalized = content.trimmed
let mentions = viewModel.parseMentions(from: normalized)
let mentions = context.parseMentions(from: normalized)
let message = BitchatMessage(
id: messageID,
sender: nickname,
@@ -48,9 +171,9 @@ final class ChatTransportEventCoordinator {
mentions: mentions.isEmpty ? nil : mentions
)
viewModel.handlePublicMessage(message)
viewModel.checkForMentions(message)
viewModel.sendHapticFeedback(for: message)
context.handlePublicMessage(message)
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
}
@@ -60,13 +183,13 @@ final class ChatTransportEventCoordinator {
payload: Data,
timestamp: Date
) {
runOnMain { [self] viewModel in
runOnMain { [self] context in
handleNoisePayload(
from: peerID,
type: type,
payload: payload,
timestamp: timestamp,
in: viewModel
in: context
)
}
}
@@ -74,60 +197,59 @@ final class ChatTransportEventCoordinator {
func didConnectToPeer(_ peerID: PeerID) {
SecureLogger.debug("🤝 Peer connected: \(peerID)", category: .session)
runOnMain { viewModel in
viewModel.isConnected = true
viewModel.identityManager.registerEphemeralSession(peerID: peerID, handshakeState: .none)
viewModel.objectWillChange.send()
runOnMain { context in
context.isConnected = true
context.registerEphemeralSession(peerID: peerID)
context.notifyUIChanged()
if let peer = viewModel.unifiedPeerService.getPeer(by: peerID) {
if let peer = context.unifiedPeer(for: peerID) {
let stablePeerID = PeerID(hexData: peer.noisePublicKey)
viewModel.cacheStablePeerID(stablePeerID, for: peerID)
context.cacheStablePeerID(stablePeerID, for: peerID)
}
viewModel.messageRouter.flushOutbox(for: peerID)
context.flushRouterOutbox(for: peerID)
}
}
func didDisconnectFromPeer(_ peerID: PeerID) {
SecureLogger.debug("👋 Peer disconnected: \(peerID)", category: .session)
runOnMain { viewModel in
viewModel.identityManager.removeEphemeralSession(peerID: peerID)
runOnMain { context in
context.removeEphemeralSession(peerID: peerID)
var stablePeerID = viewModel.cachedStablePeerID(for: peerID)
var stablePeerID = context.cachedStablePeerID(for: peerID)
if stablePeerID == nil,
let key = viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
let key = context.noiseSessionPublicKeyData(for: peerID) {
let derivedPeerID = PeerID(hexData: key)
viewModel.cacheStablePeerID(derivedPeerID, for: peerID)
context.cacheStablePeerID(derivedPeerID, for: peerID)
stablePeerID = derivedPeerID
}
if let currentPeerID = viewModel.selectedPrivateChatPeer,
if let currentPeerID = context.selectedPrivateChatPeer,
currentPeerID == peerID,
let stablePeerID {
self.migrateSelectedConversationIfNeeded(
from: peerID,
to: stablePeerID,
in: viewModel
in: context
)
}
if let messages = viewModel.privateChats[peerID] {
for message in messages where message.senderPeerID == peerID {
viewModel.sentReadReceipts.remove(message.id)
}
}
let receiptIDs = context.privateMessages(for: peerID)
.filter { $0.senderPeerID == peerID }
.map(\.id)
context.unmarkReadReceiptsSent(receiptIDs)
viewModel.objectWillChange.send()
context.notifyUIChanged()
}
}
}
private extension ChatTransportEventCoordinator {
func runOnMain(_ action: @escaping @MainActor (ChatViewModel) -> Void) {
Task { @MainActor [weak viewModel = self.viewModel] in
guard let viewModel else { return }
action(viewModel)
func runOnMain(_ action: @escaping @MainActor (any ChatTransportEventContext) -> Void) {
Task { @MainActor [weak context = self.context] in
guard let context else { return }
action(context)
}
}
@@ -135,15 +257,15 @@ private extension ChatTransportEventCoordinator {
func migrateSelectedConversationIfNeeded(
from shortPeerID: PeerID,
to stablePeerID: PeerID,
in viewModel: ChatViewModel
in context: any ChatTransportEventContext
) {
if let messages = viewModel.privateChats[shortPeerID] {
if viewModel.privateChats[stablePeerID] == nil {
viewModel.privateChats[stablePeerID] = []
}
let hadUnread = context.unreadPrivateMessages.contains(shortPeerID)
let existingIDs = Set(viewModel.privateChats[stablePeerID]?.map(\.id) ?? [])
for message in messages where !existingIDs.contains(message.id) {
let shortPeerMessages = context.privateMessages(for: shortPeerID)
if !shortPeerMessages.isEmpty {
for message in shortPeerMessages {
// Rewrite senderPeerID to the stable key so read receipts
// keep working; store append dedups by ID and keeps order.
let migrated = BitchatMessage(
id: message.id,
sender: message.sender,
@@ -153,25 +275,24 @@ private extension ChatTransportEventCoordinator {
originalSender: message.originalSender,
isPrivate: message.isPrivate,
recipientNickname: message.recipientNickname,
senderPeerID: message.senderPeerID == viewModel.meshService.myPeerID
? viewModel.meshService.myPeerID
senderPeerID: message.senderPeerID == context.myPeerID
? context.myPeerID
: stablePeerID,
mentions: message.mentions,
deliveryStatus: message.deliveryStatus
)
viewModel.privateChats[stablePeerID]?.append(migrated)
context.appendPrivateMessage(migrated, to: stablePeerID)
}
viewModel.privateChats[stablePeerID]?.sort { $0.timestamp < $1.timestamp }
viewModel.privateChats.removeValue(forKey: shortPeerID)
context.removePrivateChat(shortPeerID)
}
if viewModel.unreadPrivateMessages.contains(shortPeerID) {
viewModel.unreadPrivateMessages.remove(shortPeerID)
viewModel.unreadPrivateMessages.insert(stablePeerID)
if hadUnread {
context.markPrivateChatRead(shortPeerID)
context.markPrivateChatUnread(stablePeerID)
}
viewModel.selectedPrivateChatPeer = stablePeerID
context.selectedPrivateChatPeer = stablePeerID
}
@MainActor
@@ -180,19 +301,19 @@ private extension ChatTransportEventCoordinator {
type: NoisePayloadType,
payload: Data,
timestamp: Date,
in viewModel: ChatViewModel
in context: any ChatTransportEventContext
) {
switch type {
case .privateMessage:
guard let packet = PrivateMessagePacket.decode(from: payload) else { return }
guard !viewModel.isPeerBlocked(peerID) else {
guard !context.isPeerBlocked(peerID) else {
SecureLogger.debug("🚫 Ignoring Noise payload from blocked peer: \(peerID)", category: .security)
return
}
let senderName = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname ?? "Unknown"
let mentions = viewModel.parseMentions(from: packet.content)
let senderName = context.unifiedPeer(for: peerID)?.nickname ?? "Unknown"
let mentions = context.parseMentions(from: packet.content)
let message = BitchatMessage(
id: packet.messageID,
sender: senderName,
@@ -201,24 +322,24 @@ private extension ChatTransportEventCoordinator {
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: viewModel.nickname,
recipientNickname: context.nickname,
senderPeerID: peerID,
mentions: mentions.isEmpty ? nil : mentions
)
viewModel.handlePrivateMessage(message)
viewModel.meshService.sendDeliveryAck(for: packet.messageID, to: peerID)
context.handlePrivateMessage(message)
context.sendMeshDeliveryAck(for: packet.messageID, to: peerID)
case .delivered:
guard let messageID = String(data: payload, encoding: .utf8) else { return }
let name = deliveryStatusName(for: peerID, in: viewModel)
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
let name = deliveryStatusName(for: peerID, in: context)
let didUpdate = context.applyMessageDeliveryStatus(
messageID,
status: .delivered(to: name, at: Date())
)
if !didUpdate {
if case .read? = viewModel.deliveryCoordinator.deliveryStatus(for: messageID) {
if case .read? = context.deliveryStatus(for: messageID) {
SecureLogger.debug("📬 Ignored stale delivered ACK for already-read message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))", category: .session)
} else {
SecureLogger.debug("📬 Delivered ACK for unknown message id=\(messageID.prefix(8))… from \(peerID.id.prefix(8))", category: .session)
@@ -228,8 +349,8 @@ private extension ChatTransportEventCoordinator {
case .readReceipt:
guard let messageID = String(data: payload, encoding: .utf8) else { return }
let name = deliveryStatusName(for: peerID, in: viewModel)
let didUpdate = viewModel.deliveryCoordinator.updateMessageDeliveryStatus(
let name = deliveryStatusName(for: peerID, in: context)
let didUpdate = context.applyMessageDeliveryStatus(
messageID,
status: .read(by: name, at: Date())
)
@@ -239,15 +360,15 @@ private extension ChatTransportEventCoordinator {
}
case .verifyChallenge:
viewModel.verificationCoordinator.handleVerifyChallengePayload(from: peerID, payload: payload)
context.handleVerifyChallengePayload(from: peerID, payload: payload)
case .verifyResponse:
viewModel.verificationCoordinator.handleVerifyResponsePayload(from: peerID, payload: payload)
context.handleVerifyResponsePayload(from: peerID, payload: payload)
}
}
@MainActor
func deliveryStatusName(for peerID: PeerID, in viewModel: ChatViewModel) -> String {
viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname ?? viewModel.resolveNickname(for: peerID)
func deliveryStatusName(for peerID: PeerID, in context: any ChatTransportEventContext) -> String {
context.unifiedPeer(for: peerID)?.nickname ?? context.resolveNickname(for: peerID)
}
}
@@ -3,6 +3,124 @@ import BitLogger
import Foundation
import Security
/// The narrow surface `ChatVerificationCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of holding an `unowned` back-ref
/// to the whole `ChatViewModel`. This keeps the coordinator independently
/// testable (see `ChatVerificationCoordinatorContextTests`) and makes its true
/// dependencies explicit.
@MainActor
protocol ChatVerificationContext: AnyObject {
// MARK: Fingerprints & verification state
func getFingerprint(for peerID: PeerID) -> String?
/// The UI-facing verified-fingerprint set (peer identity store backed).
var verifiedFingerprints: Set<String> { get set }
/// The persisted verified-fingerprint set from the identity manager.
func persistedVerifiedFingerprints() -> Set<String>
/// Persists the verified flag in the identity manager.
func setIdentityVerified(fingerprint: String, verified: Bool)
/// Updates the UI-facing verified flag in the peer identity store.
func setStoredVerified(_ fingerprint: String, verified: Bool)
func isVerifiedFingerprint(_ fingerprint: String) -> Bool
func saveIdentityState()
// MARK: Encryption status
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID)
func updateEncryptionStatus(for peerID: PeerID)
func invalidateEncryptionCache(for peerID: PeerID?)
/// Signals that verification state changed so observers refresh (e.g. `objectWillChange.send()`).
func notifyUIChanged()
// MARK: Peers
var unifiedPeers: [BitchatPeer] { get }
var unifiedFavorites: [BitchatPeer] { get }
/// The peer's current entry in the unified peer service, if known.
func unifiedPeer(for peerID: PeerID) -> BitchatPeer?
func unifiedFingerprint(for peerID: PeerID) -> String?
func resolveNickname(for peerID: PeerID) -> String
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID?
func cacheStablePeerID(_ stablePeerID: PeerID, for shortPeerID: PeerID)
// MARK: Noise sessions & verification transport
/// Installs the Noise service's session callbacks (single registration point).
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
)
/// Resolves the peer's Noise static key from the active Noise session, if any.
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data?
/// Our own Noise static public key.
func noiseStaticPublicKeyData() -> Data
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool
func triggerHandshake(with peerID: PeerID)
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
// MARK: Notifications (shared with `ChatNostrContext`)
/// Posts a generic local user notification.
func postLocalNotification(title: String, body: String, identifier: String)
}
extension ChatViewModel: ChatVerificationContext {
// `getFingerprint(for:)`, `verifiedFingerprints`, `saveIdentityState()`,
// `updateEncryptionStatus(for:)`, `invalidateEncryptionCache(for:)`,
// `notifyUIChanged()`, `unifiedPeer(for:)`, `unifiedFingerprint(for:)`,
// `isVerifiedFingerprint(_:)`, `setEncryptionStatus(_:for:)`,
// `resolveNickname(for:)`, `cachedStablePeerID(for:)`,
// `cacheStablePeerID(_:for:)`, `noiseSessionPublicKeyData(for:)`,
// `hasEstablishedNoiseSession(with:)`, and `triggerHandshake(with:)` are
// shared requirements with the other contexts or satisfied by existing
// `ChatViewModel` members. The members below flatten nested service
// accesses into intent-named calls.
func persistedVerifiedFingerprints() -> Set<String> {
identityManager.getVerifiedFingerprints()
}
func setIdentityVerified(fingerprint: String, verified: Bool) {
identityManager.setVerified(fingerprint: fingerprint, verified: verified)
}
func setStoredVerified(_ fingerprint: String, verified: Bool) {
peerIdentityStore.setVerified(fingerprint, verified: verified)
}
var unifiedPeers: [BitchatPeer] {
unifiedPeerService.peers
}
var unifiedFavorites: [BitchatPeer] {
unifiedPeerService.favorites
}
func installNoiseSessionCallbacks(
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
onHandshakeRequired: @escaping (PeerID) -> Void
) {
meshService.installNoiseSessionCallbacks(
onPeerAuthenticated: onPeerAuthenticated,
onHandshakeRequired: onHandshakeRequired
)
}
func noiseStaticPublicKeyData() -> Data {
meshService.noiseStaticPublicKeyData()
}
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {
meshService.sendVerifyResponse(to: peerID, noiseKeyHex: noiseKeyHex, nonceA: nonceA)
}
func postLocalNotification(title: String, body: String, identifier: String) {
NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: identifier)
}
}
@MainActor
final class ChatVerificationCoordinator {
struct PendingVerification {
@@ -13,44 +131,44 @@ final class ChatVerificationCoordinator {
var sent: Bool
}
private unowned let viewModel: ChatViewModel
private unowned let context: any ChatVerificationContext
private var pendingQRVerifications: [PeerID: PendingVerification] = [:]
private var lastVerifyNonceByPeer: [PeerID: Data] = [:]
private var lastInboundVerifyChallengeAt: [String: Date] = [:]
private var lastMutualToastAt: [String: Date] = [:]
init(viewModel: ChatViewModel) {
self.viewModel = viewModel
init(context: any ChatVerificationContext) {
self.context = context
}
func verifyFingerprint(for peerID: PeerID) {
guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return }
guard let fingerprint = context.getFingerprint(for: peerID) else { return }
viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: true)
viewModel.saveIdentityState()
viewModel.peerIdentityStore.setVerified(fingerprint, verified: true)
viewModel.updateEncryptionStatus(for: peerID)
context.setIdentityVerified(fingerprint: fingerprint, verified: true)
context.saveIdentityState()
context.setStoredVerified(fingerprint, verified: true)
context.updateEncryptionStatus(for: peerID)
}
func unverifyFingerprint(for peerID: PeerID) {
guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return }
viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: false)
viewModel.saveIdentityState()
viewModel.peerIdentityStore.setVerified(fingerprint, verified: false)
viewModel.updateEncryptionStatus(for: peerID)
guard let fingerprint = context.getFingerprint(for: peerID) else { return }
context.setIdentityVerified(fingerprint: fingerprint, verified: false)
context.saveIdentityState()
context.setStoredVerified(fingerprint, verified: false)
context.updateEncryptionStatus(for: peerID)
}
func loadVerifiedFingerprints() {
viewModel.peerIdentityStore.setVerifiedFingerprints(viewModel.identityManager.getVerifiedFingerprints())
let sample = Array(viewModel.peerIdentityStore.verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount))
context.verifiedFingerprints = context.persistedVerifiedFingerprints()
let sample = Array(context.verifiedFingerprints.prefix(TransportConfig.uiFingerprintSampleCount))
.map { $0.prefix(8) }
.joined(separator: ", ")
SecureLogger.info("🔐 Verified loaded: \(viewModel.peerIdentityStore.verifiedFingerprints.count) [\(sample)]", category: .security)
SecureLogger.info("🔐 Verified loaded: \(context.verifiedFingerprints.count) [\(sample)]", category: .security)
let offlineFavorites = viewModel.unifiedPeerService.favorites.filter { !$0.isConnected }
let offlineFavorites = context.unifiedFavorites.filter { !$0.isConnected }
for favorite in offlineFavorites {
let fingerprint = viewModel.unifiedPeerService.getFingerprint(for: favorite.peerID)
let isVerified = fingerprint.flatMap { viewModel.peerIdentityStore.isVerified($0) } ?? false
let fingerprint = context.unifiedFingerprint(for: favorite.peerID)
let isVerified = fingerprint.flatMap { context.isVerifiedFingerprint($0) } ?? false
let shortFingerprint = fingerprint?.prefix(8) ?? "nil"
SecureLogger.info(
"⭐️ Favorite offline: \(favorite.nickname) fp=\(shortFingerprint) verified=\(isVerified)",
@@ -58,62 +176,61 @@ final class ChatVerificationCoordinator {
)
}
viewModel.invalidateEncryptionCache()
viewModel.objectWillChange.send()
context.invalidateEncryptionCache(for: nil)
context.notifyUIChanged()
}
func setupNoiseCallbacks() {
let noiseService = viewModel.meshService.getNoiseService()
context.installNoiseSessionCallbacks(
onPeerAuthenticated: { [weak self] peerID, fingerprint in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
noiseService.onPeerAuthenticated = { [weak self] peerID, fingerprint in
DispatchQueue.main.async {
guard let self else { return }
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
SecureLogger.debug("🔐 Authenticated: \(peerID)", category: .security)
if self.context.isVerifiedFingerprint(fingerprint) {
self.context.setEncryptionStatus(.noiseVerified, for: peerID)
} else {
self.context.setEncryptionStatus(.noiseSecured, for: peerID)
}
if self.viewModel.peerIdentityStore.isVerified(fingerprint) {
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseVerified, for: peerID)
} else {
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseSecured, for: peerID)
self.context.invalidateEncryptionCache(for: peerID)
if self.context.cachedStablePeerID(for: peerID) == nil,
let keyData = self.context.noiseSessionPublicKeyData(for: peerID) {
let stablePeerID = PeerID(hexData: keyData)
self.context.cacheStablePeerID(stablePeerID, for: peerID)
SecureLogger.debug(
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))",
category: .session
)
}
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
self.context.sendVerifyChallenge(
to: peerID,
noiseKeyHex: pending.noiseKeyHex,
nonceA: pending.nonceA
)
pending.sent = true
self.pendingQRVerifications[peerID] = pending
SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security)
}
}
self.viewModel.invalidateEncryptionCache(for: peerID)
if self.viewModel.cachedStablePeerID(for: peerID) == nil,
let keyData = self.viewModel.meshService.getNoiseService().getPeerPublicKeyData(peerID) {
let stablePeerID = PeerID(hexData: keyData)
self.viewModel.cacheStablePeerID(stablePeerID, for: peerID)
SecureLogger.debug(
"🗺️ Mapped short peerID to Noise key for header continuity: \(peerID) -> \(stablePeerID.id.prefix(8))",
category: .session
)
}
if var pending = self.pendingQRVerifications[peerID], pending.sent == false {
self.viewModel.meshService.sendVerifyChallenge(
to: peerID,
noiseKeyHex: pending.noiseKeyHex,
nonceA: pending.nonceA
)
pending.sent = true
self.pendingQRVerifications[peerID] = pending
SecureLogger.debug("📤 Sent deferred verify challenge to \(peerID) after handshake", category: .security)
},
onHandshakeRequired: { [weak self] peerID in
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.context.setEncryptionStatus(.noiseHandshaking, for: peerID)
self.context.invalidateEncryptionCache(for: peerID)
}
}
}
noiseService.onHandshakeRequired = { [weak self] peerID in
DispatchQueue.main.async {
guard let self else { return }
self.viewModel.peerIdentityStore.setEncryptionStatus(.noiseHandshaking, for: peerID)
self.viewModel.invalidateEncryptionCache(for: peerID)
}
}
)
}
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
let targetNoise = qr.noiseKeyHex.lowercased()
guard let peer = viewModel.unifiedPeerService.peers.first(where: {
guard let peer = context.unifiedPeers.first(where: {
$0.noisePublicKey.hexEncodedString().lowercased() == targetNoise
}) else {
return false
@@ -135,13 +252,12 @@ final class ChatVerificationCoordinator {
)
pendingQRVerifications[peerID] = pending
let noise = viewModel.meshService.getNoiseService()
if noise.hasEstablishedSession(with: peerID) {
viewModel.meshService.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
if context.hasEstablishedNoiseSession(with: peerID) {
context.sendVerifyChallenge(to: peerID, noiseKeyHex: qr.noiseKeyHex, nonceA: nonce)
pending.sent = true
pendingQRVerifications[peerID] = pending
} else {
viewModel.meshService.triggerHandshake(with: peerID)
context.triggerHandshake(with: peerID)
}
return true
@@ -150,9 +266,7 @@ final class ChatVerificationCoordinator {
func handleVerifyChallengePayload(from peerID: PeerID, payload: Data) {
guard let challenge = VerificationService.shared.parseVerifyChallenge(payload) else { return }
let myNoiseHex = viewModel.meshService
.getNoiseService()
.getStaticPublicKeyData()
let myNoiseHex = context.noiseStaticPublicKeyData()
.hexEncodedString()
.lowercased()
guard challenge.noiseKeyHex.lowercased() == myNoiseHex else { return }
@@ -160,22 +274,22 @@ final class ChatVerificationCoordinator {
lastVerifyNonceByPeer[peerID] = challenge.nonceA
if let fingerprint = viewModel.getFingerprint(for: peerID) {
if let fingerprint = context.getFingerprint(for: peerID) {
lastInboundVerifyChallengeAt[fingerprint] = Date()
if viewModel.peerIdentityStore.isVerified(fingerprint) {
if context.isVerifiedFingerprint(fingerprint) {
maybeSendMutualVerificationNotification(
fingerprint: fingerprint,
peerID: peerID,
title: "Mutual verification",
bodyName: viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname
?? viewModel.resolveNickname(for: peerID),
bodyName: context.unifiedPeer(for: peerID)?.nickname
?? context.resolveNickname(for: peerID),
notificationPrefix: "verify-mutual"
)
}
}
viewModel.meshService.sendVerifyResponse(
context.sendVerifyResponse(
to: peerID,
noiseKeyHex: challenge.noiseKeyHex,
nonceA: challenge.nonceA
@@ -198,17 +312,17 @@ final class ChatVerificationCoordinator {
pendingQRVerifications.removeValue(forKey: peerID)
guard let fingerprint = viewModel.getFingerprint(for: peerID) else { return }
guard let fingerprint = context.getFingerprint(for: peerID) else { return }
let shortFingerprint = fingerprint.prefix(8)
SecureLogger.info("🔐 Marking verified fingerprint: \(shortFingerprint)", category: .security)
viewModel.identityManager.setVerified(fingerprint: fingerprint, verified: true)
viewModel.saveIdentityState()
viewModel.peerIdentityStore.setVerified(fingerprint, verified: true)
context.setIdentityVerified(fingerprint: fingerprint, verified: true)
context.saveIdentityState()
context.setStoredVerified(fingerprint, verified: true)
let peerName = viewModel.unifiedPeerService.getPeer(by: peerID)?.nickname
?? viewModel.resolveNickname(for: peerID)
NotificationService.shared.sendLocalNotification(
let peerName = context.unifiedPeer(for: peerID)?.nickname
?? context.resolveNickname(for: peerID)
context.postLocalNotification(
title: "Verified",
body: "You verified \(peerName)",
identifier: "verify-success-\(peerID)-\(UUID().uuidString)"
@@ -225,7 +339,7 @@ final class ChatVerificationCoordinator {
)
}
viewModel.updateEncryptionStatus(for: peerID)
context.updateEncryptionStatus(for: peerID)
}
}
@@ -242,7 +356,7 @@ private extension ChatVerificationCoordinator {
guard now.timeIntervalSince(lastToast) > 60 else { return }
lastMutualToastAt[fingerprint] = now
NotificationService.shared.sendLocalNotification(
context.postLocalNotification(
title: title,
body: "You and \(bodyName) verified each other",
identifier: "\(notificationPrefix)-\(peerID)-\(UUID().uuidString)"
+521 -143
View File
@@ -119,9 +119,27 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Published Properties
@Published var messages: [BitchatMessage] = []
/// Read-only derived view of the ACTIVE public channel's conversation in
/// the single-writer `ConversationStore`. SwiftUI renders through
/// `PublicChatModel` (which observes the `Conversation` object directly);
/// this view serves the coordinators/commands that need "the visible
/// timeline" plus tests. Hot enough that the array is cached and
/// invalidated from the store's `changes` subject (filtered to the
/// active conversation) and on channel switches. `objectWillChange`
/// fires on every store change via the sink in `init`.
@MainActor
var messages: [BitchatMessage] {
if let cached = visibleMessagesCache { return cached }
// Read-only lookup (never creates the conversation): this getter
// runs during SwiftUI renders, where mutating the store's
// `@Published` collections would publish mid-view-update.
let current = conversations.conversationsByID[ConversationID(channelID: activeChannel)]?.messages ?? []
visibleMessagesCache = current
return current
}
private var visibleMessagesCache: [BitchatMessage]?
@Published var currentColorScheme: ColorScheme = .light
private let maxMessages = TransportConfig.meshTimelineCap // Maximum messages before oldest are removed
@Published var currentTheme: AppTheme = .matrix
@Published var isConnected = false
@Published var nickname: String = "" {
didSet {
@@ -146,31 +164,39 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
let unifiedPeerService: UnifiedPeerService
let autocompleteService: AutocompleteService
let deduplicationService: MessageDeduplicationService // internal for test access
private lazy var outgoingCoordinator = ChatOutgoingCoordinator(viewModel: self)
private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(viewModel: self)
private lazy var transportEventCoordinator = ChatTransportEventCoordinator(viewModel: self)
private lazy var peerListCoordinator = ChatPeerListCoordinator(viewModel: self)
private lazy var outgoingCoordinator = ChatOutgoingCoordinator(context: self)
private lazy var lifecycleCoordinator = ChatLifecycleCoordinator(context: self)
private lazy var transportEventCoordinator = ChatTransportEventCoordinator(context: self)
private lazy var peerListCoordinator = ChatPeerListCoordinator(context: self)
private lazy var messageFormatter = ChatMessageFormatter(viewModel: self)
lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(viewModel: self)
lazy var deliveryCoordinator = ChatDeliveryCoordinator(viewModel: self)
lazy var composerCoordinator = ChatComposerCoordinator(viewModel: self)
lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(viewModel: self)
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(viewModel: self)
lazy var nostrCoordinator = ChatNostrCoordinator(viewModel: self)
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(viewModel: self)
lazy var verificationCoordinator = ChatVerificationCoordinator(viewModel: self)
lazy var peerIdentityCoordinator = ChatPeerIdentityCoordinator(context: self)
lazy var deliveryCoordinator = ChatDeliveryCoordinator(context: self)
lazy var composerCoordinator = ChatComposerCoordinator(context: self)
lazy var publicConversationCoordinator = ChatPublicConversationCoordinator(context: self)
lazy var privateConversationCoordinator = ChatPrivateConversationCoordinator(context: self)
lazy var nostrCoordinator = ChatNostrCoordinator(context: self)
lazy var mediaTransferCoordinator = ChatMediaTransferCoordinator(context: self)
lazy var verificationCoordinator = ChatVerificationCoordinator(context: self)
// Computed properties for compatibility
@MainActor
var connectedPeers: Set<PeerID> { unifiedPeerService.connectedPeerIDs }
@Published var allPeers: [BitchatPeer] = []
/// Read-only derived view of all direct conversations in the
/// `ConversationStore`, keyed by routing peer ID. Serves the coordinator
/// reads that genuinely need the whole dictionary (migration scans,
/// unread resolution); simple per-peer reads go through
/// `privateMessages(for:)` instead. All mutations go through the
/// private-chat intent ops below. Rebuilt per access
/// O(#conversations) thanks to COW message arrays; measured equal to a
/// change-invalidated cache on `pipeline.privateIngest`, so the simpler
/// form wins.
@MainActor
var privateChats: [PeerID: [BitchatMessage]] {
get { privateChatManager.privateChats }
set {
privateChatManager.privateChats = newValue
synchronizePrivateConversationStore()
}
conversations.directMessagesByRoutingPeerID()
}
@MainActor
var selectedPrivateChatPeer: PeerID? {
get { privateChatManager.selectedPeer }
set {
@@ -179,19 +205,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
} else {
privateChatManager.endChat()
}
synchronizePrivateConversationStore()
synchronizeConversationSelectionStore()
}
}
/// Read-only derived view of the store's unread direct conversations.
/// Mutate via `markPrivateChatUnread(_:)` / `markPrivateChatRead(_:)`.
@MainActor
var unreadPrivateMessages: Set<PeerID> {
get { privateChatManager.unreadMessages }
set {
privateChatManager.unreadMessages = newValue
synchronizePrivateConversationStore()
}
conversations.unreadDirectRoutingPeerIDs()
}
/// Check if there are any unread messages (including from temporary Nostr peer IDs)
@MainActor
var hasAnyUnreadMessages: Bool {
!unreadPrivateMessages.isEmpty
}
@@ -219,7 +243,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
if let mapped = peerIdentityStore.stablePeerID(forShortID: shortPeerID) { return mapped }
// Fallback: derive from active Noise session if available
if shortPeerID.id.count == 16,
let key = meshService.getNoiseService().getPeerPublicKeyData(shortPeerID) {
let key = meshService.noiseSessionPublicKeyData(for: shortPeerID) {
let stable = PeerID(hexData: key)
peerIdentityStore.setStablePeerID(stable, forShortID: shortPeerID)
return stable
@@ -270,8 +294,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
let meshService: Transport
let idBridge: NostrIdentityBridge
let identityManager: SecureIdentityStateManagerProtocol
let conversationStore: ConversationStore
let identityResolver: IdentityResolver
/// Single source of truth for conversation message state and selection
/// (docs/CONVERSATION-STORE-DESIGN.md). Owned by `AppRuntime` and passed
/// through.
let conversations: ConversationStore
let peerIdentityStore: PeerIdentityStore
let locationPresenceStore: LocationPresenceStore
let locationManager: LocationChannelManager
@@ -282,18 +308,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
private let nicknameKey = "bitchat.nickname"
// Location channel state (macOS supports manual geohash selection)
var activeChannel: ChannelID {
get { conversationStore.activeChannel }
get { conversations.activeChannel }
set {
guard conversationStore.activeChannel != newValue else { return }
publicMessagePipeline.updateActiveChannel(newValue)
conversationStore.setActiveChannel(newValue)
synchronizePublicConversationStore(for: newValue)
synchronizeConversationSelectionStore()
guard conversations.activeChannel != newValue else { return }
conversations.setActiveChannel(newValue)
visibleMessagesCache = nil
objectWillChange.send()
}
}
var geoSubscriptionID: String? = nil
var geoDmSubscriptionID: String? = nil
// Single-writer: mutate only via `setGeoChatSubscriptionID(_:)` / `setGeoDmSubscriptionID(_:)` below.
private(set) var geoSubscriptionID: String? = nil
private(set) var geoDmSubscriptionID: String? = nil
var currentGeohash: String? {
get { locationPresenceStore.currentGeohash }
set { locationPresenceStore.setCurrentGeohash(newValue) }
@@ -338,11 +363,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@Published var bluetoothAlertMessage = ""
@Published var bluetoothState: CBManagerState = .unknown
var timelineStore = PublicTimelineStore(
meshCap: TransportConfig.meshTimelineCap,
geohashCap: TransportConfig.geoTimelineCap
)
private func performDeliveryUpdate(_ update: @escaping @MainActor (ChatDeliveryCoordinator) -> Void) {
if Thread.isMainThread {
MainActor.assumeIsolated {
@@ -366,7 +386,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
set { locationPresenceStore.replaceTeleportedGeo(newValue) }
} // lowercased pubkey hex
// Sampling subscriptions for multiple geohashes (when channel sheet is open)
var geoSamplingSubs: [String: String] = [:] // subID -> geohash
// Single-writer: mutate only via `addGeoSamplingSub` / `removeGeoSamplingSub` / `clearGeoSamplingSubs` below.
private(set) var geoSamplingSubs: [String: String] = [:] // subID -> geohash
var lastGeoNotificationAt: [String: Date] = [:] // geohash -> last notify time
// MARK: - Message Delivery Tracking
@@ -383,7 +404,25 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Public message batching (UI perf)
let publicMessagePipeline: PublicMessagePipeline
@Published var isBatchingPublic: Bool = false
// Single-writer: mutate only via `setPublicBatching(_:)` below.
@Published private(set) var isBatchingPublic: Bool = false
// Backing store for `sentReadReceipts` persistence. `.standard` in
// production; injectable so tests can use a scratch suite that does not
// leak state between runs.
let readReceiptsDefaults: UserDefaults
/// Default read-receipt persistence store. Production uses `.standard`.
/// Under test, a dedicated scratch suite is used instead wiped at first
/// use per process so back-to-back local test runs never see each
/// other's persisted receipts (and tests never pollute `.standard`).
static let defaultReadReceiptsDefaults: UserDefaults = {
guard TestEnvironment.isRunningTests else { return .standard }
let suiteName = "chat.bitchat.tests.readReceipts"
guard let scratch = UserDefaults(suiteName: suiteName) else { return .standard }
scratch.removePersistentDomain(forName: suiteName)
return scratch
}()
// Track sent read receipts to avoid duplicates (persisted across launches)
// Note: Persistence happens automatically in didSet, no lifecycle observers needed
@@ -392,9 +431,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Only persist if there are changes
guard oldValue != sentReadReceipts else { return }
// Persist to UserDefaults whenever it changes (no manual synchronize/verify re-read)
// Persist whenever it changes (no manual synchronize/verify re-read)
if let data = try? JSONEncoder().encode(Array(sentReadReceipts)) {
UserDefaults.standard.set(data, forKey: "sentReadReceipts")
readReceiptsDefaults.set(data, forKey: "sentReadReceipts")
} else {
SecureLogger.error("❌ Failed to encode read receipts for persistence", category: .session)
}
@@ -402,15 +441,316 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
}
// Track which GeoDM messages we've already sent a delivery ACK for (by messageID)
var sentGeoDeliveryAcks: Set<String> = []
// Single-writer: mutate only via `markGeoDeliveryAckSent(_:)` below.
private(set) var sentGeoDeliveryAcks: Set<String> = []
// Track app startup phase to prevent marking old messages as unread
var isStartupPhase = true
// ConversationStore field audit bookkeeping (see auditConversationStore()):
// runs on the read-receipt cleanup cadence, heartbeat sampled first +
// every `TransportConfig.conversationStoreAuditLogInterval`th audit.
private var storeAuditCount = 0
private var storeAuditLastAppendCount = 0
// Announce Tor initial readiness once per launch to avoid duplicates
var torInitialReadyAnnounced: Bool = false
// Track Nostr pubkey mappings for unknown senders
var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey
// Single-writer: mutate only via `registerNostrKeyMapping` / `removeNostrKeyMappings` below.
private(set) var nostrKeyMapping: [PeerID: String] = [:] // senderPeerID -> nostrPubkey
// MARK: - Single-Writer Intent Operations
// Owner-side mutation paths for state the coordinator contexts may read
// but not write directly. Each op is the sole way to mutate its backing
// state, so check-then-mutate races between coordinators cannot occur.
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
@MainActor
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) {
nostrKeyMapping[peerID] = pubkey
}
/// Drops every key mapping that resolves to the given (lowercased) Nostr pubkey.
@MainActor
func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) {
for (key, value) in nostrKeyMapping where value.lowercased() == hex {
nostrKeyMapping.removeValue(forKey: key)
}
}
/// Records that a read receipt is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@MainActor
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool {
sentReadReceipts.insert(messageID).inserted
}
/// Records that a GeoDM delivery ACK is being sent for `messageID`.
/// Returns `false` when one was already recorded the caller must skip sending.
@MainActor
@discardableResult
func markGeoDeliveryAckSent(_ messageID: String) -> Bool {
sentGeoDeliveryAcks.insert(messageID).inserted
}
/// Forgets that read receipts were sent for `ids` so READ acks can be
/// re-sent after the peer reconnects.
@MainActor
func unmarkReadReceiptsSent(_ ids: [String]) {
sentReadReceipts.subtract(ids)
}
/// Marks read receipts as sent for own messages already delivered/read in
/// `peerID`'s chat, syncing the chat manager's tracking with the persisted
/// set. (Wraps the manager's `inout` sync so the raw set never leaks.)
@MainActor
func syncReadReceiptsForSentMessages(for peerID: PeerID) {
privateChatManager.syncReadReceiptsForSentMessages(
peerID: peerID,
nickname: nickname,
externalReceipts: &sentReadReceipts
)
}
/// Drops every recorded read receipt whose message ID is no longer valid.
/// Returns the number of receipts removed.
@MainActor
func pruneSentReadReceipts(keeping validMessageIDs: Set<String>) -> Int {
let oldCount = sentReadReceipts.count
sentReadReceipts = sentReadReceipts.intersection(validMessageIDs)
return oldCount - sentReadReceipts.count
}
/// Publishes the public-timeline batching state (UI animation suppression).
@MainActor
func setPublicBatching(_ isBatching: Bool) {
isBatchingPublic = isBatching
}
@MainActor
func setGeoChatSubscriptionID(_ id: String?) {
geoSubscriptionID = id
}
@MainActor
func setGeoDmSubscriptionID(_ id: String?) {
geoDmSubscriptionID = id
}
@MainActor
func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) {
geoSamplingSubs[subID] = geohash
}
@MainActor
func removeGeoSamplingSub(_ subID: String) {
geoSamplingSubs.removeValue(forKey: subID)
}
/// Clears all sampling subscriptions and returns the removed subscription IDs
/// so the caller can unsubscribe them from the relay manager.
@MainActor
func clearGeoSamplingSubs() -> [String] {
let subIDs = Array(geoSamplingSubs.keys)
geoSamplingSubs.removeAll()
return subIDs
}
/// Moves the open private chat to `newPeerID` when the current selection is
/// one of the peer IDs being migrated away (side-effectful: re-targets the
/// private chat session fingerprint refresh, read receipts).
///
/// Note: when this runs after a store `migrateConversation`, the store has
/// already handed the selection itself off to `newPeerID` (and the manager
/// mirrors it), so a selection that reads `newPeerID` is also re-targeted
/// to run the session side effects. Selections on unrelated peers are
/// untouched.
@MainActor
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID) {
guard oldPeerIDs.contains(where: { selectedPrivateChatPeer == $0 })
|| selectedPrivateChatPeer == newPeerID else { return }
selectedPrivateChatPeer = newPeerID
}
// MARK: - Private Conversation Store Intents
// The sole mutation paths for private (direct) message state. Each op
// forwards to the single-writer `ConversationStore`
// (docs/CONVERSATION-STORE-DESIGN.md); the read-only `privateChats` /
// `unreadPrivateMessages` views above are derived from the same store.
/// Appends a private message in timestamp order. Returns `false` when a
/// message with the same ID is already in that chat (O(1) dedup via the
/// conversation's ID index).
@MainActor
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
conversations.append(message, to: .directPeer(peerID))
}
/// Replace-or-append a private message by ID (media progress, mirrored
/// copies); an existing message keeps its timeline position.
@MainActor
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID) {
conversations.upsertByID(message, in: .directPeer(peerID))
}
/// Applies a delivery status to a private message by ID. Returns `false`
/// when the message is unknown or the update would downgrade the status
/// (read beats delivered beats sent).
@MainActor
@discardableResult
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool {
conversations.setDeliveryStatus(status, forMessageID: messageID, in: .directPeer(peerID))
}
/// Flags the peer's chat as unread (store unread state).
@MainActor
func markPrivateChatUnread(_ peerID: PeerID) {
conversations.markUnread(.directPeer(peerID))
}
/// Clears the peer's unread flag (store unread state only; read-receipt
/// sending stays in `PrivateChatManager.markAsRead`).
@MainActor
func markPrivateChatRead(_ peerID: PeerID) {
conversations.markRead(.directPeer(peerID))
}
/// Empties the peer's chat but keeps the conversation alive (`/clear`).
@MainActor
func clearPrivateChat(_ peerID: PeerID) {
conversations.clear(.directPeer(peerID))
}
/// Removes the peer's chat entirely, including unread state.
@MainActor
func removePrivateChat(_ peerID: PeerID) {
conversations.removeConversation(.directPeer(peerID))
}
/// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat
/// (ephemeralstable peer-ID handoff): dedups by ID, preserves order,
/// carries unread state, removes the old chat.
@MainActor
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) {
conversations.migrateConversation(from: .directPeer(oldPeerID), to: .directPeer(newPeerID))
}
/// A single private chat's timeline, read straight from the store
/// an O(1) lookup that skips the `privateChats` dictionary build. The
/// context protocols' simple per-peer reads dispatch here.
@MainActor
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
}
/// `true` when any private chat contains a message with `messageID`
/// (O(1) per conversation via the store's ID indexes).
@MainActor
func privateChatsContainMessage(withID messageID: String) -> Bool {
conversations.directConversationsContainMessage(withID: messageID)
}
/// `true` when `peerID`'s chat contains a message with `messageID`.
@MainActor
func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool {
conversations.conversationsByID[.directPeer(peerID)]?.containsMessage(withID: messageID) ?? false
}
/// Removes a message by ID from every private chat that contains it,
/// dropping chats that become empty. Returns the removed message, if any.
@MainActor
@discardableResult
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
var removed: BitchatMessage?
for (id, conversation) in conversations.conversationsByID {
guard case .direct = id, conversation.containsMessage(withID: messageID) else { continue }
let message = conversations.removeMessage(withID: messageID, from: id)
removed = removed ?? message
if conversation.messages.isEmpty {
conversations.removeConversation(id)
}
}
return removed
}
// MARK: - Public Conversation Store Intents
// The sole mutation paths for public (mesh/geohash) message state,
// mirroring the private intents above. The store's per-conversation cap
// and timestamp-ordered insert replace `PublicTimelineStore`'s trim and
// the pipeline's late-insert positioning; the read-only `messages` shim
// above is derived from the same store.
/// Appends a public message in timestamp order. Returns `false` when a
/// message with the same ID is already in that conversation (O(1) dedup
/// via the conversation's ID index).
@MainActor
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool {
conversations.append(message, to: conversationID)
}
/// Appends a geohash message if absent. Returns `true` when stored
/// (the legacy `PublicTimelineStore.appendIfAbsent` contract).
@MainActor
@discardableResult
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
conversations.append(message, to: .geohash(geohash.lowercased()))
}
/// A public (mesh/geohash) channel's full timeline.
@MainActor
func publicMessages(for channel: ChannelID) -> [BitchatMessage] {
conversations.conversation(for: ConversationID(channelID: channel)).messages
}
/// `true` when the conversation contains a message with `messageID`.
@MainActor
func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool {
conversations.conversationsByID[conversationID]?.containsMessage(withID: messageID) ?? false
}
/// Removes a message by ID from whichever public conversation contains
/// it. Returns the removed message, if any.
@MainActor
@discardableResult
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
conversations.removePublicMessage(withID: messageID)
}
/// Removes every message matching `predicate` from a geohash
/// conversation (block-user purge).
@MainActor
func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) {
conversations.removeMessages(from: .geohash(geohash.lowercased()), where: predicate)
}
/// Empties a public conversation's timeline (`/clear`).
@MainActor
func clearPublicConversation(_ conversationID: ConversationID) {
conversations.clear(conversationID)
}
/// Queues a system message for the next geohash channel visit. (Tiny
/// UI-flow queue formerly on `PublicTimelineStore`; it is notice text,
/// not conversation state, so it stays on the owner.)
@MainActor
func queueGeohashSystemMessage(_ content: String) {
pendingGeohashSystemMessages.append(content)
}
/// Drains the queued geohash system messages (single consumer:
/// `GeohashSubscriptionManager.switchLocationChannel`).
@MainActor
func drainPendingGeohashSystemMessages() -> [String] {
defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }
return pendingGeohashSystemMessages
}
// Single-writer: mutate only via `queueGeohashSystemMessage(_:)` /
// `drainPendingGeohashSystemMessages()` above.
private var pendingGeohashSystemMessages: [String] = []
// MARK: - Initialization
@@ -419,21 +759,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
keychain: KeychainManagerProtocol,
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol,
conversationStore: ConversationStore? = nil,
identityResolver: IdentityResolver? = nil,
conversations: ConversationStore? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared
) {
let conversationStore = conversationStore ?? ConversationStore()
let identityResolver = identityResolver ?? IdentityResolver()
self.init(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager),
conversationStore: conversationStore,
identityResolver: identityResolver,
conversations: conversations,
peerIdentityStore: peerIdentityStore ?? PeerIdentityStore(),
locationPresenceStore: locationPresenceStore ?? LocationPresenceStore(),
locationManager: locationManager
@@ -448,14 +784,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
idBridge: NostrIdentityBridge,
identityManager: SecureIdentityStateManagerProtocol,
transport: Transport,
conversationStore: ConversationStore? = nil,
identityResolver: IdentityResolver? = nil,
conversations: ConversationStore? = nil,
peerIdentityStore: PeerIdentityStore? = nil,
locationPresenceStore: LocationPresenceStore? = nil,
locationManager: LocationChannelManager = .shared
locationManager: LocationChannelManager = .shared,
readReceiptsDefaults: UserDefaults? = nil
) {
let conversationStore = conversationStore ?? ConversationStore()
let identityResolver = identityResolver ?? IdentityResolver()
let conversations = conversations ?? ConversationStore()
let peerIdentityStore = peerIdentityStore ?? PeerIdentityStore()
let locationPresenceStore = locationPresenceStore ?? LocationPresenceStore()
let services = ChatViewModelServiceBundle(
@@ -468,8 +803,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
self.keychain = keychain
self.idBridge = idBridge
self.identityManager = identityManager
self.conversationStore = conversationStore
self.identityResolver = identityResolver
self.conversations = conversations
self.peerIdentityStore = peerIdentityStore
self.locationPresenceStore = locationPresenceStore
self.locationManager = locationManager
@@ -481,10 +815,27 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
self.autocompleteService = services.autocompleteService
self.deduplicationService = services.deduplicationService
self.publicMessagePipeline = services.publicMessagePipeline
self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts()
let readReceiptsDefaults = readReceiptsDefaults ?? Self.defaultReadReceiptsDefaults
self.readReceiptsDefaults = readReceiptsDefaults
self.sentReadReceipts = ChatViewModelBootstrapper.loadPersistedReadReceipts(userDefaults: readReceiptsDefaults)
// Republish on every store change so SwiftUI observers of the
// view model refresh. This replaces the UI-update role of the old
// `PrivateChatManager.@Published` dictionaries and the old
// `@Published var messages`. Changes touching the ACTIVE public
// conversation also invalidate the derived `messages` cache before
// observers re-read it.
conversations.changes
.sink { [weak self] change in
guard let self else { return }
if self.changeAffectsActivePublicConversation(change) {
self.visibleMessagesCache = nil
}
self.objectWillChange.send()
}
.store(in: &cancellables)
ChatViewModelBootstrapper(viewModel: self).configure()
initializeConversationStore()
}
// MARK: - Deinitialization
@@ -676,8 +1027,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
recipientNickname: meshService.peerNickname(peerID: peerID),
senderPeerID: meshService.myPeerID
)
if privateChats[peerID] == nil { privateChats[peerID] = [] }
privateChats[peerID]?.append(systemMessage)
appendPrivateMessage(systemMessage, to: peerID)
objectWillChange.send()
}
@@ -766,14 +1116,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
func panicClearAllData() {
// Messages are processed immediately - nothing to flush
// Clear all messages
messages.removeAll()
timelineStore = PublicTimelineStore(
meshCap: TransportConfig.meshTimelineCap,
geohashCap: TransportConfig.geoTimelineCap
)
privateChatManager.privateChats.removeAll()
privateChatManager.unreadMessages.removeAll()
// Clear all messages (public timelines and private chats live in the
// single-writer ConversationStore; the derived `messages` view and
// the legacy mirror empty with it)
conversations.clearAll()
pendingGeohashSystemMessages.removeAll()
// Delete all keychain data (including Noise and Nostr keys)
_ = keychain.deleteAllKeychainData()
@@ -782,6 +1129,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
userDefaults.removeObject(forKey: "bitchat.noiseIdentityKey")
userDefaults.removeObject(forKey: "bitchat.messageRetentionKey")
// Wipe persisted location state (selected channel, teleport set,
// bookmarks). For an activist-safety wipe, where the user has been is
// exactly the data an adversary inspecting the device wants.
LocationStateManager.shared.panicWipe()
// Reset nickname to anonymous
nickname = "anon\(Int.random(in: 1000...9999))"
saveNickname()
@@ -806,13 +1158,30 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// Clear selected private chat
selectedPrivateChatPeer = nil
// Clear live location/geohash session state. Persisted location state
// was wiped above, but the running view model can still be scoped to a
// geohash channel and hold subscriptions tied to the old Nostr identity.
activeChannel = .mesh
setGeoChatSubscriptionID(nil)
setGeoDmSubscriptionID(nil)
_ = clearGeoSamplingSubs()
cachedGeohashIdentity = nil
nostrKeyMapping.removeAll()
// Clear read receipt tracking
sentReadReceipts.removeAll()
deduplicationService.clearAll()
// IMPORTANT: Clear Nostr-related state
// Disconnect from Nostr relays and clear subscriptions
nostrRelayManager?.disconnect()
// Drop relay subscriptions, handlers, pending sends, and replay state.
// Geohash DM handlers can capture pre-wipe Nostr identities, so a plain
// disconnect is not enough here.
NostrRelayManager.shared.resetForPanicWipe()
// Clearing relay handlers stops NEW events, but a detached gift-wrap
// decrypt spawned just before the wipe still holds a pre-wipe key and
// ciphertext; bump the pipeline's wipe generation so its result is
// dropped at the main-actor delivery hop instead of landing here.
nostrCoordinator.inbound.invalidateInFlightDecrypts()
nostrRelayManager = nil
// Clear Nostr identity associations
@@ -825,20 +1194,28 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
bleService.resetIdentityForPanic(currentNickname: nickname)
}
initializeConversationStore()
// No need to force UserDefaults synchronization
// Reinitialize Nostr with new identity
// This will generate new Nostr keys derived from new Noise keys
Task { @MainActor in
// Small delay to ensure cleanup completes
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
// This will generate new Nostr keys derived from new Noise keys.
// Skipped under tests: connecting the shared relay singleton starts
// real network/reconnect work that never completes and would keep the
// test process alive (the singleton, unlike a discardable instance, is
// never deallocated to cancel it).
if !TestEnvironment.isRunningTests {
Task { @MainActor in
// Small delay to ensure cleanup completes
try? await Task.sleep(nanoseconds: TransportConfig.uiAsyncShortSleepNs) // 0.1 seconds
// Reinitialize Nostr relay manager with new identity
nostrRelayManager = NostrRelayManager()
setupNostrMessageHandling()
nostrRelayManager?.connect()
// Reinitialize Nostr relay manager with new identity. Reuse the
// shared singleton every other component (NostrTransport, geohash
// subscriptions, AppRuntime observers) is bound to `.shared`, so
// creating a fresh instance here would split relay state and leave
// sends running against a disconnected manager.
nostrRelayManager = NostrRelayManager.shared
setupNostrMessageHandling()
nostrRelayManager?.connect()
}
}
// Delete ALL media files (incoming and outgoing) in background
@@ -913,13 +1290,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Message Formatting
@MainActor
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
messageFormatter.formatMessageAsText(message, colorScheme: colorScheme)
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
messageFormatter.formatMessageAsText(message, colorScheme: colorScheme, theme: theme ?? currentTheme)
}
@MainActor
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
messageFormatter.formatMessageHeader(message, colorScheme: colorScheme)
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
messageFormatter.formatMessageHeader(message, colorScheme: colorScheme, theme: theme ?? currentTheme)
}
// MARK: - Noise Protocol Support
@@ -947,53 +1324,34 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
// MARK: - Message Handling
@MainActor
func initializeConversationStore() {
publicConversationCoordinator.initializeConversationStore()
}
@MainActor
func synchronizePublicConversationStore(for channel: ChannelID) {
publicConversationCoordinator.synchronizePublicConversationStore(for: channel)
}
@MainActor
func synchronizePublicConversationStore(forGeohash geohash: String) {
publicConversationCoordinator.synchronizePublicConversationStore(forGeohash: geohash)
}
@MainActor
func synchronizeAllPublicConversationStores() {
publicConversationCoordinator.synchronizeAllPublicConversationStores()
}
@MainActor
func synchronizePrivateConversationStore() {
conversationStore.synchronizePrivateChats(
privateChatManager.privateChats,
unreadPeerIDs: privateChatManager.unreadMessages,
identityResolver: identityResolver
)
}
@MainActor
func synchronizeConversationSelectionStore() {
conversationStore.setSelectedPeerID(
privateChatManager.selectedPeer,
activeChannel: activeChannel,
identityResolver: identityResolver
)
}
func trimMessagesIfNeeded() {
if messages.count > maxMessages {
messages = Array(messages.suffix(maxMessages))
}
}
/// Invalidates the derived `messages` cache and notifies observers.
/// (Formerly pulled the channel's timeline into a stored `messages`
/// array; `messages` is now derived from the `ConversationStore`, so
/// only the invalidation remains. The `channel` parameter is kept for
/// call-site compatibility every caller passes the active channel.)
@MainActor
func refreshVisibleMessages(from channel: ChannelID? = nil) {
publicConversationCoordinator.refreshVisibleMessages(from: channel)
visibleMessagesCache = nil
objectWillChange.send()
}
/// `true` when a store change touches the active public conversation
/// (so the derived `messages` cache must be invalidated).
@MainActor
private func changeAffectsActivePublicConversation(_ change: ConversationChange) -> Bool {
let activeID = ConversationID(channelID: activeChannel)
switch change {
case .appended(let id, _),
.updated(let id, _),
.statusChanged(let id, _, _),
.messageRemoved(let id, _),
.cleared(let id),
.removed(let id),
.unreadChanged(let id, _):
return id == activeID
case .migrated(let source, let destination):
return source == activeID || destination == activeID
}
}
@MainActor
@@ -1035,15 +1393,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
publicConversationCoordinator.clearCurrentPublicTimeline()
}
// MARK: - Message Management
private func addMessage(_ message: BitchatMessage) {
// Check for duplicates
guard !messages.contains(where: { $0.id == message.id }) else { return }
messages.append(message)
trimMessagesIfNeeded()
}
// MARK: - Peer Lookup Helpers
func getPeer(byID peerID: PeerID) -> BitchatPeer? {
@@ -1172,6 +1521,35 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
@MainActor
func cleanupOldReadReceipts() {
deliveryCoordinator.cleanupOldReadReceipts()
auditConversationStore()
}
/// Periodic on-device verification of the `ConversationStore`'s
/// correctness invariants, piggybacked on the read-receipt cleanup
/// cadence (peer-list updates) so no extra timer exists. Loud on
/// violation (one error line each), near-silent when healthy (sampled
/// heartbeat: first + every Nth audit). The audit is O(total messages)
/// and allocation-free while healthy measured ~0.5 ms at 5k messages
/// (see `PerformanceBaselineTests.testConversationStoreAudit`), cheap
/// relative to its cadence, so it always runs.
@MainActor
private func auditConversationStore() {
storeAuditCount += 1
let violations = conversations.auditInvariants()
guard violations.isEmpty else {
for violation in violations {
SecureLogger.error("🚨 ConversationStore invariant violated: \(violation)", category: .session)
}
return
}
let appendCount = conversations.appendCount
if storeAuditCount == 1 || storeAuditCount.isMultiple(of: TransportConfig.conversationStoreAuditLogInterval) {
SecureLogger.debug(
"Store audit OK: \(conversations.conversationsByID.count) conversations, \(conversations.totalMessageCount) messages, map=\(conversations.messageIDMapCount), appends since last audit=\(appendCount - storeAuditLastAppendCount)",
category: .session
)
}
storeAuditLastAppendCount = appendCount
}
func parseMentions(from content: String) -> [String] {
@@ -74,9 +74,37 @@ final class ChatViewModelBootstrapper {
private extension ChatViewModelBootstrapper {
func wireServiceGraph() {
viewModel.privateChatManager.conversationStore = viewModel.conversations
viewModel.privateChatManager.messageRouter = viewModel.messageRouter
viewModel.privateChatManager.unifiedPeerService = viewModel.unifiedPeerService
viewModel.unifiedPeerService.messageRouter = viewModel.messageRouter
// Surface silent outbox drops (attempt cap, TTL expiry, overflow
// eviction) as a visible failure. The store's no-downgrade rule does
// not cover `.failed` over confirmed receipts, so guard here: a drop
// of an already-delivered/read message (e.g. a stale retained copy)
// must not downgrade its status.
viewModel.messageRouter.onMessageDropped = { [weak viewModel] messageID, peerID in
guard let viewModel else { return }
switch viewModel.conversations.deliveryStatus(forMessageID: messageID) {
case .delivered, .read:
// Field proof of the no-downgrade guard: the drop arrived
// after a confirmed receipt, so the `.failed` write is
// deliberately skipped.
SecureLogger.warning(
"📤 Router dropped message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… → .failed skipped (already delivered/read)",
category: .session
)
default:
SecureLogger.warning(
"📤 Router dropped message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… → marked failed",
category: .session
)
viewModel.conversations.setDeliveryStatus(
.failed(reason: "Not delivered"),
forMessageID: messageID
)
}
}
viewModel.commandProcessor.contextProvider = viewModel
viewModel.commandProcessor.meshService = viewModel.meshService
viewModel.participantTracker.configure(context: viewModel)
@@ -89,33 +117,10 @@ private extension ChatViewModelBootstrapper {
}
.store(in: &viewModel.cancellables)
viewModel.privateChatManager.$privateChats
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in
Task { @MainActor [weak viewModel] in
viewModel?.synchronizePrivateConversationStore()
}
}
.store(in: &viewModel.cancellables)
viewModel.privateChatManager.$unreadMessages
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in
Task { @MainActor [weak viewModel] in
viewModel?.synchronizePrivateConversationStore()
}
}
.store(in: &viewModel.cancellables)
viewModel.privateChatManager.$selectedPeer
.receive(on: DispatchQueue.main)
.sink { [weak viewModel] _ in
Task { @MainActor [weak viewModel] in
viewModel?.synchronizeConversationSelectionStore()
}
}
.store(in: &viewModel.cancellables)
// Private message state flows through the single-writer
// `ConversationStore` intents and its `changes` subject; selection
// is owned by the store too (`PrivateChatManager.selectedPeer` is a
// read-only mirror), so no selection bridge is needed here.
viewModel.participantTracker.objectWillChange
.sink { [weak viewModel] _ in
viewModel?.objectWillChange.send()
@@ -144,7 +149,6 @@ private extension ChatViewModelBootstrapper {
viewModel.meshService.startServices()
viewModel.publicMessagePipeline.delegate = viewModel.publicConversationCoordinator
viewModel.publicMessagePipeline.updateActiveChannel(viewModel.activeChannel)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak viewModel] in
guard let viewModel,
@@ -173,7 +177,6 @@ private extension ChatViewModelBootstrapper {
guard let viewModel else { return }
viewModel.allPeers = peers
viewModel.identityResolver.register(peers: peers)
var uniquePeers: [PeerID: BitchatPeer] = [:]
for peer in peers {
@@ -191,9 +194,6 @@ private extension ChatViewModelBootstrapper {
if viewModel.hasTrackedPrivateChatSelection {
viewModel.updatePrivateChatPeerIfNeeded()
}
viewModel.synchronizePrivateConversationStore()
viewModel.synchronizeConversationSelectionStore()
}
}
.store(in: &viewModel.cancellables)
@@ -217,15 +217,7 @@ private extension ChatViewModelBootstrapper {
func configureGeoChannels() {
viewModel.geoChannelCoordinator = GeoChannelCoordinator(
locationManager: viewModel.locationManager,
onChannelSwitch: { [weak viewModel] channel in
viewModel?.switchLocationChannel(to: channel)
},
beginSampling: { [weak viewModel] geohashes in
viewModel?.beginGeohashSampling(for: geohashes)
},
endSampling: { [weak viewModel] in
viewModel?.endGeohashSampling()
}
context: viewModel
)
}
@@ -12,86 +12,86 @@ extension ChatViewModel {
@MainActor
func resubscribeCurrentGeohash() {
nostrCoordinator.resubscribeCurrentGeohash()
nostrCoordinator.subscriptions.resubscribeCurrentGeohash()
}
@MainActor
func subscribeNostrEvent(_ event: NostrEvent) {
nostrCoordinator.subscribeNostrEvent(event)
nostrCoordinator.inbound.subscribeNostrEvent(event)
}
@MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
nostrCoordinator.subscribeGiftWrap(giftWrap, id: id)
nostrCoordinator.inbound.subscribeGiftWrap(giftWrap, id: id)
}
@MainActor
func switchLocationChannel(to channel: ChannelID) {
nostrCoordinator.switchLocationChannel(to: channel)
nostrCoordinator.subscriptions.switchLocationChannel(to: channel)
}
@MainActor
func handleNostrEvent(_ event: NostrEvent) {
nostrCoordinator.handleNostrEvent(event)
nostrCoordinator.inbound.handleNostrEvent(event)
}
@MainActor
func subscribeToGeoChat(_ ch: GeohashChannel) {
nostrCoordinator.subscribeToGeoChat(ch)
nostrCoordinator.subscriptions.subscribeToGeoChat(ch)
}
@MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
nostrCoordinator.handleGiftWrap(giftWrap, id: id)
nostrCoordinator.inbound.handleGiftWrap(giftWrap, id: id)
}
@MainActor
func sendGeohash(context: GeoOutgoingContext) {
nostrCoordinator.sendGeohash(context: context)
nostrCoordinator.subscriptions.sendGeohash(context: context)
}
@MainActor
func beginGeohashSampling(for geohashes: [String]) {
nostrCoordinator.beginGeohashSampling(for: geohashes)
nostrCoordinator.subscriptions.beginGeohashSampling(for: geohashes)
}
@MainActor
func subscribe(_ gh: String) {
nostrCoordinator.subscribe(gh)
nostrCoordinator.subscriptions.subscribe(gh)
}
@MainActor
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
nostrCoordinator.subscribeNostrEvent(event, gh: gh)
nostrCoordinator.presence.subscribeNostrEvent(event, gh: gh)
}
@MainActor
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
nostrCoordinator.cooldownPerGeohash(gh, content: content, event: event)
nostrCoordinator.presence.cooldownPerGeohash(gh, content: content, event: event)
}
@MainActor
func endGeohashSampling() {
nostrCoordinator.endGeohashSampling()
nostrCoordinator.subscriptions.endGeohashSampling()
}
@MainActor
func setupNostrMessageHandling() {
nostrCoordinator.setupNostrMessageHandling()
nostrCoordinator.subscriptions.setupNostrMessageHandling()
}
@MainActor
func handleNostrMessage(_ giftWrap: NostrEvent) {
nostrCoordinator.handleNostrMessage(giftWrap)
nostrCoordinator.inbound.handleNostrMessage(giftWrap)
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
await nostrCoordinator.processNostrMessage(giftWrap)
await nostrCoordinator.inbound.processNostrMessage(giftWrap)
}
@MainActor
func findNoiseKey(for nostrPubkey: String) -> Data? {
nostrCoordinator.findNoiseKey(for: nostrPubkey)
nostrCoordinator.inbound.findNoiseKey(for: nostrPubkey)
}
@MainActor
+27 -14
View File
@@ -9,15 +9,32 @@ import Combine
import Foundation
import Tor
/// The narrow surface `GeoChannelCoordinator` needs from its owner.
///
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
/// minimal context it actually uses instead of capturing `ChatViewModel` in
/// per-callback closures. This keeps the coordinator independently testable
/// (see `GeoChannelCoordinatorContextTests`) and makes its true dependencies
/// explicit. Held `weak` the owner retains the coordinator, and every
/// callback was previously a `[weak viewModel]` capture.
@MainActor
protocol GeoChannelContext: AnyObject {
func switchLocationChannel(to channel: ChannelID)
func beginGeohashSampling(for geohashes: [String])
func endGeohashSampling()
}
// `switchLocationChannel(to:)`, `beginGeohashSampling(for:)`, and
// `endGeohashSampling()` are satisfied by existing `ChatViewModel` members.
extension ChatViewModel: GeoChannelContext {}
@MainActor
final class GeoChannelCoordinator {
private let locationManager: LocationChannelManager
private let bookmarksStore: GeohashBookmarksStore
private let torManager: TorManager
private let onChannelSwitch: (ChannelID) -> Void
private let beginSampling: ([String]) -> Void
private let endSampling: () -> Void
private weak var context: (any GeoChannelContext)?
private var cancellables = Set<AnyCancellable>()
private var regionalGeohashes: [String] = []
@@ -27,16 +44,12 @@ final class GeoChannelCoordinator {
locationManager: LocationChannelManager? = nil,
bookmarksStore: GeohashBookmarksStore? = nil,
torManager: TorManager? = nil,
onChannelSwitch: @escaping (ChannelID) -> Void,
beginSampling: @escaping ([String]) -> Void,
endSampling: @escaping () -> Void
context: any GeoChannelContext
) {
self.locationManager = locationManager ?? Self.defaultLocationManager()
self.bookmarksStore = bookmarksStore ?? GeohashBookmarksStore.shared
self.torManager = torManager ?? Self.defaultTorManager()
self.onChannelSwitch = onChannelSwitch
self.beginSampling = beginSampling
self.endSampling = endSampling
self.context = context
start()
}
@@ -50,7 +63,7 @@ final class GeoChannelCoordinator {
.sink { [weak self] channel in
guard let self else { return }
Task { @MainActor in
self.onChannelSwitch(channel)
self.context?.switchLocationChannel(to: channel)
}
}
.store(in: &cancellables)
@@ -84,7 +97,7 @@ final class GeoChannelCoordinator {
.store(in: &cancellables)
Task { @MainActor in
self.onChannelSwitch(self.locationManager.selectedChannel)
self.context?.switchLocationChannel(to: self.locationManager.selectedChannel)
}
updateSampling()
}
@@ -93,13 +106,13 @@ final class GeoChannelCoordinator {
let union = Array(Set(regionalGeohashes).union(bookmarkedGeohashes))
Task { @MainActor in
guard !union.isEmpty else {
endSampling()
context?.endGeohashSampling()
return
}
if torManager.isForeground() {
beginSampling(union)
context?.beginGeohashSampling(for: union)
} else {
endSampling()
context?.endGeohashSampling()
}
}
}
+198
View File
@@ -0,0 +1,198 @@
import BitFoundation
import BitLogger
import Foundation
import SwiftUI
/// The narrow surface `GeoPresenceTracker` needs from its owner.
///
/// Split out of `ChatNostrContext`: member names are shared with the sibling
/// component contexts so `ChatViewModel` provides a single witness for each.
@MainActor
protocol GeoPresenceContext: AnyObject {
var activeChannel: ChannelID { get }
/// Per-geohash notification cooldown: geohash -> last notify time.
var lastGeoNotificationAt: [String: Date] { get set }
var geoNicknames: [String: String] { get }
var teleportedGeoCount: Int { get }
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
func parseMentions(from content: String) -> [String]
func recordGeoParticipant(pubkeyHex: String, geohash: String)
func geoParticipantCount(for geohash: String) -> Int
func markGeoTeleported(_ pubkeyHexLowercased: String)
/// Appends a geohash message if absent (single-writer store intent).
/// Returns `true` when stored.
@discardableResult
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool
/// Posts the sampled-geohash-activity local notification.
func notifyGeohashActivity(geohash: String, bodyPreview: String)
}
extension ChatViewModel: GeoPresenceContext {
// `activeChannel`, `lastGeoNotificationAt`, `geoNicknames`, the Nostr
// identity/blocking members, and the
// `appendGeohashMessageIfAbsent(_:toGeohash:)` store intent already have
// witnesses on `ChatViewModel`. The members below flatten nested service
// accesses into intent-named calls.
var teleportedGeoCount: Int {
locationPresenceStore.teleportedGeo.count
}
func recordGeoParticipant(pubkeyHex: String, geohash: String) {
participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: geohash)
}
func geoParticipantCount(for geohash: String) -> Int {
participantTracker.participantCount(for: geohash)
}
func markGeoTeleported(_ pubkeyHexLowercased: String) {
locationPresenceStore.markTeleported(pubkeyHexLowercased)
}
func notifyGeohashActivity(geohash: String, bodyPreview: String) {
NotificationService.shared.sendGeohashActivityNotification(geohash: geohash, bodyPreview: bodyPreview)
}
}
/// Geohash presence bookkeeping that is independent of relay subscriptions:
/// teleport-tag detection and marking, the sampling-event LRU dedup, and the
/// per-geohash notification cooldown for sampled activity.
final class GeoPresenceTracker {
private weak var context: (any GeoPresenceContext)?
private var recentGeoSamplingEventIDs = Set<String>()
private var recentGeoSamplingEventIDOrder: [String] = []
init(context: any GeoPresenceContext) {
self.context = context
}
/// True when the event carries a `["t", "teleport"]` tag.
static func hasTeleportTag(_ event: NostrEvent) -> Bool {
event.tags.contains { tag in
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
}
}
/// Marks a peer teleported on a follow-up main-actor hop (keeps the
/// inbound hot path free of presence-store writes).
@MainActor
func scheduleMarkPeerTeleported(_ key: String, logged: Bool) {
Task { @MainActor [weak context] in
guard let context else { return }
context.markGeoTeleported(key)
if logged {
SecureLogger.info(
"GeoTeleport: mark peer teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
category: .session
)
}
}
}
@MainActor
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
guard let context else { return }
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else {
return
}
// The signature was already verified (exactly once, off the main
// actor) by NostrRelayManager before delivery.
guard shouldProcessGeoSamplingEvent(event.id) else { return }
let existingCount = context.geoParticipantCount(for: gh)
context.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
guard let content = event.content.trimmedOrNilIfEmpty else { return }
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
if let my = try? context.deriveNostrIdentity(forGeohash: gh),
my.publicKeyHex.lowercased() == event.pubkey.lowercased() {
return
}
guard existingCount == 0 else { return }
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) > 30 { return }
#if os(iOS)
guard UIApplication.shared.applicationState == .active else { return }
if case .location(let channel) = context.activeChannel, channel.geohash == gh { return }
#elseif os(macOS)
guard NSApplication.shared.isActive else { return }
if case .location(let channel) = context.activeChannel, channel.geohash == gh { return }
#endif
cooldownPerGeohash(gh, content: content, event: event)
}
@MainActor
func cooldownPerGeohash(_ gh: String, content: String, event: NostrEvent) {
guard let context else { return }
let now = Date()
let last = context.lastGeoNotificationAt[gh] ?? .distantPast
if now.timeIntervalSince(last) < TransportConfig.uiGeoNotifyCooldownSeconds { return }
let preview: String = {
let maxLen = TransportConfig.uiGeoNotifySnippetMaxLen
if content.count <= maxLen { return content }
let idx = content.index(content.startIndex, offsetBy: maxLen)
return String(content[..<idx]) + ""
}()
Task { @MainActor [weak context] in
guard let context else { return }
context.lastGeoNotificationAt[gh] = now
let senderSuffix = String(event.pubkey.suffix(4))
let nick = context.geoNicknames[event.pubkey.lowercased()]
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let ts = min(rawTs, Date())
let mentions = context.parseMentions(from: content)
let message = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: ts,
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
if context.appendGeohashMessageIfAbsent(message, toGeohash: gh) {
context.notifyGeohashActivity(geohash: gh, bodyPreview: preview)
}
}
}
/// First-seen check for sampled geohash events with LRU eviction so the
/// dedup set stays bounded across long sampling sessions.
func shouldProcessGeoSamplingEvent(_ eventID: String) -> Bool {
guard !eventID.isEmpty else { return true }
guard recentGeoSamplingEventIDs.insert(eventID).inserted else {
return false
}
recentGeoSamplingEventIDOrder.append(eventID)
let cap = TransportConfig.geoSamplingEventLRUCap
if recentGeoSamplingEventIDOrder.count > cap {
let removeCount = recentGeoSamplingEventIDOrder.count - cap
for staleID in recentGeoSamplingEventIDOrder.prefix(removeCount) {
recentGeoSamplingEventIDs.remove(staleID)
}
recentGeoSamplingEventIDOrder.removeFirst(removeCount)
}
return true
}
func clearGeoSamplingEventDedup() {
recentGeoSamplingEventIDs.removeAll()
recentGeoSamplingEventIDOrder.removeAll()
}
}
@@ -0,0 +1,376 @@
import BitFoundation
import BitLogger
import Foundation
import Tor
/// The narrow surface `GeohashSubscriptionManager` needs from its owner.
///
/// Split out of `ChatNostrContext`: member names are shared with the sibling
/// component contexts so `ChatViewModel` provides a single witness for each.
@MainActor
protocol GeohashSubscriptionContext: AnyObject {
// MARK: Channel & subscription state
var activeChannel: ChannelID { get set }
var currentGeohash: String? { get set }
var geoSubscriptionID: String? { get }
var geoDmSubscriptionID: String? { get }
func setGeoChatSubscriptionID(_ id: String?)
func setGeoDmSubscriptionID(_ id: String?)
/// Geohash sampling subscriptions: subscription ID -> geohash.
var geoSamplingSubs: [String: String] { get }
func addGeoSamplingSub(_ subID: String, forGeohash geohash: String)
func removeGeoSamplingSub(_ subID: String)
/// Clears all sampling subscriptions and returns the removed subscription IDs
/// so the caller can unsubscribe them from the relay manager.
func clearGeoSamplingSubs() -> [String]
var nostrRelayManager: NostrRelayManager? { get }
// MARK: Public timeline & pipeline
var messages: [BitchatMessage] { get }
/// Commits any batched-but-unflushed public messages to the store so a
/// channel switch never strands them in the pipeline buffer.
func flushPublicMessagePipeline()
func refreshVisibleMessages(from channel: ChannelID?)
func addPublicSystemMessage(_ content: String)
func drainPendingGeohashSystemMessages() -> [String]
// MARK: Nostr identity & dedup
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
func currentNostrIdentity() -> NostrIdentity?
func recordProcessedNostrEvent(_ eventID: String)
func clearProcessedNostrEvents()
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID)
// MARK: Geo participants & presence
var teleportedGeoCount: Int { get }
func startGeoParticipantRefreshTimer()
func stopGeoParticipantRefreshTimer()
func setActiveParticipantGeohash(_ geohash: String?)
func recordGeoParticipant(pubkeyHex: String)
func markGeoTeleported(_ pubkeyHexLowercased: String)
func clearGeoTeleported(_ pubkeyHexLowercased: String)
func clearTeleportedGeo()
func clearGeoNicknames()
// MARK: Location channels
var isTeleported: Bool { get }
/// True when regional channels are known and the geohash is not one of them.
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool
}
extension ChatViewModel: GeohashSubscriptionContext {
// `activeChannel`, `currentGeohash`, the subscription-ID accessors, the
// identity members, and the timeline members already have witnesses on
// `ChatViewModel`. The members below flatten nested service accesses into
// intent-named calls.
func flushPublicMessagePipeline() {
publicMessagePipeline.flushIfNeeded()
}
func clearProcessedNostrEvents() {
deduplicationService.clearNostrCaches()
}
func startGeoParticipantRefreshTimer() {
participantTracker.startRefreshTimer()
}
func stopGeoParticipantRefreshTimer() {
participantTracker.stopRefreshTimer()
}
func setActiveParticipantGeohash(_ geohash: String?) {
participantTracker.setActiveGeohash(geohash)
}
func clearGeoTeleported(_ pubkeyHexLowercased: String) {
locationPresenceStore.clearTeleported(pubkeyHexLowercased)
}
func clearTeleportedGeo() {
locationPresenceStore.clearTeleportedGeo()
}
func clearGeoNicknames() {
locationPresenceStore.clearGeoNicknames()
}
var isTeleported: Bool {
locationManager.teleported
}
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool {
let channels = locationManager.availableChannels
return !channels.isEmpty && !channels.contains { $0.geohash == geohash }
}
}
/// Owns subscription IDs and relay lifecycle for geohash channels, geohash
/// DMs, the account gift-wrap mailbox, and background geohash sampling. The
/// only component that talks to `NostrRelayManager`; inbound events are
/// forwarded to `NostrInboundPipeline` / `GeoPresenceTracker`.
final class GeohashSubscriptionManager {
private weak var context: (any GeohashSubscriptionContext)?
private let inbound: NostrInboundPipeline
private let presence: GeoPresenceTracker
init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) {
self.context = context
self.inbound = inbound
self.presence = presence
}
@MainActor
func resubscribeCurrentGeohash() {
guard let context else { return }
guard case .location(let channel) = context.activeChannel else { return }
guard let subID = context.geoSubscriptionID else {
switchLocationChannel(to: context.activeChannel)
return
}
context.startGeoParticipantRefreshTimer()
NostrRelayManager.shared.unsubscribe(id: subID)
let filter = NostrFilter.geohashEphemeral(
channel.geohash,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds),
limit: TransportConfig.nostrGeohashInitialLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: channel.geohash,
count: TransportConfig.nostrGeoRelayCount
)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.inbound.subscribeNostrEvent(event)
}
}
if let dmSub = context.geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
context.setGeoDmSubscriptionID(nil)
}
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
let dmSub = "geo-dm-\(channel.geohash)"
context.setGeoDmSubscriptionID(dmSub)
let dmFilter = NostrFilter.giftWrapsFor(
pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
Task { @MainActor [weak self] in
self?.inbound.subscribeGiftWrap(giftWrap, id: identity)
}
}
}
}
@MainActor
func switchLocationChannel(to channel: ChannelID) {
guard let context else { return }
context.flushPublicMessagePipeline()
context.activeChannel = channel
context.clearProcessedNostrEvents()
switch channel {
case .mesh:
context.refreshVisibleMessages(from: .mesh)
let emptyMesh = context.messages.filter { $0.content.trimmed.isEmpty }.count
if emptyMesh > 0 {
SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session)
}
context.stopGeoParticipantRefreshTimer()
context.setActiveParticipantGeohash(nil)
context.clearTeleportedGeo()
case .location:
context.refreshVisibleMessages(from: channel)
}
if case .location = channel {
for content in context.drainPendingGeohashSystemMessages() {
context.addPublicSystemMessage(content)
}
}
if let sub = context.geoSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: sub)
context.setGeoChatSubscriptionID(nil)
}
if let dmSub = context.geoDmSubscriptionID {
NostrRelayManager.shared.unsubscribe(id: dmSub)
context.setGeoDmSubscriptionID(nil)
}
context.currentGeohash = nil
context.setActiveParticipantGeohash(nil)
context.clearGeoNicknames()
guard case .location(let channel) = channel else { return }
context.currentGeohash = channel.geohash
context.setActiveParticipantGeohash(channel.geohash)
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
let key = identity.publicKeyHex.lowercased()
if context.isTeleported && context.isGeohashOutsideRegionalChannels(channel.geohash) {
context.markGeoTeleported(key)
SecureLogger.info(
"GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
category: .session
)
} else {
context.clearGeoTeleported(key)
}
}
let subID = "geo-\(channel.geohash)"
context.setGeoChatSubscriptionID(subID)
context.startGeoParticipantRefreshTimer()
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.inbound.handleNostrEvent(event)
}
}
subscribeToGeoChat(channel)
}
@MainActor
func subscribeToGeoChat(_ channel: GeohashChannel) {
guard let context else { return }
guard let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) else { return }
let dmSub = "geo-dm-\(channel.geohash)"
context.setGeoDmSubscriptionID(dmSub)
if TorManager.shared.isReady {
SecureLogger.debug("GeoDM: subscribing DMs pub=\(identity.publicKeyHex.prefix(8))… sub=\(dmSub)", category: .session)
}
let dmFilter = NostrFilter.giftWrapsFor(
pubkey: identity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in
Task { @MainActor [weak self] in
self?.inbound.handleGiftWrap(giftWrap, id: identity)
}
}
}
@MainActor
func sendGeohash(context geoContext: ChatViewModel.GeoOutgoingContext) {
guard let context else { return }
let channel = geoContext.channel
let event = geoContext.event
let identity = geoContext.identity
let targetRelays = GeoRelayDirectory.shared.closestRelays(
toGeohash: channel.geohash,
count: TransportConfig.nostrGeoRelayCount
)
if targetRelays.isEmpty {
SecureLogger.warning("Geo: no geohash relays available for \(channel.geohash); not sending", category: .session)
} else {
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
}
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
SecureLogger.debug(
"GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(geoContext.teleported)",
category: .session
)
if geoContext.teleported && context.isGeohashOutsideRegionalChannels(channel.geohash) {
let key = identity.publicKeyHex.lowercased()
context.markGeoTeleported(key)
SecureLogger.info(
"GeoTeleport: mark self teleported key=\(key.prefix(8))… total=\(context.teleportedGeoCount)",
category: .session
)
}
context.recordProcessedNostrEvent(event.id)
}
@MainActor
func beginGeohashSampling(for geohashes: [String]) {
guard let context else { return }
if !TorManager.shared.isForeground() {
endGeohashSampling()
return
}
let desired = Set(geohashes)
let current = Set(context.geoSamplingSubs.values)
let toAdd = desired.subtracting(current)
let toRemove = current.subtracting(desired)
for (subID, gh) in context.geoSamplingSubs where toRemove.contains(gh) {
NostrRelayManager.shared.unsubscribe(id: subID)
context.removeGeoSamplingSub(subID)
}
for gh in toAdd {
subscribe(gh)
}
}
@MainActor
func subscribe(_ gh: String) {
guard let context else { return }
let subID = "geo-sample-\(gh)"
context.addGeoSamplingSub(subID, forGeohash: gh)
let filter = NostrFilter.geohashEphemeral(
gh,
since: Date().addingTimeInterval(-TransportConfig.nostrGeohashSampleLookbackSeconds),
limit: TransportConfig.nostrGeohashSampleLimit
)
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5)
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
Task { @MainActor [weak self] in
self?.presence.subscribeNostrEvent(event, gh: gh)
}
}
}
@MainActor
func endGeohashSampling() {
guard let context else { return }
for subID in context.clearGeoSamplingSubs() {
NostrRelayManager.shared.unsubscribe(id: subID)
}
presence.clearGeoSamplingEventDedup()
}
@MainActor
func setupNostrMessageHandling() {
guard let context else { return }
guard let currentIdentity = context.currentNostrIdentity() else {
SecureLogger.warning("⚠️ No Nostr identity available for message handling", category: .session)
return
}
SecureLogger.debug(
"🔑 Setting up Nostr subscription for pubkey: \(currentIdentity.publicKeyHex.prefix(16))...",
category: .session
)
let filter = NostrFilter.giftWrapsFor(
pubkey: currentIdentity.publicKeyHex,
since: Date().addingTimeInterval(-TransportConfig.nostrDMSubscribeLookbackSeconds)
)
context.nostrRelayManager?.subscribe(filter: filter, id: "chat-messages") { [weak self] event in
Task { @MainActor [weak self] in
self?.inbound.handleNostrMessage(event)
}
}
}
}
@@ -0,0 +1,544 @@
import BitFoundation
import BitLogger
import Foundation
/// The narrow surface `NostrInboundPipeline` needs from its owner.
///
/// Split out of `ChatNostrContext`: member names are shared with the sibling
/// component contexts so `ChatViewModel` provides a single witness for each.
@MainActor
protocol NostrInboundPipelineContext: AnyObject {
var currentGeohash: String? { get }
// MARK: Event dedup
func hasProcessedNostrEvent(_ eventID: String) -> Bool
func recordProcessedNostrEvent(_ eventID: String)
// MARK: Nostr identity & blocking
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
func currentNostrIdentity() -> NostrIdentity?
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String
// MARK: Favorites bridge
/// All favorite relationships, used to bridge a Nostr pubkey back to a
/// Noise key on the inbound DM path.
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship]
// MARK: Presence & key mapping
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String)
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID)
func recordGeoParticipant(pubkeyHex: String)
// MARK: Inbound public messages
func handlePublicMessage(_ message: BitchatMessage)
func checkForMentions(_ message: BitchatMessage)
func sendHapticFeedback(for message: BitchatMessage)
func parseMentions(from content: String) -> [String]
// MARK: Inbound private (DM) payloads
func handlePrivateMessage(
_ payload: NoisePayload,
senderPubkey: String,
convKey: PeerID,
id: NostrIdentity,
messageTimestamp: Date
)
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID)
}
extension ChatViewModel: NostrInboundPipelineContext {
// `currentGeohash`, the identity/blocking members, key mapping, and the
// inbound message handlers already have witnesses on `ChatViewModel`.
// The members below flatten nested service accesses into intent-named calls.
func hasProcessedNostrEvent(_ eventID: String) -> Bool {
deduplicationService.hasProcessedNostrEvent(eventID)
}
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] {
Array(FavoritesPersistenceService.shared.favorites.values)
}
func recordProcessedNostrEvent(_ eventID: String) {
deduplicationService.recordNostrEvent(eventID)
}
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) {
locationPresenceStore.setNickname(nickname, for: pubkeyHex)
}
func recordGeoParticipant(pubkeyHex: String) {
participantTracker.recordParticipant(pubkeyHex: pubkeyHex)
}
}
/// The inbound Nostr hot path: verified relay events in, chat messages /
/// Noise payloads out. Pure transformation plus dedup no relay lifecycle.
///
/// Every event arriving here already had its Schnorr signature verified
/// exactly once, off the main actor, by `NostrRelayManager`'s serial inbound
/// pipeline (which records events into its own dedup cache only AFTER
/// verification, so forged copies can't suppress genuine events). This
/// pipeline therefore never re-verifies; it keeps its own event-ID dedup
/// (cheap main-actor lookups) and moves NIP-17 gift-wrap decryption two
/// ECDH+ChaCha layers off the main actor with an atomic main-actor
/// check-and-record.
final class NostrInboundPipeline {
private weak var context: (any NostrInboundPipelineContext)?
private let presence: GeoPresenceTracker
private var geoEventLogCount = 0
/// Monotonic panic-wipe generation for this pipeline. A panic wipe clears
/// relay handlers so no NEW events flow, but a detached decrypt task
/// spawned just BEFORE the wipe which strongly captures a pre-wipe Nostr
/// private key and ciphertext survives it. Spawn sites capture this
/// value; the task compares it at its main-actor hops and drops its result
/// (no delivery; the captured identity and plaintext die with the task)
/// if `invalidateInFlightDecrypts()` bumped it in between.
@MainActor private(set) var wipeGeneration: UInt64 = 0
/// Called from `ChatViewModel.panicClearAllData()` so plaintext decrypted
/// with pre-wipe keys can never land in post-wipe state.
@MainActor
func invalidateInFlightDecrypts() {
wipeGeneration &+= 1
}
init(context: any NostrInboundPipelineContext, presence: GeoPresenceTracker) {
self.context = context
self.presence = presence
}
@MainActor
func subscribeNostrEvent(_ event: NostrEvent) {
guard let context else { return }
// Cheap rejects (kind, dedup lookup) duplicates dominate real
// traffic. The signature was already verified (exactly once, off the
// main actor) by NostrRelayManager before delivery.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
!context.hasProcessedNostrEvent(event.id)
else {
return
}
context.recordProcessedNostrEvent(event.id)
if let gh = context.currentGeohash,
let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: gh),
myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 {
return
}
}
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
let nick = nickTag[1].trimmed
context.setGeoNickname(nick, forPubkey: event.pubkey)
}
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey))
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey))
context.recordGeoParticipant(pubkeyHex: event.pubkey)
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
}
if GeoPresenceTracker.hasTeleportTag(event) {
let key = event.pubkey.lowercased()
let isSelf: Bool = {
if let gh = context.currentGeohash,
let myIdentity = try? context.deriveNostrIdentity(forGeohash: gh) {
return myIdentity.publicKeyHex.lowercased() == key
}
return false
}()
if !isSelf {
presence.scheduleMarkPeerTeleported(key, logged: false)
}
}
let senderName = context.displayNameForNostrPubkey(event.pubkey)
let content = event.content.trimmed
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let timestamp = min(rawTs, Date())
let mentions = context.parseMentions(from: content)
let message = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: timestamp,
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor [weak context] in
guard let context else { return }
let isBlocked = context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased())
context.handlePublicMessage(message)
if !isBlocked {
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
}
}
@MainActor
func handleNostrEvent(_ event: NostrEvent) {
guard let context else { return }
// Cheap rejects (kind, dedup lookup) the signature was already
// verified (exactly once, off the main actor) by NostrRelayManager.
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
else {
return
}
if context.hasProcessedNostrEvent(event.id) { return }
context.recordProcessedNostrEvent(event.id)
// Sampled: fires for every geo event and floods dev logs in busy geohashes.
geoEventLogCount += 1
if geoEventLogCount == 1 || geoEventLogCount.isMultiple(of: TransportConfig.nostrInboundEventLogInterval) {
SecureLogger.debug("GeoTeleport: recv #\(geoEventLogCount) pub=\(event.pubkey.prefix(8))… tags=\(event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ","))", category: .session)
}
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
return
}
let hasTeleportTag = GeoPresenceTracker.hasTeleportTag(event)
let isSelf: Bool = {
if let gh = context.currentGeohash,
let my = try? context.deriveNostrIdentity(forGeohash: gh) {
return my.publicKeyHex.lowercased() == event.pubkey.lowercased()
}
return false
}()
if hasTeleportTag, !isSelf {
presence.scheduleMarkPeerTeleported(event.pubkey.lowercased(), logged: true)
}
context.recordGeoParticipant(pubkeyHex: event.pubkey)
if isSelf {
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
if Date().timeIntervalSince(eventTime) < 15 {
return
}
}
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey)
}
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey))
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey))
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
return
}
let senderName = context.displayNameForNostrPubkey(event.pubkey)
let content = event.content
if let teleTag = event.tags.first(where: { $0.first == "t" }),
teleTag.count >= 2,
teleTag[1] == "teleport",
content.trimmed.isEmpty {
return
}
let rawTs = Date(timeIntervalSince1970: TimeInterval(event.created_at))
let mentions = context.parseMentions(from: content)
let message = BitchatMessage(
id: event.id,
sender: senderName,
content: content,
timestamp: min(rawTs, Date()),
isRelay: false,
senderPeerID: PeerID(nostr: event.pubkey),
mentions: mentions.isEmpty ? nil : mentions
)
Task { @MainActor [weak context] in
guard let context else { return }
context.handlePublicMessage(message)
context.checkForMentions(message)
context.sendHapticFeedback(for: message)
}
}
@MainActor
func subscribeGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return }
// Cheap dedup pre-check only; processGeohashGiftWrap does the
// authoritative main-actor check-and-record before the off-main
// NIP-17 unwrap. The outer signature was already verified (exactly
// once, off the main actor) by NostrRelayManager.
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
// Capture the wipe generation at spawn, alongside the per-geohash
// identity (private key) the detached task strongly captures. A panic
// wipe between spawn and delivery bumps the generation, and the task
// drops its result instead of delivering plaintext post-wipe.
let wipeGeneration = self.wipeGeneration
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: false, wipeGeneration: wipeGeneration)
}
}
@MainActor
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
guard let context else { return }
// Cheap dedup pre-check only; see subscribeGiftWrap.
if context.hasProcessedNostrEvent(giftWrap.id) {
return
}
// Spawn-time wipe-generation capture; see subscribeGiftWrap.
let wipeGeneration = self.wipeGeneration
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processGeohashGiftWrap(giftWrap, id: id, verbose: true, wipeGeneration: wipeGeneration)
}
}
/// Geohash-DM gift wrap ingest. The NIP-17 unwrap (two ECDH+ChaCha
/// layers) runs off the main actor; results hop back for state updates.
/// `verbose` keeps `handleGiftWrap`'s decrypt logging without adding it
/// to the sampling path.
///
/// `wipeGeneration` is this pipeline's generation captured at spawn (the
/// moment the pre-wipe `id` was captured); a mismatch at either main-actor
/// hop means a panic wipe happened in between, so the task bails without
/// decrypting (first hop) or without delivering the plaintext (second
/// hop) the captured identity and any decrypted material are simply
/// dropped with the task.
private func processGeohashGiftWrap(
_ giftWrap: NostrEvent,
id: NostrIdentity,
verbose: Bool,
wipeGeneration: UInt64
) async {
guard let context else { return }
// Authoritative check-and-record, atomic on the main actor so two
// concurrent detached tasks can't both process the same event.
let alreadyProcessed: Bool = await MainActor.run {
guard self.wipeGeneration == wipeGeneration else { return true }
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
context.recordProcessedNostrEvent(giftWrap.id)
return false
}
if alreadyProcessed { return }
guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: id
) else {
if verbose {
SecureLogger.warning("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))", category: .session)
}
return
}
if verbose {
SecureLogger.debug(
"GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...",
category: .session
)
}
await MainActor.run {
// A panic wipe during the off-main decrypt must not let the
// pre-wipe plaintext reach post-wipe state; drop it here, atomic
// with the wipe on the main actor.
guard self.wipeGeneration == wipeGeneration else { return }
guard let packet = Self.decodeEmbeddedBitChatPacket(from: content),
packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload)
else {
return
}
let convKey = PeerID(nostr_: senderPubkey)
context.registerNostrKeyMapping(senderPubkey, for: convKey)
switch payload.type {
case .privateMessage:
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs))
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: convKey,
id: id,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: convKey)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: convKey)
case .verifyChallenge, .verifyResponse:
break
}
}
}
@MainActor
func handleNostrMessage(_ giftWrap: NostrEvent) {
guard let context else { return }
// Cheap dedup pre-check only; processNostrMessage does the
// authoritative check-and-record before the off-main NIP-17 unwrap.
// The outer signature was already verified (exactly once, off the
// main actor) by NostrRelayManager, and only verified events are
// recorded, so a forged-signature copy can never poison the dedup
// set and suppress the genuine event.
if context.hasProcessedNostrEvent(giftWrap.id) { return }
Task.detached(priority: .userInitiated) { [weak self] in
await self?.processNostrMessage(giftWrap)
}
}
func processNostrMessage(_ giftWrap: NostrEvent) async {
guard let context else { return }
// Authoritative check-and-record, atomic on the main actor so two
// concurrent detached tasks can't both process the same event.
let alreadyProcessed: Bool = await MainActor.run {
if context.hasProcessedNostrEvent(giftWrap.id) { return true }
context.recordProcessedNostrEvent(giftWrap.id)
return false
}
if alreadyProcessed { return }
// Fetch the identity and the wipe generation in ONE main-actor hop:
// the generation then vouches for exactly this identity. A wipe after
// this point bumps the generation and the delivery hop below drops
// the decrypted result (same guard as processGeohashGiftWrap; this
// account-mailbox path had the identical hazard).
let (currentIdentity, wipeGeneration): (NostrIdentity?, UInt64) = await MainActor.run {
(context.currentNostrIdentity(), self.wipeGeneration)
}
guard let currentIdentity else { return }
do {
let (content, senderPubkey, rumorTimestamp) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: currentIdentity
)
if content.hasPrefix("verify:") {
return
}
if content.hasPrefix("bitchat1:") {
let packet: BitchatPacket? = await MainActor.run {
Self.decodeEmbeddedBitChatPacket(from: content)
}
guard let packet else {
SecureLogger.error("Failed to decode embedded BitChat packet from Nostr DM", category: .session)
return
}
let actualSenderNoiseKey: Data? = await MainActor.run {
self.findNoiseKey(for: senderPubkey)
}
let targetPeerID = PeerID(str: actualSenderNoiseKey?.hexEncodedString()) ?? PeerID(nostr_: senderPubkey)
if packet.type == MessageType.noiseEncrypted.rawValue,
let payload = NoisePayload.decode(packet.payload) {
let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTimestamp))
await MainActor.run {
// Drop pre-wipe plaintext if a panic wipe landed
// during the off-main decrypt (see above).
guard self.wipeGeneration == wipeGeneration else { return }
context.registerNostrKeyMapping(senderPubkey, for: targetPeerID)
switch payload.type {
case .privateMessage:
context.handlePrivateMessage(
payload,
senderPubkey: senderPubkey,
convKey: targetPeerID,
id: currentIdentity,
messageTimestamp: messageTimestamp
)
case .delivered:
context.handleDelivered(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .readReceipt:
context.handleReadReceipt(payload, senderPubkey: senderPubkey, convKey: targetPeerID)
case .verifyChallenge, .verifyResponse:
break
}
}
}
} else {
SecureLogger.debug("Ignoring non-embedded Nostr DM content", category: .session)
}
} catch {
SecureLogger.error("Failed to decrypt Nostr message: \(error)", category: .session)
}
}
/// Resolves the Noise static key behind a Nostr pubkey via the favorites
/// store. Lives here because the inbound DM path needs it per message;
/// the favorites glue in `ChatNostrCoordinator` delegates to it.
@MainActor
func findNoiseKey(for nostrPubkey: String) -> Data? {
guard let context else { return nil }
let favorites = context.allFavoriteRelationships()
var npubToMatch = nostrPubkey
if !nostrPubkey.hasPrefix("npub") {
if let pubkeyData = Data(hexString: nostrPubkey),
let encoded = try? Bech32.encode(hrp: "npub", data: pubkeyData) {
npubToMatch = encoded
} else {
SecureLogger.warning(
"⚠️ Invalid hex public key format or encoding failed: \(nostrPubkey.prefix(16))...",
category: .session
)
}
}
for relationship in favorites {
if let storedNostrKey = relationship.peerNostrPublicKey {
if storedNostrKey == npubToMatch {
return relationship.peerNoisePublicKey
}
if !storedNostrKey.hasPrefix("npub") && storedNostrKey == nostrPubkey {
SecureLogger.debug("✅ Found Noise key for Nostr sender (hex match)", category: .session)
return relationship.peerNoisePublicKey
}
}
}
SecureLogger.debug(
"⚠️ No matching Noise key found for Nostr pubkey: \(nostrPubkey.prefix(16))... (tried npub: \(npubToMatch.prefix(16))...)",
category: .session
)
return nil
}
}
private extension NostrInboundPipeline {
@MainActor
static func decodeEmbeddedBitChatPacket(from content: String) -> BitchatPacket? {
guard content.hasPrefix("bitchat1:") else { return nil }
let encoded = String(content.dropFirst("bitchat1:".count))
let maxBytes = FileTransferLimits.maxFramedFileBytes
let maxEncoded = ((maxBytes + 2) / 3) * 4
guard encoded.count <= maxEncoded else { return nil }
guard let packetData = Base64URLCoding.decode(encoded),
packetData.count <= maxBytes
else {
return nil
}
return BitchatPacket.from(packetData)
}
}
+26 -72
View File
@@ -2,7 +2,11 @@
// PublicMessagePipeline.swift
// bitchat
//
// Handles batching and deduplication of public chat messages before surfacing them to the UI.
// Batches visible-channel public messages before committing them to the
// ConversationStore: the deliberate ~80 ms UI flush cadence survives the
// store cutover, while ordering, dedup, and caps live in the store itself
// (its timestamp-ordered insert replaced this pipeline's late-insert
// threshold positioning; see docs/CONVERSATION-STORE-DESIGN.md).
//
import BitFoundation
@@ -10,12 +14,13 @@ import Foundation
@MainActor
protocol PublicMessagePipelineDelegate: AnyObject {
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage]
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage])
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date?
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date)
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline)
/// Commits a batched message to its conversation in the store.
/// Returns `false` when the message was already present (ID dedup).
@discardableResult
func pipeline(_ pipeline: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage)
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool)
}
@@ -24,14 +29,13 @@ protocol PublicMessagePipelineDelegate: AnyObject {
final class PublicMessagePipeline {
weak var delegate: PublicMessagePipelineDelegate?
private var buffer: [BitchatMessage] = []
private var buffer: [(message: BitchatMessage, conversationID: ConversationID)] = []
private var timer: Timer?
private let baseFlushInterval: TimeInterval
private var dynamicFlushInterval: TimeInterval
private var recentBatchSizes: [Int] = []
private let maxRecentBatchSamples: Int
private let dedupWindow: TimeInterval
private var activeChannel: ChannelID = .mesh
init(
baseFlushInterval: TimeInterval = TransportConfig.basePublicFlushInterval,
@@ -48,25 +52,17 @@ final class PublicMessagePipeline {
timer?.invalidate()
}
func updateActiveChannel(_ channel: ChannelID) {
activeChannel = channel
}
func enqueue(_ message: BitchatMessage) {
buffer.append(message)
/// Buffers a message destined for `conversationID`; the next batched
/// flush commits it to the store. Each entry carries its destination so
/// a channel switch mid-batch can never misroute buffered messages.
func enqueue(_ message: BitchatMessage, to conversationID: ConversationID) {
buffer.append((message, conversationID))
scheduleFlush()
}
func flushIfNeeded() {
flushBuffer()
}
func reset() {
timer?.invalidate()
timer = nil
buffer.removeAll(keepingCapacity: false)
}
}
private extension PublicMessagePipeline {
@@ -91,57 +87,38 @@ private extension PublicMessagePipeline {
delegate.pipelineSetBatchingState(self, isBatching: true)
var existingIDs = Set(delegate.pipelineCurrentMessages(self).map { $0.id })
var pending: [(message: BitchatMessage, contentKey: String)] = []
// Content-window dedup against recorded keys and within the batch;
// ID dedup happens in the store at commit time.
var pending: [(message: BitchatMessage, conversationID: ConversationID, contentKey: String)] = []
var batchContentLatest: [String: Date] = [:]
for message in buffer {
if existingIDs.contains(message.id) { continue }
let contentKey = delegate.pipeline(self, normalizeContent: message.content)
for item in buffer {
let contentKey = delegate.pipeline(self, normalizeContent: item.message.content)
if let ts = delegate.pipeline(self, contentTimestampForKey: contentKey),
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
abs(ts.timeIntervalSince(item.message.timestamp)) < dedupWindow {
continue
}
if let ts = batchContentLatest[contentKey],
abs(ts.timeIntervalSince(message.timestamp)) < dedupWindow {
abs(ts.timeIntervalSince(item.message.timestamp)) < dedupWindow {
continue
}
existingIDs.insert(message.id)
pending.append((message, contentKey))
batchContentLatest[contentKey] = message.timestamp
pending.append((item.message, item.conversationID, contentKey))
batchContentLatest[contentKey] = item.message.timestamp
}
buffer.removeAll(keepingCapacity: true)
guard !pending.isEmpty else {
delegate.pipelineSetBatchingState(self, isBatching: false)
if !buffer.isEmpty { scheduleFlush() }
return
}
pending.sort { $0.message.timestamp < $1.message.timestamp }
var messages = delegate.pipelineCurrentMessages(self)
let threshold = lateInsertThreshold(for: activeChannel)
let lastTimestamp = messages.last?.timestamp ?? .distantPast
for item in pending {
let message = item.message
if threshold == 0 || message.timestamp < lastTimestamp.addingTimeInterval(-threshold) {
let index = insertionIndex(for: message.timestamp, in: messages)
if index >= messages.count {
messages.append(message)
} else {
messages.insert(message, at: index)
}
} else {
messages.append(message)
}
delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: message.timestamp)
guard delegate.pipeline(self, commit: item.message, to: item.conversationID) else { continue }
delegate.pipeline(self, recordContentKey: item.contentKey, timestamp: item.message.timestamp)
}
delegate.pipeline(self, setMessages: messages)
delegate.pipelineTrimMessages(self)
updateFlushInterval(withBatchSize: pending.count)
for item in pending {
@@ -165,27 +142,4 @@ private extension PublicMessagePipeline {
: Double(recentBatchSizes.reduce(0, +)) / Double(recentBatchSizes.count)
dynamicFlushInterval = avg > 100.0 ? 0.12 : baseFlushInterval
}
func lateInsertThreshold(for channel: ChannelID) -> TimeInterval {
switch channel {
case .mesh:
return TransportConfig.uiLateInsertThreshold
case .location:
return TransportConfig.uiLateInsertThresholdGeo
}
}
func insertionIndex(for timestamp: Date, in messages: [BitchatMessage]) -> Int {
var low = 0
var high = messages.count
while low < high {
let mid = (low + high) / 2
if messages[mid].timestamp < timestamp {
low = mid + 1
} else {
high = mid
}
}
return low
}
}
@@ -1,125 +0,0 @@
//
// PublicTimelineStore.swift
// bitchat
//
// Maintains mesh and geohash public timelines with simple caps and helpers.
//
import BitFoundation
import Foundation
struct PublicTimelineStore {
private var meshTimeline: [BitchatMessage] = []
private var geohashTimelines: [String: [BitchatMessage]] = [:]
private var pendingGeohashSystemMessages: [String] = []
private let meshCap: Int
private let geohashCap: Int
init(meshCap: Int, geohashCap: Int) {
self.meshCap = meshCap
self.geohashCap = geohashCap
}
mutating func append(_ message: BitchatMessage, to channel: ChannelID) {
switch channel {
case .mesh:
guard !meshTimeline.contains(where: { $0.id == message.id }) else { return }
meshTimeline.append(message)
trimMeshTimelineIfNeeded()
case .location(let channel):
append(message, toGeohash: channel.geohash)
}
}
mutating func append(_ message: BitchatMessage, toGeohash geohash: String) {
var timeline = geohashTimelines[geohash] ?? []
guard !timeline.contains(where: { $0.id == message.id }) else { return }
timeline.append(message)
trimGeohashTimelineIfNeeded(&timeline)
geohashTimelines[geohash] = timeline
}
/// Append message if absent, returning true when stored.
mutating func appendIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
var timeline = geohashTimelines[geohash] ?? []
guard !timeline.contains(where: { $0.id == message.id }) else { return false }
timeline.append(message)
trimGeohashTimelineIfNeeded(&timeline)
geohashTimelines[geohash] = timeline
return true
}
mutating func messages(for channel: ChannelID) -> [BitchatMessage] {
switch channel {
case .mesh:
return meshTimeline
case .location(let channel):
let cleaned = geohashTimelines[channel.geohash]?.cleanedAndDeduped() ?? []
geohashTimelines[channel.geohash] = cleaned
return cleaned
}
}
mutating func clear(channel: ChannelID) {
switch channel {
case .mesh:
meshTimeline.removeAll()
case .location(let channel):
geohashTimelines[channel.geohash] = []
}
}
@discardableResult
mutating func removeMessage(withID id: String) -> BitchatMessage? {
if let index = meshTimeline.firstIndex(where: { $0.id == id }) {
return meshTimeline.remove(at: index)
}
for key in Array(geohashTimelines.keys) {
var timeline = geohashTimelines[key] ?? []
if let index = timeline.firstIndex(where: { $0.id == id }) {
let removed = timeline.remove(at: index)
geohashTimelines[key] = timeline.isEmpty ? nil : timeline
return removed
}
}
return nil
}
mutating func removeMessages(in geohash: String, where predicate: (BitchatMessage) -> Bool) {
var timeline = geohashTimelines[geohash] ?? []
timeline.removeAll(where: predicate)
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
}
mutating func mutateGeohash(_ geohash: String, _ transform: (inout [BitchatMessage]) -> Void) {
var timeline = geohashTimelines[geohash] ?? []
transform(&timeline)
geohashTimelines[geohash] = timeline.isEmpty ? nil : timeline
}
mutating func queueGeohashSystemMessage(_ content: String) {
pendingGeohashSystemMessages.append(content)
}
mutating func drainPendingGeohashSystemMessages() -> [String] {
defer { pendingGeohashSystemMessages.removeAll(keepingCapacity: false) }
return pendingGeohashSystemMessages
}
func geohashKeys() -> [String] {
Array(geohashTimelines.keys)
}
private mutating func trimMeshTimelineIfNeeded() {
guard meshTimeline.count > meshCap else { return }
meshTimeline = Array(meshTimeline.suffix(meshCap))
}
private func trimGeohashTimelineIfNeeded(_ timeline: inout [BitchatMessage]) {
guard timeline.count > geohashCap else { return }
timeline = Array(timeline.suffix(geohashCap))
}
}
+58 -40
View File
@@ -2,24 +2,24 @@ import SwiftUI
struct AppInfoView: View {
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white
}
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
@ThemedPalette private var palette
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
private var selectedTheme: AppTheme {
AppTheme(rawValue: appThemeRawValue) ?? .matrix
}
private var backgroundColor: Color { palette.background }
private var textColor: Color { palette.primary }
private var secondaryTextColor: Color { palette.secondary }
// MARK: - Constants
private enum Strings {
static let appName: LocalizedStringKey = "app_info.app_name"
static let tagline: LocalizedStringKey = "app_info.tagline"
static let appearanceTitle: LocalizedStringKey = "app_info.appearance.title"
enum Features {
static let title: LocalizedStringKey = "app_info.features.title"
@@ -101,12 +101,12 @@ struct AppInfoView: View {
.foregroundColor(textColor)
.padding()
}
.background(backgroundColor.opacity(0.95))
.themedSurface(opacity: 0.95)
ScrollView {
infoContent
}
.background(backgroundColor)
.themedSheetBackground()
}
.frame(width: 600, height: 700)
#else
@@ -114,13 +114,13 @@ struct AppInfoView: View {
ScrollView {
infoContent
}
.background(backgroundColor)
.themedSheetBackground()
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(textColor)
.frame(width: 32, height: 32)
}
@@ -138,16 +138,40 @@ struct AppInfoView: View {
// Header
VStack(alignment: .center, spacing: 8) {
Text(Strings.appName)
.font(.bitchatSystem(size: 32, weight: .bold, design: .monospaced))
.bitchatFont(size: 32, weight: .bold)
.foregroundColor(textColor)
Text(Strings.tagline)
.font(.bitchatSystem(size: 16, design: .monospaced))
.bitchatFont(size: 16)
.foregroundColor(secondaryTextColor)
}
.frame(maxWidth: .infinity)
.padding(.vertical)
// Appearance single row: label left, theme chips right
HStack(spacing: 12) {
SectionHeader(Strings.appearanceTitle)
Spacer()
ForEach(AppTheme.allCases) { theme in
Button {
appThemeRawValue = theme.rawValue
} label: {
Text(theme.displayNameKey)
.bitchatFont(size: 13, weight: selectedTheme == theme ? .semibold : .regular)
.foregroundColor(selectedTheme == theme ? palette.accent : secondaryTextColor)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(selectedTheme == theme ? palette.accent.opacity(0.15) : Color.clear)
)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityAddTraits(selectedTheme == theme ? .isSelected : [])
}
}
// How to Use
VStack(alignment: .leading, spacing: 16) {
SectionHeader(Strings.HowToUse.title)
@@ -157,7 +181,7 @@ struct AppInfoView: View {
Text(instruction)
}
}
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(textColor)
}
@@ -201,19 +225,17 @@ struct AppInfoFeatureInfo {
struct SectionHeader: View {
let title: LocalizedStringKey
@Environment(\.colorScheme) var colorScheme
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
@ThemedPalette private var palette
private var textColor: Color { palette.primary }
init(_ title: LocalizedStringKey) {
self.title = title
}
var body: some View {
Text(title)
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.bitchatFont(size: 16, weight: .bold)
.foregroundColor(textColor)
.padding(.top, 8)
}
@@ -221,16 +243,12 @@ struct SectionHeader: View {
struct FeatureRow: View {
let info: AppInfoFeatureInfo
@Environment(\.colorScheme) var colorScheme
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
}
@ThemedPalette private var palette
private var textColor: Color { palette.primary }
private var secondaryTextColor: Color { palette.secondary }
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: info.icon)
@@ -240,11 +258,11 @@ struct FeatureRow: View {
VStack(alignment: .leading, spacing: 4) {
Text(info.title)
.font(.bitchatSystem(size: 14, weight: .semibold, design: .monospaced))
.bitchatFont(size: 14, weight: .semibold)
.foregroundColor(textColor)
Text(info.description)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(secondaryTextColor)
.fixedSize(horizontal: false, vertical: true)
}
@@ -10,13 +10,10 @@ import SwiftUI
struct CommandSuggestionsView: View {
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@ThemedPalette private var palette
@Binding var messageText: String
let textColor: Color
let backgroundColor: Color
let secondaryTextColor: Color
private var filteredCommands: [CommandInfo] {
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
@@ -27,42 +24,43 @@ struct CommandSuggestionsView: View {
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(filteredCommands) { command in
Button {
messageText = command.alias + " "
} label: {
buttonRow(for: command)
// Render nothing when there are no matches: a zero-height view would
// still receive the composer VStack's spacing and push the input row
// off-center.
if !filteredCommands.isEmpty {
VStack(alignment: .leading, spacing: 0) {
ForEach(filteredCommands) { command in
Button {
messageText = command.alias + " "
} label: {
buttonRow(for: command)
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
.buttonStyle(.plain)
.background(Color.gray.opacity(0.1))
}
.themedOverlayPanel()
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
}
private func buttonRow(for command: CommandInfo) -> some View {
HStack {
Text(command.alias)
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 11)
.foregroundColor(palette.primary)
.fontWeight(.medium)
if let placeholder = command.placeholder {
Text(placeholder)
.font(.bitchatSystem(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor.opacity(0.8))
.bitchatFont(size: 10)
.foregroundColor(palette.secondary.opacity(0.8))
}
Spacer()
Text(command.description)
.font(.bitchatSystem(size: 10, design: .monospaced))
.foregroundColor(secondaryTextColor)
.bitchatFont(size: 10)
.foregroundColor(palette.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 3)
@@ -81,16 +79,11 @@ struct CommandSuggestionsView: View {
)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: viewModel.conversationStore
conversations: viewModel.conversations
)
let locationChannelsModel = LocationChannelsModel()
CommandSuggestionsView(
messageText: $messageText,
textColor: .green,
backgroundColor: .primary,
secondaryTextColor: .secondary
)
.environmentObject(privateConversationModel)
.environmentObject(locationChannelsModel)
CommandSuggestionsView(messageText: $messageText)
.environmentObject(privateConversationModel)
.environmentObject(locationChannelsModel)
}
@@ -10,18 +10,14 @@ import SwiftUI
import BitFoundation
struct DeliveryStatusView: View {
@Environment(\.colorScheme) private var colorScheme
@ThemedPalette private var palette
let status: DeliveryStatus
// MARK: - Computed Properties
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
}
private var textColor: Color { palette.primary }
private var secondaryTextColor: Color { palette.secondary }
private enum Strings {
static func delivered(to nickname: String) -> String {
@@ -89,7 +85,7 @@ struct DeliveryStatusView: View {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10, weight: .bold))
}
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
.foregroundColor(palette.accentBlue)
.help(Strings.read(by: nickname))
case .failed(let reason):
@@ -103,7 +99,7 @@ struct DeliveryStatusView: View {
Image(systemName: "checkmark")
.font(.bitchatSystem(size: 10))
Text(verbatim: "\(reached)/\(total)")
.font(.bitchatSystem(size: 10, design: .monospaced))
.bitchatFont(size: 10)
}
.foregroundColor(secondaryTextColor.opacity(0.6))
.help(Strings.deliveredToMembers(reached, total))
@@ -11,6 +11,7 @@ import SwiftUI
struct PaymentChipView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.openURL) private var openURL
@ThemedPalette private var palette
enum PaymentType {
case cashu(String)
@@ -54,9 +55,7 @@ struct PaymentChipView: View {
let paymentType: PaymentType
private var fgColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var fgColor: Color { palette.primary }
private var bgColor: Color {
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
}
@@ -73,7 +72,7 @@ struct PaymentChipView: View {
HStack(spacing: 6) {
Text(paymentType.emoji)
Text(paymentType.label)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
+26 -7
View File
@@ -11,11 +11,25 @@ import BitFoundation
struct TextMessageView: View {
@Environment(\.colorScheme) private var colorScheme: ColorScheme
@Environment(\.appTheme) private var theme
@EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage
/// Value snapshot of the message's mutable delivery status, captured at
/// construction. `BitchatMessage` is a reference type mutated in place by
/// `ConversationStore`, and SwiftUI compares reference-typed view fields
/// by identity so a status-only change (e.g. delivered read) on the
/// SAME instance would otherwise compare "unchanged" and this row's body
/// would be skipped even though the parent list re-rendered. Snapshotting
/// the enum makes the change visible to SwiftUI's structural diff.
private let deliveryStatus: DeliveryStatus?
@State private var expandedMessageIDs: Set<String> = []
init(message: BitchatMessage) {
self.message = message
self.deliveryStatus = message.deliveryStatus
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Precompute heavy token scans once per row
@@ -24,14 +38,14 @@ struct TextMessageView: View {
HStack(alignment: .top, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme))
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = message.deliveryStatus {
let status = deliveryStatus {
DeliveryStatusView(status: status)
.padding(.leading, 4)
}
@@ -45,7 +59,7 @@ struct TextMessageView: View {
if isExpanded { expandedMessageIDs.remove(message.id) }
else { expandedMessageIDs.insert(message.id) }
}
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
.bitchatFont(size: 11, weight: .medium)
.foregroundColor(Color.blue)
.padding(.top, 4)
}
@@ -67,6 +81,10 @@ struct TextMessageView: View {
}
}
// Wrapped in #if DEBUG because the preview depends on _PreviewHelpers
// (PreviewKeychainManager, BitchatMessage.preview), a development asset
// excluded from archive builds.
#if DEBUG
#Preview {
let keychain = PreviewKeychainManager()
let viewModel = ChatViewModel(
@@ -76,12 +94,12 @@ struct TextMessageView: View {
)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: viewModel.conversationStore
conversations: viewModel.conversations
)
let conversationUIModel = ConversationUIModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel,
conversationStore: viewModel.conversationStore
conversations: viewModel.conversations
)
Group {
@@ -103,3 +121,4 @@ struct TextMessageView: View {
}
.environmentObject(conversationUIModel)
}
#endif
+15 -29
View File
@@ -6,16 +6,14 @@ import UIKit
struct ContentComposerView: View {
@EnvironmentObject private var conversationUIModel: ConversationUIModel
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@Binding var messageText: String
var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let onSendMessage: () -> Void
#if os(iOS)
@@ -35,8 +33,8 @@ struct ContentComposerView: View {
}) {
HStack {
Text(suggestion)
.font(.bitchatSystem(size: 11, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 11)
.foregroundColor(palette.primary)
.fontWeight(.medium)
Spacer()
}
@@ -48,20 +46,11 @@ struct ContentComposerView: View {
.background(Color.gray.opacity(0.1))
}
}
.background(backgroundColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(secondaryTextColor.opacity(0.3), lineWidth: 1)
)
.themedOverlayPanel()
.padding(.horizontal, 12)
}
CommandSuggestionsView(
messageText: $messageText,
textColor: textColor,
backgroundColor: backgroundColor,
secondaryTextColor: secondaryTextColor
)
CommandSuggestionsView(messageText: $messageText)
if voiceRecordingVM.state.isActive {
recordingIndicator
@@ -74,11 +63,11 @@ struct ContentComposerView: View {
prompt: Text(
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
)
.foregroundColor(secondaryTextColor.opacity(0.6))
.foregroundColor(palette.secondary.opacity(0.6))
)
.textFieldStyle(.plain)
.font(.bitchatSystem(size: 15, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 15)
.foregroundColor(palette.primary)
.focused(isTextFieldFocused)
.autocorrectionDisabled(true)
#if os(iOS)
@@ -86,12 +75,9 @@ struct ContentComposerView: View {
#endif
.submitLabel(.send)
.onSubmit(onSendMessage)
.padding(.vertical, 4)
.padding(.vertical, theme.usesGlassChrome ? 8 : 4)
.padding(.horizontal, 6)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(colorScheme == .dark ? Color.black.opacity(0.35) : Color.white.opacity(0.7))
)
.themedInputBackground()
.modifier(FocusEffectDisabledModifier())
.frame(maxWidth: .infinity, alignment: .leading)
.onChange(of: messageText) { newValue in
@@ -114,9 +100,9 @@ struct ContentComposerView: View {
}
}
.padding(.horizontal, 6)
.padding(.top, 6)
.padding(.top, theme.usesGlassChrome ? 8 : 6)
.padding(.bottom, 8)
.background(backgroundColor.opacity(0.95))
.themedChromePanel(edge: .bottom)
.onDisappear {
autocompleteDebounceTimer?.invalidate()
}
@@ -134,7 +120,7 @@ private extension ContentComposerView {
"recording \(voiceRecordingVM.formattedDuration(for: context.date))",
comment: "Voice note recording duration indicator"
)
.font(.bitchatSystem(size: 13, design: .monospaced))
.bitchatFont(size: 13)
.foregroundColor(.red)
}
Spacer()
@@ -154,7 +140,7 @@ private extension ContentComposerView {
}
var composerAccentColor: Color {
privateConversationModel.selectedPeerID != nil ? Color.orange : textColor
privateConversationModel.selectedPeerID != nil ? Color.orange : palette.accent
}
var attachmentButton: some View {
+67 -62
View File
@@ -8,8 +8,9 @@ struct ContentHeaderView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.colorScheme) private var colorScheme
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
@Binding var showSidebar: Bool
@Binding var showVerifySheet: Bool
@@ -20,15 +21,12 @@ struct ContentHeaderView: View {
let headerHeight: CGFloat
let headerPeerIconSize: CGFloat
let headerPeerCountFontSize: CGFloat
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
var body: some View {
HStack(spacing: 0) {
Text(verbatim: "bitchat/")
.font(.bitchatSystem(size: 18, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 18, weight: .medium)
.foregroundColor(palette.primary)
.onTapGesture(count: 3) {
appChromeModel.panicClearAllData()
}
@@ -38,8 +36,8 @@ struct ContentHeaderView: View {
HStack(spacing: 0) {
Text(verbatim: "@")
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
TextField(
"content.input.nickname_placeholder",
@@ -49,9 +47,9 @@ struct ContentHeaderView: View {
)
)
.textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.frame(maxWidth: 80)
.foregroundColor(textColor)
.foregroundColor(palette.primary)
.focused(isNicknameFieldFocused)
.autocorrectionDisabled(true)
#if os(iOS)
@@ -79,12 +77,13 @@ struct ContentHeaderView: View {
return countAndColor.0
}()
HStack(spacing: 10) {
HStack(spacing: 2) {
if appChromeModel.hasUnreadPrivateMessages {
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill")
.font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange)
.headerTapTarget()
}
.buttonStyle(.plain)
.accessibilityLabel(
@@ -99,13 +98,10 @@ struct ContentHeaderView: View {
notesGeohash = locationChannelsModel.currentBuildingGeohash
showLocationNotes = true
}) {
HStack(alignment: .center, spacing: 4) {
Image(systemName: "note.text")
.font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange.opacity(0.8))
.padding(.top, 1)
}
.fixedSize(horizontal: true, vertical: false)
Image(systemName: "note.text")
.font(.bitchatSystem(size: 12))
.foregroundColor(Color.orange.opacity(0.8))
.headerTapTarget()
}
.buttonStyle(.plain)
.accessibilityLabel(
@@ -117,6 +113,7 @@ struct ContentHeaderView: View {
Button(action: { locationChannelsModel.toggleBookmark(channel.geohash) }) {
Image(systemName: locationChannelsModel.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 12))
.headerTapTarget()
}
.buttonStyle(.plain)
.accessibilityLabel(
@@ -140,49 +137,54 @@ struct ContentHeaderView: View {
case .mesh:
return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
case .location:
return colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
return palette.locationAccent
}
}()
Text(badgeText)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(badgeColor)
.lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
.padding(.horizontal, 6)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.accessibilityLabel(
String(localized: "content.accessibility.location_channels", comment: "Accessibility label for the location channels button")
)
}
.buttonStyle(.plain)
.padding(.leading, 4)
.padding(.trailing, 2)
HStack(spacing: 4) {
Image(systemName: "person.2.fill")
.font(.system(size: headerPeerIconSize, weight: .regular))
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
locale: .current,
headerOtherPeersCount
)
)
Text("\(headerOtherPeersCount)")
.font(.system(size: headerPeerCountFontSize, weight: .regular, design: .monospaced))
.accessibilityHidden(true)
Button(action: {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar.toggle()
}
}) {
HStack(spacing: 4) {
Image(systemName: "person.2.fill")
.font(.system(size: headerPeerIconSize, weight: .regular))
Text("\(headerOtherPeersCount)")
.font(.system(size: headerPeerCountFontSize, weight: .regular, design: theme.bodyFontDesign))
.accessibilityHidden(true)
}
.foregroundColor(headerCountColor)
.lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false)
.padding(.leading, 6)
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
}
.foregroundColor(headerCountColor)
.padding(.leading, 2)
.lineLimit(headerLineLimit)
.fixedSize(horizontal: true, vertical: false)
.buttonStyle(.plain)
.accessibilityLabel(
String(
format: String(localized: "content.accessibility.people_count", comment: "Accessibility label announcing number of people in header"),
locale: .current,
headerOtherPeersCount
)
)
}
.layoutPriority(3)
.onTapGesture {
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar.toggle()
}
}
.sheet(isPresented: $showVerifySheet) {
VerificationSheetView(isPresented: $showVerifySheet)
.environmentObject(verificationModel)
@@ -208,10 +210,7 @@ struct ContentHeaderView: View {
} else {
ContentLocationNotesUnavailableView(
showLocationNotes: $showLocationNotes,
headerHeight: headerHeight,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor
headerHeight: headerHeight
)
.environmentObject(locationChannelsModel)
}
@@ -249,7 +248,16 @@ struct ContentHeaderView: View {
} message: {
Text("content.alert.screenshot.message")
}
.background(backgroundColor.opacity(0.95))
.themedChromePanel(edge: .top)
}
}
private extension View {
/// Expands a small header icon to a comfortably tappable, full-bar-height
/// hit area without changing its visual size.
func headerTapTarget() -> some View {
frame(minWidth: 30, maxHeight: .infinity)
.contentShape(Rectangle())
}
}
@@ -262,8 +270,7 @@ private extension ContentHeaderView {
switch locationChannelsModel.selectedChannel {
case .location:
let count = peerListModel.visibleGeohashPeerCount
let standardGreen = colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
return (count, count > 0 ? standardGreen : Color.secondary)
return (count, count > 0 ? palette.locationAccent : Color.secondary)
case .mesh:
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary
@@ -274,24 +281,22 @@ private extension ContentHeaderView {
private struct ContentLocationNotesUnavailableView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@ThemedPalette private var palette
@Binding var showLocationNotes: Bool
let headerHeight: CGFloat
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
var body: some View {
VStack(spacing: 12) {
HStack {
Text("content.notes.title")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.bitchatFont(size: 16, weight: .bold)
Spacer()
Button(action: { showLocationNotes = false }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 13, weight: .semibold)
.foregroundColor(palette.primary)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
@@ -299,17 +304,17 @@ private struct ContentLocationNotesUnavailableView: View {
}
.frame(height: headerHeight)
.padding(.horizontal, 12)
.background(backgroundColor.opacity(0.95))
.themedChromePanel(edge: .top)
Text("content.notes.location_unavailable")
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
Button("content.location.enable") {
locationChannelsModel.enableAndRefresh()
}
.buttonStyle(.bordered)
Spacer()
}
.background(backgroundColor)
.foregroundColor(textColor)
.themedSheetBackground()
.foregroundColor(palette.primary)
}
}
+31 -54
View File
@@ -25,10 +25,8 @@ struct ContentPeopleSheetView: View {
var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
@ThemedPalette private var palette
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let headerHeight: CGFloat
let onSendMessage: () -> Void
@@ -56,9 +54,6 @@ struct ContentPeopleSheetView: View {
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight,
onSendMessage: onSendMessage,
showImagePicker: $showImagePicker,
@@ -77,9 +72,6 @@ struct ContentPeopleSheetView: View {
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight,
onSendMessage: onSendMessage,
showMacImagePicker: $showMacImagePicker
@@ -88,9 +80,6 @@ struct ContentPeopleSheetView: View {
} else {
ContentPeopleListView(
showSidebar: $showSidebar,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight
)
}
@@ -109,8 +98,8 @@ struct ContentPeopleSheetView: View {
}
}
}
.background(backgroundColor)
.foregroundColor(textColor)
.themedSheetBackground()
.foregroundColor(palette.primary)
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
@@ -148,12 +137,10 @@ private struct ContentPeopleListView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.dismiss) private var dismiss
@ThemedPalette private var palette
@Binding var showSidebar: Bool
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let headerHeight: CGFloat
@State private var showVerifySheet = false
@@ -163,8 +150,8 @@ private struct ContentPeopleListView: View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(peopleSheetTitle)
.font(.bitchatSystem(size: 18, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 18)
.foregroundColor(palette.primary)
Spacer()
if case .mesh = locationChannelsModel.selectedChannel {
Button(action: { showVerifySheet = true }) {
@@ -185,7 +172,7 @@ private struct ContentPeopleListView: View {
}
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
@@ -201,9 +188,9 @@ private struct ContentPeopleListView: View {
let subtitleColor: Color = {
switch locationChannelsModel.selectedChannel {
case .mesh:
return Color.blue
return palette.accentBlue
case .location:
return Color.green
return palette.locationAccent
}
}()
@@ -213,32 +200,28 @@ private struct ContentPeopleListView: View {
Text(activeText)
.foregroundColor(.secondary)
}
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
} else {
Text(activeText)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.background(backgroundColor)
.themedSurface()
ScrollView {
VStack(alignment: .leading, spacing: 6) {
if case .location = locationChannelsModel.selectedChannel {
GeohashPeopleList(
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPerson: {
showSidebar = true
}
)
} else {
MeshPeerList(
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in
peerListModel.startConversation(with: peerID)
showSidebar = true
@@ -302,10 +285,9 @@ private struct ContentPrivateChatSheetView: View {
var isTextFieldFocused: FocusState<Bool>.Binding
@ObservedObject var voiceRecordingVM: VoiceRecordingViewModel
@Binding var autocompleteDebounceTimer: Timer?
@Environment(\.appTheme) private var theme
@ThemedPalette private var palette
let backgroundColor: Color
let textColor: Color
let secondaryTextColor: Color
let headerHeight: CGFloat
let onSendMessage: () -> Void
@@ -327,7 +309,7 @@ private struct ContentPrivateChatSheetView: View {
}) {
Image(systemName: "chevron.left")
.font(.bitchatSystem(size: 12))
.foregroundColor(textColor)
.foregroundColor(palette.primary)
.frame(width: 44, height: 44)
.contentShape(Rectangle())
}
@@ -341,8 +323,7 @@ private struct ContentPrivateChatSheetView: View {
HStack(spacing: 8) {
ContentPrivateHeaderInfoButton(
headerState: headerState,
headerHeight: headerHeight,
textColor: textColor
headerHeight: headerHeight
)
if headerState.supportsFavoriteToggle {
@@ -351,7 +332,7 @@ private struct ContentPrivateChatSheetView: View {
}) {
Image(systemName: headerState.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 14))
.foregroundColor(headerState.isFavorite ? Color.yellow : textColor)
.foregroundColor(headerState.isFavorite ? Color.yellow : palette.primary)
}
.buttonStyle(.plain)
.accessibilityLabel(
@@ -372,7 +353,7 @@ private struct ContentPrivateChatSheetView: View {
}
}) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
@@ -382,7 +363,7 @@ private struct ContentPrivateChatSheetView: View {
.padding(.horizontal, 16)
.padding(.top, 10)
.padding(.bottom, 12)
.background(backgroundColor)
.themedSurface()
}
MessageListView(
@@ -397,10 +378,12 @@ private struct ContentPrivateChatSheetView: View {
showSidebar: $showSidebar,
isTextFieldFocused: isTextFieldFocused
)
.background(backgroundColor)
.themedSurface()
.frame(maxWidth: .infinity, maxHeight: .infinity)
Divider()
if !theme.usesGlassChrome {
Divider()
}
#if os(iOS)
ContentComposerView(
@@ -408,9 +391,6 @@ private struct ContentPrivateChatSheetView: View {
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onSendMessage: onSendMessage,
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
@@ -421,16 +401,13 @@ private struct ContentPrivateChatSheetView: View {
isTextFieldFocused: isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onSendMessage: onSendMessage,
showMacImagePicker: $showMacImagePicker
)
#endif
}
.background(backgroundColor)
.foregroundColor(textColor)
.themedSheetBackground()
.foregroundColor(palette.primary)
.highPriorityGesture(
DragGesture(minimumDistance: 25, coordinateSpace: .local)
.onEnded { value in
@@ -448,10 +425,10 @@ private struct ContentPrivateChatSheetView: View {
private struct ContentPrivateHeaderInfoButton: View {
@EnvironmentObject private var appChromeModel: AppChromeModel
@ThemedPalette private var palette
let headerState: PrivateConversationHeaderState
let headerHeight: CGFloat
let textColor: Color
var body: some View {
Button(action: {
@@ -462,12 +439,12 @@ private struct ContentPrivateHeaderInfoButton: View {
case .bluetoothConnected:
Image(systemName: "dot.radiowaves.left.and.right")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.foregroundColor(palette.primary)
.accessibilityLabel(String(localized: "content.accessibility.connected_mesh", comment: "Accessibility label for mesh-connected peer indicator"))
case .meshReachable:
Image(systemName: "point.3.filled.connected.trianglepath.dotted")
.font(.bitchatSystem(size: 14))
.foregroundColor(textColor)
.foregroundColor(palette.primary)
.accessibilityLabel(String(localized: "content.accessibility.reachable_mesh", comment: "Accessibility label for mesh-reachable peer indicator"))
case .nostrAvailable:
Image(systemName: "globe")
@@ -479,8 +456,8 @@ private struct ContentPrivateHeaderInfoButton: View {
}
Text(headerState.displayName)
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
.foregroundColor(textColor)
.bitchatFont(size: 16, weight: .medium)
.foregroundColor(palette.primary)
if let encryptionStatus = headerState.encryptionStatus,
let icon = encryptionStatus.icon {
@@ -488,7 +465,7 @@ private struct ContentPrivateHeaderInfoButton: View {
.font(.bitchatSystem(size: 14))
.foregroundColor(
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
? textColor
? palette.primary
: Color.red
)
.accessibilityLabel(
+100 -86
View File
@@ -40,6 +40,7 @@ struct ContentView: View {
@State private var messageText = ""
@FocusState private var isTextFieldFocused: Bool
@Environment(\.colorScheme) var colorScheme
@Environment(\.appTheme) private var appTheme
@State private var showSidebar = false
@State private var selectedMessageSender: String?
@State private var selectedMessageSenderID: PeerID?
@@ -63,39 +64,19 @@ struct ContentView: View {
@State private var windowCountPublic: Int = 300
@State private var windowCountPrivate: [PeerID: Int] = [:]
private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white
}
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var secondaryTextColor: Color {
colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8)
}
@ThemedPalette private var palette
private var selectedPrivatePeerID: PeerID? {
privateConversationModel.selectedPeerID
}
private var usesGlassLayout: Bool { appTheme.usesGlassChrome }
var body: some View {
VStack(spacing: 0) {
ContentHeaderView(
showSidebar: $showSidebar,
showVerifySheet: $showVerifySheet,
showLocationNotes: $showLocationNotes,
notesGeohash: $notesGeohash,
isNicknameFieldFocused: $isNicknameFieldFocused,
headerHeight: headerHeight,
headerPeerIconSize: headerPeerIconSize,
headerPeerCountFontSize: headerPeerCountFontSize,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor
)
mainContent
.onAppear {
conversationUIModel.setCurrentColorScheme(colorScheme)
conversationUIModel.setCurrentTheme(appTheme)
#if os(macOS)
DispatchQueue.main.async {
isNicknameFieldFocused = false
@@ -106,62 +87,11 @@ struct ContentView: View {
.onChange(of: colorScheme) { newValue in
conversationUIModel.setCurrentColorScheme(newValue)
}
Divider()
GeometryReader { geometry in
VStack(spacing: 0) {
MessageListView(
privatePeer: nil,
isAtBottom: $isAtBottomPublic,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
showSidebar: $showSidebar,
isTextFieldFocused: $isTextFieldFocused
)
.background(backgroundColor)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(width: geometry.size.width, height: geometry.size.height)
.onChange(of: appTheme) { newValue in
conversationUIModel.setCurrentTheme(newValue)
}
Divider()
if selectedPrivatePeerID == nil {
#if os(iOS)
ContentComposerView(
messageText: $messageText,
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onSendMessage: sendMessage,
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
)
#else
ContentComposerView(
messageText: $messageText,
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onSendMessage: sendMessage,
showMacImagePicker: $showMacImagePicker
)
#endif
}
}
.background(backgroundColor)
.foregroundColor(textColor)
.background(ThemedRootBackground())
.foregroundColor(palette.primary)
#if os(macOS)
.frame(minWidth: 600, minHeight: 400)
#endif
@@ -194,9 +124,6 @@ struct ContentView: View {
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight,
onSendMessage: sendMessage,
showImagePicker: $showImagePicker,
@@ -215,9 +142,6 @@ struct ContentView: View {
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
backgroundColor: backgroundColor,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
headerHeight: headerHeight,
onSendMessage: sendMessage,
showMacImagePicker: $showMacImagePicker
@@ -302,6 +226,96 @@ struct ContentView: View {
}
}
/// Matrix: classic opaque bars with dividers. Glass: full-bleed message
/// list scrolling underneath floating chrome panels (safe-area insets),
/// so the translucency gains usable space instead of losing it.
@ViewBuilder
private var mainContent: some View {
if usesGlassLayout {
publicMessageList
.safeAreaInset(edge: .top, spacing: 0) {
headerView
}
.safeAreaInset(edge: .bottom, spacing: 0) {
if selectedPrivatePeerID == nil {
composerView
}
}
} else {
VStack(spacing: 0) {
headerView
Divider()
GeometryReader { geometry in
VStack(spacing: 0) {
publicMessageList
.background(palette.background)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(width: geometry.size.width, height: geometry.size.height)
}
Divider()
if selectedPrivatePeerID == nil {
composerView
}
}
}
}
private var headerView: some View {
ContentHeaderView(
showSidebar: $showSidebar,
showVerifySheet: $showVerifySheet,
showLocationNotes: $showLocationNotes,
notesGeohash: $notesGeohash,
isNicknameFieldFocused: $isNicknameFieldFocused,
headerHeight: headerHeight,
headerPeerIconSize: headerPeerIconSize,
headerPeerCountFontSize: headerPeerCountFontSize
)
}
private var publicMessageList: some View {
MessageListView(
privatePeer: nil,
isAtBottom: $isAtBottomPublic,
messageText: $messageText,
selectedMessageSender: $selectedMessageSender,
selectedMessageSenderID: $selectedMessageSenderID,
imagePreviewURL: $imagePreviewURL,
windowCountPublic: $windowCountPublic,
windowCountPrivate: $windowCountPrivate,
showSidebar: $showSidebar,
isTextFieldFocused: $isTextFieldFocused
)
}
private var composerView: some View {
#if os(iOS)
ContentComposerView(
messageText: $messageText,
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
onSendMessage: sendMessage,
showImagePicker: $showImagePicker,
imagePickerSourceType: $imagePickerSourceType
)
#else
ContentComposerView(
messageText: $messageText,
isTextFieldFocused: $isTextFieldFocused,
voiceRecordingVM: voiceRecordingVM,
autocompleteDebounceTimer: $autocompleteDebounceTimer,
onSendMessage: sendMessage,
showMacImagePicker: $showMacImagePicker
)
#endif
}
private func sendMessage() {
guard let trimmed = messageText.trimmedOrNilIfEmpty else { return }
+18 -22
View File
@@ -13,15 +13,11 @@ struct FingerprintView: View {
@EnvironmentObject private var verificationModel: VerificationModel
let peerID: PeerID
@Environment(\.dismiss) var dismiss
@Environment(\.colorScheme) var colorScheme
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white
}
@ThemedPalette private var palette
private var textColor: Color { palette.primary }
private var backgroundColor: Color { palette.background }
private enum Strings {
static let title: LocalizedStringKey = "fingerprint.title"
@@ -53,7 +49,7 @@ struct FingerprintView: View {
// Header
HStack {
Text(Strings.title)
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.bitchatFont(size: 16, weight: .bold)
.foregroundColor(textColor)
Spacer()
@@ -76,11 +72,11 @@ struct FingerprintView: View {
VStack(alignment: .leading, spacing: 4) {
Text(fingerprintState.peerNickname)
.font(.bitchatSystem(size: 18, weight: .semibold, design: .monospaced))
.bitchatFont(size: 18, weight: .semibold)
.foregroundColor(textColor)
Text(fingerprintState.encryptionStatus.description)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(textColor.opacity(0.7))
}
@@ -93,12 +89,12 @@ struct FingerprintView: View {
// Their fingerprint
VStack(alignment: .leading, spacing: 8) {
Text(Strings.theirFingerprint)
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
.bitchatFont(size: 12, weight: .bold)
.foregroundColor(textColor.opacity(0.7))
if let fingerprint = fingerprintState.theirFingerprint {
Text(formatFingerprint(fingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(textColor)
.multilineTextAlignment(.leading)
.lineLimit(nil)
@@ -119,7 +115,7 @@ struct FingerprintView: View {
}
} else {
Text(Strings.handshakePending)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(Color.orange)
.padding()
}
@@ -128,11 +124,11 @@ struct FingerprintView: View {
// My fingerprint
VStack(alignment: .leading, spacing: 8) {
Text(Strings.yourFingerprint)
.font(.bitchatSystem(size: 12, weight: .bold, design: .monospaced))
.bitchatFont(size: 12, weight: .bold)
.foregroundColor(textColor.opacity(0.7))
Text(formatFingerprint(fingerprintState.myFingerprint))
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(textColor)
.multilineTextAlignment(.leading)
.lineLimit(nil)
@@ -157,7 +153,7 @@ struct FingerprintView: View {
if fingerprintState.canToggleVerification {
VStack(spacing: 12) {
Text(fingerprintState.isVerified ? Strings.verifiedBadge : Strings.notVerifiedBadge)
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(fingerprintState.isVerified ? Color.green : Color.orange)
.frame(maxWidth: .infinity)
@@ -168,7 +164,7 @@ struct FingerprintView: View {
Text(Strings.verifyHint(fingerprintState.peerNickname))
}
}
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(textColor.opacity(0.7))
.multilineTextAlignment(.center)
.lineLimit(nil)
@@ -181,7 +177,7 @@ struct FingerprintView: View {
dismiss()
}) {
Text(Strings.markVerified)
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
@@ -195,7 +191,7 @@ struct FingerprintView: View {
dismiss()
}) {
Text(Strings.removeVerification)
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
@@ -216,7 +212,7 @@ struct FingerprintView: View {
}
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(backgroundColor)
.themedSheetBackground()
}
private func formatFingerprint(_ fingerprint: String) -> String {
+6 -7
View File
@@ -2,8 +2,7 @@ import SwiftUI
struct GeohashPeopleList: View {
@EnvironmentObject private var peerListModel: PeerListModel
let textColor: Color
let secondaryTextColor: Color
@ThemedPalette private var palette
let onTapPerson: () -> Void
@Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = []
@@ -20,8 +19,8 @@ struct GeohashPeopleList: View {
if peerListModel.geohashPeople.isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text(Strings.noneNearby)
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
.padding(.horizontal)
.padding(.top, 12)
}
@@ -52,18 +51,18 @@ struct GeohashPeopleList: View {
let (base, suffix) = person.displayName.splitSuffix()
HStack(spacing: 0) {
Text(base)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.fontWeight(person.isMe ? .bold : .regular)
.foregroundColor(rowColor)
if !suffix.isEmpty {
let suffixColor = person.isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(suffixColor)
}
if person.isMe {
Text(Strings.youSuffix)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(rowColor)
}
}
+31 -37
View File
@@ -9,11 +9,11 @@ struct LocationChannelsSheet: View {
@Binding var isPresented: Bool
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@EnvironmentObject private var peerListModel: PeerListModel
@Environment(\.colorScheme) var colorScheme
@ThemedPalette private var palette
@State private var customGeohash: String = ""
@State private var customError: String? = nil
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
private var backgroundColor: Color { palette.background }
private enum Strings {
static let title: LocalizedStringKey = "location_channels.title"
@@ -97,12 +97,12 @@ struct LocationChannelsSheet: View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 12) {
Text(Strings.title)
.font(.bitchatSystem(size: 18, design: .monospaced))
.bitchatFont(size: 18)
Spacer()
closeButton
}
Text(Strings.description)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
Group {
@@ -110,7 +110,7 @@ struct LocationChannelsSheet: View {
case .notDetermined:
Button(action: { locationChannelsModel.enableLocationChannels() }) {
Text(Strings.requestPermissions)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(standardGreen)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
@@ -121,7 +121,7 @@ struct LocationChannelsSheet: View {
case .denied, .restricted:
VStack(alignment: .leading, spacing: 8) {
Text(Strings.permissionDenied)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
Button(Strings.openSettings, action: SystemSettings.location.open)
.buttonStyle(.plain)
@@ -136,7 +136,7 @@ struct LocationChannelsSheet: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(backgroundColor)
.themedSurface()
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
@@ -147,7 +147,7 @@ struct LocationChannelsSheet: View {
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
.background(backgroundColor)
.themedSheetBackground()
.onAppear {
// Refresh channels when opening
if locationChannelsModel.permissionState == .authorized {
@@ -171,7 +171,7 @@ struct LocationChannelsSheet: View {
private var closeButton: some View {
Button(action: { isPresented = false }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
@@ -223,7 +223,7 @@ struct LocationChannelsSheet: View {
HStack(spacing: 8) {
ProgressView()
Text(Strings.loadingNearby)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 10)
@@ -246,8 +246,8 @@ struct LocationChannelsSheet: View {
.padding(.top, 12)
Button(action: SystemSettings.location.open) {
Text(Strings.removeAccess)
.font(.bitchatSystem(size: 12, design: .monospaced))
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
.bitchatFont(size: 12)
.foregroundColor(palette.alertRed)
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(Color.red.opacity(0.08))
@@ -259,9 +259,9 @@ struct LocationChannelsSheet: View {
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 6)
.background(backgroundColor)
.themedSurface()
}
.background(backgroundColor)
.themedSurface()
}
private var sectionDivider: some View {
@@ -270,15 +270,13 @@ struct LocationChannelsSheet: View {
.frame(height: 1)
}
private var dividerColor: Color {
colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08)
}
private var dividerColor: Color { palette.divider }
private var customTeleportSection: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 2) {
Text(verbatim: "#")
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(.secondary)
TextField("geohash", text: $customGeohash)
#if os(iOS)
@@ -286,7 +284,7 @@ struct LocationChannelsSheet: View {
.autocorrectionDisabled(true)
.keyboardType(.asciiCapable)
#endif
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.onChange(of: customGeohash) { newValue in
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
let filtered = newValue
@@ -312,13 +310,13 @@ struct LocationChannelsSheet: View {
}) {
HStack(spacing: 6) {
Text(Strings.teleport)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
Image(systemName: "face.dashed")
.font(.bitchatSystem(size: 14))
}
}
.buttonStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.padding(.vertical, 6)
.padding(.horizontal, 10)
.background(Color.secondary.opacity(0.12))
@@ -328,7 +326,7 @@ struct LocationChannelsSheet: View {
}
if let err = customError {
Text(err)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.red)
}
}
@@ -337,7 +335,7 @@ struct LocationChannelsSheet: View {
private func bookmarkedSection(_ entries: [String]) -> some View {
VStack(alignment: .leading, spacing: 8) {
Text(Strings.bookmarked)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
LazyVStack(spacing: 0) {
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
@@ -409,18 +407,18 @@ struct LocationChannelsSheet: View {
let parts = splitTitleAndCount(title)
HStack(spacing: 4) {
Text(parts.base)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary)
if let count = parts.countSuffix, !count.isEmpty {
Text(count)
.font(.bitchatSystem(size: 11, design: .monospaced))
.bitchatFont(size: 11)
.foregroundColor(.secondary)
}
}
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
Text(subtitleFull)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
@@ -428,7 +426,7 @@ struct LocationChannelsSheet: View {
Spacer()
if isSelected {
Text(verbatim: "✔︎")
.font(.bitchatSystem(size: 16, design: .monospaced))
.bitchatFont(size: 16)
.foregroundColor(standardGreen)
}
trailingAccessory()
@@ -478,26 +476,22 @@ extension LocationChannelsSheet {
Toggle(isOn: torToggleBinding) {
VStack(alignment: .leading, spacing: 2) {
Text(Strings.torTitle)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(.primary)
Text(Strings.torSubtitle)
.font(.bitchatSystem(size: 11, design: .monospaced))
.bitchatFont(size: 11)
.foregroundColor(.secondary)
}
}
.toggleStyle(IRCToggleStyle(accent: standardGreen, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
.toggleStyle(IRCToggleStyle(accent: palette.accent, onLabel: Strings.toggleOn, offLabel: Strings.toggleOff))
}
.padding(12)
.background(Color.secondary.opacity(0.12))
.cornerRadius(8)
}
private var standardGreen: Color {
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var standardBlue: Color {
Color(red: 0.0, green: 0.478, blue: 1.0)
}
private var standardGreen: Color { palette.primary }
private var standardBlue: Color { palette.accentBlue }
}
private struct IRCToggleStyle: ToggleStyle {
@@ -512,7 +506,7 @@ private struct IRCToggleStyle: ToggleStyle {
Spacer()
Text(configuration.isOn ? onLabel : offLabel)
.textCase(.uppercase)
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
.foregroundColor(configuration.isOn ? accent : .secondary)
.padding(.vertical, 4)
.padding(.horizontal, 10)
+28 -28
View File
@@ -6,7 +6,7 @@ struct LocationNotesView: View {
let senderNickname: String
let onNotesCountChanged: ((Int) -> Void)?
@Environment(\.colorScheme) var colorScheme
@ThemedPalette private var palette
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.dismiss) private var dismiss
@@ -25,8 +25,8 @@ struct LocationNotesView: View {
_manager = StateObject(wrappedValue: manager ?? LocationNotesManager(geohash: gh))
}
private var backgroundColor: Color { colorScheme == .dark ? .black : .white }
private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) }
private var backgroundColor: Color { palette.background }
private var accentGreen: Color { palette.accent }
private var maxDraftLines: Int { dynamicTypeSize.isAccessibilitySize ? 5 : 3 }
private enum Strings {
@@ -53,11 +53,11 @@ struct LocationNotesView: View {
notesContent
}
}
.background(backgroundColor)
.themedSurface()
inputSection
}
.frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680)
.background(backgroundColor)
.themedSheetBackground()
.onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in
manager.setGeohash(newValue)
@@ -76,7 +76,7 @@ struct LocationNotesView: View {
.frame(maxWidth: .infinity, maxHeight: .infinity)
inputSection
}
.background(backgroundColor)
.themedSurface()
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(true)
@@ -84,7 +84,7 @@ struct LocationNotesView: View {
.navigationTitle("")
#endif
}
.background(backgroundColor)
.themedSheetBackground()
.onDisappear { manager.cancel() }
.onChange(of: geohash) { newValue in
manager.setGeohash(newValue)
@@ -99,7 +99,7 @@ struct LocationNotesView: View {
private var closeButton: some View {
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.bitchatFont(size: 13, weight: .semibold)
.frame(width: 32, height: 32)
}
.buttonStyle(.plain)
@@ -111,33 +111,33 @@ struct LocationNotesView: View {
return VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Text(headerTitle(for: count))
.font(.bitchatSystem(size: 18, design: .monospaced))
.bitchatFont(size: 18)
Spacer()
closeButton
}
if let building = locationChannelsModel.locationName(for: .building), !building.isEmpty {
Text(building)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(accentGreen)
} else if let block = locationChannelsModel.locationName(for: .block), !block.isEmpty {
Text(block)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(accentGreen)
}
Text(Strings.description)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
.fixedSize(horizontal: false, vertical: true)
if manager.state == .noRelays {
Text(Strings.relaysPaused)
.font(.bitchatSystem(size: 11, design: .monospaced))
.bitchatFont(size: 11)
.foregroundColor(.secondary)
}
}
.padding(.horizontal, 16)
.padding(.top, 16)
.padding(.bottom, 12)
.background(backgroundColor)
.themedSurface()
}
private func headerTitle(for count: Int) -> String {
@@ -176,16 +176,16 @@ struct LocationNotesView: View {
return VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(verbatim: "@\(baseName)")
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
.bitchatFont(size: 12, weight: .semibold)
if !ts.isEmpty {
Text(ts)
.font(.bitchatSystem(size: 11, design: .monospaced))
.bitchatFont(size: 11)
.foregroundColor(.secondary)
}
Spacer()
}
Text(note.content)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.fixedSize(horizontal: false, vertical: true)
}
.padding(.vertical, 4)
@@ -194,12 +194,12 @@ struct LocationNotesView: View {
private var noRelaysRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.noRelaysNearby)
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.relaysRetryHint)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
Button(Strings.retry) { manager.refresh() }
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.buttonStyle(.plain)
}
.padding(.vertical, 6)
@@ -209,7 +209,7 @@ struct LocationNotesView: View {
HStack(spacing: 10) {
ProgressView()
Text(Strings.loadingNotes)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
Spacer()
}
@@ -219,9 +219,9 @@ struct LocationNotesView: View {
private var emptyRow: some View {
VStack(alignment: .leading, spacing: 4) {
Text(Strings.emptyTitle)
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
.bitchatFont(size: 13, weight: .semibold)
Text(Strings.emptySubtitle)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.secondary)
}
.padding(.vertical, 6)
@@ -231,13 +231,13 @@ struct LocationNotesView: View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
Text(message)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
Spacer()
}
Button(Strings.dismissError) { manager.clearError() }
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.buttonStyle(.plain)
}
.padding(.vertical, 6)
@@ -247,7 +247,7 @@ struct LocationNotesView: View {
HStack(alignment: .top, spacing: 10) {
TextField(Strings.addPlaceholder, text: $draft, axis: .vertical)
.textFieldStyle(.plain)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.lineLimit(maxDraftLines, reservesSpace: true)
.padding(.vertical, 6)
Button(action: send) {
@@ -261,7 +261,7 @@ struct LocationNotesView: View {
}
.padding(.horizontal, 16)
.padding(.vertical, 14)
.background(backgroundColor)
.themedSurface()
.overlay(Divider(), alignment: .top)
}
+19 -5
View File
@@ -10,24 +10,38 @@ import BitFoundation
struct MediaMessageView: View {
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme
@EnvironmentObject private var conversationUIModel: ConversationUIModel
let message: BitchatMessage
let media: BitchatMessage.Media
/// Value snapshot of the message's mutable delivery status, captured at
/// construction (see `TextMessageView.deliveryStatus`): `BitchatMessage`
/// is a reference type mutated in place, and SwiftUI compares reference
/// fields by identity, so without the snapshot a status-only change
/// (send progress, delivered read) would not re-render this row.
private let deliveryStatus: DeliveryStatus?
@Binding var imagePreviewURL: URL?
init(message: BitchatMessage, media: BitchatMessage.Media, imagePreviewURL: Binding<URL?>) {
self.message = message
self.media = media
self.deliveryStatus = message.deliveryStatus
self._imagePreviewURL = imagePreviewURL
}
var body: some View {
let state = mediaSendState(for: message)
let state = mediaSendState(for: deliveryStatus)
let isFromMe = conversationUIModel.isMediaMessageFromCurrentUser(message)
let cancelAction: (() -> Void)? = state.canCancel ? { conversationUIModel.cancelMediaSend(messageID: message.id) } : nil
VStack(alignment: .leading, spacing: 2) {
HStack(alignment: .center, spacing: 4) {
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme))
Text(conversationUIModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
let status = message.deliveryStatus {
let status = deliveryStatus {
DeliveryStatusView(status: status)
.padding(.leading, 4)
}
@@ -63,10 +77,10 @@ struct MediaMessageView: View {
.padding(.vertical, 4)
}
private func mediaSendState(for message: BitchatMessage) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
private func mediaSendState(for deliveryStatus: DeliveryStatus?) -> (isSending: Bool, progress: Double?, canCancel: Bool) {
var isSending = false
var progress: Double?
if let status = message.deliveryStatus {
if let status = deliveryStatus {
switch status {
case .sending:
isSending = true
+4 -3
View File
@@ -8,6 +8,7 @@ struct VoiceNoteView: View {
private let onCancel: (() -> Void)?
@Environment(\.colorScheme) private var colorScheme
@ThemedPalette private var palette
@StateObject private var playback: VoiceNotePlaybackController
@State private var waveform: [Float] = []
@@ -31,7 +32,7 @@ struct VoiceNoteView: View {
}
private var borderColor: Color {
colorScheme == .dark ? Color.green.opacity(0.3) : Color.green.opacity(0.2)
colorScheme == .dark ? palette.accent.opacity(0.3) : palette.accent.opacity(0.2)
}
private var playbackLabel: String {
@@ -46,7 +47,7 @@ struct VoiceNoteView: View {
Image(systemName: playback.isPlaying ? "pause.fill" : "play.fill")
.foregroundColor(.white)
.frame(width: 36, height: 36)
.background(Circle().fill(Color.green))
.background(Circle().fill(palette.accent))
}
.buttonStyle(.plain)
@@ -61,7 +62,7 @@ struct VoiceNoteView: View {
)
Text(playbackLabel)
.font(.bitchatSystem(size: 13, design: .monospaced))
.bitchatFont(size: 13)
.foregroundColor(Color.secondary)
if let onCancel = onCancel, isSending {
+3 -2
View File
@@ -6,6 +6,7 @@ struct WaveformView: View {
let sendProgress: Double?
let onSeek: ((Double) -> Void)?
let isInteractive: Bool
@ThemedPalette private var palette
private var clampedPlayback: Double {
max(0, min(1, playbackProgress))
@@ -37,9 +38,9 @@ struct WaveformView: View {
let binPosition = Double(index) / Double(samples.count)
let color: Color
if binPosition <= clampedPlayback {
color = Color.green
color = palette.accent
} else if let send = clampedSend, binPosition <= send {
color = Color.blue
color = palette.accentBlue
} else {
color = Color.gray.opacity(0.35)
}
+7 -8
View File
@@ -3,8 +3,7 @@ import BitFoundation
struct MeshPeerList: View {
@EnvironmentObject private var peerListModel: PeerListModel
let textColor: Color
let secondaryTextColor: Color
@ThemedPalette private var palette
let onTapPeer: (PeerID) -> Void
let onToggleFavorite: (PeerID) -> Void
let onShowFingerprint: (PeerID) -> Void
@@ -28,8 +27,8 @@ struct MeshPeerList: View {
if peerListModel.meshRows.isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text(Strings.noneNearby)
.font(.bitchatSystem(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.bitchatFont(size: 14)
.foregroundColor(palette.secondary)
.padding(.horizontal)
.padding(.top, 12)
}
@@ -64,18 +63,18 @@ struct MeshPeerList: View {
// Fallback icon for others (dimmed)
Image(systemName: "person")
.font(.bitchatSystem(size: 10))
.foregroundColor(secondaryTextColor)
.foregroundColor(palette.secondary)
}
let (base, suffix) = peer.displayName.splitSuffix()
HStack(spacing: 0) {
Text(base)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(baseColor)
if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
Text(suffix)
.font(.bitchatSystem(size: 14, design: .monospaced))
.bitchatFont(size: 14)
.foregroundColor(suffixColor)
}
}
@@ -123,7 +122,7 @@ struct MeshPeerList: View {
Button(action: { onToggleFavorite(peer.peerID) }) {
Image(systemName: peer.isFavorite ? "star.fill" : "star")
.font(.bitchatSystem(size: 12))
.foregroundColor(peer.isFavorite ? .yellow : secondaryTextColor)
.foregroundColor(peer.isFavorite ? .yellow : palette.secondary)
}
.buttonStyle(.plain)
}
+7 -7
View File
@@ -21,6 +21,7 @@ struct MessageListView: View {
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme
let privatePeer: PeerID?
@Binding var isAtBottom: Bool
@@ -222,7 +223,7 @@ private extension MessageListView {
@ViewBuilder
func systemMessageRow(_ message: BitchatMessage) -> some View {
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme))
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
@@ -305,10 +306,10 @@ private extension MessageListView {
var targetPeerID: String? {
if let peer = privatePeer,
let last = privateInboxModel.messages(for: peer).suffix(300).last?.id {
let last = privateInboxModel.messages(for: peer).last?.id {
return "dm:\(peer)|\(last)"
}
if let last = publicChatModel.messages.suffix(300).last?.id {
if let last = publicChatModel.messages.last?.id {
return "\(locationChannelsModel.selectedChannel.contextKey)|\(last)"
}
return nil
@@ -329,7 +330,7 @@ private extension MessageListView {
func scrollIfNeeded(date: Date) {
lastScrollTime = date
let contextKey = locationChannelsModel.selectedChannel.contextKey
if let target = messages.suffix(windowCountPublic).last.map({ "\(contextKey)|\($0.id)" }) {
if let target = messages.last.map({ "\(contextKey)|\($0.id)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
}
@@ -368,8 +369,7 @@ private extension MessageListView {
func scrollIfNeeded(date: Date) {
lastScrollTime = date
let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300
if let target = messages.suffix(count).last.map({ "\(contextKey)|\($0.id)" }){
if let target = messages.last.map({ "\(contextKey)|\($0.id)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
}
@@ -399,7 +399,7 @@ private extension MessageListView {
isAtBottom = true
windowCountPublic = TransportConfig.uiWindowInitialCountPublic
let contextKey = "geo:\(ch.geohash)"
if let target = publicChatModel.messages.suffix(windowCountPublic).last?.id.map({ "\(contextKey)|\($0)" }) {
if let target = publicChatModel.messages.last?.id.map({ "\(contextKey)|\($0)" }) {
proxy.scrollTo(target, anchor: .bottom)
}
}
+13 -13
View File
@@ -21,7 +21,7 @@ struct MyQRView: View {
var body: some View {
VStack(spacing: 12) {
Text(Strings.title)
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.bitchatFont(size: 16, weight: .bold)
VStack(spacing: 10) {
QRCodeImage(data: qrString, size: 240)
@@ -29,7 +29,7 @@ struct MyQRView: View {
// Non-scrolling, fully visible URL (wraps across lines)
Text(qrString)
.font(.bitchatSystem(size: 11, design: .monospaced))
.bitchatFont(size: 11)
.textSelection(.enabled)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
@@ -69,7 +69,7 @@ struct QRCodeImage: View {
.frame(width: size, height: size)
.overlay(
Text(Strings.unavailable)
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
.foregroundColor(.gray)
)
}
@@ -150,7 +150,7 @@ struct QRScanView: View {
.clipShape(RoundedRectangle(cornerRadius: 8))
#else
Text(Strings.pastePrompt)
.font(.bitchatSystem(size: 14, weight: .medium, design: .monospaced))
.bitchatFont(size: 14, weight: .medium)
TextEditor(text: $input)
.frame(height: 100)
.border(Color.gray.opacity(0.4))
@@ -281,10 +281,10 @@ struct VerificationSheetView: View {
@EnvironmentObject private var verificationModel: VerificationModel
@Binding var isPresented: Bool
@State private var showingScanner = false
@Environment(\.colorScheme) var colorScheme
@ThemedPalette private var palette
private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white }
private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) }
private var backgroundColor: Color { palette.background }
private var accentColor: Color { palette.accent }
private var boxColor: Color { Color.gray.opacity(0.1) }
var body: some View {
@@ -292,7 +292,7 @@ struct VerificationSheetView: View {
// Top header (always at top)
HStack {
Text("verification.sheet.title")
.font(.bitchatSystem(size: 14, weight: .bold, design: .monospaced))
.bitchatFont(size: 14, weight: .bold)
.foregroundColor(accentColor)
Spacer()
Button(action: {
@@ -316,7 +316,7 @@ struct VerificationSheetView: View {
if showingScanner {
VStack(alignment: .leading, spacing: 12) {
Text("verification.scan.prompt_friend")
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
.bitchatFont(size: 16, weight: .bold)
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.foregroundColor(accentColor)
@@ -350,13 +350,13 @@ struct VerificationSheetView: View {
if showingScanner {
Button(action: { showingScanner = false }) {
Label("show my qr", systemImage: "qrcode")
.font(.bitchatSystem(size: 13, design: .monospaced))
.bitchatFont(size: 13)
}
.buttonStyle(.bordered)
} else {
Button(action: { showingScanner = true }) {
Label("scan someone else's qr", systemImage: "camera.viewfinder")
.font(.bitchatSystem(size: 13, weight: .medium, design: .monospaced))
.bitchatFont(size: 13, weight: .medium)
}
.buttonStyle(.bordered)
.tint(.gray)
@@ -367,7 +367,7 @@ struct VerificationSheetView: View {
verificationModel.isVerified(peerID: peerID) {
Button(action: { verificationModel.unverifyFingerprint(for: peerID) }) {
Label("remove verification", systemImage: "minus.circle")
.font(.bitchatSystem(size: 12, design: .monospaced))
.bitchatFont(size: 12)
}
.buttonStyle(.bordered)
.tint(.gray)
@@ -376,7 +376,7 @@ struct VerificationSheetView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.background(backgroundColor)
.themedSheetBackground()
.onDisappear { showingScanner = false }
}
}
+193 -209
View File
@@ -1,4 +1,5 @@
import BitFoundation
import Combine
import Foundation
import Testing
@testable import bitchat
@@ -45,6 +46,27 @@ private func makeArchitectureSnapshot(
)
}
@MainActor
private func makeArchitectureMessage(
id: String,
timestamp: TimeInterval = 0,
content: String? = nil,
isPrivate: Bool = false,
senderPeerID: PeerID = PeerID(str: "peer-a")
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: "alice",
content: content ?? "message \(id)",
timestamp: Date(timeIntervalSince1970: timestamp),
isRelay: false,
originalSender: nil,
isPrivate: isPrivate,
recipientNickname: isPrivate ? "builder" : nil,
senderPeerID: senderPeerID
)
}
@MainActor
private func waitUntil(
timeoutNanoseconds: UInt64 = 3_000_000_000,
@@ -127,251 +149,119 @@ struct AppArchitectureTests {
@Test("PeerHandle equality and hashing use the canonical identity only")
func peerHandleEqualityUsesCanonicalIdentity() {
let first = PeerHandle(
id: "noise:abc123",
routingPeerID: PeerID(str: "peer-a"),
displayName: "alice",
noisePublicKeyHex: "abc123",
nostrPublicKey: nil
)
let second = PeerHandle(
id: "noise:abc123",
routingPeerID: PeerID(str: "peer-b"),
displayName: "alice-renamed",
noisePublicKeyHex: nil,
nostrPublicKey: "npub123"
)
let first = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-a"))
let second = PeerHandle(id: "noise:abc123", routingPeerID: PeerID(str: "peer-b"))
#expect(first == second)
#expect(Set([first, second]).count == 1)
}
@Test("ConversationStore normalizes timeline ordering and duplicates")
@Test("ConversationStore orders timelines and replaces duplicates by message ID")
@MainActor
func conversationStoreNormalizesMessages() {
func conversationStoreOrdersAndDedupsMessages() {
let store = ConversationStore()
let older = BitchatMessage(
id: "m1",
sender: "alice",
content: "first",
timestamp: Date(timeIntervalSince1970: 1),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: PeerID(str: "peer-a")
)
let newer = BitchatMessage(
id: "m2",
sender: "alice",
content: "second",
timestamp: Date(timeIntervalSince1970: 2),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: PeerID(str: "peer-a")
)
let replacement = BitchatMessage(
id: "m2",
sender: "alice",
content: "second-updated",
timestamp: Date(timeIntervalSince1970: 2),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: PeerID(str: "peer-a")
)
let older = makeArchitectureMessage(id: "m1", timestamp: 1, content: "first")
let newer = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second")
let replacement = makeArchitectureMessage(id: "m2", timestamp: 2, content: "second-updated")
store.replaceMessages([newer, older, replacement], for: ConversationID.mesh)
store.append(newer, to: .mesh)
store.append(older, to: .mesh)
store.upsertByID(replacement, in: .mesh)
let messages = store.messages(for: ConversationID.mesh)
let messages = store.conversation(for: .mesh).messages
#expect(messages.map(\.id) == ["m1", "m2"])
#expect(messages.last?.content == "second-updated")
}
@Test("ConversationStore tracks unread direct conversations with canonical IDs")
@Test("ConversationStore tracks unread direct conversations by routing peer ID")
@MainActor
func conversationStoreTracksUnreadDirectConversations() {
let store = ConversationStore()
let resolver = IdentityResolver()
let peerID = PeerID(str: "peer-1")
let message = BitchatMessage(
id: "dm-1",
sender: "alice",
content: "hello",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "bob",
senderPeerID: peerID
)
let message = makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: peerID)
store.synchronizePrivateChats(
[peerID: [message]],
unreadPeerIDs: Set([peerID]),
identityResolver: resolver
)
store.append(message, to: .directPeer(peerID))
store.markUnread(.directPeer(peerID))
let conversationID = ConversationID.direct(
resolver.canonicalHandle(for: peerID, displayName: "alice")
)
#expect(store.conversation(for: .directPeer(peerID)).messages.map(\.id) == ["dm-1"])
#expect(store.unreadDirectRoutingPeerIDs() == Set([peerID]))
#expect(store.conversation(for: .directPeer(peerID)).isUnread)
#expect(store.messages(for: conversationID).map(\.id) == ["dm-1"])
#expect(store.unreadConversations.contains(conversationID))
store.markRead(conversationID)
#expect(!store.unreadConversations.contains(conversationID))
store.markRead(.directPeer(peerID))
#expect(store.unreadDirectRoutingPeerIDs().isEmpty)
#expect(!store.conversation(for: .directPeer(peerID)).isUnread)
}
@Test("ConversationStore tracks the selected app conversation context")
@Test("ConversationStore derives the selected conversation from channel and private peer")
@MainActor
func conversationStoreTracksSelectedConversationContext() {
let store = ConversationStore()
let resolver = IdentityResolver()
let noiseKey = Data((0..<32).map(UInt8.init))
let shortPeerID = PeerID(str: "0011223344556677")
let peerID = PeerID(str: "0011223344556677")
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
let peer = BitchatPeer(
peerID: shortPeerID,
noisePublicKey: noiseKey,
nickname: "alice",
isConnected: true,
isReachable: true
)
resolver.register(peers: [peer])
store.synchronizeSelection(
activeChannel: geohashChannel,
selectedPeerID: shortPeerID,
identityResolver: resolver
)
let expectedConversationID = ConversationID.direct(
resolver.canonicalHandle(for: shortPeerID, displayName: "alice")
)
store.setActiveChannel(geohashChannel)
store.setSelectedPrivatePeer(peerID)
#expect(store.activeChannel == geohashChannel)
#expect(store.selectedPrivatePeerID == shortPeerID)
#expect(store.selectedConversationID == expectedConversationID)
#expect(store.selectedPrivatePeerID == peerID)
// The open private chat wins the derived selection.
#expect(store.selectedConversationID == ConversationID.directPeer(peerID))
store.synchronizeSelection(
activeChannel: ChannelID.mesh,
selectedPeerID: nil,
identityResolver: resolver
)
store.setSelectedPrivatePeer(nil)
// Selection falls back to the active public channel.
#expect(store.selectedConversationID == ConversationID(channelID: geohashChannel))
store.setActiveChannel(.mesh)
#expect(store.activeChannel == ChannelID.mesh)
#expect(store.selectedPrivatePeerID == nil)
#expect(store.selectedConversationID == ConversationID.mesh)
}
@Test("ConversationStore exposes direct conversations by the latest routing peer ID")
@Test("ConversationStore re-keys a direct conversation via the migrate intent")
@MainActor
func conversationStoreExposesDirectConversationsByLatestRoutingPeerID() {
func conversationStoreMigratesDirectConversationsBetweenPeerIDs() {
let store = ConversationStore()
let resolver = IdentityResolver()
let noiseKey = Data((0..<32).map(UInt8.init))
let shortPeerID = PeerID(str: "0011223344556677")
let fullPeerID = PeerID(hexData: noiseKey)
let firstMessage = BitchatMessage(
id: "dm-1",
sender: "alice",
content: "short id",
timestamp: Date(timeIntervalSince1970: 1),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "builder",
senderPeerID: shortPeerID
)
let secondMessage = BitchatMessage(
id: "dm-2",
sender: "alice",
content: "full id",
timestamp: Date(timeIntervalSince1970: 2),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "builder",
senderPeerID: fullPeerID
)
resolver.register(
peer: BitchatPeer(
peerID: shortPeerID,
noisePublicKey: noiseKey,
nickname: "alice",
isConnected: true,
isReachable: true
)
)
store.synchronizePrivateChats(
[shortPeerID: [firstMessage]],
unreadPeerIDs: Set([shortPeerID]),
identityResolver: resolver
store.append(
makeArchitectureMessage(id: "dm-1", timestamp: 1, isPrivate: true, senderPeerID: shortPeerID),
to: .directPeer(shortPeerID)
)
store.markUnread(.directPeer(shortPeerID))
store.setSelectedPrivatePeer(shortPeerID)
resolver.register(
peer: BitchatPeer(
peerID: fullPeerID,
noisePublicKey: noiseKey,
nickname: "alice",
isConnected: true,
isReachable: true
)
)
store.synchronizePrivateChats(
[fullPeerID: [secondMessage]],
unreadPeerIDs: Set([fullPeerID]),
identityResolver: resolver
)
store.migrateConversation(from: .directPeer(shortPeerID), to: .directPeer(fullPeerID))
#expect(Set(store.directMessagesByPeerID().keys) == Set([fullPeerID]))
#expect(store.directMessagesByPeerID()[fullPeerID]?.map(\.id) == ["dm-2"])
#expect(store.unreadDirectPeerIDs() == Set([fullPeerID]))
// Raw keying: the old peer's conversation is gone, the new peer's
// conversation holds the timeline, unread and selection carried over.
#expect(store.conversationsByID[.directPeer(shortPeerID)] == nil)
#expect(Set(store.directMessagesByRoutingPeerID().keys) == Set([fullPeerID]))
#expect(store.directMessagesByRoutingPeerID()[fullPeerID]?.map(\.id) == ["dm-1"])
#expect(store.unreadDirectRoutingPeerIDs() == Set([fullPeerID]))
#expect(store.selectedPrivatePeerID == fullPeerID)
#expect(store.selectedConversationID == ConversationID.directPeer(fullPeerID))
}
@Test("PrivateInboxModel mirrors direct message state from ConversationStore")
@Test("PrivateInboxModel reads direct message state from the ConversationStore")
@MainActor
func privateInboxModelMirrorsDirectMessageStateFromConversationStore() async {
func privateInboxModelReadsDirectMessageStateFromConversationStore() {
let store = ConversationStore()
let resolver = IdentityResolver()
let inboxModel = PrivateInboxModel(conversationStore: store)
let inboxModel = PrivateInboxModel(conversations: store)
let messagePeerID = PeerID(str: "peer-1")
let unreadOnlyPeerID = PeerID(str: "peer-2")
let selectedOnlyPeerID = PeerID(str: "peer-3")
let message = BitchatMessage(
id: "dm-1",
sender: "alice",
content: "hello",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "builder",
senderPeerID: messagePeerID
)
store.synchronizePrivateChats(
[messagePeerID: [message]],
unreadPeerIDs: Set([messagePeerID, unreadOnlyPeerID]),
identityResolver: resolver
)
store.synchronizeSelection(
activeChannel: ChannelID.mesh,
selectedPeerID: selectedOnlyPeerID,
identityResolver: resolver
store.append(
makeArchitectureMessage(id: "dm-1", isPrivate: true, senderPeerID: messagePeerID),
to: .directPeer(messagePeerID)
)
store.markUnread(.directPeer(messagePeerID))
store.markUnread(.directPeer(unreadOnlyPeerID))
store.setSelectedPrivatePeer(selectedOnlyPeerID)
await waitUntil {
inboxModel.selectedPeerID == selectedOnlyPeerID &&
inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]) &&
Set(inboxModel.messagesByPeerID.keys) == Set([messagePeerID, unreadOnlyPeerID, selectedOnlyPeerID])
}
// Reads are synchronous against the single-writer store.
#expect(inboxModel.selectedPeerID == selectedOnlyPeerID)
#expect(inboxModel.unreadPeerIDs == Set([messagePeerID, unreadOnlyPeerID]))
#expect(inboxModel.messages(for: messagePeerID).map(\.id) == ["dm-1"])
@@ -379,13 +269,115 @@ struct AppArchitectureTests {
#expect(inboxModel.messages(for: selectedOnlyPeerID).isEmpty)
}
@Test("PrivateInboxModel republishes only for the selected conversation")
@MainActor
func privateInboxModelIsolatesBackgroundConversations() {
let store = ConversationStore()
let inboxModel = PrivateInboxModel(conversations: store)
let selectedPeerID = PeerID(str: "peer-selected")
let backgroundPeerID = PeerID(str: "peer-background")
store.setSelectedPrivatePeer(selectedPeerID)
var emissions = 0
let cancellable = inboxModel.objectWillChange.sink { _ in emissions += 1 }
defer { cancellable.cancel() }
let baseline = emissions
store.append(
makeArchitectureMessage(id: "dm-bg-1", isPrivate: true, senderPeerID: backgroundPeerID),
to: .directPeer(backgroundPeerID)
)
// An append to a background chat does not republish the model.
#expect(emissions == baseline)
store.append(
makeArchitectureMessage(id: "dm-sel-1", isPrivate: true, senderPeerID: selectedPeerID),
to: .directPeer(selectedPeerID)
)
#expect(emissions == baseline + 1)
#expect(inboxModel.messages(for: selectedPeerID).map(\.id) == ["dm-sel-1"])
}
@Test("PrivateInboxModel republishes read receipts for the selected DM (ephemeral- and stable-keyed)")
@MainActor
func privateInboxModelRepublishesReadReceiptsForSelectedConversation() {
// A DM's messages can live under BOTH .directPeer(ephemeral) and
// .directPeer(stableKey) (mirroring shares one BitchatMessage
// instance); the view's read-receipt update must fire no matter
// which of the two keys the selection holds.
let ephemeralPeerID = PeerID(str: "abcdef1234567890")
let stablePeerID = PeerID(str: String(repeating: "ab", count: 32))
for selectedPeerID in [ephemeralPeerID, stablePeerID] {
let store = ConversationStore()
let inboxModel = PrivateInboxModel(conversations: store)
store.setSelectedPrivatePeer(selectedPeerID)
// One shared instance mirrored into both direct conversations,
// exactly like `mirrorToEphemeralIfNeeded`.
let message = makeArchitectureMessage(
id: "dm-read-1",
isPrivate: true,
senderPeerID: ephemeralPeerID
)
store.append(message, to: .directPeer(ephemeralPeerID))
store.upsertByID(message, in: .directPeer(stablePeerID))
var emissions = 0
let cancellable = inboxModel.objectWillChange.sink { _ in emissions += 1 }
defer { cancellable.cancel() }
// ID-only intent the exact call `ChatDeliveryCoordinator`
// makes when a READ ack arrives.
let read = DeliveryStatus.read(by: "builder", at: Date(timeIntervalSince1970: 100))
#expect(store.setDeliveryStatus(read, forMessageID: "dm-read-1"))
// The fan-out emits .statusChanged for both containing
// conversations; exactly the selected one republishes the model.
#expect(emissions == 1)
#expect(inboxModel.messages(for: selectedPeerID).first?.deliveryStatus == read)
}
}
@Test("PublicChatModel ignores appends to background conversations")
@MainActor
func publicChatModelIsolatesBackgroundConversations() {
let store = ConversationStore()
store.setActiveChannel(.mesh)
let model = PublicChatModel(conversations: store)
var emissions = 0
let cancellable = model.objectWillChange.sink { _ in emissions += 1 }
defer { cancellable.cancel() }
store.append(makeArchitectureMessage(id: "mesh-1"), to: .mesh)
let afterActiveAppend = emissions
#expect(afterActiveAppend >= 1)
#expect(model.messages.map(\.id) == ["mesh-1"])
// Appends to a background geohash channel and to a private chat do
// not invalidate the observer of the active conversation.
store.append(makeArchitectureMessage(id: "geo-1"), to: .geohash("u4pruyd"))
store.append(
makeArchitectureMessage(id: "dm-1", isPrivate: true),
to: .directPeer(PeerID(str: "peer-1"))
)
#expect(emissions == afterActiveAppend)
#expect(model.messages.map(\.id) == ["mesh-1"])
// Switching the channel retargets the observation.
store.setActiveChannel(.location(GeohashChannel(level: .neighborhood, geohash: "u4pruyd")))
#expect(model.messages.map(\.id) == ["geo-1"])
store.append(makeArchitectureMessage(id: "geo-2", timestamp: 1), to: .geohash("u4pruyd"))
#expect(model.messages.map(\.id) == ["geo-1", "geo-2"])
}
@Test("AppChromeModel mirrors nickname and unread state through focused models")
@MainActor
func appChromeModelMirrorsNicknameAndUnreadState() async {
let viewModel = makeArchitectureViewModel()
let conversationStore = ConversationStore()
let resolver = IdentityResolver()
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
let conversations = ConversationStore()
let privateInboxModel = PrivateInboxModel(conversations: conversations)
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
chromeModel.setNickname("builder")
@@ -398,11 +390,7 @@ struct AppArchitectureTests {
#expect(!chromeModel.hasUnreadPrivateMessages)
let peerID = PeerID(str: "peer-1")
conversationStore.synchronizePrivateChats(
[:],
unreadPeerIDs: Set([peerID]),
identityResolver: resolver
)
conversations.markUnread(.directPeer(peerID))
await waitUntil {
chromeModel.hasUnreadPrivateMessages
}
@@ -414,8 +402,7 @@ struct AppArchitectureTests {
@MainActor
func appChromeModelOwnsPresentationState() {
let viewModel = makeArchitectureViewModel()
let conversationStore = ConversationStore()
let privateInboxModel = PrivateInboxModel(conversationStore: conversationStore)
let privateInboxModel = PrivateInboxModel(conversations: ConversationStore())
let chromeModel = AppChromeModel(chatViewModel: viewModel, privateInboxModel: privateInboxModel)
let peerID = PeerID(str: "peer-2")
@@ -441,11 +428,10 @@ struct AppArchitectureTests {
Issue.record("Expected ChatViewModel meshService to be a MockTransport in architecture tests")
return
}
let conversationStore = viewModel.conversationStore
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
let conversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: conversationStore,
conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel
)
@@ -493,18 +479,17 @@ struct AppArchitectureTests {
return
}
let conversationStore = viewModel.conversationStore
locationManager.select(.mesh)
let locationChannelsModel = LocationChannelsModel(manager: locationManager)
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: conversationStore,
conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel
)
let uiModel = ConversationUIModel(
chatViewModel: viewModel,
privateConversationModel: privateConversationModel,
conversationStore: conversationStore
conversations: viewModel.conversations
)
let geohashChannel = ChannelID.location(GeohashChannel(level: .city, geohash: "9q8yy"))
defer {
@@ -558,11 +543,10 @@ struct AppArchitectureTests {
let peerID = PeerID(str: "0011223344556677")
let fingerprint = "verified-fingerprint"
let conversationStore = viewModel.conversationStore
let locationChannelsModel = LocationChannelsModel(manager: makeArchitectureLocationManager())
let privateConversationModel = PrivateConversationModel(
chatViewModel: viewModel,
conversationStore: conversationStore,
conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel
)
let verificationModel = VerificationModel(
@@ -631,7 +615,7 @@ struct AppArchitectureTests {
transport.reachablePeers.insert(otherPeerID)
viewModel.nickname = "builder"
viewModel.verifiedFingerprints.insert(verifiedFingerprint)
viewModel.unreadPrivateMessages = Set([otherPeerID])
viewModel.markPrivateChatUnread(otherPeerID)
transport.updatePeerSnapshots([
makeArchitectureSnapshot(
peerID: myPeerID,
@@ -664,7 +648,7 @@ struct AppArchitectureTests {
let peerListModel = PeerListModel(
chatViewModel: viewModel,
conversationStore: viewModel.conversationStore,
conversations: viewModel.conversations,
locationChannelsModel: locationChannelsModel
)
+27 -4
View File
@@ -14,23 +14,29 @@ import BitFoundation
struct BLEServiceCoreTests {
@Test
func duplicatePacket_isDeduped() async {
func duplicatePacket_isDeduped() async throws {
let ble = makeService()
let delegate = PublicCaptureDelegate()
ble.delegate = delegate
// Public messages must carry a valid signature from the claimed sender;
// sign the packet and preseed the sender's signing key so the receiver
// can verify it (production `sendMessage` signs public broadcasts too).
let signer = NoiseEncryptionService(keychain: MockKeychain())
let sender = PeerID(str: "1122334455667788")
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
let unsigned = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
let packet = try #require(signer.signPacket(unsigned), "Failed to sign public message")
let signingKey = signer.getSigningPublicKeyData()
ble._test_handlePacket(packet, fromPeerID: sender)
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
let receivedFirst = await TestHelpers.waitUntil(
{ delegate.publicMessagesSnapshot().count == 1 },
timeout: TestConstants.defaultTimeout
)
#expect(receivedFirst)
ble._test_handlePacket(packet, fromPeerID: sender)
ble._test_handlePacket(packet, fromPeerID: sender, signingPublicKey: signingKey)
let receivedDuplicate = await TestHelpers.waitUntil(
{ delegate.publicMessagesSnapshot().count > 1 },
timeout: TestConstants.shortTimeout
@@ -164,6 +170,23 @@ struct BLEServiceCoreTests {
#expect(!ble._test_recordIngressIfNew(packet: packet, linkID: "central-b"))
}
@Test
func panicReset_rotatesPeerIDDerivedFromNewNoiseFingerprint() async throws {
let ble = makeService()
let originalPeerID = ble.myPeerID
let originalFingerprint = ble.noiseIdentityFingerprint()
#expect(originalPeerID == PeerID(str: originalFingerprint.prefix(16)))
ble.resetIdentityForPanic(currentNickname: "anon")
// The Noise identity is regenerated and the peer ID swaps with it
// (atomically, behind a messageQueue barrier).
let newFingerprint = ble.noiseIdentityFingerprint()
#expect(newFingerprint != originalFingerprint)
#expect(ble.myPeerID != originalPeerID)
#expect(ble.myPeerID == PeerID(str: newFingerprint.prefix(16)))
}
@Test
func modifiedServices_rediscoverWhenBitChatServiceIsInvalidated() async throws {
let otherService = CBUUID(string: "0000180F-0000-1000-8000-00805F9B34FB")
@@ -0,0 +1,157 @@
//
// ChatComposerCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatComposerCoordinator` against a mock `ChatComposerContext`
// proving the coordinator works without a `ChatViewModel`, following the
// `ChatDeliveryCoordinatorContextTests` exemplar.
//
// Scope note: mention parsing uses the shared, precompiled
// `ChatViewModel.Patterns.mention` regex (a static, stateless singleton);
// everything else flows through the mock context.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatComposerContext` proving that
/// `ChatComposerCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatComposerContext: ChatComposerContext {
// Autocomplete UI state
var autocompleteSuggestions: [String] = []
var autocompleteRange: NSRange?
var showAutocomplete = false
var selectedAutocompleteIndex = -1
var queryResult: (suggestions: [String], range: NSRange?) = ([], nil)
private(set) var queriedPeerCandidates: [[String]] = []
private(set) var appliedSuggestions: [(suggestion: String, text: String, range: NSRange)] = []
func autocompleteQuery(
for text: String,
peers: [String],
cursorPosition: Int
) -> (suggestions: [String], range: NSRange?) {
queriedPeerCandidates.append(peers.sorted())
return queryResult
}
func applyAutocompleteSuggestion(_ suggestion: String, to text: String, range: NSRange) -> String {
appliedSuggestions.append((suggestion, text, range))
guard let textRange = Range(range, in: text) else { return text }
return text.replacingCharacters(in: textRange, with: suggestion)
}
// Identity & channel state
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var activeChannel: ChannelID = .mesh
var meshNickname = "me"
var meshNicknamesByPeerID: [PeerID: String] = [:]
func meshPeerNicknames() -> [PeerID: String] { meshNicknamesByPeerID }
// Geohash identity
var geoNicknames: [String: String] = [:]
static let dummyIdentity = NostrIdentity(
privateKey: Data(repeating: 0x11, count: 32),
publicKey: Data(repeating: 0x22, count: 32),
npub: "npub1mock",
createdAt: Date(timeIntervalSince1970: 0)
)
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
Self.dummyIdentity
}
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatComposerCoordinator` against `MockChatComposerContext` with
/// no `ChatViewModel`.
struct ChatComposerCoordinatorContextTests {
@Test @MainActor
func updateAutocomplete_onMesh_excludesOwnNicknameAndPublishesSuggestions() {
let context = MockChatComposerContext()
let coordinator = ChatComposerCoordinator(context: context)
context.meshNicknamesByPeerID = [
PeerID(str: "1111111111111111"): "alice",
PeerID(str: "2222222222222222"): "bob",
PeerID(str: "3333333333333333"): "me",
]
// Matching query: suggestions and range are published, index resets.
context.queryResult = (["@alice"], NSRange(location: 0, length: 3))
coordinator.updateAutocomplete(for: "@al", cursorPosition: 3)
#expect(context.queriedPeerCandidates == [["alice", "bob"]])
#expect(context.autocompleteSuggestions == ["@alice"])
#expect(context.autocompleteRange == NSRange(location: 0, length: 3))
#expect(context.showAutocomplete)
#expect(context.selectedAutocompleteIndex == 0)
// No match: all autocomplete state is cleared.
context.queryResult = ([], nil)
context.selectedAutocompleteIndex = 3
coordinator.updateAutocomplete(for: "plain text", cursorPosition: 5)
#expect(context.autocompleteSuggestions.isEmpty)
#expect(context.autocompleteRange == nil)
#expect(!context.showAutocomplete)
#expect(context.selectedAutocompleteIndex == 0)
}
@Test @MainActor
func updateAutocomplete_onLocationChannel_buildsGeoTokensWithoutOwnToken() {
let context = MockChatComposerContext()
let coordinator = ChatComposerCoordinator(context: context)
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruydq"))
context.geoNicknames = [
"aaaabbbbccccdddd": "carol",
// Own token (nickname#last-4-of-pubkey) must be removed; the dummy
// identity's public key hex ends in "2222".
"ffffeeeeddddcccc2222": "me",
]
coordinator.updateAutocomplete(for: "@ca", cursorPosition: 3)
#expect(context.queriedPeerCandidates == [["carol#dddd"]])
}
@Test @MainActor
func completeNickname_appliesSuggestionResetsStateAndReturnsCursor() {
let context = MockChatComposerContext()
let coordinator = ChatComposerCoordinator(context: context)
// Without an active range the text is untouched.
var text = "hello @al"
#expect(coordinator.completeNickname("@alice", in: &text) == text.count)
#expect(context.appliedSuggestions.isEmpty)
// With a range the suggestion is applied and state cleared.
context.autocompleteRange = NSRange(location: 6, length: 3)
context.autocompleteSuggestions = ["@alice"]
context.showAutocomplete = true
let cursor = coordinator.completeNickname("@alice", in: &text)
#expect(text == "hello @alice")
#expect(cursor == 6 + "@alice".count + 1)
#expect(!context.showAutocomplete)
#expect(context.autocompleteSuggestions.isEmpty)
#expect(context.autocompleteRange == nil)
#expect(context.selectedAutocompleteIndex == 0)
}
@Test @MainActor
func parseMentions_acceptsKnownPeersOwnNicknameAndHashSuffix() {
let context = MockChatComposerContext()
let coordinator = ChatComposerCoordinator(context: context)
context.meshNicknamesByPeerID = [PeerID(str: "1111111111111111"): "alice"]
let mentions = coordinator.parseMentions(
from: "hi @alice and @me and @me#0011 but not @stranger"
)
#expect(Set(mentions) == ["alice", "me", "me#0011"])
}
}
@@ -0,0 +1,362 @@
//
// ChatLifecycleCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatLifecycleCoordinator` against a mock `ChatLifecycleContext`
// proving the coordinator works without a `ChatViewModel`, following the
// `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: the geohash-screenshot branch publishes via
// `NostrRelayManager.shared` / `GeoRelayDirectory.shared`; that stays covered
// by the full view-model tests. The GeoDM read pass, the favorites-backed
// mesh/Nostr read-receipt branch (favorites are injected through the
// context), message merging, screenshot notices, and lifecycle persistence
// flows are covered here.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatLifecycleContext` proving that
/// `ChatLifecycleCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatLifecycleContext: ChatLifecycleContext {
// Chat & receipt state
var messages: [BitchatMessage] = []
var privateChats: [PeerID: [BitchatMessage]] = [:]
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
var unreadPrivateMessages: Set<PeerID> = []
var selectedPrivateChatPeer: PeerID?
var sentReadReceipts: Set<String> = []
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var activeChannel: ChannelID = .mesh
var nostrKeyMapping: [PeerID: String] = [:]
private(set) var ownerLevelReadPasses: [PeerID] = []
private(set) var managerReadMarks: [PeerID] = []
private(set) var systemMessages: [String] = []
// Conversation store intents
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
var chat = privateChats[peerID] ?? []
guard !chat.contains(where: { $0.id == message.id }) else { return false }
let index = chat.firstIndex(where: { $0.timestamp > message.timestamp }) ?? chat.count
chat.insert(message, at: index)
privateChats[peerID] = chat
return true
}
func markPrivateChatRead(_ peerID: PeerID) {
unreadPrivateMessages.remove(peerID)
}
@discardableResult
func markReadReceiptSent(_ messageID: String) -> Bool {
sentReadReceipts.insert(messageID).inserted
}
func markPrivateMessagesAsRead(from peerID: PeerID) {
ownerLevelReadPasses.append(peerID)
}
func markChatAsRead(from peerID: PeerID) {
managerReadMarks.append(peerID)
}
// Scheduled work runs synchronously so tests never poll wall-clock queues.
private(set) var scheduledDelays: [TimeInterval] = []
func scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) {
scheduledDelays.append(delay)
work()
}
func addSystemMessage(_ content: String) { systemMessages.append(content) }
// Peers & sessions
var nicknamesByPeerID: [PeerID: String] = [:]
var peersByID: [PeerID: BitchatPeer] = [:]
var noiseSessionStates: [PeerID: LazyHandshakeState] = [:]
private(set) var stopMeshServicesCount = 0
private(set) var refreshBluetoothStateCount = 0
func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] }
func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] }
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
noiseSessionStates[peerID] ?? .none
}
func stopMeshServices() { stopMeshServicesCount += 1 }
func refreshBluetoothState() { refreshBluetoothStateCount += 1 }
// Routing & receipts
private(set) var routedPrivateMessages: [(content: String, peerID: PeerID, recipientNickname: String)] = []
private(set) var routedReadReceipts: [(messageID: String, peerID: PeerID)] = []
private(set) var meshBroadcasts: [String] = []
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
routedPrivateMessages.append((content, peerID, recipientNickname))
}
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
routedReadReceipts.append((receipt.originalMessageID, peerID))
}
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
meshBroadcasts.append(content)
}
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
geoReadReceipts.append((messageID, recipientHex))
}
// Nostr & geohash
var isTeleported = false
private(set) var recordedGeoParticipants: [String] = []
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity { Self.dummyIdentity }
func recordGeoParticipant(pubkeyHex: String) { recordedGeoParticipants.append(pubkeyHex) }
// Favorites
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByNoiseKey[noiseKey]
}
// Identity persistence
private(set) var forceSaveIdentityCount = 0
private(set) var verifyIdentityKeyExistsCount = 0
func forceSaveIdentity() { forceSaveIdentityCount += 1 }
@discardableResult
func verifyIdentityKeyExists() -> Bool {
verifyIdentityKeyExistsCount += 1
return true
}
static let dummyIdentity = NostrIdentity(
privateKey: Data(repeating: 0x11, count: 32),
publicKey: Data(repeating: 0x22, count: 32),
npub: "npub1mock",
createdAt: Date(timeIntervalSince1970: 0)
)
}
// MARK: - Helpers
private func makeFavoriteRelationship(
noiseKey: Data,
nostrPublicKey: String? = nil,
nickname: String = "alice",
isFavorite: Bool = false,
theyFavoritedUs: Bool = false
) -> FavoritesPersistenceService.FavoriteRelationship {
FavoritesPersistenceService.FavoriteRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nostrPublicKey,
peerNickname: nickname,
isFavorite: isFavorite,
theyFavoritedUs: theyFavoritedUs,
favoritedAt: Date(timeIntervalSince1970: 0),
lastUpdated: Date(timeIntervalSince1970: 0)
)
}
@MainActor
private func makePrivateMessage(
id: String,
sender: String = "alice",
timestamp: Date = Date(),
senderPeerID: PeerID? = nil,
isRelay: Bool = false,
deliveryStatus: DeliveryStatus? = nil
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: sender,
content: "hello",
timestamp: timestamp,
isRelay: isRelay,
isPrivate: true,
recipientNickname: "me",
senderPeerID: senderPeerID,
deliveryStatus: deliveryStatus
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatLifecycleCoordinator` against `MockChatLifecycleContext`
/// with no `ChatViewModel`.
struct ChatLifecycleCoordinatorContextTests {
@Test @MainActor
func getPrivateChatMessages_mergesEphemeralAndStableKeepingBestStatus() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
let noiseKey = Data(repeating: 0xAB, count: 32)
let stablePeerID = PeerID(hexData: noiseKey)
context.peersByID[peerID] = BitchatPeer(peerID: peerID, noisePublicKey: noiseKey, nickname: "alice")
let t1 = Date(timeIntervalSince1970: 1)
let t2 = Date(timeIntervalSince1970: 2)
// Same message under both keys: the read copy must win over sent.
context.privateChats[peerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .sent),
makePrivateMessage(id: "m2", timestamp: t2),
]
context.privateChats[stablePeerID] = [
makePrivateMessage(id: "m1", timestamp: t1, deliveryStatus: .read(by: "alice", at: t2)),
]
let merged = coordinator.getPrivateChatMessages(for: peerID)
#expect(merged.map(\.id) == ["m1", "m2"])
if case .read? = merged.first?.deliveryStatus {
} else {
Issue.record("expected the .read copy of m1 to win the merge")
}
// getMessages(for: nil) falls back to the public timeline.
context.messages = [makePrivateMessage(id: "pub")]
#expect(coordinator.getMessages(for: nil).map(\.id) == ["pub"])
}
@Test @MainActor
func markPrivateMessagesAsRead_geoDM_sendsReadReceiptsOnce() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
let convKey = PeerID(nostr_: "feedface00112233")
let recipientHex = "feedface00112233"
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy"))
context.nostrKeyMapping[convKey] = recipientHex
context.sentReadReceipts = ["already-acked"]
context.privateChats[convKey] = [
makePrivateMessage(id: "m1", senderPeerID: convKey),
makePrivateMessage(id: "already-acked", senderPeerID: convKey),
makePrivateMessage(id: "relay", senderPeerID: convKey, isRelay: true),
makePrivateMessage(id: "mine", sender: "me", senderPeerID: context.myPeerID),
]
coordinator.markPrivateMessagesAsRead(from: convKey)
#expect(context.managerReadMarks == [convKey])
// Only the peer's own un-acked, non-relay message gets a READ.
#expect(context.geoReadReceipts.map(\.messageID) == ["m1"])
#expect(context.geoReadReceipts.first?.recipientHex == recipientHex)
#expect(context.sentReadReceipts.contains("m1"))
// Second pass: nothing new to send.
coordinator.markPrivateMessagesAsRead(from: convKey)
#expect(context.geoReadReceipts.count == 1)
}
@Test @MainActor
func handleScreenshotCaptured_privateChat_appendsNoticeAndRoutesWhenEstablished() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.selectedPrivateChatPeer = peerID
context.nicknamesByPeerID[peerID] = "alice"
// No established session: local notice only, no network send.
coordinator.handleScreenshotCaptured()
#expect(context.routedPrivateMessages.isEmpty)
#expect(context.privateChats[peerID]?.map(\.content) == ["you took a screenshot"])
#expect(context.privateChats[peerID]?.first?.sender == "system")
// Established session: the peer is notified too.
context.noiseSessionStates[peerID] = .established
coordinator.handleScreenshotCaptured()
#expect(context.routedPrivateMessages.count == 1)
#expect(context.routedPrivateMessages.first?.content == "* me took a screenshot *")
#expect(context.routedPrivateMessages.first?.recipientNickname == "alice")
#expect(context.privateChats[peerID]?.count == 2)
// The public-channel system message is not used for private chats.
#expect(context.systemMessages.isEmpty)
#expect(context.meshBroadcasts.isEmpty)
}
@Test @MainActor
func handleScreenshotCaptured_meshChannel_broadcastsAndConfirmsLocally() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
coordinator.handleScreenshotCaptured()
#expect(context.meshBroadcasts == ["* me took a screenshot *"])
#expect(context.systemMessages == ["you took a screenshot"])
#expect(context.privateChats.isEmpty)
}
@Test @MainActor
func lifecycleEvents_persistIdentityAndScheduleReadPasses() async {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
coordinator.applicationWillTerminate()
#expect(context.stopMeshServicesCount == 1)
#expect(context.forceSaveIdentityCount == 1)
#expect(context.verifyIdentityKeyExistsCount == 1)
// Becoming active with no open chat only refreshes Bluetooth state.
coordinator.handleDidBecomeActive()
#expect(context.refreshBluetoothStateCount == 1)
#expect(context.managerReadMarks.isEmpty)
// With an open chat the read pass runs immediately (manager-level) and
// a delayed owner-level pass is scheduled.
let peerID = PeerID(nostr_: "feedface00112233")
context.selectedPrivateChatPeer = peerID
coordinator.handleDidBecomeActive()
#expect(context.refreshBluetoothStateCount == 2)
#expect(context.managerReadMarks == [peerID])
// The mock executes scheduled work synchronously, so the delayed
// owner-level pass has already run - no wall-clock polling.
#expect(context.scheduledDelays == [TransportConfig.uiAnimationMediumSeconds])
#expect(context.ownerLevelReadPasses == [peerID])
}
@Test @MainActor
func markPrivateMessagesAsRead_routesReceiptsOnlyForNostrReachableFavorites() {
let context = MockChatLifecycleContext()
let coordinator = ChatLifecycleCoordinator(context: context)
let noiseKey = Data(repeating: 0xAB, count: 32)
let peerID = PeerID(hexData: noiseKey)
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
nostrPublicKey: "npub1alice"
)
context.privateChats[peerID] = [
makePrivateMessage(id: "in-1", senderPeerID: peerID),
makePrivateMessage(id: "in-relay", senderPeerID: peerID, isRelay: true),
]
coordinator.markPrivateMessagesAsRead(from: peerID)
// Favorite with a Nostr key: READ receipts routed for non-relay
// inbound messages and recorded as sent.
#expect(context.managerReadMarks == [peerID])
#expect(context.routedReadReceipts.map(\.messageID) == ["in-1"])
#expect(context.routedReadReceipts.map(\.peerID) == [peerID])
#expect(context.sentReadReceipts.contains("in-1"))
// No favorite relationship (no Nostr key): the receipt pass is skipped.
let otherKey = Data(repeating: 0xCD, count: 32)
let otherPeer = PeerID(hexData: otherKey)
context.privateChats[otherPeer] = [makePrivateMessage(id: "in-2", senderPeerID: otherPeer)]
coordinator.markPrivateMessagesAsRead(from: otherPeer)
#expect(context.routedReadReceipts.map(\.messageID) == ["in-1"])
}
}
@@ -0,0 +1,209 @@
//
// ChatMediaTransferCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatMediaTransferCoordinator` against a mock
// `ChatMediaTransferContext` proving the coordinator works without a
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: the async media-preparation pipelines (`ImageUtils`,
// `ChatMediaPreparation`) run real file/codec work and remain covered by
// `ChatMediaPreparationTests`; here we cover message enqueueing, transfer
// bookkeeping, and the blocked-context guards.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatMediaTransferContext` proving that
/// `ChatMediaTransferCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatMediaTransferContext: ChatMediaTransferContext {
// Composition state
var canSendMediaInCurrentContext = true
var selectedPrivateChatPeer: PeerID?
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var activeChannel: ChannelID = .mesh
var nicknamesByPeerID: [PeerID: String] = [:]
func nicknameForPeer(_ peerID: PeerID) -> String {
nicknamesByPeerID[peerID] ?? "user"
}
func currentPublicSender() -> (name: String, peerID: PeerID) {
(nickname, myPeerID)
}
// Message state
var privateChats: [PeerID: [BitchatMessage]] = [:]
@discardableResult
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool {
var chat = privateChats[peerID] ?? []
guard !chat.contains(where: { $0.id == message.id }) else { return false }
chat.append(message)
privateChats[peerID] = chat
return true
}
private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = []
private(set) var removedMessages: [(messageID: String, cleanupFile: Bool)] = []
private(set) var systemMessages: [String] = []
private(set) var notifyUIChangedCount = 0
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool {
appendedPublicMessages.append((message, conversationID))
return true
}
func removeMessage(withID messageID: String, cleanupFile: Bool) {
removedMessages.append((messageID, cleanupFile))
}
func addSystemMessage(_ content: String) { systemMessages.append(content) }
func notifyUIChanged() { notifyUIChangedCount += 1 }
// Delivery status & dedup
private(set) var deliveryStatusUpdates: [(messageID: String, status: DeliveryStatus)] = []
private(set) var recordedContentKeys: [String] = []
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
deliveryStatusUpdates.append((messageID, status))
}
func normalizedContentKey(_ content: String) -> String { content.lowercased() }
func recordContentKey(_ key: String, timestamp: Date) {
recordedContentKeys.append(key)
}
// Mesh file transfer
private(set) var privateFileSends: [(peerID: PeerID, transferId: String)] = []
private(set) var broadcastFileSends: [String] = []
private(set) var cancelledTransfers: [String] = []
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {
privateFileSends.append((peerID, transferId))
}
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {
broadcastFileSends.append(transferId)
}
func cancelTransfer(_ transferId: String) {
cancelledTransfers.append(transferId)
}
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatMediaTransferCoordinator` against
/// `MockChatMediaTransferContext` with no `ChatViewModel`.
struct ChatMediaTransferCoordinatorContextTests {
@Test @MainActor
func enqueueMediaMessage_privateChatAppendsAndRecordsDedupKey() async {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.nicknamesByPeerID[peerID] = "alice"
let message = coordinator.enqueueMediaMessage(content: "[voice] note.m4a", targetPeer: peerID)
#expect(context.privateChats[peerID]?.map(\.id) == [message.id])
#expect(message.isPrivate)
#expect(message.recipientNickname == "alice")
#expect(message.senderPeerID == context.myPeerID)
#expect(message.deliveryStatus == .sending)
#expect(context.recordedContentKeys == ["[voice] note.m4a"])
#expect(context.notifyUIChangedCount == 1)
#expect(context.appendedPublicMessages.isEmpty)
}
@Test @MainActor
func enqueueMediaMessage_publicAppendsToActiveConversation() async {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
let message = coordinator.enqueueMediaMessage(content: "[image] pic.jpg", targetPeer: nil)
#expect(context.appendedPublicMessages.map(\.message.id) == [message.id])
#expect(context.appendedPublicMessages.first?.conversationID == .mesh)
#expect(!message.isPrivate)
#expect(message.sender == "me")
#expect(context.privateChats.isEmpty)
#expect(context.notifyUIChangedCount == 1)
}
@Test @MainActor
func transferEvents_driveDeliveryStatusAndMappingCleanup() async {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
coordinator.handleTransferEvent(.started(id: "t1", totalFragments: 10))
coordinator.handleTransferEvent(.updated(id: "t1", sentFragments: 4, totalFragments: 10))
coordinator.handleTransferEvent(.completed(id: "t1", totalFragments: 10))
// After completion the mapping is gone: further events are ignored.
coordinator.handleTransferEvent(.updated(id: "t1", sentFragments: 9, totalFragments: 10))
#expect(context.deliveryStatusUpdates.count == 3)
#expect(context.deliveryStatusUpdates[0].status == .partiallyDelivered(reached: 0, total: 10))
#expect(context.deliveryStatusUpdates[1].status == .partiallyDelivered(reached: 4, total: 10))
#expect(context.deliveryStatusUpdates[2].status == .sent)
#expect(coordinator.messageIDToTransferId.isEmpty)
// A cancelled transfer removes the message (with file cleanup).
coordinator.registerTransfer(transferId: "t2", messageID: "m2")
coordinator.handleTransferEvent(.cancelled(id: "t2", sentFragments: 1, totalFragments: 5))
#expect(context.removedMessages.count == 1)
#expect(context.removedMessages.first?.messageID == "m2")
#expect(context.removedMessages.first?.cleanupFile == true)
}
@Test @MainActor
func cancelMediaSend_cancelsOnlyActiveTransferAndRemovesMessage() async {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
// Two messages share a transfer queue; only the active head cancels
// the underlying transfer.
coordinator.registerTransfer(transferId: "t1", messageID: "m1")
coordinator.registerTransfer(transferId: "t1", messageID: "m2")
coordinator.cancelMediaSend(messageID: "m2")
#expect(context.cancelledTransfers.isEmpty)
#expect(context.removedMessages.map(\.messageID) == ["m2"])
coordinator.cancelMediaSend(messageID: "m1")
#expect(context.cancelledTransfers == ["t1"])
#expect(context.removedMessages.map(\.messageID) == ["m2", "m1"])
#expect(coordinator.transferIdToMessageIDs.isEmpty)
#expect(coordinator.messageIDToTransferId.isEmpty)
}
@Test @MainActor
func sendVoiceNote_blockedContextRemovesFileAndExplains() async throws {
let context = MockChatMediaTransferContext()
let coordinator = ChatMediaTransferCoordinator(context: context)
context.canSendMediaInCurrentContext = false
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("voice-note-test-\(UUID().uuidString).m4a")
try Data([0x01, 0x02]).write(to: url)
coordinator.sendVoiceNote(at: url)
#expect(!FileManager.default.fileExists(atPath: url.path))
#expect(context.systemMessages == ["Voice notes are only available in mesh chats."])
#expect(context.privateChats.isEmpty)
#expect(context.appendedPublicMessages.isEmpty)
#expect(coordinator.transferIdToMessageIDs.isEmpty)
}
}
@@ -0,0 +1,740 @@
//
// ChatNostrCoordinatorContextTests.swift
// bitchatTests
//
// Exercises the `ChatNostrCoordinator` facade and its components
// (`NostrInboundPipeline`, `GeohashSubscriptionManager`, `GeoPresenceTracker`)
// against a mock `ChatNostrContext` proving the stack works without a
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatNostrContext` (and, via protocol
/// inheritance, the component contexts) proving that the Nostr stack is
/// testable without a `ChatViewModel`.
@MainActor
private final class MockChatNostrContext: ChatNostrContext {
// Channel & subscription state
var activeChannel: ChannelID = .mesh
var currentGeohash: String?
var geoSubscriptionID: String?
var geoDmSubscriptionID: String?
var geoSamplingSubs: [String: String] = [:]
var lastGeoNotificationAt: [String: Date] = [:]
var nostrRelayManager: NostrRelayManager? { nil }
func setGeoChatSubscriptionID(_ id: String?) { geoSubscriptionID = id }
func setGeoDmSubscriptionID(_ id: String?) { geoDmSubscriptionID = id }
func addGeoSamplingSub(_ subID: String, forGeohash geohash: String) { geoSamplingSubs[subID] = geohash }
func removeGeoSamplingSub(_ subID: String) { geoSamplingSubs.removeValue(forKey: subID) }
func clearGeoSamplingSubs() -> [String] {
defer { geoSamplingSubs.removeAll() }
return Array(geoSamplingSubs.keys)
}
// Public timeline & pipeline
var messages: [BitchatMessage] = []
private(set) var pipelineFlushCount = 0
private(set) var refreshedChannels: [ChannelID?] = []
private(set) var publicSystemMessages: [String] = []
var pendingGeohashSystemMessages: [String] = []
private(set) var appendedGeohashMessages: [(message: BitchatMessage, geohash: String)] = []
func flushPublicMessagePipeline() { pipelineFlushCount += 1 }
func refreshVisibleMessages(from channel: ChannelID?) { refreshedChannels.append(channel) }
func addPublicSystemMessage(_ content: String) { publicSystemMessages.append(content) }
func drainPendingGeohashSystemMessages() -> [String] {
defer { pendingGeohashSystemMessages.removeAll() }
return pendingGeohashSystemMessages
}
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool {
guard !appendedGeohashMessages.contains(where: { $0.message.id == message.id && $0.geohash == geohash }) else {
return false
}
appendedGeohashMessages.append((message, geohash))
return true
}
// Inbound public messages
private(set) var handledPublicMessages: [BitchatMessage] = []
private(set) var mentionCheckedMessageIDs: [String] = []
private(set) var hapticMessageIDs: [String] = []
func handlePublicMessage(_ message: BitchatMessage) { handledPublicMessages.append(message) }
func checkForMentions(_ message: BitchatMessage) { mentionCheckedMessageIDs.append(message.id) }
func sendHapticFeedback(for message: BitchatMessage) { hapticMessageIDs.append(message.id) }
func parseMentions(from content: String) -> [String] { [] }
// Inbound private (geohash DM) payloads
var selectedPrivateChatPeer: PeerID?
var nostrKeyMapping: [PeerID: String] = [:]
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) { nostrKeyMapping[peerID] = pubkey }
private(set) var handledPrivateMessages: [(payload: NoisePayload, senderPubkey: String, convKey: PeerID, timestamp: Date)] = []
private(set) var handledDelivered: [(senderPubkey: String, convKey: PeerID)] = []
private(set) var handledReadReceipts: [(senderPubkey: String, convKey: PeerID)] = []
private(set) var startedPrivateChats: [PeerID] = []
func handlePrivateMessage(
_ payload: NoisePayload,
senderPubkey: String,
convKey: PeerID,
id: NostrIdentity,
messageTimestamp: Date
) {
handledPrivateMessages.append((payload, senderPubkey, convKey, messageTimestamp))
}
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
handledDelivered.append((senderPubkey, convKey))
}
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
handledReadReceipts.append((senderPubkey, convKey))
}
func startPrivateChat(with peerID: PeerID) { startedPrivateChats.append(peerID) }
// Nostr identity & blocking
var geohashIdentities: [String: NostrIdentity] = [:]
var nostrIdentity: NostrIdentity?
var blockedNostrPubkeys: Set<String> = []
var displayNamesByPubkey: [String: String] = [:]
private struct NoIdentity: Error {}
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
guard let identity = geohashIdentities[geohash] else { throw NoIdentity() }
return identity
}
func currentNostrIdentity() -> NostrIdentity? { nostrIdentity }
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased())
}
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
displayNamesByPubkey[pubkeyHex] ?? "anon"
}
// Event dedup
private(set) var recordedNostrEventIDs: [String] = []
private var processedNostrEventIDs: Set<String> = []
private(set) var clearProcessedNostrEventsCount = 0
func hasProcessedNostrEvent(_ eventID: String) -> Bool { processedNostrEventIDs.contains(eventID) }
func recordProcessedNostrEvent(_ eventID: String) {
processedNostrEventIDs.insert(eventID)
recordedNostrEventIDs.append(eventID)
}
func clearProcessedNostrEvents() {
processedNostrEventIDs.removeAll()
clearProcessedNostrEventsCount += 1
}
// Geo participants & presence
var geoNicknames: [String: String] = [:]
private(set) var teleportedKeys: Set<String> = []
var teleportedGeoCount: Int { teleportedKeys.count }
private(set) var refreshTimerStartCount = 0
private(set) var refreshTimerStopCount = 0
private(set) var activeParticipantGeohashes: [String?] = []
private(set) var recordedParticipants: [String] = []
private(set) var recordedSampledParticipants: [(pubkeyHex: String, geohash: String)] = []
private(set) var clearTeleportedGeoCount = 0
private(set) var clearGeoNicknamesCount = 0
var visiblePeople: [GeoPerson] = []
func startGeoParticipantRefreshTimer() { refreshTimerStartCount += 1 }
func stopGeoParticipantRefreshTimer() { refreshTimerStopCount += 1 }
func setActiveParticipantGeohash(_ geohash: String?) { activeParticipantGeohashes.append(geohash) }
func recordGeoParticipant(pubkeyHex: String) { recordedParticipants.append(pubkeyHex) }
func recordGeoParticipant(pubkeyHex: String, geohash: String) {
recordedSampledParticipants.append((pubkeyHex, geohash))
}
func geoParticipantCount(for geohash: String) -> Int {
recordedSampledParticipants.filter { $0.geohash == geohash }.count
}
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { geoNicknames[pubkeyHex.lowercased()] = nickname }
func markGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.insert(pubkeyHexLowercased) }
func clearGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.remove(pubkeyHexLowercased) }
func clearTeleportedGeo() {
teleportedKeys.removeAll()
clearTeleportedGeoCount += 1
}
func clearGeoNicknames() {
geoNicknames.removeAll()
clearGeoNicknamesCount += 1
}
func visibleGeohashPeople() -> [GeoPerson] { visiblePeople }
// Location channels
var isTeleported = false
var regionalGeohashes: Set<String> = []
func isGeohashOutsideRegionalChannels(_ geohash: String) -> Bool {
!regionalGeohashes.isEmpty && !regionalGeohashes.contains(geohash)
}
// Routing & acknowledgements
private(set) var routedFavoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
private(set) var geoDeliveryAcks: [(messageID: String, recipientHex: String)] = []
private(set) var geoReadReceipts: [(messageID: String, recipientHex: String)] = []
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
routedFavoriteNotifications.append((peerID, isFavorite))
}
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
geoDeliveryAcks.append((messageID, recipientHex))
}
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
geoReadReceipts.append((messageID, recipientHex))
}
// Favorites & notifications
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
private(set) var addedFavorites: [(noiseKey: Data, nostrPublicKey: String?, nickname: String)] = []
private(set) var postedLocalNotifications: [(title: String, body: String, identifier: String)] = []
private(set) var geohashActivityNotifications: [(geohash: String, bodyPreview: String)] = []
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByNoiseKey[noiseKey]
}
func allFavoriteRelationships() -> [FavoritesPersistenceService.FavoriteRelationship] {
Array(favoriteRelationshipsByNoiseKey.values)
}
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {
addedFavorites.append((noiseKey, nostrPublicKey, nickname))
}
func postLocalNotification(title: String, body: String, identifier: String) {
postedLocalNotifications.append((title, body, identifier))
}
func notifyGeohashActivity(geohash: String, bodyPreview: String) {
geohashActivityNotifications.append((geohash, bodyPreview))
}
}
// MARK: - Helpers
/// Let the inner `Task { @MainActor in ... }` hops the coordinator schedules
/// run to completion.
@MainActor
private func drainMainQueue() async {
for _ in 0..<5 {
await Task.yield()
}
}
private func makeFavoriteRelationship(
noiseKey: Data,
nostrPublicKey: String? = nil,
nickname: String = "alice",
isFavorite: Bool = false,
theyFavoritedUs: Bool = false
) -> FavoritesPersistenceService.FavoriteRelationship {
FavoritesPersistenceService.FavoriteRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nostrPublicKey,
peerNickname: nickname,
isFavorite: isFavorite,
theyFavoritedUs: theyFavoritedUs,
favoritedAt: Date(timeIntervalSince1970: 0),
lastUpdated: Date(timeIntervalSince1970: 0)
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
/// `ChatViewModel`. Scoped to the inbound event pipeline (dedup, presence,
/// public-message ingest), gift-wrap DM ingest, key mapping, channel-switch
/// teardown, embedded ack flows, and now that favorites and notifications
/// are injected through the context the favorite-notification ingest and
/// the sampled-geohash notification cooldown. Flows that hit live singletons
/// (`NostrRelayManager.shared` subscriptions, `TorManager`) remain covered by
/// the full view-model tests.
struct ChatNostrCoordinatorContextTests {
@Test @MainActor
func handleNostrEvent_ingestsPublicMessageOnceAndDeduplicates() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let sender = try NostrIdentity.generate()
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: "hello geohash",
geohash: "u4pruyd",
senderIdentity: sender,
nickname: "alice"
)
context.displayNamesByPubkey[event.pubkey] = "alice#1234"
coordinator.inbound.handleNostrEvent(event)
await drainMainQueue()
// Dedup recorded exactly once, presence and key mapping updated.
#expect(context.recordedNostrEventIDs == [event.id])
#expect(context.geoNicknames[event.pubkey.lowercased()] == "alice")
#expect(context.recordedParticipants == [event.pubkey])
#expect(context.nostrKeyMapping[PeerID(nostr: event.pubkey)] == event.pubkey)
#expect(context.nostrKeyMapping[PeerID(nostr_: event.pubkey)] == event.pubkey)
// The message reached the public ingest path with the resolved name.
#expect(context.handledPublicMessages.map(\.id) == [event.id])
#expect(context.handledPublicMessages.first?.sender == "alice#1234")
#expect(context.handledPublicMessages.first?.content == "hello geohash")
#expect(context.mentionCheckedMessageIDs == [event.id])
#expect(context.hapticMessageIDs == [event.id])
// A replay of the same event is dropped before any processing.
coordinator.inbound.handleNostrEvent(event)
await drainMainQueue()
#expect(context.recordedNostrEventIDs == [event.id])
#expect(context.handledPublicMessages.count == 1)
#expect(context.recordedParticipants.count == 1)
}
@Test @MainActor
func handleNostrEvent_marksTeleportedPeerWithoutIngestingEmptyContent() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let sender = try NostrIdentity.generate()
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: "",
geohash: "u4pruyd",
senderIdentity: sender,
teleported: true
)
coordinator.inbound.handleNostrEvent(event)
await drainMainQueue()
// Teleport detection fires even though the empty message is dropped.
#expect(context.teleportedKeys == [event.pubkey.lowercased()])
#expect(context.recordedParticipants == [event.pubkey])
#expect(context.handledPublicMessages.isEmpty)
}
@Test @MainActor
func handleNostrEvent_skipsBlockedSender() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let sender = try NostrIdentity.generate()
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: "spam",
geohash: "u4pruyd",
senderIdentity: sender
)
context.blockedNostrPubkeys.insert(event.pubkey.lowercased())
coordinator.inbound.handleNostrEvent(event)
await drainMainQueue()
// The event is still recorded for dedup but nothing else happens.
#expect(context.recordedNostrEventIDs == [event.id])
#expect(context.recordedParticipants.isEmpty)
#expect(context.handledPublicMessages.isEmpty)
}
@Test @MainActor
func handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: "psst",
messageID: "gm-1",
senderPeerID: PeerID(str: "aabbccddeeff0011")
))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
// The NIP-17 unwrap runs off the main actor; wait for the hop back.
let convKey = PeerID(nostr_: sender.publicKeyHex)
let routed = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
#expect(routed)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.nostrKeyMapping[convKey] == sender.publicKeyHex)
#expect(context.handledPrivateMessages.first?.senderPubkey == sender.publicKeyHex)
#expect(context.handledPrivateMessages.first?.convKey == convKey)
// The embedded Noise payload survives the round trip intact.
let payload = try #require(context.handledPrivateMessages.first?.payload)
#expect(payload.type == .privateMessage)
let pm = try #require(PrivateMessagePacket.decode(from: payload.data))
#expect(pm.messageID == "gm-1")
#expect(pm.content == "psst")
// The same gift wrap is dropped on replay.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
await drainMainQueue()
#expect(context.recordedNostrEventIDs == [giftWrap.id])
#expect(context.handledPrivateMessages.count == 1)
}
@Test @MainActor
func handleGiftWrap_panicWipeAfterSpawnDropsDecryptedResult() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
let embedded = try #require(NostrEmbeddedBitChat.encodePMForNostrNoRecipient(
content: "pre-wipe secret",
messageID: "gm-wipe-1",
senderPeerID: PeerID(str: "aabbccddeeff0011")
))
let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
// Spawn the detached decrypt (it strongly captures the pre-wipe
// identity), then panic-wipe in the SAME main-actor turn guaranteed
// to land before the task's first main-actor hop.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
coordinator.inbound.invalidateInFlightDecrypts()
// Give the detached task ample time to have delivered if the wipe
// guard were broken.
try? await Task.sleep(nanoseconds: 200_000_000)
await drainMainQueue()
#expect(context.handledPrivateMessages.isEmpty)
#expect(context.recordedNostrEventIDs.isEmpty)
// The pipeline itself stays usable: a gift wrap spawned AFTER the
// wipe (new generation) still decrypts and delivers.
coordinator.inbound.handleGiftWrap(giftWrap, id: recipient)
let delivered = await TestHelpers.waitUntil({ context.handledPrivateMessages.count == 1 })
#expect(delivered)
}
// NOTE: Inbound Schnorr signature verification (and the forged-copy
// dedup-poisoning invariant) is enforced once, off the main actor, at the
// relay boundary see NostrRelayManagerTests
// `test_receiveEvent_invalidSignatureDoesNotPoisonDuplicateCache` and
// `test_receiveGiftWrap_tamperedSignatureIsDroppedAndDoesNotPoisonDedup`.
// The inbound pipeline only ever sees verified events.
@Test @MainActor
func processNostrMessage_duplicateDeliveryProcessesOnce() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let recipient = try NostrIdentity.generate()
let sender = try NostrIdentity.generate()
context.nostrIdentity = recipient
let giftWrap = try NostrProtocol.createPrivateMessage(
content: "verify:noop",
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
// Fan-in of the same (already verified) gift wrap from several relays
// records and processes exactly once.
await coordinator.inbound.processNostrMessage(giftWrap)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
await coordinator.inbound.processNostrMessage(giftWrap)
#expect(context.recordedNostrEventIDs == [giftWrap.id])
}
@Test @MainActor
func switchLocationChannel_toMesh_tearsDownGeohashState() async {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
context.activeChannel = .mesh
context.currentGeohash = "u4pruyd"
context.geoNicknames = ["abcd": "alice"]
coordinator.subscriptions.switchLocationChannel(to: .mesh)
#expect(context.pipelineFlushCount == 1)
#expect(context.activeChannel == .mesh)
#expect(context.clearProcessedNostrEventsCount == 1)
#expect(context.refreshedChannels == [.mesh])
#expect(context.refreshTimerStopCount == 1)
#expect(context.clearTeleportedGeoCount == 1)
// Cleared once in the mesh branch, once in the shared teardown.
#expect(context.activeParticipantGeohashes == [nil, nil])
#expect(context.currentGeohash == nil)
#expect(context.geoSubscriptionID == nil)
#expect(context.geoDmSubscriptionID == nil)
#expect(context.clearGeoNicknamesCount == 1)
#expect(context.geoNicknames.isEmpty)
// Mesh never starts a geohash subscription or refresh timer.
#expect(context.refreshTimerStartCount == 0)
}
@Test @MainActor
func sendDeliveryAckViaNostrEmbedded_sendsReadReceiptOnlyWhenViewingUnread() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
context.nostrIdentity = try NostrIdentity.generate()
let senderPubkey = "feedface00112233"
let convKey = PeerID(nostr_: senderPubkey)
let message = BitchatMessage(
id: "mid-1",
sender: "alice#1234",
content: "hi",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "me",
senderPeerID: convKey
)
// Not viewing the chat: delivery ack only.
coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: false, senderPubkey: senderPubkey, key: nil)
#expect(context.geoDeliveryAcks.map(\.messageID) == ["mid-1"])
#expect(context.geoDeliveryAcks.first?.recipientHex == senderPubkey)
#expect(context.geoReadReceipts.isEmpty)
// Viewing the chat: delivery ack plus read receipt.
context.selectedPrivateChatPeer = convKey
coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: false, senderPubkey: senderPubkey, key: Data([0x01]))
#expect(context.geoDeliveryAcks.count == 2)
#expect(context.geoReadReceipts.map(\.messageID) == ["mid-1"])
// Already read: no further read receipt.
coordinator.sendDeliveryAckViaNostrEmbedded(message, wasReadBefore: true, senderPubkey: senderPubkey, key: nil)
#expect(context.geoDeliveryAcks.count == 3)
#expect(context.geoReadReceipts.count == 1)
}
@Test @MainActor
func geohashDMKeyMappingHelpers_resolveAndStartChats() async {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let hex = "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff"
let convKey = PeerID(nostr_: hex)
context.displayNamesByPubkey[hex] = "bob#eeff"
coordinator.startGeohashDM(withPubkeyHex: hex)
#expect(context.nostrKeyMapping[convKey] == hex)
#expect(context.startedPrivateChats == [convKey])
#expect(coordinator.fullNostrHex(forSenderPeerID: convKey) == hex)
#expect(coordinator.geohashDisplayName(for: convKey) == "bob#eeff")
// Unmapped conversation keys fall back to the bare peer ID.
let unknown = PeerID(nostr_: "ffeeddccbbaa99887766554433221100ffeeddccbbaa99887766554433221100")
#expect(coordinator.geohashDisplayName(for: unknown) == unknown.bare)
// Display-name lookup prefers visible people, then nicknames.
context.visiblePeople = [GeoPerson(id: "aa11", displayName: "carol#aa11", lastSeen: Date())]
context.geoNicknames = ["bb22": "dave"]
#expect(coordinator.nostrPubkeyForDisplayName("carol#aa11") == "aa11")
#expect(coordinator.nostrPubkeyForDisplayName("dave") == "bb22")
#expect(coordinator.nostrPubkeyForDisplayName("nobody") == nil)
}
}
// MARK: - GeoPresenceTracker Tests
/// Focused tests for seams the coordinator split made independently
/// testable: the sampling-event LRU dedup and the per-geohash notification
/// cooldown. The cooldown tests stop short of the live notification center by
/// pre-seeding the timeline append as a duplicate.
struct GeoPresenceTrackerTests {
@Test @MainActor
func samplingEventDedup_evictsOldestBeyondLRUCap() {
let context = MockChatNostrContext()
let tracker = GeoPresenceTracker(context: context)
let cap = TransportConfig.geoSamplingEventLRUCap
// Empty IDs are never deduplicated.
#expect(tracker.shouldProcessGeoSamplingEvent(""))
#expect(tracker.shouldProcessGeoSamplingEvent(""))
// First sight passes; a replay is rejected.
#expect(tracker.shouldProcessGeoSamplingEvent("ev-0"))
#expect(!tracker.shouldProcessGeoSamplingEvent("ev-0"))
// Fill one past the cap: the oldest entry is evicted and accepted
// again, while a still-resident entry stays deduplicated.
for i in 1...cap {
#expect(tracker.shouldProcessGeoSamplingEvent("ev-\(i)"))
}
#expect(tracker.shouldProcessGeoSamplingEvent("ev-0"))
#expect(!tracker.shouldProcessGeoSamplingEvent("ev-\(cap)"))
// Clearing resets the dedup entirely.
tracker.clearGeoSamplingEventDedup()
#expect(tracker.shouldProcessGeoSamplingEvent("ev-\(cap)"))
}
@Test @MainActor
func notificationCooldown_skipsWithinWindow() async throws {
let context = MockChatNostrContext()
let tracker = GeoPresenceTracker(context: context)
let sender = try NostrIdentity.generate()
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: "sampled activity",
geohash: "9q8yy",
senderIdentity: sender,
nickname: "alice"
)
// Within the cooldown window nothing is appended or re-stamped.
let recent = Date()
context.lastGeoNotificationAt["9q8yy"] = recent
tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event)
await drainMainQueue()
#expect(context.appendedGeohashMessages.isEmpty)
#expect(context.lastGeoNotificationAt["9q8yy"] == recent)
}
@Test @MainActor
func notificationCooldown_stampsGeohashOnceWindowElapses() async throws {
let context = MockChatNostrContext()
let tracker = GeoPresenceTracker(context: context)
let sender = try NostrIdentity.generate()
let event = try NostrProtocol.createEphemeralGeohashEvent(
content: "sampled activity",
geohash: "9q8yy",
senderIdentity: sender,
nickname: "alice"
)
// Pre-seed the same event ID so the timeline append reports a
// duplicate and the flow never reaches the live notification center.
let placeholder = BitchatMessage(
id: event.id,
sender: "seed",
content: "seed",
timestamp: Date(),
isRelay: false
)
#expect(context.appendGeohashMessageIfAbsent(placeholder, toGeohash: "9q8yy"))
// Cooldown elapsed: the geohash is re-stamped and the append is
// attempted (and rejected as a duplicate, so no notification either).
let stale = Date().addingTimeInterval(-TransportConfig.uiGeoNotifyCooldownSeconds - 1)
context.lastGeoNotificationAt["9q8yy"] = stale
tracker.cooldownPerGeohash("9q8yy", content: "sampled activity", event: event)
await drainMainQueue()
let stamped = try #require(context.lastGeoNotificationAt["9q8yy"])
#expect(stamped > stale)
#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
func geoPresence_sampledActivityNotificationRespectsPerGeohashCooldown() async throws {
let context = MockChatNostrContext()
let coordinator = ChatNostrCoordinator(context: context)
let sender = try NostrIdentity.generate()
context.geoNicknames[sender.publicKeyHex.lowercased()] = "alice"
let first = try NostrProtocol.createEphemeralGeohashEvent(
content: "hello geohash",
geohash: "u4pruyd",
senderIdentity: sender,
nickname: "alice"
)
coordinator.presence.cooldownPerGeohash("u4pruyd", content: "hello geohash", event: first)
await drainMainQueue()
// Sampled message recorded in the store and notification posted.
#expect(context.appendedGeohashMessages.map(\.message.id) == [first.id])
#expect(context.appendedGeohashMessages.first?.message.sender == "alice#" + String(first.pubkey.suffix(4)))
#expect(context.geohashActivityNotifications.count == 1)
#expect(context.geohashActivityNotifications.first?.geohash == "u4pruyd")
#expect(context.geohashActivityNotifications.first?.bodyPreview == "hello geohash")
#expect(context.lastGeoNotificationAt["u4pruyd"] != nil)
// A second sampled event inside the cooldown window is fully suppressed.
let second = try NostrProtocol.createEphemeralGeohashEvent(
content: "again",
geohash: "u4pruyd",
senderIdentity: sender,
nickname: "alice"
)
coordinator.presence.cooldownPerGeohash("u4pruyd", content: "again", event: second)
await drainMainQueue()
#expect(context.geohashActivityNotifications.count == 1)
#expect(context.appendedGeohashMessages.count == 1)
// Long previews are truncated to the snippet cap with an ellipsis.
let longContent = String(repeating: "x", count: TransportConfig.uiGeoNotifySnippetMaxLen + 20)
let third = try NostrProtocol.createEphemeralGeohashEvent(
content: longContent,
geohash: "9q8yyk",
senderIdentity: sender,
nickname: "alice"
)
coordinator.presence.cooldownPerGeohash("9q8yyk", content: longContent, event: third)
await drainMainQueue()
#expect(
context.geohashActivityNotifications.last?.bodyPreview
== String(repeating: "x", count: TransportConfig.uiGeoNotifySnippetMaxLen) + ""
)
}
}
@@ -0,0 +1,218 @@
//
// ChatOutgoingCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatOutgoingCoordinator` against a mock `ChatOutgoingContext`
// proving the coordinator works without a `ChatViewModel`, following the
// `ChatDeliveryCoordinatorContextTests` exemplar.
//
// Scope note: the geohash path builds and signs a real Nostr event via
// `NostrProtocol.createEphemeralGeohashEvent` (pure crypto, no shared state);
// everything else flows through the mock context.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatOutgoingContext` proving that
/// `ChatOutgoingCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatOutgoingContext: ChatOutgoingContext {
// Identity & channel state
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var activeChannel: ChannelID = .mesh
var selectedPrivateChatPeer: PeerID?
var isTeleported = false
// Commands & private messages
var selectedPeerAfterUpdate: PeerID??
private(set) var handledCommands: [String] = []
private(set) var updatePrivateChatPeerIfNeededCount = 0
private(set) var sentPrivateMessages: [(content: String, peerID: PeerID)] = []
func handleCommand(_ command: String) { handledCommands.append(command) }
func updatePrivateChatPeerIfNeeded() {
updatePrivateChatPeerIfNeededCount += 1
if let selectedPeerAfterUpdate {
selectedPrivateChatPeer = selectedPeerAfterUpdate
}
}
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
sentPrivateMessages.append((content, peerID))
}
// Public timeline (local echo)
private(set) var appendedPublicMessages: [(message: BitchatMessage, conversationID: ConversationID)] = []
private(set) var systemMessages: [String] = []
func parseMentions(from content: String) -> [String] {
content.contains("@bob") ? ["bob"] : []
}
@discardableResult
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool {
appendedPublicMessages.append((message, conversationID))
return true
}
func addSystemMessage(_ content: String) { systemMessages.append(content) }
// Content dedup
private(set) var recordedContentKeys: [(key: String, timestamp: Date)] = []
func normalizedContentKey(_ content: String) -> String { "key:\(content)" }
func recordContentKey(_ key: String, timestamp: Date) {
recordedContentKeys.append((key, timestamp))
}
// Outbound routing
private(set) var recordedActivityKeys: [String] = []
private(set) var sentMeshMessages: [(content: String, mentions: [String], messageID: String, timestamp: Date)] = []
private(set) var sentGeohashContexts: [ChatViewModel.GeoOutgoingContext] = []
func recordPublicActivity(forChannelKey key: String) { recordedActivityKeys.append(key) }
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
sentMeshMessages.append((content, mentions, messageID, timestamp))
}
func sendGeohash(context: ChatViewModel.GeoOutgoingContext) {
sentGeohashContexts.append(context)
}
// Geohash identity
struct IdentityUnavailable: Error {}
var deriveNostrIdentityError: Error?
static let dummyIdentity = NostrIdentity(
privateKey: Data(repeating: 0x11, count: 32),
publicKey: Data(repeating: 0x22, count: 32),
npub: "npub1mock",
createdAt: Date(timeIntervalSince1970: 0)
)
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
if let deriveNostrIdentityError { throw deriveNostrIdentityError }
return Self.dummyIdentity
}
}
// MARK: - Helpers
/// Lets the coordinator's internal `Task { @MainActor }` hops run.
@MainActor
private func drainMainActorTasks() async {
for _ in 0..<10 { await Task.yield() }
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatOutgoingCoordinator` against `MockChatOutgoingContext` with
/// no `ChatViewModel`.
struct ChatOutgoingCoordinatorContextTests {
@Test @MainActor
func sendMessage_routesSlashCommandsAndDropsEmptyContent() async {
let context = MockChatOutgoingContext()
let coordinator = ChatOutgoingCoordinator(context: context)
coordinator.sendMessage(" ")
coordinator.sendMessage("/who all")
await drainMainActorTasks()
#expect(context.handledCommands == ["/who all"])
#expect(context.appendedPublicMessages.isEmpty)
#expect(context.sentMeshMessages.isEmpty)
}
@Test @MainActor
func sendMessage_inPrivateChat_reResolvesPeerBeforeSending() async {
let context = MockChatOutgoingContext()
let coordinator = ChatOutgoingCoordinator(context: context)
let shortPeer = PeerID(str: "1111111111111111")
let stablePeer = PeerID(str: String(repeating: "ab", count: 32))
// The selected peer is refreshed (short stable) before sending.
context.selectedPrivateChatPeer = shortPeer
context.selectedPeerAfterUpdate = stablePeer
coordinator.sendMessage("hi there")
#expect(context.updatePrivateChatPeerIfNeededCount == 1)
#expect(context.sentPrivateMessages.map(\.peerID) == [stablePeer])
#expect(context.sentPrivateMessages.map(\.content) == ["hi there"])
// If the refresh clears the selection, nothing is sent.
context.selectedPeerAfterUpdate = PeerID??.some(nil)
coordinator.sendMessage("dropped")
await drainMainActorTasks()
#expect(context.sentPrivateMessages.count == 1)
#expect(context.appendedPublicMessages.isEmpty)
}
@Test @MainActor
func sendMessage_onMesh_appendsLocalEchoRecordsActivityAndSends() async {
let context = MockChatOutgoingContext()
let coordinator = ChatOutgoingCoordinator(context: context)
coordinator.sendMessage(" hello @bob ")
await drainMainActorTasks()
// Local echo uses the trimmed content, own nickname/peer ID, mentions.
#expect(context.appendedPublicMessages.count == 1)
let echo = context.appendedPublicMessages[0]
#expect(echo.message.content == "hello @bob")
#expect(echo.message.sender == "me")
#expect(echo.message.senderPeerID == context.myPeerID)
#expect(echo.message.mentions == ["bob"])
#expect(echo.conversationID == .mesh)
#expect(context.recordedContentKeys.map(\.key) == ["key:hello @bob"])
// The mesh send carries the original (untrimmed) content and reuses
// the echo's message ID and timestamp; activity is stamped for "mesh".
#expect(context.recordedActivityKeys == ["mesh"])
#expect(context.sentMeshMessages.count == 1)
let sent = context.sentMeshMessages[0]
#expect(sent.content == " hello @bob ")
#expect(sent.mentions == ["bob"])
#expect(sent.messageID == echo.message.id)
#expect(sent.timestamp == echo.message.timestamp)
}
@Test @MainActor
func sendMessage_onLocationChannel_sendsGeohashEventOrFailsWithSystemMessage() async {
let context = MockChatOutgoingContext()
let coordinator = ChatOutgoingCoordinator(context: context)
let channel = GeohashChannel(level: .city, geohash: "u4pruydq")
context.activeChannel = .location(channel)
context.isTeleported = true
coordinator.sendMessage("hello geo")
await drainMainActorTasks()
// Local echo carries the geohash sender suffix (#last-4-of-pubkey) and
// the signed event's ID; the send context targets the same channel.
#expect(context.appendedPublicMessages.count == 1)
let echo = context.appendedPublicMessages[0].message
#expect(context.appendedPublicMessages[0].conversationID == .geohash("u4pruydq"))
#expect(echo.sender == "me#2222")
#expect(context.recordedActivityKeys == ["geo:u4pruydq"])
#expect(context.sentGeohashContexts.count == 1)
let geoContext = context.sentGeohashContexts[0]
#expect(geoContext.channel == channel)
#expect(geoContext.teleported)
#expect(geoContext.event.id == echo.id)
// Identity derivation failure: system message, no echo, no send.
context.deriveNostrIdentityError = MockChatOutgoingContext.IdentityUnavailable()
coordinator.sendMessage("doomed")
await drainMainActorTasks()
#expect(context.systemMessages.count == 1)
#expect(context.appendedPublicMessages.count == 1)
#expect(context.sentGeohashContexts.count == 1)
}
}
@@ -0,0 +1,451 @@
//
// ChatPeerIdentityCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatPeerIdentityCoordinator` against a mock
// `ChatPeerIdentityContext` proving the coordinator works without a
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
//
// Scope note: favorites are injected through the context
// (`favoriteRelationship(forNoiseKey:)` / `addFavorite` / `removeFavorite`),
// so the favorite toggle and lookup flows are covered here alongside the
// session, migration, encryption-status, and nickname resolution flows.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatPeerIdentityContext` proving that
/// `ChatPeerIdentityCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatPeerIdentityContext: ChatPeerIdentityContext {
// Conversation state
var privateChats: [PeerID: [BitchatMessage]] = [:]
var unreadPrivateMessages: Set<PeerID> = []
var selectedPrivateChatPeer: PeerID?
var selectedPrivateChatFingerprint: String?
var nickname = "me"
var myPeerID = PeerID(str: "0011223344556677")
var activeChannel: ChannelID = .mesh
private(set) var notifyUIChangedCount = 0
private(set) var systemMessages: [String] = []
func notifyUIChanged() { notifyUIChangedCount += 1 }
func addSystemMessage(_ content: String) { systemMessages.append(content) }
// Conversation store intents (mirror `ConversationStore` migrate
// semantics: dedup by ID, timestamp order, unread carried, old chat
// removed) while recording calls for assertions.
private(set) var migratedChats: [(from: PeerID, to: PeerID)] = []
func markPrivateChatRead(_ peerID: PeerID) {
unreadPrivateMessages.remove(peerID)
}
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID) {
migratedChats.append((oldPeerID, newPeerID))
guard oldPeerID != newPeerID, let source = privateChats[oldPeerID] else { return }
var destination = privateChats[newPeerID] ?? []
for message in source where !destination.contains(where: { $0.id == message.id }) {
let index = destination.firstIndex(where: { $0.timestamp > message.timestamp }) ?? destination.count
destination.insert(message, at: index)
}
privateChats[newPeerID] = destination
privateChats.removeValue(forKey: oldPeerID)
if unreadPrivateMessages.remove(oldPeerID) != nil {
unreadPrivateMessages.insert(newPeerID)
}
}
// Private chat session lifecycle
private(set) var consolidatedPeers: [(peerID: PeerID, peerNickname: String)] = []
private(set) var syncedReadReceiptPeers: [PeerID] = []
private(set) var begunChatSessions: [PeerID] = []
private(set) var markedReadPeers: [PeerID] = []
@discardableResult
func consolidatePrivateMessages(for peerID: PeerID, peerNickname: String) -> Bool {
consolidatedPeers.append((peerID, peerNickname))
return false
}
func syncReadReceiptsForSentMessages(for peerID: PeerID) {
syncedReadReceiptPeers.append(peerID)
}
func beginPrivateChatSession(with peerID: PeerID) {
begunChatSessions.append(peerID)
}
func markPrivateMessagesAsRead(from peerID: PeerID) { markedReadPeers.append(peerID) }
// Unified peer service
var connectedPeers: Set<PeerID> = []
var peersByID: [PeerID: BitchatPeer] = [:]
var blockedPeers: Set<PeerID> = []
var fingerprintsByPeerID: [PeerID: String] = [:]
var peerIDsByNickname: [String: PeerID] = [:]
var ephemeralPeerIDsByNoiseKey: [Data: PeerID] = [:]
private(set) var toggledFavoritePeers: [PeerID] = []
func unifiedPeer(for peerID: PeerID) -> BitchatPeer? { peersByID[peerID] }
func unifiedIsBlocked(_ peerID: PeerID) -> Bool { blockedPeers.contains(peerID) }
func unifiedToggleFavorite(_ peerID: PeerID) { toggledFavoritePeers.append(peerID) }
func unifiedFingerprint(for peerID: PeerID) -> String? { fingerprintsByPeerID[peerID] }
func unifiedPeerID(forNickname nickname: String) -> PeerID? { peerIDsByNickname[nickname] }
func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? { ephemeralPeerIDsByNoiseKey[noiseKey] }
// Mesh & Noise sessions
var nicknamesByPeerID: [PeerID: String] = [:]
var noiseSessionStates: [PeerID: LazyHandshakeState] = [:]
var establishedNoiseSessions: Set<PeerID> = []
var activeNoiseSessions: Set<PeerID> = []
var myNoiseFingerprint = "my-fingerprint"
private(set) var triggeredHandshakes: [PeerID] = []
func peerNickname(for peerID: PeerID) -> String? { nicknamesByPeerID[peerID] }
func meshPeerNicknames() -> [PeerID: String] { nicknamesByPeerID }
func noiseSessionState(for peerID: PeerID) -> LazyHandshakeState {
noiseSessionStates[peerID] ?? .none
}
func triggerHandshake(with peerID: PeerID) { triggeredHandshakes.append(peerID) }
func hasEstablishedNoiseSession(with peerID: PeerID) -> Bool {
establishedNoiseSessions.contains(peerID)
}
func hasNoiseSession(with peerID: PeerID) -> Bool { activeNoiseSessions.contains(peerID) }
func noiseIdentityFingerprint() -> String { myNoiseFingerprint }
// Identity store (fingerprints & encryption status)
var verifiedFingerprintSet: Set<String> = []
var socialIdentitiesByFingerprint: [String: SocialIdentity] = [:]
private(set) var storedFingerprints: [(fingerprint: String, peerID: PeerID)] = []
private(set) var encryptionStatuses: [PeerID: EncryptionStatus?] = [:]
private(set) var cachedEncryptionStatuses: [PeerID: EncryptionStatus] = [:]
private(set) var invalidatedEncryptionCachePeers: [PeerID?] = []
func setStoredFingerprint(_ fingerprint: String, for peerID: PeerID) {
storedFingerprints.append((fingerprint, peerID))
fingerprintsByPeerID[peerID] = fingerprint
}
func migrateFingerprintMapping(from oldPeerID: PeerID, to newPeerID: PeerID, fallback: String?) -> String? {
let fingerprint = fingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback
if let fingerprint {
fingerprintsByPeerID[newPeerID] = fingerprint
}
return fingerprint
}
func isVerifiedFingerprint(_ fingerprint: String) -> Bool {
verifiedFingerprintSet.contains(fingerprint)
}
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
encryptionStatuses[peerID] = status
}
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
cachedEncryptionStatuses[peerID]
}
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
cachedEncryptionStatuses[peerID] = status
}
func invalidateStoredEncryptionCache(for peerID: PeerID?) {
invalidatedEncryptionCachePeers.append(peerID)
if let peerID {
cachedEncryptionStatuses.removeValue(forKey: peerID)
} else {
cachedEncryptionStatuses.removeAll()
}
}
func socialIdentity(forFingerprint fingerprint: String) -> SocialIdentity? {
socialIdentitiesByFingerprint[fingerprint]
}
// Geohash & Nostr
var geoNicknames: [String: String] = [:]
var geohashPeople: [GeoPerson] = []
private(set) var registeredNostrKeyMappings: [(pubkey: String, peerID: PeerID)] = []
private(set) var nostrFavoriteNotifications: [(noisePublicKey: Data, isFavorite: Bool)] = []
var bridgedNostrKeysByNoiseKey: [Data: String] = [:]
func visibleGeohashPeople() -> [GeoPerson] { geohashPeople }
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID) {
registeredNostrKeyMappings.append((pubkey, peerID))
}
func bridgedNostrPublicKey(for noiseKey: Data) -> String? {
bridgedNostrKeysByNoiseKey[noiseKey]
}
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
nostrFavoriteNotifications.append((noisePublicKey, isFavorite))
}
// Favorites
var favoriteRelationshipsByNoiseKey: [Data: FavoritesPersistenceService.FavoriteRelationship] = [:]
var favoriteRelationshipsByPeerID: [PeerID: FavoritesPersistenceService.FavoriteRelationship] = [:]
private(set) var addedFavorites: [(noiseKey: Data, nostrPublicKey: String?, nickname: String)] = []
private(set) var removedFavorites: [Data] = []
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByNoiseKey[noiseKey]
}
func favoriteRelationship(forPeerID peerID: PeerID) -> FavoritesPersistenceService.FavoriteRelationship? {
favoriteRelationshipsByPeerID[peerID]
}
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String) {
addedFavorites.append((noiseKey, nostrPublicKey, nickname))
}
func removeFavorite(noiseKey: Data) {
removedFavorites.append(noiseKey)
}
}
// MARK: - Helpers
private func makeFavoriteRelationship(
noiseKey: Data,
nostrPublicKey: String? = nil,
nickname: String = "alice",
isFavorite: Bool = false,
theyFavoritedUs: Bool = false
) -> FavoritesPersistenceService.FavoriteRelationship {
FavoritesPersistenceService.FavoriteRelationship(
peerNoisePublicKey: noiseKey,
peerNostrPublicKey: nostrPublicKey,
peerNickname: nickname,
isFavorite: isFavorite,
theyFavoritedUs: theyFavoritedUs,
favoritedAt: Date(timeIntervalSince1970: 0),
lastUpdated: Date(timeIntervalSince1970: 0)
)
}
@MainActor
private func makePrivateMessage(
id: String,
sender: String = "alice",
timestamp: Date = Date(),
senderPeerID: PeerID? = nil
) -> BitchatMessage {
BitchatMessage(
id: id,
sender: sender,
content: "hello",
timestamp: timestamp,
isRelay: false,
isPrivate: true,
recipientNickname: "me",
senderPeerID: senderPeerID
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatPeerIdentityCoordinator` against
/// `MockChatPeerIdentityContext` with no `ChatViewModel`.
struct ChatPeerIdentityCoordinatorContextTests {
@Test @MainActor
func startPrivateChat_runsFullSessionSetupSequence() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.nicknamesByPeerID[peerID] = "alice"
context.fingerprintsByPeerID[peerID] = "fp-alice"
// Chatting with ourselves is a no-op.
coordinator.startPrivateChat(with: context.myPeerID)
#expect(context.begunChatSessions.isEmpty)
coordinator.startPrivateChat(with: peerID)
#expect(context.consolidatedPeers.map(\.peerID) == [peerID])
#expect(context.consolidatedPeers.first?.peerNickname == "alice")
// No Noise session yet -> handshake triggered.
#expect(context.triggeredHandshakes == [peerID])
#expect(context.syncedReadReceiptPeers == [peerID])
#expect(context.storedFingerprints.map(\.fingerprint) == ["fp-alice"])
#expect(context.selectedPrivateChatFingerprint == "fp-alice")
#expect(context.begunChatSessions == [peerID])
#expect(context.markedReadPeers == [peerID])
// Established session: no second handshake.
context.noiseSessionStates[peerID] = .established
coordinator.startPrivateChat(with: peerID)
#expect(context.triggeredHandshakes == [peerID])
}
@Test @MainActor
func startPrivateChat_blockedPeerOnlyGetsSystemMessage() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
context.blockedPeers = [peerID]
coordinator.startPrivateChat(with: peerID)
#expect(context.systemMessages.count == 1)
#expect(context.begunChatSessions.isEmpty)
#expect(context.consolidatedPeers.isEmpty)
#expect(context.markedReadPeers.isEmpty)
}
@Test @MainActor
func updatePrivateChatPeerIfNeeded_migratesChatStateByFingerprint() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let oldPeerID = PeerID(str: "1111111111111111")
let newPeerID = PeerID(str: "2222222222222222")
context.selectedPrivateChatFingerprint = "fp"
context.selectedPrivateChatPeer = oldPeerID
context.connectedPeers = [newPeerID]
context.fingerprintsByPeerID[newPeerID] = "fp"
let earlier = makePrivateMessage(id: "m1", timestamp: Date(timeIntervalSince1970: 1))
let later = makePrivateMessage(id: "m2", timestamp: Date(timeIntervalSince1970: 2))
context.privateChats[oldPeerID] = [later]
context.privateChats[newPeerID] = [earlier, later] // duplicate id "m2"
context.unreadPrivateMessages = [oldPeerID]
coordinator.updatePrivateChatPeerIfNeeded()
// Old chat is merged into the new peer's chat, deduplicated by id and
// sorted by timestamp; old keys are dropped.
#expect(context.privateChats[oldPeerID] == nil)
#expect(context.privateChats[newPeerID]?.map(\.id) == ["m1", "m2"])
#expect(context.selectedPrivateChatPeer == newPeerID)
// Unread moved to the new peer, then cleared for the now-open chat.
#expect(context.unreadPrivateMessages.isEmpty)
}
@Test @MainActor
func getEncryptionStatus_computesVerifiedStatusAndCachesIt() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let peerID = PeerID(str: "1122334455667788")
// Unknown peer with no fingerprint or session: no handshake yet.
#expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake)
#expect(context.cachedEncryptionStatuses[peerID] == .noHandshake)
// Cache hit short-circuits recomputation.
context.noiseSessionStates[peerID] = .established
#expect(coordinator.getEncryptionStatus(for: peerID) == .noHandshake)
// After invalidation, an established session with a verified
// fingerprint resolves (and re-caches) as verified.
coordinator.invalidateEncryptionCache(for: peerID)
context.fingerprintsByPeerID[peerID] = "fp"
context.verifiedFingerprintSet = ["fp"]
#expect(coordinator.getEncryptionStatus(for: peerID) == .noiseVerified)
#expect(context.cachedEncryptionStatuses[peerID] == .noiseVerified)
// updateEncryptionStatus publishes to the store and invalidates the cache.
context.establishedNoiseSessions = [peerID]
coordinator.updateEncryptionStatus(for: peerID)
#expect(context.encryptionStatuses[peerID] == .noiseVerified)
#expect(context.cachedEncryptionStatuses[peerID] == nil)
}
@Test @MainActor
func resolveNickname_walksMeshIdentityAndAnonFallbacks() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let meshPeer = PeerID(str: "aabbccddeeff0011")
let identityPeer = PeerID(str: "1234567890abcdef")
let unknownPeer = PeerID(str: "feedfacefeedface")
context.nicknamesByPeerID[meshPeer] = "alice"
#expect(coordinator.resolveNickname(for: meshPeer) == "alice")
context.fingerprintsByPeerID[identityPeer] = "fp"
context.socialIdentitiesByFingerprint["fp"] = SocialIdentity(
fingerprint: "fp",
localPetname: "bob!",
claimedNickname: "bob",
trustLevel: .casual,
isFavorite: false,
isBlocked: false,
notes: nil
)
#expect(coordinator.resolveNickname(for: identityPeer) == "bob!")
#expect(coordinator.resolveNickname(for: unknownPeer) == "anonfeed")
#expect(coordinator.getMyFingerprint() == "my-fingerprint")
}
@Test @MainActor
func getPeerIDForNickname_inGeohashChannel_registersNostrMapping() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let pubkey = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789".lowercased()
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy"))
context.geohashPeople = [GeoPerson(id: pubkey, displayName: "alice#6789", lastSeen: Date())]
context.geoNicknames[pubkey] = "alice"
// Suffixed display-name match.
let bySuffix = coordinator.getPeerIDForNickname("alice#6789")
#expect(bySuffix == PeerID(nostr_: pubkey))
// Base-nickname match via geoNicknames.
let byBase = coordinator.getPeerIDForNickname("ALICE")
#expect(byBase == PeerID(nostr_: pubkey))
#expect(context.registeredNostrKeyMappings.count == 2)
#expect(context.registeredNostrKeyMappings.allSatisfy { $0.pubkey == pubkey })
// Mesh channel falls through to the unified peer service.
context.activeChannel = .mesh
let meshPeer = PeerID(str: "1122334455667788")
context.peerIDsByNickname["carol"] = meshPeer
#expect(coordinator.getPeerIDForNickname("carol") == meshPeer)
}
@Test @MainActor
func toggleFavorite_forNoiseKeyPeer_usesInjectedFavoritesStore() async {
let context = MockChatPeerIdentityContext()
let coordinator = ChatPeerIdentityCoordinator(context: context)
let noiseKey = Data(repeating: 0xAB, count: 32)
let peerID = PeerID(hexData: noiseKey)
// No prior relationship: adds a favorite, no Nostr notification yet.
coordinator.toggleFavorite(peerID: peerID)
#expect(context.addedFavorites.count == 1)
#expect(context.addedFavorites.first?.noiseKey == noiseKey)
#expect(context.addedFavorites.first?.nickname == "Unknown")
#expect(context.nostrFavoriteNotifications.isEmpty)
#expect(coordinator.isFavorite(peerID: peerID) == false)
// They already favorite us: adding sends the mutual notification.
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
theyFavoritedUs: true
)
coordinator.toggleFavorite(peerID: peerID)
#expect(context.addedFavorites.count == 2)
#expect(context.addedFavorites.last?.nickname == "alice")
#expect(context.nostrFavoriteNotifications.map(\.isFavorite) == [true])
// Existing favorite: toggling removes it and notifies the unfavorite.
context.favoriteRelationshipsByNoiseKey[noiseKey] = makeFavoriteRelationship(
noiseKey: noiseKey,
isFavorite: true
)
#expect(coordinator.isFavorite(peerID: peerID) == true)
coordinator.toggleFavorite(peerID: peerID)
#expect(context.removedFavorites == [noiseKey])
#expect(context.nostrFavoriteNotifications.map(\.isFavorite) == [true, false])
}
}
@@ -0,0 +1,261 @@
//
// ChatPeerListCoordinatorContextTests.swift
// bitchatTests
//
// Exercises `ChatPeerListCoordinator` against a mock `ChatPeerListContext`
// proving the coordinator works without a `ChatViewModel`, following the
// `ChatDeliveryCoordinatorContextTests` /
// `ChatTransportEventCoordinatorContextTests` exemplars.
//
// Scope note: the network-availability notification now posts through the
// injected `ChatPeerListContext` (`notifyNetworkAvailable(peerCount:)`), so
// its gating is covered here; the wall-clock timer-driven reset flows are
// covered by integration-level tests.
//
import Testing
import Foundation
import BitFoundation
@testable import bitchat
// MARK: - Mock Context
/// Lightweight stand-in for `ChatPeerListContext` proving that
/// `ChatPeerListCoordinator` is testable without a `ChatViewModel`.
@MainActor
private final class MockChatPeerListContext: ChatPeerListContext {
// Connection & chat state
var isConnected = false
var privateChats: [PeerID: [BitchatMessage]] = [:]
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
privateChats[peerID] ?? []
}
var unreadPrivateMessages: Set<PeerID> = []
var hasTrackedPrivateChatSelection = false
private(set) var updatePrivateChatPeerIfNeededCount = 0
private(set) var cleanupOldReadReceiptsCount = 0
func markPrivateChatRead(_ peerID: PeerID) {
unreadPrivateMessages.remove(peerID)
}
func updatePrivateChatPeerIfNeeded() {
updatePrivateChatPeerIfNeededCount += 1
}
func cleanupOldReadReceipts() {
cleanupOldReadReceiptsCount += 1
}
// Peers & sessions
var unifiedPeers: [BitchatPeer] = []
var connectedMeshPeers: Set<PeerID> = []
var reachableMeshPeers: Set<PeerID> = []
var activeMeshPeerCountValue = 0
private(set) var registeredEphemeralSessions: [PeerID] = []
private(set) var updateEncryptionStatusForPeersCount = 0
func isPeerConnected(_ peerID: PeerID) -> Bool { connectedMeshPeers.contains(peerID) }
func isPeerReachable(_ peerID: PeerID) -> Bool { reachableMeshPeers.contains(peerID) }
func activeMeshPeerCount() -> Int { activeMeshPeerCountValue }
func registerEphemeralSession(peerID: PeerID) { registeredEphemeralSessions.append(peerID) }
func updateEncryptionStatusForPeers() { updateEncryptionStatusForPeersCount += 1 }
// Notifications
private(set) var networkAvailableNotifications: [Int] = []
func notifyNetworkAvailable(peerCount: Int) {
networkAvailableNotifications.append(peerCount)
}
}
// MARK: - Helpers
/// Lets the coordinator's internal `Task { @MainActor }` hops run.
@MainActor
private func drainMainActorTasks() async {
for _ in 0..<10 { await Task.yield() }
}
private func makeMessage(id: String, senderPeerID: PeerID? = nil) -> BitchatMessage {
BitchatMessage(
id: id,
sender: "alice",
content: "hello",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "me",
senderPeerID: senderPeerID
)
}
// MARK: - Coordinator Tests Against Mock Context
/// Exercises `ChatPeerListCoordinator` against `MockChatPeerListContext` with
/// no `ChatViewModel`.
struct ChatPeerListCoordinatorContextTests {
@Test @MainActor
func didUpdatePeerList_updatesConnectionSessionsAndEncryptionStatus() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerA = PeerID(str: "0011223344556677")
let peerB = PeerID(str: "8899aabbccddeeff")
context.isConnected = true
// Empty list: disconnected, read-receipt hygiene still runs, no sessions.
coordinator.didUpdatePeerList([])
await drainMainActorTasks()
#expect(!context.isConnected)
#expect(context.cleanupOldReadReceiptsCount == 1)
#expect(context.registeredEphemeralSessions.isEmpty)
#expect(context.updateEncryptionStatusForPeersCount == 1)
// Non-empty list: connected, every peer gets an ephemeral session.
coordinator.didUpdatePeerList([peerA, peerB])
await drainMainActorTasks()
#expect(context.isConnected)
#expect(context.registeredEphemeralSessions == [peerA, peerB])
#expect(context.updateEncryptionStatusForPeersCount == 2)
#expect(context.cleanupOldReadReceiptsCount == 2)
}
@Test @MainActor
func didUpdatePeerList_refreshesPrivateChatPeerOnlyWhenSelectionIsTracked() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerID = PeerID(str: "0011223344556677")
coordinator.didUpdatePeerList([peerID])
await drainMainActorTasks()
#expect(context.updatePrivateChatPeerIfNeededCount == 0)
context.hasTrackedPrivateChatSelection = true
coordinator.didUpdatePeerList([peerID])
await drainMainActorTasks()
#expect(context.updatePrivateChatPeerIfNeededCount == 1)
}
@Test @MainActor
func didUpdatePeerList_removesStaleUnreadPeerIDsButKeepsBackedConversations() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let currentPeer = PeerID(str: "0011223344556677")
let staleShortPeer = PeerID(str: "8899aabbccddeeff")
let geoDMWithMessages = PeerID(str: "nostr_" + String(repeating: "ab", count: 8))
let geoDMWithoutMessages = PeerID(str: "nostr_" + String(repeating: "cd", count: 8))
let noiseKeyWithMessages = PeerID(str: String(repeating: "ef", count: 32))
context.unifiedPeers = [
BitchatPeer(
peerID: currentPeer,
noisePublicKey: Data(repeating: 0x01, count: 32),
nickname: "alice"
),
]
context.unreadPrivateMessages = [
currentPeer,
staleShortPeer,
geoDMWithMessages,
geoDMWithoutMessages,
noiseKeyWithMessages,
]
context.privateChats = [
geoDMWithMessages: [makeMessage(id: "geo-1")],
noiseKeyWithMessages: [makeMessage(id: "noise-1")],
]
coordinator.didUpdatePeerList([currentPeer])
await drainMainActorTasks()
// Stale IDs without a backing conversation are dropped; geo-DM and
// Noise-key IDs with stored messages survive, as does the live peer.
#expect(context.unreadPrivateMessages == [currentPeer, geoDMWithMessages, noiseKeyWithMessages])
#expect(context.cleanupOldReadReceiptsCount == 1)
}
@Test @MainActor
func didUpdatePeerList_notifiesNetworkAvailableOncePerCooldownForNewMeshPeers() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerA = PeerID(str: "0011223344556677")
let peerB = PeerID(str: "8899aabbccddeeff")
context.connectedMeshPeers = [peerA, peerB]
// First sighting of a mesh-active peer notifies with the mesh peer count.
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
// The same peer again is not new no repeat notification.
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
// A genuinely new peer inside the cooldown window stays silent too.
coordinator.didUpdatePeerList([peerA, peerB])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
}
@Test @MainActor
func didUpdatePeerList_peerJoiningExistingMeshDoesNotNotify() async {
// Cooldown zero so this proves the empty-transition gate alone a
// new peer joining while already meshed must stay silent even with
// the cooldown long expired (the sitting-idle re-notify bug).
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context, notificationCooldownSeconds: 0)
let peerA = PeerID(str: "0011223344556677")
let peerB = PeerID(str: "8899aabbccddeeff")
context.connectedMeshPeers = [peerA, peerB]
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
// peerB arrives while peerA is still connected: no notification.
coordinator.didUpdatePeerList([peerA, peerB])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
// Repeat events while idle keep staying silent.
coordinator.didUpdatePeerList([peerA, peerB])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
}
@Test @MainActor
func didUpdatePeerList_briefMeshFlapDoesNotRenotify() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context, notificationCooldownSeconds: 0)
let peerA = PeerID(str: "0011223344556677")
context.connectedMeshPeers = [peerA]
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
// Link flap: empty list, then the peer returns before the 30s empty
// confirmation fires silent.
coordinator.didUpdatePeerList([])
await drainMainActorTasks()
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications == [1])
}
@Test @MainActor
func didUpdatePeerList_meshInactivePeersNeverNotify() async {
let context = MockChatPeerListContext()
let coordinator = ChatPeerListCoordinator(context: context)
let peerA = PeerID(str: "0011223344556677")
// Peer present but neither connected nor reachable: no notification.
coordinator.didUpdatePeerList([peerA])
await drainMainActorTasks()
#expect(context.networkAvailableNotifications.isEmpty)
}
}

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