mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 04:05:20 +00:00
Compare commits
73
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a66c591f8e | ||
|
|
75da63c9d7 | ||
|
|
d285c6ad53 | ||
|
|
8296630cf3 | ||
|
|
c74e212ea3 | ||
|
|
7a0c821807 | ||
|
|
0d251ad20c | ||
|
|
c8ceac1968 | ||
|
|
9ccff9cce4 | ||
|
|
66536063ca | ||
|
|
e191e9c6f2 | ||
|
|
b31a63ce37 | ||
|
|
0f26a27980 | ||
|
|
68eeba97ff | ||
|
|
f688e529f6 | ||
|
|
96e32ba990 | ||
|
|
0a2f4d9c9d | ||
|
|
914135adb0 | ||
|
|
cd7ffa0df9 | ||
|
|
bbe1ed0652 | ||
|
|
f07b032b99 | ||
|
|
2cbcb290f7 | ||
|
|
9cf7c80518 | ||
|
|
f76fd8a538 | ||
|
|
09c2c12838 | ||
|
|
8378ff949a | ||
|
|
ca63893197 | ||
|
|
fdf28aa5bb | ||
|
|
266827ceff | ||
|
|
97bc3f53bc | ||
|
|
9dc0ba6991 | ||
|
|
af954b05ea | ||
|
|
c8381737bb | ||
|
|
fa136d8973 | ||
|
|
7fbe6a4a7e | ||
|
|
4791114406 | ||
|
|
1a6a08f92a | ||
|
|
0b007eee1a | ||
|
|
1480b51c76 | ||
|
|
0e38ccfb3b | ||
|
|
e74f36927f | ||
|
|
cf3b85f65c | ||
|
|
8899cb7f9e | ||
|
|
22be3d6392 | ||
|
|
8a867a17a1 | ||
|
|
ed86ed1065 | ||
|
|
e74d9a7937 | ||
|
|
8289a6d05e | ||
|
|
38331e62f1 | ||
|
|
7fb1f4a219 | ||
|
|
99d1d1dccd | ||
|
|
879d8cba12 | ||
|
|
ac3a2f2d34 | ||
|
|
45650854e7 | ||
|
|
75dd83d9cc | ||
|
|
93d01b8fa6 | ||
|
|
6c0dbbbd0d | ||
|
|
09087b74cc | ||
|
|
cc76086615 | ||
|
|
638f3f5005 | ||
|
|
6091ee83ad | ||
|
|
82736c4991 | ||
|
|
707b22878d | ||
|
|
80bed1f395 | ||
|
|
4b287f7490 | ||
|
|
3caf2d7663 | ||
|
|
14d025cc2a | ||
|
|
19b28cf49d | ||
|
|
a2825ca288 | ||
|
|
3a995e20b6 | ||
|
|
6bda919dd4 | ||
|
|
4093ee6733 | ||
|
|
eb2c128cab |
@@ -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
|
||||
@@ -10,6 +10,12 @@ 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).
|
||||
# The long steps carry tighter individual bounds (5-minute test watchdog,
|
||||
# 6-minute benchmark step, 10-minute floor gate that may re-run the
|
||||
# benchmarks up to twice on a noisy runner); this is the backstop.
|
||||
timeout-minutes: 25
|
||||
|
||||
strategy:
|
||||
fail-fast: false # Don't cancel other matrix jobs when one fails
|
||||
@@ -26,17 +32,148 @@ 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. If a metric
|
||||
# still lands below floor (a saturated runner can dip one), the script
|
||||
# re-runs the benchmarks — appending to the same log and keeping each
|
||||
# benchmark's best value per metric — so noise clears on retry while a
|
||||
# real regression fails every attempt. Floors are never lowered by this.
|
||||
- name: Performance floor gate
|
||||
if: matrix.name == 'app'
|
||||
timeout-minutes: 10
|
||||
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
|
||||
|
||||
# Advisory only: SwiftLint reports style violations without ever failing the
|
||||
# build. Runs in a pinned container (no Xcode plugin, no pbxproj changes) so
|
||||
# it can never break the documented xcodebuild path or block a merge.
|
||||
lint:
|
||||
name: SwiftLint (advisory)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
# This job runs a third-party container image, so give it the least
|
||||
# privilege we can: a read-only token, and no credentials left in the
|
||||
# checkout for the container to find.
|
||||
permissions:
|
||||
contents: read
|
||||
container:
|
||||
# Tag for readability, digest for immutability (tags can be repointed).
|
||||
# Bump both together, deliberately — never a floating tag.
|
||||
image: ghcr.io/realm/swiftlint:0.65.0@sha256:a482729f4b58741875af1566f23397f3f6db300372756fc31606d0a4527fab9e
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run SwiftLint
|
||||
run: swiftlint lint --reporter github-actions-logging
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
# Build artifacts and generated sources; keeps local `swiftlint` runs clean
|
||||
# (CI checkouts are fresh, so this only matters in a working tree).
|
||||
excluded:
|
||||
- .build
|
||||
- .swiftpm
|
||||
- .DerivedData
|
||||
- DerivedData
|
||||
- build
|
||||
- localPackages/*/.build
|
||||
|
||||
disabled_rules:
|
||||
- line_length
|
||||
- type_name
|
||||
- identifier_name
|
||||
- statement_position
|
||||
- implicit_optional_initialization
|
||||
- force_try
|
||||
- vertical_whitespace
|
||||
- for_where
|
||||
- control_statement
|
||||
- void_function_in_ternary
|
||||
- redundant_discardable_let # SwiftUI breaks without it
|
||||
# To be enabled as we fix the issues
|
||||
- trailing_whitespace
|
||||
- cyclomatic_complexity
|
||||
- function_body_length
|
||||
- function_parameter_count
|
||||
- type_body_length
|
||||
- file_length
|
||||
- large_tuple
|
||||
- force_cast
|
||||
- multiple_closures_with_trailing_closure
|
||||
- nesting
|
||||
@@ -1,4 +1,4 @@
|
||||
MARKETING_VERSION = 1.5.1
|
||||
MARKETING_VERSION = 1.5.4
|
||||
CURRENT_PROJECT_VERSION = 1
|
||||
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||
|
||||
+42
-16
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
+10
-4
@@ -13,9 +13,9 @@ let package = Package(
|
||||
.executable(
|
||||
name: "bitchat",
|
||||
targets: ["bitchat"]
|
||||
),
|
||||
)
|
||||
],
|
||||
dependencies:[
|
||||
dependencies: [
|
||||
.package(path: "localPackages/Arti"),
|
||||
.package(path: "localPackages/BitFoundation"),
|
||||
.package(path: "localPackages/BitLogger"),
|
||||
@@ -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")
|
||||
]
|
||||
)
|
||||
]
|
||||
|
||||
Generated
+11
-15
@@ -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.4;
|
||||
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.4;
|
||||
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.4;
|
||||
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.4;
|
||||
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"
|
||||
|
||||
@@ -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,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]
|
||||
|
||||
@@ -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
|
||||
/// ephemeral↔stable 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
|
||||
}
|
||||
}
|
||||
@@ -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,10 +41,22 @@ final class ConversationUIModel: ObservableObject {
|
||||
chatViewModel.currentColorScheme = colorScheme
|
||||
}
|
||||
|
||||
func setCurrentTheme(_ theme: AppTheme) {
|
||||
chatViewModel.currentTheme = theme
|
||||
}
|
||||
|
||||
func sendMessage(_ message: String) {
|
||||
chatViewModel.sendMessage(message)
|
||||
}
|
||||
|
||||
/// Resends a failed private message through the normal send path,
|
||||
/// removing the failed original so the re-submission replaces it
|
||||
/// instead of stacking a duplicate under the red bubble.
|
||||
func resendFailedPrivateMessage(_ message: BitchatMessage) {
|
||||
chatViewModel.removePrivateMessage(withID: message.id)
|
||||
chatViewModel.sendMessage(message.content)
|
||||
}
|
||||
|
||||
func clearCurrentConversation() {
|
||||
chatViewModel.sendMessage("/clear")
|
||||
}
|
||||
@@ -63,11 +75,23 @@ final class ConversationUIModel: ObservableObject {
|
||||
if let peerID, peerID.isGeoChat,
|
||||
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
|
||||
} else if let peerID, !peerID.isGeoDM, !peerID.isGeoChat {
|
||||
// Mesh: block the peer's stable Noise identity resolved from the
|
||||
// tapped peerID rather than re-resolving a display-name string.
|
||||
chatViewModel.blockMeshPeer(peerID: peerID, displayName: displayName)
|
||||
} else {
|
||||
chatViewModel.sendMessage("/block \(displayName)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Mesh counterpart of `block(peerID:displayName:)`. Resolves the unblock by
|
||||
/// the tapped peer's stable identity so the exact row is unblocked — this
|
||||
/// also works for offline peers, which the `/unblock <displayName>` command
|
||||
/// cannot resolve.
|
||||
func unblock(peerID: PeerID, displayName: String) {
|
||||
chatViewModel.unblockMeshPeer(peerID: peerID, displayName: displayName)
|
||||
}
|
||||
|
||||
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
|
||||
}
|
||||
@@ -76,12 +100,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 +175,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
|
||||
|
||||
@@ -7,19 +7,8 @@ final class LocationPresenceStore: ObservableObject {
|
||||
@Published private(set) var geoNicknames: [String: String] = [:]
|
||||
@Published private(set) var teleportedGeo: Set<String> = []
|
||||
|
||||
private let teleportedGeoCapacity: Int
|
||||
private var teleportedGeoOrder: [String] = []
|
||||
|
||||
init(teleportedGeoCapacity: Int = TransportConfig.geoTeleportedParticipantsCap) {
|
||||
self.teleportedGeoCapacity = max(0, teleportedGeoCapacity)
|
||||
}
|
||||
|
||||
func setCurrentGeohash(_ geohash: String?) {
|
||||
let normalized = geohash?.lowercased()
|
||||
if currentGeohash != normalized {
|
||||
clearTeleportedGeo()
|
||||
}
|
||||
currentGeohash = normalized
|
||||
currentGeohash = geohash?.lowercased()
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||
@@ -39,63 +28,24 @@ final class LocationPresenceStore: ObservableObject {
|
||||
}
|
||||
|
||||
func markTeleported(_ pubkeyHex: String) {
|
||||
guard teleportedGeoCapacity > 0 else {
|
||||
clearTeleportedGeo()
|
||||
return
|
||||
}
|
||||
|
||||
let key = pubkeyHex.lowercased()
|
||||
guard !teleportedGeo.contains(key) else { return }
|
||||
|
||||
while teleportedGeoOrder.count >= teleportedGeoCapacity, let oldest = teleportedGeoOrder.first {
|
||||
teleportedGeoOrder.removeFirst()
|
||||
teleportedGeo.remove(oldest)
|
||||
}
|
||||
|
||||
teleportedGeo.insert(key)
|
||||
teleportedGeoOrder.append(key)
|
||||
teleportedGeo.insert(pubkeyHex.lowercased())
|
||||
}
|
||||
|
||||
func clearTeleported(_ pubkeyHex: String) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
teleportedGeo.remove(key)
|
||||
teleportedGeoOrder.removeAll { $0 == key }
|
||||
teleportedGeo.remove(pubkeyHex.lowercased())
|
||||
}
|
||||
|
||||
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
||||
guard teleportedGeoCapacity > 0 else {
|
||||
clearTeleportedGeo()
|
||||
return
|
||||
}
|
||||
|
||||
var seen: Set<String> = []
|
||||
var ordered: [String] = []
|
||||
for key in pubkeys.map({ $0.lowercased() }) where !seen.contains(key) {
|
||||
seen.insert(key)
|
||||
ordered.append(key)
|
||||
}
|
||||
if ordered.count > teleportedGeoCapacity {
|
||||
ordered = Array(ordered.suffix(teleportedGeoCapacity))
|
||||
}
|
||||
teleportedGeoOrder = ordered
|
||||
teleportedGeo = Set(ordered)
|
||||
}
|
||||
|
||||
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
|
||||
let allowed = Set(pubkeys.map { $0.lowercased() })
|
||||
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
|
||||
teleportedGeo = teleportedGeo.intersection(allowed)
|
||||
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
|
||||
}
|
||||
|
||||
func clearTeleportedGeo() {
|
||||
teleportedGeo.removeAll()
|
||||
teleportedGeoOrder.removeAll()
|
||||
}
|
||||
|
||||
func reset() {
|
||||
currentGeohash = nil
|
||||
geoNicknames.removeAll()
|
||||
teleportedGeo.removeAll()
|
||||
teleportedGeoOrder.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -207,7 +232,13 @@ final class PrivateConversationModel: ObservableObject {
|
||||
let headerPeerID = chatViewModel.getShortIDForNoiseKey(conversationPeerID)
|
||||
let peer = chatViewModel.getPeer(byID: headerPeerID)
|
||||
let displayName = resolveDisplayName(for: conversationPeerID, headerPeerID: headerPeerID, peer: peer)
|
||||
let availability = resolveAvailability(for: headerPeerID, peer: peer)
|
||||
// Geo DMs are always routed over Nostr (NIP-17); their nostr_ keys
|
||||
// never resolve to a reachable mesh peer, so resolveAvailability would
|
||||
// report .offline. Report .nostrAvailable so the header shows the
|
||||
// globe instead of a misleading "offline" tag.
|
||||
let availability = conversationPeerID.isGeoDM
|
||||
? .nostrAvailable
|
||||
: resolveAvailability(for: headerPeerID, peer: peer)
|
||||
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
||||
? nil
|
||||
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -69,7 +71,7 @@ struct BitchatApp: App {
|
||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||
weak var runtime: AppRuntime?
|
||||
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
|
||||
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
@@ -127,10 +127,10 @@ struct SocialIdentity: Codable {
|
||||
}
|
||||
|
||||
enum TrustLevel: String, Codable {
|
||||
case unknown = "unknown"
|
||||
case casual = "casual"
|
||||
case trusted = "trusted"
|
||||
case verified = "verified"
|
||||
case unknown
|
||||
case casual
|
||||
case trusted
|
||||
case verified
|
||||
}
|
||||
|
||||
// MARK: - Identity Cache
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+1453
-159
File diff suppressed because it is too large
Load Diff
@@ -11,33 +11,38 @@ import Foundation
|
||||
// MARK: - CommandInfo Enum
|
||||
|
||||
enum CommandInfo: String, Identifiable {
|
||||
// Raw values must match the aliases CommandProcessor actually accepts —
|
||||
// the suggestion panel is the app's only command-discovery surface, and
|
||||
// suggesting a spelling the processor rejects teaches users dead ends.
|
||||
case block
|
||||
case clear
|
||||
case help
|
||||
case hug
|
||||
case message = "dm"
|
||||
case message = "msg"
|
||||
case slap
|
||||
case unblock
|
||||
case who
|
||||
case favorite
|
||||
case unfavorite
|
||||
|
||||
case favorite = "fav"
|
||||
case unfavorite = "unfav"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
|
||||
var alias: String { "/" + rawValue }
|
||||
|
||||
|
||||
var placeholder: String? {
|
||||
switch self {
|
||||
case .block, .hug, .message, .slap, .unblock, .favorite, .unfavorite:
|
||||
return "<" + String(localized: "content.input.nickname_placeholder") + ">"
|
||||
case .clear, .who:
|
||||
case .clear, .help, .who:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .block: String(localized: "content.commands.block")
|
||||
case .clear: String(localized: "content.commands.clear")
|
||||
case .help: String(localized: "content.commands.help")
|
||||
case .hug: String(localized: "content.commands.hug")
|
||||
case .message: String(localized: "content.commands.message")
|
||||
case .slap: String(localized: "content.commands.slap")
|
||||
@@ -47,12 +52,14 @@ enum CommandInfo: String, Identifiable {
|
||||
case .unfavorite: String(localized: "content.commands.unfavorite")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static func all(isGeoPublic: Bool, isGeoDM: Bool) -> [CommandInfo] {
|
||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .hug, .message, .slap, .who]
|
||||
let baseCommands: [CommandInfo] = [.block, .unblock, .clear, .help, .hug, .message, .slap, .who]
|
||||
// The processor rejects favorites in geohash contexts, so only
|
||||
// suggest them where they actually work: mesh.
|
||||
if isGeoPublic || isGeoDM {
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
return baseCommands
|
||||
}
|
||||
return baseCommands
|
||||
return baseCommands + [.favorite, .unfavorite]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,7 @@ enum NoisePattern {
|
||||
case XX // Most versatile, mutual authentication
|
||||
case IK // Initiator knows responder's static key
|
||||
case NK // Anonymous initiator
|
||||
case X // One-way: single message to a known static key (no response)
|
||||
}
|
||||
|
||||
enum NoiseRole {
|
||||
@@ -322,6 +323,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)
|
||||
@@ -594,7 +602,7 @@ final class NoiseHandshakeState {
|
||||
switch pattern {
|
||||
case .XX:
|
||||
break // No pre-message keys
|
||||
case .IK, .NK:
|
||||
case .IK, .NK, .X:
|
||||
if role == .initiator, let remoteStatic = remoteStaticPublic {
|
||||
symmetricState.mixHash(remoteStatic.rawRepresentation)
|
||||
} else if role == .responder, let localStatic = localStaticPublic {
|
||||
@@ -897,6 +905,7 @@ extension NoisePattern {
|
||||
case .XX: return "XX"
|
||||
case .IK: return "IK"
|
||||
case .NK: return "NK"
|
||||
case .X: return "X"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -918,6 +927,10 @@ extension NoisePattern {
|
||||
[.e, .es], // -> e, es
|
||||
[.e, .ee] // <- e, ee
|
||||
]
|
||||
case .X:
|
||||
return [
|
||||
[.e, .es, .s, .ss] // -> e, es, s, ss (single one-way message)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -587,7 +648,7 @@ private extension NostrProtocol {
|
||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||
salt: Data(),
|
||||
info: "nip44-v2".data(using: .utf8)!,
|
||||
info: Data("nip44-v2".utf8),
|
||||
outputByteCount: 32
|
||||
)
|
||||
return derivedKey.withUnsafeBytes { Data($0) }
|
||||
|
||||
@@ -66,6 +66,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 +96,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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +137,10 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
@Published private(set) var relays: [Relay] = []
|
||||
@Published private(set) var isConnected = false
|
||||
/// Whether a relay that carries private messages is connected. DMs
|
||||
/// target the default (gift-wrap-capable) relay set, so a connected
|
||||
/// geohash/custom relay alone must not count — sends would still queue.
|
||||
@Published private(set) var isDMRelayConnected = false
|
||||
|
||||
private let dependencies: NostrRelayManagerDependencies
|
||||
private var allowDefaultRelays: Bool = false
|
||||
@@ -140,13 +148,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 +188,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 +202,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() }
|
||||
|
||||
@@ -252,19 +285,78 @@ final class NostrRelayManager: ObservableObject {
|
||||
task.cancel(with: .goingAway, reason: nil)
|
||||
}
|
||||
connections.removeAll()
|
||||
// Clear known subscriptions and any queued subs since connections are gone
|
||||
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()
|
||||
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 +377,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 +402,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 +519,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)
|
||||
}
|
||||
@@ -486,11 +599,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 +635,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 +676,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 +821,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
|
||||
@@ -665,26 +925,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) {
|
||||
@@ -722,12 +991,26 @@ final class NostrRelayManager: ObservableObject {
|
||||
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
|
||||
}
|
||||
guard event.isValidSignature() else {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Dropped invalid Nostr event id=\(event.id.prefix(16))… sub=\(subId) relay=\(relayUrl)",
|
||||
category: .session
|
||||
)
|
||||
return
|
||||
}
|
||||
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 {
|
||||
@@ -737,7 +1020,6 @@ final class NostrRelayManager: ObservableObject {
|
||||
if var tracker = eoseTrackers[subId] {
|
||||
tracker.pendingRelays.remove(relayUrl)
|
||||
if tracker.pendingRelays.isEmpty {
|
||||
tracker.timer?.invalidate()
|
||||
eoseTrackers.removeValue(forKey: subId)
|
||||
tracker.callback()
|
||||
} else {
|
||||
@@ -809,19 +1091,35 @@ final class NostrRelayManager: ObservableObject {
|
||||
|
||||
private func updateConnectionStatus() {
|
||||
isConnected = relays.contains { $0.isConnected }
|
||||
// Relay URLs are normalized before entries are created, so direct
|
||||
// set membership is sound.
|
||||
isDMRelayConnected = relays.contains { $0.isConnected && Self.defaultRelaySet.contains($0.url) }
|
||||
}
|
||||
|
||||
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)
|
||||
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 +1150,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
|
||||
@@ -919,6 +1227,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 +1276,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 {
|
||||
@@ -974,8 +1314,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
|
||||
}
|
||||
|
||||
@@ -132,4 +132,3 @@ private extension Data {
|
||||
replaceSubrange(offset..<(offset+4), with: bytes)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||
case .city: return 5
|
||||
case .province: return 4
|
||||
case .region: return 2
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
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
|
||||
}
|
||||
|
||||
/// Outcome of an accepted announce, surfaced so the service can run
|
||||
/// follow-up work (e.g. courier handover) that keys off the announce.
|
||||
struct BLEAnnounceHandlingResult {
|
||||
let peerID: PeerID
|
||||
let announcement: AnnouncementPacket
|
||||
let isDirectAnnounce: Bool
|
||||
let isVerified: Bool
|
||||
}
|
||||
|
||||
/// Orchestrates inbound announce packets: preflight validation, signature
|
||||
/// 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
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func handle(_ packet: BitchatPacket, from peerID: PeerID) -> BLEAnnounceHandlingResult? {
|
||||
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 nil
|
||||
case .reject(.senderMismatch(let derivedFromKey)):
|
||||
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
||||
return nil
|
||||
case .reject(.selfAnnounce):
|
||||
return nil
|
||||
case .reject(.stale(let ageSeconds)):
|
||||
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
return BLEAnnounceHandlingResult(
|
||||
peerID: peerID,
|
||||
announcement: announcement,
|
||||
isDirectAnnounce: isDirectAnnounce,
|
||||
isVerified: verifiedAnnounce
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -13,17 +13,45 @@ 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(
|
||||
let rawAllowed = allowedLinks(
|
||||
peripheralIDs: peripheralIDs,
|
||||
centralIDs: centralIDs,
|
||||
ingressLink: ingressLink,
|
||||
excludedLinks: excludedLinks
|
||||
)
|
||||
|
||||
if let directedPeerHint,
|
||||
let directedSelection = directLinks(
|
||||
to: directedPeerHint,
|
||||
links: rawAllowed,
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
) {
|
||||
return directedSelection
|
||||
}
|
||||
if let directedPeerHint,
|
||||
hasBoundLink(
|
||||
to: directedPeerHint,
|
||||
peripheralIDs: peripheralIDs,
|
||||
centralIDs: centralIDs,
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
) {
|
||||
return BLEFanoutSelection(peripheralIDs: [], centralIDs: [])
|
||||
}
|
||||
|
||||
let allowed = collapseDuplicateLinksPerPeer(
|
||||
rawAllowed,
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
)
|
||||
|
||||
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
|
||||
return BLEFanoutSelection(
|
||||
peripheralIDs: Set(allowed.peripheralIDs),
|
||||
@@ -65,6 +93,79 @@ enum BLEFanoutSelector {
|
||||
return (allowedPeripheralIDs, allowedCentralIDs)
|
||||
}
|
||||
|
||||
private static func directLinks(
|
||||
to peerID: PeerID,
|
||||
links: (peripheralIDs: [String], centralIDs: [String]),
|
||||
peripheralPeerBindings: [String: PeerID],
|
||||
centralPeerBindings: [String: PeerID]
|
||||
) -> BLEFanoutSelection? {
|
||||
let directLinks = collapseDuplicateLinksPerPeer(
|
||||
(
|
||||
peripheralIDs: links.peripheralIDs.filter { peripheralPeerBindings[$0] == peerID },
|
||||
centralIDs: links.centralIDs.filter { centralPeerBindings[$0] == peerID }
|
||||
),
|
||||
peripheralPeerBindings: peripheralPeerBindings,
|
||||
centralPeerBindings: centralPeerBindings
|
||||
)
|
||||
|
||||
guard !directLinks.peripheralIDs.isEmpty || !directLinks.centralIDs.isEmpty else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return BLEFanoutSelection(
|
||||
peripheralIDs: Set(directLinks.peripheralIDs),
|
||||
centralIDs: Set(directLinks.centralIDs)
|
||||
)
|
||||
}
|
||||
|
||||
private static func hasBoundLink(
|
||||
to peerID: PeerID,
|
||||
peripheralIDs: [String],
|
||||
centralIDs: [String],
|
||||
peripheralPeerBindings: [String: PeerID],
|
||||
centralPeerBindings: [String: PeerID]
|
||||
) -> Bool {
|
||||
peripheralIDs.contains { peripheralPeerBindings[$0] == peerID }
|
||||
|| centralIDs.contains { centralPeerBindings[$0] == peerID }
|
||||
}
|
||||
|
||||
// Dual-role pairs hold two live links (we-as-central writing to their
|
||||
// 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -12,7 +12,7 @@ enum BLEOutboundPacketPolicy {
|
||||
switch MessageType(rawValue: packetType) {
|
||||
case .noiseEncrypted, .noiseHandshake:
|
||||
return true
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
|
||||
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer, .courierEnvelope:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -50,8 +51,9 @@ protocol CommandContextProvider: AnyObject {
|
||||
func addPublicSystemMessage(_ content: String)
|
||||
|
||||
// MARK: - Favorites
|
||||
/// Toggles the favorite via the unified peer flow, which persists by the
|
||||
/// real noise key and notifies the peer over mesh or Nostr.
|
||||
func toggleFavorite(peerID: PeerID)
|
||||
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||
}
|
||||
|
||||
/// Processes chat commands in a focused, efficient way
|
||||
@@ -104,11 +106,28 @@ final class CommandProcessor {
|
||||
case "/unfav":
|
||||
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
|
||||
return handleFavorite(args, add: false)
|
||||
case "/help":
|
||||
return .success(message: Self.helpText)
|
||||
default:
|
||||
return .error(message: "unknown command: \(cmd)")
|
||||
return .error(message: "unknown command: \(cmd) — type /help for commands")
|
||||
}
|
||||
}
|
||||
|
||||
/// Local-only command reference, printed as a system message. The
|
||||
/// suggestion panel hides once arguments are typed, and typos used to
|
||||
/// dead-end in a bare "unknown command" — this is the way out.
|
||||
static let helpText = """
|
||||
commands:
|
||||
/msg @name [message] — start a private chat
|
||||
/who — list who's here
|
||||
/clear — clear this chat
|
||||
/hug @name — send a hug
|
||||
/slap @name — slap with a large trout
|
||||
/block @name · /unblock @name
|
||||
/fav @name · /unfav @name — favorites (mesh only)
|
||||
/help — this list
|
||||
"""
|
||||
|
||||
// MARK: - Command Handlers
|
||||
|
||||
private func handleMessage(_ args: String) -> CommandResult {
|
||||
@@ -160,7 +179,7 @@ final class CommandProcessor {
|
||||
|
||||
private func handleClear() -> CommandResult {
|
||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
||||
contextProvider?.privateChats[peerID]?.removeAll()
|
||||
contextProvider?.clearPrivateChat(peerID)
|
||||
} else {
|
||||
contextProvider?.clearCurrentPublicTimeline()
|
||||
}
|
||||
@@ -317,34 +336,31 @@ final class CommandProcessor {
|
||||
guard !targetName.isEmpty else {
|
||||
return .error(message: "usage: /\(add ? "fav" : "unfav") <nickname>")
|
||||
}
|
||||
|
||||
|
||||
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
|
||||
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname),
|
||||
let noisePublicKey = Data(hexString: peerID.id) else {
|
||||
|
||||
guard let peerID = contextProvider?.getPeerIDForNickname(nickname) else {
|
||||
return .error(message: "can't find peer: \(nickname)")
|
||||
}
|
||||
|
||||
if add {
|
||||
let existingFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: noisePublicKey)
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: noisePublicKey,
|
||||
peerNostrPublicKey: existingFavorite?.peerNostrPublicKey,
|
||||
peerNickname: nickname
|
||||
)
|
||||
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: true)
|
||||
|
||||
return .success(message: "added \(nickname) to favorites")
|
||||
|
||||
// Resolve current state by the peer's real noise key. The resolved
|
||||
// peerID is either the short 16-hex mesh ID or the full 64-hex
|
||||
// noise-key ID (offline favorite row) — never the noise key itself.
|
||||
let isCurrentlyFavorite: Bool
|
||||
if let noiseKey = peerID.noiseKey {
|
||||
isCurrentlyFavorite = FavoritesPersistenceService.shared.isFavorite(noiseKey)
|
||||
} else {
|
||||
FavoritesPersistenceService.shared.removeFavorite(peerNoisePublicKey: noisePublicKey)
|
||||
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
contextProvider?.sendFavoriteNotification(to: peerID, isFavorite: false)
|
||||
|
||||
return .success(message: "removed \(nickname) from favorites")
|
||||
isCurrentlyFavorite = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.isFavorite ?? false
|
||||
}
|
||||
|
||||
guard add != isCurrentlyFavorite else {
|
||||
return .success(message: add ? "\(nickname) is already a favorite" : "\(nickname) is not a favorite")
|
||||
}
|
||||
|
||||
// toggleFavorite persists by the real noise key and notifies the peer.
|
||||
contextProvider?.toggleFavorite(peerID: peerID)
|
||||
|
||||
return .success(message: add ? "added \(nickname) to favorites" : "removed \(nickname) from favorites")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
//
|
||||
// CourierStore.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import BitLogger
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
/// Holds courier envelopes this device is carrying for offline third parties.
|
||||
///
|
||||
/// Envelopes are opaque ciphertext deposited by mutual favorites; this store
|
||||
/// never learns sender, recipient, or content. Strict quotas keep the device
|
||||
/// from becoming a public mailbag: bounded count, bounded per-depositor
|
||||
/// count, bounded size, and a 24-hour lifetime aligned with the outbox
|
||||
/// retention policy. Carried mail is included in the panic wipe.
|
||||
final class CourierStore {
|
||||
struct StoredEnvelope: Codable, Equatable {
|
||||
let recipientTag: Data
|
||||
let expiry: UInt64
|
||||
let ciphertext: Data
|
||||
let depositorNoiseKey: Data
|
||||
let storedAt: Date
|
||||
|
||||
var envelope: CourierEnvelope {
|
||||
CourierEnvelope(recipientTag: recipientTag, expiry: expiry, ciphertext: ciphertext)
|
||||
}
|
||||
}
|
||||
|
||||
enum Limits {
|
||||
static let maxEnvelopes = 20
|
||||
static let maxPerDepositor = 5
|
||||
/// Slack on top of the 24h lifetime for depositor clock skew.
|
||||
static let maxExpirySlack: TimeInterval = 60 * 60
|
||||
}
|
||||
|
||||
static let shared = CourierStore()
|
||||
|
||||
/// Number of envelopes currently carried, published on the main thread
|
||||
/// so the UI can show a "carrying mail" indicator.
|
||||
@Published private(set) var carriedCount: Int = 0
|
||||
|
||||
/// Fast path so hot code (announce handling) can skip tag computation.
|
||||
var isEmpty: Bool {
|
||||
queue.sync { envelopes.isEmpty }
|
||||
}
|
||||
|
||||
private var envelopes: [StoredEnvelope] = []
|
||||
private let queue = DispatchQueue(label: "chat.bitchat.courier.store")
|
||||
private let fileURL: URL?
|
||||
private let now: () -> Date
|
||||
|
||||
/// - Parameter fileURL: Overrides the on-disk location (tests). Ignored
|
||||
/// when `persistsToDisk` is false.
|
||||
init(persistsToDisk: Bool = true, fileURL: URL? = nil, now: @escaping () -> Date = Date.init) {
|
||||
self.now = now
|
||||
self.fileURL = persistsToDisk ? (fileURL ?? Self.defaultFileURL()) : nil
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
// MARK: - Depositing (courier side)
|
||||
|
||||
/// Accept an envelope from a depositor. Returns false when quotas or
|
||||
/// validity checks reject it. Trust policy (mutual favorite) is the
|
||||
/// caller's responsibility; this store only enforces resource bounds.
|
||||
@discardableResult
|
||||
func deposit(_ envelope: CourierEnvelope, from depositorNoiseKey: Data) -> Bool {
|
||||
let date = now()
|
||||
guard envelope.recipientTag.count == CourierEnvelope.tagLength,
|
||||
!envelope.ciphertext.isEmpty,
|
||||
envelope.ciphertext.count <= CourierEnvelope.maxCiphertextBytes,
|
||||
!envelope.isExpired(at: date) else {
|
||||
return false
|
||||
}
|
||||
// Reject expiries beyond the policy lifetime so depositors can't pin
|
||||
// storage longer than the outbox would retain the message itself.
|
||||
let maxExpiry = date.addingTimeInterval(CourierEnvelope.maxLifetimeSeconds + Limits.maxExpirySlack)
|
||||
guard envelope.expiry <= UInt64(maxExpiry.timeIntervalSince1970 * 1000) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
|
||||
// Identical ciphertext is the same envelope; accept idempotently.
|
||||
if envelopes.contains(where: { $0.ciphertext == envelope.ciphertext }) {
|
||||
return true
|
||||
}
|
||||
guard envelopes.filter({ $0.depositorNoiseKey == depositorNoiseKey }).count < Limits.maxPerDepositor else {
|
||||
SecureLogger.debug("📦 Courier deposit rejected: per-depositor quota reached", category: .session)
|
||||
return false
|
||||
}
|
||||
if envelopes.count >= Limits.maxEnvelopes {
|
||||
// Oldest-first eviction, matching outbox overflow behavior.
|
||||
let evicted = envelopes.removeFirst()
|
||||
SecureLogger.debug("📦 Courier store full - evicted envelope stored at \(evicted.storedAt)", category: .session)
|
||||
}
|
||||
|
||||
envelopes.append(StoredEnvelope(
|
||||
recipientTag: envelope.recipientTag,
|
||||
expiry: envelope.expiry,
|
||||
ciphertext: envelope.ciphertext,
|
||||
depositorNoiseKey: depositorNoiseKey,
|
||||
storedAt: date
|
||||
))
|
||||
persistLocked()
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Handover (on encountering a peer)
|
||||
|
||||
/// Remove and return all envelopes addressed to the given peer, matching
|
||||
/// the rotating recipient tag across adjacent days. Envelopes are removed
|
||||
/// optimistically: handover happens over a live link, and the depositor's
|
||||
/// outbox still retains the original for direct delivery.
|
||||
func takeEnvelopes(for noiseStaticKey: Data) -> [CourierEnvelope] {
|
||||
let date = now()
|
||||
let candidates = CourierEnvelope.candidateTags(noiseStaticKey: noiseStaticKey, around: date)
|
||||
return queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
let matched = envelopes.filter { candidates.contains($0.recipientTag) }
|
||||
guard !matched.isEmpty else { return [] }
|
||||
envelopes.removeAll { stored in matched.contains(stored) }
|
||||
persistLocked()
|
||||
return matched.map(\.envelope)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Maintenance
|
||||
|
||||
func pruneExpired() {
|
||||
let date = now()
|
||||
queue.sync {
|
||||
pruneExpiredLocked(at: date)
|
||||
persistLocked()
|
||||
}
|
||||
}
|
||||
|
||||
/// Panic wipe: drop all carried mail from memory and disk.
|
||||
func wipe() {
|
||||
queue.sync {
|
||||
envelopes.removeAll()
|
||||
if let fileURL {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
}
|
||||
publishCountLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internals (call only on `queue`)
|
||||
|
||||
private func pruneExpiredLocked(at date: Date) {
|
||||
let before = envelopes.count
|
||||
envelopes.removeAll { $0.envelope.isExpired(at: date) }
|
||||
if envelopes.count != before {
|
||||
SecureLogger.debug("📦 Courier store pruned \(before - envelopes.count) expired envelope(s)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func publishCountLocked() {
|
||||
let count = envelopes.count
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.carriedCount = count
|
||||
}
|
||||
}
|
||||
|
||||
private func persistLocked() {
|
||||
publishCountLocked()
|
||||
guard let fileURL else { return }
|
||||
do {
|
||||
if envelopes.isEmpty {
|
||||
try? FileManager.default.removeItem(at: fileURL)
|
||||
return
|
||||
}
|
||||
try FileManager.default.createDirectory(
|
||||
at: fileURL.deletingLastPathComponent(),
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
let data = try JSONEncoder().encode(envelopes)
|
||||
var options: Data.WritingOptions = [.atomic]
|
||||
#if os(iOS)
|
||||
options.insert(.completeFileProtection)
|
||||
#endif
|
||||
try data.write(to: fileURL, options: options)
|
||||
} catch {
|
||||
SecureLogger.error("Failed to persist courier store: \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadFromDisk() {
|
||||
guard let fileURL else { return }
|
||||
queue.sync {
|
||||
guard let data = try? Data(contentsOf: fileURL),
|
||||
let stored = try? JSONDecoder().decode([StoredEnvelope].self, from: data) else {
|
||||
return
|
||||
}
|
||||
envelopes = stored
|
||||
pruneExpiredLocked(at: now())
|
||||
publishCountLocked()
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultFileURL() -> URL? {
|
||||
guard let base = try? FileManager.default.url(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: nil,
|
||||
create: true
|
||||
) else { return nil }
|
||||
return base
|
||||
.appendingPathComponent("courier", isDirectory: true)
|
||||
.appendingPathComponent("envelopes.json")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -125,7 +141,13 @@ final class FavoritesPersistenceService: ObservableObject {
|
||||
peerNostrPublicKey: String? = nil
|
||||
) {
|
||||
let existing = favorites[peerNoisePublicKey]
|
||||
let displayName = peerNickname ?? existing?.peerNickname ?? "Unknown"
|
||||
// Callers that can't resolve the live nickname pass the "Unknown"
|
||||
// placeholder (e.g. a notification arriving before the announce);
|
||||
// never let it clobber a real stored nickname.
|
||||
let incoming = peerNickname.flatMap { name in
|
||||
(name.isEmpty || name == "Unknown") ? nil : name
|
||||
}
|
||||
let displayName = incoming ?? existing?.peerNickname ?? "Unknown"
|
||||
|
||||
SecureLogger.info("📨 Received favorite notification: \(displayName) \(favorited ? "favorited" : "unfavorited") us", category: .session)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -2,10 +2,49 @@ import BitLogger
|
||||
import BitFoundation
|
||||
import Foundation
|
||||
|
||||
/// Trust and identity lookups the router needs to pick couriers. Backed by
|
||||
/// the favorites store in production; injectable for tests.
|
||||
struct CourierDirectory {
|
||||
/// Noise static key for a peer we can address while they're offline.
|
||||
var noiseKey: (PeerID) -> Data?
|
||||
/// Whether a peer (by Noise static key) may carry our mail.
|
||||
var isTrustedCourier: (Data) -> Bool
|
||||
|
||||
@MainActor
|
||||
static func favoritesBacked() -> CourierDirectory {
|
||||
CourierDirectory(
|
||||
noiseKey: { peerID in
|
||||
// Offline favorites are addressed by the full 64-hex
|
||||
// noise-key ID, which carries the key itself; the favorites
|
||||
// lookup only resolves short 16-hex IDs.
|
||||
peerID.noiseKey
|
||||
?? FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNoisePublicKey
|
||||
},
|
||||
isTrustedCourier: { noiseKey in
|
||||
FavoritesPersistenceService.shared.isMutualFavorite(noiseKey)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Routes messages using available transports (Mesh, Nostr, etc.)
|
||||
@MainActor
|
||||
final class MessageRouter {
|
||||
private let transports: [Transport]
|
||||
private let now: () -> Date
|
||||
private let courierDirectory: CourierDirectory
|
||||
|
||||
/// 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)?
|
||||
|
||||
/// Invoked when a message with no reachable transport was handed to at
|
||||
/// least one courier (a connected mutual favorite who will physically
|
||||
/// carry the sealed envelope). Delivery stays best-effort: the outbox
|
||||
/// retains the message until an ack arrives.
|
||||
var onMessageCarried: ((_ messageID: String, _ peerID: PeerID) -> Void)?
|
||||
|
||||
// Outbox entry with timestamp for TTL-based eviction
|
||||
private struct QueuedMessage {
|
||||
@@ -13,6 +52,7 @@ final class MessageRouter {
|
||||
let nickname: String
|
||||
let messageID: String
|
||||
let timestamp: Date
|
||||
var sendAttempts: Int = 0
|
||||
}
|
||||
|
||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||
@@ -20,9 +60,20 @@ 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
|
||||
// Redundant couriers improve delivery odds; receivers dedup by message ID.
|
||||
private static let maxCouriersPerMessage = 3
|
||||
|
||||
init(transports: [Transport]) {
|
||||
init(
|
||||
transports: [Transport],
|
||||
now: @escaping () -> Date = Date.init,
|
||||
courierDirectory: CourierDirectory? = nil
|
||||
) {
|
||||
self.transports = transports
|
||||
self.now = now
|
||||
self.courierDirectory = courierDirectory ?? .favoritesBacked()
|
||||
|
||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||
NotificationCenter.default.addObserver(
|
||||
@@ -39,7 +90,7 @@ final class MessageRouter {
|
||||
}
|
||||
// Handle key updates
|
||||
if let newKey = note.userInfo?["peerPublicKey"] as? Data,
|
||||
let _ = note.userInfo?["isKeyUpdate"] as? Bool {
|
||||
note.userInfo?["isKeyUpdate"] is Bool {
|
||||
let peerID = PeerID(publicKey: newKey)
|
||||
Task { @MainActor in
|
||||
self.flushOutbox(for: peerID)
|
||||
@@ -61,24 +112,88 @@ 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)
|
||||
} 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)
|
||||
}
|
||||
|
||||
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
||||
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)
|
||||
// "Reachable" without prompt delivery means the send only joined
|
||||
// a queue (Nostr with relays down): also hand a sealed copy to
|
||||
// any connected couriers rather than waiting for internet that
|
||||
// may never come. Double delivery is harmless — receivers dedup
|
||||
// by message ID, and delivered/read acks never downgrade.
|
||||
if !transport.canDeliverPromptly(to: peerID) {
|
||||
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
|
||||
}
|
||||
} else {
|
||||
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)
|
||||
attemptCourierDeposit(content: content, messageID: messageID, for: peerID)
|
||||
}
|
||||
}
|
||||
|
||||
/// Last resort when no transport can deliver promptly — the peer is
|
||||
/// unreachable, or only reachable through a send queue waiting on
|
||||
/// internet: seal the message to their known static key and hand it to
|
||||
/// connected mutual favorites who may physically encounter them. The
|
||||
/// queued copy above stays retained, so direct delivery still wins if
|
||||
/// the peer reappears first (receivers dedup by message ID).
|
||||
private func attemptCourierDeposit(content: String, messageID: String, for peerID: PeerID) {
|
||||
guard let recipientKey = courierDirectory.noiseKey(peerID) else { return }
|
||||
for transport in transports {
|
||||
let couriers = transport.currentPeerSnapshots()
|
||||
.filter { snapshot in
|
||||
guard snapshot.isConnected,
|
||||
let key = snapshot.noisePublicKey,
|
||||
key != recipientKey else { return false }
|
||||
return courierDirectory.isTrustedCourier(key)
|
||||
}
|
||||
.prefix(Self.maxCouriersPerMessage)
|
||||
.map(\.peerID)
|
||||
guard !couriers.isEmpty else { continue }
|
||||
if transport.sendCourierMessage(content, messageID: messageID, recipientNoiseKey: recipientKey, via: Array(couriers)) {
|
||||
SecureLogger.debug("📦 PM \(messageID.prefix(8))… handed to \(couriers.count) courier(s) for \(peerID.id.prefix(8))…", category: .session)
|
||||
onMessageCarried?(messageID, peerID)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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) {
|
||||
@@ -111,19 +226,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 +272,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
|
||||
}
|
||||
|
||||
@@ -369,6 +369,49 @@ final class NoiseEncryptionService {
|
||||
func getPeerPublicKeyData(_ peerID: PeerID) -> Data? {
|
||||
return sessionManager.getRemoteStaticKey(for: peerID)?.rawRepresentation
|
||||
}
|
||||
|
||||
// MARK: - Courier Envelopes (one-way Noise X)
|
||||
|
||||
/// Domain separation for courier envelopes so X-pattern transcripts can
|
||||
/// never be confused with interactive XX handshakes.
|
||||
private static let courierPrologue = Data("bitchat-courier-v1".utf8)
|
||||
|
||||
/// Encrypt a payload to a peer's known static key without an interactive
|
||||
/// handshake (Noise X pattern). Used for store-and-forward envelopes
|
||||
/// carried by couriers while the recipient is offline.
|
||||
/// - Warning: One-way messages have no forward secrecy: a later compromise
|
||||
/// of the recipient's static key exposes envelopes captured in transit.
|
||||
/// Use established sessions whenever the peer is reachable.
|
||||
func sealCourierPayload(_ payload: Data, recipientStaticKey: Data) throws -> Data {
|
||||
let remoteKey = try NoiseHandshakeState.validatePublicKey(recipientStaticKey)
|
||||
let handshake = NoiseHandshakeState(
|
||||
role: .initiator,
|
||||
pattern: .X,
|
||||
keychain: keychain,
|
||||
localStaticKey: staticIdentityKey,
|
||||
remoteStaticKey: remoteKey,
|
||||
prologue: Self.courierPrologue
|
||||
)
|
||||
return try handshake.writeMessage(payload: payload)
|
||||
}
|
||||
|
||||
/// Decrypt a courier envelope addressed to our static key. Returns the
|
||||
/// payload and the sender's authenticated static public key (the `ss`
|
||||
/// DH in the X pattern binds the sender's identity to the ciphertext).
|
||||
func openCourierPayload(_ envelopeCiphertext: Data) throws -> (payload: Data, senderStaticKey: Data) {
|
||||
let handshake = NoiseHandshakeState(
|
||||
role: .responder,
|
||||
pattern: .X,
|
||||
keychain: keychain,
|
||||
localStaticKey: staticIdentityKey,
|
||||
prologue: Self.courierPrologue
|
||||
)
|
||||
let payload = try handshake.readMessage(envelopeCiphertext)
|
||||
guard let senderKey = handshake.getRemoteStaticPublicKey() else {
|
||||
throw NoiseError.missingKeys
|
||||
}
|
||||
return (payload: payload, senderStaticKey: senderKey.rawRepresentation)
|
||||
}
|
||||
|
||||
/// Clear persistent identity (for panic mode)
|
||||
func clearPersistentIdentity() {
|
||||
@@ -428,7 +471,7 @@ final class NoiseEncryptionService {
|
||||
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
|
||||
var out = Data()
|
||||
// context
|
||||
let context = "bitchat-announce-v1".data(using: .utf8) ?? Data()
|
||||
let context = Data("bitchat-announce-v1".utf8)
|
||||
out.append(UInt8(min(context.count, 255)))
|
||||
out.append(context.prefix(255))
|
||||
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
|
||||
@@ -444,7 +487,7 @@ final class NoiseEncryptionService {
|
||||
out.append(ed32)
|
||||
if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }
|
||||
// nickname length + bytes
|
||||
let nickData = nickname.data(using: .utf8) ?? Data()
|
||||
let nickData = Data(nickname.utf8)
|
||||
out.append(UInt8(min(nickData.count, 255)))
|
||||
out.append(nickData.prefix(255))
|
||||
// timestamp
|
||||
|
||||
@@ -14,7 +14,13 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
let registerPendingGiftWrap: @MainActor (String) -> Void
|
||||
let sendEvent: @MainActor (NostrEvent) -> Void
|
||||
let scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
||||
/// Emits whether a relay that carries private messages is up
|
||||
/// (fail-closed behind Tor). A connected geohash/custom relay alone
|
||||
/// doesn't count: DM sends target the default relay set and would
|
||||
/// still queue.
|
||||
let relayConnectivity: @MainActor () -> AnyPublisher<Bool, Never>
|
||||
|
||||
@MainActor
|
||||
static func live(idBridge: NostrIdentityBridge) -> Dependencies {
|
||||
Dependencies(
|
||||
notificationCenter: .default,
|
||||
@@ -26,7 +32,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
sendEvent: { NostrRelayManager.shared.sendEvent($0) },
|
||||
scheduleAfter: { delay, action in
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||
}
|
||||
},
|
||||
relayConnectivity: { NostrRelayManager.shared.$isDMRelayConnected.eraseToAnyPublisher() }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -49,6 +56,10 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
|
||||
// Reachability Cache (thread-safe)
|
||||
private var reachablePeers: Set<PeerID> = []
|
||||
// Mirror of the relay manager's connection state, cached here because
|
||||
// canDeliverPromptly is called synchronously off the main actor.
|
||||
private var relaysConnected = false
|
||||
private var relayConnectivityCancellable: AnyCancellable?
|
||||
private let queue = DispatchQueue(label: "nostr.transport.state", attributes: .concurrent)
|
||||
|
||||
@MainActor
|
||||
@@ -72,6 +83,12 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
queue.sync(flags: .barrier) {
|
||||
self.reachablePeers = Set(reachable)
|
||||
}
|
||||
|
||||
relayConnectivityCancellable = self.dependencies.relayConnectivity()
|
||||
.sink { [weak self] connected in
|
||||
guard let self else { return }
|
||||
self.queue.async(flags: .barrier) { self.relaysConnected = connected }
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -125,34 +142,32 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool { false }
|
||||
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||||
queue.sync {
|
||||
// Check if exact match
|
||||
// Callers address peers by either the short 16-hex ID or the full
|
||||
// 64-hex noise key (offline favorites), so compare in short form.
|
||||
let short = peerID.toShort()
|
||||
return queue.sync {
|
||||
if reachablePeers.contains(peerID) { return true }
|
||||
// Check for short ID match
|
||||
if peerID.isShort {
|
||||
return reachablePeers.contains(where: { $0.toShort() == peerID })
|
||||
}
|
||||
return false
|
||||
return reachablePeers.contains(where: { $0.toShort() == short })
|
||||
}
|
||||
}
|
||||
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool {
|
||||
// A known npub makes a peer "reachable", but with no relay
|
||||
// connection a send only joins the local queue. Answering honestly
|
||||
// here lets the router hand a sealed copy to a courier in parallel
|
||||
// instead of waiting for internet that may never come.
|
||||
isPeerReachable(peerID) && queue.sync { relaysConnected }
|
||||
}
|
||||
|
||||
func peerNickname(peerID: PeerID) -> String? { nil }
|
||||
func getPeerNicknames() -> [PeerID : String] { [:] }
|
||||
func getPeerNicknames() -> [PeerID: String] { [:] }
|
||||
|
||||
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 */ }
|
||||
|
||||
@@ -111,7 +111,7 @@ final class NotificationService {
|
||||
|
||||
func requestAuthorization() {
|
||||
guard !isRunningTests else { return }
|
||||
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, error in
|
||||
authorizer.requestAuthorization(options: [.alert, .sound, .badge]) { granted, _ in
|
||||
if granted {
|
||||
// Permission granted
|
||||
} else {
|
||||
|
||||
@@ -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,102 +202,82 @@ 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 {
|
||||
case .read, .delivered:
|
||||
externalReceipts.insert(message.id)
|
||||
sentReadReceipts.insert(message.id)
|
||||
case .failed, .partiallyDelivered, .sending, .sent:
|
||||
case .failed, .partiallyDelivered, .sending, .sent, .carried:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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)
|
||||
|
||||
@@ -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))
|
||||
}()
|
||||
|
||||
@@ -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
|
||||
}()
|
||||
}
|
||||
@@ -54,6 +54,11 @@ protocol Transport: AnyObject {
|
||||
// Connectivity and peers
|
||||
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||
func isPeerReachable(_ peerID: PeerID) -> Bool
|
||||
/// Whether a send to this peer is likely to leave the device promptly.
|
||||
/// Distinct from reachability: Nostr claims any favorite with a known
|
||||
/// npub as reachable even with no relay connection, where a send only
|
||||
/// joins a queue waiting for internet that may never come.
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool
|
||||
func peerNickname(peerID: PeerID) -> String?
|
||||
func getPeerNicknames() -> [PeerID: String]
|
||||
|
||||
@@ -61,7 +66,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])
|
||||
@@ -75,6 +100,12 @@ protocol Transport: AnyObject {
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String)
|
||||
func cancelTransfer(_ transferId: String)
|
||||
|
||||
// Courier store-and-forward (mesh transports only): seal a message to the
|
||||
// recipient's static key and hand it to connected couriers for physical
|
||||
// delivery while the recipient is offline. Returns false when the
|
||||
// transport cannot courier (no connected courier, or unsupported).
|
||||
func sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool
|
||||
|
||||
// QR verification (optional for transports)
|
||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data)
|
||||
@@ -85,8 +116,26 @@ protocol Transport: AnyObject {
|
||||
}
|
||||
|
||||
extension Transport {
|
||||
// Reachability implies prompt delivery for transports that hand packets
|
||||
// straight to the radio; queue-backed transports override this.
|
||||
func canDeliverPromptly(to peerID: PeerID) -> Bool { isPeerReachable(peerID) }
|
||||
|
||||
// Noise identity hooks default to inert for transports that do not carry
|
||||
// Noise 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 sendCourierMessage(_ content: String, messageID: String, recipientNoiseKey: Data, via couriers: [PeerID]) -> Bool { false }
|
||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||
func sendFilePrivate(_ packet: BitchatFilePacket, to peerID: PeerID, transferId: String) {}
|
||||
func cancelTransfer(_ transferId: String) {}
|
||||
|
||||
@@ -11,14 +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 geoTeleportedParticipantsCap: Int = 1337
|
||||
static let contentLRUCap: Int = 2000
|
||||
static let geoSamplingEventLRUCap: Int = 2000
|
||||
|
||||
// Timers
|
||||
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
|
||||
@@ -41,14 +44,27 @@ 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
|
||||
|
||||
// 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
|
||||
|
||||
@@ -77,19 +93,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 connected→reachable 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
|
||||
@@ -146,7 +164,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
|
||||
@@ -170,8 +208,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
|
||||
@@ -181,6 +221,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
|
||||
|
||||
@@ -86,45 +86,44 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
var enrichedPeers: [BitchatPeer] = []
|
||||
var connected: Set<PeerID> = []
|
||||
var addedPeerIDs: Set<PeerID> = []
|
||||
|
||||
var meshNoiseKeys: Set<Data> = []
|
||||
|
||||
// Phase 1: Add all mesh peers (connected and reachable)
|
||||
for peerInfo in meshPeers {
|
||||
let peerID = peerInfo.peerID
|
||||
guard peerID != meshService.myPeerID else { continue } // Never add self
|
||||
|
||||
|
||||
let peer = buildPeerFromMesh(
|
||||
peerInfo: peerInfo,
|
||||
favorites: favorites,
|
||||
meshAttached: hasAnyConnected
|
||||
)
|
||||
|
||||
|
||||
enrichedPeers.append(peer)
|
||||
if peer.isConnected { connected.insert(peerID) }
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
|
||||
// Update fingerprint cache
|
||||
if let publicKey = peerInfo.noisePublicKey {
|
||||
meshNoiseKeys.insert(publicKey)
|
||||
fingerprintCache[peerID] = publicKey.sha256Fingerprint()
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Add offline favorites that we actively favorite
|
||||
|
||||
// Phase 2: Add offline favorites that we actively favorite.
|
||||
// Mesh rows use the short 16-hex peer ID while favorites are keyed by
|
||||
// the full 32-byte noise key, so dedup must compare noise keys — a
|
||||
// PeerID comparison between the two forms can never match.
|
||||
for (favoriteKey, favorite) in favorites where favorite.isFavorite {
|
||||
if meshNoiseKeys.contains(favoriteKey) { continue }
|
||||
|
||||
let peerID = PeerID(hexData: favoriteKey)
|
||||
|
||||
// Skip if already added (connected peer)
|
||||
if addedPeerIDs.contains(peerID) { continue }
|
||||
|
||||
// Skip if connected under different ID but same nickname
|
||||
let isConnectedByNickname = enrichedPeers.contains {
|
||||
$0.nickname == favorite.peerNickname && $0.isConnected
|
||||
}
|
||||
if isConnectedByNickname { continue }
|
||||
|
||||
|
||||
let peer = buildPeerFromFavorite(favorite: favorite, peerID: peerID)
|
||||
enrichedPeers.append(peer)
|
||||
addedPeerIDs.insert(peerID)
|
||||
|
||||
|
||||
// Update fingerprint cache
|
||||
fingerprintCache[peerID] = favoriteKey.sha256Fingerprint()
|
||||
}
|
||||
@@ -257,7 +256,29 @@ final class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
/// Block or unblock a mesh peer by its stable Noise identity.
|
||||
///
|
||||
/// The block is keyed by the peer's fingerprint, resolved from `peerID`
|
||||
/// (cache / mesh session / known-peer Noise key). This works even when the
|
||||
/// peer is offline — including offline favorites — so the exact tapped peer
|
||||
/// is (un)blocked unambiguously instead of being re-resolved by a
|
||||
/// display-name string that two peers could share.
|
||||
/// - Returns: the resolved fingerprint, or `nil` if the identity is unknown.
|
||||
@discardableResult
|
||||
func setBlocked(_ peerID: PeerID, blocked: Bool) -> String? {
|
||||
guard let fingerprint = getFingerprint(for: peerID) else {
|
||||
SecureLogger.warning(
|
||||
"⚠️ Cannot \(blocked ? "block" : "unblock") - unknown identity for peer: \(peerID)",
|
||||
category: .session
|
||||
)
|
||||
return nil
|
||||
}
|
||||
identityManager.setBlocked(fingerprint, isBlocked: blocked)
|
||||
updatePeers()
|
||||
return fingerprint
|
||||
}
|
||||
|
||||
/// Toggle favorite status
|
||||
func toggleFavorite(_ peerID: PeerID) {
|
||||
guard let peer = getPeer(by: peerID) else {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -20,6 +20,9 @@ struct SyncTypeFlags: OptionSet {
|
||||
case .fragment: return 5
|
||||
case .requestSync: return 6
|
||||
case .fileTransfer: return 7
|
||||
// Courier envelopes are directed deposits between trusted peers and
|
||||
// must never spread via gossip sync.
|
||||
case .courierEnvelope: return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,4 +27,3 @@ struct PeerDisplayNameResolver {
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
)
|
||||
}
|
||||
@@ -252,9 +355,10 @@ private extension ChatLifecycleCoordinator {
|
||||
case .failed: return 1
|
||||
case .sending: return 2
|
||||
case .sent: return 3
|
||||
case .partiallyDelivered: return 4
|
||||
case .delivered: return 5
|
||||
case .read: return 6
|
||||
case .carried: return 4
|
||||
case .partiallyDelivered: return 5
|
||||
case .delivered: return 6
|
||||
case .read: return 7
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,28 +100,30 @@ 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 {
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_too_large", comment: "Failure reason shown when a voice note exceeds the size limit"))
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||
await MainActor.run {
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.handleMediaSendFailure(messageID: messageID, reason: String(localized: "content.delivery.reason.voice_send_failed", comment: "Failure reason shown when a voice note could not be sent"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,706 +1,62 @@
|
||||
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 (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?
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
let content = event.content.trimmed
|
||||
if hasTeleportTag && content.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
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 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 content = event.content
|
||||
if hasTeleportTag && content.trimmed.isEmpty {
|
||||
return
|
||||
}
|
||||
|
||||
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 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
|
||||
@@ -710,33 +66,26 @@ final class ChatNostrCoordinator {
|
||||
senderPubkey: String,
|
||||
key: Data?
|
||||
) {
|
||||
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)
|
||||
guard let context else { return }
|
||||
if key != nil {
|
||||
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 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 !wasReadBefore && context.selectedPrivateChatPeer == message.senderPeerID {
|
||||
if key != nil {
|
||||
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
|
||||
@@ -745,74 +94,26 @@ final class ChatNostrCoordinator {
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
||||
guard let senderNoiseKey = 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
|
||||
)
|
||||
}
|
||||
|
||||
var extractedNostrPubkey: String?
|
||||
if let range = content.range(of: "NPUB:") {
|
||||
let suffix = content[range.upperBound...]
|
||||
let parts = suffix.components(separatedBy: "|")
|
||||
if let key = parts.first {
|
||||
extractedNostrPubkey = String(key)
|
||||
}
|
||||
} else if content.contains(":") {
|
||||
let parts = content.components(separatedBy: ":")
|
||||
if parts.count >= 3 {
|
||||
extractedNostrPubkey = String(parts[2])
|
||||
}
|
||||
}
|
||||
|
||||
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
|
||||
|
||||
if isFavorite && extractedNostrPubkey != nil {
|
||||
SecureLogger.info(
|
||||
"💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...",
|
||||
category: .session
|
||||
)
|
||||
FavoritesPersistenceService.shared.addFavorite(
|
||||
peerNoisePublicKey: senderNoiseKey,
|
||||
peerNostrPublicKey: extractedNostrPubkey,
|
||||
peerNickname: senderNickname
|
||||
)
|
||||
}
|
||||
|
||||
NotificationService.shared.sendLocalNotification(
|
||||
title: isFavorite ? "New Favorite" : "Favorite Removed",
|
||||
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
|
||||
identifier: "fav-\(UUID().uuidString)"
|
||||
)
|
||||
}
|
||||
|
||||
@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
|
||||
@@ -820,38 +121,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] ?? []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
/// (ephemeral↔stable 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
|
||||
@@ -637,6 +988,48 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
)
|
||||
}
|
||||
|
||||
// Mesh (Noise identity) block helpers. Unlike the `/block <nickname>`
|
||||
// command, these resolve and persist the block by the peer's stable
|
||||
// fingerprint (derived from `peerID`), so the exact tapped peer is
|
||||
// (un)blocked — unambiguous across nickname collisions and functional for
|
||||
// offline peers that can no longer be resolved through the mesh service.
|
||||
@MainActor
|
||||
func blockMeshPeer(peerID: PeerID, displayName: String) {
|
||||
setMeshPeerBlocked(peerID, blocked: true, displayName: displayName)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func unblockMeshPeer(peerID: PeerID, displayName: String) {
|
||||
setMeshPeerBlocked(peerID, blocked: false, displayName: displayName)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func setMeshPeerBlocked(_ peerID: PeerID, blocked: Bool, displayName: String) {
|
||||
guard unifiedPeerService.setBlocked(peerID, blocked: blocked) != nil else {
|
||||
addCommandOutput(
|
||||
String(
|
||||
format: String(
|
||||
localized: blocked ? "system.mesh.block_failed" : "system.mesh.unblock_failed",
|
||||
comment: "System message shown when a mesh peer cannot be blocked or unblocked"
|
||||
),
|
||||
locale: .current,
|
||||
displayName
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
addCommandOutput(
|
||||
String(
|
||||
format: String(
|
||||
localized: blocked ? "system.mesh.blocked" : "system.mesh.unblocked",
|
||||
comment: "System message shown when a mesh peer is blocked or unblocked"
|
||||
),
|
||||
locale: .current,
|
||||
displayName
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
||||
publicConversationCoordinator.displayNameForNostrPubkey(pubkeyHex)
|
||||
}
|
||||
@@ -676,8 +1069,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 +1158,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 +1171,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()
|
||||
@@ -795,6 +1189,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
// Clear persistent favorites from keychain
|
||||
FavoritesPersistenceService.shared.clearAllFavorites()
|
||||
|
||||
// Drop courier mail carried for third parties (memory and disk)
|
||||
CourierStore.shared.wipe()
|
||||
|
||||
// Identity manager has cleared persisted identity data above
|
||||
|
||||
// Clear autocomplete state
|
||||
@@ -806,13 +1203,25 @@ 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()
|
||||
nostrRelayManager = nil
|
||||
|
||||
// Clear Nostr identity associations
|
||||
@@ -825,20 +1234,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 +1330,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 +1364,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 +1433,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? {
|
||||
@@ -1091,7 +1480,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
|
||||
/// Processes IRC-style commands starting with '/'.
|
||||
/// - Parameter command: The full command string including the leading slash
|
||||
/// - Note: Supports commands like /nick, /msg, /who, /slap, /clear, /help
|
||||
/// - Note: Supports commands like /msg, /who, /slap, /clear, /help
|
||||
@MainActor
|
||||
func handleCommand(_ command: String) {
|
||||
let result = commandProcessor.process(command)
|
||||
@@ -1099,16 +1488,29 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, TransportEventDele
|
||||
switch result {
|
||||
case .success(let message):
|
||||
if let msg = message {
|
||||
addSystemMessage(msg)
|
||||
addCommandOutput(msg)
|
||||
}
|
||||
case .error(let message):
|
||||
addSystemMessage(message)
|
||||
addCommandOutput(message)
|
||||
case .handled:
|
||||
// Command was handled, no message needed
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/// Command output belongs in the conversation where the user typed the
|
||||
/// command; the public timeline is invisible while a DM is open. The DM
|
||||
/// selection is read *after* processing so commands that switch chats
|
||||
/// (`/msg`) print into the conversation they just opened.
|
||||
@MainActor
|
||||
private func addCommandOutput(_ content: String) {
|
||||
if let peerID = selectedPrivateChatPeer {
|
||||
addLocalPrivateSystemMessage(content, to: peerID)
|
||||
} else {
|
||||
addSystemMessage(content)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Message Reception
|
||||
|
||||
@MainActor
|
||||
@@ -1172,6 +1574,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,54 @@ 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: String(localized: "content.delivery.reason.not_delivered", comment: "Failure reason shown when the router gave up delivering a message")),
|
||||
forMessageID: messageID
|
||||
)
|
||||
}
|
||||
}
|
||||
// A message with no reachable transport that was handed to a courier
|
||||
// shows a distinct "carried" state instead of sitting in "sending"
|
||||
// forever. Never downgrade a confirmed receipt: the courier copy can
|
||||
// race direct delivery when the peer reappears.
|
||||
viewModel.messageRouter.onMessageCarried = { [weak viewModel] messageID, peerID in
|
||||
guard let viewModel else { return }
|
||||
switch viewModel.conversations.deliveryStatus(forMessageID: messageID) {
|
||||
case .delivered, .read:
|
||||
break
|
||||
default:
|
||||
SecureLogger.debug(
|
||||
"📦 Message \(messageID.prefix(8))… for \(peerID.id.prefix(8))… handed to courier → marked carried",
|
||||
category: .session
|
||||
)
|
||||
viewModel.conversations.setDeliveryStatus(.carried, forMessageID: messageID)
|
||||
}
|
||||
}
|
||||
viewModel.commandProcessor.contextProvider = viewModel
|
||||
viewModel.commandProcessor.meshService = viewModel.meshService
|
||||
viewModel.participantTracker.configure(context: viewModel)
|
||||
@@ -89,49 +134,15 @@ 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()
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
|
||||
viewModel.participantTracker.$visiblePeople
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewModel] people in
|
||||
Task { @MainActor [weak viewModel] in
|
||||
viewModel?.locationPresenceStore.retainTeleportedGeo(
|
||||
keeping: Set(people.map { $0.id })
|
||||
)
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
}
|
||||
|
||||
func loadPersistedViewState() {
|
||||
@@ -155,7 +166,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,
|
||||
@@ -184,7 +194,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 {
|
||||
@@ -202,9 +211,6 @@ private extension ChatViewModelBootstrapper {
|
||||
if viewModel.hasTrackedPrivateChatSelection {
|
||||
viewModel.updatePrivateChatPeerIfNeeded()
|
||||
}
|
||||
|
||||
viewModel.synchronizePrivateConversationStore()
|
||||
viewModel.synchronizeConversationSelectionStore()
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
@@ -228,15 +234,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
|
||||
@@ -109,11 +109,6 @@ extension ChatViewModel {
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
||||
nostrCoordinator.handleFavoriteNotification(content: content, from: nostrPubkey)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||
nostrCoordinator.sendFavoriteNotificationViaNostr(noisePublicKey: noisePublicKey, isFavorite: isFavorite)
|
||||
|
||||
@@ -121,25 +121,6 @@ extension ChatViewModel {
|
||||
mediaTransferCoordinator.deleteMediaMessage(messageID: messageID)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handlePrivateMessage(
|
||||
_ payload: NoisePayload,
|
||||
actualSenderNoiseKey: Data?,
|
||||
senderNickname: String,
|
||||
targetPeerID: PeerID,
|
||||
messageTimestamp: Date,
|
||||
senderPubkey: String
|
||||
) {
|
||||
privateConversationCoordinator.handlePrivateMessage(
|
||||
payload,
|
||||
actualSenderNoiseKey: actualSenderNoiseKey,
|
||||
senderNickname: senderNickname,
|
||||
targetPeerID: targetPeerID,
|
||||
messageTimestamp: messageTimestamp,
|
||||
senderPubkey: senderPubkey
|
||||
)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handlePrivateMessage(_ message: BitchatMessage) {
|
||||
privateConversationCoordinator.handlePrivateMessage(message)
|
||||
@@ -190,8 +171,8 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||
privateConversationCoordinator.handleFavoriteNotificationFromMesh(
|
||||
func handleFavoriteNotification(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||
privateConversationCoordinator.handleFavoriteNotification(
|
||||
content,
|
||||
from: peerID,
|
||||
senderNickname: senderNickname
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
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
|
||||
}
|
||||
guard event.isValidSignature() else { return }
|
||||
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,499 @@
|
||||
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: raw relay events in, chat messages / Noise
|
||||
/// payloads out. Pure transformation plus dedup — no relay lifecycle.
|
||||
///
|
||||
/// Ordering is deliberate and performance-critical: cheap rejects (kind,
|
||||
/// dedup lookup) run BEFORE Schnorr signature verification because duplicates
|
||||
/// dominate real relay traffic; events are recorded only AFTER verification so
|
||||
/// a forged-signature copy can never poison the dedup set; gift-wrap
|
||||
/// verification for the account mailbox runs off-main 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
|
||||
|
||||
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) before Schnorr verification —
|
||||
// duplicates dominate real traffic and must not pay for crypto.
|
||||
// Only verified events are recorded, so a forged-signature copy can
|
||||
// never poison the dedup set and suppress the genuine event.
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||
!context.hasProcessedNostrEvent(event.id)
|
||||
else {
|
||||
return
|
||||
}
|
||||
guard event.isValidSignature() 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) before Schnorr verification —
|
||||
// duplicates dominate real traffic and must not pay for crypto.
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue
|
||||
|| event.kind == NostrProtocol.EventKind.geohashPresence.rawValue)
|
||||
else {
|
||||
return
|
||||
}
|
||||
if context.hasProcessedNostrEvent(event.id) { return }
|
||||
guard event.isValidSignature() else { 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 }
|
||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||
guard !context.hasProcessedNostrEvent(giftWrap.id) else { return }
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(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)
|
||||
context.registerNostrKeyMapping(senderPubkey, for: convKey)
|
||||
|
||||
switch noisePayload.type {
|
||||
case .privateMessage:
|
||||
context.handlePrivateMessage(
|
||||
noisePayload,
|
||||
senderPubkey: senderPubkey,
|
||||
convKey: convKey,
|
||||
id: id,
|
||||
messageTimestamp: messageTimestamp
|
||||
)
|
||||
case .delivered:
|
||||
context.handleDelivered(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .readReceipt:
|
||||
context.handleReadReceipt(noisePayload, senderPubkey: senderPubkey, convKey: convKey)
|
||||
case .verifyChallenge, .verifyResponse:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func handleGiftWrap(_ giftWrap: NostrEvent, id: NostrIdentity) {
|
||||
guard let context else { return }
|
||||
// Dedup lookup before Schnorr verification; record only after it passes.
|
||||
if context.hasProcessedNostrEvent(giftWrap.id) {
|
||||
return
|
||||
}
|
||||
guard giftWrap.isValidSignature() else { return }
|
||||
context.recordProcessedNostrEvent(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)
|
||||
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; Schnorr verification runs off-main in
|
||||
// processNostrMessage, which then does the authoritative
|
||||
// check-and-record. Recording stays after verification 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 giftWrap.isValidSignature() else { return }
|
||||
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 }
|
||||
let currentIdentity: NostrIdentity? = await MainActor.run {
|
||||
context.currentNostrIdentity()
|
||||
}
|
||||
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 {
|
||||
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.
|
||||
@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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
+101
-47
@@ -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"
|
||||
@@ -55,6 +55,26 @@ struct AppInfoView: View {
|
||||
)
|
||||
}
|
||||
|
||||
enum Legend {
|
||||
static let title: LocalizedStringKey = "app_info.legend.title"
|
||||
/// Every glyph the peer lists and headers use, in one place —
|
||||
/// nothing else in the app defines them.
|
||||
static let items: [(icon: String, text: LocalizedStringKey)] = [
|
||||
("antenna.radiowaves.left.and.right", "app_info.legend.mesh_connected"),
|
||||
("point.3.filled.connected.trianglepath.dotted", "app_info.legend.mesh_relayed"),
|
||||
("globe", "app_info.legend.nostr"),
|
||||
("person", "app_info.legend.offline"),
|
||||
("mappin.and.ellipse", "app_info.legend.location_nearby"),
|
||||
("face.dashed", "app_info.legend.teleported"),
|
||||
("lock.fill", "app_info.legend.encrypted"),
|
||||
("lock.slash", "app_info.legend.encryption_failed"),
|
||||
("checkmark.seal.fill", "app_info.legend.verified"),
|
||||
("star.fill", "app_info.legend.favorite"),
|
||||
("envelope.fill", "app_info.legend.unread"),
|
||||
("nosign", "app_info.legend.blocked")
|
||||
]
|
||||
}
|
||||
|
||||
enum Privacy {
|
||||
static let title: LocalizedStringKey = "app_info.privacy.title"
|
||||
static let noTracking = AppInfoFeatureInfo(
|
||||
@@ -101,12 +121,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,18 +134,12 @@ 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))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("app_info.close")
|
||||
SheetCloseButton { dismiss() }
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -138,16 +152,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 +195,7 @@ struct AppInfoView: View {
|
||||
Text(instruction)
|
||||
}
|
||||
}
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
|
||||
@@ -178,6 +216,28 @@ struct AppInfoView: View {
|
||||
FeatureRow(info: Strings.Features.mentions)
|
||||
}
|
||||
|
||||
// Symbols legend
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
SectionHeader(Strings.Legend.title)
|
||||
|
||||
ForEach(Strings.Legend.items, id: \.icon) { item in
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: item.icon)
|
||||
.font(.bitchatSystem(size: 14))
|
||||
.foregroundColor(textColor)
|
||||
.frame(width: 30)
|
||||
|
||||
Text(item.text)
|
||||
.bitchatFont(size: 13)
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
}
|
||||
|
||||
// Privacy
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
SectionHeader(Strings.Privacy.title)
|
||||
@@ -201,19 +261,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 +279,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 +294,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,59 +10,74 @@ 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
|
||||
|
||||
|
||||
/// The command already typed in full, once arguments have begun.
|
||||
private var typedCommandAlias: String? {
|
||||
guard messageText.hasPrefix("/"),
|
||||
let spaceIndex = messageText.firstIndex(of: " ")
|
||||
else { return nil }
|
||||
return String(messageText[..<spaceIndex]).lowercased()
|
||||
}
|
||||
|
||||
private var filteredCommands: [CommandInfo] {
|
||||
guard messageText.hasPrefix("/") && !messageText.contains(" ") else { return [] }
|
||||
guard messageText.hasPrefix("/") else { return [] }
|
||||
let isGeoPublic = locationChannelsModel.selectedChannel.isLocation
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
return CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM).filter { command in
|
||||
let commands = CommandInfo.all(isGeoPublic: isGeoPublic, isGeoDM: isGeoDM)
|
||||
// While arguments are being typed, keep the matched command's usage
|
||||
// row visible instead of vanishing at the first space.
|
||||
if let typed = typedCommandAlias {
|
||||
return commands.filter { $0.alias == typed && $0.placeholder != nil }
|
||||
}
|
||||
return commands.filter { command in
|
||||
command.alias.starts(with: messageText.lowercased())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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 {
|
||||
let isUsageReminder = typedCommandAlias != nil
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(filteredCommands) { command in
|
||||
Button {
|
||||
// In usage-reminder mode the row is informational; an
|
||||
// insert here would wipe the arguments being typed.
|
||||
guard !isUsageReminder else { return }
|
||||
messageText = command.alias + " "
|
||||
} label: {
|
||||
buttonRow(for: command)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.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 +96,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)
|
||||
}
|
||||
|
||||
@@ -9,47 +9,38 @@
|
||||
import SwiftUI
|
||||
import BitFoundation
|
||||
|
||||
struct DeliveryStatusView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
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 enum Strings {
|
||||
static func delivered(to nickname: String) -> String {
|
||||
String(
|
||||
extension DeliveryStatus {
|
||||
/// Localized, user-facing description of the status. Used for macOS
|
||||
/// tooltips, the tap-to-reveal caption under a message, and VoiceOver —
|
||||
/// the glyphs alone are unexplained 10pt icons.
|
||||
var bitchatDescription: String {
|
||||
switch self {
|
||||
case .sending:
|
||||
return String(localized: "content.delivery.sending", comment: "Delivery status description while a private message is being sent")
|
||||
case .sent:
|
||||
return String(localized: "content.delivery.sent", comment: "Delivery status description for a sent but not yet confirmed private message")
|
||||
case .carried:
|
||||
return String(localized: "content.delivery.carried", defaultValue: "Carried by a friend who may meet them", comment: "Delivery status description for messages handed to a courier for physical delivery")
|
||||
case .delivered(let nickname, _):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.delivered_to", comment: "Tooltip for delivered private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
|
||||
static func read(by nickname: String) -> String {
|
||||
String(
|
||||
case .read(let nickname, _):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.read_by", comment: "Tooltip for read private messages"),
|
||||
locale: .current,
|
||||
nickname
|
||||
)
|
||||
}
|
||||
|
||||
static func failed(_ reason: String) -> String {
|
||||
String(
|
||||
case .failed(let reason):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.failed", comment: "Tooltip for failed message delivery"),
|
||||
locale: .current,
|
||||
reason
|
||||
)
|
||||
}
|
||||
|
||||
static func deliveredToMembers(_ reached: Int, _ total: Int) -> String {
|
||||
String(
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
return String(
|
||||
format: String(localized: "content.delivery.delivered_members", comment: "Tooltip for partially delivered messages"),
|
||||
locale: .current,
|
||||
reached,
|
||||
@@ -57,22 +48,46 @@ struct DeliveryStatusView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
struct DeliveryStatusView: View {
|
||||
@ThemedPalette private var palette
|
||||
let status: DeliveryStatus
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
private var textColor: Color { palette.primary }
|
||||
|
||||
private var secondaryTextColor: Color { palette.secondary }
|
||||
|
||||
// MARK: - Body
|
||||
|
||||
|
||||
var body: some View {
|
||||
statusGlyph
|
||||
.help(status.bitchatDescription)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(status.bitchatDescription)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusGlyph: some View {
|
||||
switch status {
|
||||
case .sending:
|
||||
Image(systemName: "circle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
|
||||
case .sent:
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
|
||||
case .delivered(let nickname, _):
|
||||
|
||||
case .carried:
|
||||
Image(systemName: "figure.walk")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(secondaryTextColor.opacity(0.8))
|
||||
|
||||
case .delivered:
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
@@ -80,33 +95,30 @@ struct DeliveryStatusView: View {
|
||||
.font(.bitchatSystem(size: 10))
|
||||
}
|
||||
.foregroundColor(textColor.opacity(0.8))
|
||||
.help(Strings.delivered(to: nickname))
|
||||
|
||||
case .read(let nickname, _):
|
||||
HStack(spacing: -2) {
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
Image(systemName: "checkmark")
|
||||
.font(.bitchatSystem(size: 10, weight: .bold))
|
||||
|
||||
case .read:
|
||||
// Filled variant so read vs delivered is legible without color.
|
||||
HStack(spacing: 0) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.bitchatSystem(size: 9, weight: .bold))
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.font(.bitchatSystem(size: 9, weight: .bold))
|
||||
}
|
||||
.foregroundColor(Color(red: 0.0, green: 0.478, blue: 1.0)) // Bright blue
|
||||
.help(Strings.read(by: nickname))
|
||||
|
||||
case .failed(let reason):
|
||||
.foregroundColor(palette.accentBlue)
|
||||
|
||||
case .failed:
|
||||
Image(systemName: "exclamationmark.triangle")
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(Color.red.opacity(0.8))
|
||||
.help(Strings.failed(reason))
|
||||
|
||||
|
||||
case .partiallyDelivered(let reached, let total):
|
||||
HStack(spacing: 1) {
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -115,6 +127,7 @@ struct DeliveryStatusView: View {
|
||||
let statuses: [DeliveryStatus] = [
|
||||
.sending,
|
||||
.sent,
|
||||
.carried,
|
||||
.delivered(to: "John Doe", at: Date()),
|
||||
.read(by: "Jane Doe", at: Date()),
|
||||
.failed(reason: "Offline"),
|
||||
|
||||
@@ -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,11 +55,9 @@ 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)
|
||||
palette.secondary.opacity(colorScheme == .dark ? 0.18 : 0.12)
|
||||
}
|
||||
private var border: Color { fgColor.opacity(0.25) }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
//
|
||||
// SheetCloseButton.swift
|
||||
// bitchat
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// The close "X" every sheet and header shares. One glyph size and weight
|
||||
/// everywhere (the sheets had drifted across 12/13/14pt), a 32pt visual box
|
||||
/// so existing header metrics don't move, and a hit target extended to 44pt
|
||||
/// per platform guidelines. Tint comes from the environment, so callers keep
|
||||
/// their own foreground color.
|
||||
struct SheetCloseButton: View {
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: action) {
|
||||
Image(systemName: "xmark")
|
||||
.bitchatFont(size: 13, weight: .semibold)
|
||||
.frame(width: 32, height: 32)
|
||||
.contentShape(Rectangle().inset(by: -6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
|
||||
}
|
||||
}
|
||||
@@ -11,29 +11,85 @@ import BitFoundation
|
||||
|
||||
struct TextMessageView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme: ColorScheme
|
||||
@Environment(\.appTheme) private var theme
|
||||
@ThemedPalette private var palette
|
||||
@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> = []
|
||||
|
||||
@State private var showDeliveryDetail = false
|
||||
|
||||
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
|
||||
let cashuLinks = message.content.extractCashuLinks()
|
||||
let lightningLinks = message.content.extractLightningLinks()
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
// Baseline alignment keeps the lock and delivery glyphs on the
|
||||
// first text line; a fixed top padding left the lock's solid body
|
||||
// hanging below the line's visual center.
|
||||
HStack(alignment: .firstTextBaseline, spacing: 0) {
|
||||
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty
|
||||
let isExpanded = expandedMessageIDs.contains(message.id)
|
||||
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme))
|
||||
if message.isPrivate {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.bitchatSystem(size: 8))
|
||||
.foregroundColor(Color.orange.opacity(0.75))
|
||||
.padding(.trailing, 4)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme, theme: theme))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
|
||||
// Delivery status indicator for private messages
|
||||
// Delivery status indicator for private messages. Tappable:
|
||||
// .help() tooltips only exist on macOS, so iOS users get the
|
||||
// explanation as a caption under the row instead.
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = message.deliveryStatus {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
let status = deliveryStatus {
|
||||
Button {
|
||||
showDeliveryDetail.toggle()
|
||||
} label: {
|
||||
DeliveryStatusView(status: status)
|
||||
.padding(.leading, 4)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.delivery_detail_hint", comment: "Accessibility hint for the delivery status glyph explaining a tap reveals details")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Failure reasons stay visible without a tap; other statuses
|
||||
// reveal on demand.
|
||||
if message.isPrivate && conversationUIModel.isSentByCurrentUser(message),
|
||||
let status = deliveryStatus {
|
||||
if case .failed = status {
|
||||
Text(verbatim: status.bitchatDescription)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(Color.red.opacity(0.9))
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 2)
|
||||
} else if showDeliveryDetail {
|
||||
Text(verbatim: status.bitchatDescription)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.top, 2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +101,8 @@ struct TextMessageView: View {
|
||||
if isExpanded { expandedMessageIDs.remove(message.id) }
|
||||
else { expandedMessageIDs.insert(message.id) }
|
||||
}
|
||||
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(Color.blue)
|
||||
.bitchatFont(size: 11, weight: .medium)
|
||||
.foregroundColor(palette.accentBlue)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
|
||||
@@ -64,9 +120,19 @@ struct TextMessageView: View {
|
||||
.padding(.leading, 2)
|
||||
}
|
||||
}
|
||||
// Collapse the revealed caption when the status advances (e.g.
|
||||
// sending → sent → delivered) so a detail opened for one state
|
||||
// doesn't linger and silently morph into another.
|
||||
.onChange(of: deliveryStatus) { _ in
|
||||
showDeliveryDetail = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 +142,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 +169,4 @@ struct TextMessageView: View {
|
||||
}
|
||||
.environmentObject(conversationUIModel)
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -6,16 +6,15 @@ import UIKit
|
||||
struct ContentComposerView: View {
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@EnvironmentObject private var locationChannelsModel: LocationChannelsModel
|
||||
@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 +34,8 @@ struct ContentComposerView: View {
|
||||
}) {
|
||||
HStack {
|
||||
Text(suggestion)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.primary)
|
||||
.fontWeight(.medium)
|
||||
Spacer()
|
||||
}
|
||||
@@ -45,23 +44,13 @@ struct ContentComposerView: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.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
|
||||
@@ -71,14 +60,12 @@ struct ContentComposerView: View {
|
||||
TextField(
|
||||
"",
|
||||
text: $messageText,
|
||||
prompt: Text(
|
||||
String(localized: "content.input.message_placeholder", comment: "Placeholder shown in the chat composer")
|
||||
)
|
||||
.foregroundColor(secondaryTextColor.opacity(0.6))
|
||||
prompt: Text(placeholderText)
|
||||
.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 +73,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 +98,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()
|
||||
}
|
||||
@@ -124,6 +108,33 @@ struct ContentComposerView: View {
|
||||
}
|
||||
|
||||
private extension ContentComposerView {
|
||||
/// States where a message will land: the DM partner's name for private
|
||||
/// chats, the channel (and its public nature) otherwise — so a stressed
|
||||
/// user never has to guess who can read what they're typing.
|
||||
var placeholderText: String {
|
||||
if let header = privateConversationModel.selectedHeaderState {
|
||||
// A geohash-DM display name already carries its own "#geohash/@name"
|
||||
// form, so it must not get another "@" prefix; a mesh nickname does.
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
let target = isGeoDM ? header.displayName : "@\(header.displayName)"
|
||||
return String(
|
||||
format: String(localized: "content.input.placeholder.private", comment: "Composer placeholder inside a private chat, naming the conversation partner"),
|
||||
locale: .current,
|
||||
target
|
||||
)
|
||||
}
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .mesh:
|
||||
return String(localized: "content.input.placeholder.mesh", comment: "Composer placeholder for the public mesh channel")
|
||||
case .location(let channel):
|
||||
return String(
|
||||
format: String(localized: "content.input.placeholder.location", comment: "Composer placeholder for a public geohash channel, naming it"),
|
||||
locale: .current,
|
||||
channel.geohash
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var recordingIndicator: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: "waveform.circle.fill")
|
||||
@@ -134,7 +145,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 +165,7 @@ private extension ContentComposerView {
|
||||
}
|
||||
|
||||
var composerAccentColor: Color {
|
||||
privateConversationModel.selectedPeerID != nil ? Color.orange : textColor
|
||||
privateConversationModel.selectedPeerID != nil ? Color.orange : palette.accent
|
||||
}
|
||||
|
||||
var attachmentButton: some View {
|
||||
@@ -172,7 +183,19 @@ private extension ContentComposerView {
|
||||
imagePickerSourceType = .camera
|
||||
showImagePicker = true
|
||||
}
|
||||
.accessibilityLabel("Tap for library, long press for camera")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.attach_photo", comment: "Accessibility label for the photo attachment button")
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.attach_photo_hint", comment: "Accessibility hint explaining the attachment button opens the photo library")
|
||||
)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
// The long-press → camera path is unreachable for VoiceOver users;
|
||||
// mirror it as a named action.
|
||||
.accessibilityAction(named: Text("content.accessibility.take_photo", comment: "Accessibility action name for taking a photo with the camera")) {
|
||||
imagePickerSourceType = .camera
|
||||
showImagePicker = true
|
||||
}
|
||||
#else
|
||||
Button(action: { showMacImagePicker = true }) {
|
||||
Image(systemName: "photo.circle.fill")
|
||||
@@ -181,7 +204,9 @@ private extension ContentComposerView {
|
||||
.frame(width: 36, height: 36)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Choose photo")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.choose_photo", comment: "Accessibility label for the macOS photo picker button")
|
||||
)
|
||||
#endif
|
||||
}
|
||||
|
||||
@@ -223,7 +248,27 @@ private extension ContentComposerView {
|
||||
}
|
||||
)
|
||||
)
|
||||
.accessibilityLabel("Hold to record a voice note")
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.record_voice_note", comment: "Accessibility label for the voice note button")
|
||||
)
|
||||
.accessibilityValue(
|
||||
voiceRecordingVM.state.isActive
|
||||
? String(localized: "content.accessibility.recording", comment: "Accessibility value announced while a voice note is recording")
|
||||
: ""
|
||||
)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.record_voice_hint", comment: "Accessibility hint explaining double-tap toggles voice recording")
|
||||
)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
// Press-and-hold drag gestures can't be activated by VoiceOver;
|
||||
// give it a start/stop toggle as the default action.
|
||||
.accessibilityAction {
|
||||
if voiceRecordingVM.state.isActive {
|
||||
voiceRecordingVM.finish(completion: conversationUIModel.sendVoiceNote)
|
||||
} else {
|
||||
voiceRecordingVM.start(shouldShow: conversationUIModel.canSendMediaInCurrentContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendButtonView(enabled: Bool) -> some View {
|
||||
|
||||
@@ -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,26 +21,36 @@ struct ContentHeaderView: View {
|
||||
let headerHeight: CGFloat
|
||||
let headerPeerIconSize: CGFloat
|
||||
let headerPeerCountFontSize: CGFloat
|
||||
let backgroundColor: Color
|
||||
let textColor: Color
|
||||
let secondaryTextColor: Color
|
||||
|
||||
/// Courier envelopes this device is carrying for offline third parties.
|
||||
@State private var carriedMailCount = 0
|
||||
|
||||
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()
|
||||
}
|
||||
.onTapGesture(count: 1) {
|
||||
appChromeModel.presentAppInfo()
|
||||
}
|
||||
// This is the only entry point to App Info, but it reads as
|
||||
// static text; surface the tap. (The triple-tap panic wipe
|
||||
// stays undiscoverable on purpose — it's destructive.)
|
||||
.accessibilityAddTraits(.isButton)
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.app_info_hint", comment: "Accessibility hint on the bitchat/ logo explaining a tap opens app info")
|
||||
)
|
||||
.accessibilityAction {
|
||||
appChromeModel.presentAppInfo()
|
||||
}
|
||||
|
||||
HStack(spacing: 0) {
|
||||
Text(verbatim: "@")
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(palette.secondary)
|
||||
|
||||
TextField(
|
||||
"content.input.nickname_placeholder",
|
||||
@@ -49,9 +60,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 +90,30 @@ struct ContentHeaderView: View {
|
||||
return countAndColor.0
|
||||
}()
|
||||
|
||||
HStack(spacing: 10) {
|
||||
HStack(spacing: 2) {
|
||||
if carriedMailCount > 0 {
|
||||
Image(systemName: "figure.walk")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(palette.secondary.opacity(0.8))
|
||||
.headerTapTarget()
|
||||
.accessibilityLabel(
|
||||
String(
|
||||
format: String(localized: "content.accessibility.carrying_mail", defaultValue: "Carrying %lld sealed messages for friends", comment: "Accessibility label for the courier mail indicator"),
|
||||
locale: .current,
|
||||
carriedMailCount
|
||||
)
|
||||
)
|
||||
.help(
|
||||
String(localized: "content.header.carrying_mail", defaultValue: "Carrying sealed messages for friends to deliver", comment: "Tooltip for the courier mail indicator")
|
||||
)
|
||||
}
|
||||
|
||||
if appChromeModel.hasUnreadPrivateMessages {
|
||||
Button(action: { appChromeModel.openMostRelevantPrivateChat() }) {
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.bitchatSystem(size: 12))
|
||||
.foregroundColor(Color.orange)
|
||||
.headerTapTarget()
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
@@ -99,13 +128,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 +143,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,56 +167,76 @@ 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
|
||||
)
|
||||
)
|
||||
// Connected-vs-nobody is otherwise encoded only in the icon's
|
||||
// color; say it.
|
||||
.accessibilityValue(
|
||||
headerPeersReachable
|
||||
? String(localized: "content.accessibility.peers_connected", comment: "Accessibility value when peers are reachable")
|
||||
: String(localized: "content.accessibility.peers_none", comment: "Accessibility value when no peers are reachable")
|
||||
)
|
||||
}
|
||||
.layoutPriority(3)
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
showSidebar.toggle()
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showVerifySheet) {
|
||||
VerificationSheetView(isPresented: $showVerifySheet)
|
||||
.environmentObject(verificationModel)
|
||||
}
|
||||
}
|
||||
// Fixed height is load-bearing: children fill the bar with
|
||||
// .frame(maxHeight: .infinity) tap targets, so an open-ended
|
||||
// minHeight lets the header expand to swallow the whole screen.
|
||||
// headerHeight is a @ScaledMetric, so it still grows with Dynamic
|
||||
// Type.
|
||||
.frame(height: headerHeight)
|
||||
.padding(.horizontal, 12)
|
||||
.onReceive(CourierStore.shared.$carriedCount) { count in
|
||||
carriedMailCount = count
|
||||
}
|
||||
.sheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented) {
|
||||
LocationChannelsSheet(isPresented: $appChromeModel.isLocationChannelsSheetPresented)
|
||||
.environmentObject(locationChannelsModel)
|
||||
@@ -208,10 +255,7 @@ struct ContentHeaderView: View {
|
||||
} else {
|
||||
ContentLocationNotesUnavailableView(
|
||||
showLocationNotes: $showLocationNotes,
|
||||
headerHeight: headerHeight,
|
||||
backgroundColor: backgroundColor,
|
||||
textColor: textColor,
|
||||
secondaryTextColor: secondaryTextColor
|
||||
headerHeight: headerHeight
|
||||
)
|
||||
.environmentObject(locationChannelsModel)
|
||||
}
|
||||
@@ -249,7 +293,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())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -258,15 +311,25 @@ private extension ContentHeaderView {
|
||||
dynamicTypeSize.isAccessibilitySize ? 2 : 1
|
||||
}
|
||||
|
||||
/// Whether anyone is actually reachable on the current channel — the
|
||||
/// state the count icon's color encodes visually.
|
||||
var headerPeersReachable: Bool {
|
||||
switch locationChannelsModel.selectedChannel {
|
||||
case .location:
|
||||
return peerListModel.visibleGeohashPeerCount > 0
|
||||
case .mesh:
|
||||
return peerListModel.connectedMeshPeerCount > 0
|
||||
}
|
||||
}
|
||||
|
||||
func channelPeopleCountAndColor() -> (Int, Color) {
|
||||
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 : palette.secondary)
|
||||
case .mesh:
|
||||
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
|
||||
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : Color.secondary
|
||||
let color: Color = peerListModel.connectedMeshPeerCount > 0 ? meshBlue : palette.secondary
|
||||
return (peerListModel.reachableMeshPeerCount, color)
|
||||
}
|
||||
}
|
||||
@@ -274,42 +337,34 @@ 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)
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(String(localized: "common.close", comment: "Accessibility label for close buttons"))
|
||||
SheetCloseButton { showLocationNotes = false }
|
||||
.foregroundColor(palette.primary)
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
.frame(minHeight: 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -145,15 +134,14 @@ private struct ContentPeopleListView: View {
|
||||
@EnvironmentObject private var appChromeModel: AppChromeModel
|
||||
@EnvironmentObject private var privateConversationModel: PrivateConversationModel
|
||||
@EnvironmentObject private var verificationModel: VerificationModel
|
||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||
@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 +151,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 }) {
|
||||
@@ -172,24 +160,23 @@ private struct ContentPeopleListView: View {
|
||||
.font(.bitchatSystem(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
// .help maps to the accessibility *hint* on iOS, so the
|
||||
// button still needs a spoken name.
|
||||
.accessibilityLabel(
|
||||
String(localized: "content.accessibility.verification", comment: "Accessibility label for the verification QR button")
|
||||
)
|
||||
.help(
|
||||
String(localized: "content.help.verification", comment: "Help text for verification button")
|
||||
)
|
||||
}
|
||||
Button(action: {
|
||||
SheetCloseButton {
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
dismiss()
|
||||
showSidebar = false
|
||||
showVerifySheet = false
|
||||
privateConversationModel.endConversation()
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
|
||||
let activeText = String.localizedStringWithFormat(
|
||||
@@ -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
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -211,34 +198,30 @@ private struct ContentPeopleListView: View {
|
||||
Text(subtitle)
|
||||
.foregroundColor(subtitleColor)
|
||||
Text(activeText)
|
||||
.foregroundColor(.secondary)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.bitchatFont(size: 12)
|
||||
} else {
|
||||
Text(activeText)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.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
|
||||
@@ -248,6 +231,13 @@ private struct ContentPeopleListView: View {
|
||||
},
|
||||
onShowFingerprint: { peerID in
|
||||
appChromeModel.showFingerprint(for: peerID)
|
||||
},
|
||||
onToggleBlock: { peer in
|
||||
if peer.isBlocked {
|
||||
conversationUIModel.unblock(peerID: peer.peerID, displayName: peer.displayName)
|
||||
} else {
|
||||
conversationUIModel.block(peerID: peer.peerID, displayName: peer.displayName)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -302,10 +292,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 +316,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 +330,7 @@ private struct ContentPrivateChatSheetView: View {
|
||||
HStack(spacing: 8) {
|
||||
ContentPrivateHeaderInfoButton(
|
||||
headerState: headerState,
|
||||
headerHeight: headerHeight,
|
||||
textColor: textColor
|
||||
headerHeight: headerHeight
|
||||
)
|
||||
|
||||
if headerState.supportsFavoriteToggle {
|
||||
@@ -351,7 +339,10 @@ 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)
|
||||
// Same visual box + 44pt hit target as SheetCloseButton.
|
||||
.frame(width: 32, height: 32)
|
||||
.contentShape(Rectangle().inset(by: -6))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
@@ -365,24 +356,20 @@ private struct ContentPrivateChatSheetView: View {
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
Button(action: {
|
||||
SheetCloseButton {
|
||||
withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
|
||||
privateConversationModel.endConversation()
|
||||
showSidebar = true
|
||||
}
|
||||
}) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
// minHeight so scaled text at accessibility sizes grows the
|
||||
// bar instead of clipping inside it.
|
||||
.frame(minHeight: headerHeight)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 10)
|
||||
.padding(.bottom, 12)
|
||||
.background(backgroundColor)
|
||||
.modifier(PrivateHeaderChrome())
|
||||
}
|
||||
|
||||
MessageListView(
|
||||
@@ -397,10 +384,14 @@ private struct ContentPrivateChatSheetView: View {
|
||||
showSidebar: $showSidebar,
|
||||
isTextFieldFocused: isTextFieldFocused
|
||||
)
|
||||
.background(backgroundColor)
|
||||
.themedSurface()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
Divider()
|
||||
if !theme.usesGlassChrome {
|
||||
Divider()
|
||||
}
|
||||
|
||||
privacyCaption
|
||||
|
||||
#if os(iOS)
|
||||
ContentComposerView(
|
||||
@@ -408,9 +399,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 +409,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
|
||||
@@ -444,14 +429,77 @@ private struct ContentPrivateChatSheetView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Persistent one-line reminder that this composer feeds a private
|
||||
/// conversation — the DM sheet otherwise renders identically to the
|
||||
/// public timeline. Claims end-to-end encryption only once the session
|
||||
/// is actually secured.
|
||||
private var privacyCaption: some View {
|
||||
HStack(spacing: 5) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.bitchatSystem(size: 9))
|
||||
// Optical centering: lock.fill's ink is bottom-heavy, so
|
||||
// geometric centering reads low next to the caption text.
|
||||
.offset(y: -1)
|
||||
Text(verbatim: privacyCaptionText)
|
||||
.bitchatFont(size: 11, weight: .medium)
|
||||
}
|
||||
.foregroundColor(Color.orange)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 4)
|
||||
// The orange text is signature enough; a tinted band here reads as a
|
||||
// stray strip against the untinted composer chrome below it, so the
|
||||
// caption sits on the same surface as the rest of the bottom chrome.
|
||||
.themedSurface()
|
||||
.accessibilityElement(children: .combine)
|
||||
}
|
||||
|
||||
private var privacyCaptionText: String {
|
||||
// Geohash DMs are NIP-17 gift-wrapped — always end-to-end encrypted,
|
||||
// even though they carry no Noise session status. Mesh DMs earn the
|
||||
// "encrypted" claim only once the Noise handshake has secured.
|
||||
let isGeoDM = privateConversationModel.selectedPeerID?.isGeoDM == true
|
||||
let noiseSecured: Bool = {
|
||||
switch privateConversationModel.selectedHeaderState?.encryptionStatus {
|
||||
case .noiseSecured, .noiseVerified: return true
|
||||
default: return false
|
||||
}
|
||||
}()
|
||||
if isGeoDM || noiseSecured {
|
||||
return String(localized: "content.private.caption_encrypted", comment: "Caption above the private chat composer once the session is end-to-end encrypted")
|
||||
}
|
||||
return String(localized: "content.private.caption", comment: "Caption above the private chat composer before encryption is established")
|
||||
}
|
||||
}
|
||||
|
||||
/// Chrome for the private-chat header. Matrix keeps its orange privacy wash
|
||||
/// over an opaque themed surface. Glass gets the same floating panel as the
|
||||
/// main header instead: an orange wash over the backdrop gradient reads as a
|
||||
/// muddy gray-beige band, and the DM signature is already carried by the
|
||||
/// orange lock, caption, and composer accents.
|
||||
private struct PrivateHeaderChrome: ViewModifier {
|
||||
@Environment(\.appTheme) private var theme
|
||||
|
||||
@ViewBuilder
|
||||
func body(content: Content) -> some View {
|
||||
if theme.usesGlassChrome {
|
||||
content.themedChromePanel(edge: .top)
|
||||
} else {
|
||||
// Orange tint before themedSurface so it layers in front of the
|
||||
// opaque themed background rather than behind it.
|
||||
content
|
||||
.background(Color.orange.opacity(0.06))
|
||||
.themedSurface()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct ContentPrivateHeaderInfoButton: View {
|
||||
@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 +510,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")
|
||||
@@ -475,20 +523,33 @@ private struct ContentPrivateHeaderInfoButton: View {
|
||||
.foregroundColor(.purple)
|
||||
.accessibilityLabel(String(localized: "content.accessibility.available_nostr", comment: "Accessibility label for Nostr-available peer indicator"))
|
||||
case .offline:
|
||||
EmptyView()
|
||||
// Absence of a glyph was the only offline signal; say it.
|
||||
Text("mesh_peers.state.offline")
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
|
||||
Text(headerState.displayName)
|
||||
.font(.bitchatSystem(size: 16, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
.bitchatFont(size: 16, weight: .medium)
|
||||
.foregroundColor(palette.primary)
|
||||
// Middle truncation keeps the identity suffix visible on
|
||||
// long nicknames instead of wrapping into the fixed-height
|
||||
// header.
|
||||
.lineLimit(1)
|
||||
.truncationMode(.middle)
|
||||
|
||||
if let encryptionStatus = headerState.encryptionStatus,
|
||||
let icon = encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.bitchatSystem(size: 14))
|
||||
// Optical centering: the lock glyphs' ink is bottom-heavy
|
||||
// (solid body, thin shackle), so geometric centering reads
|
||||
// ~1pt low next to the name. The seal badge is symmetric
|
||||
// and needs no lift.
|
||||
.offset(y: icon.hasPrefix("lock") ? -1 : 0)
|
||||
.foregroundColor(
|
||||
encryptionStatus == .noiseVerified || encryptionStatus == .noiseSecured
|
||||
? textColor
|
||||
? palette.primary
|
||||
: Color.red
|
||||
)
|
||||
.accessibilityLabel(
|
||||
@@ -512,6 +573,6 @@ private struct ContentPrivateHeaderInfoButton: View {
|
||||
.accessibilityHint(
|
||||
String(localized: "content.accessibility.view_fingerprint_hint", comment: "Accessibility hint for viewing encryption fingerprint")
|
||||
)
|
||||
.frame(height: headerHeight)
|
||||
.frame(minHeight: headerHeight)
|
||||
}
|
||||
}
|
||||
|
||||
+100
-86
@@ -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 }
|
||||
|
||||
|
||||
@@ -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,16 +49,13 @@ struct FingerprintView: View {
|
||||
// Header
|
||||
HStack {
|
||||
Text(Strings.title)
|
||||
.font(.bitchatSystem(size: 16, weight: .bold, design: .monospaced))
|
||||
.bitchatFont(size: 16, weight: .bold)
|
||||
.foregroundColor(textColor)
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 14, weight: .semibold))
|
||||
}
|
||||
.foregroundColor(textColor)
|
||||
SheetCloseButton { dismiss() }
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
.padding()
|
||||
|
||||
@@ -76,36 +69,36 @@ 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))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding()
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.background(palette.secondary.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
|
||||
// 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)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.background(palette.secondary.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
Button(Strings.copy) {
|
||||
@@ -119,7 +112,7 @@ struct FingerprintView: View {
|
||||
}
|
||||
} else {
|
||||
Text(Strings.handshakePending)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(Color.orange)
|
||||
.padding()
|
||||
}
|
||||
@@ -128,18 +121,18 @@ 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)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(Color.gray.opacity(0.1))
|
||||
.background(palette.secondary.opacity(0.1))
|
||||
.cornerRadius(8)
|
||||
.contextMenu {
|
||||
Button(Strings.copy) {
|
||||
@@ -157,7 +150,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 +161,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 +174,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 +188,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 +209,7 @@ struct FingerprintView: View {
|
||||
}
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(backgroundColor)
|
||||
.themedSheetBackground()
|
||||
}
|
||||
|
||||
private func formatFingerprint(_ fingerprint: String) -> String {
|
||||
|
||||
@@ -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] = []
|
||||
@@ -14,14 +13,21 @@ struct GeohashPeopleList: View {
|
||||
static let blockedTooltip = String(localized: "geohash_people.tooltip.blocked", comment: "Tooltip shown next to users blocked in geohash channels")
|
||||
static let unblock: LocalizedStringKey = "geohash_people.action.unblock"
|
||||
static let block: LocalizedStringKey = "geohash_people.action.block"
|
||||
static let unblockText = String(localized: "geohash_people.action.unblock", comment: "Context menu action to unblock a person")
|
||||
static let blockText = String(localized: "geohash_people.action.block", comment: "Context menu action to block a person")
|
||||
static let teleported = String(localized: "geohash_people.state.teleported", comment: "State label for someone who joined the location channel from elsewhere")
|
||||
static let nearby = String(localized: "geohash_people.state.nearby", comment: "State label for someone physically in the location channel's area")
|
||||
static let blockedState = String(localized: "mesh_peers.state.blocked", comment: "State label for a blocked peer")
|
||||
static let youState = String(localized: "geohash_people.state.you", comment: "State label marking your own row in the people list")
|
||||
static let openDMHint = String(localized: "mesh_peers.accessibility.open_dm_hint", comment: "Accessibility hint on a peer row explaining activation opens a private chat")
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
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)
|
||||
}
|
||||
@@ -47,23 +53,30 @@ struct GeohashPeopleList: View {
|
||||
let icon = person.isTeleported ? "face.dashed" : "mappin.and.ellipse"
|
||||
let assignedColor = peerListModel.colorForGeohashPerson(id: person.id, isDark: colorScheme == .dark)
|
||||
let rowColor: Color = person.isMe ? .orange : assignedColor
|
||||
Image(systemName: icon).font(.bitchatSystem(size: 12)).foregroundColor(rowColor)
|
||||
Image(systemName: icon)
|
||||
// Size 10 to match the mesh rows' leading glyphs —
|
||||
// both lists share the sidebar.
|
||||
.font(.bitchatSystem(size: 10))
|
||||
.foregroundColor(rowColor)
|
||||
.help(person.isTeleported ? Strings.teleported : Strings.nearby)
|
||||
|
||||
let (base, suffix) = person.displayName.splitSuffix()
|
||||
HStack(spacing: 0) {
|
||||
Text(base)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.bitchatFont(size: 14)
|
||||
.fontWeight(person.isMe ? .bold : .regular)
|
||||
.foregroundColor(rowColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -106,6 +119,27 @@ struct GeohashPeopleList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(accessibilityDescription(for: person))
|
||||
.accessibilityAddTraits(person.isMe ? [] : .isButton)
|
||||
.accessibilityHint(person.isMe ? "" : Strings.openDMHint)
|
||||
.accessibilityActions {
|
||||
if !person.isMe {
|
||||
Button(person.isBlocked ? Strings.unblockText : Strings.blockText) {
|
||||
if person.isBlocked {
|
||||
peerListModel.unblockGeohashUser(
|
||||
pubkeyHexLowercased: person.id,
|
||||
displayName: person.displayName
|
||||
)
|
||||
} else {
|
||||
peerListModel.blockGeohashUser(
|
||||
pubkeyHexLowercased: person.id,
|
||||
displayName: person.displayName
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Seed and update order outside result builder
|
||||
@@ -120,4 +154,13 @@ struct GeohashPeopleList: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One spoken sentence per row: name, presence type, and block state.
|
||||
private func accessibilityDescription(for person: GeohashPersonRow) -> String {
|
||||
var parts: [String] = [person.displayName]
|
||||
if person.isMe { parts.append(Strings.youState) }
|
||||
parts.append(person.isTeleported ? Strings.teleported : Strings.nearby)
|
||||
if person.isBlocked { parts.append(Strings.blockedState) }
|
||||
return parts.joined(separator: ", ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
@@ -31,6 +31,9 @@ struct LocationChannelsSheet: View {
|
||||
static let toggleOff: LocalizedStringKey = "common.toggle.off"
|
||||
|
||||
static let invalidGeohash = String(localized: "location_channels.error.invalid_geohash", comment: "Error shown when a custom geohash is invalid")
|
||||
static let switchChannelHint = String(localized: "location_channels.accessibility.switch_hint", comment: "Accessibility hint on a channel row explaining activation switches to it")
|
||||
static let addBookmark = String(localized: "location_channels.accessibility.add_bookmark", comment: "Accessibility action name for bookmarking a channel")
|
||||
static let removeBookmark = String(localized: "location_channels.accessibility.remove_bookmark", comment: "Accessibility action name for removing a channel bookmark")
|
||||
|
||||
static func meshTitle(_ count: Int) -> String {
|
||||
let label = String(localized: "location_channels.mesh_label", comment: "Label for the mesh channel row")
|
||||
@@ -97,20 +100,20 @@ 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))
|
||||
.foregroundColor(.secondary)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
|
||||
Group {
|
||||
switch locationChannelsModel.permissionState {
|
||||
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,8 +124,8 @@ struct LocationChannelsSheet: View {
|
||||
case .denied, .restricted:
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Strings.permissionDenied)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
Button(Strings.openSettings, action: SystemSettings.location.open)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
@@ -136,7 +139,7 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.background(backgroundColor)
|
||||
.themedSurface()
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(true)
|
||||
@@ -147,7 +150,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 {
|
||||
@@ -169,13 +172,7 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button(action: { isPresented = false }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Close")
|
||||
SheetCloseButton { isPresented = false }
|
||||
}
|
||||
|
||||
private var channelList: some View {
|
||||
@@ -210,7 +207,10 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
.accessibilityLabel(locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark)
|
||||
},
|
||||
accessoryActionTitle: locationChannelsModel.isBookmarked(channel.geohash) ? Strings.removeBookmark : Strings.addBookmark,
|
||||
accessoryAction: { locationChannelsModel.toggleBookmark(channel.geohash) }
|
||||
) {
|
||||
locationChannelsModel.markTeleported(for: channel.geohash, false)
|
||||
locationChannelsModel.select(ChannelID.location(channel))
|
||||
@@ -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,23 +270,21 @@ 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))
|
||||
.foregroundColor(.secondary)
|
||||
.bitchatFont(size: 14)
|
||||
.foregroundColor(palette.secondary)
|
||||
TextField("geohash", text: $customGeohash)
|
||||
#if os(iOS)
|
||||
.textInputAutocapitalization(.never)
|
||||
.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,23 +310,23 @@ 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))
|
||||
.background(palette.secondary.opacity(0.12))
|
||||
.cornerRadius(6)
|
||||
.opacity(isValid ? 1.0 : 0.4)
|
||||
.disabled(!isValid)
|
||||
}
|
||||
if let err = customError {
|
||||
Text(err)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
@@ -337,8 +335,8 @@ struct LocationChannelsSheet: View {
|
||||
private func bookmarkedSection(_ entries: [String]) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(Strings.bookmarked)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(entries.enumerated()), id: \.offset) { index, gh in
|
||||
let level = levelForLength(gh.count)
|
||||
@@ -359,7 +357,10 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
.accessibilityLabel(locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark)
|
||||
},
|
||||
accessoryActionTitle: locationChannelsModel.isBookmarked(gh) ? Strings.removeBookmark : Strings.addBookmark,
|
||||
accessoryAction: { locationChannelsModel.toggleBookmark(gh) }
|
||||
) {
|
||||
let inRegional = locationChannelsModel.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !locationChannelsModel.availableChannels.isEmpty {
|
||||
@@ -401,6 +402,8 @@ struct LocationChannelsSheet: View {
|
||||
titleColor: Color? = nil,
|
||||
titleBold: Bool = false,
|
||||
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
|
||||
accessoryActionTitle: String? = nil,
|
||||
accessoryAction: (() -> Void)? = nil,
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
@@ -409,26 +412,26 @@ 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)
|
||||
.foregroundColor(titleColor ?? palette.primary)
|
||||
if let count = parts.countSuffix, !count.isEmpty {
|
||||
Text(count)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
let subtitleFull = Strings.subtitle(prefix: subtitlePrefix, name: subtitleName)
|
||||
Text(subtitleFull)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Text(verbatim: "✔︎")
|
||||
.font(.bitchatSystem(size: 16, design: .monospaced))
|
||||
.bitchatFont(size: 16)
|
||||
.foregroundColor(standardGreen)
|
||||
}
|
||||
trailingAccessory()
|
||||
@@ -436,6 +439,19 @@ struct LocationChannelsSheet: View {
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: action)
|
||||
// The row is a plain HStack with a tap gesture, which VoiceOver reads
|
||||
// as disconnected static text. Expose it as one activatable button;
|
||||
// the visible bookmark accessory is mirrored as a named action.
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(Text(verbatim: "\(title), \(Strings.subtitle(prefix: subtitlePrefix, name: subtitleName))"))
|
||||
.accessibilityAddTraits(isSelected ? [.isButton, .isSelected] : [.isButton])
|
||||
.accessibilityHint(Strings.switchChannelHint)
|
||||
.accessibilityAction(.default, action)
|
||||
.accessibilityActions {
|
||||
if let accessoryActionTitle, let accessoryAction {
|
||||
Button(accessoryActionTitle, action: accessoryAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||||
@@ -478,26 +494,22 @@ extension LocationChannelsSheet {
|
||||
Toggle(isOn: torToggleBinding) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(Strings.torTitle)
|
||||
.font(.bitchatSystem(size: 12, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(.primary)
|
||||
.bitchatFont(size: 12, weight: .semibold)
|
||||
.foregroundColor(palette.primary)
|
||||
Text(Strings.torSubtitle)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.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))
|
||||
.background(palette.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 +524,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)
|
||||
|
||||
@@ -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,12 +25,11 @@ 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 {
|
||||
static let closeAccessibility = String(localized: "common.close", comment: "Accessibility label for close buttons")
|
||||
static let description: LocalizedStringKey = "location_notes.description"
|
||||
static let loadingRecent: LocalizedStringKey = "location_notes.loading_recent"
|
||||
static let relaysPaused: LocalizedStringKey = "location_notes.relays_paused"
|
||||
@@ -53,11 +52,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 +75,7 @@ struct LocationNotesView: View {
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
inputSection
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.themedSurface()
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarHidden(true)
|
||||
@@ -84,7 +83,7 @@ struct LocationNotesView: View {
|
||||
.navigationTitle("")
|
||||
#endif
|
||||
}
|
||||
.background(backgroundColor)
|
||||
.themedSheetBackground()
|
||||
.onDisappear { manager.cancel() }
|
||||
.onChange(of: geohash) { newValue in
|
||||
manager.setGeohash(newValue)
|
||||
@@ -97,13 +96,7 @@ struct LocationNotesView: View {
|
||||
}
|
||||
|
||||
private var closeButton: some View {
|
||||
Button(action: { dismiss() }) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.bitchatSystem(size: 13, weight: .semibold, design: .monospaced))
|
||||
.frame(width: 32, height: 32)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(Strings.closeAccessibility)
|
||||
SheetCloseButton { dismiss() }
|
||||
}
|
||||
|
||||
private var headerSection: some View {
|
||||
@@ -111,33 +104,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))
|
||||
.foregroundColor(.secondary)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
if manager.state == .noRelays {
|
||||
Text(Strings.relaysPaused)
|
||||
.font(.bitchatSystem(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 12)
|
||||
.background(backgroundColor)
|
||||
.themedSurface()
|
||||
}
|
||||
|
||||
private func headerTitle(for count: Int) -> String {
|
||||
@@ -176,16 +169,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))
|
||||
.foregroundColor(.secondary)
|
||||
.bitchatFont(size: 11)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
Text(note.content)
|
||||
.font(.bitchatSystem(size: 14, design: .monospaced))
|
||||
.bitchatFont(size: 14)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
@@ -194,12 +187,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))
|
||||
.foregroundColor(.secondary)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
Button(Strings.retry) { manager.refresh() }
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.bitchatFont(size: 12)
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
@@ -209,8 +202,8 @@ struct LocationNotesView: View {
|
||||
HStack(spacing: 10) {
|
||||
ProgressView()
|
||||
Text(Strings.loadingNotes)
|
||||
.font(.bitchatSystem(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
@@ -219,10 +212,10 @@ 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))
|
||||
.foregroundColor(.secondary)
|
||||
.bitchatFont(size: 12)
|
||||
.foregroundColor(palette.secondary)
|
||||
}
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
@@ -231,13 +224,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 +240,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 +254,7 @@ struct LocationNotesView: View {
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 14)
|
||||
.background(backgroundColor)
|
||||
.themedSurface()
|
||||
.overlay(Divider(), alignment: .top)
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user