mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 09:05:20 +00:00
Compare commits
60
Commits
refactor/noise
...
v1.5.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
3eb4f2bd72 | ||
|
|
193cfdc06a | ||
|
|
ffa0d7aa4f | ||
|
|
9e84f5e822 | ||
|
|
df36b19afe | ||
|
|
ab0da61533 | ||
|
|
3be8fbf1c4 | ||
|
|
764f016d17 | ||
|
|
a6cb8872fb | ||
|
|
3daf02732a | ||
|
|
0d1cdc7644 | ||
|
|
43eef0d49c | ||
|
|
bafa461f26 | ||
|
|
602d2316ef |
@@ -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,9 @@ jobs:
|
|||||||
test:
|
test:
|
||||||
name: Run Swift Tests (${{ matrix.name }})
|
name: Run Swift Tests (${{ matrix.name }})
|
||||||
runs-on: macos-latest
|
runs-on: macos-latest
|
||||||
|
# A hung test must fail fast, not hold a runner for GitHub's 360-minute
|
||||||
|
# default (observed: intermittent app-suite hangs starving the queue).
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false # Don't cancel other matrix jobs when one fails
|
fail-fast: false # Don't cancel other matrix jobs when one fails
|
||||||
@@ -38,5 +41,102 @@ jobs:
|
|||||||
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
${{ runner.os }}-${{ matrix.name }}-${{ hashFiles(format('{0}/**/Package.resolved', matrix.path)) }}
|
||||||
${{ runner.os }}-${{ matrix.name }}-
|
${{ runner.os }}-${{ 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
|
- name: Run Tests
|
||||||
run: swift test --parallel --quiet --package-path ${{ matrix.path }}
|
# Perf benchmarks are excluded here and run in their own serial step
|
||||||
|
# below: measuring while parallel test processes contend for cores
|
||||||
|
# produces noisy numbers, and the XCTest measure machinery has hung
|
||||||
|
# intermittently under parallel workers on loaded runners. Excluded
|
||||||
|
# via --skip (not just the env guard): every app run since the
|
||||||
|
# baselines landed timed out at the 15-minute job limit with the
|
||||||
|
# baseline tests dispatched into the parallel phase.
|
||||||
|
#
|
||||||
|
# The watchdog samples any still-running test processes after 5
|
||||||
|
# minutes (the suite passes in seconds when healthy; the build is
|
||||||
|
# done by this step) and kills the run, so a hang fails fast with
|
||||||
|
# stacks in the log instead of a silent timeout.
|
||||||
|
env:
|
||||||
|
BITCHAT_SKIP_PERF_BASELINES: "1"
|
||||||
|
run: |
|
||||||
|
swift test --skip-build --parallel --quiet --enable-code-coverage \
|
||||||
|
--skip PerformanceBaselineTests \
|
||||||
|
--package-path ${{ matrix.path }} &
|
||||||
|
test_pid=$!
|
||||||
|
(
|
||||||
|
sleep 300
|
||||||
|
if kill -0 "$test_pid" 2>/dev/null; then
|
||||||
|
echo "::group::Tests still running after 5 minutes — sampling before kill"
|
||||||
|
for pid in $(pgrep -if 'swiftpm-testing|xctest|PackageTests' || true); do
|
||||||
|
echo "--- sample of pid $pid ---"
|
||||||
|
sample "$pid" 5 2>/dev/null || true
|
||||||
|
done
|
||||||
|
echo "::endgroup::"
|
||||||
|
pkill -KILL -P "$test_pid" 2>/dev/null || true
|
||||||
|
kill -KILL "$test_pid" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
) &
|
||||||
|
watchdog_pid=$!
|
||||||
|
wait "$test_pid" && status=0 || status=$?
|
||||||
|
kill "$watchdog_pid" 2>/dev/null || true
|
||||||
|
exit "$status"
|
||||||
|
|
||||||
|
# Benchmarks run serially on an otherwise idle runner for stable
|
||||||
|
# numbers; BITCHAT_PERF_LOG captures the PERF[...] lines for the gate.
|
||||||
|
- name: Run performance benchmarks (serial)
|
||||||
|
if: matrix.name == 'app'
|
||||||
|
timeout-minutes: 6
|
||||||
|
env:
|
||||||
|
BITCHAT_PERF_LOG: ${{ github.workspace }}/perf-output.log
|
||||||
|
run: swift test --quiet --filter PerformanceBaselineTests
|
||||||
|
|
||||||
|
# Order-of-magnitude performance regression gate. Floors are deliberately
|
||||||
|
# generous (see bitchatTests/Performance/perf-floors.json) so this
|
||||||
|
# catches algorithmic regressions, never runner variance.
|
||||||
|
- name: Performance floor gate
|
||||||
|
if: matrix.name == 'app'
|
||||||
|
run: ./scripts/check-perf-floors.sh perf-output.log
|
||||||
|
|
||||||
|
# Informational only: surfaces per-file and total line coverage in the
|
||||||
|
# job log so coverage trends are visible on every PR. No thresholds —
|
||||||
|
# this must never be the reason a build goes red.
|
||||||
|
- name: Coverage summary
|
||||||
|
run: |
|
||||||
|
BIN_PATH=$(swift build --show-bin-path --package-path ${{ matrix.path }})
|
||||||
|
PROF="$BIN_PATH/codecov/default.profdata"
|
||||||
|
XCTEST=$(find "$BIN_PATH" -maxdepth 1 -name '*.xctest' | head -1)
|
||||||
|
BINARY="$XCTEST/Contents/MacOS/$(basename "$XCTEST" .xctest)"
|
||||||
|
if [ -f "$PROF" ] && [ -f "$BINARY" ]; then
|
||||||
|
xcrun llvm-cov report "$BINARY" -instr-profile "$PROF" \
|
||||||
|
-ignore-filename-regex='(Tests|\.build|checkouts|Mocks|_PreviewHelpers)' || true
|
||||||
|
else
|
||||||
|
echo "No coverage data found; skipping summary."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# SPM tests above only compile the macOS slice; this job covers the
|
||||||
|
# iOS-conditional code paths (UIKit, CoreBluetooth restoration, etc.).
|
||||||
|
ios-build:
|
||||||
|
name: Build iOS app (simulator)
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 15
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Build iOS (simulator, no signing)
|
||||||
|
# arm64 only: the vendored arti.xcframework has no x86_64 simulator slice.
|
||||||
|
run: |
|
||||||
|
set -o pipefail
|
||||||
|
xcodebuild -project bitchat.xcodeproj \
|
||||||
|
-scheme "bitchat (iOS)" \
|
||||||
|
-sdk iphonesimulator \
|
||||||
|
-destination 'generic/platform=iOS Simulator' \
|
||||||
|
ARCHS=arm64 \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
build
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
MARKETING_VERSION = 1.5.1
|
MARKETING_VERSION = 1.5.3
|
||||||
CURRENT_PROJECT_VERSION = 1
|
CURRENT_PROJECT_VERSION = 1
|
||||||
|
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
IPHONEOS_DEPLOYMENT_TARGET = 16.0
|
||||||
|
|||||||
+42
-16
@@ -1,6 +1,6 @@
|
|||||||
# bitchat Privacy Policy
|
# bitchat Privacy Policy
|
||||||
|
|
||||||
*Last updated: January 2025*
|
*Last updated: June 2026*
|
||||||
|
|
||||||
## Our Commitment
|
## Our Commitment
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ bitchat is designed with privacy as its foundation. We believe private communica
|
|||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- **No personal data collection** - We don't collect names, emails, or phone numbers
|
- **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
|
- **No tracking** - We have no analytics, telemetry, or user tracking
|
||||||
- **Open source** - You can verify these claims by reading our code
|
- **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
|
### On Your Device Only
|
||||||
|
|
||||||
1. **Identity Key**
|
1. **Identity Keys**
|
||||||
- A cryptographic key generated on first launch
|
- Cryptographic private keys generated on first launch or when optional Nostr identities are created
|
||||||
- Stored locally in your device's secure storage
|
- Stored locally in your device's secure storage
|
||||||
- Allows you to maintain "favorite" relationships across app restarts
|
- 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**
|
2. **Nickname**
|
||||||
- The display name you choose (or auto-generated)
|
- 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
|
- Stored only on your device
|
||||||
- Allows you to recognize these peers in future sessions
|
- 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
|
### Temporary Session Data
|
||||||
|
|
||||||
During each session, bitchat temporarily maintains:
|
During each session, bitchat temporarily maintains:
|
||||||
- Active peer connections (forgotten when app closes)
|
- Active peer connections (forgotten when app closes)
|
||||||
- Routing information for message delivery
|
- Routing information for message delivery
|
||||||
- Cached messages for offline peers (12 hours max)
|
- 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
|
## What Information is Shared
|
||||||
|
|
||||||
@@ -62,13 +69,21 @@ When you join a password-protected room:
|
|||||||
- Your nickname appears in the member list
|
- Your nickname appears in the member list
|
||||||
- Room owners can see you've joined
|
- 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
|
## What We DON'T Do
|
||||||
|
|
||||||
bitchat **never**:
|
bitchat **never**:
|
||||||
- Collects personal information
|
- Collects personal information
|
||||||
- Tracks your location
|
- Sells or shares your exact GPS location
|
||||||
- Stores data on servers
|
- Stores data on servers we operate
|
||||||
- Shares data with third parties
|
- Sells your data to advertisers or data brokers
|
||||||
- Uses analytics or telemetry
|
- Uses analytics or telemetry
|
||||||
- Creates user profiles
|
- Creates user profiles
|
||||||
- Requires registration
|
- Requires registration
|
||||||
@@ -84,19 +99,27 @@ All private messages use end-to-end encryption:
|
|||||||
## Your Rights
|
## Your Rights
|
||||||
|
|
||||||
You have complete control:
|
You have complete control:
|
||||||
- **Delete Everything**: Triple-tap the logo to instantly wipe all data
|
- **Delete Local State**: Triple-tap the logo to instantly wipe local keys, sessions, caches, and preferences
|
||||||
- **Leave Anytime**: Close the app and your presence disappears
|
- **Leave Anytime**: Close the app and local presence stops; relay-backed presence ages out
|
||||||
- **No Account**: Nothing to delete from servers because there are none
|
- **No Account**: No account record exists for you to delete from us
|
||||||
- **Portability**: Your data never leaves your device unless you export it
|
- **Portability**: Your local state stays on your device unless you send messages, use optional relay-backed features, or export it
|
||||||
|
|
||||||
## Bluetooth & Permissions
|
## Bluetooth & Permissions
|
||||||
|
|
||||||
bitchat requires Bluetooth permission to function:
|
bitchat requires Bluetooth permission to function:
|
||||||
- Used only for peer-to-peer communication
|
- Used only for peer-to-peer communication
|
||||||
- No location data is accessed or stored
|
|
||||||
- Bluetooth is not used for tracking
|
- Bluetooth is not used for tracking
|
||||||
- You can revoke this permission at any time in system settings
|
- 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
|
## 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.
|
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)
|
- **Messages**: Deleted from memory when app closes (unless room retention is enabled)
|
||||||
- **Identity Key**: Persists until you delete the app
|
- **Identity Key**: Persists until you delete the app
|
||||||
- **Favorites**: Persist until you remove them or 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
|
- **Everything Else**: Exists only during active sessions
|
||||||
|
|
||||||
## Security Measures
|
## Security Measures
|
||||||
|
|
||||||
- All communication is encrypted
|
- 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
|
- Open source code for public audit
|
||||||
- Regular security updates
|
- Regular security updates
|
||||||
- Cryptographic signatures prevent tampering
|
- 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:
|
If we update this policy:
|
||||||
- The "Last updated" date will change
|
- The "Last updated" date will change
|
||||||
- The updated policy will be included in the app
|
- 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
|
## Contact
|
||||||
|
|
||||||
@@ -132,7 +158,7 @@ bitchat is an open source project. For privacy questions:
|
|||||||
|
|
||||||
## Philosophy
|
## Philosophy
|
||||||
|
|
||||||
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no servers, no surveillance. Just people talking freely.
|
Privacy isn't just a feature—it's the entire point. bitchat proves that modern communication doesn't require surrendering your privacy. No accounts, no company servers, no analytics. Just people talking freely.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -53,11 +53,17 @@ let package = Package(
|
|||||||
path: "bitchatTests",
|
path: "bitchatTests",
|
||||||
exclude: [
|
exclude: [
|
||||||
"Info.plist",
|
"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: [
|
resources: [
|
||||||
.process("Localization"),
|
.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;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
BuildIndependentTargetsInParallel = YES;
|
BuildIndependentTargetsInParallel = YES;
|
||||||
LastUpgradeCheck = 1640;
|
LastUpgradeCheck = 2650;
|
||||||
};
|
};
|
||||||
buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */;
|
buildConfigurationList = 3EA424CBD51200895D361189 /* Build configuration list for PBXProject "bitchat" */;
|
||||||
developmentRegion = en;
|
developmentRegion = en;
|
||||||
@@ -446,7 +446,6 @@
|
|||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -471,7 +470,6 @@
|
|||||||
CODE_SIGNING_ALLOWED = YES;
|
CODE_SIGNING_ALLOWED = YES;
|
||||||
CODE_SIGNING_REQUIRED = YES;
|
CODE_SIGNING_REQUIRED = YES;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
@@ -498,7 +496,6 @@
|
|||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -523,7 +520,6 @@
|
|||||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
@@ -556,7 +552,6 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
ENABLE_PREVIEWS = NO;
|
ENABLE_PREVIEWS = NO;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
@@ -566,7 +561,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = 1.5.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -590,7 +585,6 @@
|
|||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
INFOPLIST_FILE = bitchatTests/Info.plist;
|
INFOPLIST_FILE = bitchatTests/Info.plist;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -617,7 +611,6 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchat/bitchat.entitlements;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
DEVELOPMENT_ASSET_PATHS = bitchat/_PreviewHelpers;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
@@ -627,7 +620,7 @@
|
|||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"@executable_path/Frameworks",
|
"@executable_path/Frameworks",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = 1.5.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -653,7 +646,6 @@
|
|||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
ENABLE_PREVIEWS = YES;
|
ENABLE_PREVIEWS = YES;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
@@ -663,7 +655,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = 1.5.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -676,6 +668,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
@@ -709,6 +702,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
ENABLE_NS_ASSERTIONS = NO;
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -726,6 +720,7 @@
|
|||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SWIFT_COMPILATION_MODE = wholemodule;
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
@@ -745,7 +740,6 @@
|
|||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
COMBINE_HIDPI_IMAGES = YES;
|
COMBINE_HIDPI_IMAGES = YES;
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
ENABLE_PREVIEWS = NO;
|
ENABLE_PREVIEWS = NO;
|
||||||
INFOPLIST_FILE = bitchat/Info.plist;
|
INFOPLIST_FILE = bitchat/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
@@ -755,7 +749,7 @@
|
|||||||
"@executable_path/../Frameworks",
|
"@executable_path/../Frameworks",
|
||||||
);
|
);
|
||||||
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
MACOSX_DEPLOYMENT_TARGET = "$(MACOSX_DEPLOYMENT_TARGET)";
|
||||||
MARKETING_VERSION = 1.5.1;
|
MARKETING_VERSION = 1.5.3;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
PRODUCT_BUNDLE_IDENTIFIER = "$(PRODUCT_BUNDLE_IDENTIFIER)";
|
||||||
PRODUCT_NAME = bitchat;
|
PRODUCT_NAME = bitchat;
|
||||||
REGISTER_APP_GROUPS = YES;
|
REGISTER_APP_GROUPS = YES;
|
||||||
@@ -768,6 +762,7 @@
|
|||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES;
|
||||||
CLANG_ANALYZER_NONNULL = YES;
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
@@ -801,6 +796,7 @@
|
|||||||
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
CURRENT_PROJECT_VERSION = "$(CURRENT_PROJECT_VERSION)";
|
||||||
DEAD_CODE_STRIPPING = YES;
|
DEAD_CODE_STRIPPING = YES;
|
||||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
||||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
ENABLE_TESTABILITY = YES;
|
ENABLE_TESTABILITY = YES;
|
||||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||||
@@ -825,6 +821,7 @@
|
|||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
SWIFT_VERSION = "$(SWIFT_VERSION)";
|
||||||
@@ -841,7 +838,6 @@
|
|||||||
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = bitchatShareExtension/bitchatShareExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
CODE_SIGN_STYLE = "$(CODE_SIGN_STYLE)";
|
||||||
DEVELOPMENT_TEAM = "$(DEVELOPMENT_TEAM)";
|
|
||||||
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
INFOPLIST_FILE = bitchatShareExtension/Info.plist;
|
||||||
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
INFOPLIST_KEY_CFBundleDisplayName = bitchat;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
IPHONEOS_DEPLOYMENT_TARGET = "$(IPHONEOS_DEPLOYMENT_TARGET)";
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1640"
|
LastUpgradeVersion = "2650"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1640"
|
LastUpgradeVersion = "2650"
|
||||||
version = "1.3">
|
version = "1.3">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum SharedContentKind: String, Sendable, Equatable {
|
||||||
|
case text
|
||||||
|
case url
|
||||||
|
}
|
||||||
|
|
||||||
|
enum RuntimeScenePhase: String, Sendable, Equatable {
|
||||||
|
case active
|
||||||
|
case inactive
|
||||||
|
case background
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TorLifecycleEvent: String, Sendable, Equatable {
|
||||||
|
case willStart
|
||||||
|
case willRestart
|
||||||
|
case didBecomeReady
|
||||||
|
case preferenceChanged
|
||||||
|
}
|
||||||
|
|
||||||
|
enum AppEvent: Sendable, Equatable {
|
||||||
|
case launched
|
||||||
|
case startupCompleted
|
||||||
|
case scenePhaseChanged(RuntimeScenePhase)
|
||||||
|
case openedURL(String)
|
||||||
|
case sharedContentAccepted(SharedContentKind)
|
||||||
|
case notificationOpened(peerID: PeerID?)
|
||||||
|
case deepLinkOpened(String)
|
||||||
|
case torLifecycleChanged(TorLifecycleEvent)
|
||||||
|
case nostrRelayConnectionChanged(Bool)
|
||||||
|
case terminationRequested
|
||||||
|
}
|
||||||
|
|
||||||
|
actor AppEventStream {
|
||||||
|
private var continuations: [UUID: AsyncStream<AppEvent>.Continuation] = [:]
|
||||||
|
|
||||||
|
func stream() -> AsyncStream<AppEvent> {
|
||||||
|
let id = UUID()
|
||||||
|
return AsyncStream { continuation in
|
||||||
|
continuations[id] = continuation
|
||||||
|
continuation.onTermination = { [id] _ in
|
||||||
|
Task {
|
||||||
|
await self.removeContinuation(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func emit(_ event: AppEvent) {
|
||||||
|
for continuation in continuations.values {
|
||||||
|
continuation.yield(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finish() {
|
||||||
|
for continuation in continuations.values {
|
||||||
|
continuation.finish()
|
||||||
|
}
|
||||||
|
continuations.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func removeContinuation(_ id: UUID) {
|
||||||
|
continuations.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PeerHandle: Equatable {
|
||||||
|
static func == (lhs: PeerHandle, rhs: PeerHandle) -> Bool {
|
||||||
|
lhs.id == rhs.id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension PeerHandle: Hashable {
|
||||||
|
func hash(into hasher: inout Hasher) {
|
||||||
|
hasher.combine(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ConversationID: Hashable, Sendable {
|
||||||
|
case mesh
|
||||||
|
case geohash(String)
|
||||||
|
case direct(PeerHandle)
|
||||||
|
|
||||||
|
init(channelID: ChannelID) {
|
||||||
|
switch channelID {
|
||||||
|
case .mesh:
|
||||||
|
self = .mesh
|
||||||
|
case .location(let channel):
|
||||||
|
self = .geohash(channel.geohash.lowercased())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import CoreBluetooth
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppChromeModel: ObservableObject {
|
||||||
|
@Published private(set) var hasUnreadPrivateMessages = false
|
||||||
|
@Published var nickname: String
|
||||||
|
@Published var showingFingerprintFor: PeerID?
|
||||||
|
@Published var isAppInfoPresented = false
|
||||||
|
@Published var isLocationChannelsSheetPresented = false
|
||||||
|
@Published var showBluetoothAlert = false
|
||||||
|
@Published var bluetoothAlertMessage = ""
|
||||||
|
@Published var bluetoothState: CBManagerState = .unknown
|
||||||
|
@Published var showScreenshotPrivacyWarning = false
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(chatViewModel: ChatViewModel, privateInboxModel: PrivateInboxModel) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.nickname = chatViewModel.nickname
|
||||||
|
|
||||||
|
bind(privateInboxModel: privateInboxModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldSuppressScreenshotNotification: Bool {
|
||||||
|
isLocationChannelsSheetPresented || isAppInfoPresented
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNickname(_ nickname: String) {
|
||||||
|
self.nickname = nickname
|
||||||
|
if chatViewModel.nickname != nickname {
|
||||||
|
chatViewModel.nickname = nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateAndSaveNickname() {
|
||||||
|
chatViewModel.validateAndSaveNickname()
|
||||||
|
if nickname != chatViewModel.nickname {
|
||||||
|
nickname = chatViewModel.nickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func openMostRelevantPrivateChat() {
|
||||||
|
chatViewModel.openMostRelevantPrivateChat()
|
||||||
|
}
|
||||||
|
|
||||||
|
func showFingerprint(for peerID: PeerID) {
|
||||||
|
showingFingerprintFor = peerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearFingerprint() {
|
||||||
|
showingFingerprintFor = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentAppInfo() {
|
||||||
|
isAppInfoPresented = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func triggerScreenshotPrivacyWarning() {
|
||||||
|
showScreenshotPrivacyWarning = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func panicClearAllData() {
|
||||||
|
chatViewModel.panicClearAllData()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind(privateInboxModel: PrivateInboxModel) {
|
||||||
|
privateInboxModel.$unreadPeerIDs
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] unreadPeerIDs in
|
||||||
|
self?.hasUnreadPrivateMessages = !unreadPeerIDs.isEmpty
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$nickname
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] nickname in
|
||||||
|
guard let self, self.nickname != nickname else { return }
|
||||||
|
self.nickname = nickname
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$showBluetoothAlert
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$showBluetoothAlert)
|
||||||
|
|
||||||
|
chatViewModel.$bluetoothAlertMessage
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$bluetoothAlertMessage)
|
||||||
|
|
||||||
|
chatViewModel.$bluetoothState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$bluetoothState)
|
||||||
|
|
||||||
|
hasUnreadPrivateMessages = !privateInboxModel.unreadPeerIDs.isEmpty
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import Tor
|
||||||
|
import UserNotifications
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#elseif os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class AppRuntime: ObservableObject {
|
||||||
|
let chatViewModel: ChatViewModel
|
||||||
|
let events = AppEventStream()
|
||||||
|
/// 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
|
||||||
|
let privateInboxModel: PrivateInboxModel
|
||||||
|
let privateConversationModel: PrivateConversationModel
|
||||||
|
let verificationModel: VerificationModel
|
||||||
|
let conversationUIModel: ConversationUIModel
|
||||||
|
let locationChannelsModel: LocationChannelsModel
|
||||||
|
let peerListModel: PeerListModel
|
||||||
|
let appChromeModel: AppChromeModel
|
||||||
|
|
||||||
|
private let idBridge: NostrIdentityBridge
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
private var started = false
|
||||||
|
private var lastNostrRelayConnectedState = false
|
||||||
|
private var didHandleInitialNostrConnection = false
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
private var didHandleInitialActive = false
|
||||||
|
private var didEnterBackground = false
|
||||||
|
#endif
|
||||||
|
|
||||||
|
init(
|
||||||
|
keychain: KeychainManagerProtocol = KeychainManager(),
|
||||||
|
idBridge: NostrIdentityBridge = NostrIdentityBridge()
|
||||||
|
) {
|
||||||
|
self.idBridge = idBridge
|
||||||
|
let conversations = ConversationStore()
|
||||||
|
let peerIdentityStore = PeerIdentityStore()
|
||||||
|
let locationPresenceStore = LocationPresenceStore()
|
||||||
|
let locationManager = LocationChannelManager.shared
|
||||||
|
self.conversations = conversations
|
||||||
|
self.peerIdentityStore = peerIdentityStore
|
||||||
|
self.locationPresenceStore = locationPresenceStore
|
||||||
|
self.chatViewModel = ChatViewModel(
|
||||||
|
keychain: keychain,
|
||||||
|
idBridge: idBridge,
|
||||||
|
identityManager: SecureIdentityStateManager(keychain),
|
||||||
|
conversations: conversations,
|
||||||
|
peerIdentityStore: peerIdentityStore,
|
||||||
|
locationPresenceStore: locationPresenceStore,
|
||||||
|
locationManager: locationManager
|
||||||
|
)
|
||||||
|
self.publicChatModel = PublicChatModel(conversations: conversations)
|
||||||
|
self.privateInboxModel = PrivateInboxModel(conversations: conversations)
|
||||||
|
self.locationChannelsModel = LocationChannelsModel(manager: locationManager)
|
||||||
|
self.privateConversationModel = PrivateConversationModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
conversations: conversations,
|
||||||
|
locationChannelsModel: self.locationChannelsModel,
|
||||||
|
peerIdentityStore: peerIdentityStore
|
||||||
|
)
|
||||||
|
self.verificationModel = VerificationModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
privateConversationModel: self.privateConversationModel,
|
||||||
|
peerIdentityStore: peerIdentityStore
|
||||||
|
)
|
||||||
|
self.conversationUIModel = ConversationUIModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
privateConversationModel: self.privateConversationModel,
|
||||||
|
conversations: conversations
|
||||||
|
)
|
||||||
|
self.peerListModel = PeerListModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
conversations: conversations,
|
||||||
|
locationChannelsModel: self.locationChannelsModel,
|
||||||
|
peerIdentityStore: peerIdentityStore,
|
||||||
|
locationPresenceStore: locationPresenceStore
|
||||||
|
)
|
||||||
|
self.appChromeModel = AppChromeModel(
|
||||||
|
chatViewModel: self.chatViewModel,
|
||||||
|
privateInboxModel: self.privateInboxModel
|
||||||
|
)
|
||||||
|
|
||||||
|
GeoRelayDirectory.shared.prefetchIfNeeded()
|
||||||
|
bindRuntimeObservers()
|
||||||
|
NotificationDelegate.shared.runtime = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func start() {
|
||||||
|
guard !started else {
|
||||||
|
checkForSharedContent()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
started = true
|
||||||
|
NotificationDelegate.shared.runtime = self
|
||||||
|
VerificationService.shared.configure(with: chatViewModel.meshService)
|
||||||
|
announceInitialTorStatusIfNeeded()
|
||||||
|
|
||||||
|
Task(priority: .utility) { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let nickname = await MainActor.run { self.chatViewModel.nickname }
|
||||||
|
let npub = await MainActor.run {
|
||||||
|
try? self.idBridge.getCurrentNostrIdentity()?.npub
|
||||||
|
}
|
||||||
|
await MainActor.run {
|
||||||
|
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
NetworkActivationService.shared.start()
|
||||||
|
GeohashPresenceService.shared.start()
|
||||||
|
checkForSharedContent()
|
||||||
|
|
||||||
|
record(.launched)
|
||||||
|
record(.startupCompleted)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleOpenURL(_ url: URL) {
|
||||||
|
record(.openedURL(url.absoluteString))
|
||||||
|
|
||||||
|
if url.scheme == "bitchat", url.host == "share" {
|
||||||
|
checkForSharedContent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDidBecomeActiveNotification() {
|
||||||
|
chatViewModel.handleDidBecomeActive()
|
||||||
|
checkForSharedContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
func handleMacDidBecomeActiveNotification() {
|
||||||
|
record(.scenePhaseChanged(.active))
|
||||||
|
chatViewModel.handleDidBecomeActive()
|
||||||
|
checkForSharedContent()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
func handleScenePhaseChange(_ newPhase: ScenePhase) {
|
||||||
|
switch newPhase {
|
||||||
|
case .background:
|
||||||
|
record(.scenePhaseChanged(.background))
|
||||||
|
TorManager.shared.setAppForeground(false)
|
||||||
|
TorManager.shared.goDormantOnBackground()
|
||||||
|
chatViewModel.endGeohashSampling()
|
||||||
|
NostrRelayManager.shared.disconnect()
|
||||||
|
didEnterBackground = true
|
||||||
|
|
||||||
|
case .active:
|
||||||
|
record(.scenePhaseChanged(.active))
|
||||||
|
chatViewModel.meshService.startServices()
|
||||||
|
TorManager.shared.setAppForeground(true)
|
||||||
|
let shouldRefreshNostrConnections = didHandleInitialActive && didEnterBackground
|
||||||
|
|
||||||
|
if didHandleInitialActive && didEnterBackground {
|
||||||
|
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
||||||
|
TorManager.shared.ensureRunningOnForeground()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
didHandleInitialActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
didEnterBackground = false
|
||||||
|
|
||||||
|
if shouldRefreshNostrConnections && TorManager.shared.isAutoStartAllowed() {
|
||||||
|
Task.detached {
|
||||||
|
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
||||||
|
await MainActor.run {
|
||||||
|
TorURLSession.shared.rebuild()
|
||||||
|
NostrRelayManager.shared.resetAllConnections()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chatViewModel.handleDidBecomeActive()
|
||||||
|
checkForSharedContent()
|
||||||
|
|
||||||
|
case .inactive:
|
||||||
|
record(.scenePhaseChanged(.inactive))
|
||||||
|
|
||||||
|
@unknown default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
func applicationWillTerminate() {
|
||||||
|
record(.terminationRequested)
|
||||||
|
chatViewModel.applicationWillTerminate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleNotificationResponse(identifier: String, userInfo: [AnyHashable: Any]) {
|
||||||
|
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||||
|
record(.notificationOpened(peerID: peerID))
|
||||||
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let deepLink = userInfo["deeplink"] as? String, let url = URL(string: deepLink) {
|
||||||
|
record(.deepLinkOpened(deepLink))
|
||||||
|
openExternalURL(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentationOptions(
|
||||||
|
forNotificationIdentifier identifier: String,
|
||||||
|
userInfo: [AnyHashable: Any]
|
||||||
|
) async -> UNNotificationPresentationOptions {
|
||||||
|
if identifier.hasPrefix("private-"), let peerID = PeerID(str: userInfo["peerID"] as? String) {
|
||||||
|
if conversations.selectedPrivatePeerID == peerID {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
return [.banner, .sound]
|
||||||
|
}
|
||||||
|
|
||||||
|
if identifier.hasPrefix("geo-activity-"),
|
||||||
|
let deepLink = userInfo["deeplink"] as? String,
|
||||||
|
let geohash = deepLink.components(separatedBy: "/").last,
|
||||||
|
case .location(let channel) = locationChannelsModel.selectedChannel,
|
||||||
|
channel.geohash == geohash {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return [.banner, .sound]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension AppRuntime {
|
||||||
|
func bindRuntimeObservers() {
|
||||||
|
NostrRelayManager.shared.$isConnected
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] isConnected in
|
||||||
|
self?.handleNostrRelayConnectionChanged(isConnected)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .TorWillRestart)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.record(.torLifecycleChanged(.willRestart))
|
||||||
|
self?.chatViewModel.handleTorWillRestart()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.record(.torLifecycleChanged(.didBecomeReady))
|
||||||
|
self?.chatViewModel.handleTorDidBecomeReady()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .TorWillStart)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.record(.torLifecycleChanged(.willStart))
|
||||||
|
self?.chatViewModel.handleTorWillStart()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .TorUserPreferenceChanged)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] notification in
|
||||||
|
self?.record(.torLifecycleChanged(.preferenceChanged))
|
||||||
|
self?.chatViewModel.handleTorPreferenceChanged(notification)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
NotificationCenter.default.publisher(for: UIApplication.userDidTakeScreenshotNotification)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.handleScreenshotCaptured()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkForSharedContent() {
|
||||||
|
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID),
|
||||||
|
let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
||||||
|
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentKind = SharedContentKind(rawValue: userDefaults.string(forKey: "sharedContentType") ?? "") ?? .text
|
||||||
|
|
||||||
|
userDefaults.removeObject(forKey: "sharedContent")
|
||||||
|
userDefaults.removeObject(forKey: "sharedContentType")
|
||||||
|
userDefaults.removeObject(forKey: "sharedContentDate")
|
||||||
|
|
||||||
|
switch contentKind {
|
||||||
|
case .url:
|
||||||
|
if let data = sharedContent.data(using: .utf8),
|
||||||
|
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
||||||
|
let url = urlData["url"] {
|
||||||
|
chatViewModel.sendMessage(url)
|
||||||
|
} else {
|
||||||
|
chatViewModel.sendMessage(sharedContent)
|
||||||
|
}
|
||||||
|
case .text:
|
||||||
|
chatViewModel.sendMessage(sharedContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
record(.sharedContentAccepted(contentKind))
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleNostrRelayConnectionChanged(_ isConnected: Bool) {
|
||||||
|
record(.nostrRelayConnectionChanged(isConnected))
|
||||||
|
|
||||||
|
let becameConnected = isConnected && !lastNostrRelayConnectedState
|
||||||
|
lastNostrRelayConnectedState = isConnected
|
||||||
|
|
||||||
|
guard started, becameConnected else { return }
|
||||||
|
|
||||||
|
let isInitialConnection = !didHandleInitialNostrConnection
|
||||||
|
didHandleInitialNostrConnection = true
|
||||||
|
|
||||||
|
if !chatViewModel.nostrHandlersSetup {
|
||||||
|
chatViewModel.setupNostrMessageHandling()
|
||||||
|
chatViewModel.nostrHandlersSetup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !isInitialConnection else { return }
|
||||||
|
|
||||||
|
chatViewModel.resubscribeCurrentGeohash()
|
||||||
|
chatViewModel.geoChannelCoordinator?.refreshSampling()
|
||||||
|
}
|
||||||
|
|
||||||
|
func announceInitialTorStatusIfNeeded() {
|
||||||
|
if TorManager.shared.torEnforced &&
|
||||||
|
!chatViewModel.torStatusAnnounced &&
|
||||||
|
TorManager.shared.isAutoStartAllowed() {
|
||||||
|
chatViewModel.torStatusAnnounced = true
|
||||||
|
chatViewModel.addGeohashOnlySystemMessage(
|
||||||
|
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
|
||||||
|
)
|
||||||
|
} else if !TorManager.shared.torEnforced && !chatViewModel.torStatusAnnounced {
|
||||||
|
chatViewModel.torStatusAnnounced = true
|
||||||
|
chatViewModel.addGeohashOnlySystemMessage(
|
||||||
|
String(localized: "system.tor.dev_bypass", comment: "System message when Tor bypass is enabled in development")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleScreenshotCaptured() {
|
||||||
|
if appChromeModel.isLocationChannelsSheetPresented {
|
||||||
|
appChromeModel.triggerScreenshotPrivacyWarning()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if appChromeModel.isAppInfoPresented {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
chatViewModel.handleScreenshotCaptured()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openExternalURL(_ url: URL) {
|
||||||
|
#if os(iOS)
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
#else
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(_ event: AppEvent) {
|
||||||
|
Task {
|
||||||
|
await events.emit(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ConversationUIModel: ObservableObject {
|
||||||
|
@Published private(set) var showAutocomplete = false
|
||||||
|
@Published private(set) var autocompleteSuggestions: [String] = []
|
||||||
|
@Published private(set) var currentNickname: String
|
||||||
|
@Published private(set) var isBatchingPublic = false
|
||||||
|
@Published private(set) var canSendMediaInCurrentContext = true
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private let privateConversationModel: PrivateConversationModel
|
||||||
|
private let conversations: ConversationStore
|
||||||
|
private var activeChannel: ChannelID
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
privateConversationModel: PrivateConversationModel,
|
||||||
|
conversations: ConversationStore
|
||||||
|
) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.privateConversationModel = privateConversationModel
|
||||||
|
self.conversations = conversations
|
||||||
|
self.activeChannel = conversations.activeChannel
|
||||||
|
self.currentNickname = chatViewModel.nickname
|
||||||
|
self.isBatchingPublic = chatViewModel.isBatchingPublic
|
||||||
|
self.showAutocomplete = chatViewModel.showAutocomplete
|
||||||
|
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
|
||||||
|
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
|
||||||
|
|
||||||
|
bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCurrentColorScheme(_ colorScheme: ColorScheme) {
|
||||||
|
chatViewModel.currentColorScheme = colorScheme
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCurrentTheme(_ theme: AppTheme) {
|
||||||
|
chatViewModel.currentTheme = theme
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessage(_ message: String) {
|
||||||
|
chatViewModel.sendMessage(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearCurrentConversation() {
|
||||||
|
chatViewModel.sendMessage("/clear")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendHug(to sender: String) {
|
||||||
|
chatViewModel.sendMessage("/hug @\(sender)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendSlap(to sender: String) {
|
||||||
|
chatViewModel.sendMessage("/slap @\(sender)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func block(peerID: PeerID?, displayName: String?) {
|
||||||
|
guard let displayName else { return }
|
||||||
|
|
||||||
|
if let peerID, peerID.isGeoChat,
|
||||||
|
let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||||
|
chatViewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: displayName)
|
||||||
|
} else {
|
||||||
|
chatViewModel.sendMessage("/block \(displayName)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||||
|
chatViewModel.updateAutocomplete(for: text, cursorPosition: cursorPosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeNickname(_ nickname: String, in text: inout String) -> Int {
|
||||||
|
chatViewModel.completeNickname(nickname, in: &text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
|
||||||
|
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
|
||||||
|
chatViewModel.formatMessageHeader(message, colorScheme: colorScheme, theme: theme)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mediaAttachment(for message: BitchatMessage) -> BitchatMessage.Media? {
|
||||||
|
message.mediaAttachment(for: currentNickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool {
|
||||||
|
chatViewModel.isSelfSender(peerID: peerID, displayName: displayName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSentByCurrentUser(_ message: BitchatMessage) -> Bool {
|
||||||
|
message.sender == currentNickname || message.sender.hasPrefix(currentNickname + "#")
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMediaMessageFromCurrentUser(_ message: BitchatMessage) -> Bool {
|
||||||
|
message.sender == currentNickname || message.senderPeerID == chatViewModel.meshService.myPeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func senderDisplayName(for peerID: PeerID, fallbackMessages: [BitchatMessage]) -> String? {
|
||||||
|
if peerID.isGeoDM || peerID.isGeoChat {
|
||||||
|
return chatViewModel.geohashDisplayName(for: peerID)
|
||||||
|
}
|
||||||
|
if let nickname = chatViewModel.meshService.peerNickname(peerID: peerID) {
|
||||||
|
return nickname
|
||||||
|
}
|
||||||
|
return fallbackMessages.last(where: { $0.senderPeerID == peerID && $0.sender != "system" })?.sender
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
func processSelectedImage(_ image: UIImage?) {
|
||||||
|
chatViewModel.processThenSendImage(image)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
func processSelectedImage(from url: URL?) {
|
||||||
|
#if os(macOS)
|
||||||
|
chatViewModel.processThenSendImage(from: url)
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendVoiceNote(at url: URL) {
|
||||||
|
chatViewModel.sendVoiceNote(at: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelMediaSend(messageID: String) {
|
||||||
|
chatViewModel.cancelMediaSend(messageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteMediaMessage(messageID: String) {
|
||||||
|
chatViewModel.deleteMediaMessage(messageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
chatViewModel.$nickname
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$currentNickname)
|
||||||
|
|
||||||
|
chatViewModel.$showAutocomplete
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$showAutocomplete)
|
||||||
|
|
||||||
|
chatViewModel.$autocompleteSuggestions
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$autocompleteSuggestions)
|
||||||
|
|
||||||
|
chatViewModel.$isBatchingPublic
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$isBatchingPublic)
|
||||||
|
|
||||||
|
conversations.$activeChannel
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] channel in
|
||||||
|
self?.activeChannel = channel
|
||||||
|
self?.refreshComputedState()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
privateConversationModel.$selectedPeerID
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshComputedState()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshComputedState() {
|
||||||
|
if let selectedPeerID = privateConversationModel.selectedPeerID {
|
||||||
|
canSendMediaInCurrentContext = !(selectedPeerID.isGeoDM || selectedPeerID.isGeoChat)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch activeChannel {
|
||||||
|
case .mesh:
|
||||||
|
canSendMediaInCurrentContext = true
|
||||||
|
case .location:
|
||||||
|
canSendMediaInCurrentContext = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationChannelsModel: ObservableObject {
|
||||||
|
@Published private(set) var permissionState: LocationChannelManager.PermissionState
|
||||||
|
@Published private(set) var availableChannels: [GeohashChannel]
|
||||||
|
@Published private(set) var selectedChannel: ChannelID
|
||||||
|
@Published private(set) var teleported: Bool
|
||||||
|
@Published private(set) var bookmarks: [String]
|
||||||
|
@Published private(set) var bookmarkNames: [String: String]
|
||||||
|
@Published private(set) var locationNames: [GeohashChannelLevel: String]
|
||||||
|
@Published private(set) var userTorEnabled: Bool
|
||||||
|
|
||||||
|
private let manager: LocationChannelManager
|
||||||
|
private let network: NetworkActivationService
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
manager: LocationChannelManager? = nil,
|
||||||
|
network: NetworkActivationService? = nil
|
||||||
|
) {
|
||||||
|
let manager = manager ?? .shared
|
||||||
|
let network = network ?? .shared
|
||||||
|
|
||||||
|
self.manager = manager
|
||||||
|
self.network = network
|
||||||
|
self.permissionState = manager.permissionState
|
||||||
|
self.availableChannels = manager.availableChannels
|
||||||
|
self.selectedChannel = manager.selectedChannel
|
||||||
|
self.teleported = manager.teleported
|
||||||
|
self.bookmarks = manager.bookmarks
|
||||||
|
self.bookmarkNames = manager.bookmarkNames
|
||||||
|
self.locationNames = manager.locationNames
|
||||||
|
self.userTorEnabled = network.userTorEnabled
|
||||||
|
|
||||||
|
bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentBuildingGeohash: String? {
|
||||||
|
availableChannels.first(where: { $0.level == .building })?.geohash
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||||
|
guard case .location(let selected) = selectedChannel else { return false }
|
||||||
|
return selected == channel
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBookmarked(_ geohash: String) -> Bool {
|
||||||
|
manager.isBookmarked(geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableLocationChannels() {
|
||||||
|
manager.enableLocationChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshChannels() {
|
||||||
|
manager.refreshChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
func enableAndRefresh() {
|
||||||
|
manager.enableLocationChannels()
|
||||||
|
manager.refreshChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginLiveRefresh() {
|
||||||
|
manager.beginLiveRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func endLiveRefresh() {
|
||||||
|
manager.endLiveRefresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func select(_ channel: ChannelID) {
|
||||||
|
manager.select(channel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markTeleported(for geohash: String, _ flag: Bool) {
|
||||||
|
manager.markTeleported(for: geohash, flag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleBookmark(_ geohash: String) {
|
||||||
|
manager.toggleBookmark(geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveBookmarkNameIfNeeded(for geohash: String) {
|
||||||
|
manager.resolveBookmarkNameIfNeeded(for: geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func locationName(for level: GeohashChannelLevel) -> String? {
|
||||||
|
locationNames[level]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setUserTorEnabled(_ enabled: Bool) {
|
||||||
|
network.setUserTorEnabled(enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
func refreshMeshChannelsIfNeeded() {
|
||||||
|
guard case .mesh = selectedChannel,
|
||||||
|
permissionState == .authorized,
|
||||||
|
availableChannels.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
refreshChannels()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openLocationChannel(for geohash: String) {
|
||||||
|
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
guard (2...12).contains(normalized.count),
|
||||||
|
normalized.allSatisfy({ allowed.contains($0) }) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
|
||||||
|
let isRegional = availableChannels.contains { $0.geohash == normalized }
|
||||||
|
if !isRegional && !availableChannels.isEmpty {
|
||||||
|
markTeleported(for: normalized, true)
|
||||||
|
}
|
||||||
|
select(.location(channel))
|
||||||
|
}
|
||||||
|
|
||||||
|
func teleport(to geohash: String) {
|
||||||
|
let normalized = geohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
|
||||||
|
let channel = GeohashChannel(level: level(forLength: normalized.count), geohash: normalized)
|
||||||
|
markTeleported(for: normalized, true)
|
||||||
|
select(.location(channel))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
manager.$permissionState
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$permissionState)
|
||||||
|
|
||||||
|
manager.$availableChannels
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$availableChannels)
|
||||||
|
|
||||||
|
manager.$selectedChannel
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$selectedChannel)
|
||||||
|
|
||||||
|
manager.$teleported
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$teleported)
|
||||||
|
|
||||||
|
manager.$bookmarks
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$bookmarks)
|
||||||
|
|
||||||
|
manager.$bookmarkNames
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$bookmarkNames)
|
||||||
|
|
||||||
|
manager.$locationNames
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$locationNames)
|
||||||
|
|
||||||
|
network.$userTorEnabled
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$userTorEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func level(forLength length: Int) -> GeohashChannelLevel {
|
||||||
|
switch length {
|
||||||
|
case 0...2: return .region
|
||||||
|
case 3...4: return .province
|
||||||
|
case 5: return .city
|
||||||
|
case 6: return .neighborhood
|
||||||
|
case 7: return .block
|
||||||
|
case 8...12: return .building
|
||||||
|
default: return .block
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class LocationPresenceStore: ObservableObject {
|
||||||
|
@Published private(set) var currentGeohash: String?
|
||||||
|
@Published private(set) var geoNicknames: [String: String] = [:]
|
||||||
|
@Published private(set) var teleportedGeo: Set<String> = []
|
||||||
|
|
||||||
|
func setCurrentGeohash(_ geohash: String?) {
|
||||||
|
currentGeohash = geohash?.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||||
|
geoNicknames[pubkeyHex.lowercased()] = nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||||
|
geoNicknames = Dictionary(
|
||||||
|
uniqueKeysWithValues: nicknames.map { key, value in
|
||||||
|
(key.lowercased(), value)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearGeoNicknames() {
|
||||||
|
geoNicknames.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markTeleported(_ pubkeyHex: String) {
|
||||||
|
teleportedGeo.insert(pubkeyHex.lowercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearTeleported(_ pubkeyHex: String) {
|
||||||
|
teleportedGeo.remove(pubkeyHex.lowercased())
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceTeleportedGeo(_ pubkeys: Set<String>) {
|
||||||
|
teleportedGeo = Set(pubkeys.map { $0.lowercased() })
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearTeleportedGeo() {
|
||||||
|
teleportedGeo.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
currentGeohash = nil
|
||||||
|
geoNicknames.removeAll()
|
||||||
|
teleportedGeo.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PeerIdentityStore: ObservableObject {
|
||||||
|
@Published private(set) var encryptionStatuses: [PeerID: EncryptionStatus] = [:]
|
||||||
|
@Published private(set) var verifiedFingerprints: Set<String> = []
|
||||||
|
|
||||||
|
private(set) var peerFingerprintsByPeerID: [PeerID: String] = [:]
|
||||||
|
private(set) var selectedPrivateChatFingerprint: String?
|
||||||
|
|
||||||
|
private var stablePeerIDsByShortID: [PeerID: PeerID] = [:]
|
||||||
|
private var encryptionStatusCache: [PeerID: EncryptionStatus] = [:]
|
||||||
|
|
||||||
|
func stablePeerID(forShortID peerID: PeerID) -> PeerID? {
|
||||||
|
stablePeerIDsByShortID[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortPeerID(forStablePeerID stablePeerID: PeerID) -> PeerID? {
|
||||||
|
stablePeerIDsByShortID.first(where: { $0.value == stablePeerID })?.key
|
||||||
|
}
|
||||||
|
|
||||||
|
func setStablePeerID(_ stablePeerID: PeerID, forShortID peerID: PeerID) {
|
||||||
|
stablePeerIDsByShortID[peerID] = stablePeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceStablePeerIDs(_ mappings: [PeerID: PeerID]) {
|
||||||
|
stablePeerIDsByShortID = mappings
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprint(for peerID: PeerID) -> String? {
|
||||||
|
peerFingerprintsByPeerID[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setFingerprint(_ fingerprint: String?, for peerID: PeerID) {
|
||||||
|
if let fingerprint {
|
||||||
|
peerFingerprintsByPeerID[peerID] = fingerprint
|
||||||
|
} else {
|
||||||
|
peerFingerprintsByPeerID.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceFingerprintMappings(_ mappings: [PeerID: String]) {
|
||||||
|
peerFingerprintsByPeerID = mappings
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func migrateFingerprintMapping(
|
||||||
|
from oldPeerID: PeerID,
|
||||||
|
to newPeerID: PeerID,
|
||||||
|
fallback: String? = nil
|
||||||
|
) -> String? {
|
||||||
|
let fingerprint = peerFingerprintsByPeerID.removeValue(forKey: oldPeerID) ?? fallback
|
||||||
|
if let fingerprint {
|
||||||
|
peerFingerprintsByPeerID[newPeerID] = fingerprint
|
||||||
|
if selectedPrivateChatFingerprint == nil {
|
||||||
|
selectedPrivateChatFingerprint = fingerprint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSelectedPrivateChatFingerprint(_ fingerprint: String?) {
|
||||||
|
selectedPrivateChatFingerprint = fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
func cachedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
|
||||||
|
encryptionStatusCache[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setCachedEncryptionStatus(_ status: EncryptionStatus, for peerID: PeerID) {
|
||||||
|
encryptionStatusCache[peerID] = status
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
|
||||||
|
if let peerID {
|
||||||
|
encryptionStatusCache.removeValue(forKey: peerID)
|
||||||
|
} else {
|
||||||
|
encryptionStatusCache.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func encryptionStatus(for peerID: PeerID) -> EncryptionStatus? {
|
||||||
|
encryptionStatuses[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setEncryptionStatus(_ status: EncryptionStatus?, for peerID: PeerID) {
|
||||||
|
if let status {
|
||||||
|
encryptionStatuses[peerID] = status
|
||||||
|
} else {
|
||||||
|
encryptionStatuses.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
invalidateEncryptionCache(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func replaceEncryptionStatuses(_ statuses: [PeerID: EncryptionStatus]) {
|
||||||
|
encryptionStatuses = statuses
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVerifiedFingerprints(_ fingerprints: Set<String>) {
|
||||||
|
verifiedFingerprints = fingerprints
|
||||||
|
}
|
||||||
|
|
||||||
|
func setVerified(_ fingerprint: String, verified: Bool) {
|
||||||
|
if verified {
|
||||||
|
verifiedFingerprints.insert(fingerprint)
|
||||||
|
} else {
|
||||||
|
verifiedFingerprints.remove(fingerprint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVerified(_ fingerprint: String) -> Bool {
|
||||||
|
verifiedFingerprints.contains(fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() {
|
||||||
|
encryptionStatuses.removeAll()
|
||||||
|
verifiedFingerprints.removeAll()
|
||||||
|
peerFingerprintsByPeerID.removeAll()
|
||||||
|
selectedPrivateChatFingerprint = nil
|
||||||
|
stablePeerIDsByShortID.removeAll()
|
||||||
|
encryptionStatusCache.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MeshPeerRow: Identifiable, Equatable {
|
||||||
|
let peerID: PeerID
|
||||||
|
let displayName: String
|
||||||
|
let isMe: Bool
|
||||||
|
let hasUnread: Bool
|
||||||
|
let isBlocked: Bool
|
||||||
|
let isFavorite: Bool
|
||||||
|
let isConnected: Bool
|
||||||
|
let isReachable: Bool
|
||||||
|
let isMutualFavorite: Bool
|
||||||
|
let encryptionStatus: EncryptionStatus
|
||||||
|
let showsVerifiedBadgeWhenOffline: Bool
|
||||||
|
|
||||||
|
var id: String { peerID.id }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GeohashPersonRow: Identifiable, Equatable {
|
||||||
|
let id: String
|
||||||
|
let displayName: String
|
||||||
|
let isMe: Bool
|
||||||
|
let isTeleported: Bool
|
||||||
|
let isBlocked: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PeerListModel: ObservableObject {
|
||||||
|
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||||
|
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||||
|
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||||
|
@Published private(set) var reachableMeshPeerCount = 0
|
||||||
|
@Published private(set) var connectedMeshPeerCount = 0
|
||||||
|
@Published private(set) var visibleGeohashPeerCount = 0
|
||||||
|
@Published private(set) var renderID = ""
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private let conversations: ConversationStore
|
||||||
|
private let locationChannelsModel: LocationChannelsModel
|
||||||
|
private let peerIdentityStore: PeerIdentityStore
|
||||||
|
private let locationPresenceStore: LocationPresenceStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
conversations: ConversationStore,
|
||||||
|
locationChannelsModel: LocationChannelsModel? = nil,
|
||||||
|
peerIdentityStore: PeerIdentityStore? = nil,
|
||||||
|
locationPresenceStore: LocationPresenceStore? = nil
|
||||||
|
) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.conversations = conversations
|
||||||
|
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||||
|
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||||
|
self.locationPresenceStore = locationPresenceStore ?? chatViewModel.locationPresenceStore
|
||||||
|
self.allPeers = chatViewModel.allPeers
|
||||||
|
|
||||||
|
bind()
|
||||||
|
refresh()
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
|
||||||
|
chatViewModel.colorForMeshPeer(id: peerID, isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorForGeohashPerson(id: String, isDark: Bool) -> Color {
|
||||||
|
chatViewModel.colorForNostrPubkey(id, isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func participantCount(for geohash: String) -> Int {
|
||||||
|
chatViewModel.geohashParticipantCount(for: geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startConversation(with peerID: PeerID) {
|
||||||
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavorite(peerID: PeerID) {
|
||||||
|
chatViewModel.toggleFavorite(peerID: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func openGeohashDirectMessage(with pubkeyHex: String) {
|
||||||
|
chatViewModel.startGeohashDM(withPubkeyHex: pubkeyHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||||
|
chatViewModel.blockGeohashUser(
|
||||||
|
pubkeyHexLowercased: pubkeyHexLowercased,
|
||||||
|
displayName: displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||||
|
chatViewModel.unblockGeohashUser(
|
||||||
|
pubkeyHexLowercased: pubkeyHexLowercased,
|
||||||
|
displayName: displayName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
chatViewModel.$allPeers
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] peers in
|
||||||
|
self?.allPeers = peers
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$nickname
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationPresenceStore.$teleportedGeo
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
conversations.$unreadConversations
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
peerIdentityStore.$encryptionStatuses
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
peerIdentityStore.$verifiedFingerprints
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.participantTracker.$visiblePeople
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationChannelsModel.$selectedChannel
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationChannelsModel.$teleported
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationChannelsModel.$availableChannels
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refresh()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refresh() {
|
||||||
|
let myPeerID = chatViewModel.meshService.myPeerID
|
||||||
|
let meshRows = allPeers.map { peer in
|
||||||
|
let isMe = peer.peerID == myPeerID
|
||||||
|
let verifiedBadge: Bool
|
||||||
|
if !isMe && !peer.isConnected,
|
||||||
|
let fingerprint = chatViewModel.getFingerprint(for: peer.peerID) {
|
||||||
|
verifiedBadge = peerIdentityStore.isVerified(fingerprint)
|
||||||
|
} else {
|
||||||
|
verifiedBadge = false
|
||||||
|
}
|
||||||
|
|
||||||
|
return MeshPeerRow(
|
||||||
|
peerID: peer.peerID,
|
||||||
|
displayName: isMe ? chatViewModel.nickname : peer.nickname,
|
||||||
|
isMe: isMe,
|
||||||
|
hasUnread: chatViewModel.hasUnreadMessages(for: peer.peerID),
|
||||||
|
isBlocked: !isMe && chatViewModel.isPeerBlocked(peer.peerID),
|
||||||
|
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
|
||||||
|
isConnected: peer.isConnected,
|
||||||
|
isReachable: peer.isReachable,
|
||||||
|
isMutualFavorite: peer.isMutualFavorite,
|
||||||
|
encryptionStatus: chatViewModel.getEncryptionStatus(for: peer.peerID),
|
||||||
|
showsVerifiedBadgeWhenOffline: verifiedBadge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let meshCounts = meshRows.reduce(into: (reachable: 0, connected: 0)) { counts, row in
|
||||||
|
guard !row.isMe else { return }
|
||||||
|
if row.isConnected {
|
||||||
|
counts.connected += 1
|
||||||
|
counts.reachable += 1
|
||||||
|
} else if row.isReachable {
|
||||||
|
counts.reachable += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let geohashPeople = buildGeohashPeople()
|
||||||
|
|
||||||
|
self.meshRows = meshRows
|
||||||
|
reachableMeshPeerCount = meshCounts.reachable
|
||||||
|
connectedMeshPeerCount = meshCounts.connected
|
||||||
|
self.geohashPeople = geohashPeople
|
||||||
|
visibleGeohashPeerCount = geohashPeople.count
|
||||||
|
renderID = (
|
||||||
|
meshRows.map {
|
||||||
|
"\($0.id)-\($0.isConnected)-\($0.isReachable)-\($0.hasUnread)-\($0.isFavorite)-\($0.isBlocked)"
|
||||||
|
} +
|
||||||
|
geohashPeople.map {
|
||||||
|
"geo:\($0.id)-\($0.isTeleported)-\($0.isBlocked)-\($0.displayName)"
|
||||||
|
}
|
||||||
|
).joined(separator: "|")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func buildGeohashPeople() -> [GeohashPersonRow] {
|
||||||
|
let myHex = currentGeohashIdentityHex()
|
||||||
|
let teleportedSet = Set(locationPresenceStore.teleportedGeo.map { $0.lowercased() })
|
||||||
|
|
||||||
|
return chatViewModel.visibleGeohashPeople().map { person in
|
||||||
|
let isMe = person.id == myHex
|
||||||
|
return GeohashPersonRow(
|
||||||
|
id: person.id,
|
||||||
|
displayName: person.displayName,
|
||||||
|
isMe: isMe,
|
||||||
|
isTeleported: teleportedSet.contains(person.id.lowercased()) || (isMe && locationChannelsModel.teleported),
|
||||||
|
isBlocked: !isMe && chatViewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentGeohashIdentityHex() -> String? {
|
||||||
|
guard case .location(let channel) = locationChannelsModel.selectedChannel,
|
||||||
|
let identity = try? chatViewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return identity.publicKeyHex.lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,323 @@
|
|||||||
|
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> = []
|
||||||
|
|
||||||
|
private let conversations: ConversationStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(conversations: ConversationStore) {
|
||||||
|
self.conversations = conversations
|
||||||
|
self.selectedPeerID = conversations.selectedPrivatePeerID
|
||||||
|
self.unreadPeerIDs = conversations.unreadDirectRoutingPeerIDs()
|
||||||
|
|
||||||
|
bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
func messages(for peerID: PeerID?) -> [BitchatMessage] {
|
||||||
|
guard let peerID else { return [] }
|
||||||
|
return conversations.conversationsByID[.directPeer(peerID)]?.messages ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
conversations.$selectedPrivatePeerID
|
||||||
|
.dropFirst()
|
||||||
|
.sink { [weak self] peerID in
|
||||||
|
guard let self, self.selectedPeerID != peerID else { return }
|
||||||
|
self.selectedPeerID = peerID
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
conversations.changes
|
||||||
|
.sink { [weak self] change in
|
||||||
|
self?.apply(change)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
case .unreadChanged(let id, _):
|
||||||
|
guard isDirect(id) else { return }
|
||||||
|
refreshUnreadPeerIDs()
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum PrivateConversationAvailability: Equatable {
|
||||||
|
case bluetoothConnected
|
||||||
|
case meshReachable
|
||||||
|
case nostrAvailable
|
||||||
|
case offline
|
||||||
|
}
|
||||||
|
|
||||||
|
struct PrivateConversationHeaderState: Equatable {
|
||||||
|
let conversationPeerID: PeerID
|
||||||
|
let headerPeerID: PeerID
|
||||||
|
let displayName: String
|
||||||
|
let availability: PrivateConversationAvailability
|
||||||
|
let isFavorite: Bool
|
||||||
|
let encryptionStatus: EncryptionStatus?
|
||||||
|
|
||||||
|
var supportsFavoriteToggle: Bool {
|
||||||
|
!conversationPeerID.isGeoDM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class PrivateConversationModel: ObservableObject {
|
||||||
|
@Published private(set) var selectedPeerID: PeerID?
|
||||||
|
@Published private(set) var selectedHeaderState: PrivateConversationHeaderState?
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private let conversations: ConversationStore
|
||||||
|
private let locationChannelsModel: LocationChannelsModel
|
||||||
|
private let peerIdentityStore: PeerIdentityStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
conversations: ConversationStore,
|
||||||
|
locationChannelsModel: LocationChannelsModel? = nil,
|
||||||
|
peerIdentityStore: PeerIdentityStore? = nil
|
||||||
|
) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.conversations = conversations
|
||||||
|
self.locationChannelsModel = locationChannelsModel ?? LocationChannelsModel()
|
||||||
|
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||||
|
let initialPeerID = conversations.selectedPrivatePeerID
|
||||||
|
self.selectedPeerID = initialPeerID
|
||||||
|
self.selectedHeaderState = initialPeerID.flatMap { peerID in
|
||||||
|
makeHeaderState(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
func startConversation(with peerID: PeerID) {
|
||||||
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
|
refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func openConversation(for peerID: PeerID) {
|
||||||
|
if peerID.isGeoChat {
|
||||||
|
guard let full = chatViewModel.fullNostrHex(forSenderPeerID: peerID) else { return }
|
||||||
|
chatViewModel.startGeohashDM(withPubkeyHex: full)
|
||||||
|
} else {
|
||||||
|
chatViewModel.startPrivateChat(with: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func endConversation() {
|
||||||
|
chatViewModel.endPrivateChat()
|
||||||
|
refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavorite(peerID: PeerID) {
|
||||||
|
chatViewModel.toggleFavorite(peerID: peerID)
|
||||||
|
refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleFavoriteForSelectedConversation() {
|
||||||
|
guard let headerPeerID = selectedHeaderState?.headerPeerID else { return }
|
||||||
|
toggleFavorite(peerID: headerPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markMessagesAsRead(from peerID: PeerID) {
|
||||||
|
chatViewModel.markPrivateMessagesAsRead(from: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
conversations.$selectedPrivatePeerID
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$allPeers
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
peerIdentityStore.$encryptionStatuses
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: .favoriteStatusChanged)
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
locationChannelsModel.$selectedChannel
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.refreshSelectedConversation()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshSelectedConversation() {
|
||||||
|
selectedPeerID = conversations.selectedPrivatePeerID
|
||||||
|
selectedHeaderState = selectedPeerID.flatMap { peerID in
|
||||||
|
makeHeaderState(for: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeHeaderState(for conversationPeerID: PeerID) -> PrivateConversationHeaderState {
|
||||||
|
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)
|
||||||
|
let encryptionStatus: EncryptionStatus? = conversationPeerID.isGeoDM
|
||||||
|
? nil
|
||||||
|
: chatViewModel.getEncryptionStatus(for: headerPeerID)
|
||||||
|
|
||||||
|
return PrivateConversationHeaderState(
|
||||||
|
conversationPeerID: conversationPeerID,
|
||||||
|
headerPeerID: headerPeerID,
|
||||||
|
displayName: displayName,
|
||||||
|
availability: availability,
|
||||||
|
isFavorite: chatViewModel.isFavorite(peerID: headerPeerID),
|
||||||
|
encryptionStatus: encryptionStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveDisplayName(
|
||||||
|
for conversationPeerID: PeerID,
|
||||||
|
headerPeerID: PeerID,
|
||||||
|
peer: BitchatPeer?
|
||||||
|
) -> String {
|
||||||
|
if conversationPeerID.isGeoDM, case .location(let channel) = locationChannelsModel.selectedChannel {
|
||||||
|
return "#\(channel.geohash)/@\(chatViewModel.geohashDisplayName(for: conversationPeerID))"
|
||||||
|
}
|
||||||
|
if let displayName = peer?.displayName {
|
||||||
|
return displayName
|
||||||
|
}
|
||||||
|
if let nickname = chatViewModel.meshService.peerNickname(peerID: headerPeerID) {
|
||||||
|
return nickname
|
||||||
|
}
|
||||||
|
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(
|
||||||
|
for: Data(hexString: headerPeerID.id) ?? Data()
|
||||||
|
), !favorite.peerNickname.isEmpty {
|
||||||
|
return favorite.peerNickname
|
||||||
|
}
|
||||||
|
if headerPeerID.id.count == 16 {
|
||||||
|
let candidates = chatViewModel.identityManager.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
|
||||||
|
if let identity = candidates.first,
|
||||||
|
let social = chatViewModel.identityManager.getSocialIdentity(for: identity.fingerprint) {
|
||||||
|
if let pet = social.localPetname, !pet.isEmpty {
|
||||||
|
return pet
|
||||||
|
}
|
||||||
|
if !social.claimedNickname.isEmpty {
|
||||||
|
return social.claimedNickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if let noiseKey = headerPeerID.noiseKey {
|
||||||
|
let fingerprint = noiseKey.sha256Fingerprint()
|
||||||
|
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
||||||
|
if let pet = social.localPetname, !pet.isEmpty {
|
||||||
|
return pet
|
||||||
|
}
|
||||||
|
if !social.claimedNickname.isEmpty {
|
||||||
|
return social.claimedNickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(localized: "common.unknown", comment: "Fallback label for unknown peer")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveAvailability(for headerPeerID: PeerID, peer: BitchatPeer?) -> PrivateConversationAvailability {
|
||||||
|
if let connectionState = peer?.connectionState {
|
||||||
|
switch connectionState {
|
||||||
|
case .bluetoothConnected:
|
||||||
|
return .bluetoothConnected
|
||||||
|
case .meshReachable:
|
||||||
|
return .meshReachable
|
||||||
|
case .nostrAvailable:
|
||||||
|
return .nostrAvailable
|
||||||
|
case .offline:
|
||||||
|
return .offline
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if chatViewModel.meshService.isPeerReachable(headerPeerID) {
|
||||||
|
return .meshReachable
|
||||||
|
}
|
||||||
|
if let noiseKey = Data(hexString: headerPeerID.id),
|
||||||
|
let favoriteStatus = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
|
||||||
|
favoriteStatus.isMutual {
|
||||||
|
return .nostrAvailable
|
||||||
|
}
|
||||||
|
if chatViewModel.meshService.isPeerConnected(headerPeerID) || chatViewModel.connectedPeers.contains(headerPeerID) {
|
||||||
|
return .bluetoothConnected
|
||||||
|
}
|
||||||
|
|
||||||
|
return .offline
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/// 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(conversations: ConversationStore) {
|
||||||
|
let channel = conversations.activeChannel
|
||||||
|
self.conversations = conversations
|
||||||
|
self.activeChannel = channel
|
||||||
|
self.activeConversation = conversations.conversation(for: ConversationID(channelID: channel))
|
||||||
|
|
||||||
|
observeActiveConversation()
|
||||||
|
bind()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind() {
|
||||||
|
conversations.$activeChannel
|
||||||
|
.dropFirst()
|
||||||
|
.sink { [weak self] channel in
|
||||||
|
guard let self else { return }
|
||||||
|
self.activeChannel = channel
|
||||||
|
self.retargetActiveConversation(to: channel)
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
// 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 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct FingerprintPresentationState: Equatable {
|
||||||
|
let statusPeerID: PeerID
|
||||||
|
let peerNickname: String
|
||||||
|
let encryptionStatus: EncryptionStatus
|
||||||
|
let theirFingerprint: String?
|
||||||
|
let myFingerprint: String
|
||||||
|
let isVerified: Bool
|
||||||
|
|
||||||
|
var canToggleVerification: Bool {
|
||||||
|
encryptionStatus == .noiseSecured || encryptionStatus == .noiseVerified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum VerificationScanOutcome: Equatable {
|
||||||
|
case requested(String)
|
||||||
|
case notFound
|
||||||
|
case invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class VerificationModel: ObservableObject {
|
||||||
|
@Published private(set) var currentNickname: String
|
||||||
|
@Published private(set) var selectedPeerID: PeerID?
|
||||||
|
|
||||||
|
private let chatViewModel: ChatViewModel
|
||||||
|
private let peerIdentityStore: PeerIdentityStore
|
||||||
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
init(
|
||||||
|
chatViewModel: ChatViewModel,
|
||||||
|
privateConversationModel: PrivateConversationModel,
|
||||||
|
peerIdentityStore: PeerIdentityStore? = nil
|
||||||
|
) {
|
||||||
|
self.chatViewModel = chatViewModel
|
||||||
|
self.peerIdentityStore = peerIdentityStore ?? chatViewModel.peerIdentityStore
|
||||||
|
self.currentNickname = chatViewModel.nickname
|
||||||
|
self.selectedPeerID = privateConversationModel.selectedPeerID
|
||||||
|
|
||||||
|
bind(privateConversationModel: privateConversationModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
func myQRString() -> String {
|
||||||
|
let npub = try? chatViewModel.idBridge.getCurrentNostrIdentity()?.npub
|
||||||
|
return VerificationService.shared.buildMyQRString(nickname: currentNickname, npub: npub) ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginQRVerification(with qr: VerificationService.VerificationQR) -> Bool {
|
||||||
|
chatViewModel.beginQRVerification(with: qr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyScannedPayload(_ payload: String) -> VerificationScanOutcome {
|
||||||
|
guard let qr = VerificationService.shared.verifyScannedQR(payload) else {
|
||||||
|
return .invalid
|
||||||
|
}
|
||||||
|
|
||||||
|
guard chatViewModel.beginQRVerification(with: qr) else {
|
||||||
|
return .notFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return .requested(qr.nickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func verifyFingerprint(for peerID: PeerID) {
|
||||||
|
chatViewModel.verifyFingerprint(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unverifyFingerprint(for peerID: PeerID) {
|
||||||
|
chatViewModel.unverifyFingerprint(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isVerified(peerID: PeerID) -> Bool {
|
||||||
|
guard let fingerprint = chatViewModel.getFingerprint(for: peerID) else { return false }
|
||||||
|
return peerIdentityStore.isVerified(fingerprint)
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintPresentation(for peerID: PeerID) -> FingerprintPresentationState {
|
||||||
|
let statusPeerID = chatViewModel.getShortIDForNoiseKey(peerID)
|
||||||
|
let encryptionStatus = chatViewModel.getEncryptionStatus(for: statusPeerID)
|
||||||
|
let theirFingerprint = chatViewModel.getFingerprint(for: statusPeerID)
|
||||||
|
let peerNickname = resolveDisplayName(for: peerID, statusPeerID: statusPeerID)
|
||||||
|
|
||||||
|
return FingerprintPresentationState(
|
||||||
|
statusPeerID: statusPeerID,
|
||||||
|
peerNickname: peerNickname,
|
||||||
|
encryptionStatus: encryptionStatus,
|
||||||
|
theirFingerprint: theirFingerprint,
|
||||||
|
myFingerprint: chatViewModel.getMyFingerprint(),
|
||||||
|
isVerified: theirFingerprint.map { peerIdentityStore.isVerified($0) } ?? false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func bind(privateConversationModel: PrivateConversationModel) {
|
||||||
|
chatViewModel.$nickname
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$currentNickname)
|
||||||
|
|
||||||
|
privateConversationModel.$selectedPeerID
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.assign(to: &$selectedPeerID)
|
||||||
|
|
||||||
|
peerIdentityStore.$encryptionStatuses
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
peerIdentityStore.$verifiedFingerprints
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
chatViewModel.$allPeers
|
||||||
|
.receive(on: DispatchQueue.main)
|
||||||
|
.sink { [weak self] _ in
|
||||||
|
self?.objectWillChange.send()
|
||||||
|
}
|
||||||
|
.store(in: &cancellables)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveDisplayName(for peerID: PeerID, statusPeerID: PeerID) -> String {
|
||||||
|
if let peer = chatViewModel.getPeer(byID: statusPeerID) {
|
||||||
|
return peer.displayName
|
||||||
|
}
|
||||||
|
if let name = chatViewModel.meshService.peerNickname(peerID: statusPeerID) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if let data = peerID.noiseKey {
|
||||||
|
if let favorite = FavoritesPersistenceService.shared.getFavoriteStatus(for: data),
|
||||||
|
!favorite.peerNickname.isEmpty {
|
||||||
|
return favorite.peerNickname
|
||||||
|
}
|
||||||
|
let fingerprint = data.sha256Fingerprint()
|
||||||
|
if let social = chatViewModel.identityManager.getSocialIdentity(for: fingerprint) {
|
||||||
|
if let pet = social.localPetname, !pet.isEmpty {
|
||||||
|
return pet
|
||||||
|
}
|
||||||
|
if !social.claimedNickname.isEmpty {
|
||||||
|
return social.claimedNickname
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(localized: "common.unknown", comment: "Label for an unknown peer")
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
-189
@@ -6,9 +6,7 @@
|
|||||||
// For more information, see <https://unlicense.org>
|
// For more information, see <https://unlicense.org>
|
||||||
//
|
//
|
||||||
|
|
||||||
import Tor
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import BitFoundation
|
|
||||||
import UserNotifications
|
import UserNotifications
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@@ -16,118 +14,49 @@ struct BitchatApp: App {
|
|||||||
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
static let bundleID = Bundle.main.bundleIdentifier ?? "chat.bitchat"
|
||||||
static let groupID = "group.\(bundleID)"
|
static let groupID = "group.\(bundleID)"
|
||||||
|
|
||||||
@StateObject private var chatViewModel: ChatViewModel
|
@StateObject private var runtime: AppRuntime
|
||||||
|
@AppStorage(AppTheme.storageKey) private var appThemeRawValue = AppTheme.matrix.rawValue
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
@Environment(\.scenePhase) var scenePhase
|
@Environment(\.scenePhase) var scenePhase
|
||||||
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
|
||||||
// Skip the very first .active-triggered Tor restart on cold launch
|
|
||||||
@State private var didHandleInitialActive: Bool = false
|
|
||||||
@State private var didEnterBackground: Bool = false
|
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
@NSApplicationDelegateAdaptor(MacAppDelegate.self) var appDelegate
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private let idBridge = NostrIdentityBridge()
|
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
let keychain = KeychainManager()
|
_runtime = StateObject(wrappedValue: AppRuntime())
|
||||||
let idBridge = self.idBridge
|
|
||||||
_chatViewModel = StateObject(
|
|
||||||
wrappedValue: ChatViewModel(
|
|
||||||
keychain: keychain,
|
|
||||||
idBridge: idBridge,
|
|
||||||
identityManager: SecureIdentityStateManager(keychain)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
|
||||||
// Warm up georelay directory and refresh if stale (once/day)
|
|
||||||
GeoRelayDirectory.shared.prefetchIfNeeded()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some Scene {
|
var body: some Scene {
|
||||||
WindowGroup {
|
WindowGroup {
|
||||||
ContentView()
|
ContentView()
|
||||||
.environmentObject(chatViewModel)
|
.environment(\.appTheme, AppTheme(rawValue: appThemeRawValue) ?? .matrix)
|
||||||
|
.environmentObject(runtime.publicChatModel)
|
||||||
|
.environmentObject(runtime.privateInboxModel)
|
||||||
|
.environmentObject(runtime.privateConversationModel)
|
||||||
|
.environmentObject(runtime.verificationModel)
|
||||||
|
.environmentObject(runtime.conversationUIModel)
|
||||||
|
.environmentObject(runtime.locationChannelsModel)
|
||||||
|
.environmentObject(runtime.peerListModel)
|
||||||
|
.environmentObject(runtime.appChromeModel)
|
||||||
.onAppear {
|
.onAppear {
|
||||||
NotificationDelegate.shared.chatViewModel = chatViewModel
|
appDelegate.runtime = runtime
|
||||||
// Inject live Noise service into VerificationService to avoid creating new BLE instances
|
runtime.start()
|
||||||
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
|
|
||||||
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
|
|
||||||
let nickname = chatViewModel.nickname
|
|
||||||
DispatchQueue.global(qos: .utility).async {
|
|
||||||
let npub = try? idBridge.getCurrentNostrIdentity()?.npub
|
|
||||||
_ = VerificationService.shared.buildMyQRString(nickname: nickname, npub: npub)
|
|
||||||
}
|
|
||||||
|
|
||||||
appDelegate.chatViewModel = chatViewModel
|
|
||||||
|
|
||||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
|
||||||
NetworkActivationService.shared.start()
|
|
||||||
|
|
||||||
// Start presence service (will wait for Tor readiness)
|
|
||||||
GeohashPresenceService.shared.start()
|
|
||||||
|
|
||||||
// Check for shared content
|
|
||||||
checkForSharedContent()
|
|
||||||
}
|
}
|
||||||
.onOpenURL { url in
|
.onOpenURL { url in
|
||||||
handleURL(url)
|
runtime.handleOpenURL(url)
|
||||||
}
|
}
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
.onChange(of: scenePhase) { newPhase in
|
.onChange(of: scenePhase) { newPhase in
|
||||||
switch newPhase {
|
runtime.handleScenePhaseChange(newPhase)
|
||||||
case .background:
|
|
||||||
// Keep BLE mesh running in background; BLEService adapts scanning automatically
|
|
||||||
// Always send Tor to dormant on background for a clean restart later.
|
|
||||||
TorManager.shared.setAppForeground(false)
|
|
||||||
TorManager.shared.goDormantOnBackground()
|
|
||||||
// Stop geohash sampling while backgrounded
|
|
||||||
Task { @MainActor in
|
|
||||||
chatViewModel.endGeohashSampling()
|
|
||||||
}
|
|
||||||
// Proactively disconnect Nostr to avoid spurious socket errors while Tor is down
|
|
||||||
NostrRelayManager.shared.disconnect()
|
|
||||||
didEnterBackground = true
|
|
||||||
case .active:
|
|
||||||
// Restart services when becoming active
|
|
||||||
chatViewModel.meshService.startServices()
|
|
||||||
TorManager.shared.setAppForeground(true)
|
|
||||||
// On initial cold launch, Tor was just started in onAppear.
|
|
||||||
// Skip the deterministic restart the first time we become active.
|
|
||||||
if didHandleInitialActive && didEnterBackground {
|
|
||||||
if TorManager.shared.isAutoStartAllowed() && !TorManager.shared.isReady {
|
|
||||||
TorManager.shared.ensureRunningOnForeground()
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
didHandleInitialActive = true
|
|
||||||
}
|
|
||||||
didEnterBackground = false
|
|
||||||
if TorManager.shared.isAutoStartAllowed() {
|
|
||||||
Task.detached {
|
|
||||||
let _ = await TorManager.shared.awaitReady(timeout: 60)
|
|
||||||
await MainActor.run {
|
|
||||||
// Rebuild proxied sessions to bind to the live Tor after readiness
|
|
||||||
TorURLSession.shared.rebuild()
|
|
||||||
// Reconnect Nostr via fresh sessions; will gate until Tor 100%
|
|
||||||
NostrRelayManager.shared.resetAllConnections()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checkForSharedContent()
|
|
||||||
case .inactive:
|
|
||||||
break
|
|
||||||
@unknown default:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification)) { _ in
|
||||||
// Check for shared content when app becomes active
|
runtime.handleDidBecomeActiveNotification()
|
||||||
checkForSharedContent()
|
|
||||||
}
|
}
|
||||||
#elseif os(macOS)
|
#elseif os(macOS)
|
||||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
|
||||||
// App became active
|
runtime.handleMacDidBecomeActiveNotification()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
@@ -136,66 +65,18 @@ struct BitchatApp: App {
|
|||||||
.windowResizability(.contentSize)
|
.windowResizability(.contentSize)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleURL(_ url: URL) {
|
|
||||||
if url.scheme == "bitchat" && url.host == "share" {
|
|
||||||
// Handle shared content
|
|
||||||
checkForSharedContent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func checkForSharedContent() {
|
|
||||||
// Check app group for shared content from extension
|
|
||||||
guard let userDefaults = UserDefaults(suiteName: BitchatApp.groupID) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let sharedContent = userDefaults.string(forKey: "sharedContent"),
|
|
||||||
let sharedDate = userDefaults.object(forKey: "sharedContentDate") as? Date else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only process if shared within configured window
|
|
||||||
if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
|
|
||||||
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
|
|
||||||
|
|
||||||
// Clear the shared content
|
|
||||||
userDefaults.removeObject(forKey: "sharedContent")
|
|
||||||
userDefaults.removeObject(forKey: "sharedContentType")
|
|
||||||
userDefaults.removeObject(forKey: "sharedContentDate")
|
|
||||||
// No need to force synchronize here
|
|
||||||
|
|
||||||
// Send the shared content immediately on the main queue
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if contentType == "url" {
|
|
||||||
// Try to parse as JSON first
|
|
||||||
if let data = sharedContent.data(using: .utf8),
|
|
||||||
let urlData = try? JSONSerialization.jsonObject(with: data) as? [String: String],
|
|
||||||
let url = urlData["url"] {
|
|
||||||
// Send plain URL
|
|
||||||
self.chatViewModel.sendMessage(url)
|
|
||||||
} else {
|
|
||||||
// Fallback to simple URL
|
|
||||||
self.chatViewModel.sendMessage(sharedContent)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
self.chatViewModel.sendMessage(sharedContent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
final class AppDelegate: NSObject, UIApplicationDelegate {
|
final class AppDelegate: NSObject, UIApplicationDelegate {
|
||||||
weak var chatViewModel: ChatViewModel?
|
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 {
|
||||||
return true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationWillTerminate(_ application: UIApplication) {
|
func applicationWillTerminate(_ application: UIApplication) {
|
||||||
chatViewModel?.applicationWillTerminate()
|
runtime?.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -204,44 +85,29 @@ final class AppDelegate: NSObject, UIApplicationDelegate {
|
|||||||
import AppKit
|
import AppKit
|
||||||
|
|
||||||
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
final class MacAppDelegate: NSObject, NSApplicationDelegate {
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var runtime: AppRuntime?
|
||||||
|
|
||||||
func applicationWillTerminate(_ notification: Notification) {
|
func applicationWillTerminate(_ notification: Notification) {
|
||||||
chatViewModel?.applicationWillTerminate()
|
runtime?.applicationWillTerminate()
|
||||||
}
|
}
|
||||||
|
|
||||||
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
|
||||||
return true
|
true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
||||||
static let shared = NotificationDelegate()
|
static let shared = NotificationDelegate()
|
||||||
weak var chatViewModel: ChatViewModel?
|
weak var runtime: AppRuntime?
|
||||||
|
|
||||||
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) {
|
||||||
let identifier = response.notification.request.identifier
|
let identifier = response.notification.request.identifier
|
||||||
let userInfo = response.notification.request.content.userInfo
|
let userInfo = response.notification.request.content.userInfo
|
||||||
|
|
||||||
// Check if this is a private message notification
|
Task { @MainActor in
|
||||||
if identifier.hasPrefix("private-") {
|
self.runtime?.handleNotificationResponse(identifier: identifier, userInfo: userInfo)
|
||||||
// Get peer ID from userInfo
|
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.chatViewModel?.startPrivateChat(with: PeerID(str: peerID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Handle deeplink (e.g., geohash activity)
|
|
||||||
if let deep = userInfo["deeplink"] as? String, let url = URL(string: deep) {
|
|
||||||
#if os(iOS)
|
|
||||||
DispatchQueue.main.async { UIApplication.shared.open(url) }
|
|
||||||
#else
|
|
||||||
DispatchQueue.main.async { NSWorkspace.shared.open(url) }
|
|
||||||
#endif
|
|
||||||
}
|
|
||||||
|
|
||||||
completionHandler()
|
completionHandler()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -249,34 +115,12 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
|
|||||||
let identifier = notification.request.identifier
|
let identifier = notification.request.identifier
|
||||||
let userInfo = notification.request.content.userInfo
|
let userInfo = notification.request.content.userInfo
|
||||||
|
|
||||||
// Check if this is a private message notification
|
Task {
|
||||||
if identifier.hasPrefix("private-") {
|
let options = await self.runtime?.presentationOptions(
|
||||||
// Get peer ID from userInfo
|
forNotificationIdentifier: identifier,
|
||||||
if let peerID = userInfo["peerID"] as? String {
|
userInfo: userInfo
|
||||||
// Don't show notification if the private chat is already open
|
) ?? [.banner, .sound]
|
||||||
// Access main-actor-isolated property via Task
|
completionHandler(options)
|
||||||
Task { @MainActor in
|
|
||||||
if self.chatViewModel?.selectedPrivateChatPeer == PeerID(str: peerID) {
|
|
||||||
completionHandler([])
|
|
||||||
} else {
|
|
||||||
completionHandler([.banner, .sound])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// Suppress geohash activity notification if we're already in that geohash channel
|
|
||||||
if identifier.hasPrefix("geo-activity-"),
|
|
||||||
let deep = userInfo["deeplink"] as? String,
|
|
||||||
let gh = deep.components(separatedBy: "/").last {
|
|
||||||
if case .location(let ch) = LocationChannelManager.shared.selectedChannel, ch.geohash == gh {
|
|
||||||
completionHandler([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show notification in all other cases
|
|
||||||
completionHandler([.banner, .sound])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,30 +15,39 @@ enum ImageUtilsError: Error {
|
|||||||
enum ImageUtils {
|
enum ImageUtils {
|
||||||
private static let compressionQuality: CGFloat = 0.82
|
private static let compressionQuality: CGFloat = 0.82
|
||||||
private static let targetImageBytes: Int = 45_000
|
private static let targetImageBytes: Int = 45_000
|
||||||
|
private static let maxSourceImageBytes: Int = 10 * 1024 * 1024
|
||||||
|
|
||||||
static func processImage(at url: URL, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(at url: URL, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||||
// Security H1: Check file size BEFORE reading into memory
|
try validateImageSource(at: url)
|
||||||
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
|
||||||
guard let fileSize = attrs[.size] as? Int else {
|
|
||||||
throw ImageUtilsError.invalidImage
|
|
||||||
}
|
|
||||||
// Allow up to 10MB source images (will be scaled down)
|
|
||||||
guard fileSize <= 10 * 1024 * 1024 else {
|
|
||||||
throw ImageUtilsError.invalidImage
|
|
||||||
}
|
|
||||||
|
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
guard let image = UIImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
return try processImage(image, maxDimension: maxDimension)
|
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||||
#else
|
#else
|
||||||
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
guard let image = NSImage(data: data) else { throw ImageUtilsError.invalidImage }
|
||||||
return try processImage(image, maxDimension: maxDimension)
|
return try processImage(image, maxDimension: maxDimension, outputDirectory: outputDirectory)
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func validateImageSource(at url: URL) throws {
|
||||||
|
// Security H1: Check file size BEFORE reading into memory.
|
||||||
|
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
|
guard let fileSize = attrs[.size] as? Int,
|
||||||
|
fileSize > 0,
|
||||||
|
fileSize <= maxSourceImageBytes else {
|
||||||
|
throw ImageUtilsError.invalidImage
|
||||||
|
}
|
||||||
|
|
||||||
|
let options = [kCGImageSourceShouldCache: false] as CFDictionary
|
||||||
|
guard let source = CGImageSourceCreateWithURL(url as CFURL, options),
|
||||||
|
CGImageSourceGetType(source) != nil else {
|
||||||
|
throw ImageUtilsError.invalidImage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(_ image: UIImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
// Scale the image first
|
// Scale the image first
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
@@ -64,7 +73,7 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let outputURL = try makeOutputURL()
|
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
@@ -106,7 +115,7 @@ enum ImageUtils {
|
|||||||
return data as Data
|
return data as Data
|
||||||
}
|
}
|
||||||
#else
|
#else
|
||||||
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448) throws -> URL {
|
static func processImage(_ image: NSImage, maxDimension: CGFloat = 448, outputDirectory: URL? = nil) throws -> URL {
|
||||||
return try autoreleasepool {
|
return try autoreleasepool {
|
||||||
let scaled = scaledImage(image, maxDimension: maxDimension)
|
let scaled = scaledImage(image, maxDimension: maxDimension)
|
||||||
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
guard let inputCG = scaled.cgImage(forProposedRect: nil, context: nil, hints: nil) else {
|
||||||
@@ -142,7 +151,7 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let outputURL = try makeOutputURL()
|
let outputURL = try makeOutputURL(outputDirectory: outputDirectory)
|
||||||
try jpegData.write(to: outputURL, options: .atomic)
|
try jpegData.write(to: outputURL, options: .atomic)
|
||||||
return outputURL
|
return outputURL
|
||||||
}
|
}
|
||||||
@@ -186,12 +195,17 @@ enum ImageUtils {
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
private static func makeOutputURL() throws -> URL {
|
private static func makeOutputURL(outputDirectory: URL? = nil) throws -> URL {
|
||||||
let formatter = DateFormatter()
|
let formatter = DateFormatter()
|
||||||
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
let fileName = "img_\(formatter.string(from: Date())).jpg"
|
let fileName = "img_\(formatter.string(from: Date()))_\(UUID().uuidString).jpg"
|
||||||
|
|
||||||
let directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
let directory: URL
|
||||||
|
if let outputDirectory {
|
||||||
|
directory = outputDirectory
|
||||||
|
} else {
|
||||||
|
directory = try applicationFilesDirectory().appendingPathComponent("images/outgoing", isDirectory: true)
|
||||||
|
}
|
||||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil)
|
||||||
return directory.appendingPathComponent(fileName)
|
return directory.appendingPathComponent(fileName)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,8 +202,12 @@ final class SecureIdentityStateManager: SecureIdentityStateManagerProtocol {
|
|||||||
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
let decryptedData = try AES.GCM.open(sealedBox, using: encryptionKey)
|
||||||
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
cache = try JSONDecoder().decode(IdentityCache.self, from: decryptedData)
|
||||||
} catch {
|
} catch {
|
||||||
// Log error but continue with empty cache
|
cache = IdentityCache()
|
||||||
SecureLogger.error(error, context: "Failed to load identity cache", category: .security)
|
let deleted = keychain.deleteIdentityKey(forKey: cacheKey)
|
||||||
|
SecureLogger.warning(
|
||||||
|
"Discarded unreadable identity cache; starting fresh (deleted=\(deleted), error=\(error.localizedDescription))",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -540,6 +540,543 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"app_info.appearance.liquid_glass" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"ar" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "زجاج سائل"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bn" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "লিকুইড গ্লাস"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fil" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"he" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "זכוכית נוזלית"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hi" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "लिक्विड ग्लास"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "リキッドガラス"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "리퀴드 글래스"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ms" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ne" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "लिक्विड ग्लास"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt-BR" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "жидкое стекло"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sv" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ta" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "லிக்விட் கிளாஸ்"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"th" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "ลิควิดกลาส"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uk" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "рідке скло"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ur" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "لیکویڈ گلاس"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vi" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "liquid glass"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "液态玻璃"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "液態玻璃"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"app_info.appearance.matrix" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"ar" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "ماتريكس"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bn" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "ম্যাট্রিক্স"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fil" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"he" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "מטריקס"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hi" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "मैट्रिक्स"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "マトリックス"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "매트릭스"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ms" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ne" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "म्याट्रिक्स"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt-BR" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "матрица"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sv" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ta" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "மேட்ரிக்ஸ்"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"th" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "เมทริกซ์"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uk" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "матриця"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ur" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "میٹرکس"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vi" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "matrix"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "矩阵"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "矩陣"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"app_info.appearance.title" : {
|
||||||
|
"extractionState" : "manual",
|
||||||
|
"localizations" : {
|
||||||
|
"ar" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "المظهر"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bn" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "চেহারা"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"de" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "ERSCHEINUNGSBILD"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"en" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "APPEARANCE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"es" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "APARIENCIA"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fil" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "HITSURA"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "APPARENCE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"he" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "מראה"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"hi" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "दिखावट"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"id" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "TAMPILAN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"it" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "ASPETTO"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ja" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "外観"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ko" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "화면 모드"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ms" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "PENAMPILAN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ne" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "रूप"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "WEERGAVE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pl" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "WYGLĄD"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "APARÊNCIA"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"pt-BR" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "APARÊNCIA"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ru" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "ОФОРМЛЕНИЕ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sv" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "UTSEENDE"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ta" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "தோற்றம்"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"th" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "ลักษณะที่ปรากฏ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tr" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "GÖRÜNÜM"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"uk" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "ОФОРМЛЕННЯ"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ur" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "ظاہری شکل"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"vi" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "GIAO DIỆN"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hans" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "外观"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"zh-Hant" : {
|
||||||
|
"stringUnit" : {
|
||||||
|
"state" : "translated",
|
||||||
|
"value" : "外觀"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"app_info.close" : {
|
"app_info.close" : {
|
||||||
"extractionState" : "manual",
|
"extractionState" : "manual",
|
||||||
"localizations" : {
|
"localizations" : {
|
||||||
@@ -24397,7 +24934,7 @@
|
|||||||
"en" : {
|
"en" : {
|
||||||
"stringUnit" : {
|
"stringUnit" : {
|
||||||
"state" : "translated",
|
"state" : "translated",
|
||||||
"value" : "chat with people near you using geohash channels. only a coarse geohash is shared, never exact GPS. your IP address is hidden by routing all traffic over tor."
|
"value" : "chat with people near you using geohash channels. the selected geohash is public and may reveal an approximate area; exact GPS is never shared. your IP address is hidden by routing all traffic over tor."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es" : {
|
"es" : {
|
||||||
|
|||||||
@@ -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)
|
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum Base64URLCoding {
|
||||||
|
static func encode(_ data: Data) -> String {
|
||||||
|
data.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func decode(_ string: String) -> Data? {
|
||||||
|
var base64 = string
|
||||||
|
let padding = (4 - (base64.count % 4)) % 4
|
||||||
|
if padding > 0 {
|
||||||
|
base64 += String(repeating: "=", count: padding)
|
||||||
|
}
|
||||||
|
base64 = base64
|
||||||
|
.replacingOccurrences(of: "-", with: "+")
|
||||||
|
.replacingOccurrences(of: "_", with: "/")
|
||||||
|
return Data(base64Encoded: base64)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,11 @@ import UIKit
|
|||||||
import AppKit
|
import AppKit
|
||||||
#endif
|
#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.
|
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
|
||||||
struct GeoRelayDirectoryDependencies {
|
struct GeoRelayDirectoryDependencies {
|
||||||
var userDefaults: UserDefaults
|
var userDefaults: UserDefaults
|
||||||
@@ -165,33 +170,16 @@ final class GeoRelayDirectory {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
|
/// 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] {
|
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
|
||||||
guard !entries.isEmpty, count > 0 else { return [] }
|
guard !entries.isEmpty, count > 0 else { return [] }
|
||||||
|
|
||||||
if entries.count <= count {
|
return entries
|
||||||
return entries
|
.map { (entry: $0, distance: haversineKm(lat, lon, $0.lat, $0.lon)) }
|
||||||
.sorted { a, b in
|
.sorted { ($0.distance, $0.entry.host) < ($1.distance, $1.entry.host) }
|
||||||
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
|
.prefix(count)
|
||||||
}
|
.map { "wss://\($0.entry.host)" }
|
||||||
.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)" }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Remote Fetch
|
// MARK: - Remote Fetch
|
||||||
@@ -289,6 +277,8 @@ final class GeoRelayDirectory {
|
|||||||
isFetching = false
|
isFetching = false
|
||||||
retryAttempt = 0
|
retryAttempt = 0
|
||||||
cancelRetry()
|
cancelRetry()
|
||||||
|
// Let waiters (e.g. location notes stuck in a "no relays" state) retry.
|
||||||
|
dependencies.notificationCenter.post(name: .geoRelayDirectoryDidRefresh, object: nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -381,12 +371,7 @@ final class GeoRelayDirectory {
|
|||||||
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
if idx == 0 && line.lowercased().contains("relay url") { continue }
|
||||||
let parts = line.split(separator: ",").map { $0.trimmed }
|
let parts = line.split(separator: ",").map { $0.trimmed }
|
||||||
guard parts.count >= 3 else { continue }
|
guard parts.count >= 3 else { continue }
|
||||||
var host = parts[0]
|
guard let host = NostrRelayURL.directoryAddress(parts[0]) else { continue }
|
||||||
host = host.replacingOccurrences(of: "https://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "http://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "wss://", with: "")
|
|
||||||
host = host.replacingOccurrences(of: "ws://", with: "")
|
|
||||||
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
|
|
||||||
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue }
|
||||||
result.insert(Entry(host: host, lat: lat, lon: lon))
|
result.insert(Entry(host: host, lat: lat, lon: lon))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -303,7 +303,7 @@ struct NostrProtocol {
|
|||||||
combined.append(nonce24)
|
combined.append(nonce24)
|
||||||
combined.append(sealed.ciphertext)
|
combined.append(sealed.ciphertext)
|
||||||
combined.append(sealed.tag)
|
combined.append(sealed.tag)
|
||||||
return "v2:" + base64URLEncode(combined)
|
return "v2:" + Base64URLCoding.encode(combined)
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func decrypt(
|
private static func decrypt(
|
||||||
@@ -314,7 +314,7 @@ struct NostrProtocol {
|
|||||||
// Expect NIP-44 v2 format
|
// Expect NIP-44 v2 format
|
||||||
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
|
||||||
let encoded = String(ciphertext.dropFirst(3))
|
let encoded = String(ciphertext.dropFirst(3))
|
||||||
guard let data = base64URLDecode(encoded),
|
guard let data = Base64URLCoding.decode(encoded),
|
||||||
data.count > (24 + 16),
|
data.count > (24 + 16),
|
||||||
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
let senderPubkeyData = Data(hexString: senderPubkey) else {
|
||||||
throw NostrError.invalidCiphertext
|
throw NostrError.invalidCiphertext
|
||||||
@@ -580,24 +580,9 @@ enum NostrError: Error {
|
|||||||
case encryptionFailed
|
case encryptionFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
|
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305)
|
||||||
|
|
||||||
private extension NostrProtocol {
|
private extension NostrProtocol {
|
||||||
static func base64URLEncode(_ data: Data) -> String {
|
|
||||||
return data.base64EncodedString()
|
|
||||||
.replacingOccurrences(of: "+", with: "-")
|
|
||||||
.replacingOccurrences(of: "/", with: "_")
|
|
||||||
.replacingOccurrences(of: "=", with: "")
|
|
||||||
}
|
|
||||||
|
|
||||||
static func base64URLDecode(_ s: String) -> Data? {
|
|
||||||
var str = s
|
|
||||||
let pad = (4 - (str.count % 4)) % 4
|
|
||||||
if pad > 0 { str += String(repeating: "=", count: pad) }
|
|
||||||
str = str.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
|
|
||||||
return Data(base64Encoded: str)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
static func deriveNIP44V2Key(from sharedSecretData: Data) throws -> Data {
|
||||||
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
|
||||||
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ struct NostrRelayManagerDependencies {
|
|||||||
var makeSession: () -> NostrRelaySessionProtocol
|
var makeSession: () -> NostrRelaySessionProtocol
|
||||||
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
var scheduleAfter: @Sendable (TimeInterval, @escaping @Sendable () -> Void) -> Void
|
||||||
var now: () -> Date
|
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 {
|
private extension NostrRelayManagerDependencies {
|
||||||
@@ -93,7 +96,8 @@ private extension NostrRelayManagerDependencies {
|
|||||||
scheduleAfter: { delay, action in
|
scheduleAfter: { delay, action in
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: action)
|
||||||
},
|
},
|
||||||
now: Date.init
|
now: Date.init,
|
||||||
|
jitterUnit: { Double.random(in: 0..<1) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,16 +125,15 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
var nextReconnectTime: Date?
|
var nextReconnectTime: Date?
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default relay list (can be customized)
|
// Default relays carry NIP-17 gift wraps, so avoid relays known to reject kind 1059.
|
||||||
private static let defaultRelays = [
|
private static let defaultRelays = [
|
||||||
"wss://relay.damus.io",
|
"wss://relay.damus.io",
|
||||||
"wss://nos.lol",
|
"wss://nos.lol",
|
||||||
"wss://relay.primal.net",
|
"wss://relay.primal.net",
|
||||||
"wss://offchain.pub",
|
"wss://offchain.pub"
|
||||||
"wss://nostr21.com"
|
|
||||||
// For local testing, you can add: "ws://localhost:8080"
|
// For local testing, you can add: "ws://localhost:8080"
|
||||||
]
|
]
|
||||||
private static let defaultRelaySet = Set(defaultRelays)
|
private static let defaultRelaySet = Set(defaultRelays.compactMap { NostrRelayURL.normalized($0) })
|
||||||
|
|
||||||
@Published private(set) var relays: [Relay] = []
|
@Published private(set) var relays: [Relay] = []
|
||||||
@Published private(set) var isConnected = false
|
@Published private(set) var isConnected = false
|
||||||
@@ -141,19 +144,51 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private var hasLocationPermission: Bool = false
|
private var hasLocationPermission: Bool = false
|
||||||
private var connections: [String: NostrRelayConnectionProtocol] = [:]
|
private var connections: [String: NostrRelayConnectionProtocol] = [:]
|
||||||
private var subscriptions: [String: Set<String>] = [:] // relay URL -> active subscription IDs
|
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 var messageHandlers: [String: (NostrEvent) -> Void] = [:]
|
||||||
// Coalesce duplicate subscribe requests for the same id within a short window
|
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 subscribeCoalesce: [String: Date] = [:]
|
||||||
|
private var pendingTorConnectionURLs = Set<String>()
|
||||||
|
private var awaitingTorForConnections = false
|
||||||
|
private var torReadyWaitAttempts = 0
|
||||||
private var cancellables = Set<AnyCancellable>()
|
private var cancellables = Set<AnyCancellable>()
|
||||||
|
|
||||||
|
private struct SubscriptionRequestState: Equatable {
|
||||||
|
let messageString: String
|
||||||
|
let relayURLs: Set<String>
|
||||||
|
}
|
||||||
|
private var subscriptionRequestState: [String: SubscriptionRequestState] = [:]
|
||||||
|
|
||||||
// Track EOSE per subscription to signal when initial stored events are done
|
// Track EOSE per subscription to signal when initial stored events are done
|
||||||
private struct EOSETracker {
|
private struct EOSETracker {
|
||||||
var pendingRelays: Set<String>
|
var pendingRelays: Set<String>
|
||||||
var callback: () -> Void
|
var callback: () -> Void
|
||||||
var timer: Timer?
|
let epoch: Int
|
||||||
}
|
}
|
||||||
private var eoseTrackers: [String: EOSETracker] = [:]
|
private var eoseTrackers: [String: EOSETracker] = [:]
|
||||||
|
private var eoseTrackerEpoch = 0
|
||||||
|
private var pendingEOSECallbacks: [String: () -> Void] = [:]
|
||||||
|
|
||||||
// Message queue for reliability
|
// Message queue for reliability
|
||||||
// Pending sends held only for relays that are not yet connected.
|
// Pending sends held only for relays that are not yet connected.
|
||||||
@@ -163,6 +198,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
private var messageQueue: [PendingSend] = []
|
private var messageQueue: [PendingSend] = []
|
||||||
private let messageQueueLock = NSLock()
|
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 let encoder = JSONEncoder()
|
||||||
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
|
private var shouldUseTor: Bool { dependencies.userTorEnabled() }
|
||||||
|
|
||||||
@@ -233,25 +271,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
func connect() {
|
func connect() {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard dependencies.activationAllowed() else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
if shouldUseTor {
|
connectToRelays(relays.map(\.url), shouldLog: true)
|
||||||
// Ensure Tor is started early and wait for readiness off-main; then hop back to connect.
|
|
||||||
dependencies.awaitTorReady { [weak self] ready in
|
|
||||||
guard let self = self else { return }
|
|
||||||
if !ready {
|
|
||||||
SecureLogger.error("❌ Tor not ready; aborting relay connections (fail-closed)", category: .session)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (via Tor)", category: .session)
|
|
||||||
for relay in self.relays {
|
|
||||||
self.connectToRelay(relay.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
SecureLogger.debug("🌐 Connecting to \(self.relays.count) Nostr relays (direct)", category: .session)
|
|
||||||
for relay in self.relays {
|
|
||||||
connectToRelay(relay.url)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Disconnect from all relays
|
/// Disconnect from all relays
|
||||||
@@ -261,9 +281,21 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
task.cancel(with: .goingAway, reason: nil)
|
task.cancel(with: .goingAway, reason: nil)
|
||||||
}
|
}
|
||||||
connections.removeAll()
|
connections.removeAll()
|
||||||
// Clear known subscriptions and any queued subs since connections are gone
|
// 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()
|
subscriptions.removeAll()
|
||||||
pendingSubscriptions.removeAll()
|
pendingSubscriptions.removeAll()
|
||||||
|
// 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()
|
updateConnectionStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,22 +305,12 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
guard dependencies.activationAllowed() else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
let targets = allowedRelayList(from: relayUrls)
|
let targets = allowedRelayList(from: relayUrls)
|
||||||
guard !targets.isEmpty else { return }
|
guard !targets.isEmpty else { return }
|
||||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
|
||||||
// Defer until Tor is fully ready; avoid queuing connection attempts early
|
|
||||||
dependencies.awaitTorReady { [weak self] ready in
|
|
||||||
guard let self = self else { return }
|
|
||||||
if ready { self.ensureConnections(to: relayUrls) }
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var existing = Set(relays.map { $0.url })
|
var existing = Set(relays.map { $0.url })
|
||||||
for url in targets where !existing.contains(url) {
|
for url in targets where !existing.contains(url) {
|
||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
existing.insert(url)
|
existing.insert(url)
|
||||||
}
|
}
|
||||||
for url in targets where connections[url] == nil {
|
connectToRelays(targets)
|
||||||
connectToRelay(url)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send an event to specified relays (or all if none specified)
|
/// Send an event to specified relays (or all if none specified)
|
||||||
@@ -296,11 +318,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard dependencies.activationAllowed() else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
||||||
// Defer sends until Tor is ready to avoid premature queueing
|
// Fail-closed: nothing touches the network until Tor is up. Queue the
|
||||||
dependencies.awaitTorReady { [weak self] ready in
|
// event locally so it survives a slow bootstrap (queued sends flush
|
||||||
guard let self = self else { return }
|
// when relays connect), then kick off connection setup, which itself
|
||||||
if ready { self.sendEvent(event, to: relayUrls) }
|
// 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
|
return
|
||||||
}
|
}
|
||||||
let requestedRelays = relayUrls ?? Self.defaultRelays
|
let requestedRelays = relayUrls ?? Self.defaultRelays
|
||||||
@@ -311,16 +336,36 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Attempt immediate send to relays with active connections; queue the rest
|
// Attempt immediate send to relays with active connections; queue the rest
|
||||||
var stillPending = Set<String>()
|
var stillPending = Set<String>()
|
||||||
for relayUrl in targetRelays {
|
for relayUrl in targetRelays {
|
||||||
if let connection = connections[relayUrl] {
|
if let connection = connectedConnection(for: relayUrl) {
|
||||||
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
sendToRelay(event: event, connection: connection, relayUrl: relayUrl)
|
||||||
} else {
|
} else {
|
||||||
stillPending.insert(relayUrl)
|
stillPending.insert(relayUrl)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !stillPending.isEmpty {
|
if !stillPending.isEmpty {
|
||||||
messageQueueLock.lock()
|
enqueuePendingSend(event, pendingRelays: stillPending)
|
||||||
messageQueue.append(PendingSend(event: event, pendingRelays: stillPending))
|
}
|
||||||
messageQueueLock.unlock()
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +378,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Flush only for a specific relay
|
// Flush only for a specific relay
|
||||||
for i in (0..<messageQueue.count).reversed() {
|
for i in (0..<messageQueue.count).reversed() {
|
||||||
var item = messageQueue[i]
|
var item = messageQueue[i]
|
||||||
if item.pendingRelays.contains(target), let conn = connections[target] {
|
if item.pendingRelays.contains(target), let conn = connectedConnection(for: target) {
|
||||||
sendToRelay(event: item.event, connection: conn, relayUrl: target)
|
sendToRelay(event: item.event, connection: conn, relayUrl: target)
|
||||||
item.pendingRelays.remove(target)
|
item.pendingRelays.remove(target)
|
||||||
if item.pendingRelays.isEmpty {
|
if item.pendingRelays.isEmpty {
|
||||||
@@ -348,7 +393,7 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
for i in (0..<messageQueue.count).reversed() {
|
for i in (0..<messageQueue.count).reversed() {
|
||||||
var item = messageQueue[i]
|
var item = messageQueue[i]
|
||||||
for url in item.pendingRelays {
|
for url in item.pendingRelays {
|
||||||
if let conn = connections[url] {
|
if let conn = connectedConnection(for: url) {
|
||||||
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
sendToRelay(event: item.event, connection: conn, relayUrl: url)
|
||||||
item.pendingRelays.remove(url)
|
item.pendingRelays.remove(url)
|
||||||
}
|
}
|
||||||
@@ -362,6 +407,14 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func connectedConnection(for relayUrl: String) -> NostrRelayConnectionProtocol? {
|
||||||
|
guard let connection = connections[relayUrl],
|
||||||
|
relays.first(where: { $0.url == relayUrl })?.isConnected == true else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return connection
|
||||||
|
}
|
||||||
|
|
||||||
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
|
/// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
|
||||||
func subscribe(
|
func subscribe(
|
||||||
filter: NostrFilter,
|
filter: NostrFilter,
|
||||||
@@ -372,24 +425,12 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
) {
|
) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard dependencies.activationAllowed() else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
// Coalesce rapid duplicate subscribe requests only if a handler already exists
|
// Coalesce rapid duplicate subscribe requests even while Tor readiness is pending.
|
||||||
let now = dependencies.now()
|
let now = dependencies.now()
|
||||||
if messageHandlers[id] != nil {
|
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < subscribeCoalesceInterval {
|
||||||
if let last = subscribeCoalesce[id], now.timeIntervalSince(last) < 1.0 {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
subscribeCoalesce[id] = now
|
|
||||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
|
||||||
// Defer subscription setup until Tor is ready; avoid queuing subs early
|
|
||||||
dependencies.awaitTorReady { [weak self] ready in
|
|
||||||
guard let self = self else { return }
|
|
||||||
if ready {
|
|
||||||
self.subscribe(filter: filter, id: id, relayUrls: relayUrls, handler: handler, onEOSE: onEOSE)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
subscribeCoalesce[id] = now
|
||||||
messageHandlers[id] = handler
|
messageHandlers[id] = handler
|
||||||
|
|
||||||
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
let req = NostrRequest.subscribe(id: id, filters: [filter])
|
||||||
@@ -405,36 +446,30 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// Target specific relays if provided; else default. Filter permanently failed relays.
|
// Target specific relays if provided; else default. Filter permanently failed relays.
|
||||||
let baseUrls = relayUrls ?? Self.defaultRelays
|
let baseUrls = relayUrls ?? Self.defaultRelays
|
||||||
let candidateUrls = baseUrls.filter { !isPermanentlyFailed($0) }
|
let urls = allowedRelayList(from: baseUrls).filter { !isPermanentlyFailed($0) }
|
||||||
let urls = allowedRelayList(from: candidateUrls)
|
let requestState = SubscriptionRequestState(messageString: messageString, relayURLs: Set(urls))
|
||||||
|
if subscriptionRequestState[id] == requestState, subscriptionStateExists(id: id, requestState: requestState) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
subscriptionRequestState[id] = requestState
|
||||||
|
|
||||||
// Always queue subscriptions; sending happens when a relay reports connected
|
// Always queue subscriptions; sending happens when a relay reports connected
|
||||||
let existingSet = Set(relays.map { $0.url })
|
var existingSet = Set(relays.map { $0.url })
|
||||||
for url in urls where !existingSet.contains(url) {
|
for url in urls where !existingSet.contains(url) {
|
||||||
relays.append(Relay(url: url))
|
relays.append(Relay(url: url))
|
||||||
|
existingSet.insert(url)
|
||||||
}
|
}
|
||||||
for url in urls {
|
for url in urls {
|
||||||
var map = self.pendingSubscriptions[url] ?? [:]
|
queuePendingSubscription(id: id, messageString: messageString, for: url)
|
||||||
map[id] = messageString
|
|
||||||
self.pendingSubscriptions[url] = map
|
|
||||||
}
|
}
|
||||||
// Initialize EOSE tracking if requested
|
// Initialize EOSE tracking if requested
|
||||||
if let onEOSE = onEOSE {
|
if let onEOSE = onEOSE {
|
||||||
if urls.isEmpty {
|
if urls.isEmpty {
|
||||||
onEOSE()
|
onEOSE()
|
||||||
|
} else if shouldWaitForTorBeforeConnecting {
|
||||||
|
parkEOSECallbackUntilTorReady(id: id, callback: onEOSE)
|
||||||
} else {
|
} else {
|
||||||
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
|
startEOSETracking(id: id, relayURLs: Set(urls), callback: onEOSE)
|
||||||
// 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
|
|
||||||
guard let self = self else { return }
|
|
||||||
if let t = self.eoseTrackers[id] {
|
|
||||||
t.timer?.invalidate()
|
|
||||||
self.eoseTrackers.removeValue(forKey: id)
|
|
||||||
onEOSE()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
eoseTrackers[id] = tracker
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
|
||||||
@@ -492,7 +527,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private func allowedRelayList(from urls: [String]) -> [String] {
|
private func allowedRelayList(from urls: [String]) -> [String] {
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
var result: [String] = []
|
var result: [String] = []
|
||||||
for url in urls {
|
for rawURL in urls {
|
||||||
|
guard let url = NostrRelayURL.normalized(rawURL) else { continue }
|
||||||
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
if !allowDefaultRelays && Self.defaultRelaySet.contains(url) { continue }
|
||||||
if seen.insert(url).inserted {
|
if seen.insert(url).inserted {
|
||||||
result.append(url)
|
result.append(url)
|
||||||
@@ -504,8 +540,16 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
/// Unsubscribe from a subscription
|
/// Unsubscribe from a subscription
|
||||||
func unsubscribe(id: String) {
|
func unsubscribe(id: String) {
|
||||||
messageHandlers.removeValue(forKey: id)
|
messageHandlers.removeValue(forKey: id)
|
||||||
|
removeRecentInboundEvents(forSubscriptionID: id)
|
||||||
|
duplicateInboundEventDropCountBySubscription.removeValue(forKey: id)
|
||||||
// Allow immediate re-subscription by clearing coalescer timestamp
|
// Allow immediate re-subscription by clearing coalescer timestamp
|
||||||
subscribeCoalesce.removeValue(forKey: id)
|
subscribeCoalesce.removeValue(forKey: id)
|
||||||
|
subscriptionRequestState.removeValue(forKey: id)
|
||||||
|
pendingEOSECallbacks.removeValue(forKey: id)
|
||||||
|
eoseTrackers.removeValue(forKey: id)
|
||||||
|
for url in Array(pendingSubscriptions.keys) {
|
||||||
|
pendingSubscriptions[url]?.removeValue(forKey: id)
|
||||||
|
}
|
||||||
|
|
||||||
let req = NostrRequest.close(id: id)
|
let req = NostrRequest.close(id: id)
|
||||||
let message = try? encoder.encode(req)
|
let message = try? encoder.encode(req)
|
||||||
@@ -526,6 +570,246 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
// MARK: - Private Methods
|
// MARK: - Private Methods
|
||||||
|
|
||||||
|
private var shouldWaitForTorBeforeConnecting: Bool {
|
||||||
|
shouldUseTor && !dependencies.torIsReady()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
guard !targets.isEmpty else { return }
|
||||||
|
|
||||||
|
if shouldWaitForTorBeforeConnecting {
|
||||||
|
queueConnectionsUntilTorReady(targets)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if shouldLog {
|
||||||
|
let route = shouldUseTor ? "via Tor" : "direct"
|
||||||
|
SecureLogger.debug("🌐 Connecting to \(targets.count) Nostr relay(s) (\(route))", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
for url in targets {
|
||||||
|
connectToRelay(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func queueConnectionsUntilTorReady(_ relayUrls: [String]) {
|
||||||
|
let targets = allowedRelayList(from: relayUrls).filter {
|
||||||
|
connections[$0] == nil && !isPermanentlyFailed($0)
|
||||||
|
}
|
||||||
|
guard !targets.isEmpty else { return }
|
||||||
|
|
||||||
|
pendingTorConnectionURLs.formUnion(targets)
|
||||||
|
guard !awaitingTorForConnections else { return }
|
||||||
|
|
||||||
|
awaitingTorForConnections = true
|
||||||
|
let generation = connectionGeneration
|
||||||
|
dependencies.awaitTorReady { [weak self] ready in
|
||||||
|
guard let self else { return }
|
||||||
|
guard generation == self.connectionGeneration else { return }
|
||||||
|
|
||||||
|
let pending = Array(self.pendingTorConnectionURLs)
|
||||||
|
self.pendingTorConnectionURLs.removeAll()
|
||||||
|
self.awaitingTorForConnections = false
|
||||||
|
|
||||||
|
guard ready else {
|
||||||
|
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]?.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) {
|
||||||
|
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.
|
||||||
|
dependencies.scheduleAfter(TransportConfig.nostrSubscriptionEOSEFallbackSeconds) { [weak self] in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
guard let tracker = self.eoseTrackers[id], tracker.epoch == epoch else { return }
|
||||||
|
self.eoseTrackers.removeValue(forKey: id)
|
||||||
|
tracker.callback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func startPendingEOSETrackingIfNeeded(id: String) {
|
||||||
|
guard eoseTrackers[id] == nil,
|
||||||
|
let callback = pendingEOSECallbacks.removeValue(forKey: id),
|
||||||
|
let requestState = subscriptionRequestState[id]
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if requestState.relayURLs.isEmpty {
|
||||||
|
callback()
|
||||||
|
} else {
|
||||||
|
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) {
|
private func connectToRelay(_ urlString: String) {
|
||||||
// Global network policy gate
|
// Global network policy gate
|
||||||
guard dependencies.activationAllowed() else { return }
|
guard dependencies.activationAllowed() else { return }
|
||||||
@@ -550,12 +834,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// Attempting to connect to Nostr relay via the proxied session
|
// Attempting to connect to Nostr relay via the proxied session
|
||||||
|
|
||||||
// If Tor is enforced but not ready, delay connection until it is.
|
// If Tor is enforced but not ready, delay connection until it is.
|
||||||
if shouldUseTor && dependencies.torEnforced() && !dependencies.torIsReady() {
|
if shouldWaitForTorBeforeConnecting {
|
||||||
dependencies.awaitTorReady { [weak self] ready in
|
queueConnectionsUntilTorReady([urlString])
|
||||||
guard let self = self else { return }
|
|
||||||
if ready { self.connectToRelay(urlString) }
|
|
||||||
else { SecureLogger.error("❌ Tor not ready; skipping connection to \(urlString)", category: .session) }
|
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -586,25 +866,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) {
|
private func flushPendingSubscriptions(for relayUrl: String) {
|
||||||
guard let map = pendingSubscriptions[relayUrl], !map.isEmpty else { return }
|
|
||||||
guard let connection = connections[relayUrl] 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 }
|
if self.subscriptions[relayUrl]?.contains(id) == true { continue }
|
||||||
connection.send(.string(messageString)) { error in
|
startPendingEOSETrackingIfNeeded(id: id)
|
||||||
if let error = error {
|
connection.send(.string(messageString)) { [weak self, weak connection] error in
|
||||||
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
|
Task { @MainActor [weak self] in
|
||||||
} else {
|
guard let self else { return }
|
||||||
Task { @MainActor in
|
if let error = error {
|
||||||
var subs = self.subscriptions[relayUrl] ?? Set<String>()
|
// Keep the pending entry; the next (re)connect retries it.
|
||||||
subs.insert(id)
|
SecureLogger.error("❌ Failed to send pending subscription to \(relayUrl): \(error)", category: .session)
|
||||||
self.subscriptions[relayUrl] = subs
|
} 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) {
|
private func receiveMessage(from task: NostrRelayConnectionProtocol, relayUrl: String) {
|
||||||
@@ -642,12 +932,26 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
|
private func handleParsedMessage(_ parsed: ParsedInbound, from relayUrl: String) {
|
||||||
switch parsed {
|
switch parsed {
|
||||||
case .event(let subId, let event):
|
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 }) {
|
if let index = self.relays.firstIndex(where: { $0.url == relayUrl }) {
|
||||||
self.relays[index].messagesReceived += 1
|
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] {
|
if let handler = self.messageHandlers[subId] {
|
||||||
handler(event)
|
handler(event)
|
||||||
} else {
|
} else {
|
||||||
@@ -657,7 +961,6 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
if var tracker = eoseTrackers[subId] {
|
if var tracker = eoseTrackers[subId] {
|
||||||
tracker.pendingRelays.remove(relayUrl)
|
tracker.pendingRelays.remove(relayUrl)
|
||||||
if tracker.pendingRelays.isEmpty {
|
if tracker.pendingRelays.isEmpty {
|
||||||
tracker.timer?.invalidate()
|
|
||||||
eoseTrackers.removeValue(forKey: subId)
|
eoseTrackers.removeValue(forKey: subId)
|
||||||
tracker.callback()
|
tracker.callback()
|
||||||
} else {
|
} else {
|
||||||
@@ -671,9 +974,9 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
} else {
|
} else {
|
||||||
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
||||||
if isGiftWrap {
|
if isGiftWrap {
|
||||||
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
|
SecureLogger.warning("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
||||||
} else {
|
} else {
|
||||||
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)", category: .session)
|
SecureLogger.error("📮 Rejected id=\(eventId.prefix(16))… relay=\(relayUrl) reason=\(reason)", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .notice:
|
case .notice:
|
||||||
@@ -731,17 +1034,30 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
isConnected = relays.contains { $0.isConnected }
|
isConnected = relays.contains { $0.isConnected }
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleDisconnection(relayUrl: String, error: Error) {
|
/// A relay that drops before sending EOSE must not stall initial-load
|
||||||
// If networking is disallowed, do not schedule reconnection
|
/// callbacks; treat it as done and let the remaining relays (or the
|
||||||
if !dependencies.activationAllowed() {
|
/// fallback timeout) drive completion.
|
||||||
connections.removeValue(forKey: relayUrl)
|
private func settleEOSETrackers(droppingRelay relayUrl: String) {
|
||||||
subscriptions.removeValue(forKey: relayUrl)
|
for (id, var tracker) in eoseTrackers where tracker.pendingRelays.contains(relayUrl) {
|
||||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
tracker.pendingRelays.remove(relayUrl)
|
||||||
return
|
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)
|
connections.removeValue(forKey: relayUrl)
|
||||||
subscriptions.removeValue(forKey: relayUrl)
|
subscriptions.removeValue(forKey: relayUrl)
|
||||||
updateRelayStatus(relayUrl, isConnected: false, error: error)
|
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
|
// Check if this is a DNS or handshake error; treat as permanent
|
||||||
let errorDescription = error.localizedDescription.lowercased()
|
let errorDescription = error.localizedDescription.lowercased()
|
||||||
@@ -772,15 +1088,25 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate backoff interval
|
// Calculate backoff interval with ±jitterRatio random jitter so relays
|
||||||
let backoffInterval = min(
|
// that dropped together don't all reconnect at the same instant.
|
||||||
|
let baseBackoffInterval = min(
|
||||||
initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)),
|
initialBackoffInterval * pow(backoffMultiplier, Double(relays[index].reconnectAttempts - 1)),
|
||||||
maxBackoffInterval
|
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)
|
let nextReconnectTime = dependencies.now().addingTimeInterval(backoffInterval)
|
||||||
relays[index].nextReconnectTime = nextReconnectTime
|
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
|
// Schedule reconnection with exponential backoff
|
||||||
let gen = connectionGeneration
|
let gen = connectionGeneration
|
||||||
@@ -801,7 +1127,8 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
|
|
||||||
/// Manually retry connection to a specific relay
|
/// Manually retry connection to a specific relay
|
||||||
func retryConnection(to relayUrl: String) {
|
func retryConnection(to relayUrl: String) {
|
||||||
guard let index = relays.firstIndex(where: { $0.url == relayUrl }) else { return }
|
let normalizedRelayUrl = NostrRelayURL.normalized(relayUrl) ?? relayUrl
|
||||||
|
guard let index = relays.firstIndex(where: { $0.url == normalizedRelayUrl }) else { return }
|
||||||
|
|
||||||
// Reset reconnection attempts
|
// Reset reconnection attempts
|
||||||
relays[index].reconnectAttempts = 0
|
relays[index].reconnectAttempts = 0
|
||||||
@@ -809,13 +1136,13 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
relays[index].lastError = nil
|
relays[index].lastError = nil
|
||||||
|
|
||||||
// Disconnect if connected
|
// Disconnect if connected
|
||||||
if let connection = connections[relayUrl] {
|
if let connection = connections[normalizedRelayUrl] {
|
||||||
connection.cancel(with: .goingAway, reason: nil)
|
connection.cancel(with: .goingAway, reason: nil)
|
||||||
connections.removeValue(forKey: relayUrl)
|
connections.removeValue(forKey: normalizedRelayUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Attempt immediate reconnection
|
// Attempt immediate reconnection
|
||||||
connectToRelay(relayUrl)
|
connectToRelay(normalizedRelayUrl)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get detailed status for all relays
|
/// Get detailed status for all relays
|
||||||
@@ -838,6 +1165,19 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
pendingSubscriptions[relayUrl]?.count ?? 0
|
pendingSubscriptions[relayUrl]?.count ?? 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func debugPendingSubscriptionIDs(for relayUrl: String) -> Set<String> {
|
||||||
|
guard let map = pendingSubscriptions[relayUrl] else { return [] }
|
||||||
|
return Set(map.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
var debugDuplicateInboundEventDropCount: Int {
|
||||||
|
duplicateInboundEventDropCount
|
||||||
|
}
|
||||||
|
|
||||||
|
func debugDuplicateInboundEventDropCount(forSubscriptionID subscriptionID: String) -> Int {
|
||||||
|
duplicateInboundEventDropCountBySubscription[subscriptionID] ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
func debugFlushMessageQueue() {
|
func debugFlushMessageQueue() {
|
||||||
flushMessageQueue(for: nil)
|
flushMessageQueue(for: nil)
|
||||||
}
|
}
|
||||||
@@ -862,6 +1202,13 @@ final class NostrRelayManager: ObservableObject {
|
|||||||
// MARK: - Failure classification
|
// MARK: - Failure classification
|
||||||
private func isPermanentlyFailed(_ url: String) -> Bool {
|
private func isPermanentlyFailed(_ url: String) -> Bool {
|
||||||
guard let r = relays.first(where: { $0.url == url }) else { return false }
|
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 r.reconnectAttempts >= maxReconnectAttempts { return true }
|
||||||
if let ns = r.lastError as NSError?, ns.domain == NSURLErrorDomain {
|
if let ns = r.lastError as NSError?, ns.domain == NSURLErrorDomain {
|
||||||
if ns.code == NSURLErrorBadServerResponse || ns.code == NSURLErrorCannotFindHost {
|
if ns.code == NSURLErrorBadServerResponse || ns.code == NSURLErrorCannotFindHost {
|
||||||
@@ -893,8 +1240,7 @@ private enum ParsedInbound {
|
|||||||
if array.count >= 3,
|
if array.count >= 3,
|
||||||
let subId = array[1] as? String,
|
let subId = array[1] as? String,
|
||||||
let eventDict = array[2] as? [String: Any],
|
let eventDict = array[2] as? [String: Any],
|
||||||
let event = try? NostrEvent(from: eventDict),
|
let event = try? NostrEvent(from: eventDict) {
|
||||||
event.isValidSignature() {
|
|
||||||
self = .event(subId: subId, event: event)
|
self = .event(subId: subId, event: event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum NostrRelayURL {
|
||||||
|
static func normalized(_ rawValue: String, defaultScheme: String? = nil) -> String? {
|
||||||
|
var value = rawValue.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
guard !value.isEmpty else { return nil }
|
||||||
|
|
||||||
|
if !value.contains("://"), let defaultScheme {
|
||||||
|
value = "\(defaultScheme)://\(value)"
|
||||||
|
}
|
||||||
|
|
||||||
|
guard var components = URLComponents(string: value),
|
||||||
|
let rawScheme = components.scheme?.lowercased(),
|
||||||
|
let rawHost = components.host?.lowercased(),
|
||||||
|
!rawHost.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
switch rawScheme {
|
||||||
|
case "wss", "https":
|
||||||
|
components.scheme = "wss"
|
||||||
|
if components.port == 443 {
|
||||||
|
components.port = nil
|
||||||
|
}
|
||||||
|
case "ws", "http":
|
||||||
|
components.scheme = "ws"
|
||||||
|
if components.port == 80 {
|
||||||
|
components.port = nil
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
components.host = rawHost
|
||||||
|
if components.path == "/" {
|
||||||
|
components.path = ""
|
||||||
|
}
|
||||||
|
components.fragment = nil
|
||||||
|
|
||||||
|
return components.string
|
||||||
|
}
|
||||||
|
|
||||||
|
static func directoryAddress(_ rawValue: String) -> String? {
|
||||||
|
guard var normalized = normalized(rawValue, defaultScheme: "wss") else { return nil }
|
||||||
|
for prefix in ["wss://", "ws://"] where normalized.hasPrefix(prefix) {
|
||||||
|
normalized.removeFirst(prefix.count)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// Narrow environment for `BLEAnnounceHandler`.
|
||||||
|
///
|
||||||
|
/// All queue hops (collections barrier, BLE-queue link-state reads, main-actor
|
||||||
|
/// UI notification, delayed re-announce) live inside the closures supplied by
|
||||||
|
/// `BLEService`, keeping the handler queue-agnostic and synchronously testable.
|
||||||
|
struct BLEAnnounceHandlerEnvironment {
|
||||||
|
/// Local peer identity at the time the announce is handled.
|
||||||
|
let localPeerID: () -> PeerID
|
||||||
|
/// TTL value used for direct (non-relayed) packets.
|
||||||
|
let messageTTL: UInt8
|
||||||
|
/// Current time source.
|
||||||
|
let now: () -> Date
|
||||||
|
/// Noise public key already recorded for the peer, if any (registry read).
|
||||||
|
let existingNoisePublicKey: (PeerID) -> Data?
|
||||||
|
/// Verifies the packet signature against the announced signing key.
|
||||||
|
let verifySignature: (_ packet: BitchatPacket, _ signingPublicKey: Data) -> Bool
|
||||||
|
/// Direct link state for the peer (BLE-queue read).
|
||||||
|
let linkState: (PeerID) -> (hasPeripheral: Bool, hasCentral: Bool)
|
||||||
|
/// Runs the registry mutation phase under the collections barrier.
|
||||||
|
let withRegistryBarrier: (() -> Void) -> Void
|
||||||
|
/// Upserts the verified announce into the peer registry.
|
||||||
|
/// Must only be called from inside `withRegistryBarrier`.
|
||||||
|
let upsertVerifiedAnnounce: (
|
||||||
|
_ peerID: PeerID,
|
||||||
|
_ announcement: AnnouncementPacket,
|
||||||
|
_ isConnected: Bool,
|
||||||
|
_ now: Date
|
||||||
|
) -> BLEPeerAnnounceUpdate
|
||||||
|
/// Debounced reconnect-log decision.
|
||||||
|
/// Must only be called from inside `withRegistryBarrier`.
|
||||||
|
let shouldEmitReconnectLog: (_ peerID: PeerID, _ now: Date) -> Bool
|
||||||
|
/// Records verified direct-neighbor claims in the mesh topology.
|
||||||
|
let updateTopology: (_ peerID: PeerID, _ neighbors: [Data]) -> Void
|
||||||
|
/// Persists the announced cryptographic identity for offline verification.
|
||||||
|
let persistIdentity: (AnnouncementPacket) -> Void
|
||||||
|
/// Announce-back dedup check.
|
||||||
|
let dedupContains: (String) -> Bool
|
||||||
|
/// Announce-back dedup marking.
|
||||||
|
let dedupMarkProcessed: (String) -> Void
|
||||||
|
/// Delivers the announce UI events as one ordered main-actor hop:
|
||||||
|
/// `.peerConnected` (if flagged) → initial gossip sync scheduling (if
|
||||||
|
/// flagged) → peer-ID snapshot + data publish + `.peerListUpdated`.
|
||||||
|
/// A single closure keeps the original in-order delivery guarantee that
|
||||||
|
/// separate unstructured tasks would not provide.
|
||||||
|
let deliverAnnounceUIEvents: (
|
||||||
|
_ peerID: PeerID,
|
||||||
|
_ notifyPeerConnected: Bool,
|
||||||
|
_ scheduleInitialSync: Bool
|
||||||
|
) -> Void
|
||||||
|
/// Tracks the announce packet for gossip sync.
|
||||||
|
let trackPacketSeen: (BitchatPacket) -> Void
|
||||||
|
/// Reciprocates the announce for bidirectional discovery.
|
||||||
|
let sendAnnounceBack: () -> Void
|
||||||
|
/// Schedules a delayed re-announce (afterglow) after the given delay.
|
||||||
|
let scheduleAfterglow: (TimeInterval) -> Void
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Orchestrates inbound announce packets: preflight validation, signature
|
||||||
|
/// trust, registry/topology updates, identity persistence, UI notification,
|
||||||
|
/// gossip tracking, and the reciprocal announce response.
|
||||||
|
final class BLEAnnounceHandler {
|
||||||
|
private let environment: BLEAnnounceHandlerEnvironment
|
||||||
|
|
||||||
|
init(environment: BLEAnnounceHandlerEnvironment) {
|
||||||
|
self.environment = environment
|
||||||
|
}
|
||||||
|
|
||||||
|
func handle(_ packet: BitchatPacket, from peerID: PeerID) {
|
||||||
|
let env = environment
|
||||||
|
let now = env.now()
|
||||||
|
let preflight = BLEAnnouncePreflightPolicy.evaluate(
|
||||||
|
packet: packet,
|
||||||
|
from: peerID,
|
||||||
|
localPeerID: env.localPeerID(),
|
||||||
|
now: now
|
||||||
|
)
|
||||||
|
|
||||||
|
let announcement: AnnouncementPacket
|
||||||
|
switch preflight {
|
||||||
|
case .accept(let acceptance):
|
||||||
|
announcement = acceptance.announcement
|
||||||
|
case .reject(.malformed):
|
||||||
|
SecureLogger.error("❌ Failed to decode announce packet from \(peerID.id.prefix(8))…", category: .session)
|
||||||
|
return
|
||||||
|
case .reject(.senderMismatch(let derivedFromKey)):
|
||||||
|
SecureLogger.warning("⚠️ Announce sender mismatch: derived \(derivedFromKey.id.prefix(8))… vs packet \(peerID.id.prefix(8))…", category: .security)
|
||||||
|
return
|
||||||
|
case .reject(.selfAnnounce):
|
||||||
|
return
|
||||||
|
case .reject(.stale(let ageSeconds)):
|
||||||
|
SecureLogger.debug("⏰ Ignoring stale announce from \(peerID.id.prefix(8))… (age: \(ageSeconds)s)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress announce logs to reduce noise
|
||||||
|
|
||||||
|
// Precompute signature verification outside barrier to reduce contention
|
||||||
|
let existingNoisePublicKey = env.existingNoisePublicKey(peerID)
|
||||||
|
let hasSignature = packet.signature != nil
|
||||||
|
let signatureValid: Bool
|
||||||
|
if hasSignature {
|
||||||
|
signatureValid = env.verifySignature(packet, announcement.signingPublicKey)
|
||||||
|
if !signatureValid {
|
||||||
|
SecureLogger.warning("⚠️ Signature verification for announce failed \(peerID.id.prefix(8))", category: .security)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
signatureValid = false
|
||||||
|
}
|
||||||
|
let trustDecision = BLEAnnounceTrustPolicy.evaluate(
|
||||||
|
hasSignature: hasSignature,
|
||||||
|
signatureValid: signatureValid,
|
||||||
|
existingNoisePublicKey: existingNoisePublicKey,
|
||||||
|
announcedNoisePublicKey: announcement.noisePublicKey
|
||||||
|
)
|
||||||
|
if case .reject(.keyMismatch) = trustDecision {
|
||||||
|
SecureLogger.warning("⚠️ Announce key mismatch for \(peerID.id.prefix(8))… — keeping unverified", category: .security)
|
||||||
|
}
|
||||||
|
let verifiedAnnounce = trustDecision.isVerified
|
||||||
|
|
||||||
|
var isNewPeer = false
|
||||||
|
var isReconnectedPeer = false
|
||||||
|
let directLinkState = env.linkState(peerID)
|
||||||
|
let isDirectAnnounce = packet.ttl == env.messageTTL
|
||||||
|
|
||||||
|
env.withRegistryBarrier {
|
||||||
|
let hasPeripheralConnection = directLinkState.hasPeripheral
|
||||||
|
let hasCentralSubscription = directLinkState.hasCentral
|
||||||
|
|
||||||
|
// Require verified announce; ignore otherwise (no backward compatibility)
|
||||||
|
if !verifiedAnnounce {
|
||||||
|
SecureLogger.warning("❌ Ignoring unverified announce from \(peerID.id.prefix(8))…", category: .security)
|
||||||
|
// Reset flags to prevent post-barrier code from acting on unverified announces
|
||||||
|
isNewPeer = false
|
||||||
|
isReconnectedPeer = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let update = env.upsertVerifiedAnnounce(
|
||||||
|
peerID,
|
||||||
|
announcement,
|
||||||
|
isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription,
|
||||||
|
now
|
||||||
|
)
|
||||||
|
isNewPeer = update.isNewPeer
|
||||||
|
isReconnectedPeer = update.wasDisconnected
|
||||||
|
|
||||||
|
// Log connection status only for direct connectivity changes; debounce to reduce spam
|
||||||
|
if isDirectAnnounce || hasPeripheralConnection || hasCentralSubscription {
|
||||||
|
let now = env.now()
|
||||||
|
if update.isNewPeer {
|
||||||
|
SecureLogger.debug("🆕 New peer: \(announcement.nickname)", category: .session)
|
||||||
|
} else if update.wasDisconnected {
|
||||||
|
if env.shouldEmitReconnectLog(peerID, now) {
|
||||||
|
SecureLogger.debug("🔄 Peer \(announcement.nickname) reconnected", category: .session)
|
||||||
|
}
|
||||||
|
} else if let previousNickname = update.previousNickname, previousNickname != announcement.nickname {
|
||||||
|
SecureLogger.debug("🔄 Peer \(peerID.id.prefix(8))… changed nickname: \(previousNickname) -> \(announcement.nickname)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update topology with verified neighbor claims (only for authenticated announces)
|
||||||
|
if verifiedAnnounce, let neighbors = announcement.directNeighbors {
|
||||||
|
env.updateTopology(peerID, neighbors)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist cryptographic identity and signing key for robust offline verification
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEAnnouncePreflightAcceptance {
|
||||||
|
let announcement: AnnouncementPacket
|
||||||
|
let derivedPeerID: PeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnouncePreflightRejection: Equatable {
|
||||||
|
case malformed
|
||||||
|
case senderMismatch(derivedPeerID: PeerID)
|
||||||
|
case selfAnnounce
|
||||||
|
case stale(ageSeconds: Double)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnouncePreflightDecision {
|
||||||
|
case accept(BLEAnnouncePreflightAcceptance)
|
||||||
|
case reject(BLEAnnouncePreflightRejection)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnouncePreflightPolicy {
|
||||||
|
static func evaluate(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
from peerID: PeerID,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
now: Date
|
||||||
|
) -> BLEAnnouncePreflightDecision {
|
||||||
|
guard let announcement = AnnouncementPacket.decode(from: packet.payload) else {
|
||||||
|
return .reject(.malformed)
|
||||||
|
}
|
||||||
|
|
||||||
|
let derivedPeerID = PeerID(publicKey: announcement.noisePublicKey)
|
||||||
|
guard derivedPeerID == peerID else {
|
||||||
|
return .reject(.senderMismatch(derivedPeerID: derivedPeerID))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard peerID != localPeerID else {
|
||||||
|
return .reject(.selfAnnounce)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) else {
|
||||||
|
return .reject(.stale(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
||||||
|
timestampMilliseconds: packet.timestamp,
|
||||||
|
now: now
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return .accept(BLEAnnouncePreflightAcceptance(
|
||||||
|
announcement: announcement,
|
||||||
|
derivedPeerID: derivedPeerID
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnounceTrustRejection: Equatable {
|
||||||
|
case missingSignature
|
||||||
|
case invalidSignature
|
||||||
|
case keyMismatch
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnounceTrustDecision: Equatable {
|
||||||
|
case verified
|
||||||
|
case reject(BLEAnnounceTrustRejection)
|
||||||
|
|
||||||
|
var isVerified: Bool {
|
||||||
|
self == .verified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnounceTrustPolicy {
|
||||||
|
static func evaluate(
|
||||||
|
hasSignature: Bool,
|
||||||
|
signatureValid: Bool,
|
||||||
|
existingNoisePublicKey: Data?,
|
||||||
|
announcedNoisePublicKey: Data
|
||||||
|
) -> BLEAnnounceTrustDecision {
|
||||||
|
if let existingNoisePublicKey, existingNoisePublicKey != announcedNoisePublicKey {
|
||||||
|
return .reject(.keyMismatch)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard hasSignature else {
|
||||||
|
return .reject(.missingSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard signatureValid else {
|
||||||
|
return .reject(.invalidSignature)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .verified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEAnnounceResponsePlan: Equatable {
|
||||||
|
let shouldNotifyPeerConnected: Bool
|
||||||
|
let shouldScheduleInitialSync: Bool
|
||||||
|
let shouldSendAnnounceBack: Bool
|
||||||
|
let shouldScheduleAfterglow: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEAnnounceResponsePolicy {
|
||||||
|
static func plan(
|
||||||
|
isDirectAnnounce: Bool,
|
||||||
|
isNewPeer: Bool,
|
||||||
|
isReconnectedPeer: Bool,
|
||||||
|
shouldSendAnnounceBack: Bool
|
||||||
|
) -> BLEAnnounceResponsePlan {
|
||||||
|
let shouldNotifyPeerConnected = isDirectAnnounce && (isNewPeer || isReconnectedPeer)
|
||||||
|
|
||||||
|
return BLEAnnounceResponsePlan(
|
||||||
|
shouldNotifyPeerConnected: shouldNotifyPeerConnected,
|
||||||
|
shouldScheduleInitialSync: shouldNotifyPeerConnected,
|
||||||
|
shouldSendAnnounceBack: shouldSendAnnounceBack,
|
||||||
|
shouldScheduleAfterglow: isNewPeer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEAnnounceThrottle {
|
||||||
|
private var lastSent: Date
|
||||||
|
private let normalMinimumInterval: TimeInterval
|
||||||
|
private let forcedMinimumInterval: TimeInterval
|
||||||
|
|
||||||
|
init(
|
||||||
|
lastSent: Date = .distantPast,
|
||||||
|
normalMinimumInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval,
|
||||||
|
forcedMinimumInterval: TimeInterval = TransportConfig.bleForceAnnounceMinIntervalSeconds
|
||||||
|
) {
|
||||||
|
self.lastSent = lastSent
|
||||||
|
self.normalMinimumInterval = normalMinimumInterval
|
||||||
|
self.forcedMinimumInterval = forcedMinimumInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
func elapsed(since now: Date) -> TimeInterval {
|
||||||
|
now.timeIntervalSince(lastSent)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func shouldSend(force: Bool, now: Date) -> Bool {
|
||||||
|
let minimumInterval = force ? forcedMinimumInterval : normalMinimumInterval
|
||||||
|
guard elapsed(since: now) >= minimumInterval else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
lastSent = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEConnectionCandidate<Peripheral> {
|
||||||
|
let peripheral: Peripheral
|
||||||
|
let peripheralID: String
|
||||||
|
let rssi: Int
|
||||||
|
let name: String
|
||||||
|
let isConnectable: Bool
|
||||||
|
let discoveredAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEExistingConnectionState {
|
||||||
|
let isConnecting: Bool
|
||||||
|
let isConnected: Bool
|
||||||
|
let lastConnectionAttempt: Date?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEPeripheralConnectionState {
|
||||||
|
case disconnected
|
||||||
|
case connecting
|
||||||
|
case connected
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEDiscoveryDecision: Equatable {
|
||||||
|
case ignore
|
||||||
|
case queued
|
||||||
|
case scheduleRetry(after: TimeInterval)
|
||||||
|
case cancelStaleConnection
|
||||||
|
case connectNow
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEConnectionQueueDecision<Peripheral> {
|
||||||
|
case none
|
||||||
|
case retryAfter(TimeInterval)
|
||||||
|
case connect(BLEConnectionCandidate<Peripheral>)
|
||||||
|
}
|
||||||
|
|
||||||
|
final class BLEConnectionScheduler<Peripheral> {
|
||||||
|
private let maxCentralLinks: Int
|
||||||
|
private let connectRateLimitInterval: TimeInterval
|
||||||
|
private let candidateCap: Int
|
||||||
|
private let weakLinkCooldownSeconds: TimeInterval
|
||||||
|
private let weakLinkRSSICutoff: 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
|
||||||
|
private(set) var dynamicRSSIThreshold: Int
|
||||||
|
|
||||||
|
var candidateCount: Int {
|
||||||
|
candidates.count
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
maxCentralLinks: Int = TransportConfig.bleMaxCentralLinks,
|
||||||
|
connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval,
|
||||||
|
candidateCap: Int = TransportConfig.bleConnectionCandidatesMax,
|
||||||
|
weakLinkCooldownSeconds: TimeInterval = TransportConfig.bleWeakLinkCooldownSeconds,
|
||||||
|
weakLinkRSSICutoff: Int = TransportConfig.bleWeakLinkRSSICutoff,
|
||||||
|
dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
|
||||||
|
) {
|
||||||
|
self.maxCentralLinks = maxCentralLinks
|
||||||
|
self.connectRateLimitInterval = connectRateLimitInterval
|
||||||
|
self.candidateCap = candidateCap
|
||||||
|
self.weakLinkCooldownSeconds = weakLinkCooldownSeconds
|
||||||
|
self.weakLinkRSSICutoff = weakLinkRSSICutoff
|
||||||
|
self.initialDynamicRSSIThreshold = dynamicRSSIThreshold
|
||||||
|
self.dynamicRSSIThreshold = dynamicRSSIThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDiscovery(
|
||||||
|
_ candidate: BLEConnectionCandidate<Peripheral>,
|
||||||
|
connectedOrConnectingCount: Int,
|
||||||
|
existingState: BLEExistingConnectionState?,
|
||||||
|
peripheralState: BLEPeripheralConnectionState,
|
||||||
|
now: Date
|
||||||
|
) -> BLEDiscoveryDecision {
|
||||||
|
guard candidate.isConnectable else { return .ignore }
|
||||||
|
|
||||||
|
if candidate.rssi <= dynamicRSSIThreshold {
|
||||||
|
enqueue(candidate)
|
||||||
|
return .queued
|
||||||
|
}
|
||||||
|
|
||||||
|
if connectedOrConnectingCount >= maxCentralLinks {
|
||||||
|
enqueue(candidate)
|
||||||
|
return .queued
|
||||||
|
}
|
||||||
|
|
||||||
|
if let retryDelay = rateLimitRetryDelay(now: now) {
|
||||||
|
enqueue(candidate)
|
||||||
|
return .scheduleRetry(after: retryDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let existingState {
|
||||||
|
if existingState.isConnected || existingState.isConnecting {
|
||||||
|
return .ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastAttempt = existingState.lastConnectionAttempt,
|
||||||
|
now.timeIntervalSince(lastAttempt) < 2.0 {
|
||||||
|
return .ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastTimeout = recentConnectTimeouts[candidate.peripheralID],
|
||||||
|
now.timeIntervalSince(lastTimeout) < TransportConfig.bleTimeoutDiscoveryIgnoreSeconds {
|
||||||
|
return .ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
if let lastDisconnect = recentDisconnects[candidate.peripheralID],
|
||||||
|
now.timeIntervalSince(lastDisconnect) < TransportConfig.bleDisconnectDiscoveryIgnoreSeconds {
|
||||||
|
return .ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
switch peripheralState {
|
||||||
|
case .disconnected:
|
||||||
|
return .connectNow
|
||||||
|
case .connecting, .connected:
|
||||||
|
return .cancelStaleConnection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueue(_ candidate: BLEConnectionCandidate<Peripheral>) {
|
||||||
|
if let existingIndex = candidates.firstIndex(where: { $0.peripheralID == candidate.peripheralID }) {
|
||||||
|
candidates[existingIndex] = candidate
|
||||||
|
} else {
|
||||||
|
candidates.append(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
candidates.sort {
|
||||||
|
if $0.rssi != $1.rssi { return $0.rssi > $1.rssi }
|
||||||
|
return $0.discoveredAt < $1.discoveredAt
|
||||||
|
}
|
||||||
|
if candidates.count > candidateCap {
|
||||||
|
candidates.removeLast(candidates.count - candidateCap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextCandidate(
|
||||||
|
connectedOrConnectingCount: Int,
|
||||||
|
isAlreadyConnectingOrConnected: (String) -> Bool,
|
||||||
|
now: Date
|
||||||
|
) -> BLEConnectionQueueDecision<Peripheral> {
|
||||||
|
guard connectedOrConnectingCount < maxCentralLinks else { return .none }
|
||||||
|
|
||||||
|
if let retryDelay = rateLimitRetryDelay(now: now) {
|
||||||
|
return .retryAfter(retryDelay)
|
||||||
|
}
|
||||||
|
|
||||||
|
while !candidates.isEmpty {
|
||||||
|
candidates.sort { score($0, now: now) > score($1, now: now) }
|
||||||
|
let candidate = candidates.removeFirst()
|
||||||
|
guard candidate.isConnectable else { continue }
|
||||||
|
|
||||||
|
if let delay = weakLinkRetryDelay(for: candidate, now: now) {
|
||||||
|
enqueue(candidate)
|
||||||
|
return .retryAfter(delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let delay = disconnectSettleDelay(for: candidate, now: now) {
|
||||||
|
enqueue(candidate)
|
||||||
|
return .retryAfter(delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isAlreadyConnectingOrConnected(candidate.peripheralID) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
return .connect(candidate)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .none
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordConnectionAttempt(at now: Date) {
|
||||||
|
lastGlobalConnectAttempt = now
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordConnectionSuccess(peripheralID: String) {
|
||||||
|
failureCounts[peripheralID] = 0
|
||||||
|
recentConnectTimeouts.removeValue(forKey: peripheralID)
|
||||||
|
recentDisconnects.removeValue(forKey: peripheralID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordConnectionFailure(peripheralID: String) {
|
||||||
|
failureCounts[peripheralID, default: 0] += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordDisconnectError(peripheralID: String, at now: Date) {
|
||||||
|
recentDisconnects[peripheralID] = now
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordConnectionTimeout(peripheralID: String, at now: Date) {
|
||||||
|
recentConnectTimeouts[peripheralID] = now
|
||||||
|
recordConnectionFailure(peripheralID: peripheralID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pruneConnectionTimeouts(before cutoff: Date) {
|
||||||
|
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= cutoff }
|
||||||
|
recentDisconnects = recentDisconnects.filter { $0.value >= cutoff }
|
||||||
|
}
|
||||||
|
|
||||||
|
func reset() {
|
||||||
|
lastGlobalConnectAttempt = .distantPast
|
||||||
|
candidates.removeAll()
|
||||||
|
failureCounts.removeAll()
|
||||||
|
recentConnectTimeouts.removeAll()
|
||||||
|
recentDisconnects.removeAll()
|
||||||
|
lastIsolatedAt = nil
|
||||||
|
dynamicRSSIThreshold = initialDynamicRSSIThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updateRSSIThreshold(
|
||||||
|
connectedCount: Int,
|
||||||
|
connectedOrConnectingLinkCount: Int,
|
||||||
|
now: Date
|
||||||
|
) -> Int {
|
||||||
|
if connectedCount == 0 {
|
||||||
|
if lastIsolatedAt == nil { lastIsolatedAt = now }
|
||||||
|
let isolatedAt = lastIsolatedAt ?? now
|
||||||
|
let elapsed = now.timeIntervalSince(isolatedAt)
|
||||||
|
dynamicRSSIThreshold = elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds
|
||||||
|
? TransportConfig.bleRSSIIsolatedRelaxed
|
||||||
|
: TransportConfig.bleRSSIIsolatedBase
|
||||||
|
return dynamicRSSIThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamicRSSIThreshold = threshold
|
||||||
|
return threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rateLimitRetryDelay(now: Date) -> TimeInterval? {
|
||||||
|
let elapsed = now.timeIntervalSince(lastGlobalConnectAttempt)
|
||||||
|
guard elapsed < connectRateLimitInterval else { return nil }
|
||||||
|
return connectRateLimitInterval - elapsed + 0.05
|
||||||
|
}
|
||||||
|
|
||||||
|
private func weakLinkRetryDelay(
|
||||||
|
for candidate: BLEConnectionCandidate<Peripheral>,
|
||||||
|
now: Date
|
||||||
|
) -> TimeInterval? {
|
||||||
|
guard let lastTimeout = recentConnectTimeouts[candidate.peripheralID] else { return nil }
|
||||||
|
let elapsed = now.timeIntervalSince(lastTimeout)
|
||||||
|
guard elapsed < weakLinkCooldownSeconds && candidate.rssi <= weakLinkRSSICutoff else { return nil }
|
||||||
|
let remaining = weakLinkCooldownSeconds - elapsed
|
||||||
|
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))
|
||||||
|
let timeoutBias = recentConnectTimeouts[candidate.peripheralID].map {
|
||||||
|
now.timeIntervalSince($0) < 60 ? 10 : 0
|
||||||
|
} ?? 0
|
||||||
|
let base = (candidate.isConnectable ? 1000 : 0) + (candidate.rssi + 100) * 2
|
||||||
|
let recency = -Int(now.timeIntervalSince(candidate.discoveredAt) * 10)
|
||||||
|
return base + recency - penalty - timeoutBias
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEDirectedRelaySpoolEntry {
|
||||||
|
let recipient: PeerID
|
||||||
|
let packet: BitchatPacket
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEDirectedRelaySpool {
|
||||||
|
private struct StoredPacket {
|
||||||
|
let packet: BitchatPacket
|
||||||
|
let enqueuedAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var packetsByRecipient: [PeerID: [String: StoredPacket]] = [:]
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
packetsByRecipient.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
packetsByRecipient.values.reduce(0) { $0 + $1.count }
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func enqueue(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
recipient: PeerID,
|
||||||
|
messageID: String,
|
||||||
|
enqueuedAt: Date
|
||||||
|
) -> Bool {
|
||||||
|
var packets = packetsByRecipient[recipient] ?? [:]
|
||||||
|
guard packets[messageID] == nil else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
packets[messageID] = StoredPacket(packet: packet, enqueuedAt: enqueuedAt)
|
||||||
|
packetsByRecipient[recipient] = packets
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func drainUnexpired(now: Date, window: TimeInterval) -> [BLEDirectedRelaySpoolEntry] {
|
||||||
|
var entries: [BLEDirectedRelaySpoolEntry] = []
|
||||||
|
|
||||||
|
for (recipient, packets) in packetsByRecipient {
|
||||||
|
for stored in packets.values where now.timeIntervalSince(stored.enqueuedAt) <= window {
|
||||||
|
entries.append(BLEDirectedRelaySpoolEntry(recipient: recipient, packet: stored.packet))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
packetsByRecipient.removeAll()
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func pruneExpired(now: Date, window: TimeInterval) {
|
||||||
|
guard !packetsByRecipient.isEmpty else { return }
|
||||||
|
|
||||||
|
var pruned: [PeerID: [String: StoredPacket]] = [:]
|
||||||
|
for (recipient, packets) in packetsByRecipient {
|
||||||
|
let freshPackets = packets.filter { now.timeIntervalSince($0.value.enqueuedAt) <= window }
|
||||||
|
if !freshPackets.isEmpty {
|
||||||
|
pruned[recipient] = freshPackets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
packetsByRecipient = pruned
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
packetsByRecipient.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import CryptoKit
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEFanoutSelection: Equatable {
|
||||||
|
let peripheralIDs: Set<String>
|
||||||
|
let centralIDs: Set<String>
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEFanoutSelector {
|
||||||
|
static func selectLinks(
|
||||||
|
peripheralIDs: [String],
|
||||||
|
centralIDs: [String],
|
||||||
|
ingressLink: BLEIngressLinkID?,
|
||||||
|
excludedLinks: Set<BLEIngressLinkID> = [],
|
||||||
|
peripheralPeerBindings: [String: PeerID] = [:],
|
||||||
|
centralPeerBindings: [String: PeerID] = [:],
|
||||||
|
directedPeerHint: PeerID?,
|
||||||
|
packetType: UInt8,
|
||||||
|
messageID: String
|
||||||
|
) -> BLEFanoutSelection {
|
||||||
|
let allowed = collapseDuplicateLinksPerPeer(
|
||||||
|
allowedLinks(
|
||||||
|
peripheralIDs: peripheralIDs,
|
||||||
|
centralIDs: centralIDs,
|
||||||
|
ingressLink: ingressLink,
|
||||||
|
excludedLinks: excludedLinks
|
||||||
|
),
|
||||||
|
peripheralPeerBindings: peripheralPeerBindings,
|
||||||
|
centralPeerBindings: centralPeerBindings
|
||||||
|
)
|
||||||
|
|
||||||
|
guard shouldSubset(packetType: packetType, directedPeerHint: directedPeerHint) else {
|
||||||
|
return BLEFanoutSelection(
|
||||||
|
peripheralIDs: Set(allowed.peripheralIDs),
|
||||||
|
centralIDs: Set(allowed.centralIDs)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return BLEFanoutSelection(
|
||||||
|
peripheralIDs: deterministicSubset(
|
||||||
|
ids: allowed.peripheralIDs,
|
||||||
|
k: subsetSize(for: allowed.peripheralIDs.count),
|
||||||
|
seed: messageID
|
||||||
|
),
|
||||||
|
centralIDs: deterministicSubset(
|
||||||
|
ids: allowed.centralIDs,
|
||||||
|
k: subsetSize(for: allowed.centralIDs.count),
|
||||||
|
seed: messageID
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func allowedLinks(
|
||||||
|
peripheralIDs: [String],
|
||||||
|
centralIDs: [String],
|
||||||
|
ingressLink: BLEIngressLinkID?,
|
||||||
|
excludedLinks: Set<BLEIngressLinkID>
|
||||||
|
) -> (peripheralIDs: [String], centralIDs: [String]) {
|
||||||
|
var allowedPeripheralIDs = peripheralIDs
|
||||||
|
var allowedCentralIDs = centralIDs
|
||||||
|
var blockedLinks = excludedLinks
|
||||||
|
|
||||||
|
if let ingressLink {
|
||||||
|
blockedLinks.insert(ingressLink)
|
||||||
|
}
|
||||||
|
|
||||||
|
allowedPeripheralIDs.removeAll { blockedLinks.contains(.peripheral($0)) }
|
||||||
|
allowedCentralIDs.removeAll { blockedLinks.contains(.central($0)) }
|
||||||
|
|
||||||
|
return (allowedPeripheralIDs, allowedCentralIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dual-role pairs hold two live links (we-as-central writing to their
|
||||||
|
// peripheral, and they-as-central subscribed to ours). Sending the same
|
||||||
|
// packet down both doubles airtime for nothing — the receiver's assembler
|
||||||
|
// and deduplicator just discard the copy. Keep one link per bound peer,
|
||||||
|
// preferring the peripheral (write) side: it has per-link flow control
|
||||||
|
// via canSendWriteWithoutResponse, while notifications share the
|
||||||
|
// peripheral manager's update queue across all centrals. Links with no
|
||||||
|
// bound peer yet (pre-announce) pass through untouched.
|
||||||
|
private static func collapseDuplicateLinksPerPeer(
|
||||||
|
_ links: (peripheralIDs: [String], centralIDs: [String]),
|
||||||
|
peripheralPeerBindings: [String: PeerID],
|
||||||
|
centralPeerBindings: [String: PeerID]
|
||||||
|
) -> (peripheralIDs: [String], centralIDs: [String]) {
|
||||||
|
guard !peripheralPeerBindings.isEmpty || !centralPeerBindings.isEmpty else {
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
var seenPeers = Set<PeerID>()
|
||||||
|
var keptPeripheralIDs: [String] = []
|
||||||
|
for id in links.peripheralIDs {
|
||||||
|
if let peer = peripheralPeerBindings[id], !seenPeers.insert(peer).inserted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keptPeripheralIDs.append(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
var keptCentralIDs: [String] = []
|
||||||
|
for id in links.centralIDs {
|
||||||
|
if let peer = centralPeerBindings[id], !seenPeers.insert(peer).inserted {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keptCentralIDs.append(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (keptPeripheralIDs, keptCentralIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shouldSubset(packetType: UInt8, directedPeerHint: PeerID?) -> Bool {
|
||||||
|
directedPeerHint == nil
|
||||||
|
&& packetType != MessageType.fragment.rawValue
|
||||||
|
&& packetType != MessageType.announce.rawValue
|
||||||
|
&& packetType != MessageType.requestSync.rawValue
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func subsetSize(for count: Int) -> Int {
|
||||||
|
guard count > 0 else { return 0 }
|
||||||
|
if count <= 2 { return count }
|
||||||
|
|
||||||
|
var value = count - 1
|
||||||
|
var bits = 0
|
||||||
|
while value > 0 {
|
||||||
|
value >>= 1
|
||||||
|
bits += 1
|
||||||
|
}
|
||||||
|
return min(count, max(1, bits + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func deterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
|
||||||
|
guard k > 0 && ids.count > k else { return Set(ids) }
|
||||||
|
|
||||||
|
var scored: [(score: [UInt8], id: String)] = []
|
||||||
|
for id in ids {
|
||||||
|
let data = (seed + "::" + id).data(using: .utf8) ?? Data()
|
||||||
|
let digest = Array(SHA256.hash(data: data))
|
||||||
|
scored.append((digest, id))
|
||||||
|
}
|
||||||
|
|
||||||
|
scored.sort { lhs, rhs in
|
||||||
|
for index in 0..<min(lhs.score.count, rhs.score.count) {
|
||||||
|
if lhs.score[index] != rhs.score[index] {
|
||||||
|
return lhs.score[index] < rhs.score[index]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lhs.id < rhs.id
|
||||||
|
}
|
||||||
|
|
||||||
|
return Set(scored.prefix(k).map(\.id))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,71 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEFileTransferDeliveryPlan: Equatable {
|
||||||
|
let isPrivateMessage: Bool
|
||||||
|
let shouldTrackForSync: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEFileTransferPolicy {
|
||||||
|
static func isSelfEcho(packet: BitchatPacket, from peerID: PeerID, localPeerID: PeerID) -> Bool {
|
||||||
|
peerID == localPeerID && packet.ttl != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
static func deliveryPlan(packet: BitchatPacket, localPeerID: PeerID) -> BLEFileTransferDeliveryPlan? {
|
||||||
|
guard let recipientID = packet.recipientID else {
|
||||||
|
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
let isBroadcast = recipientID.allSatisfy { $0 == 0xFF }
|
||||||
|
if isBroadcast {
|
||||||
|
return BLEFileTransferDeliveryPlan(isPrivateMessage: false, shouldTrackForSync: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard PeerID(hexData: recipientID) == localPeerID else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return BLEFileTransferDeliveryPlan(isPrivateMessage: true, shouldTrackForSync: false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEIncomingFileAcceptance {
|
||||||
|
let filePacket: BitchatFilePacket
|
||||||
|
let mime: MimeType
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEIncomingFileRejection: Error, Equatable {
|
||||||
|
case malformedPayload
|
||||||
|
case payloadTooLarge(bytes: Int)
|
||||||
|
case unsupportedMime(mimeType: String?, bytes: Int)
|
||||||
|
case magicMismatch(mime: MimeType, bytes: Int, prefixHex: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEIncomingFileValidator {
|
||||||
|
static func validate(payload: Data) -> Result<BLEIncomingFileAcceptance, BLEIncomingFileRejection> {
|
||||||
|
guard let filePacket = BitchatFilePacket.decode(payload) else {
|
||||||
|
return .failure(.malformedPayload)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard FileTransferLimits.isValidPayload(filePacket.content.count) else {
|
||||||
|
return .failure(.payloadTooLarge(bytes: filePacket.content.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let mime = MimeType(filePacket.mimeType), mime.isAllowed else {
|
||||||
|
return .failure(.unsupportedMime(
|
||||||
|
mimeType: filePacket.mimeType,
|
||||||
|
bytes: filePacket.content.count
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
guard mime.matches(data: filePacket.content) else {
|
||||||
|
return .failure(.magicMismatch(
|
||||||
|
mime: mime,
|
||||||
|
bytes: filePacket.content.count,
|
||||||
|
prefixHex: filePacket.content.prefix(20).map { String(format: "%02x", $0) }.joined(separator: " ")
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success(BLEIncomingFileAcceptance(filePacket: filePacket, mime: mime))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEFragmentKey: Hashable, Equatable {
|
||||||
|
let sender: UInt64
|
||||||
|
let id: UInt64
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEFragmentHeader: Equatable {
|
||||||
|
let key: BLEFragmentKey
|
||||||
|
let index: Int
|
||||||
|
let total: Int
|
||||||
|
let originalType: UInt8
|
||||||
|
let fragmentData: Data
|
||||||
|
let isBroadcastFragment: Bool
|
||||||
|
|
||||||
|
var idLogString: String {
|
||||||
|
String(format: "%016llx", key.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
init?(packet: BitchatPacket) {
|
||||||
|
// Minimum header: 8 bytes ID + 2 index + 2 total + 1 type.
|
||||||
|
guard packet.payload.count >= 13 else { return nil }
|
||||||
|
|
||||||
|
var senderU64: UInt64 = 0
|
||||||
|
for byte in packet.senderID.prefix(8) {
|
||||||
|
senderU64 = (senderU64 << 8) | UInt64(byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
var fragmentU64: UInt64 = 0
|
||||||
|
for byte in packet.payload.prefix(8) {
|
||||||
|
fragmentU64 = (fragmentU64 << 8) | UInt64(byte)
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = Int((UInt16(packet.payload[8]) << 8) | UInt16(packet.payload[9]))
|
||||||
|
let total = Int((UInt16(packet.payload[10]) << 8) | UInt16(packet.payload[11]))
|
||||||
|
|
||||||
|
guard total > 0 && total <= 10_000 && index >= 0 && index < total else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let isBroadcastFragment: Bool = {
|
||||||
|
guard let recipient = packet.recipientID else { return true }
|
||||||
|
return recipient.count == 8 && recipient.allSatisfy { $0 == 0xFF }
|
||||||
|
}()
|
||||||
|
|
||||||
|
self.key = BLEFragmentKey(sender: senderU64, id: fragmentU64)
|
||||||
|
self.index = index
|
||||||
|
self.total = total
|
||||||
|
self.originalType = packet.payload[12]
|
||||||
|
self.fragmentData = Data(packet.payload.suffix(from: 13))
|
||||||
|
self.isBroadcastFragment = isBroadcastFragment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEFragmentAssemblyBuffer {
|
||||||
|
enum AppendResult: Equatable {
|
||||||
|
case stored(header: BLEFragmentHeader, started: Bool)
|
||||||
|
case complete(header: BLEFragmentHeader, reassembledData: Data, started: Bool)
|
||||||
|
case oversized(header: BLEFragmentHeader, projectedSize: Int, limit: Int, started: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct Metadata {
|
||||||
|
let type: UInt8
|
||||||
|
let total: Int
|
||||||
|
let timestamp: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fragmentsByKey: [BLEFragmentKey: [Int: Data]] = [:]
|
||||||
|
private var metadataByKey: [BLEFragmentKey: Metadata] = [:]
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
fragmentsByKey.removeAll()
|
||||||
|
metadataByKey.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func removeExpired(before cutoff: Date) -> Int {
|
||||||
|
let expiredKeys = metadataByKey
|
||||||
|
.filter { $0.value.timestamp < cutoff }
|
||||||
|
.map(\.key)
|
||||||
|
|
||||||
|
for key in expiredKeys {
|
||||||
|
fragmentsByKey.removeValue(forKey: key)
|
||||||
|
metadataByKey.removeValue(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return expiredKeys.count
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func append(
|
||||||
|
_ header: BLEFragmentHeader,
|
||||||
|
maxInFlightAssemblies: Int,
|
||||||
|
now: Date = Date()
|
||||||
|
) -> AppendResult {
|
||||||
|
let started = startAssemblyIfNeeded(for: header, maxInFlightAssemblies: maxInFlightAssemblies, now: now)
|
||||||
|
|
||||||
|
let currentSize = fragmentsByKey[header.key]?.values.reduce(0) { $0 + $1.count } ?? 0
|
||||||
|
let limit = Self.assemblyLimit(for: header.originalType)
|
||||||
|
let projectedSize = currentSize + header.fragmentData.count
|
||||||
|
|
||||||
|
guard projectedSize <= limit else {
|
||||||
|
fragmentsByKey.removeValue(forKey: header.key)
|
||||||
|
metadataByKey.removeValue(forKey: header.key)
|
||||||
|
return .oversized(header: header, projectedSize: projectedSize, limit: limit, started: started)
|
||||||
|
}
|
||||||
|
|
||||||
|
fragmentsByKey[header.key]?[header.index] = header.fragmentData
|
||||||
|
|
||||||
|
guard let fragments = fragmentsByKey[header.key],
|
||||||
|
fragments.count == header.total else {
|
||||||
|
return .stored(header: header, started: started)
|
||||||
|
}
|
||||||
|
|
||||||
|
let reassembled = (0..<header.total).reduce(into: Data()) { data, index in
|
||||||
|
if let fragment = fragments[index] {
|
||||||
|
data.append(fragment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fragmentsByKey.removeValue(forKey: header.key)
|
||||||
|
metadataByKey.removeValue(forKey: header.key)
|
||||||
|
|
||||||
|
return .complete(header: header, reassembledData: reassembled, started: started)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func startAssemblyIfNeeded(
|
||||||
|
for header: BLEFragmentHeader,
|
||||||
|
maxInFlightAssemblies: Int,
|
||||||
|
now: Date
|
||||||
|
) -> Bool {
|
||||||
|
guard fragmentsByKey[header.key] == nil else { return false }
|
||||||
|
|
||||||
|
if fragmentsByKey.count >= maxInFlightAssemblies,
|
||||||
|
let oldest = metadataByKey.min(by: { $0.value.timestamp < $1.value.timestamp })?.key {
|
||||||
|
fragmentsByKey.removeValue(forKey: oldest)
|
||||||
|
metadataByKey.removeValue(forKey: oldest)
|
||||||
|
}
|
||||||
|
|
||||||
|
fragmentsByKey[header.key] = [:]
|
||||||
|
metadataByKey[header.key] = Metadata(type: header.originalType, total: header.total, timestamp: now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func assemblyLimit(for originalType: UInt8) -> Int {
|
||||||
|
if originalType == MessageType.fileTransfer.rawValue {
|
||||||
|
// Allow headroom for TLV metadata and binary framing overhead.
|
||||||
|
return FileTransferLimits.maxFramedFileBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileTransferLimits.maxPayloadBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEInboundWriteChunk: Equatable {
|
||||||
|
let offset: Int
|
||||||
|
let data: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEInboundWriteAppendMetadata: Equatable {
|
||||||
|
let accumulatedBytes: Int
|
||||||
|
let appendedBytes: Int
|
||||||
|
let offsets: [Int]
|
||||||
|
let packetType: UInt8?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEInboundWriteBuffer {
|
||||||
|
enum AppendResult {
|
||||||
|
case decoded(packet: BitchatPacket, metadata: BLEInboundWriteAppendMetadata)
|
||||||
|
case waiting(metadata: BLEInboundWriteAppendMetadata)
|
||||||
|
case oversized(metadata: BLEInboundWriteAppendMetadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var buffersByCentralID: [String: Data] = [:]
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
buffersByCentralID.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func append(
|
||||||
|
chunks: [BLEInboundWriteChunk],
|
||||||
|
for centralID: String,
|
||||||
|
capBytes: Int
|
||||||
|
) -> AppendResult {
|
||||||
|
var combined = buffersByCentralID[centralID] ?? Data()
|
||||||
|
var appendedBytes = 0
|
||||||
|
var offsets: [Int] = []
|
||||||
|
|
||||||
|
for chunk in chunks where !chunk.data.isEmpty {
|
||||||
|
offsets.append(chunk.offset)
|
||||||
|
let end = chunk.offset + chunk.data.count
|
||||||
|
|
||||||
|
if combined.count < end {
|
||||||
|
combined.append(Data(repeating: 0, count: end - combined.count))
|
||||||
|
}
|
||||||
|
|
||||||
|
combined.replaceSubrange(chunk.offset..<end, with: chunk.data)
|
||||||
|
appendedBytes += chunk.data.count
|
||||||
|
}
|
||||||
|
|
||||||
|
let metadata = BLEInboundWriteAppendMetadata(
|
||||||
|
accumulatedBytes: combined.count,
|
||||||
|
appendedBytes: appendedBytes,
|
||||||
|
offsets: offsets,
|
||||||
|
packetType: combined.count >= 2 ? combined[1] : nil
|
||||||
|
)
|
||||||
|
|
||||||
|
if let packet = BinaryProtocol.decode(combined) {
|
||||||
|
buffersByCentralID.removeValue(forKey: centralID)
|
||||||
|
return .decoded(packet: packet, metadata: metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard combined.count <= capBytes else {
|
||||||
|
buffersByCentralID.removeValue(forKey: centralID)
|
||||||
|
return .oversized(metadata: metadata)
|
||||||
|
}
|
||||||
|
|
||||||
|
buffersByCentralID[centralID] = combined
|
||||||
|
return .waiting(metadata: metadata)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import BitLogger
|
||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEIncomingFileStore {
|
||||||
|
private static let quotaBytes: Int64 = 100 * 1024 * 1024
|
||||||
|
|
||||||
|
private let fileManager: FileManager
|
||||||
|
private let baseDirectory: URL?
|
||||||
|
private let dateProvider: () -> Date
|
||||||
|
|
||||||
|
init(fileManager: FileManager = .default, baseDirectory: URL? = nil, dateProvider: @escaping () -> Date = Date.init) {
|
||||||
|
self.fileManager = fileManager
|
||||||
|
self.baseDirectory = baseDirectory
|
||||||
|
self.dateProvider = dateProvider
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(
|
||||||
|
data: Data,
|
||||||
|
preferredName: String?,
|
||||||
|
subdirectory: String,
|
||||||
|
fallbackExtension: String?,
|
||||||
|
defaultPrefix: String
|
||||||
|
) -> URL? {
|
||||||
|
do {
|
||||||
|
let base = try filesDirectory().appendingPathComponent(subdirectory, isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: base, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
let sanitized = sanitizedFileName(
|
||||||
|
preferredName,
|
||||||
|
defaultName: "\(defaultPrefix)_\(Self.timestampString(from: dateProvider()))",
|
||||||
|
fallbackExtension: fallbackExtension
|
||||||
|
)
|
||||||
|
let destination = uniqueFileURL(in: base, fileName: sanitized)
|
||||||
|
try data.write(to: destination, options: .atomic)
|
||||||
|
return destination
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("❌ Failed to persist incoming media: \(error)", category: .session)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enforceQuota(reservingBytes: Int) {
|
||||||
|
do {
|
||||||
|
let base = try filesDirectory()
|
||||||
|
let incomingDirs = [
|
||||||
|
base.appendingPathComponent("voicenotes/incoming", isDirectory: true),
|
||||||
|
base.appendingPathComponent("images/incoming", isDirectory: true),
|
||||||
|
base.appendingPathComponent("files/incoming", isDirectory: true)
|
||||||
|
]
|
||||||
|
var allFiles: [(url: URL, size: Int64, modified: Date)] = []
|
||||||
|
|
||||||
|
for dir in incomingDirs where fileManager.fileExists(atPath: dir.path) {
|
||||||
|
guard let contents = try? fileManager.contentsOfDirectory(
|
||||||
|
at: dir,
|
||||||
|
includingPropertiesForKeys: [.fileSizeKey, .contentModificationDateKey],
|
||||||
|
options: [.skipsHiddenFiles]
|
||||||
|
) else { continue }
|
||||||
|
|
||||||
|
for fileURL in contents {
|
||||||
|
guard let attrs = try? fileURL.resourceValues(forKeys: [.fileSizeKey, .contentModificationDateKey]),
|
||||||
|
let size = attrs.fileSize,
|
||||||
|
let modified = attrs.contentModificationDate else { continue }
|
||||||
|
allFiles.append((url: fileURL, size: Int64(size), modified: modified))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentUsage = allFiles.reduce(0) { $0 + $1.size }
|
||||||
|
let targetUsage = Self.quotaBytes - Int64(reservingBytes)
|
||||||
|
guard currentUsage > targetUsage else { return }
|
||||||
|
|
||||||
|
let needToFree = currentUsage - targetUsage
|
||||||
|
var freedSpace: Int64 = 0
|
||||||
|
for file in allFiles.sorted(by: { $0.modified < $1.modified }) {
|
||||||
|
guard freedSpace < needToFree else { break }
|
||||||
|
do {
|
||||||
|
try fileManager.removeItem(at: file.url)
|
||||||
|
freedSpace += file.size
|
||||||
|
SecureLogger.debug("🗑️ BCH-01-002: Deleted old incoming file to free space: \(file.url.lastPathComponent)", category: .security)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning("⚠️ Failed to delete old file for quota: \(error)", category: .security)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if freedSpace > 0 {
|
||||||
|
SecureLogger.info("📊 BCH-01-002: Freed \(ByteCountFormatter.string(fromByteCount: freedSpace, countStyle: .file)) to stay within incoming files quota", category: .security)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.warning("⚠️ Could not enforce storage quota: \(error)", category: .security)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func filesDirectory() throws -> URL {
|
||||||
|
let root = try baseDirectory ?? fileManager.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let filesDir = root.appendingPathComponent("files", isDirectory: true)
|
||||||
|
try fileManager.createDirectory(at: filesDir, withIntermediateDirectories: true, attributes: nil)
|
||||||
|
return filesDir
|
||||||
|
}
|
||||||
|
|
||||||
|
private func sanitizedFileName(_ name: String?, defaultName: String, fallbackExtension: String?) -> String {
|
||||||
|
var candidate = (name ?? "")
|
||||||
|
.replacingOccurrences(of: "\0", with: "")
|
||||||
|
.precomposedStringWithCanonicalMapping
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "\\", with: "_")
|
||||||
|
|
||||||
|
let invalid = CharacterSet(charactersIn: "<>:\"|?*\0").union(.controlCharacters)
|
||||||
|
candidate = candidate.components(separatedBy: invalid).joined(separator: "_").trimmed
|
||||||
|
if candidate.isEmpty { candidate = defaultName }
|
||||||
|
if candidate.hasPrefix(".") { candidate = "_" + candidate }
|
||||||
|
|
||||||
|
if candidate.count > 120 {
|
||||||
|
let ext = (candidate as NSString).pathExtension
|
||||||
|
let base = (candidate as NSString).deletingPathExtension
|
||||||
|
candidate = ext.isEmpty
|
||||||
|
? String(candidate.prefix(120))
|
||||||
|
: String(base.prefix(max(10, 120 - ext.count - 1))) + "." + ext
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fallbackExtension, (candidate as NSString).pathExtension.isEmpty {
|
||||||
|
candidate += ".\(fallbackExtension)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate.isEmpty ? defaultName : candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
private func uniqueFileURL(in directory: URL, fileName: String) -> URL {
|
||||||
|
let directoryPath = directory.standardizedFileURL.path
|
||||||
|
func isInsideDirectory(_ url: URL) -> Bool {
|
||||||
|
url.standardizedFileURL.path.hasPrefix(directoryPath + "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = directory.appendingPathComponent(fileName)
|
||||||
|
guard isInsideDirectory(candidate) else {
|
||||||
|
SecureLogger.warning("⚠️ Path traversal blocked: \(fileName)", category: .security)
|
||||||
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fileManager.fileExists(atPath: candidate.path) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
let baseName = (fileName as NSString).deletingPathExtension
|
||||||
|
let ext = (fileName as NSString).pathExtension
|
||||||
|
for counter in 1..<100 {
|
||||||
|
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
||||||
|
candidate = directory.appendingPathComponent(newName)
|
||||||
|
guard isInsideDirectory(candidate) else {
|
||||||
|
return directory.appendingPathComponent("blocked_\(UUID().uuidString)")
|
||||||
|
}
|
||||||
|
if !fileManager.fileExists(atPath: candidate.path) {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return directory.appendingPathComponent("\(baseName)_\(UUID().uuidString).\(ext.isEmpty ? "dat" : ext)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func timestampString(from date: Date) -> String {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyyMMdd_HHmmss"
|
||||||
|
return formatter.string(from: date)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEIngressLinkID: Hashable, Equatable {
|
||||||
|
case peripheral(String)
|
||||||
|
case central(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEIngressPacketContext: Equatable {
|
||||||
|
let receivedFromPeerID: PeerID
|
||||||
|
let validationPeerID: PeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEIngressLinkRecord: Equatable {
|
||||||
|
let link: BLEIngressLinkID
|
||||||
|
let peerID: PeerID
|
||||||
|
let timestamp: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEIngressRejection: Error, Equatable {
|
||||||
|
case selfLoopback(packetType: UInt8)
|
||||||
|
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEIngressLinkRegistry {
|
||||||
|
private var ingressByMessageID: [String: BLEIngressLinkRecord] = [:]
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
ingressByMessageID.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
ingressByMessageID.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func record(for packet: BitchatPacket) -> BLEIngressLinkRecord? {
|
||||||
|
ingressByMessageID[Self.messageID(for: packet)]
|
||||||
|
}
|
||||||
|
|
||||||
|
func link(for packet: BitchatPacket) -> BLEIngressLinkID? {
|
||||||
|
record(for: packet)?.link
|
||||||
|
}
|
||||||
|
|
||||||
|
func peerID(for packet: BitchatPacket) -> PeerID? {
|
||||||
|
record(for: packet)?.peerID
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func recordIfNew(
|
||||||
|
_ packet: BitchatPacket,
|
||||||
|
link: BLEIngressLinkID,
|
||||||
|
peerID: PeerID,
|
||||||
|
now: Date = Date(),
|
||||||
|
lifetime: TimeInterval
|
||||||
|
) -> Bool {
|
||||||
|
let messageID = Self.messageID(for: packet)
|
||||||
|
if let existing = ingressByMessageID[messageID],
|
||||||
|
now.timeIntervalSince(existing.timestamp) <= lifetime {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ingressByMessageID[messageID] = BLEIngressLinkRecord(link: link, peerID: peerID, timestamp: now)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func prune(before cutoff: Date) {
|
||||||
|
ingressByMessageID = ingressByMessageID.filter { $0.value.timestamp >= cutoff }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func packetContext(
|
||||||
|
for packet: BitchatPacket,
|
||||||
|
claimedSenderID: PeerID,
|
||||||
|
boundPeerID: PeerID?,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
directAnnounceTTL: UInt8
|
||||||
|
) -> Result<BLEIngressPacketContext, BLEIngressRejection> {
|
||||||
|
if claimedSenderID == localPeerID,
|
||||||
|
!isSelfAuthoredSyncResponse(packet) {
|
||||||
|
return .failure(.selfLoopback(packetType: packet.type))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let boundPeerID,
|
||||||
|
boundPeerID != claimedSenderID,
|
||||||
|
requiresDirectSenderBinding(packet, directAnnounceTTL: directAnnounceTTL) {
|
||||||
|
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||||
|
}
|
||||||
|
|
||||||
|
let receivedFromPeerID = boundPeerID ?? claimedSenderID
|
||||||
|
let validationPeerID = packet.isRSR ? receivedFromPeerID : claimedSenderID
|
||||||
|
return .success(BLEIngressPacketContext(
|
||||||
|
receivedFromPeerID: receivedFromPeerID,
|
||||||
|
validationPeerID: validationPeerID
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func messageID(for packet: BitchatPacket) -> String {
|
||||||
|
let senderID = packet.senderID.hexEncodedString()
|
||||||
|
let digestPrefix = packet.payload.sha256Hash().prefix(4).hexEncodedString()
|
||||||
|
return "\(senderID)-\(packet.timestamp)-\(packet.type)-\(digestPrefix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func requiresDirectSenderBinding(_ packet: BitchatPacket, directAnnounceTTL: UInt8) -> Bool {
|
||||||
|
packet.type == MessageType.announce.rawValue && packet.ttl == directAnnounceTTL
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func isSelfAuthoredSyncResponse(_ packet: BitchatPacket) -> Bool {
|
||||||
|
packet.isRSR && packet.ttl == 0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEIngressPacketGuard {
|
||||||
|
enum Rejection: Error, Equatable {
|
||||||
|
case selfLoopback(packetType: UInt8)
|
||||||
|
case directSenderMismatch(boundPeerID: PeerID, claimedSenderID: PeerID)
|
||||||
|
case invalidRSR(peerID: PeerID)
|
||||||
|
case timestampSkew(peerID: PeerID, skewMs: UInt64, maxSkewMs: UInt64)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func evaluate(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
claimedSenderID: PeerID,
|
||||||
|
boundPeerID: PeerID?,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
directAnnounceTTL: UInt8,
|
||||||
|
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
maxTimestampSkewMs: UInt64 = 120_000,
|
||||||
|
isValidSyncResponse: (PeerID) -> Bool
|
||||||
|
) -> Result<BLEIngressPacketContext, Rejection> {
|
||||||
|
let contextResult = BLEIngressLinkRegistry.packetContext(
|
||||||
|
for: packet,
|
||||||
|
claimedSenderID: claimedSenderID,
|
||||||
|
boundPeerID: boundPeerID,
|
||||||
|
localPeerID: localPeerID,
|
||||||
|
directAnnounceTTL: directAnnounceTTL
|
||||||
|
)
|
||||||
|
|
||||||
|
let context: BLEIngressPacketContext
|
||||||
|
switch contextResult {
|
||||||
|
case .success(let acceptedContext):
|
||||||
|
context = acceptedContext
|
||||||
|
case .failure(.selfLoopback(let packetType)):
|
||||||
|
return .failure(.selfLoopback(packetType: packetType))
|
||||||
|
case .failure(.directSenderMismatch(let boundPeerID, let claimedSenderID)):
|
||||||
|
return .failure(.directSenderMismatch(boundPeerID: boundPeerID, claimedSenderID: claimedSenderID))
|
||||||
|
}
|
||||||
|
|
||||||
|
switch validatePayload(
|
||||||
|
packet,
|
||||||
|
from: context.validationPeerID,
|
||||||
|
nowMs: nowMs,
|
||||||
|
maxTimestampSkewMs: maxTimestampSkewMs,
|
||||||
|
isValidSyncResponse: isValidSyncResponse
|
||||||
|
) {
|
||||||
|
case .success:
|
||||||
|
return .success(context)
|
||||||
|
case .failure(let rejection):
|
||||||
|
return .failure(rejection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func validatePayload(
|
||||||
|
_ packet: BitchatPacket,
|
||||||
|
from peerID: PeerID,
|
||||||
|
nowMs: UInt64 = UInt64(Date().timeIntervalSince1970 * 1000),
|
||||||
|
maxTimestampSkewMs: UInt64 = 120_000,
|
||||||
|
isValidSyncResponse: (PeerID) -> Bool
|
||||||
|
) -> Result<Void, Rejection> {
|
||||||
|
if packet.isRSR {
|
||||||
|
guard isValidSyncResponse(peerID) else {
|
||||||
|
return .failure(.invalidRSR(peerID: peerID))
|
||||||
|
}
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
|
||||||
|
let packetTime = packet.timestamp
|
||||||
|
let skew = packetTime > nowMs ? packetTime - nowMs : nowMs - packetTime
|
||||||
|
guard skew <= maxTimestampSkewMs else {
|
||||||
|
return .failure(.timestampSkew(peerID: peerID, skewMs: skew, maxSkewMs: maxTimestampSkewMs))
|
||||||
|
}
|
||||||
|
|
||||||
|
return .success(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import CoreBluetooth
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEPeripheralLinkState {
|
||||||
|
let peripheral: CBPeripheral
|
||||||
|
var characteristic: CBCharacteristic?
|
||||||
|
var peerID: PeerID?
|
||||||
|
var isConnecting: Bool
|
||||||
|
var isConnected: Bool
|
||||||
|
var lastConnectionAttempt: Date?
|
||||||
|
var assembler: NotificationStreamAssembler
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEDirectLinkState: Equatable {
|
||||||
|
let hasPeripheral: Bool
|
||||||
|
let hasCentral: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLESubscribedCentralSnapshot {
|
||||||
|
let centrals: [CBCentral]
|
||||||
|
let peerIDsByCentralUUID: [String: PeerID]
|
||||||
|
|
||||||
|
func central(for peerID: PeerID) -> CBCentral? {
|
||||||
|
centrals.first { peerIDsByCentralUUID[$0.identifier.uuidString] == peerID }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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] {
|
||||||
|
assertOwned()
|
||||||
|
return Array(peripherals.values)
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscribedCentralSnapshot: BLESubscribedCentralSnapshot {
|
||||||
|
assertOwned()
|
||||||
|
return BLESubscribedCentralSnapshot(
|
||||||
|
centrals: subscribedCentrals,
|
||||||
|
peerIDsByCentralUUID: centralToPeerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var subscribedCentralCount: Int {
|
||||||
|
assertOwned()
|
||||||
|
return subscribedCentrals.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectedOrConnectingPeripheralCount: Int {
|
||||||
|
assertOwned()
|
||||||
|
return peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
|
||||||
|
}
|
||||||
|
|
||||||
|
func state(forPeripheralID peripheralID: String) -> BLEPeripheralLinkState? {
|
||||||
|
assertOwned()
|
||||||
|
return peripherals[peripheralID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func setPeripheralState(_ state: BLEPeripheralLinkState, for peripheralID: String) {
|
||||||
|
assertOwned()
|
||||||
|
peripherals[peripheralID] = state
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
func updatePeripheral(
|
||||||
|
_ peripheralID: String,
|
||||||
|
_ update: (inout BLEPeripheralLinkState) -> Void
|
||||||
|
) -> BLEPeripheralLinkState? {
|
||||||
|
assertOwned()
|
||||||
|
guard var state = peripherals[peripheralID] else { return nil }
|
||||||
|
update(&state)
|
||||||
|
peripherals[peripheralID] = state
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
func beginConnecting(to peripheral: CBPeripheral, at date: Date) {
|
||||||
|
setPeripheralState(
|
||||||
|
BLEPeripheralLinkState(
|
||||||
|
peripheral: peripheral,
|
||||||
|
characteristic: nil,
|
||||||
|
peerID: nil,
|
||||||
|
isConnecting: true,
|
||||||
|
isConnected: false,
|
||||||
|
lastConnectionAttempt: date,
|
||||||
|
assembler: NotificationStreamAssembler()
|
||||||
|
),
|
||||||
|
for: peripheral.identifier.uuidString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func markConnected(_ peripheral: CBPeripheral) {
|
||||||
|
let peripheralID = peripheral.identifier.uuidString
|
||||||
|
if updatePeripheral(peripheralID, {
|
||||||
|
$0.isConnecting = false
|
||||||
|
$0.isConnected = true
|
||||||
|
}) == nil {
|
||||||
|
setPeripheralState(
|
||||||
|
BLEPeripheralLinkState(
|
||||||
|
peripheral: peripheral,
|
||||||
|
characteristic: nil,
|
||||||
|
peerID: nil,
|
||||||
|
isConnecting: false,
|
||||||
|
isConnected: true,
|
||||||
|
lastConnectionAttempt: nil,
|
||||||
|
assembler: NotificationStreamAssembler()
|
||||||
|
),
|
||||||
|
for: peripheralID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateCharacteristic(_ characteristic: CBCharacteristic, forPeripheralID peripheralID: String) {
|
||||||
|
updatePeripheral(peripheralID) {
|
||||||
|
$0.characteristic = characteristic
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func directPeripheralState(for peerID: PeerID) -> BLEPeripheralLinkState? {
|
||||||
|
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)
|
||||||
|
return BLEDirectLinkState(hasPeripheral: hasPeripheral, hasCentral: hasCentral)
|
||||||
|
}
|
||||||
|
|
||||||
|
func links(to peerID: PeerID?) -> Set<BLEIngressLinkID> {
|
||||||
|
assertOwned()
|
||||||
|
guard let peerID else { return [] }
|
||||||
|
|
||||||
|
var links: Set<BLEIngressLinkID> = []
|
||||||
|
if let peripheralUUID = peerToPeripheralUUID[peerID] {
|
||||||
|
links.insert(.peripheral(peripheralUUID))
|
||||||
|
}
|
||||||
|
for (centralUUID, mappedPeerID) in centralToPeerID where mappedPeerID == peerID {
|
||||||
|
links.insert(.central(centralUUID))
|
||||||
|
}
|
||||||
|
return links
|
||||||
|
}
|
||||||
|
|
||||||
|
func peerID(forPeripheralID peripheralID: String) -> PeerID? {
|
||||||
|
assertOwned()
|
||||||
|
return peripherals[peripheralID]?.peerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func peerID(forCentralUUID centralUUID: String) -> PeerID? {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
return peerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearPeripherals() -> [PeerID] {
|
||||||
|
assertOwned()
|
||||||
|
let peerIDs = peripherals.compactMap { $0.value.peerID }
|
||||||
|
peripherals.removeAll()
|
||||||
|
peerToPeripheralUUID.removeAll()
|
||||||
|
return peerIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearCentrals() -> [PeerID] {
|
||||||
|
assertOwned()
|
||||||
|
let peerIDs = Array(centralToPeerID.values)
|
||||||
|
subscribedCentrals.removeAll()
|
||||||
|
centralToPeerID.removeAll()
|
||||||
|
return peerIDs
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAll() {
|
||||||
|
assertOwned()
|
||||||
|
peripherals.removeAll()
|
||||||
|
peerToPeripheralUUID.removeAll()
|
||||||
|
subscribedCentrals.removeAll()
|
||||||
|
centralToPeerID.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
final class BLELogRateLimiter {
|
||||||
|
private let defaultMinimumInterval: TimeInterval
|
||||||
|
private let queue = DispatchQueue(label: "chat.bitchat.ble.log-rate-limiter")
|
||||||
|
private var lastLogTimeByKey: [String: Date] = [:]
|
||||||
|
|
||||||
|
init(defaultMinimumInterval: TimeInterval) {
|
||||||
|
self.defaultMinimumInterval = defaultMinimumInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldLog(
|
||||||
|
key: String,
|
||||||
|
now: Date = Date(),
|
||||||
|
minimumInterval: TimeInterval? = nil
|
||||||
|
) -> Bool {
|
||||||
|
queue.sync {
|
||||||
|
let interval = minimumInterval ?? defaultMinimumInterval
|
||||||
|
if let lastLogTime = lastLogTimeByKey[key],
|
||||||
|
now.timeIntervalSince(lastLogTime) < interval {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
lastLogTimeByKey[key] = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeAll() {
|
||||||
|
queue.sync {
|
||||||
|
lastLogTimeByKey.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEMaintenancePlan: Equatable {
|
||||||
|
let shouldSendAnnounce: Bool
|
||||||
|
let shouldEnsureAdvertising: Bool
|
||||||
|
let shouldRunCleanup: Bool
|
||||||
|
let shouldFlushDirectedSpool: Bool
|
||||||
|
let shouldResetCounter: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEMaintenancePolicy {
|
||||||
|
static func plan(
|
||||||
|
cycle: Int,
|
||||||
|
connectedCount: Int,
|
||||||
|
peerRegistryIsEmpty: Bool,
|
||||||
|
elapsedSinceLastAnnounce: TimeInterval,
|
||||||
|
hasRecentTraffic: Bool,
|
||||||
|
connectedAnnounceJitterOffset: TimeInterval? = nil,
|
||||||
|
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
|
||||||
|
) -> BLEMaintenancePlan {
|
||||||
|
BLEMaintenancePlan(
|
||||||
|
shouldSendAnnounce: shouldSendAnnounce(
|
||||||
|
connectedCount: connectedCount,
|
||||||
|
elapsedSinceLastAnnounce: elapsedSinceLastAnnounce,
|
||||||
|
hasRecentTraffic: hasRecentTraffic,
|
||||||
|
connectedAnnounceJitterOffset: connectedAnnounceJitterOffset,
|
||||||
|
highDegreeThreshold: highDegreeThreshold
|
||||||
|
),
|
||||||
|
shouldEnsureAdvertising: peerRegistryIsEmpty,
|
||||||
|
shouldRunCleanup: cycle.isMultiple(of: 3),
|
||||||
|
shouldFlushDirectedSpool: !cycle.isMultiple(of: 2),
|
||||||
|
shouldResetCounter: cycle >= 6
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldSendAnnounce(
|
||||||
|
connectedCount: Int,
|
||||||
|
elapsedSinceLastAnnounce: TimeInterval,
|
||||||
|
hasRecentTraffic: Bool,
|
||||||
|
connectedAnnounceJitterOffset: TimeInterval? = nil,
|
||||||
|
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
|
||||||
|
) -> Bool {
|
||||||
|
if hasRecentTraffic && elapsedSinceLastAnnounce >= 10.0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
guard connectedCount > 0 else {
|
||||||
|
return elapsedSinceLastAnnounce >= TransportConfig.bleAnnounceIntervalSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
let highDegree = connectedCount >= highDegreeThreshold
|
||||||
|
let base = highDegree ?
|
||||||
|
TransportConfig.bleConnectedAnnounceBaseSecondsDense :
|
||||||
|
TransportConfig.bleConnectedAnnounceBaseSecondsSparse
|
||||||
|
let jitter = highDegree ?
|
||||||
|
TransportConfig.bleConnectedAnnounceJitterDense :
|
||||||
|
TransportConfig.bleConnectedAnnounceJitterSparse
|
||||||
|
let jitterOffset = connectedAnnounceJitterOffset ?? Double.random(in: -jitter...jitter)
|
||||||
|
|
||||||
|
return elapsedSinceLastAnnounce >= base + jitterOffset
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLENoisePayloadFactory {
|
||||||
|
static func privateMessage(content: String, messageID: String) -> Data? {
|
||||||
|
guard let payload = PrivateMessagePacket(messageID: messageID, content: content).encode() else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return typedPayload(.privateMessage, payload: payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func readReceipt(originalMessageID: String) -> Data {
|
||||||
|
typedPayload(.readReceipt, payload: Data(originalMessageID.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func delivered(messageID: String) -> Data {
|
||||||
|
typedPayload(.delivered, payload: Data(messageID.utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
static func typedPayload(_ type: NoisePayloadType, payload: Data) -> Data {
|
||||||
|
var typed = Data([type.rawValue])
|
||||||
|
typed.append(payload)
|
||||||
|
return typed
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEPendingPrivateMessage: Equatable {
|
||||||
|
let content: String
|
||||||
|
let messageID: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLENoiseSessionQueues {
|
||||||
|
private var privateMessagesByPeerID: [PeerID: [BLEPendingPrivateMessage]] = [:]
|
||||||
|
private var typedPayloadsByPeerID: [PeerID: [Data]] = [:]
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
privateMessagesByPeerID.isEmpty && typedPayloadsByPeerID.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
privateMessagesByPeerID.removeAll()
|
||||||
|
typedPayloadsByPeerID.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func appendPrivateMessage(content: String, messageID: String, for peerID: PeerID) {
|
||||||
|
privateMessagesByPeerID[peerID, default: []].append(BLEPendingPrivateMessage(content: content, messageID: messageID))
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takePrivateMessages(for peerID: PeerID) -> [BLEPendingPrivateMessage] {
|
||||||
|
let messages = privateMessagesByPeerID[peerID] ?? []
|
||||||
|
privateMessagesByPeerID.removeValue(forKey: peerID)
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func prependPrivateMessages(_ messages: [BLEPendingPrivateMessage], for peerID: PeerID) {
|
||||||
|
guard !messages.isEmpty else { return }
|
||||||
|
privateMessagesByPeerID[peerID, default: []].insert(contentsOf: messages, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func appendTypedPayload(_ payload: Data, for peerID: PeerID) {
|
||||||
|
typedPayloadsByPeerID[peerID, default: []].append(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takeTypedPayloads(for peerID: PeerID) -> [Data] {
|
||||||
|
let payloads = typedPayloadsByPeerID[peerID] ?? []
|
||||||
|
typedPayloadsByPeerID.removeValue(forKey: peerID)
|
||||||
|
return payloads
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEOutboundFragmentPlan {
|
||||||
|
let fragmentPackets: [BitchatPacket]
|
||||||
|
let fragmentVersion: UInt8
|
||||||
|
let chunkSize: Int
|
||||||
|
let spacingMs: Int
|
||||||
|
|
||||||
|
var totalFragments: Int {
|
||||||
|
fragmentPackets.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var shouldPauseScanning: Bool {
|
||||||
|
totalFragments > 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEOutboundFragmentPlanner {
|
||||||
|
private static let minimumChunkSize = 64
|
||||||
|
private static let fragmentIDLength = 8
|
||||||
|
|
||||||
|
static func makePlan(
|
||||||
|
for request: BLEOutboundFragmentTransferRequest,
|
||||||
|
defaultChunkSize: Int,
|
||||||
|
bleMaxMTU: Int,
|
||||||
|
fragmentID: Data = randomFragmentID()
|
||||||
|
) -> BLEOutboundFragmentPlan? {
|
||||||
|
guard fragmentID.count == fragmentIDLength,
|
||||||
|
let fullData = request.packet.toBinaryData(padding: request.pad) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let sizing = sizingPolicy(
|
||||||
|
for: request.packet,
|
||||||
|
requestedMaxChunk: request.maxChunk,
|
||||||
|
defaultChunkSize: defaultChunkSize,
|
||||||
|
bleMaxMTU: bleMaxMTU
|
||||||
|
)
|
||||||
|
|
||||||
|
let chunks = stride(from: 0, to: fullData.count, by: sizing.chunkSize).map { offset in
|
||||||
|
Data(fullData[offset..<min(offset + sizing.chunkSize, fullData.count)])
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !chunks.isEmpty else { return nil }
|
||||||
|
|
||||||
|
let fragmentRecipient: Data? = {
|
||||||
|
if let directedPeer = request.directedPeer {
|
||||||
|
return Data(hexString: directedPeer.id)
|
||||||
|
}
|
||||||
|
return request.packet.recipientID
|
||||||
|
}()
|
||||||
|
|
||||||
|
let fragmentPackets = chunks.enumerated().map { index, chunk in
|
||||||
|
makeFragmentPacket(
|
||||||
|
original: request.packet,
|
||||||
|
fragmentID: fragmentID,
|
||||||
|
index: index,
|
||||||
|
total: chunks.count,
|
||||||
|
fragmentData: chunk,
|
||||||
|
fragmentRecipient: fragmentRecipient,
|
||||||
|
fragmentVersion: sizing.fragmentVersion
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return BLEOutboundFragmentPlan(
|
||||||
|
fragmentPackets: fragmentPackets,
|
||||||
|
fragmentVersion: sizing.fragmentVersion,
|
||||||
|
chunkSize: sizing.chunkSize,
|
||||||
|
spacingMs: spacingMs(for: request)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func sizingPolicy(
|
||||||
|
for packet: BitchatPacket,
|
||||||
|
requestedMaxChunk: Int?,
|
||||||
|
defaultChunkSize: Int,
|
||||||
|
bleMaxMTU: Int
|
||||||
|
) -> (fragmentVersion: UInt8, chunkSize: Int) {
|
||||||
|
var fragmentVersion: UInt8 = 1
|
||||||
|
var calculatedChunk = defaultChunkSize
|
||||||
|
|
||||||
|
if let route = packet.route, !route.isEmpty {
|
||||||
|
fragmentVersion = 2
|
||||||
|
let routeSize = 1 + (route.count * 8)
|
||||||
|
let overhead = 16 + 8 + 8 + routeSize + 13 + 16
|
||||||
|
calculatedChunk = max(minimumChunkSize, bleMaxMTU - overhead)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
fragmentVersion: fragmentVersion,
|
||||||
|
chunkSize: max(minimumChunkSize, requestedMaxChunk ?? calculatedChunk)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func makeFragmentPacket(
|
||||||
|
original packet: BitchatPacket,
|
||||||
|
fragmentID: Data,
|
||||||
|
index: Int,
|
||||||
|
total: Int,
|
||||||
|
fragmentData: Data,
|
||||||
|
fragmentRecipient: Data?,
|
||||||
|
fragmentVersion: UInt8
|
||||||
|
) -> BitchatPacket {
|
||||||
|
var payload = Data()
|
||||||
|
payload.append(fragmentID)
|
||||||
|
payload.append(contentsOf: withUnsafeBytes(of: UInt16(index).bigEndian) { Data($0) })
|
||||||
|
payload.append(contentsOf: withUnsafeBytes(of: UInt16(total).bigEndian) { Data($0) })
|
||||||
|
payload.append(packet.type)
|
||||||
|
payload.append(fragmentData)
|
||||||
|
|
||||||
|
return BitchatPacket(
|
||||||
|
type: MessageType.fragment.rawValue,
|
||||||
|
senderID: packet.senderID,
|
||||||
|
recipientID: fragmentRecipient,
|
||||||
|
timestamp: packet.timestamp,
|
||||||
|
payload: payload,
|
||||||
|
signature: nil,
|
||||||
|
ttl: packet.ttl,
|
||||||
|
version: fragmentVersion,
|
||||||
|
route: packet.route,
|
||||||
|
isRSR: packet.isRSR
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func spacingMs(for request: BLEOutboundFragmentTransferRequest) -> Int {
|
||||||
|
if request.directedPeer != nil || request.packet.recipientID != nil {
|
||||||
|
return TransportConfig.bleFragmentSpacingDirectedMs
|
||||||
|
}
|
||||||
|
|
||||||
|
return TransportConfig.bleFragmentSpacingMs
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func randomFragmentID() -> Data {
|
||||||
|
Data((0..<fragmentIDLength).map { _ in UInt8.random(in: 0...255) })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEOutboundFragmentTransferRequest {
|
||||||
|
let packet: BitchatPacket
|
||||||
|
let pad: Bool
|
||||||
|
let maxChunk: Int?
|
||||||
|
let directedPeer: PeerID?
|
||||||
|
let transferId: String?
|
||||||
|
|
||||||
|
var resolvedTransferId: String? {
|
||||||
|
guard packet.type == MessageType.fileTransfer.rawValue else { return nil }
|
||||||
|
return transferId ?? packet.payload.sha256Hex()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEOutboundFragmentTransferScheduler {
|
||||||
|
enum QueuePosition {
|
||||||
|
case front
|
||||||
|
case back
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SubmitResult {
|
||||||
|
case start(request: BLEOutboundFragmentTransferRequest, reservedTransferId: String?)
|
||||||
|
case queued(request: BLEOutboundFragmentTransferRequest, transferId: String?, position: QueuePosition)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CancelResult {
|
||||||
|
case active(transferId: String, workItems: [DispatchWorkItem])
|
||||||
|
case pending(transferId: String)
|
||||||
|
case missing
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SentResult: Equatable {
|
||||||
|
case progress(sentFragments: Int, totalFragments: Int)
|
||||||
|
case complete(sentFragments: Int, totalFragments: Int)
|
||||||
|
case missing
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct ActiveTransferState {
|
||||||
|
let totalFragments: Int
|
||||||
|
var sentFragments: Int
|
||||||
|
var workItems: [DispatchWorkItem]
|
||||||
|
}
|
||||||
|
|
||||||
|
private var activeTransfers: [String: ActiveTransferState] = [:]
|
||||||
|
private var pendingTransfers: [BLEOutboundFragmentTransferRequest] = []
|
||||||
|
|
||||||
|
var activeCount: Int {
|
||||||
|
activeTransfers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var pendingCount: Int {
|
||||||
|
pendingTransfers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() -> [(id: String, workItems: [DispatchWorkItem])] {
|
||||||
|
let active = activeTransfers.map { ($0.key, $0.value.workItems) }
|
||||||
|
activeTransfers.removeAll()
|
||||||
|
pendingTransfers.removeAll()
|
||||||
|
return active
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func submit(
|
||||||
|
_ request: BLEOutboundFragmentTransferRequest,
|
||||||
|
maxConcurrentTransfers: Int
|
||||||
|
) -> SubmitResult {
|
||||||
|
guard let transferId = request.resolvedTransferId else {
|
||||||
|
return .start(request: request, reservedTransferId: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||||
|
pendingTransfers.append(request)
|
||||||
|
return .queued(request: request, transferId: transferId, position: .back)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard activeTransfers[transferId] == nil else {
|
||||||
|
pendingTransfers.insert(request, at: 0)
|
||||||
|
return .queued(request: request, transferId: transferId, position: .front)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
|
||||||
|
return .start(request: request, reservedTransferId: transferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func activateReservedTransfer(
|
||||||
|
id transferId: String,
|
||||||
|
totalFragments: Int,
|
||||||
|
workItems: [DispatchWorkItem]
|
||||||
|
) -> Bool {
|
||||||
|
guard activeTransfers[transferId] != nil else { return false }
|
||||||
|
activeTransfers[transferId] = ActiveTransferState(
|
||||||
|
totalFragments: totalFragments,
|
||||||
|
sentFragments: 0,
|
||||||
|
workItems: workItems
|
||||||
|
)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func updateWorkItems(_ workItems: [DispatchWorkItem], for transferId: String) -> Bool {
|
||||||
|
guard var state = activeTransfers[transferId] else { return false }
|
||||||
|
state.workItems = workItems
|
||||||
|
activeTransfers[transferId] = state
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func releaseReservation(_ transferId: String) -> [DispatchWorkItem]? {
|
||||||
|
activeTransfers.removeValue(forKey: transferId)?.workItems
|
||||||
|
}
|
||||||
|
|
||||||
|
func isActive(_ transferId: String) -> Bool {
|
||||||
|
activeTransfers[transferId] != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func cancelTransfer(_ transferId: String) -> CancelResult {
|
||||||
|
if let active = activeTransfers.removeValue(forKey: transferId) {
|
||||||
|
return .active(transferId: transferId, workItems: active.workItems)
|
||||||
|
}
|
||||||
|
|
||||||
|
if let pendingIndex = pendingTransfers.firstIndex(where: { $0.resolvedTransferId == transferId || $0.transferId == transferId }) {
|
||||||
|
pendingTransfers.remove(at: pendingIndex)
|
||||||
|
return .pending(transferId: transferId)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .missing
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func markFragmentSent(transferId: String) -> SentResult {
|
||||||
|
guard var state = activeTransfers[transferId] else { return .missing }
|
||||||
|
|
||||||
|
state.sentFragments = min(state.sentFragments + 1, state.totalFragments)
|
||||||
|
let isComplete = state.sentFragments >= state.totalFragments
|
||||||
|
|
||||||
|
if isComplete {
|
||||||
|
activeTransfers.removeValue(forKey: transferId)
|
||||||
|
return .complete(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTransfers[transferId] = state
|
||||||
|
return .progress(sentFragments: state.sentFragments, totalFragments: state.totalFragments)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func reservePendingStarts(maxConcurrentTransfers: Int) -> [SubmitResult] {
|
||||||
|
var availableSlots = max(0, maxConcurrentTransfers - activeTransfers.count)
|
||||||
|
guard availableSlots > 0, !pendingTransfers.isEmpty else { return [] }
|
||||||
|
|
||||||
|
var results: [SubmitResult] = []
|
||||||
|
var blockedFront: [BLEOutboundFragmentTransferRequest] = []
|
||||||
|
|
||||||
|
while availableSlots > 0, !pendingTransfers.isEmpty {
|
||||||
|
let request = pendingTransfers.removeFirst()
|
||||||
|
availableSlots -= 1
|
||||||
|
|
||||||
|
guard let transferId = request.resolvedTransferId else {
|
||||||
|
results.append(.start(request: request, reservedTransferId: nil))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard activeTransfers.count < maxConcurrentTransfers else {
|
||||||
|
pendingTransfers.insert(request, at: 0)
|
||||||
|
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
guard activeTransfers[transferId] == nil else {
|
||||||
|
blockedFront.append(request)
|
||||||
|
results.append(.queued(request: request, transferId: transferId, position: .front))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTransfers[transferId] = ActiveTransferState(totalFragments: 0, sentFragments: 0, workItems: [])
|
||||||
|
results.append(.start(request: request, reservedTransferId: transferId))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !blockedFront.isEmpty {
|
||||||
|
pendingTransfers.insert(contentsOf: blockedFront, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEOutboundLinkPlan: Equatable {
|
||||||
|
let directedPeerHint: PeerID?
|
||||||
|
let fragmentChunkSize: Int?
|
||||||
|
let selectedLinks: BLEFanoutSelection
|
||||||
|
let shouldSpoolDirectedPacket: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEOutboundLinkPlanner {
|
||||||
|
static func plan(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
dataCount: Int,
|
||||||
|
peripheralIDs: [String],
|
||||||
|
peripheralWriteLimits: [Int],
|
||||||
|
centralIDs: [String],
|
||||||
|
centralNotifyLimits: [Int],
|
||||||
|
ingressRecord: BLEIngressLinkRecord?,
|
||||||
|
excludedLinks: Set<BLEIngressLinkID>,
|
||||||
|
peripheralPeerBindings: [String: PeerID] = [:],
|
||||||
|
centralPeerBindings: [String: PeerID] = [:],
|
||||||
|
directedOnlyPeer: PeerID?
|
||||||
|
) -> BLEOutboundLinkPlan {
|
||||||
|
if let minLimit = minimumLinkLimit(
|
||||||
|
peripheralWriteLimits: peripheralWriteLimits,
|
||||||
|
centralNotifyLimits: centralNotifyLimits
|
||||||
|
), packet.type != MessageType.fragment.rawValue,
|
||||||
|
dataCount > minLimit {
|
||||||
|
return BLEOutboundLinkPlan(
|
||||||
|
directedPeerHint: directedPeerHint(for: packet, explicitPeer: directedOnlyPeer),
|
||||||
|
fragmentChunkSize: BLEOutboundPacketPolicy.fragmentChunkSize(forLinkLimit: minLimit),
|
||||||
|
selectedLinks: BLEFanoutSelection(peripheralIDs: [], centralIDs: []),
|
||||||
|
shouldSpoolDirectedPacket: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
let directedPeerHint = directedPeerHint(for: packet, explicitPeer: directedOnlyPeer)
|
||||||
|
let selectedLinks = BLEFanoutSelector.selectLinks(
|
||||||
|
peripheralIDs: peripheralIDs,
|
||||||
|
centralIDs: centralIDs,
|
||||||
|
ingressLink: ingressRecord?.link,
|
||||||
|
excludedLinks: excludedLinks,
|
||||||
|
peripheralPeerBindings: peripheralPeerBindings,
|
||||||
|
centralPeerBindings: centralPeerBindings,
|
||||||
|
directedPeerHint: directedPeerHint,
|
||||||
|
packetType: packet.type,
|
||||||
|
messageID: BLEOutboundPacketPolicy.messageID(for: packet)
|
||||||
|
)
|
||||||
|
|
||||||
|
return BLEOutboundLinkPlan(
|
||||||
|
directedPeerHint: directedPeerHint,
|
||||||
|
fragmentChunkSize: nil,
|
||||||
|
selectedLinks: selectedLinks,
|
||||||
|
shouldSpoolDirectedPacket: shouldSpoolDirectedPacket(
|
||||||
|
directedPeerHint: directedPeerHint,
|
||||||
|
selectedLinks: selectedLinks,
|
||||||
|
packetType: packet.type
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func directedPeerHint(for packet: BitchatPacket, explicitPeer: PeerID?) -> PeerID? {
|
||||||
|
if let explicitPeer { return explicitPeer }
|
||||||
|
if let recipient = PeerID(str: packet.recipientID?.hexEncodedString()), !recipient.isEmpty {
|
||||||
|
return recipient
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func minimumLinkLimit(peripheralWriteLimits: [Int], centralNotifyLimits: [Int]) -> Int? {
|
||||||
|
[peripheralWriteLimits.min(), centralNotifyLimits.min()]
|
||||||
|
.compactMap { $0 }
|
||||||
|
.min()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldSpoolDirectedPacket(
|
||||||
|
directedPeerHint: PeerID?,
|
||||||
|
selectedLinks: BLEFanoutSelection,
|
||||||
|
packetType: UInt8
|
||||||
|
) -> Bool {
|
||||||
|
guard directedPeerHint != nil,
|
||||||
|
selectedLinks.peripheralIDs.isEmpty,
|
||||||
|
selectedLinks.centralIDs.isEmpty else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return packetType == MessageType.noiseEncrypted.rawValue ||
|
||||||
|
packetType == MessageType.noiseHandshake.rawValue
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEPendingNotification<Target> {
|
||||||
|
let data: Data
|
||||||
|
let targets: [Target]?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEOutboundNotificationBuffer<Target> {
|
||||||
|
enum EnqueueResult {
|
||||||
|
case enqueued(count: Int)
|
||||||
|
case full(count: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var notifications: [BLEPendingNotification<Target>] = []
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
notifications.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
notifications.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
notifications.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func enqueue(data: Data, targets: [Target]?, capCount: Int) -> EnqueueResult {
|
||||||
|
guard notifications.count < capCount else {
|
||||||
|
return .full(count: notifications.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
notifications.append(BLEPendingNotification(data: data, targets: targets))
|
||||||
|
return .enqueued(count: notifications.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takeAll() -> [BLEPendingNotification<Target>] {
|
||||||
|
let pending = notifications
|
||||||
|
notifications.removeAll()
|
||||||
|
return pending
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func prepend(_ pending: [BLEPendingNotification<Target>]) {
|
||||||
|
guard !pending.isEmpty else { return }
|
||||||
|
notifications.insert(contentsOf: pending, at: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEOutboundPacketPolicy {
|
||||||
|
private static let fragmentFrameOverhead = 13 + 8 + 8 + 13
|
||||||
|
|
||||||
|
static func messageID(for packet: BitchatPacket) -> String {
|
||||||
|
BLEIngressLinkRegistry.messageID(for: packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func padsBLEFrame(for packetType: UInt8) -> Bool {
|
||||||
|
switch MessageType(rawValue: packetType) {
|
||||||
|
case .noiseEncrypted, .noiseHandshake:
|
||||||
|
return true
|
||||||
|
case .none, .announce, .message, .leave, .requestSync, .fragment, .fileTransfer:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func priority(for packet: BitchatPacket, data: Data) -> BLEOutboundWritePriority {
|
||||||
|
guard let messageType = MessageType(rawValue: packet.type) else { return .low }
|
||||||
|
switch messageType {
|
||||||
|
case .fragment:
|
||||||
|
return .fragment(totalFragments: fragmentTotalCount(from: packet.payload))
|
||||||
|
case .fileTransfer:
|
||||||
|
return .fileTransfer
|
||||||
|
default:
|
||||||
|
return .high
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func fragmentChunkSize(forLinkLimit limit: Int) -> Int {
|
||||||
|
max(64, limit - fragmentFrameOverhead)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func fragmentTotalCount(from payload: Data) -> Int {
|
||||||
|
guard payload.count >= 12 else { return Int(UInt16.max) }
|
||||||
|
let totalHigh = Int(payload[10])
|
||||||
|
let totalLow = Int(payload[11])
|
||||||
|
let total = (totalHigh << 8) | totalLow
|
||||||
|
return max(total, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEOutboundWritePriority: Comparable {
|
||||||
|
let level: Int
|
||||||
|
let suborder: Int
|
||||||
|
|
||||||
|
static let high = BLEOutboundWritePriority(level: 0, suborder: 0)
|
||||||
|
|
||||||
|
static func fragment(totalFragments: Int) -> BLEOutboundWritePriority {
|
||||||
|
BLEOutboundWritePriority(level: 1, suborder: max(1, min(totalFragments, Int(UInt16.max))))
|
||||||
|
}
|
||||||
|
|
||||||
|
static let fileTransfer = BLEOutboundWritePriority(level: 2, suborder: Int.max - 1)
|
||||||
|
static let low = BLEOutboundWritePriority(level: 2, suborder: Int.max)
|
||||||
|
|
||||||
|
static func < (lhs: BLEOutboundWritePriority, rhs: BLEOutboundWritePriority) -> Bool {
|
||||||
|
if lhs.level != rhs.level { return lhs.level < rhs.level }
|
||||||
|
return lhs.suborder < rhs.suborder
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPendingWrite {
|
||||||
|
let priority: BLEOutboundWritePriority
|
||||||
|
let data: Data
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEOutboundWriteBuffer {
|
||||||
|
enum EnqueueResult {
|
||||||
|
case enqueued(trimmedBytes: Int, remainingBytes: Int)
|
||||||
|
case oversized(bytes: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var writesByPeripheralID: [String: [BLEPendingWrite]] = [:]
|
||||||
|
|
||||||
|
var peripheralIDs: [String] {
|
||||||
|
Array(writesByPeripheralID.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
writesByPeripheralID.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func enqueue(
|
||||||
|
data: Data,
|
||||||
|
for peripheralID: String,
|
||||||
|
priority: BLEOutboundWritePriority,
|
||||||
|
capBytes: Int
|
||||||
|
) -> EnqueueResult {
|
||||||
|
guard data.count <= capBytes else {
|
||||||
|
return .oversized(bytes: data.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
var queue = writesByPeripheralID[peripheralID] ?? []
|
||||||
|
let item = BLEPendingWrite(priority: priority, data: data)
|
||||||
|
let insertIndex = queue.firstIndex { item.priority < $0.priority } ?? queue.count
|
||||||
|
queue.insert(item, at: insertIndex)
|
||||||
|
|
||||||
|
var total = queue.reduce(0) { $0 + $1.data.count }
|
||||||
|
var trimmedBytes = 0
|
||||||
|
|
||||||
|
while total > capBytes && !queue.isEmpty {
|
||||||
|
let removed = queue.removeLast()
|
||||||
|
trimmedBytes += removed.data.count
|
||||||
|
total -= removed.data.count
|
||||||
|
}
|
||||||
|
|
||||||
|
writesByPeripheralID[peripheralID] = queue.isEmpty ? nil : queue
|
||||||
|
return .enqueued(trimmedBytes: trimmedBytes, remainingBytes: total)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takeAll(for peripheralID: String) -> [BLEPendingWrite] {
|
||||||
|
let items = writesByPeripheralID[peripheralID] ?? []
|
||||||
|
writesByPeripheralID[peripheralID] = nil
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func prepend(_ items: [BLEPendingWrite], for peripheralID: String) {
|
||||||
|
guard !items.isEmpty else { return }
|
||||||
|
var existing = writesByPeripheralID[peripheralID] ?? []
|
||||||
|
existing.insert(contentsOf: items, at: 0)
|
||||||
|
writesByPeripheralID[peripheralID] = existing
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEPacketFreshnessPolicy {
|
||||||
|
static let defaultMaxAgeSeconds: TimeInterval = 900
|
||||||
|
|
||||||
|
static func isBroadcastRecipient(_ recipientID: Data?) -> Bool {
|
||||||
|
guard let recipientID else { return true }
|
||||||
|
return recipientID.count == 8 && recipientID.allSatisfy { $0 == 0xFF }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func isStale(
|
||||||
|
timestampMilliseconds: UInt64,
|
||||||
|
now: Date,
|
||||||
|
maxAgeSeconds: TimeInterval = defaultMaxAgeSeconds
|
||||||
|
) -> Bool {
|
||||||
|
let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000)
|
||||||
|
let maxAgeMilliseconds = UInt64(maxAgeSeconds * 1000)
|
||||||
|
guard nowMilliseconds >= maxAgeMilliseconds else { return false }
|
||||||
|
return timestampMilliseconds < nowMilliseconds - maxAgeMilliseconds
|
||||||
|
}
|
||||||
|
|
||||||
|
static func ageSeconds(timestampMilliseconds: UInt64, now: Date) -> Double {
|
||||||
|
let nowMilliseconds = UInt64(now.timeIntervalSince1970 * 1000)
|
||||||
|
guard nowMilliseconds >= timestampMilliseconds else { return 0 }
|
||||||
|
return Double(nowMilliseconds - timestampMilliseconds) / 1000.0
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEPeerEventDebouncer {
|
||||||
|
private var lastEmitByPeer: [PeerID: Date] = [:]
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
lastEmitByPeer.count
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func shouldEmit(peerID: PeerID, now: Date, minimumInterval: TimeInterval) -> Bool {
|
||||||
|
if let lastEmit = lastEmitByPeer[peerID],
|
||||||
|
now.timeIntervalSince(lastEmit) < minimumInterval {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
lastEmitByPeer[peerID] = now
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
lastEmitByPeer.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEPeerPublishDecision: Equatable {
|
||||||
|
case publishNow
|
||||||
|
case schedule(delay: TimeInterval)
|
||||||
|
case skip
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPeerPublishCoalescer {
|
||||||
|
private var lastPublishAt: Date
|
||||||
|
private var publishPending: Bool
|
||||||
|
private let minimumInterval: TimeInterval
|
||||||
|
|
||||||
|
init(
|
||||||
|
lastPublishAt: Date = .distantPast,
|
||||||
|
publishPending: Bool = false,
|
||||||
|
minimumInterval: TimeInterval = 0.1
|
||||||
|
) {
|
||||||
|
self.lastPublishAt = lastPublishAt
|
||||||
|
self.publishPending = publishPending
|
||||||
|
self.minimumInterval = minimumInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func requestPublish(now: Date) -> BLEPeerPublishDecision {
|
||||||
|
let elapsed = now.timeIntervalSince(lastPublishAt)
|
||||||
|
if elapsed >= minimumInterval {
|
||||||
|
lastPublishAt = now
|
||||||
|
return .publishNow
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !publishPending else {
|
||||||
|
return .skip
|
||||||
|
}
|
||||||
|
|
||||||
|
publishPending = true
|
||||||
|
return .schedule(delay: minimumInterval - elapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func scheduledPublishFired(now: Date) {
|
||||||
|
lastPublishAt = now
|
||||||
|
publishPending = false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEPeerInfo: Equatable {
|
||||||
|
let peerID: PeerID
|
||||||
|
var nickname: String
|
||||||
|
var isConnected: Bool
|
||||||
|
var noisePublicKey: Data?
|
||||||
|
var signingPublicKey: Data?
|
||||||
|
var isVerifiedNickname: Bool
|
||||||
|
var lastSeen: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPeerAnnounceUpdate: Equatable {
|
||||||
|
let isNewPeer: Bool
|
||||||
|
let wasDisconnected: Bool
|
||||||
|
let previousNickname: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPeerLinkPresence: Equatable {
|
||||||
|
var hasPeripheral: Bool
|
||||||
|
var hasCentral: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLERemovedPeer: Equatable {
|
||||||
|
let peerID: PeerID
|
||||||
|
let nickname: String
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPeerConnectivityChanges: Equatable {
|
||||||
|
var disconnectedPeerIDs: [PeerID] = []
|
||||||
|
var removedPeers: [BLERemovedPeer] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEPeerRegistry {
|
||||||
|
private var peers: [PeerID: BLEPeerInfo] = [:]
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
peers.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
peers.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var peerIDs: [PeerID] {
|
||||||
|
Array(peers.keys)
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectedCount: Int {
|
||||||
|
peers.values.filter(\.isConnected).count
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectedPeerIDs: [PeerID] {
|
||||||
|
peers.values.compactMap { $0.isConnected ? $0.peerID : nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
var connectedRoutingData: [Data] {
|
||||||
|
peers.values.filter(\.isConnected).compactMap { $0.peerID.routingData }
|
||||||
|
}
|
||||||
|
|
||||||
|
var snapshotByID: [PeerID: BLEPeerInfo] {
|
||||||
|
peers
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
peers.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func info(for peerID: PeerID) -> BLEPeerInfo? {
|
||||||
|
peers[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func upsert(_ info: BLEPeerInfo) {
|
||||||
|
peers[info.peerID] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func remove(_ peerID: PeerID) -> BLEPeerInfo? {
|
||||||
|
peers.removeValue(forKey: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isConnected(_ peerID: PeerID) -> Bool {
|
||||||
|
peers[peerID.toShort()]?.isConnected ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isReachable(_ peerID: PeerID, now: Date) -> Bool {
|
||||||
|
let shortID = peerID.toShort()
|
||||||
|
let meshAttached = connectedCount > 0
|
||||||
|
guard let info = peers[shortID] else { return false }
|
||||||
|
if info.isConnected { return true }
|
||||||
|
guard meshAttached else { return false }
|
||||||
|
|
||||||
|
let retention: TimeInterval = info.isVerifiedNickname
|
||||||
|
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
|
||||||
|
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||||
|
return now.timeIntervalSince(info.lastSeen) <= retention
|
||||||
|
}
|
||||||
|
|
||||||
|
func nickname(for peerID: PeerID, connectedOnly: Bool) -> String? {
|
||||||
|
guard let peer = peers[peerID] else { return nil }
|
||||||
|
if connectedOnly && !peer.isConnected { return nil }
|
||||||
|
return peer.nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprint(for peerID: PeerID) -> String? {
|
||||||
|
peers[peerID]?.noisePublicKey?.sha256Fingerprint()
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayNicknames(selfNickname: String) -> [PeerID: String] {
|
||||||
|
let connected = peers.filter { $0.value.isConnected }
|
||||||
|
let tuples = connected.map { ($0.key, $0.value.nickname, true) }
|
||||||
|
return PeerDisplayNameResolver.resolve(tuples, selfNickname: selfNickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
func transportSnapshots(selfNickname: String) -> [TransportPeerSnapshot] {
|
||||||
|
let snapshot = Array(peers.values)
|
||||||
|
let resolvedNames = PeerDisplayNameResolver.resolve(
|
||||||
|
snapshot.map { ($0.peerID, $0.nickname, $0.isConnected) },
|
||||||
|
selfNickname: selfNickname
|
||||||
|
)
|
||||||
|
return snapshot.map { info in
|
||||||
|
TransportPeerSnapshot(
|
||||||
|
peerID: info.peerID,
|
||||||
|
nickname: resolvedNames[info.peerID] ?? info.nickname,
|
||||||
|
isConnected: info.isConnected,
|
||||||
|
noisePublicKey: info.noisePublicKey,
|
||||||
|
lastSeen: info.lastSeen
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func collisionResolvedNickname(for peerID: PeerID, selfNickname: String) -> String? {
|
||||||
|
guard let info = peers[peerID], info.isVerifiedNickname else { return nil }
|
||||||
|
let hasCollision = peers.values.contains {
|
||||||
|
$0.isConnected && $0.nickname == info.nickname && $0.peerID != peerID
|
||||||
|
} || selfNickname == info.nickname
|
||||||
|
return hasCollision ? info.nickname + "#" + String(peerID.id.prefix(4)) : info.nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func markDisconnected(_ peerID: PeerID) {
|
||||||
|
guard var info = peers[peerID] else { return }
|
||||||
|
info.isConnected = false
|
||||||
|
peers[peerID] = info
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func updateLastSeen(_ peerID: PeerID, at date: Date) {
|
||||||
|
guard var peer = peers[peerID] else { return }
|
||||||
|
peer.lastSeen = date
|
||||||
|
peers[peerID] = peer
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func upsertVerifiedAnnounce(
|
||||||
|
peerID: PeerID,
|
||||||
|
nickname: String,
|
||||||
|
noisePublicKey: Data,
|
||||||
|
signingPublicKey: Data?,
|
||||||
|
isConnected: Bool,
|
||||||
|
now: Date
|
||||||
|
) -> BLEPeerAnnounceUpdate {
|
||||||
|
let existing = peers[peerID]
|
||||||
|
let update = BLEPeerAnnounceUpdate(
|
||||||
|
isNewPeer: existing == nil,
|
||||||
|
wasDisconnected: existing?.isConnected == false,
|
||||||
|
previousNickname: existing?.nickname
|
||||||
|
)
|
||||||
|
|
||||||
|
peers[peerID] = BLEPeerInfo(
|
||||||
|
peerID: existing?.peerID ?? peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
isConnected: isConnected,
|
||||||
|
noisePublicKey: noisePublicKey,
|
||||||
|
signingPublicKey: signingPublicKey,
|
||||||
|
isVerifiedNickname: true,
|
||||||
|
lastSeen: now
|
||||||
|
)
|
||||||
|
|
||||||
|
return update
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func reconcileConnectivity(
|
||||||
|
now: Date,
|
||||||
|
linkStates: [PeerID: BLEPeerLinkPresence]
|
||||||
|
) -> BLEPeerConnectivityChanges {
|
||||||
|
var changes = BLEPeerConnectivityChanges()
|
||||||
|
|
||||||
|
for (peerID, peer) in Array(peers) {
|
||||||
|
let age = now.timeIntervalSince(peer.lastSeen)
|
||||||
|
let retention: TimeInterval = peer.isVerifiedNickname
|
||||||
|
? TransportConfig.bleReachabilityRetentionVerifiedSeconds
|
||||||
|
: TransportConfig.bleReachabilityRetentionUnverifiedSeconds
|
||||||
|
|
||||||
|
if peer.isConnected && age > TransportConfig.blePeerInactivityTimeoutSeconds {
|
||||||
|
let state = linkStates[peerID] ?? BLEPeerLinkPresence(hasPeripheral: false, hasCentral: false)
|
||||||
|
if !state.hasPeripheral && !state.hasCentral {
|
||||||
|
var updated = peer
|
||||||
|
updated.isConnected = false
|
||||||
|
peers[peerID] = updated
|
||||||
|
changes.disconnectedPeerIDs.append(peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !peer.isConnected && age > retention {
|
||||||
|
peers.removeValue(forKey: peerID)
|
||||||
|
changes.removedPeers.append(BLERemovedPeer(peerID: peerID, nickname: peer.nickname))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return changes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEPeerSenderDisplayName {
|
||||||
|
static func resolveKnownPeer(
|
||||||
|
peerID: PeerID,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
localNickname: String,
|
||||||
|
peers: [PeerID: BLEPeerInfo],
|
||||||
|
allowConnectedUnverified: Bool
|
||||||
|
) -> String? {
|
||||||
|
if peerID == localPeerID {
|
||||||
|
return localNickname
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let info = peers[peerID] else { return nil }
|
||||||
|
|
||||||
|
if info.isVerifiedNickname {
|
||||||
|
return collisionResolvedName(
|
||||||
|
displayName: info.nickname,
|
||||||
|
collisionNickname: info.nickname,
|
||||||
|
peerID: peerID,
|
||||||
|
localNickname: localNickname,
|
||||||
|
peers: peers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if allowConnectedUnverified, info.isConnected {
|
||||||
|
let displayName = info.nickname.isEmpty ? anonymousNickname(for: peerID) : info.nickname
|
||||||
|
return collisionResolvedName(
|
||||||
|
displayName: displayName,
|
||||||
|
collisionNickname: info.nickname,
|
||||||
|
peerID: peerID,
|
||||||
|
localNickname: localNickname,
|
||||||
|
peers: peers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
static func anonymousNickname(for peerID: PeerID) -> String {
|
||||||
|
"anon" + String(peerID.id.prefix(4))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func collisionResolvedName(
|
||||||
|
displayName: String,
|
||||||
|
collisionNickname: String,
|
||||||
|
peerID: PeerID,
|
||||||
|
localNickname: String,
|
||||||
|
peers: [PeerID: BLEPeerInfo]
|
||||||
|
) -> String {
|
||||||
|
let hasCollision = peers.values.contains {
|
||||||
|
$0.isConnected && $0.nickname == collisionNickname && $0.peerID != peerID
|
||||||
|
} || localNickname == collisionNickname
|
||||||
|
|
||||||
|
guard hasCollision else { return displayName }
|
||||||
|
return displayName + "#" + String(peerID.id.prefix(4))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
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]
|
||||||
|
/// 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()
|
||||||
|
|
||||||
|
guard let senderNickname = BLEPeerSenderDisplayName.resolveKnownPeer(
|
||||||
|
peerID: peerID,
|
||||||
|
localPeerID: env.localPeerID(),
|
||||||
|
localNickname: env.localNickname(),
|
||||||
|
peers: peersSnapshot,
|
||||||
|
allowConnectedUnverified: false
|
||||||
|
) ?? env.signedSenderDisplayName(packet, peerID) else {
|
||||||
|
SecureLogger.warning("🚫 Dropping public message from unverified or 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEPublicMessageAcceptance: Equatable {
|
||||||
|
let shouldTrackForSync: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEPublicMessageRejection: Equatable {
|
||||||
|
case selfEcho
|
||||||
|
case staleBroadcast(ageSeconds: Double)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEPublicMessageDecision: Equatable {
|
||||||
|
case accept(BLEPublicMessageAcceptance)
|
||||||
|
case reject(BLEPublicMessageRejection)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEPublicMessagePolicy {
|
||||||
|
static func evaluate(
|
||||||
|
packet: BitchatPacket,
|
||||||
|
from peerID: PeerID,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
now: Date
|
||||||
|
) -> BLEPublicMessageDecision {
|
||||||
|
if peerID == localPeerID && packet.ttl != 0 {
|
||||||
|
return .reject(.selfEcho)
|
||||||
|
}
|
||||||
|
|
||||||
|
let isBroadcast = BLEPacketFreshnessPolicy.isBroadcastRecipient(packet.recipientID)
|
||||||
|
if isBroadcast,
|
||||||
|
BLEPacketFreshnessPolicy.isStale(timestampMilliseconds: packet.timestamp, now: now) {
|
||||||
|
return .reject(.staleBroadcast(ageSeconds: BLEPacketFreshnessPolicy.ageSeconds(
|
||||||
|
timestampMilliseconds: packet.timestamp,
|
||||||
|
now: now
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return .accept(BLEPublicMessageAcceptance(
|
||||||
|
shouldTrackForSync: isBroadcast && packet.type == MessageType.message.rawValue
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEReceivedPacketContext: Equatable {
|
||||||
|
let senderID: PeerID
|
||||||
|
let messageID: String
|
||||||
|
let messageType: MessageType?
|
||||||
|
let shouldDeduplicate: Bool
|
||||||
|
let logsHandlingDetails: Bool
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLEReceivePipeline {
|
||||||
|
static func context(for packet: BitchatPacket, localPeerID: PeerID) -> BLEReceivedPacketContext {
|
||||||
|
let senderID = PeerID(hexData: packet.senderID)
|
||||||
|
let messageID = "\(senderID)-\(packet.timestamp)-\(packet.type)"
|
||||||
|
let messageType = MessageType(rawValue: packet.type)
|
||||||
|
let allowSelfSyncReplay = packet.ttl == 0 && senderID == localPeerID
|
||||||
|
let shouldDeduplicate = messageType != .fragment && !allowSelfSyncReplay
|
||||||
|
|
||||||
|
return BLEReceivedPacketContext(
|
||||||
|
senderID: senderID,
|
||||||
|
messageID: messageID,
|
||||||
|
messageType: messageType,
|
||||||
|
shouldDeduplicate: shouldDeduplicate,
|
||||||
|
logsHandlingDetails: messageType != .announce
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static func shouldCancelScheduledRelayForDuplicate(connectedPeerCount: Int) -> Bool {
|
||||||
|
connectedPeerCount > 2
|
||||||
|
}
|
||||||
|
|
||||||
|
static func relayDecision(
|
||||||
|
for packet: BitchatPacket,
|
||||||
|
senderID: PeerID,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
degree: Int,
|
||||||
|
highDegreeThreshold: Int
|
||||||
|
) -> RelayDecision {
|
||||||
|
RelayController.decide(
|
||||||
|
ttl: packet.ttl,
|
||||||
|
senderIsSelf: senderID == localPeerID,
|
||||||
|
recipientIsSelf: PeerID(hexData: packet.recipientID) == localPeerID,
|
||||||
|
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
|
||||||
|
isDirectedEncrypted: packet.type == MessageType.noiseEncrypted.rawValue && packet.recipientID != nil,
|
||||||
|
isFragment: packet.type == MessageType.fragment.rawValue,
|
||||||
|
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
|
||||||
|
isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
|
||||||
|
isAnnounce: packet.type == MessageType.announce.rawValue,
|
||||||
|
degree: degree,
|
||||||
|
highDegreeThreshold: highDegreeThreshold
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLERecentTrafficTracker: Equatable {
|
||||||
|
private var packetTimestamps: [Date] = []
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
packetTimestamps.count
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
packetTimestamps.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func recordPacket(at now: Date) {
|
||||||
|
packetTimestamps.append(now)
|
||||||
|
prune(at: now)
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasTraffic(within seconds: TimeInterval, now: Date) -> Bool {
|
||||||
|
let cutoff = now.addingTimeInterval(-seconds)
|
||||||
|
return packetTimestamps.contains { $0 >= cutoff }
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func prune(at now: Date) {
|
||||||
|
let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds)
|
||||||
|
if packetTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount {
|
||||||
|
packetTimestamps.removeFirst(packetTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount)
|
||||||
|
}
|
||||||
|
packetTimestamps.removeAll { $0 < cutoff }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLERouteForwardingPlan {
|
||||||
|
let shouldSuppressFloodRelay: Bool
|
||||||
|
let forwardPacket: BitchatPacket?
|
||||||
|
let nextHop: PeerID?
|
||||||
|
|
||||||
|
static let allowFloodRelay = BLERouteForwardingPlan(
|
||||||
|
shouldSuppressFloodRelay: false,
|
||||||
|
forwardPacket: nil,
|
||||||
|
nextHop: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
static let suppressFloodRelay = BLERouteForwardingPlan(
|
||||||
|
shouldSuppressFloodRelay: true,
|
||||||
|
forwardPacket: nil,
|
||||||
|
nextHop: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
static func forward(_ packet: BitchatPacket, to nextHop: PeerID) -> BLERouteForwardingPlan {
|
||||||
|
BLERouteForwardingPlan(
|
||||||
|
shouldSuppressFloodRelay: true,
|
||||||
|
forwardPacket: packet,
|
||||||
|
nextHop: nextHop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLERouteForwardingPolicy {
|
||||||
|
static func plan(
|
||||||
|
for packet: BitchatPacket,
|
||||||
|
localPeerID: PeerID,
|
||||||
|
localRoutingData: Data?,
|
||||||
|
routingPeer: (Data) -> PeerID?,
|
||||||
|
isPeerConnected: (PeerID) -> Bool
|
||||||
|
) -> BLERouteForwardingPlan {
|
||||||
|
if PeerID(hexData: packet.recipientID) == localPeerID {
|
||||||
|
return .suppressFloodRelay
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let route = packet.route, !route.isEmpty else {
|
||||||
|
return .allowFloodRelay
|
||||||
|
}
|
||||||
|
|
||||||
|
guard packet.ttl > 1 else {
|
||||||
|
return .suppressFloodRelay
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let localRoutingData else {
|
||||||
|
return .allowFloodRelay
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let localIndex = route.firstIndex(of: localRoutingData) else {
|
||||||
|
return forward(packet, toRouteData: route[0], routingPeer: routingPeer, isPeerConnected: isPeerConnected)
|
||||||
|
}
|
||||||
|
|
||||||
|
if localIndex == route.count - 1 {
|
||||||
|
guard let destinationPeer = PeerID(hexData: packet.recipientID),
|
||||||
|
isPeerConnected(destinationPeer) else {
|
||||||
|
return .allowFloodRelay
|
||||||
|
}
|
||||||
|
return .forward(relayed(packet), to: destinationPeer)
|
||||||
|
}
|
||||||
|
|
||||||
|
return forward(
|
||||||
|
packet,
|
||||||
|
toRouteData: route[localIndex + 1],
|
||||||
|
routingPeer: routingPeer,
|
||||||
|
isPeerConnected: isPeerConnected
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func forward(
|
||||||
|
_ packet: BitchatPacket,
|
||||||
|
toRouteData routeData: Data,
|
||||||
|
routingPeer: (Data) -> PeerID?,
|
||||||
|
isPeerConnected: (PeerID) -> Bool
|
||||||
|
) -> BLERouteForwardingPlan {
|
||||||
|
guard let nextPeer = routingPeer(routeData),
|
||||||
|
isPeerConnected(nextPeer) else {
|
||||||
|
return .allowFloodRelay
|
||||||
|
}
|
||||||
|
|
||||||
|
return .forward(relayed(packet), to: nextPeer)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func relayed(_ packet: BitchatPacket) -> BitchatPacket {
|
||||||
|
var relayPacket = packet
|
||||||
|
relayPacket.ttl = packet.ttl - 1
|
||||||
|
return relayPacket
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLEScanDutyPlan: Equatable {
|
||||||
|
case continuous
|
||||||
|
case dutyCycle(onDuration: TimeInterval, offDuration: TimeInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum BLEScanDutyPolicy {
|
||||||
|
static func plan(
|
||||||
|
dutyEnabled: Bool,
|
||||||
|
appIsActive: Bool,
|
||||||
|
connectedCount: Int,
|
||||||
|
hasRecentTraffic: Bool,
|
||||||
|
highDegreeThreshold: Int = TransportConfig.bleHighDegreeThreshold
|
||||||
|
) -> BLEScanDutyPlan {
|
||||||
|
let forceContinuousScan = connectedCount <= 2 || hasRecentTraffic
|
||||||
|
let shouldDutyCycle = dutyEnabled && appIsActive && connectedCount > 0 && !forceContinuousScan
|
||||||
|
|
||||||
|
guard shouldDutyCycle else {
|
||||||
|
return .continuous
|
||||||
|
}
|
||||||
|
|
||||||
|
if connectedCount >= highDegreeThreshold {
|
||||||
|
return .dutyCycle(
|
||||||
|
onDuration: TransportConfig.bleDutyOnDurationDense,
|
||||||
|
offDuration: TransportConfig.bleDutyOffDurationDense
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return .dutyCycle(
|
||||||
|
onDuration: TransportConfig.bleDutyOnDuration,
|
||||||
|
offDuration: TransportConfig.bleDutyOffDuration
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLEScheduledRelayStore {
|
||||||
|
private var relays: [String: DispatchWorkItem] = [:]
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
relays.count
|
||||||
|
}
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
relays.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func schedule(_ workItem: DispatchWorkItem, messageID: String) {
|
||||||
|
relays[messageID] = workItem
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func remove(messageID: String) -> DispatchWorkItem? {
|
||||||
|
relays.removeValue(forKey: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@discardableResult
|
||||||
|
mutating func cancel(messageID: String) -> Bool {
|
||||||
|
guard let workItem = relays.removeValue(forKey: messageID) else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
workItem.cancel()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func cancelAll() {
|
||||||
|
relays.values.forEach { $0.cancel() }
|
||||||
|
relays.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAllIfOverCapacity(_ maxCount: Int) {
|
||||||
|
if relays.count > maxCount {
|
||||||
|
relays.removeAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct BLESelfBroadcastTracker {
|
||||||
|
private struct Entry {
|
||||||
|
let messageID: String
|
||||||
|
let sentAt: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
private var entriesByDedupID: [String: Entry] = [:]
|
||||||
|
|
||||||
|
var isEmpty: Bool {
|
||||||
|
entriesByDedupID.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var count: Int {
|
||||||
|
entriesByDedupID.count
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func record(messageID: String, packet: BitchatPacket, sentAt: Date) {
|
||||||
|
entriesByDedupID[Self.dedupID(for: packet)] = Entry(messageID: messageID, sentAt: sentAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func takeMessageID(for packet: BitchatPacket) -> String? {
|
||||||
|
entriesByDedupID.removeValue(forKey: Self.dedupID(for: packet))?.messageID
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func prune(before cutoff: Date) {
|
||||||
|
guard !entriesByDedupID.isEmpty else { return }
|
||||||
|
entriesByDedupID = entriesByDedupID.filter { cutoff <= $0.value.sentAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
entriesByDedupID.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
static func dedupID(for packet: BitchatPacket) -> String {
|
||||||
|
"\(packet.senderID.hexEncodedString())-\(packet.timestamp)-\(packet.type)"
|
||||||
|
}
|
||||||
|
}
|
||||||
+1243
-2351
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,71 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum BLESubscriptionAnnounceDecision: Equatable {
|
||||||
|
case allowed
|
||||||
|
case rateLimited(backoffSeconds: TimeInterval, attemptCount: Int, suppressAnnounce: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BLESubscriptionAnnounceLimiter {
|
||||||
|
private struct State {
|
||||||
|
var lastAnnounceTime: Date
|
||||||
|
var attemptCount: Int
|
||||||
|
var currentBackoffSeconds: TimeInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
private var states: [String: State] = [:]
|
||||||
|
|
||||||
|
var trackedCentralCount: Int {
|
||||||
|
states.count
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() {
|
||||||
|
states.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func decision(for centralID: String, now: Date) -> BLESubscriptionAnnounceDecision {
|
||||||
|
pruneStaleEntries(now: now)
|
||||||
|
|
||||||
|
guard let existing = states[centralID] else {
|
||||||
|
recordAllowedAttempt(for: centralID, now: now)
|
||||||
|
return .allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeSinceLastAnnounce = now.timeIntervalSince(existing.lastAnnounceTime)
|
||||||
|
guard timeSinceLastAnnounce < existing.currentBackoffSeconds else {
|
||||||
|
recordAllowedAttempt(for: centralID, now: now)
|
||||||
|
return .allowed
|
||||||
|
}
|
||||||
|
|
||||||
|
let newAttemptCount = existing.attemptCount + 1
|
||||||
|
let newBackoff = min(
|
||||||
|
existing.currentBackoffSeconds * TransportConfig.bleSubscriptionRateLimitBackoffFactor,
|
||||||
|
TransportConfig.bleSubscriptionRateLimitMaxBackoffSeconds
|
||||||
|
)
|
||||||
|
states[centralID] = State(
|
||||||
|
lastAnnounceTime: now,
|
||||||
|
attemptCount: newAttemptCount,
|
||||||
|
currentBackoffSeconds: newBackoff
|
||||||
|
)
|
||||||
|
|
||||||
|
return .rateLimited(
|
||||||
|
backoffSeconds: existing.currentBackoffSeconds,
|
||||||
|
attemptCount: existing.attemptCount,
|
||||||
|
suppressAnnounce: newAttemptCount >= TransportConfig.bleSubscriptionRateLimitMaxAttempts
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func recordAllowedAttempt(for centralID: String, now: Date) {
|
||||||
|
states[centralID] = State(
|
||||||
|
lastAnnounceTime: now,
|
||||||
|
attemptCount: 1,
|
||||||
|
currentBackoffSeconds: TransportConfig.bleSubscriptionRateLimitMinSeconds
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private mutating func pruneStaleEntries(now: Date) {
|
||||||
|
let windowSeconds = TransportConfig.bleSubscriptionRateLimitWindowSeconds
|
||||||
|
states = states.filter { _, state in
|
||||||
|
now.timeIntervalSince(state.lastAnnounceTime) < windowSeconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,9 +28,9 @@ struct CommandGeoParticipant {
|
|||||||
protocol CommandContextProvider: AnyObject {
|
protocol CommandContextProvider: AnyObject {
|
||||||
// MARK: - State Properties
|
// MARK: - State Properties
|
||||||
var nickname: String { get }
|
var nickname: String { get }
|
||||||
|
var activeChannel: ChannelID { get }
|
||||||
var selectedPrivateChatPeer: PeerID? { get }
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
var blockedUsers: Set<String> { get }
|
var blockedUsers: Set<String> { get }
|
||||||
var privateChats: [PeerID: [BitchatMessage]] { get set }
|
|
||||||
var idBridge: NostrIdentityBridge { get }
|
var idBridge: NostrIdentityBridge { get }
|
||||||
|
|
||||||
// MARK: - Peer Lookup
|
// MARK: - Peer Lookup
|
||||||
@@ -42,6 +42,8 @@ protocol CommandContextProvider: AnyObject {
|
|||||||
func startPrivateChat(with peerID: PeerID)
|
func startPrivateChat(with peerID: PeerID)
|
||||||
func sendPrivateMessage(_ content: String, to peerID: PeerID)
|
func sendPrivateMessage(_ content: String, to peerID: PeerID)
|
||||||
func clearCurrentPublicTimeline()
|
func clearCurrentPublicTimeline()
|
||||||
|
/// Empties the peer's chat (single-writer store intent for `/clear`).
|
||||||
|
func clearPrivateChat(_ peerID: PeerID)
|
||||||
func sendPublicRaw(_ content: String)
|
func sendPublicRaw(_ content: String)
|
||||||
|
|
||||||
// MARK: - System Messages
|
// MARK: - System Messages
|
||||||
@@ -75,7 +77,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
// Geohash context: disable favoriting in public geohash or GeoDM
|
// Geohash context: disable favoriting in public geohash or GeoDM
|
||||||
let inGeoPublic: Bool = {
|
let inGeoPublic: Bool = {
|
||||||
switch LocationChannelManager.shared.selectedChannel {
|
switch contextProvider?.activeChannel ?? .mesh {
|
||||||
case .mesh: return false
|
case .mesh: return false
|
||||||
case .location: return true
|
case .location: return true
|
||||||
}
|
}
|
||||||
@@ -135,7 +137,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
private func handleWho() -> CommandResult {
|
private func handleWho() -> CommandResult {
|
||||||
// Show geohash participants when in a geohash channel; otherwise mesh peers
|
// Show geohash participants when in a geohash channel; otherwise mesh peers
|
||||||
switch LocationChannelManager.shared.selectedChannel {
|
switch contextProvider?.activeChannel ?? .mesh {
|
||||||
case .location(let ch):
|
case .location(let ch):
|
||||||
// Geohash context: show visible geohash participants (exclude self)
|
// Geohash context: show visible geohash participants (exclude self)
|
||||||
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
guard let vm = contextProvider else { return .success(message: "nobody around") }
|
||||||
@@ -159,7 +161,7 @@ final class CommandProcessor {
|
|||||||
|
|
||||||
private func handleClear() -> CommandResult {
|
private func handleClear() -> CommandResult {
|
||||||
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
if let peerID = contextProvider?.selectedPrivateChatPeer {
|
||||||
contextProvider?.privateChats[peerID]?.removeAll()
|
contextProvider?.clearPrivateChat(peerID)
|
||||||
} else {
|
} else {
|
||||||
contextProvider?.clearCurrentPublicTimeline()
|
contextProvider?.clearCurrentPublicTimeline()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,23 @@ final class FavoritesPersistenceService: ObservableObject {
|
|||||||
|
|
||||||
static let shared = FavoritesPersistenceService()
|
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
|
self.keychain = keychain
|
||||||
loadFavorites()
|
loadFavorites()
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import BitLogger
|
import BitLogger
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
/// Dependencies for location notes, allowing tests to stub relay/identity behavior.
|
||||||
@@ -14,6 +15,8 @@ struct LocationNotesDependencies {
|
|||||||
var sendEvent: SendEvent
|
var sendEvent: SendEvent
|
||||||
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
var deriveIdentity: (_ geohash: String) throws -> NostrIdentity
|
||||||
var now: () -> Date
|
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()
|
private static let idBridge = NostrIdentityBridge()
|
||||||
|
|
||||||
@@ -39,7 +42,11 @@ struct LocationNotesDependencies {
|
|||||||
deriveIdentity: { geohash in
|
deriveIdentity: { geohash in
|
||||||
try idBridge.deriveIdentity(forGeohash: geohash)
|
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?
|
@Published private(set) var errorMessage: String?
|
||||||
private var subscriptionID: String?
|
private var subscriptionID: String?
|
||||||
private var noteIDs = Set<String>() // O(1) duplicate detection
|
private var noteIDs = Set<String>() // O(1) duplicate detection
|
||||||
|
private var directoryUpdateCancellable: AnyCancellable?
|
||||||
private let dependencies: LocationNotesDependencies
|
private let dependencies: LocationNotesDependencies
|
||||||
private let maxNotesInMemory = 500 // Defensive cap (relay limit is 200)
|
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)
|
SecureLogger.warning("LocationNotesManager: invalid geohash '\(norm)' (expected 8 valid base32 chars)", category: .session)
|
||||||
}
|
}
|
||||||
subscribe()
|
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) {
|
func setGeohash(_ newGeohash: String) {
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ import Foundation
|
|||||||
@MainActor
|
@MainActor
|
||||||
final class MessageRouter {
|
final class MessageRouter {
|
||||||
private let transports: [Transport]
|
private let transports: [Transport]
|
||||||
|
private let now: () -> Date
|
||||||
|
|
||||||
|
/// Invoked whenever a retained private message is dropped without a
|
||||||
|
/// delivery ack (attempt cap, TTL expiry, or per-peer overflow eviction)
|
||||||
|
/// so the UI can surface the failure instead of leaving the message in a
|
||||||
|
/// stale "sending/sent" state forever.
|
||||||
|
var onMessageDropped: ((_ messageID: String, _ peerID: PeerID) -> Void)?
|
||||||
|
|
||||||
// Outbox entry with timestamp for TTL-based eviction
|
// Outbox entry with timestamp for TTL-based eviction
|
||||||
private struct QueuedMessage {
|
private struct QueuedMessage {
|
||||||
@@ -13,6 +20,7 @@ final class MessageRouter {
|
|||||||
let nickname: String
|
let nickname: String
|
||||||
let messageID: String
|
let messageID: String
|
||||||
let timestamp: Date
|
let timestamp: Date
|
||||||
|
var sendAttempts: Int = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
private var outbox: [PeerID: [QueuedMessage]] = [:]
|
||||||
@@ -20,9 +28,13 @@ final class MessageRouter {
|
|||||||
// Outbox limits to prevent unbounded memory growth
|
// Outbox limits to prevent unbounded memory growth
|
||||||
private static let maxMessagesPerPeer = 100
|
private static let maxMessagesPerPeer = 100
|
||||||
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
|
||||||
|
// Bound resends of messages sent on a weak reachability signal that never
|
||||||
|
// get a delivery ack (e.g. peer on an old client that doesn't ack).
|
||||||
|
private static let maxSendAttempts = 8
|
||||||
|
|
||||||
init(transports: [Transport]) {
|
init(transports: [Transport], now: @escaping () -> Date = Date.init) {
|
||||||
self.transports = transports
|
self.transports = transports
|
||||||
|
self.now = now
|
||||||
|
|
||||||
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
// Observe favorites changes to learn Nostr mapping and flush queued messages
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
@@ -61,26 +73,54 @@ final class MessageRouter {
|
|||||||
// MARK: - Message Sending
|
// MARK: - Message Sending
|
||||||
|
|
||||||
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
if let transport = reachableTransport(for: peerID) {
|
if let transport = connectedTransport(for: peerID) {
|
||||||
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
// 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)
|
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: now(), sendAttempts: 1)
|
||||||
|
if let transport = reachableTransport(for: peerID) {
|
||||||
|
// Reachability without a connection is a freshness heuristic (e.g.
|
||||||
|
// the mesh retention window), so the send can silently go nowhere.
|
||||||
|
// Send now, but retain a copy until a delivery/read ack clears it;
|
||||||
|
// receivers dedup resends by message ID.
|
||||||
|
SecureLogger.debug("Routing PM via \(type(of: transport)) (reachable) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))…", category: .session)
|
||||||
|
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
|
enqueue(message, for: peerID)
|
||||||
} else {
|
} else {
|
||||||
// Queue for later with timestamp for TTL tracking
|
var unsent = message
|
||||||
if outbox[peerID] == nil { outbox[peerID] = [] }
|
unsent.sendAttempts = 0
|
||||||
|
enqueue(unsent, for: 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)
|
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A delivery or read ack confirms receipt; stop retaining the message.
|
||||||
|
func markDelivered(_ messageID: String) {
|
||||||
|
for (peerID, queue) in outbox {
|
||||||
|
let filtered = queue.filter { $0.messageID != messageID }
|
||||||
|
guard filtered.count != queue.count else { continue }
|
||||||
|
outbox[peerID] = filtered.isEmpty ? nil : filtered
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func enqueue(_ message: QueuedMessage, for peerID: PeerID) {
|
||||||
|
var queue = outbox[peerID] ?? []
|
||||||
|
// Re-sending an already-queued ID replaces the entry (keeps attempt count fresh)
|
||||||
|
queue.removeAll { $0.messageID == message.messageID }
|
||||||
|
queue.append(message)
|
||||||
|
|
||||||
|
// Enforce per-peer size limit with FIFO eviction
|
||||||
|
if queue.count > Self.maxMessagesPerPeer {
|
||||||
|
let evicted = queue.removeFirst()
|
||||||
|
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted.messageID.prefix(8))…", category: .session)
|
||||||
|
onMessageDropped?(evicted.messageID, peerID)
|
||||||
|
}
|
||||||
|
outbox[peerID] = queue
|
||||||
|
}
|
||||||
|
|
||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
if let transport = reachableTransport(for: peerID) {
|
if let transport = reachableTransport(for: peerID) {
|
||||||
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))…", category: .session)
|
||||||
@@ -111,19 +151,34 @@ final class MessageRouter {
|
|||||||
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
guard let queued = outbox[peerID], !queued.isEmpty else { return }
|
||||||
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
|
||||||
|
|
||||||
let now = Date()
|
let now = now()
|
||||||
var remaining: [QueuedMessage] = []
|
var remaining: [QueuedMessage] = []
|
||||||
|
|
||||||
for message in queued {
|
for message in queued {
|
||||||
// Skip expired messages (TTL exceeded)
|
// Skip expired messages (TTL exceeded)
|
||||||
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
|
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)
|
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
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if let transport = reachableTransport(for: peerID) {
|
if let transport = connectedTransport(for: peerID) {
|
||||||
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))…", category: .session)
|
// 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)
|
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 {
|
} else {
|
||||||
remaining.append(message)
|
remaining.append(message)
|
||||||
}
|
}
|
||||||
@@ -142,12 +197,21 @@ final class MessageRouter {
|
|||||||
|
|
||||||
/// Periodically clean up expired messages from all outboxes
|
/// Periodically clean up expired messages from all outboxes
|
||||||
func cleanupExpiredMessages() {
|
func cleanupExpiredMessages() {
|
||||||
let now = Date()
|
let now = now()
|
||||||
for peerID in Array(outbox.keys) {
|
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 {
|
if outbox[peerID]?.isEmpty == true {
|
||||||
outbox.removeValue(forKey: peerID)
|
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 permissionProvider: () -> LocationChannelManager.PermissionState
|
||||||
private let mutualFavoritesProvider: () -> Set<Data>
|
private let mutualFavoritesProvider: () -> Set<Data>
|
||||||
private let torController: NetworkActivationTorControlling
|
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 proxyController: NetworkActivationProxyControlling
|
||||||
private let notificationCenter: NotificationCenter
|
private let notificationCenter: NotificationCenter
|
||||||
|
|
||||||
@@ -55,7 +59,7 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
permissionProvider = { LocationChannelManager.shared.permissionState }
|
permissionProvider = { LocationChannelManager.shared.permissionState }
|
||||||
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
mutualFavoritesProvider = { FavoritesPersistenceService.shared.mutualFavorites }
|
||||||
torController = TorManager.shared
|
torController = TorManager.shared
|
||||||
relayController = NostrRelayManager.shared
|
relayControllerProvider = { NostrRelayManager.shared }
|
||||||
proxyController = TorURLSession.shared
|
proxyController = TorURLSession.shared
|
||||||
notificationCenter = .default
|
notificationCenter = .default
|
||||||
}
|
}
|
||||||
@@ -77,7 +81,7 @@ final class NetworkActivationService: ObservableObject {
|
|||||||
self.permissionProvider = permissionProvider
|
self.permissionProvider = permissionProvider
|
||||||
self.mutualFavoritesProvider = mutualFavoritesProvider
|
self.mutualFavoritesProvider = mutualFavoritesProvider
|
||||||
self.torController = torController
|
self.torController = torController
|
||||||
self.relayController = relayController
|
self.relayControllerProvider = { relayController }
|
||||||
self.proxyController = proxyController
|
self.proxyController = proxyController
|
||||||
self.notificationCenter = notificationCenter
|
self.notificationCenter = notificationCenter
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
// MARK: - Transport Protocol Conformance
|
// MARK: - Transport Protocol Conformance
|
||||||
|
|
||||||
weak var delegate: BitchatDelegate?
|
weak var delegate: BitchatDelegate?
|
||||||
|
weak var eventDelegate: TransportEventDelegate?
|
||||||
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
weak var peerEventsDelegate: TransportPeerEventsDelegate?
|
||||||
|
|
||||||
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> {
|
||||||
@@ -142,16 +143,8 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState { .none }
|
||||||
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
func triggerHandshake(with peerID: PeerID) { /* no-op */ }
|
||||||
|
|
||||||
// Nostr does not use Noise sessions here; return a cached placeholder to avoid reallocation
|
// Nostr does not use Noise sessions here; the inert Transport defaults
|
||||||
private static var cachedNoiseService: NoiseEncryptionService?
|
// for the noise* identity hooks apply.
|
||||||
func getNoiseService() -> NoiseEncryptionService {
|
|
||||||
if let noiseService = Self.cachedNoiseService {
|
|
||||||
return noiseService
|
|
||||||
}
|
|
||||||
let noiseService = NoiseEncryptionService(keychain: keychain)
|
|
||||||
Self.cachedNoiseService = noiseService
|
|
||||||
return noiseService
|
|
||||||
}
|
|
||||||
|
|
||||||
// Public broadcast not supported over Nostr here
|
// Public broadcast not supported over Nostr here
|
||||||
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
func sendMessage(_ content: String, mentions: [String]) { /* no-op */ }
|
||||||
@@ -173,9 +166,9 @@ final class NostrTransport: Transport, @unchecked Sendable {
|
|||||||
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
// Enqueue and process with throttling to avoid relay rate limits
|
// Enqueue and process with throttling to avoid relay rate limits
|
||||||
// Use barrier to synchronize access to readQueue
|
// Use barrier to synchronize access to readQueue
|
||||||
queue.async(flags: .barrier) { [weak self] in
|
queue.async(flags: .barrier) {
|
||||||
self?.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
self.readQueue.append(QueuedRead(receipt: receipt, peerID: peerID))
|
||||||
self?.processReadQueueIfNeeded()
|
self.processReadQueueIfNeeded()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,19 @@ struct NotificationStreamAssembler {
|
|||||||
pendingFrameExpectedLength = 0
|
pendingFrameExpectedLength = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private mutating func discardLeadingPaddingIfPresent() -> Bool {
|
||||||
|
guard let first = buffer.first else { return false }
|
||||||
|
guard first != 1 && first != 2 else { return false }
|
||||||
|
let paddingLength = Int(first)
|
||||||
|
guard paddingLength > 0, paddingLength <= buffer.count else { return false }
|
||||||
|
guard buffer.prefix(paddingLength).allSatisfy({ $0 == first }) else { return false }
|
||||||
|
|
||||||
|
buffer.removeFirst(paddingLength)
|
||||||
|
pendingFrameStartedAt = nil
|
||||||
|
pendingFrameExpectedLength = 0
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
mutating func append(_ chunk: Data) -> (frames: [Data], droppedPrefixes: [UInt8], reset: Bool) {
|
||||||
guard !chunk.isEmpty else { return ([], [], false) }
|
guard !chunk.isEmpty else { return ([], [], false) }
|
||||||
|
|
||||||
@@ -42,6 +55,9 @@ struct NotificationStreamAssembler {
|
|||||||
while buffer.count >= minimumFramePrefix {
|
while buffer.count >= minimumFramePrefix {
|
||||||
guard let version = buffer.first else { break }
|
guard let version = buffer.first else { break }
|
||||||
guard version == 1 || version == 2 else {
|
guard version == 1 || version == 2 else {
|
||||||
|
if discardLeadingPaddingIfPresent() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
dropped.append(buffer.removeFirst())
|
dropped.append(buffer.removeFirst())
|
||||||
pendingFrameStartedAt = nil
|
pendingFrameStartedAt = nil
|
||||||
pendingFrameExpectedLength = 0
|
pendingFrameExpectedLength = 0
|
||||||
@@ -133,6 +149,11 @@ struct NotificationStreamAssembler {
|
|||||||
let frame = Data(buffer.prefix(frameLength))
|
let frame = Data(buffer.prefix(frameLength))
|
||||||
frames.append(frame)
|
frames.append(frame)
|
||||||
buffer.removeFirst(frameLength)
|
buffer.removeFirst(frameLength)
|
||||||
|
_ = discardLeadingPaddingIfPresent()
|
||||||
|
}
|
||||||
|
|
||||||
|
if discardLeadingPaddingIfPresent() {
|
||||||
|
return (frames, dropped, didReset)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
if !buffer.isEmpty, buffer.allSatisfy({ $0 == 0 }) {
|
||||||
|
|||||||
@@ -8,14 +8,25 @@
|
|||||||
|
|
||||||
import BitLogger
|
import BitLogger
|
||||||
import BitFoundation
|
import BitFoundation
|
||||||
|
import Combine
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftUI
|
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 {
|
final class PrivateChatManager: ObservableObject {
|
||||||
@Published var privateChats: [PeerID: [BitchatMessage]] = [:]
|
/// Read-only mirror of `ConversationStore.selectedPrivatePeerID` — the
|
||||||
@Published var selectedPeer: PeerID? = nil
|
/// store is the sole owner of conversation selection. Kept `@Published`
|
||||||
@Published var unreadMessages: Set<PeerID> = []
|
/// 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
|
private var selectedPeerFingerprint: String? = nil
|
||||||
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
|
||||||
@@ -25,13 +36,51 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
// Peer service for looking up peer info during consolidation
|
// Peer service for looking up peer info during consolidation
|
||||||
weak var unifiedPeerService: UnifiedPeerService?
|
weak var unifiedPeerService: UnifiedPeerService?
|
||||||
|
/// Single source of truth for message and selection state; injected by
|
||||||
init(meshService: Transport? = nil) {
|
/// the bootstrapper (`wireServiceGraph`).
|
||||||
self.meshService = meshService
|
var conversationStore: ConversationStore? {
|
||||||
|
didSet { bindSelectionMirror() }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cap for messages stored per private chat
|
init(meshService: Transport? = nil, conversationStore: ConversationStore? = nil) {
|
||||||
private let privateChatCap = TransportConfig.privateChatCap
|
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
|
// MARK: - Message Consolidation
|
||||||
|
|
||||||
@@ -44,57 +93,51 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
/// - Returns: True if any unread messages were found during consolidation
|
/// - Returns: True if any unread messages were found during consolidation
|
||||||
@MainActor
|
@MainActor
|
||||||
func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<String>) -> Bool {
|
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
|
var hasUnreadMessages = false
|
||||||
|
|
||||||
// 1. Consolidate from stable Noise key (64-char hex)
|
// 1. Consolidate from stable Noise key (64-char hex)
|
||||||
if let peer = unifiedPeerService?.getPeer(by: peerID) {
|
if let peer = unifiedPeerService?.getPeer(by: peerID) {
|
||||||
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||||||
|
let nostrMessages = messages(for: noiseKeyHex)
|
||||||
|
|
||||||
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
|
if noiseKeyHex != peerID, !nostrMessages.isEmpty {
|
||||||
if privateChats[peerID] == nil {
|
|
||||||
privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
|
||||||
for message in nostrMessages {
|
for message in nostrMessages {
|
||||||
if !existingMessageIds.contains(message.id) {
|
// Update senderPeerID for correct read receipts
|
||||||
// Update senderPeerID for correct read receipts
|
let updatedMessage = BitchatMessage(
|
||||||
let updatedMessage = BitchatMessage(
|
id: message.id,
|
||||||
id: message.id,
|
sender: message.sender,
|
||||||
sender: message.sender,
|
content: message.content,
|
||||||
content: message.content,
|
timestamp: message.timestamp,
|
||||||
timestamp: message.timestamp,
|
isRelay: message.isRelay,
|
||||||
isRelay: message.isRelay,
|
originalSender: message.originalSender,
|
||||||
originalSender: message.originalSender,
|
isPrivate: message.isPrivate,
|
||||||
isPrivate: message.isPrivate,
|
recipientNickname: message.recipientNickname,
|
||||||
recipientNickname: message.recipientNickname,
|
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
|
||||||
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
|
mentions: message.mentions,
|
||||||
mentions: message.mentions,
|
deliveryStatus: message.deliveryStatus
|
||||||
deliveryStatus: message.deliveryStatus
|
)
|
||||||
)
|
// Store append dedups by message ID (skips ones the
|
||||||
privateChats[peerID]?.append(updatedMessage)
|
// 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)
|
// Check for recent unread messages (< 60s, not sent by us, not already read)
|
||||||
// Use persistedReadReceipts to correctly identify already-read messages after app restart
|
// Use persistedReadReceipts to correctly identify already-read messages after app restart
|
||||||
if message.senderPeerID != meshService.myPeerID {
|
if message.senderPeerID != meshService.myPeerID {
|
||||||
let messageAge = Date().timeIntervalSince(message.timestamp)
|
let messageAge = Date().timeIntervalSince(message.timestamp)
|
||||||
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
|
if messageAge < 60 && !persistedReadReceipts.contains(message.id) {
|
||||||
hasUnreadMessages = true
|
hasUnreadMessages = true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
||||||
|
|
||||||
if hasUnreadMessages {
|
if hasUnreadMessages {
|
||||||
unreadMessages.insert(peerID)
|
store.markUnread(.directPeer(peerID))
|
||||||
} else if unreadMessages.contains(noiseKeyHex) {
|
} else {
|
||||||
unreadMessages.remove(noiseKeyHex)
|
store.markRead(.directPeer(noiseKeyHex))
|
||||||
}
|
}
|
||||||
|
|
||||||
privateChats.removeValue(forKey: noiseKeyHex)
|
store.removeConversation(.directPeer(noiseKeyHex))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,52 +155,43 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if !tempPeerIDsToConsolidate.isEmpty {
|
if !tempPeerIDsToConsolidate.isEmpty {
|
||||||
if privateChats[peerID] == nil {
|
|
||||||
privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
|
||||||
var consolidatedCount = 0
|
var consolidatedCount = 0
|
||||||
var hadUnreadTemp = false
|
var hadUnreadTemp = false
|
||||||
|
let unreadPeerIDs = unreadMessages
|
||||||
|
|
||||||
for tempPeerID in tempPeerIDsToConsolidate {
|
for tempPeerID in tempPeerIDsToConsolidate {
|
||||||
if unreadMessages.contains(tempPeerID) {
|
if unreadPeerIDs.contains(tempPeerID) {
|
||||||
hadUnreadTemp = true
|
hadUnreadTemp = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if let tempMessages = privateChats[tempPeerID] {
|
for message in messages(for: tempPeerID) {
|
||||||
for message in tempMessages {
|
let updatedMessage = BitchatMessage(
|
||||||
if !existingMessageIds.contains(message.id) {
|
id: message.id,
|
||||||
let updatedMessage = BitchatMessage(
|
sender: message.sender,
|
||||||
id: message.id,
|
content: message.content,
|
||||||
sender: message.sender,
|
timestamp: message.timestamp,
|
||||||
content: message.content,
|
isRelay: message.isRelay,
|
||||||
timestamp: message.timestamp,
|
originalSender: message.originalSender,
|
||||||
isRelay: message.isRelay,
|
isPrivate: message.isPrivate,
|
||||||
originalSender: message.originalSender,
|
recipientNickname: message.recipientNickname,
|
||||||
isPrivate: message.isPrivate,
|
senderPeerID: peerID,
|
||||||
recipientNickname: message.recipientNickname,
|
mentions: message.mentions,
|
||||||
senderPeerID: peerID,
|
deliveryStatus: message.deliveryStatus
|
||||||
mentions: message.mentions,
|
)
|
||||||
deliveryStatus: message.deliveryStatus
|
if store.append(updatedMessage, to: .directPeer(peerID)) {
|
||||||
)
|
consolidatedCount += 1
|
||||||
privateChats[peerID]?.append(updatedMessage)
|
|
||||||
consolidatedCount += 1
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
privateChats.removeValue(forKey: tempPeerID)
|
|
||||||
unreadMessages.remove(tempPeerID)
|
|
||||||
}
|
}
|
||||||
|
store.removeConversation(.directPeer(tempPeerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
if hadUnreadTemp {
|
if hadUnreadTemp {
|
||||||
unreadMessages.insert(peerID)
|
store.markUnread(.directPeer(peerID))
|
||||||
hasUnreadMessages = true
|
hasUnreadMessages = true
|
||||||
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
|
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
if consolidatedCount > 0 {
|
if consolidatedCount > 0 {
|
||||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
||||||
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
|
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,9 +202,7 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
/// Syncs the read receipt tracking between manager and view model for sent messages
|
/// Syncs the read receipt tracking between manager and view model for sent messages
|
||||||
@MainActor
|
@MainActor
|
||||||
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
|
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
|
||||||
guard let messages = privateChats[peerID] else { return }
|
for message in messages(for: peerID) {
|
||||||
|
|
||||||
for message in messages {
|
|
||||||
if message.sender == nickname {
|
if message.sender == nickname {
|
||||||
if let status = message.deliveryStatus {
|
if let status = message.deliveryStatus {
|
||||||
switch status {
|
switch status {
|
||||||
@@ -185,9 +217,14 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start a private chat with a peer
|
/// Start a private chat with a peer. Selection is mutated through the
|
||||||
|
/// store's intent (the store owns it); the manager keeps its side
|
||||||
|
/// effects (fingerprint tracking, read receipts, unread clearing).
|
||||||
|
@MainActor
|
||||||
func startChat(with peerID: PeerID) {
|
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
|
// Store fingerprint for persistence across reconnections
|
||||||
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
if let fingerprint = meshService?.getFingerprint(for: peerID) {
|
||||||
@@ -196,53 +233,30 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
|
|
||||||
// Mark messages as read
|
// Mark messages as read
|
||||||
markAsRead(from: peerID)
|
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() {
|
func endChat() {
|
||||||
selectedPeer = nil
|
conversationStore?.setSelectedPrivatePeer(nil)
|
||||||
selectedPeerFingerprint = nil
|
selectedPeerFingerprint = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Remove duplicate messages by ID and keep chronological order
|
/// No-op since the `ConversationStore` cutover: the store maintains
|
||||||
func sanitizeChat(for peerID: PeerID) {
|
/// chronological order and dedups by message ID on every insert, so the
|
||||||
guard let arr = privateChats[peerID] else { return }
|
/// per-append re-sort/dedup sweep this performed is no longer needed.
|
||||||
if arr.count <= 1 {
|
/// Kept only for API compatibility until step 5 removes the callers.
|
||||||
return
|
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
|
/// Mark messages from a peer as read
|
||||||
|
@MainActor
|
||||||
func markAsRead(from peerID: PeerID) {
|
func markAsRead(from peerID: PeerID) {
|
||||||
unreadMessages.remove(peerID)
|
conversationStore?.markRead(.directPeer(peerID))
|
||||||
|
|
||||||
// Send read receipts for unread messages that haven't been sent yet
|
// Send read receipts for unread messages that haven't been sent yet
|
||||||
if let messages = privateChats[peerID] {
|
for message in messages(for: peerID) {
|
||||||
for message in messages {
|
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
||||||
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
|
sendReadReceipt(for: message)
|
||||||
sendReadReceipt(for: message)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct RelayDecision {
|
|||||||
struct RelayController {
|
struct RelayController {
|
||||||
static func decide(ttl: UInt8,
|
static func decide(ttl: UInt8,
|
||||||
senderIsSelf: Bool,
|
senderIsSelf: Bool,
|
||||||
|
recipientIsSelf: Bool = false,
|
||||||
isEncrypted: Bool,
|
isEncrypted: Bool,
|
||||||
isDirectedEncrypted: Bool,
|
isDirectedEncrypted: Bool,
|
||||||
isFragment: Bool,
|
isFragment: Bool,
|
||||||
@@ -22,7 +23,7 @@ struct RelayController {
|
|||||||
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
let ttlCap = min(ttl, TransportConfig.messageTTLDefault)
|
||||||
|
|
||||||
// Suppress obvious non-relays
|
// Suppress obvious non-relays
|
||||||
if ttlCap <= 1 || senderIsSelf {
|
if ttlCap <= 1 || senderIsSelf || recipientIsSelf {
|
||||||
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
return RelayDecision(shouldRelay: false, newTTL: ttlCap, delayMs: 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +39,12 @@ struct RelayController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if isFragment {
|
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 {
|
guard ttlLimit > 1 else {
|
||||||
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
|
return RelayDecision(shouldRelay: false, newTTL: ttlLimit, delayMs: 0)
|
||||||
}
|
}
|
||||||
@@ -49,11 +55,16 @@ struct RelayController {
|
|||||||
|
|
||||||
// TTL clamping for broadcast
|
// TTL clamping for broadcast
|
||||||
// - Dense graphs: keep lower but still allow multi-hop bridging
|
// - 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
|
// - Announces get a bit more headroom
|
||||||
let ttlLimit: UInt8 = {
|
let ttlLimit: UInt8 = {
|
||||||
if degree >= highDegreeThreshold {
|
if degree >= highDegreeThreshold {
|
||||||
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
return max(UInt8(2), min(ttlCap, UInt8(5)))
|
||||||
}
|
}
|
||||||
|
if degree <= 2 {
|
||||||
|
return ttlCap
|
||||||
|
}
|
||||||
let preferred = UInt8(isAnnounce ? 7 : 6)
|
let preferred = UInt8(isAnnounce ? 7 : 6)
|
||||||
return max(UInt8(2), min(ttlCap, preferred))
|
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
|
||||||
|
}()
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import BitFoundation
|
import BitFoundation
|
||||||
import Foundation
|
import Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
import CoreBluetooth
|
||||||
|
|
||||||
/// Abstract transport interface used by ChatViewModel and services.
|
/// Abstract transport interface used by ChatViewModel and services.
|
||||||
/// BLEService implements this protocol; a future Nostr transport can too.
|
/// BLEService implements this protocol; a future Nostr transport can too.
|
||||||
@@ -12,9 +13,27 @@ struct TransportPeerSnapshot: Equatable, Hashable {
|
|||||||
let lastSeen: Date
|
let lastSeen: Date
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum TransportEvent: @unchecked Sendable {
|
||||||
|
case messageReceived(BitchatMessage)
|
||||||
|
case publicMessageReceived(peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?)
|
||||||
|
case noisePayloadReceived(peerID: PeerID, type: NoisePayloadType, payload: Data, timestamp: Date)
|
||||||
|
case peerConnected(PeerID)
|
||||||
|
case peerDisconnected(PeerID)
|
||||||
|
case peerListUpdated([PeerID])
|
||||||
|
case peerSnapshotsUpdated([TransportPeerSnapshot])
|
||||||
|
case messageDeliveryStatusUpdated(messageID: String, status: DeliveryStatus)
|
||||||
|
case bluetoothStateUpdated(CBManagerState)
|
||||||
|
}
|
||||||
|
|
||||||
|
protocol TransportEventDelegate: AnyObject {
|
||||||
|
@MainActor func didReceiveTransportEvent(_ event: TransportEvent)
|
||||||
|
}
|
||||||
|
|
||||||
protocol Transport: AnyObject {
|
protocol Transport: AnyObject {
|
||||||
// Event sink
|
// Event sink
|
||||||
var delegate: BitchatDelegate? { get set }
|
var delegate: BitchatDelegate? { get set }
|
||||||
|
// Typed event sink for transport-domain events. Prefer this over BitchatDelegate for new code.
|
||||||
|
var eventDelegate: TransportEventDelegate? { get set }
|
||||||
// Peer events (preferred over publishers for UI)
|
// Peer events (preferred over publishers for UI)
|
||||||
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
var peerEventsDelegate: TransportPeerEventsDelegate? { get set }
|
||||||
|
|
||||||
@@ -42,7 +61,27 @@ protocol Transport: AnyObject {
|
|||||||
func getFingerprint(for peerID: PeerID) -> String?
|
func getFingerprint(for peerID: PeerID) -> String?
|
||||||
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
|
func getNoiseSessionState(for peerID: PeerID) -> LazyHandshakeState
|
||||||
func triggerHandshake(with peerID: PeerID)
|
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
|
// Messaging
|
||||||
func sendMessage(_ content: String, mentions: [String])
|
func sendMessage(_ content: String, mentions: [String])
|
||||||
@@ -66,6 +105,19 @@ protocol Transport: AnyObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
extension Transport {
|
extension Transport {
|
||||||
|
// Noise identity hooks default to inert for transports that do not carry
|
||||||
|
// Noise sessions (e.g. NostrTransport).
|
||||||
|
func noiseSessionPublicKeyData(for peerID: PeerID) -> Data? { nil }
|
||||||
|
func noiseIdentityFingerprint() -> String { "" }
|
||||||
|
func noiseStaticPublicKeyData() -> Data { Data() }
|
||||||
|
func noiseSigningPublicKeyData() -> Data { Data() }
|
||||||
|
func noiseSignData(_ data: Data) -> Data? { nil }
|
||||||
|
func noiseVerifySignature(_ signature: Data, for data: Data, publicKey: Data) -> Bool { false }
|
||||||
|
func installNoiseSessionCallbacks(
|
||||||
|
onPeerAuthenticated: @escaping (PeerID, String) -> Void,
|
||||||
|
onHandshakeRequired: @escaping (PeerID) -> Void
|
||||||
|
) {}
|
||||||
|
|
||||||
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyChallenge(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
func sendVerifyResponse(to peerID: PeerID, noiseKeyHex: String, nonceA: Data) {}
|
||||||
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
func sendFileBroadcast(_ packet: BitchatFilePacket, transferId: String) {}
|
||||||
@@ -84,4 +136,36 @@ protocol TransportPeerEventsDelegate: AnyObject {
|
|||||||
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension BitchatDelegate {
|
||||||
|
@MainActor
|
||||||
|
func receiveTransportEvent(_ event: TransportEvent) {
|
||||||
|
switch event {
|
||||||
|
case .messageReceived(let message):
|
||||||
|
didReceiveMessage(message)
|
||||||
|
case let .publicMessageReceived(peerID, nickname, content, timestamp, messageID):
|
||||||
|
didReceivePublicMessage(
|
||||||
|
from: peerID,
|
||||||
|
nickname: nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
case let .noisePayloadReceived(peerID, type, payload, timestamp):
|
||||||
|
didReceiveNoisePayload(from: peerID, type: type, payload: payload, timestamp: timestamp)
|
||||||
|
case .peerConnected(let peerID):
|
||||||
|
didConnectToPeer(peerID)
|
||||||
|
case .peerDisconnected(let peerID):
|
||||||
|
didDisconnectFromPeer(peerID)
|
||||||
|
case .peerListUpdated(let peers):
|
||||||
|
didUpdatePeerList(peers)
|
||||||
|
case .peerSnapshotsUpdated:
|
||||||
|
break
|
||||||
|
case let .messageDeliveryStatusUpdated(messageID, status):
|
||||||
|
didUpdateMessageDeliveryStatus(messageID, status: status)
|
||||||
|
case .bluetoothStateUpdated(let state):
|
||||||
|
didUpdateBluetoothState(state)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
extension BLEService: Transport {}
|
extension BLEService: Transport {}
|
||||||
|
|||||||
@@ -11,13 +11,17 @@ enum TransportConfig {
|
|||||||
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
static let bleMaxConcurrentTransfers: Int = 2 // Limit simultaneous large media sends
|
||||||
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
static let bleFragmentRelayMinDelayMs: Int = 8 // Faster forwarding for media fragments
|
||||||
static let bleFragmentRelayMaxDelayMs: Int = 25 // Upper jitter bound for fragment relays
|
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
|
// UI / Storage Caps
|
||||||
static let privateChatCap: Int = 1337
|
static let privateChatCap: Int = 1337
|
||||||
static let meshTimelineCap: Int = 1337
|
static let meshTimelineCap: Int = 1337
|
||||||
static let geoTimelineCap: Int = 1337
|
static let geoTimelineCap: Int = 1337
|
||||||
static let contentLRUCap: Int = 2000
|
static let contentLRUCap: Int = 2000
|
||||||
|
static let geoSamplingEventLRUCap: Int = 2000
|
||||||
|
|
||||||
// Timers
|
// Timers
|
||||||
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
|
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
|
||||||
@@ -40,14 +44,27 @@ enum TransportConfig {
|
|||||||
static let blePendingNotificationsCapCount: Int = 128
|
static let blePendingNotificationsCapCount: Int = 128
|
||||||
static let bleNotificationRetryDelayMs: Int = 25
|
static let bleNotificationRetryDelayMs: Int = 25
|
||||||
static let bleNotificationRetryMaxAttempts: Int = 80
|
static let bleNotificationRetryMaxAttempts: Int = 80
|
||||||
|
// Sample interval for notification backpressure logs (fire per fragment
|
||||||
|
// during media transfers).
|
||||||
|
static let bleBackpressureLogInterval: Int = 25
|
||||||
|
|
||||||
// Nostr
|
// Nostr
|
||||||
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
|
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
|
// 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 uiProcessedNostrEventsCap: Int = 2000
|
||||||
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
|
||||||
|
|
||||||
@@ -76,19 +93,21 @@ enum TransportConfig {
|
|||||||
// BLE maintenance & thresholds
|
// BLE maintenance & thresholds
|
||||||
static let bleMaintenanceInterval: TimeInterval = 5.0
|
static let bleMaintenanceInterval: TimeInterval = 5.0
|
||||||
static let bleMaintenanceLeewaySeconds: Int = 1
|
static let bleMaintenanceLeewaySeconds: Int = 1
|
||||||
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60
|
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 30
|
||||||
static let bleRecentTimeoutWindowSeconds: TimeInterval = 60
|
// Isolated nodes accept the weakest usable links — a fringe connection
|
||||||
static let bleRecentTimeoutCountThreshold: Int = 3
|
// beats no connection. Relaxed floor sits at CoreBluetooth's practical
|
||||||
static let bleRSSIIsolatedBase: Int = -90
|
// reporting limit so prolonged isolation gates on nothing but decode.
|
||||||
static let bleRSSIIsolatedRelaxed: Int = -92
|
static let bleRSSIIsolatedBase: Int = -95
|
||||||
|
static let bleRSSIIsolatedRelaxed: Int = -100
|
||||||
static let bleRSSIConnectedThreshold: Int = -85
|
static let bleRSSIConnectedThreshold: Int = -85
|
||||||
static let bleRSSIHighTimeoutThreshold: Int = -80
|
|
||||||
// How long without seeing traffic before we sanity-check the direct link
|
// 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
|
// Lowered to make connected→reachable icon changes react faster when walking out of range
|
||||||
static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0
|
static let blePeerInactivityTimeoutSeconds: TimeInterval = 8.0
|
||||||
// How long to retain a peer as "reachable" (not directly connected) since lastSeen
|
// How long to retain a peer as "reachable" (not directly connected) since lastSeen.
|
||||||
static let bleReachabilityRetentionVerifiedSeconds: TimeInterval = 21.0 // 21s for verified/favorites
|
// Must comfortably exceed the worst-case dense announce interval (38s) plus a
|
||||||
static let bleReachabilityRetentionUnverifiedSeconds: TimeInterval = 21.0 // 21s for unknown/unverified
|
// 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 bleFragmentLifetimeSeconds: TimeInterval = 30.0
|
||||||
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
|
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
|
||||||
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
|
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
|
||||||
@@ -145,7 +164,27 @@ enum TransportConfig {
|
|||||||
static let nostrRelayMaxBackoffSeconds: TimeInterval = 300.0
|
static let nostrRelayMaxBackoffSeconds: TimeInterval = 300.0
|
||||||
static let nostrRelayBackoffMultiplier: Double = 2.0
|
static let nostrRelayBackoffMultiplier: Double = 2.0
|
||||||
static let nostrRelayMaxReconnectAttempts: Int = 10
|
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
|
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
|
// Geo relay directory
|
||||||
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
|
||||||
@@ -169,8 +208,10 @@ enum TransportConfig {
|
|||||||
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
|
static let bleSubscriptionRateLimitWindowSeconds: TimeInterval = 60.0 // Window for tracking subscription attempts
|
||||||
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
|
static let bleSubscriptionRateLimitMaxAttempts: Int = 5 // Max attempts before extended cooldown
|
||||||
|
|
||||||
// Store-and-forward for directed packets at relays
|
// Store-and-forward for directed packets at relays. Spooled packets retry
|
||||||
static let bleDirectedSpoolWindowSeconds: TimeInterval = 15.0
|
// 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
|
// Log/UI debounce windows
|
||||||
// Shorter debounce so UI reacts faster while still suppressing duplicate callbacks
|
// Shorter debounce so UI reacts faster while still suppressing duplicate callbacks
|
||||||
@@ -180,6 +221,12 @@ enum TransportConfig {
|
|||||||
// Weak-link cooldown after connection timeouts
|
// Weak-link cooldown after connection timeouts
|
||||||
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
|
static let bleWeakLinkCooldownSeconds: TimeInterval = 30.0
|
||||||
static let bleWeakLinkRSSICutoff: Int = -90
|
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
|
// Content hashing / formatting
|
||||||
static let contentKeyPrefixLength: Int = 256
|
static let contentKeyPrefixLength: Int = 256
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import Foundation
|
|||||||
final class VerificationService {
|
final class VerificationService {
|
||||||
static let shared = VerificationService()
|
static let shared = VerificationService()
|
||||||
|
|
||||||
// Injected Noise service from the running transport (do NOT create new BLEService)
|
// Injected running transport (do NOT create new BLEService). Noise
|
||||||
private var noise: NoiseEncryptionService?
|
// identity operations go through the transport's narrow noise* wrappers
|
||||||
func configure(with noise: NoiseEncryptionService) { self.noise = noise }
|
// 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
|
/// Encapsulates the data encoded into a verification QR
|
||||||
struct VerificationQR: Codable {
|
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 {
|
if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
|
||||||
return c.value
|
return c.value
|
||||||
}
|
}
|
||||||
guard let noise = noise else { return nil }
|
guard let transport = transport else { return nil }
|
||||||
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
|
let noiseKey = transport.noiseStaticPublicKeyData().hexEncodedString()
|
||||||
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
|
let signKey = transport.noiseSigningPublicKeyData().hexEncodedString()
|
||||||
let ts = Int64(Date().timeIntervalSince1970)
|
let ts = Int64(Date().timeIntervalSince1970)
|
||||||
var nonce = Data(count: 16)
|
var nonce = Data(count: 16)
|
||||||
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
|
||||||
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
|
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 payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
|
||||||
let msg = payload.canonicalBytes()
|
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,
|
let signed = VerificationQR(v: payload.v,
|
||||||
noiseKeyHex: payload.noiseKeyHex,
|
noiseKeyHex: payload.noiseKeyHex,
|
||||||
signKeyHex: payload.signKeyHex,
|
signKeyHex: payload.signKeyHex,
|
||||||
@@ -108,8 +110,8 @@ final class VerificationService {
|
|||||||
if now - Double(qr.ts) > maxAge { return nil }
|
if now - Double(qr.ts) > maxAge { return nil }
|
||||||
// Verify signature using embedded ed25519 signKey
|
// Verify signature using embedded ed25519 signKey
|
||||||
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
|
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
|
||||||
guard let noise = noise else { return nil }
|
guard let transport = transport else { return nil }
|
||||||
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
|
let ok = transport.noiseVerifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
|
||||||
return ok ? qr : nil
|
return ok ? qr : nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,7 +135,7 @@ final class VerificationService {
|
|||||||
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||||
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||||
msg.append(nonceA)
|
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()
|
var tlv = Data()
|
||||||
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
|
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))
|
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()
|
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
|
||||||
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
|
||||||
msg.append(nonceA)
|
msg.append(nonceA)
|
||||||
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }
|
guard let transport = transport, let pub = Data(hexString: signerPublicKeyHex) else { return false }
|
||||||
return noise.verifySignature(signature, for: msg, publicKey: pub)
|
return transport.noiseVerifySignature(signature, for: msg, publicKey: pub)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ import CryptoKit
|
|||||||
enum GCSFilter {
|
enum GCSFilter {
|
||||||
struct Params { let p: Int; let m: UInt32; let data: Data }
|
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)
|
// Derive P from FPR (~ 1 / 2^P)
|
||||||
static func deriveP(targetFpr: Double) -> Int {
|
static func deriveP(targetFpr: Double) -> Int {
|
||||||
let f = max(0.000001, min(0.25, targetFpr))
|
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] {
|
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] = []
|
var values: [UInt64] = []
|
||||||
let reader = BitReader(data)
|
let reader = BitReader(data)
|
||||||
var acc: UInt64 = 0
|
var acc: UInt64 = 0
|
||||||
|
|||||||
@@ -128,17 +128,15 @@ final class GossipSyncManager {
|
|||||||
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
func scheduleInitialSyncToPeer(_ peerID: PeerID, delaySeconds: TimeInterval = 5.0) {
|
||||||
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
queue.asyncAfter(deadline: .now() + delaySeconds) { [weak self] in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
self.sendRequestSync(to: peerID, types: .publicMessages)
|
|
||||||
|
var types: SyncTypeFlags = .publicMessages
|
||||||
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
if self.config.fragmentCapacity > 0 && self.config.fragmentSyncIntervalSeconds > 0 {
|
||||||
self.queue.asyncAfter(deadline: .now() + 0.5) { [weak self] in
|
types.formUnion(.fragment)
|
||||||
self?.sendRequestSync(to: peerID, types: .fragment)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
if self.config.fileTransferCapacity > 0 && self.config.fileTransferSyncIntervalSeconds > 0 {
|
||||||
self.queue.asyncAfter(deadline: .now() + 1.0) { [weak self] in
|
types.formUnion(.fileTransfer)
|
||||||
self?.sendRequestSync(to: peerID, types: .fileTransfer)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
self.sendRequestSync(to: peerID, types: types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -393,13 +391,18 @@ final class GossipSyncManager {
|
|||||||
cleanupStaleAnnouncementsIfNeeded(now: now)
|
cleanupStaleAnnouncementsIfNeeded(now: now)
|
||||||
requestSyncManager.cleanup() // Cleanup expired sync requests
|
requestSyncManager.cleanup() // Cleanup expired sync requests
|
||||||
|
|
||||||
|
var dueTypes: SyncTypeFlags = []
|
||||||
for index in syncSchedules.indices {
|
for index in syncSchedules.indices {
|
||||||
guard syncSchedules[index].interval > 0 else { continue }
|
guard syncSchedules[index].interval > 0 else { continue }
|
||||||
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
|
||||||
syncSchedules[index].lastSent = now
|
syncSchedules[index].lastSent = now
|
||||||
sendPeriodicSync(for: syncSchedules[index].types)
|
dueTypes.formUnion(syncSchedules[index].types)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !dueTypes.isEmpty {
|
||||||
|
sendPeriodicSync(for: dueTypes)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
private func cleanupStaleAnnouncementsIfNeeded(now: Date) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import CoreBluetooth
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ChatBluetoothAlertUpdate: Equatable {
|
||||||
|
let isPresented: Bool
|
||||||
|
let message: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatBluetoothAlertPolicy {
|
||||||
|
static func update(for state: CBManagerState) -> ChatBluetoothAlertUpdate {
|
||||||
|
switch state {
|
||||||
|
case .poweredOff:
|
||||||
|
ChatBluetoothAlertUpdate(
|
||||||
|
isPresented: true,
|
||||||
|
message: String(localized: "content.alert.bluetooth_required.off", comment: "Message shown when Bluetooth is turned off")
|
||||||
|
)
|
||||||
|
case .unauthorized:
|
||||||
|
ChatBluetoothAlertUpdate(
|
||||||
|
isPresented: true,
|
||||||
|
message: String(localized: "content.alert.bluetooth_required.permission", comment: "Message shown when Bluetooth permission is missing")
|
||||||
|
)
|
||||||
|
case .unsupported:
|
||||||
|
ChatBluetoothAlertUpdate(
|
||||||
|
isPresented: true,
|
||||||
|
message: String(localized: "content.alert.bluetooth_required.unsupported", comment: "Message shown when the device lacks Bluetooth support")
|
||||||
|
)
|
||||||
|
case .poweredOn:
|
||||||
|
ChatBluetoothAlertUpdate(isPresented: false, message: "")
|
||||||
|
case .unknown, .resetting:
|
||||||
|
ChatBluetoothAlertUpdate(isPresented: false, message: nil)
|
||||||
|
@unknown default:
|
||||||
|
ChatBluetoothAlertUpdate(isPresented: false, message: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
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 context: any ChatComposerContext
|
||||||
|
|
||||||
|
init(context: any ChatComposerContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAutocomplete(for text: String, cursorPosition: Int) {
|
||||||
|
let peerCandidates = autocompleteCandidates()
|
||||||
|
let (suggestions, range) = context.autocompleteQuery(
|
||||||
|
for: text,
|
||||||
|
peers: peerCandidates,
|
||||||
|
cursorPosition: cursorPosition
|
||||||
|
)
|
||||||
|
|
||||||
|
if !suggestions.isEmpty {
|
||||||
|
context.autocompleteSuggestions = suggestions
|
||||||
|
context.autocompleteRange = range
|
||||||
|
context.showAutocomplete = true
|
||||||
|
context.selectedAutocompleteIndex = 0
|
||||||
|
} else {
|
||||||
|
context.autocompleteSuggestions = []
|
||||||
|
context.autocompleteRange = nil
|
||||||
|
context.showAutocomplete = false
|
||||||
|
context.selectedAutocompleteIndex = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func completeNickname(_ nickname: String, in text: inout String) -> Int {
|
||||||
|
guard let range = context.autocompleteRange else { return text.count }
|
||||||
|
|
||||||
|
text = context.applyAutocompleteSuggestion(nickname, to: text, range: range)
|
||||||
|
|
||||||
|
context.showAutocomplete = false
|
||||||
|
context.autocompleteSuggestions = []
|
||||||
|
context.autocompleteRange = nil
|
||||||
|
context.selectedAutocompleteIndex = 0
|
||||||
|
|
||||||
|
return range.location + nickname.count + (nickname.hasPrefix("@") ? 1 : 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMentions(from content: String) -> [String] {
|
||||||
|
let regex = ChatViewModel.Patterns.mention
|
||||||
|
let nsContent = content as NSString
|
||||||
|
let matches = regex.matches(
|
||||||
|
in: content,
|
||||||
|
options: [],
|
||||||
|
range: NSRange(location: 0, length: nsContent.length)
|
||||||
|
)
|
||||||
|
|
||||||
|
let peerNicknames = context.meshPeerNicknames()
|
||||||
|
var validTokens = Set(peerNicknames.values)
|
||||||
|
validTokens.insert(context.nickname)
|
||||||
|
validTokens.insert(context.nickname + "#" + String(context.myPeerID.id.prefix(4)))
|
||||||
|
|
||||||
|
var mentions: [String] = []
|
||||||
|
for match in matches {
|
||||||
|
guard let range = Range(match.range(at: 1), in: content) else { continue }
|
||||||
|
let mentionedName = String(content[range])
|
||||||
|
if validTokens.contains(mentionedName) {
|
||||||
|
mentions.append(mentionedName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array(Set(mentions))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ChatComposerCoordinator {
|
||||||
|
func autocompleteCandidates() -> [String] {
|
||||||
|
switch context.activeChannel {
|
||||||
|
case .mesh:
|
||||||
|
let values = context.meshPeerNicknames().values
|
||||||
|
return Array(values.filter { $0 != context.meshNickname })
|
||||||
|
|
||||||
|
case .location(let channel):
|
||||||
|
var tokens = Set<String>()
|
||||||
|
for (pubkey, nick) in context.geoNicknames {
|
||||||
|
tokens.insert("\(nick)#\(pubkey.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 !context.isStartupPhase else { return }
|
||||||
|
let validMessageIDs = context.privateMessageIDs()
|
||||||
|
guard !validMessageIDs.isEmpty else { return }
|
||||||
|
|
||||||
|
let removedCount = context.pruneSentReadReceipts(keeping: validMessageIDs)
|
||||||
|
if removedCount > 0 {
|
||||||
|
SecureLogger.debug("🧹 Cleaned up \(removedCount) old read receipts", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didReceiveReadReceipt(_ receipt: ReadReceipt) {
|
||||||
|
updateMessageDeliveryStatus(
|
||||||
|
receipt.originalMessageID,
|
||||||
|
status: .read(by: receipt.readerNickname, at: receipt.timestamp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func didUpdateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) {
|
||||||
|
updateMessageDeliveryStatus(messageID, status: status)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func deliveryStatus(for messageID: String) -> DeliveryStatus? {
|
||||||
|
context.deliveryStatus(forMessageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@discardableResult
|
||||||
|
func updateMessageDeliveryStatus(_ messageID: String, status: DeliveryStatus) -> Bool {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct ChatFavoriteStatusSnapshot: Equatable {
|
||||||
|
let peerNickname: String
|
||||||
|
let peerNostrPublicKey: String?
|
||||||
|
let isFavorite: Bool
|
||||||
|
let theyFavoritedUs: Bool
|
||||||
|
|
||||||
|
init(
|
||||||
|
peerNickname: String,
|
||||||
|
peerNostrPublicKey: String?,
|
||||||
|
isFavorite: Bool,
|
||||||
|
theyFavoritedUs: Bool
|
||||||
|
) {
|
||||||
|
self.peerNickname = peerNickname
|
||||||
|
self.peerNostrPublicKey = peerNostrPublicKey
|
||||||
|
self.isFavorite = isFavorite
|
||||||
|
self.theyFavoritedUs = theyFavoritedUs
|
||||||
|
}
|
||||||
|
|
||||||
|
init(_ relationship: FavoritesPersistenceService.FavoriteRelationship) {
|
||||||
|
self.peerNickname = relationship.peerNickname
|
||||||
|
self.peerNostrPublicKey = relationship.peerNostrPublicKey
|
||||||
|
self.isFavorite = relationship.isFavorite
|
||||||
|
self.theyFavoritedUs = relationship.theyFavoritedUs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatFavoritePersistenceAction: Equatable {
|
||||||
|
case add(nickname: String, nostrKey: String?)
|
||||||
|
case remove
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatFavoriteNotificationDecision: Equatable {
|
||||||
|
case none
|
||||||
|
case send(isFavorite: Bool)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatFavoriteTogglePlan: Equatable {
|
||||||
|
let persistenceAction: ChatFavoritePersistenceAction
|
||||||
|
let notification: ChatFavoriteNotificationDecision
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatFavoriteTogglePolicy {
|
||||||
|
static func plan(
|
||||||
|
currentStatus: ChatFavoriteStatusSnapshot?,
|
||||||
|
fallbackNickname: String?,
|
||||||
|
bridgedNostrKey: String?
|
||||||
|
) -> ChatFavoriteTogglePlan {
|
||||||
|
let wasFavorite = currentStatus?.isFavorite ?? false
|
||||||
|
|
||||||
|
if wasFavorite {
|
||||||
|
return ChatFavoriteTogglePlan(
|
||||||
|
persistenceAction: .remove,
|
||||||
|
notification: .send(isFavorite: false)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ChatFavoriteTogglePlan(
|
||||||
|
persistenceAction: .add(
|
||||||
|
nickname: currentStatus?.peerNickname ?? fallbackNickname ?? "Unknown",
|
||||||
|
nostrKey: currentStatus?.peerNostrPublicKey ?? bridgedNostrKey
|
||||||
|
),
|
||||||
|
notification: currentStatus?.theyFavoritedUs == true ? .send(isFavorite: true) : .none
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,363 @@
|
|||||||
|
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
|
||||||
|
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)
|
||||||
|
|
||||||
|
// 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 scheduleOnMainAfter(_ delay: TimeInterval, _ work: @escaping @MainActor () -> Void) {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
|
||||||
|
Task { @MainActor in
|
||||||
|
work()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopMeshServices() {
|
||||||
|
meshService.stopServices()
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
appendPrivateScreenshotNotice(for: peerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch context.activeChannel {
|
||||||
|
case .mesh:
|
||||||
|
context.sendMeshMessage(
|
||||||
|
screenshotMessage,
|
||||||
|
mentions: [],
|
||||||
|
messageID: UUID().uuidString,
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
|
||||||
|
case .location(let channel):
|
||||||
|
sendPublicGeohashScreenshotMessage(
|
||||||
|
screenshotMessage,
|
||||||
|
channel: channel
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.addSystemMessage("you took a screenshot")
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveIdentityState() {
|
||||||
|
context.forceSaveIdentity()
|
||||||
|
context.verifyIdentityKeyExists()
|
||||||
|
}
|
||||||
|
|
||||||
|
func applicationWillTerminate() {
|
||||||
|
context.stopMeshServices()
|
||||||
|
saveIdentityState()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markPrivateMessagesAsRead(from peerID: PeerID) {
|
||||||
|
context.markChatAsRead(from: peerID)
|
||||||
|
|
||||||
|
if peerID.isGeoDM,
|
||||||
|
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 !context.sentReadReceipts.contains(message.id) else { continue }
|
||||||
|
|
||||||
|
SecureLogger.debug(
|
||||||
|
"GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
context.sendGeohashReadReceipt(
|
||||||
|
message.id,
|
||||||
|
toRecipientHex: recipientHex,
|
||||||
|
from: identity
|
||||||
|
)
|
||||||
|
context.markReadReceiptSent(message.id)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var noiseKeyHex: PeerID?
|
||||||
|
var peerNostrPubkey: String?
|
||||||
|
|
||||||
|
if let noiseKey = Data(hexString: peerID.id),
|
||||||
|
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey) {
|
||||||
|
noiseKeyHex = peerID
|
||||||
|
peerNostrPubkey = favoriteStatus.peerNostrPublicKey
|
||||||
|
} else if let peer = context.unifiedPeer(for: peerID) {
|
||||||
|
noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||||||
|
let favoriteStatus = context.favoriteRelationship(forNoiseKey: peer.noisePublicKey)
|
||||||
|
peerNostrPubkey = favoriteStatus?.peerNostrPublicKey
|
||||||
|
|
||||||
|
if let noiseKeyHex, context.unreadPrivateMessages.contains(noiseKeyHex) {
|
||||||
|
context.markPrivateChatRead(noiseKeyHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
guard peerNostrPubkey != nil else { return }
|
||||||
|
|
||||||
|
for message in getPrivateChatMessages(for: peerID) {
|
||||||
|
guard (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
guard !context.sentReadReceipts.contains(message.id) else { continue }
|
||||||
|
|
||||||
|
let receipt = ReadReceipt(
|
||||||
|
originalMessageID: message.id,
|
||||||
|
readerID: context.myPeerID,
|
||||||
|
readerNickname: context.nickname
|
||||||
|
)
|
||||||
|
let recipientPeerID = peerID.isHex
|
||||||
|
? peerID
|
||||||
|
: (context.unifiedPeer(for: peerID)?.peerID ?? peerID)
|
||||||
|
|
||||||
|
context.routeReadReceipt(receipt, to: recipientPeerID)
|
||||||
|
context.markReadReceiptSent(message.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getMessages(for peerID: PeerID?) -> [BitchatMessage] {
|
||||||
|
guard let peerID else { return context.messages }
|
||||||
|
return getPrivateChatMessages(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPrivateChatMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||||
|
var combined: [BitchatMessage] = []
|
||||||
|
|
||||||
|
combined.append(contentsOf: context.privateMessages(for: peerID))
|
||||||
|
|
||||||
|
if let peer = context.unifiedPeer(for: peerID) {
|
||||||
|
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||||||
|
if noiseKeyHex != peerID {
|
||||||
|
combined.append(contentsOf: context.privateMessages(for: noiseKeyHex))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bestByID: [String: BitchatMessage] = [:]
|
||||||
|
for message in combined {
|
||||||
|
if let existing = bestByID[message.id] {
|
||||||
|
let existingRank = deliveryStatusRank(existing.deliveryStatus)
|
||||||
|
let candidateRank = deliveryStatusRank(message.deliveryStatus)
|
||||||
|
if candidateRank > existingRank || (candidateRank == existingRank && message.timestamp > existing.timestamp) {
|
||||||
|
bestByID[message.id] = message
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
bestByID[message.id] = message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestByID.values.sorted { $0.timestamp < $1.timestamp }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ChatLifecycleCoordinator {
|
||||||
|
func sendPrivateScreenshotNotificationIfPossible(_ message: String, to peerID: PeerID) {
|
||||||
|
guard let peerNickname = context.peerNickname(for: peerID) else { return }
|
||||||
|
|
||||||
|
let sessionState = context.noiseSessionState(for: peerID)
|
||||||
|
switch sessionState {
|
||||||
|
case .established:
|
||||||
|
context.routePrivateMessage(
|
||||||
|
message,
|
||||||
|
to: peerID,
|
||||||
|
recipientNickname: peerNickname,
|
||||||
|
messageID: UUID().uuidString
|
||||||
|
)
|
||||||
|
|
||||||
|
case .none, .failed, .handshakeQueued, .handshaking:
|
||||||
|
SecureLogger.debug(
|
||||||
|
"Skipping screenshot notification to \(peerID) - no established session",
|
||||||
|
category: .security
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendPrivateScreenshotNotice(for peerID: PeerID) {
|
||||||
|
let notice = BitchatMessage(
|
||||||
|
sender: "system",
|
||||||
|
content: "you took a screenshot",
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: context.peerNickname(for: peerID),
|
||||||
|
senderPeerID: context.myPeerID
|
||||||
|
)
|
||||||
|
|
||||||
|
context.appendPrivateMessage(notice, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPublicGeohashScreenshotMessage(_ message: String, channel: GeohashChannel) {
|
||||||
|
Task { @MainActor [weak context = self.context] in
|
||||||
|
guard let context else { return }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||||
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
|
content: message,
|
||||||
|
geohash: channel.geohash,
|
||||||
|
senderIdentity: identity,
|
||||||
|
nickname: context.nickname,
|
||||||
|
teleported: context.isTeleported
|
||||||
|
)
|
||||||
|
|
||||||
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
|
||||||
|
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)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session)
|
||||||
|
context.addSystemMessage(
|
||||||
|
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deliveryStatusRank(_ status: DeliveryStatus?) -> Int {
|
||||||
|
guard let status else { return 0 }
|
||||||
|
switch status {
|
||||||
|
case .failed: return 1
|
||||||
|
case .sending: return 2
|
||||||
|
case .sent: return 3
|
||||||
|
case .partiallyDelivered: return 4
|
||||||
|
case .delivered: return 5
|
||||||
|
case .read: return 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ChatMediaPreparationError: Error, Equatable {
|
||||||
|
case encodingFailed
|
||||||
|
case voiceNoteTooLarge(bytes: Int)
|
||||||
|
case imageTooLarge(bytes: Int)
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ChatPreparedImage {
|
||||||
|
let outputURL: URL
|
||||||
|
let packet: BitchatFilePacket
|
||||||
|
}
|
||||||
|
|
||||||
|
enum ChatMediaPreparation {
|
||||||
|
static func prepareVoiceNotePacket(at url: URL) throws -> BitchatFilePacket {
|
||||||
|
let attributes = try FileManager.default.attributesOfItem(atPath: url.path)
|
||||||
|
guard let fileSize = attributes[.size] as? Int else {
|
||||||
|
throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: 0)
|
||||||
|
}
|
||||||
|
guard fileSize <= FileTransferLimits.maxVoiceNoteBytes else {
|
||||||
|
throw ChatMediaPreparationError.voiceNoteTooLarge(bytes: fileSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = try Data(contentsOf: url)
|
||||||
|
let packet = BitchatFilePacket(
|
||||||
|
fileName: url.lastPathComponent,
|
||||||
|
fileSize: UInt64(data.count),
|
||||||
|
mimeType: "audio/mp4",
|
||||||
|
content: data
|
||||||
|
)
|
||||||
|
guard packet.encode() != nil else { throw ChatMediaPreparationError.encodingFailed }
|
||||||
|
return packet
|
||||||
|
}
|
||||||
|
|
||||||
|
static func prepareImagePacket(from sourceURL: URL) throws -> ChatPreparedImage {
|
||||||
|
let outputURL = try ImageUtils.processImage(at: sourceURL)
|
||||||
|
do {
|
||||||
|
let data = try Data(contentsOf: outputURL)
|
||||||
|
guard data.count <= FileTransferLimits.maxImageBytes else {
|
||||||
|
throw ChatMediaPreparationError.imageTooLarge(bytes: data.count)
|
||||||
|
}
|
||||||
|
|
||||||
|
let packet = BitchatFilePacket(
|
||||||
|
fileName: outputURL.lastPathComponent,
|
||||||
|
fileSize: UInt64(data.count),
|
||||||
|
mimeType: "image/jpeg",
|
||||||
|
content: data
|
||||||
|
)
|
||||||
|
guard packet.encode() != nil else { throw ChatMediaPreparationError.encodingFailed }
|
||||||
|
return ChatPreparedImage(outputURL: outputURL, packet: packet)
|
||||||
|
} catch {
|
||||||
|
try? FileManager.default.removeItem(at: outputURL)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
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 context: any ChatMediaTransferContext
|
||||||
|
|
||||||
|
private(set) var transferIdToMessageIDs: [String: [String]] = [:]
|
||||||
|
private(set) var messageIDToTransferId: [String: String] = [:]
|
||||||
|
|
||||||
|
init(context: any ChatMediaTransferContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendVoiceNote(at url: URL) {
|
||||||
|
guard context.canSendMediaInCurrentContext else {
|
||||||
|
SecureLogger.info("Voice note blocked outside mesh/private context", category: .session)
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
context.addSystemMessage("Voice notes are only available in mesh chats.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetPeer = context.selectedPrivateChatPeer
|
||||||
|
let message = enqueueMediaMessage(
|
||||||
|
content: "\(MimeType.Category.audio.messagePrefix)\(url.lastPathComponent)",
|
||||||
|
targetPeer: targetPeer
|
||||||
|
)
|
||||||
|
let messageID = message.id
|
||||||
|
let transferId = makeTransferID(messageID: messageID)
|
||||||
|
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
|
do {
|
||||||
|
let packet = try ChatMediaPreparation.prepareVoiceNotePacket(at: url)
|
||||||
|
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
|
if let peerID = targetPeer {
|
||||||
|
self.context.sendFilePrivate(packet, to: peerID, transferId: transferId)
|
||||||
|
} else {
|
||||||
|
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 { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.handleMediaSendFailure(messageID: messageID, reason: "Voice note too large")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Voice note send failed: \(error)", category: .session)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.handleMediaSendFailure(messageID: messageID, reason: "Failed to send voice note")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#if os(iOS)
|
||||||
|
func processThenSendImage(_ image: UIImage?) {
|
||||||
|
guard let image else { return }
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
let processedURL = try ImageUtils.processImage(image)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.sendImage(from: processedURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#elseif os(macOS)
|
||||||
|
func processThenSendImage(from url: URL?) {
|
||||||
|
guard let url else { return }
|
||||||
|
Task.detached { [weak self] in
|
||||||
|
do {
|
||||||
|
let processedURL = try ImageUtils.processImage(at: url)
|
||||||
|
await MainActor.run { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.sendImage(from: processedURL)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Image processing failed: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
func sendImage(from sourceURL: URL, cleanup: (() -> Void)? = nil) {
|
||||||
|
guard context.canSendMediaInCurrentContext else {
|
||||||
|
SecureLogger.info("Image send blocked outside mesh/private context", category: .session)
|
||||||
|
cleanup?()
|
||||||
|
context.addSystemMessage("Images are only available in mesh chats.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let targetPeer = context.selectedPrivateChatPeer
|
||||||
|
|
||||||
|
do {
|
||||||
|
try ImageUtils.validateImageSource(at: sourceURL)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Image send preparation failed: \(error)", category: .session)
|
||||||
|
context.addSystemMessage("Failed to prepare image for sending.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task.detached(priority: .userInitiated) { [weak self] in
|
||||||
|
do {
|
||||||
|
let prepared = try ChatMediaPreparation.prepareImagePacket(from: sourceURL)
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
let messageID = message.id
|
||||||
|
let transferId = self.makeTransferID(messageID: messageID)
|
||||||
|
self.registerTransfer(transferId: transferId, messageID: messageID)
|
||||||
|
if let peerID = targetPeer {
|
||||||
|
self.context.sendFilePrivate(prepared.packet, to: peerID, transferId: transferId)
|
||||||
|
} else {
|
||||||
|
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 { [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 { [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
self.context.addSystemMessage("Failed to prepare image for sending.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func enqueueMediaMessage(content: String, targetPeer: PeerID?) -> BitchatMessage {
|
||||||
|
let timestamp = Date()
|
||||||
|
let message: BitchatMessage
|
||||||
|
|
||||||
|
if let peerID = targetPeer {
|
||||||
|
message = BitchatMessage(
|
||||||
|
sender: context.nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: context.nicknameForPeer(peerID),
|
||||||
|
senderPeerID: context.myPeerID,
|
||||||
|
deliveryStatus: .sending
|
||||||
|
)
|
||||||
|
context.appendPrivateMessage(message, to: peerID)
|
||||||
|
} else {
|
||||||
|
let (displayName, senderPeerID) = context.currentPublicSender()
|
||||||
|
message = BitchatMessage(
|
||||||
|
sender: displayName,
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: false,
|
||||||
|
recipientNickname: nil,
|
||||||
|
senderPeerID: senderPeerID,
|
||||||
|
deliveryStatus: .sending
|
||||||
|
)
|
||||||
|
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = context.normalizedContentKey(message.content)
|
||||||
|
context.recordContentKey(key, timestamp: timestamp)
|
||||||
|
context.notifyUIChanged()
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
func registerTransfer(transferId: String, messageID: String) {
|
||||||
|
transferIdToMessageIDs[transferId, default: []].append(messageID)
|
||||||
|
messageIDToTransferId[messageID] = transferId
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTransferID(messageID: String) -> String {
|
||||||
|
"\(messageID)-\(UUID().uuidString)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearTransferMapping(for messageID: String) {
|
||||||
|
guard let transferId = messageIDToTransferId.removeValue(forKey: messageID) else { return }
|
||||||
|
guard var queue = transferIdToMessageIDs[transferId] else { return }
|
||||||
|
|
||||||
|
if !queue.isEmpty {
|
||||||
|
if queue.first == messageID {
|
||||||
|
queue.removeFirst()
|
||||||
|
} else if let index = queue.firstIndex(of: messageID) {
|
||||||
|
queue.remove(at: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
transferIdToMessageIDs[transferId] = queue.isEmpty ? nil : queue
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleMediaSendFailure(messageID: String, reason: String) {
|
||||||
|
context.updateMessageDeliveryStatus(messageID, status: .failed(reason: reason))
|
||||||
|
clearTransferMapping(for: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleTransferEvent(_ event: TransferProgressManager.Event) {
|
||||||
|
switch event {
|
||||||
|
case .started(let id, let total):
|
||||||
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||||
|
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 }
|
||||||
|
context.updateMessageDeliveryStatus(messageID, status: .partiallyDelivered(reached: sent, total: total))
|
||||||
|
case .completed(let id, _):
|
||||||
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||||
|
context.updateMessageDeliveryStatus(messageID, status: .sent)
|
||||||
|
clearTransferMapping(for: messageID)
|
||||||
|
case .cancelled(let id, _, _):
|
||||||
|
guard let messageID = transferIdToMessageIDs[id]?.first else { return }
|
||||||
|
clearTransferMapping(for: messageID)
|
||||||
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cleanupLocalFile(forMessage message: BitchatMessage) {
|
||||||
|
let categories: [MimeType.Category] = [.audio, .image, .file]
|
||||||
|
guard let category = categories.first(where: { message.content.hasPrefix($0.messagePrefix) }),
|
||||||
|
let rawFilename = String(message.content.dropFirst(category.messagePrefix.count)).trimmedOrNilIfEmpty,
|
||||||
|
let base = try? applicationFilesDirectory(),
|
||||||
|
let safeFilename = (rawFilename as NSString).lastPathComponent.nilIfEmpty,
|
||||||
|
safeFilename != ".",
|
||||||
|
safeFilename != ".." else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let subdirs = categories.flatMap { ["\($0.mediaDir)/outgoing", "\($0.mediaDir)/incoming"] }
|
||||||
|
for subdir in subdirs {
|
||||||
|
let target = base.appendingPathComponent(subdir, isDirectory: true).appendingPathComponent(safeFilename)
|
||||||
|
guard target.path.hasPrefix(base.path) else { continue }
|
||||||
|
|
||||||
|
do {
|
||||||
|
try FileManager.default.removeItem(at: target)
|
||||||
|
} catch CocoaError.fileNoSuchFile {
|
||||||
|
continue
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to cleanup \(safeFilename): \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func cancelMediaSend(messageID: String) {
|
||||||
|
if let transferId = messageIDToTransferId[messageID],
|
||||||
|
let active = transferIdToMessageIDs[transferId]?.first,
|
||||||
|
active == messageID {
|
||||||
|
context.cancelTransfer(transferId)
|
||||||
|
}
|
||||||
|
clearTransferMapping(for: messageID)
|
||||||
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deleteMediaMessage(messageID: String) {
|
||||||
|
clearTransferMapping(for: messageID)
|
||||||
|
context.removeMessage(withID: messageID, cleanupFile: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ChatMediaTransferCoordinator {
|
||||||
|
func applicationFilesDirectory() throws -> URL {
|
||||||
|
let base = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let filesDirectory = base.appendingPathComponent("files", isDirectory: true)
|
||||||
|
try FileManager.default.createDirectory(
|
||||||
|
at: filesDirectory,
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
return filesDirectory
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,505 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ChatMessageFormatter {
|
||||||
|
typealias Patterns = MessageFormattingEngine.Patterns
|
||||||
|
|
||||||
|
private unowned let viewModel: ChatViewModel
|
||||||
|
private let meshPalette = MinimalDistancePalette(config: .mesh)
|
||||||
|
private let nostrPalette = MinimalDistancePalette(config: .nostr)
|
||||||
|
|
||||||
|
init(viewModel: ChatViewModel) {
|
||||||
|
self.viewModel = viewModel
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let myGeo: NostrIdentity? = {
|
||||||
|
if let cached = viewModel.cachedGeohashIdentity, cached.geohash == channel.geohash {
|
||||||
|
return cached.identity
|
||||||
|
}
|
||||||
|
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
||||||
|
viewModel.cachedGeohashIdentity = (channel.geohash, identity)
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
if let myGeo {
|
||||||
|
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return spid == viewModel.meshService.myPeerID
|
||||||
|
}
|
||||||
|
if message.sender == viewModel.nickname { return true }
|
||||||
|
if message.sender.hasPrefix(viewModel.nickname + "#") { return true }
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
|
||||||
|
let isDark = colorScheme == .dark
|
||||||
|
if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant) {
|
||||||
|
return cachedText
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = AttributedString()
|
||||||
|
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
|
||||||
|
|
||||||
|
if message.sender != "system" {
|
||||||
|
let (baseName, suffix) = message.sender.splitSuffix()
|
||||||
|
var senderStyle = AttributeContainer()
|
||||||
|
senderStyle.foregroundColor = baseColor
|
||||||
|
let fontWeight: Font.Weight = isSelf ? .bold : .medium
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
||||||
|
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
||||||
|
if !suffix.isEmpty {
|
||||||
|
var suffixStyle = senderStyle
|
||||||
|
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||||
|
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||||
|
}
|
||||||
|
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||||
|
|
||||||
|
let content = message.content
|
||||||
|
let nsContent = content as NSString
|
||||||
|
let nsLen = nsContent.length
|
||||||
|
let containsCashuEarly: Bool = {
|
||||||
|
let regex = Patterns.quickCashuPresence
|
||||||
|
return regex.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
|
||||||
|
}()
|
||||||
|
|
||||||
|
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
||||||
|
var plainStyle = AttributeContainer()
|
||||||
|
plainStyle.foregroundColor = baseColor
|
||||||
|
plainStyle.font = isSelf
|
||||||
|
? .bitchatSystem(size: 14, weight: .bold, design: design)
|
||||||
|
: .bitchatSystem(size: 14, design: design)
|
||||||
|
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
||||||
|
} else {
|
||||||
|
let hashtagRegex = Patterns.hashtag
|
||||||
|
let mentionRegex = Patterns.mention
|
||||||
|
let cashuRegex = Patterns.cashu
|
||||||
|
let bolt11Regex = Patterns.bolt11
|
||||||
|
let lnurlRegex = Patterns.lnurl
|
||||||
|
let lightningSchemeRegex = Patterns.lightningScheme
|
||||||
|
let detector = Patterns.linkDetector
|
||||||
|
let hasMentionsHint = content.contains("@")
|
||||||
|
let hasHashtagsHint = content.contains("#")
|
||||||
|
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
||||||
|
let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:")
|
||||||
|
let hasCashuHint = content.lowercased().contains("cashu")
|
||||||
|
|
||||||
|
let hashtagMatches = hasHashtagsHint
|
||||||
|
? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
||||||
|
: []
|
||||||
|
let mentionMatches = hasMentionsHint
|
||||||
|
? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
||||||
|
: []
|
||||||
|
let urlMatches = hasURLHint
|
||||||
|
? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? [])
|
||||||
|
: []
|
||||||
|
let cashuMatches = hasCashuHint
|
||||||
|
? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
||||||
|
: []
|
||||||
|
let lightningMatches = hasLightningHint
|
||||||
|
? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
||||||
|
: []
|
||||||
|
let bolt11Matches = hasLightningHint
|
||||||
|
? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
||||||
|
: []
|
||||||
|
let lnurlMatches = hasLightningHint
|
||||||
|
? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
||||||
|
: []
|
||||||
|
|
||||||
|
let mentionRanges = mentionMatches.map { $0.range(at: 0) }
|
||||||
|
func overlapsMention(_ range: NSRange) -> Bool {
|
||||||
|
for mentionRange in mentionRanges where NSIntersectionRange(range, mentionRange).length > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachedToMention(_ range: NSRange) -> Bool {
|
||||||
|
if let swiftRange = Range(range, in: content), swiftRange.lowerBound > content.startIndex {
|
||||||
|
var index = content.index(before: swiftRange.lowerBound)
|
||||||
|
while true {
|
||||||
|
let character = content[index]
|
||||||
|
if character.isWhitespace || character.isNewline { break }
|
||||||
|
if character == "@" { return true }
|
||||||
|
if index == content.startIndex { break }
|
||||||
|
index = content.index(before: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func isStandaloneHashtag(_ range: NSRange) -> Bool {
|
||||||
|
guard let swiftRange = Range(range, in: content) else { return false }
|
||||||
|
if swiftRange.lowerBound == content.startIndex { return true }
|
||||||
|
let previous = content.index(before: swiftRange.lowerBound)
|
||||||
|
return content[previous].isWhitespace || content[previous].isNewline
|
||||||
|
}
|
||||||
|
|
||||||
|
var allMatches: [(range: NSRange, type: String)] = []
|
||||||
|
for match in hashtagMatches
|
||||||
|
where !overlapsMention(match.range(at: 0))
|
||||||
|
&& !attachedToMention(match.range(at: 0))
|
||||||
|
&& isStandaloneHashtag(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "hashtag"))
|
||||||
|
}
|
||||||
|
for match in mentionMatches {
|
||||||
|
allMatches.append((match.range(at: 0), "mention"))
|
||||||
|
}
|
||||||
|
for match in urlMatches where !overlapsMention(match.range) {
|
||||||
|
allMatches.append((match.range, "url"))
|
||||||
|
}
|
||||||
|
for match in cashuMatches where !overlapsMention(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "cashu"))
|
||||||
|
}
|
||||||
|
for match in lightningMatches where !overlapsMention(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "lightning"))
|
||||||
|
}
|
||||||
|
|
||||||
|
let occupied = urlMatches.map(\.range) + lightningMatches.map { $0.range(at: 0) }
|
||||||
|
func overlapsOccupied(_ range: NSRange) -> Bool {
|
||||||
|
for occupiedRange in occupied where NSIntersectionRange(range, occupiedRange).length > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for match in bolt11Matches
|
||||||
|
where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "bolt11"))
|
||||||
|
}
|
||||||
|
for match in lnurlMatches
|
||||||
|
where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) {
|
||||||
|
allMatches.append((match.range(at: 0), "lnurl"))
|
||||||
|
}
|
||||||
|
allMatches.sort { $0.range.location < $1.range.location }
|
||||||
|
|
||||||
|
var lastEnd = content.startIndex
|
||||||
|
let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false
|
||||||
|
|
||||||
|
for (range, type) in allMatches {
|
||||||
|
guard let swiftRange = Range(range, in: content) else { continue }
|
||||||
|
|
||||||
|
if lastEnd < swiftRange.lowerBound {
|
||||||
|
let beforeText = String(content[lastEnd..<swiftRange.lowerBound])
|
||||||
|
if !beforeText.isEmpty {
|
||||||
|
var beforeStyle = AttributeContainer()
|
||||||
|
beforeStyle.foregroundColor = baseColor
|
||||||
|
beforeStyle.font = isSelf
|
||||||
|
? .bitchatSystem(size: 14, weight: .bold, design: design)
|
||||||
|
: .bitchatSystem(size: 14, design: design)
|
||||||
|
if isMentioned {
|
||||||
|
beforeStyle.font = beforeStyle.font?.bold()
|
||||||
|
}
|
||||||
|
result.append(AttributedString(beforeText).mergingAttributes(beforeStyle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let matchText = String(content[swiftRange])
|
||||||
|
if type == "mention" {
|
||||||
|
let (mentionBase, mentionSuffix) = matchText.splitSuffix()
|
||||||
|
let mySuffix: String? = {
|
||||||
|
if case .location(let channel) = viewModel.activeChannel,
|
||||||
|
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
||||||
|
return String(identity.publicKeyHex.suffix(4))
|
||||||
|
}
|
||||||
|
return String(viewModel.meshService.myPeerID.id.prefix(4))
|
||||||
|
}()
|
||||||
|
let isMentionToMe: Bool = {
|
||||||
|
if mentionBase == viewModel.nickname {
|
||||||
|
if let mySuffix, !mentionSuffix.isEmpty {
|
||||||
|
return mentionSuffix == "#\(mySuffix)"
|
||||||
|
}
|
||||||
|
return mentionSuffix.isEmpty
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
|
||||||
|
var mentionStyle = AttributeContainer()
|
||||||
|
mentionStyle.font = .bitchatSystem(
|
||||||
|
size: 14,
|
||||||
|
weight: isSelf ? .bold : .semibold,
|
||||||
|
design: design
|
||||||
|
)
|
||||||
|
let mentionColor: Color = isMentionToMe ? .orange : baseColor
|
||||||
|
mentionStyle.foregroundColor = mentionColor
|
||||||
|
let at = "@"
|
||||||
|
result.append(AttributedString("\(at)").mergingAttributes(mentionStyle))
|
||||||
|
result.append(AttributedString(mentionBase).mergingAttributes(mentionStyle))
|
||||||
|
if !mentionSuffix.isEmpty {
|
||||||
|
var lightStyle = mentionStyle
|
||||||
|
lightStyle.foregroundColor = mentionColor.opacity(0.6)
|
||||||
|
result.append(AttributedString(mentionSuffix).mergingAttributes(lightStyle))
|
||||||
|
}
|
||||||
|
} else if type == "hashtag" {
|
||||||
|
let token = String(matchText.dropFirst()).lowercased()
|
||||||
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
|
let isGeohash = (2...12).contains(token.count) && token.allSatisfy { allowed.contains($0) }
|
||||||
|
let attachedToMentionToken: Bool = {
|
||||||
|
if swiftRange.lowerBound > content.startIndex {
|
||||||
|
var index = content.index(before: swiftRange.lowerBound)
|
||||||
|
while true {
|
||||||
|
let character = content[index]
|
||||||
|
if character.isWhitespace || character.isNewline { break }
|
||||||
|
if character == "@" { return true }
|
||||||
|
if index == content.startIndex { break }
|
||||||
|
index = content.index(before: index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
let standalone: Bool = {
|
||||||
|
if swiftRange.lowerBound == content.startIndex { return true }
|
||||||
|
let previous = content.index(before: swiftRange.lowerBound)
|
||||||
|
return content[previous].isWhitespace || content[previous].isNewline
|
||||||
|
}()
|
||||||
|
|
||||||
|
var tagStyle = AttributeContainer()
|
||||||
|
tagStyle.font = isSelf
|
||||||
|
? .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)") {
|
||||||
|
tagStyle.link = url
|
||||||
|
tagStyle.underlineStyle = .single
|
||||||
|
}
|
||||||
|
result.append(AttributedString(matchText).mergingAttributes(tagStyle))
|
||||||
|
} else if type == "cashu" || type == "lightning" || type == "bolt11" || type == "lnurl" {
|
||||||
|
var spacer = AttributeContainer()
|
||||||
|
spacer.foregroundColor = baseColor
|
||||||
|
spacer.font = isSelf
|
||||||
|
? .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: design
|
||||||
|
)
|
||||||
|
if type == "url" {
|
||||||
|
matchStyle.foregroundColor = isSelf ? .orange : .blue
|
||||||
|
matchStyle.underlineStyle = .single
|
||||||
|
if let url = URL(string: matchText) {
|
||||||
|
matchStyle.link = url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result.append(AttributedString(matchText).mergingAttributes(matchStyle))
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastEnd < swiftRange.upperBound {
|
||||||
|
lastEnd = swiftRange.upperBound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastEnd < content.endIndex {
|
||||||
|
let remainingText = String(content[lastEnd...])
|
||||||
|
var remainingStyle = AttributeContainer()
|
||||||
|
remainingStyle.foregroundColor = baseColor
|
||||||
|
remainingStyle.font = isSelf
|
||||||
|
? .bitchatSystem(size: 14, weight: .bold, design: design)
|
||||||
|
: .bitchatSystem(size: 14, design: design)
|
||||||
|
if isMentioned {
|
||||||
|
remainingStyle.font = remainingStyle.font?.bold()
|
||||||
|
}
|
||||||
|
result.append(AttributedString(remainingText).mergingAttributes(remainingStyle))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
||||||
|
var timestampStyle = AttributeContainer()
|
||||||
|
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
||||||
|
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: 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: design)
|
||||||
|
result.append(timestamp.mergingAttributes(timestampStyle))
|
||||||
|
}
|
||||||
|
|
||||||
|
message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
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:"),
|
||||||
|
let myGeo = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
||||||
|
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
||||||
|
}
|
||||||
|
return spid == viewModel.meshService.myPeerID
|
||||||
|
}
|
||||||
|
if message.sender == viewModel.nickname { return true }
|
||||||
|
if message.sender.hasPrefix(viewModel.nickname + "#") { return true }
|
||||||
|
return false
|
||||||
|
}()
|
||||||
|
|
||||||
|
let isDark = colorScheme == .dark
|
||||||
|
let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark)
|
||||||
|
|
||||||
|
if message.sender == "system" {
|
||||||
|
var style = AttributeContainer()
|
||||||
|
style.foregroundColor = baseColor
|
||||||
|
style.font = .bitchatSystem(size: 14, weight: .medium, design: design)
|
||||||
|
return AttributedString(message.sender).mergingAttributes(style)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = AttributedString()
|
||||||
|
let (baseName, suffix) = message.sender.splitSuffix()
|
||||||
|
var senderStyle = AttributeContainer()
|
||||||
|
senderStyle.foregroundColor = baseColor
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
result.append(AttributedString("<@").mergingAttributes(senderStyle))
|
||||||
|
result.append(AttributedString(baseName).mergingAttributes(senderStyle))
|
||||||
|
if !suffix.isEmpty {
|
||||||
|
var suffixStyle = senderStyle
|
||||||
|
suffixStyle.foregroundColor = baseColor.opacity(0.6)
|
||||||
|
result.append(AttributedString(suffix).mergingAttributes(suffixStyle))
|
||||||
|
}
|
||||||
|
result.append(AttributedString("> ").mergingAttributes(senderStyle))
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSelfMessage(_ message: BitchatMessage) -> Bool {
|
||||||
|
if let spid = message.senderPeerID {
|
||||||
|
if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat {
|
||||||
|
let myGeo: NostrIdentity? = {
|
||||||
|
if let cached = viewModel.cachedGeohashIdentity, cached.geohash == channel.geohash {
|
||||||
|
return cached.identity
|
||||||
|
}
|
||||||
|
if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
||||||
|
viewModel.cachedGeohashIdentity = (channel.geohash, identity)
|
||||||
|
return identity
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}()
|
||||||
|
if let myGeo {
|
||||||
|
return spid == PeerID(nostr: myGeo.publicKeyHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return spid == viewModel.meshService.myPeerID
|
||||||
|
}
|
||||||
|
if message.sender == viewModel.nickname { return true }
|
||||||
|
if message.sender.hasPrefix(viewModel.nickname + "#") { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func senderColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||||||
|
peerColor(for: message, isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func peerURL(for peerID: PeerID) -> URL? {
|
||||||
|
URL(string: "bitchat://user/\(peerID.toPercentEncoded())")
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color {
|
||||||
|
getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color {
|
||||||
|
getPeerPaletteColor(for: peerID, isDark: isDark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ChatMessageFormatter {
|
||||||
|
func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
|
||||||
|
if let spid = message.senderPeerID {
|
||||||
|
if spid.isGeoChat || spid.isGeoDM {
|
||||||
|
let full = viewModel.nostrKeyMapping[spid]?.lowercased() ?? spid.bare.lowercased()
|
||||||
|
return getNostrPaletteColor(for: full, isDark: isDark)
|
||||||
|
} else if spid.id.count == 16 {
|
||||||
|
return getPeerPaletteColor(for: spid, isDark: isDark)
|
||||||
|
} else {
|
||||||
|
return getPeerPaletteColor(for: PeerID(str: spid.id.lowercased()), isDark: isDark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Color(peerSeed: message.sender.lowercased(), isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func meshSeed(for peerID: PeerID) -> String {
|
||||||
|
if let full = viewModel.cachedStablePeerID(for: peerID)?.id.lowercased() {
|
||||||
|
return "noise:" + full
|
||||||
|
}
|
||||||
|
return peerID.id.lowercased()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color {
|
||||||
|
if peerID == viewModel.meshService.myPeerID {
|
||||||
|
return .orange
|
||||||
|
}
|
||||||
|
|
||||||
|
meshPalette.ensurePalette(for: currentMeshPaletteSeeds())
|
||||||
|
if let color = meshPalette.color(for: peerID.id, isDark: isDark) {
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
return Color(peerSeed: meshSeed(for: peerID), isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentMeshPaletteSeeds() -> [String: String] {
|
||||||
|
let myID = viewModel.meshService.myPeerID
|
||||||
|
var seeds: [String: String] = [:]
|
||||||
|
for peer in viewModel.allPeers where peer.peerID != myID {
|
||||||
|
seeds[peer.peerID.id] = meshSeed(for: peer.peerID)
|
||||||
|
}
|
||||||
|
return seeds
|
||||||
|
}
|
||||||
|
|
||||||
|
func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color {
|
||||||
|
let myHex = currentGeohashIdentityHex()
|
||||||
|
if let myHex, pubkeyHexLowercased == myHex {
|
||||||
|
return .orange
|
||||||
|
}
|
||||||
|
|
||||||
|
nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex))
|
||||||
|
if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) {
|
||||||
|
return color
|
||||||
|
}
|
||||||
|
return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] {
|
||||||
|
var seeds: [String: String] = [:]
|
||||||
|
let excluded = myHex ?? ""
|
||||||
|
for person in viewModel.visibleGeohashPeople() where person.id != excluded {
|
||||||
|
seeds[person.id] = "nostr:" + person.id
|
||||||
|
}
|
||||||
|
return seeds
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentGeohashIdentityHex() -> String? {
|
||||||
|
if case .location(let channel) = viewModel.activeChannel,
|
||||||
|
let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) {
|
||||||
|
return identity.publicKeyHex.lowercased()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The surface `ChatNostrCoordinator` needs from its owner.
|
||||||
|
///
|
||||||
|
/// Inherits the component contexts (`GeohashSubscriptionContext`,
|
||||||
|
/// `NostrInboundPipelineContext`, `GeoPresenceContext`) so a single object —
|
||||||
|
/// `ChatViewModel` in production, one mock in tests — can back the whole
|
||||||
|
/// Nostr stack. The members declared here are only the residual
|
||||||
|
/// favorites/ack glue the slimmed coordinator still owns.
|
||||||
|
@MainActor
|
||||||
|
protocol ChatNostrContext: GeohashSubscriptionContext, NostrInboundPipelineContext, GeoPresenceContext {
|
||||||
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
|
var nostrKeyMapping: [PeerID: String] { get }
|
||||||
|
func startPrivateChat(with peerID: PeerID)
|
||||||
|
func visibleGeohashPeople() -> [GeoPerson]
|
||||||
|
|
||||||
|
// MARK: Routing & acknowledgements (shared with `ChatPrivateConversationContext`)
|
||||||
|
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||||
|
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||||
|
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||||
|
|
||||||
|
// MARK: Favorites & notifications (shared with the other contexts)
|
||||||
|
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||||
|
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||||
|
/// Adds (or updates) a favorite in the favorites store.
|
||||||
|
func addFavorite(noiseKey: Data, nostrPublicKey: String?, nickname: String)
|
||||||
|
/// Posts a generic local user notification.
|
||||||
|
func postLocalNotification(title: String, body: String, identifier: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ChatViewModel: ChatNostrContext {
|
||||||
|
// All requirements — including the component-context witnesses declared
|
||||||
|
// in `GeohashSubscriptionManager.swift`, `NostrInboundPipeline.swift`,
|
||||||
|
// `GeoPresenceTracker.swift`, and the favorites/notification witnesses in
|
||||||
|
// `ChatPrivateConversationCoordinator.swift`,
|
||||||
|
// `ChatPeerIdentityCoordinator.swift`, and
|
||||||
|
// `ChatVerificationCoordinator.swift` — already exist on `ChatViewModel`.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Thin facade over the Nostr stack: owns and wires the three components and
|
||||||
|
/// keeps the residual favorites/ack glue that fits none of them.
|
||||||
|
///
|
||||||
|
/// - `subscriptions`: relay lifecycle and subscription IDs
|
||||||
|
/// (`GeohashSubscriptionManager`)
|
||||||
|
/// - `inbound`: the hot event -> message/payload pipeline
|
||||||
|
/// (`NostrInboundPipeline`)
|
||||||
|
/// - `presence`: teleport marking, sampling dedup, notification cooldown
|
||||||
|
/// (`GeoPresenceTracker`)
|
||||||
|
final class ChatNostrCoordinator {
|
||||||
|
private 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
|
||||||
|
func sendDeliveryAckViaNostrEmbedded(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
wasReadBefore: Bool,
|
||||||
|
senderPubkey: String,
|
||||||
|
key: Data?
|
||||||
|
) {
|
||||||
|
guard let context else { return }
|
||||||
|
if let _ = key {
|
||||||
|
if let identity = context.currentNostrIdentity() {
|
||||||
|
context.sendGeohashDeliveryAck(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 && context.selectedPrivateChatPeer == message.senderPeerID {
|
||||||
|
if let _ = key {
|
||||||
|
if let identity = context.currentNostrIdentity() {
|
||||||
|
context.sendGeohashReadReceipt(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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func handleFavoriteNotification(content: String, from nostrPubkey: String) {
|
||||||
|
guard let context else { return }
|
||||||
|
guard let senderNoiseKey = inbound.findNoiseKey(for: nostrPubkey) else { return }
|
||||||
|
|
||||||
|
let isFavorite = content.contains("FAVORITE:TRUE")
|
||||||
|
let senderNickname = content.components(separatedBy: "|").last ?? "Unknown"
|
||||||
|
|
||||||
|
if isFavorite {
|
||||||
|
context.addFavorite(
|
||||||
|
noiseKey: senderNoiseKey,
|
||||||
|
nostrPublicKey: nostrPubkey,
|
||||||
|
nickname: senderNickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var extractedNostrPubkey: String?
|
||||||
|
if let range = content.range(of: "NPUB:") {
|
||||||
|
let suffix = content[range.upperBound...]
|
||||||
|
let parts = suffix.components(separatedBy: "|")
|
||||||
|
if let key = parts.first {
|
||||||
|
extractedNostrPubkey = String(key)
|
||||||
|
}
|
||||||
|
} else if content.contains(":") {
|
||||||
|
let parts = content.components(separatedBy: ":")
|
||||||
|
if parts.count >= 3 {
|
||||||
|
extractedNostrPubkey = String(parts[2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.info("📝 Received favorite notification from \(senderNickname): \(isFavorite)", category: .session)
|
||||||
|
|
||||||
|
if isFavorite && extractedNostrPubkey != nil {
|
||||||
|
SecureLogger.info(
|
||||||
|
"💾 Storing Nostr key association for \(senderNickname): \(extractedNostrPubkey!.prefix(16))...",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
context.addFavorite(
|
||||||
|
noiseKey: senderNoiseKey,
|
||||||
|
nostrPublicKey: extractedNostrPubkey,
|
||||||
|
nickname: senderNickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.postLocalNotification(
|
||||||
|
title: isFavorite ? "New Favorite" : "Favorite Removed",
|
||||||
|
body: "\(senderNickname) \(isFavorite ? "favorited" : "unfavorited") you",
|
||||||
|
identifier: "fav-\(UUID().uuidString)"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func sendFavoriteNotificationViaNostr(noisePublicKey: Data, isFavorite: Bool) {
|
||||||
|
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)
|
||||||
|
context.routeFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func nostrPubkeyForDisplayName(_ name: String) -> String? {
|
||||||
|
guard let context else { return nil }
|
||||||
|
for person in context.visibleGeohashPeople() where person.displayName == name {
|
||||||
|
return person.id
|
||||||
|
}
|
||||||
|
for (pub, nick) in context.geoNicknames where nick == name {
|
||||||
|
return pub
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func startGeohashDM(withPubkeyHex hex: String) {
|
||||||
|
guard let context else { return }
|
||||||
|
let convKey = PeerID(nostr_: hex)
|
||||||
|
context.registerNostrKeyMapping(hex, for: convKey)
|
||||||
|
context.startPrivateChat(with: convKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func fullNostrHex(forSenderPeerID senderID: PeerID) -> String? {
|
||||||
|
guard let context else { return nil }
|
||||||
|
return context.nostrKeyMapping[senderID]
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func geohashDisplayName(for convKey: PeerID) -> String {
|
||||||
|
guard let context else { return convKey.bare }
|
||||||
|
guard let full = context.nostrKeyMapping[convKey] else {
|
||||||
|
return convKey.bare
|
||||||
|
}
|
||||||
|
return context.displayNameForNostrPubkey(full)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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 context: any ChatOutgoingContext
|
||||||
|
|
||||||
|
init(context: any ChatOutgoingContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMessage(_ content: String) {
|
||||||
|
guard let trimmed = content.trimmedOrNilIfEmpty else { return }
|
||||||
|
|
||||||
|
if content.hasPrefix("/") {
|
||||||
|
Task { @MainActor [weak context = self.context] in
|
||||||
|
context?.handleCommand(content)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if context.selectedPrivateChatPeer != nil {
|
||||||
|
context.updatePrivateChatPeerIfNeeded()
|
||||||
|
|
||||||
|
if let selectedPeer = context.selectedPrivateChatPeer {
|
||||||
|
context.sendPrivateMessage(content, to: selectedPeer)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let mentions = context.parseMentions(from: content)
|
||||||
|
let preparedMessage = preparePublicMessage(content: content, trimmed: trimmed, mentions: mentions)
|
||||||
|
guard let preparedMessage else { return }
|
||||||
|
|
||||||
|
appendLocalEcho(preparedMessage.message)
|
||||||
|
routePublicMessage(
|
||||||
|
originalContent: content,
|
||||||
|
mentions: mentions,
|
||||||
|
geoContext: preparedMessage.geoContext,
|
||||||
|
messageID: preparedMessage.message.id,
|
||||||
|
timestamp: preparedMessage.message.timestamp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ChatOutgoingCoordinator {
|
||||||
|
func preparePublicMessage(
|
||||||
|
content: String,
|
||||||
|
trimmed: String,
|
||||||
|
mentions: [String]
|
||||||
|
) -> (message: BitchatMessage, geoContext: ChatViewModel.GeoOutgoingContext?)? {
|
||||||
|
var geoContext: ChatViewModel.GeoOutgoingContext?
|
||||||
|
var displaySender = context.nickname
|
||||||
|
var localSenderPeerID = context.myPeerID
|
||||||
|
var messageID: String?
|
||||||
|
var messageTimestamp = Date()
|
||||||
|
|
||||||
|
switch context.activeChannel {
|
||||||
|
case .mesh:
|
||||||
|
break
|
||||||
|
|
||||||
|
case .location(let channel):
|
||||||
|
do {
|
||||||
|
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||||
|
let suffix = String(identity.publicKeyHex.suffix(4))
|
||||||
|
displaySender = context.nickname + "#" + suffix
|
||||||
|
localSenderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||||
|
|
||||||
|
let teleported = context.isTeleported
|
||||||
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
|
content: trimmed,
|
||||||
|
geohash: channel.geohash,
|
||||||
|
senderIdentity: identity,
|
||||||
|
nickname: context.nickname,
|
||||||
|
teleported: teleported
|
||||||
|
)
|
||||||
|
|
||||||
|
messageID = event.id
|
||||||
|
messageTimestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||||
|
geoContext = (
|
||||||
|
channel: channel,
|
||||||
|
event: event,
|
||||||
|
identity: identity,
|
||||||
|
teleported: teleported
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("❌ Failed to prepare geohash message: \(error)", category: .session)
|
||||||
|
context.addSystemMessage(
|
||||||
|
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: displaySender,
|
||||||
|
content: trimmed,
|
||||||
|
timestamp: messageTimestamp,
|
||||||
|
isRelay: false,
|
||||||
|
senderPeerID: localSenderPeerID,
|
||||||
|
mentions: mentions.isEmpty ? nil : mentions
|
||||||
|
)
|
||||||
|
|
||||||
|
return (message, geoContext)
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendLocalEcho(_ message: BitchatMessage) {
|
||||||
|
context.appendPublicMessage(message, to: ConversationID(channelID: context.activeChannel))
|
||||||
|
|
||||||
|
let contentKey = context.normalizedContentKey(message.content)
|
||||||
|
context.recordContentKey(contentKey, timestamp: message.timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func routePublicMessage(
|
||||||
|
originalContent: String,
|
||||||
|
mentions: [String],
|
||||||
|
geoContext: ChatViewModel.GeoOutgoingContext?,
|
||||||
|
messageID: String,
|
||||||
|
timestamp: Date
|
||||||
|
) {
|
||||||
|
switch context.activeChannel {
|
||||||
|
case .mesh:
|
||||||
|
context.recordPublicActivity(forChannelKey: "mesh")
|
||||||
|
context.sendMeshMessage(
|
||||||
|
originalContent,
|
||||||
|
mentions: mentions,
|
||||||
|
messageID: messageID,
|
||||||
|
timestamp: timestamp
|
||||||
|
)
|
||||||
|
|
||||||
|
case .location(let channel):
|
||||||
|
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)
|
||||||
|
context.addSystemMessage(
|
||||||
|
String(localized: "system.location.send_failed", comment: "System message when a location channel send fails")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Task { @MainActor [weak context = self.context] in
|
||||||
|
context?.sendGeohash(context: geoContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,662 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import CoreBluetooth
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// 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)
|
||||||
|
|
||||||
|
// 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 = 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 = context.privateChats
|
||||||
|
.map { (id: $0.key, ts: $0.value.last?.timestamp ?? Date.distantPast) }
|
||||||
|
.sorted { $0.ts > $1.ts }
|
||||||
|
if let target = recent.first?.id {
|
||||||
|
startPrivateChat(with: target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func isPeerBlocked(_ peerID: PeerID) -> Bool {
|
||||||
|
context.unifiedIsBlocked(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func hasUnreadMessages(for peerID: PeerID) -> Bool {
|
||||||
|
var noiseKeyPeerID: PeerID?
|
||||||
|
var nostrPeerID: PeerID?
|
||||||
|
|
||||||
|
if let peer = context.unifiedPeer(for: peerID) {
|
||||||
|
noiseKeyPeerID = PeerID(hexData: peer.noisePublicKey)
|
||||||
|
if let nostrHex = peer.nostrPublicKey {
|
||||||
|
nostrPeerID = PeerID(nostr_: nostrHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let unreadContext = ChatUnreadPeerContext(
|
||||||
|
peerID: peerID,
|
||||||
|
noiseKeyPeerID: noiseKeyPeerID,
|
||||||
|
nostrPeerID: nostrPeerID,
|
||||||
|
nickname: context.peerNickname(for: peerID)
|
||||||
|
)
|
||||||
|
|
||||||
|
return ChatUnreadStateResolver.hasUnreadMessages(
|
||||||
|
for: unreadContext,
|
||||||
|
unreadPrivateMessages: context.unreadPrivateMessages,
|
||||||
|
privateChats: context.privateChats
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func toggleFavorite(peerID: PeerID) {
|
||||||
|
if let noisePublicKey = peerID.noiseKey {
|
||||||
|
toggleFavoriteForNoiseKey(noisePublicKey, peerID: peerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.unifiedToggleFavorite(peerID)
|
||||||
|
context.notifyUIChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func isFavorite(peerID: PeerID) -> Bool {
|
||||||
|
if let noisePublicKey = peerID.noiseKey {
|
||||||
|
return context.favoriteRelationship(forNoiseKey: noisePublicKey)?.isFavorite ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.unifiedPeer(for: peerID)?.isFavorite ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func updatePrivateChatPeerIfNeeded() {
|
||||||
|
guard let chatFingerprint = context.selectedPrivateChatFingerprint,
|
||||||
|
let currentPeerID = currentPeerID(forFingerprint: chatFingerprint) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let oldPeerID = context.selectedPrivateChatPeer, oldPeerID != currentPeerID {
|
||||||
|
migrateChatState(from: oldPeerID, to: currentPeerID)
|
||||||
|
context.selectedPrivateChatPeer = currentPeerID
|
||||||
|
} else if context.selectedPrivateChatPeer == nil {
|
||||||
|
context.selectedPrivateChatPeer = currentPeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
context.markPrivateChatRead(currentPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func startPrivateChat(with peerID: PeerID) {
|
||||||
|
guard peerID != context.myPeerID else { return }
|
||||||
|
|
||||||
|
let peerNickname = context.peerNickname(for: peerID) ?? "unknown"
|
||||||
|
|
||||||
|
if context.unifiedIsBlocked(peerID) {
|
||||||
|
context.addSystemMessage(
|
||||||
|
String(
|
||||||
|
format: String(
|
||||||
|
localized: "system.chat.blocked",
|
||||||
|
comment: "System message when starting chat fails because peer is blocked"
|
||||||
|
),
|
||||||
|
locale: .current,
|
||||||
|
peerNickname
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if let peer = context.unifiedPeer(for: peerID),
|
||||||
|
peer.isFavorite && !peer.theyFavoritedUs && !peer.isConnected {
|
||||||
|
context.addSystemMessage(
|
||||||
|
String(
|
||||||
|
format: String(
|
||||||
|
localized: "system.chat.requires_favorite",
|
||||||
|
comment: "System message when mutual favorite requirement blocks chat"
|
||||||
|
),
|
||||||
|
locale: .current,
|
||||||
|
peerNickname
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = context.consolidatePrivateMessages(for: peerID, peerNickname: peerNickname)
|
||||||
|
|
||||||
|
if !peerID.isGeoDM && !peerID.isGeoChat {
|
||||||
|
switch context.noiseSessionState(for: peerID) {
|
||||||
|
case .none, .failed:
|
||||||
|
context.triggerHandshake(with: peerID)
|
||||||
|
case .handshakeQueued, .handshaking, .established:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.syncReadReceiptsForSentMessages(for: peerID)
|
||||||
|
|
||||||
|
if let fingerprint = getFingerprint(for: peerID) {
|
||||||
|
context.setStoredFingerprint(fingerprint, for: peerID)
|
||||||
|
context.selectedPrivateChatFingerprint = fingerprint
|
||||||
|
} else {
|
||||||
|
context.selectedPrivateChatFingerprint = nil
|
||||||
|
}
|
||||||
|
context.beginPrivateChatSession(with: peerID)
|
||||||
|
context.markPrivateMessagesAsRead(from: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func endPrivateChat() {
|
||||||
|
context.selectedPrivateChatPeer = nil
|
||||||
|
context.selectedPrivateChatFingerprint = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func handlePeerStatusUpdate() {
|
||||||
|
updatePrivateChatPeerIfNeeded()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleFavoriteStatusChanged(_ notification: Notification) {
|
||||||
|
guard let peerPublicKey = notification.userInfo?["peerPublicKey"] as? Data else { return }
|
||||||
|
|
||||||
|
Task { @MainActor [weak context = self.context] in
|
||||||
|
guard let context else { return }
|
||||||
|
|
||||||
|
if let isKeyUpdate = notification.userInfo?["isKeyUpdate"] as? Bool,
|
||||||
|
isKeyUpdate,
|
||||||
|
let oldKey = notification.userInfo?["oldPeerPublicKey"] as? Data {
|
||||||
|
migrateNoiseKeyUpdate(
|
||||||
|
oldPeerID: PeerID(hexData: oldKey),
|
||||||
|
newPeerID: PeerID(hexData: peerPublicKey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePrivateChatPeerIfNeeded()
|
||||||
|
|
||||||
|
if let isFavorite = notification.userInfo?["isFavorite"] as? Bool {
|
||||||
|
let peerID = PeerID(hexData: peerPublicKey)
|
||||||
|
let action = isFavorite ? "favorited" : "unfavorited"
|
||||||
|
let peerNickname = favoriteNotificationNickname(for: peerID, peerPublicKey: peerPublicKey)
|
||||||
|
context.addSystemMessage("\(peerNickname) \(action) you")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func updateEncryptionStatusForPeers() {
|
||||||
|
for peerID in context.connectedPeers {
|
||||||
|
updateEncryptionStatus(for: peerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func updateEncryptionStatus(for peerID: 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 {
|
||||||
|
context.setEncryptionStatus(nil, for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateEncryptionCache(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func getEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
|
||||||
|
if let cachedStatus = context.cachedEncryptionStatus(for: peerID) {
|
||||||
|
return cachedStatus
|
||||||
|
}
|
||||||
|
|
||||||
|
let hasEverEstablishedSession = getFingerprint(for: peerID) != nil
|
||||||
|
let sessionState = context.noiseSessionState(for: peerID)
|
||||||
|
|
||||||
|
let status: EncryptionStatus
|
||||||
|
switch sessionState {
|
||||||
|
case .established:
|
||||||
|
status = verifiedEncryptionStatus(for: peerID)
|
||||||
|
case .handshaking, .handshakeQueued:
|
||||||
|
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noiseHandshaking
|
||||||
|
case .none:
|
||||||
|
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .noHandshake
|
||||||
|
case .failed:
|
||||||
|
status = hasEverEstablishedSession ? verifiedEncryptionStatus(for: peerID) : .none
|
||||||
|
}
|
||||||
|
|
||||||
|
context.setCachedEncryptionStatus(status, for: peerID)
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func invalidateEncryptionCache(for peerID: PeerID? = nil) {
|
||||||
|
context.invalidateStoredEncryptionCache(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func getFingerprint(for peerID: PeerID) -> String? {
|
||||||
|
context.unifiedFingerprint(for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func resolveNickname(for peerID: PeerID) -> String {
|
||||||
|
guard !peerID.isEmpty else { return "unknown" }
|
||||||
|
|
||||||
|
if !peerID.isHex {
|
||||||
|
return peerID.id
|
||||||
|
}
|
||||||
|
|
||||||
|
if let nickname = context.meshPeerNicknames()[peerID] {
|
||||||
|
return nickname
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fingerprint = getFingerprint(for: peerID),
|
||||||
|
let identity = context.socialIdentity(forFingerprint: fingerprint) {
|
||||||
|
if let petname = identity.localPetname {
|
||||||
|
return petname
|
||||||
|
}
|
||||||
|
return identity.claimedNickname
|
||||||
|
}
|
||||||
|
|
||||||
|
let prefixLength = min(4, peerID.id.count)
|
||||||
|
let prefix = String(peerID.id.prefix(prefixLength))
|
||||||
|
return prefix.starts(with: "anon") ? "peer\(prefix)" : "anon\(prefix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func getMyFingerprint() -> String {
|
||||||
|
context.noiseIdentityFingerprint()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func getPeerIDForNickname(_ nickname: String) -> PeerID? {
|
||||||
|
switch context.activeChannel {
|
||||||
|
case .location:
|
||||||
|
if nickname.contains("#"),
|
||||||
|
let person = context.visibleGeohashPeople()
|
||||||
|
.first(where: { $0.displayName == nickname }) {
|
||||||
|
let conversationKey = PeerID(nostr_: person.id)
|
||||||
|
context.registerNostrKeyMapping(person.id, for: conversationKey)
|
||||||
|
return conversationKey
|
||||||
|
}
|
||||||
|
|
||||||
|
let base = nickname
|
||||||
|
.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
|
||||||
|
.first
|
||||||
|
.map(String.init)?
|
||||||
|
.lowercased() ?? nickname.lowercased()
|
||||||
|
if let pubkey = context.geoNicknames.first(where: { $0.value.lowercased() == base })?.key {
|
||||||
|
let conversationKey = PeerID(nostr_: pubkey)
|
||||||
|
context.registerNostrKeyMapping(pubkey, for: conversationKey)
|
||||||
|
return conversationKey
|
||||||
|
}
|
||||||
|
|
||||||
|
case .mesh:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.unifiedPeerID(forNickname: nickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func nicknameForPeer(_ peerID: PeerID) -> String {
|
||||||
|
if let name = context.peerNickname(for: peerID) {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if let favorite = context.favoriteRelationship(forPeerID: peerID),
|
||||||
|
!favorite.peerNickname.isEmpty {
|
||||||
|
return favorite.peerNickname
|
||||||
|
}
|
||||||
|
if let noiseKey = Data(hexString: peerID.id),
|
||||||
|
let favorite = context.favoriteRelationship(forNoiseKey: noiseKey),
|
||||||
|
!favorite.peerNickname.isEmpty {
|
||||||
|
return favorite.peerNickname
|
||||||
|
}
|
||||||
|
return "user"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ChatPeerIdentityCoordinator {
|
||||||
|
@MainActor
|
||||||
|
func currentPeerID(forFingerprint fingerprint: String) -> PeerID? {
|
||||||
|
for peerID in context.connectedPeers where getFingerprint(for: peerID) == fingerprint {
|
||||||
|
return peerID
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func migrateChatState(from oldPeerID: PeerID, to newPeerID: PeerID) {
|
||||||
|
// 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) {
|
||||||
|
// 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 !context.privateMessages(for: oldPeerID).isEmpty {
|
||||||
|
SecureLogger.debug("📱 Migrating private chat messages from \(oldPeerID) to \(newPeerID)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
migrateChatState(from: oldPeerID, to: newPeerID)
|
||||||
|
|
||||||
|
if wasSelected {
|
||||||
|
context.selectedPrivateChatPeer = newPeerID
|
||||||
|
}
|
||||||
|
|
||||||
|
if let fingerprint = context.migrateFingerprintMapping(
|
||||||
|
from: oldPeerID,
|
||||||
|
to: newPeerID,
|
||||||
|
fallback: getFingerprint(for: newPeerID)
|
||||||
|
) {
|
||||||
|
if context.selectedPrivateChatPeer == newPeerID {
|
||||||
|
context.selectedPrivateChatFingerprint = fingerprint
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func favoriteNotificationNickname(for peerID: PeerID, peerPublicKey: Data) -> String {
|
||||||
|
if let nickname = context.peerNickname(for: peerID) {
|
||||||
|
return nickname
|
||||||
|
}
|
||||||
|
if let favorite = context.favoriteRelationship(forNoiseKey: peerPublicKey) {
|
||||||
|
return favorite.peerNickname
|
||||||
|
}
|
||||||
|
return "Unknown"
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func verifiedEncryptionStatus(for peerID: PeerID) -> EncryptionStatus {
|
||||||
|
if let fingerprint = getFingerprint(for: peerID),
|
||||||
|
context.isVerifiedFingerprint(fingerprint) {
|
||||||
|
return .noiseVerified
|
||||||
|
}
|
||||||
|
return .noiseSecured
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func toggleFavoriteForNoiseKey(_ noisePublicKey: Data, peerID: PeerID) {
|
||||||
|
if let ephemeralID = context.ephemeralPeerID(forNoiseKey: noisePublicKey) {
|
||||||
|
context.unifiedToggleFavorite(ephemeralID)
|
||||||
|
context.notifyUIChanged()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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: context.bridgedNostrPublicKey(for: noisePublicKey)
|
||||||
|
)
|
||||||
|
|
||||||
|
switch plan.persistenceAction {
|
||||||
|
case .add(let nickname, let nostrKey):
|
||||||
|
context.addFavorite(
|
||||||
|
noiseKey: noisePublicKey,
|
||||||
|
nostrPublicKey: nostrKey,
|
||||||
|
nickname: nickname
|
||||||
|
)
|
||||||
|
|
||||||
|
case .remove:
|
||||||
|
context.removeFavorite(noiseKey: noisePublicKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.notifyUIChanged()
|
||||||
|
|
||||||
|
if case .send(let isFavorite) = plan.notification {
|
||||||
|
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] ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
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 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(
|
||||||
|
context: any ChatPeerListContext,
|
||||||
|
notificationCooldownSeconds: TimeInterval = TransportConfig.networkNotificationCooldownSeconds
|
||||||
|
) {
|
||||||
|
self.context = context
|
||||||
|
self.notificationCooldownSeconds = notificationCooldownSeconds
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
networkResetTimer?.invalidate()
|
||||||
|
networkEmptyTimer?.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
func didUpdatePeerList(_ peers: [PeerID]) {
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.handlePeerListUpdate(peers)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ChatPeerListCoordinator {
|
||||||
|
@MainActor
|
||||||
|
func handlePeerListUpdate(_ peers: [PeerID]) {
|
||||||
|
context.isConnected = !peers.isEmpty
|
||||||
|
cleanupStaleUnreadPeerIDs()
|
||||||
|
|
||||||
|
let meshPeers = peers.filter { peerID in
|
||||||
|
context.isPeerConnected(peerID) || context.isPeerReachable(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNetworkAvailability(meshPeers)
|
||||||
|
|
||||||
|
for peerID in peers {
|
||||||
|
context.registerEphemeralSession(peerID: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.updateEncryptionStatusForPeers()
|
||||||
|
|
||||||
|
if context.hasTrackedPrivateChatSelection {
|
||||||
|
context.updatePrivateChatPeerIfNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func handleNetworkAvailability(_ meshPeers: [PeerID]) {
|
||||||
|
let meshPeerSet = Set(meshPeers)
|
||||||
|
|
||||||
|
if meshPeerSet.isEmpty {
|
||||||
|
scheduleNetworkEmptyTimer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
invalidateNetworkEmptyTimer()
|
||||||
|
|
||||||
|
let newPeers = meshPeerSet.subtracting(recentlySeenPeers)
|
||||||
|
// 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 cameFromEmpty = meshWasEmpty
|
||||||
|
meshWasEmpty = false
|
||||||
|
|
||||||
|
guard cameFromEmpty, !newPeers.isEmpty else { return }
|
||||||
|
|
||||||
|
if Date().timeIntervalSince(lastNetworkNotificationTime) >= notificationCooldownSeconds {
|
||||||
|
lastNetworkNotificationTime = Date()
|
||||||
|
context.notifyNetworkAvailable(peerCount: meshPeers.count)
|
||||||
|
SecureLogger.info(
|
||||||
|
"👥 Sent bitchatters nearby notification for \(meshPeers.count) mesh peers (new: \(newPeers.count))",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleNetworkResetTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func cleanupStaleUnreadPeerIDs() {
|
||||||
|
let currentPeerIDs = Set(context.unifiedPeers.map(\.peerID))
|
||||||
|
let staleIDs = context.unreadPrivateMessages.subtracting(currentPeerIDs)
|
||||||
|
|
||||||
|
guard !staleIDs.isEmpty else {
|
||||||
|
context.cleanupOldReadReceipts()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var idsToRemove: [PeerID] = []
|
||||||
|
|
||||||
|
for staleID in staleIDs {
|
||||||
|
if staleID.isGeoDM, !context.privateMessages(for: staleID).isEmpty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if staleID.isNoiseKeyHex, !context.privateMessages(for: staleID).isEmpty {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
idsToRemove.append(staleID)
|
||||||
|
context.markPrivateChatRead(staleID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !idsToRemove.isEmpty {
|
||||||
|
SecureLogger.debug("🧹 Cleaned up \(idsToRemove.count) stale unread peer IDs", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.cleanupOldReadReceipts()
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func scheduleNetworkResetTimer() {
|
||||||
|
networkResetTimer?.invalidate()
|
||||||
|
networkResetTimer = Timer.scheduledTimer(withTimeInterval: networkResetGraceSeconds, repeats: false) { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { [weak self] in
|
||||||
|
self?.handleNetworkResetTimerFired()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func handleNetworkResetTimerFired() {
|
||||||
|
let activeMeshPeerCount = context.activeMeshPeerCount()
|
||||||
|
|
||||||
|
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 \(activeMeshPeerCount) mesh peers",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
networkResetTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func scheduleNetworkEmptyTimer() {
|
||||||
|
guard networkEmptyTimer == nil else { return }
|
||||||
|
|
||||||
|
networkEmptyTimer = Timer.scheduledTimer(
|
||||||
|
withTimeInterval: TransportConfig.uiMeshEmptyConfirmationSeconds,
|
||||||
|
repeats: false
|
||||||
|
) { [weak self] _ in
|
||||||
|
MainActor.assumeIsolated { [weak self] in
|
||||||
|
self?.handleNetworkEmptyTimerFired()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.debug("⏳ Mesh empty — waiting before resetting notification state", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func invalidateNetworkEmptyTimer() {
|
||||||
|
guard networkEmptyTimer != nil else { return }
|
||||||
|
networkEmptyTimer?.invalidate()
|
||||||
|
networkEmptyTimer = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
func handleNetworkEmptyTimerFired() {
|
||||||
|
let activeMeshPeerCount = context.activeMeshPeerCount()
|
||||||
|
|
||||||
|
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; \(activeMeshPeerCount) mesh peers detected again",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
networkEmptyTimer = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,889 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// The narrow surface `ChatPrivateConversationCoordinator` needs from its owner.
|
||||||
|
///
|
||||||
|
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
||||||
|
/// minimal context it actually uses instead of holding an `unowned` back-ref
|
||||||
|
/// to the whole `ChatViewModel`. This keeps the coordinator independently
|
||||||
|
/// testable (see `ChatPrivateConversationCoordinatorContextTests`) and makes
|
||||||
|
/// its true dependencies explicit. The surface is intentionally large — it
|
||||||
|
/// documents the coordinator's real coupling to private-chat state, peer
|
||||||
|
/// identity, and the routing/ack transports.
|
||||||
|
@MainActor
|
||||||
|
protocol ChatPrivateConversationContext: AnyObject {
|
||||||
|
// MARK: Conversation state
|
||||||
|
var privateChats: [PeerID: [BitchatMessage]] { get }
|
||||||
|
/// A single private chat's timeline. Witnessed by the store-direct
|
||||||
|
/// lookup on `ChatViewModel` (no `privateChats` dictionary build).
|
||||||
|
func privateMessages(for peerID: PeerID) -> [BitchatMessage]
|
||||||
|
var sentReadReceipts: Set<String> { get }
|
||||||
|
var unreadPrivateMessages: Set<PeerID> { get }
|
||||||
|
var selectedPrivateChatPeer: PeerID? { get }
|
||||||
|
var nickname: String { get }
|
||||||
|
var activeChannel: ChannelID { get }
|
||||||
|
var nostrKeyMapping: [PeerID: String] { get }
|
||||||
|
|
||||||
|
// MARK: Conversation store intents
|
||||||
|
// The sole mutation paths for private message state (single-writer
|
||||||
|
// `ConversationStore` ops; see docs/CONVERSATION-STORE-DESIGN.md).
|
||||||
|
/// Appends a private message in timestamp order; returns `false` on
|
||||||
|
/// duplicate message ID.
|
||||||
|
@discardableResult
|
||||||
|
func appendPrivateMessage(_ message: BitchatMessage, to peerID: PeerID) -> Bool
|
||||||
|
/// Replace-or-append a private message by ID, keeping its position.
|
||||||
|
func upsertPrivateMessage(_ message: BitchatMessage, in peerID: PeerID)
|
||||||
|
/// Applies a delivery status by message ID; returns `false` when the
|
||||||
|
/// message is unknown or the update would downgrade the status.
|
||||||
|
@discardableResult
|
||||||
|
func setPrivateDeliveryStatus(_ status: DeliveryStatus, forMessageID messageID: String, peerID: PeerID) -> Bool
|
||||||
|
func markPrivateChatUnread(_ peerID: PeerID)
|
||||||
|
func markPrivateChatRead(_ peerID: PeerID)
|
||||||
|
/// Removes the peer's chat entirely, including unread state.
|
||||||
|
func removePrivateChat(_ peerID: PeerID)
|
||||||
|
/// Moves all messages from `oldPeerID`'s chat into `newPeerID`'s chat
|
||||||
|
/// (dedup by ID, order preserved, unread carried, old chat removed).
|
||||||
|
func migratePrivateChat(from oldPeerID: PeerID, to newPeerID: PeerID)
|
||||||
|
/// `true` when any private chat contains a message with `messageID`.
|
||||||
|
func privateChatsContainMessage(withID messageID: String) -> Bool
|
||||||
|
/// `true` when `peerID`'s chat contains a message with `messageID`.
|
||||||
|
func privateChat(_ peerID: PeerID, containsMessageWithID messageID: String) -> Bool
|
||||||
|
|
||||||
|
/// Records that a read receipt is being sent for `messageID`.
|
||||||
|
/// Returns `false` when one was already recorded — the caller must skip sending.
|
||||||
|
@discardableResult
|
||||||
|
func markReadReceiptSent(_ messageID: String) -> Bool
|
||||||
|
/// Records that a GeoDM delivery ACK is being sent for `messageID`.
|
||||||
|
/// Returns `false` when one was already recorded — the caller must skip sending.
|
||||||
|
@discardableResult
|
||||||
|
func markGeoDeliveryAckSent(_ messageID: String) -> Bool
|
||||||
|
/// Moves the open private chat to `newPeerID` when the current selection is
|
||||||
|
/// one of the peer IDs being migrated away.
|
||||||
|
func handOffSelectedPrivateChat(from oldPeerIDs: [PeerID], to newPeerID: PeerID)
|
||||||
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
||||||
|
func notifyUIChanged()
|
||||||
|
|
||||||
|
// MARK: Peers & identity
|
||||||
|
var myPeerID: PeerID { get }
|
||||||
|
func peerNickname(for peerID: PeerID) -> String?
|
||||||
|
func isPeerConnected(_ peerID: PeerID) -> Bool
|
||||||
|
func isPeerReachable(_ peerID: PeerID) -> Bool
|
||||||
|
func isPeerBlocked(_ peerID: PeerID) -> Bool
|
||||||
|
func noisePublicKey(for peerID: PeerID) -> Data?
|
||||||
|
/// Resolves the ephemeral (short) peer ID for a known Noise public key, if connected.
|
||||||
|
func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID?
|
||||||
|
func getPeerIDForNickname(_ nickname: String) -> PeerID?
|
||||||
|
func getFingerprint(for peerID: PeerID) -> String?
|
||||||
|
func storedFingerprint(for peerID: PeerID) -> String?
|
||||||
|
func clearStoredFingerprint(for peerID: PeerID)
|
||||||
|
|
||||||
|
// MARK: Nostr identity
|
||||||
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
||||||
|
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String
|
||||||
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
|
||||||
|
func currentNostrIdentity() -> NostrIdentity?
|
||||||
|
|
||||||
|
// MARK: Routing & acknowledgements
|
||||||
|
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String)
|
||||||
|
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
|
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool)
|
||||||
|
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID)
|
||||||
|
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String)
|
||||||
|
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||||
|
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity)
|
||||||
|
func sendDeliveryAckViaNostrEmbedded(_ message: BitchatMessage, wasReadBefore: Bool, senderPubkey: String, key: Data?)
|
||||||
|
|
||||||
|
// MARK: System messages
|
||||||
|
func addSystemMessage(_ content: String)
|
||||||
|
func addMeshOnlySystemMessage(_ content: String)
|
||||||
|
|
||||||
|
// MARK: Favorites & notifications
|
||||||
|
/// The persisted favorite relationship for the peer's Noise static key, if any.
|
||||||
|
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship?
|
||||||
|
/// Persists that the peer favorited/unfavorited us (favorites store write).
|
||||||
|
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?)
|
||||||
|
/// Posts the incoming-private-message local notification.
|
||||||
|
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
extension ChatViewModel: ChatPrivateConversationContext {
|
||||||
|
// `privateChats` and `notifyUIChanged()` are shared requirements with
|
||||||
|
// `ChatDeliveryContext`; the single-writer intent ops (`markReadReceiptSent`,
|
||||||
|
// `markGeoDeliveryAckSent`, `handOffSelectedPrivateChat`) live next to their
|
||||||
|
// backing state in `ChatViewModel`. The remaining state members are
|
||||||
|
// satisfied by existing `ChatViewModel` properties and methods.
|
||||||
|
|
||||||
|
var myPeerID: PeerID { meshService.myPeerID }
|
||||||
|
|
||||||
|
func peerNickname(for peerID: PeerID) -> String? {
|
||||||
|
meshService.peerNickname(peerID: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPeerConnected(_ peerID: PeerID) -> Bool {
|
||||||
|
meshService.isPeerConnected(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPeerReachable(_ peerID: PeerID) -> Bool {
|
||||||
|
meshService.isPeerReachable(peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func noisePublicKey(for peerID: PeerID) -> Data? {
|
||||||
|
unifiedPeerService.getPeer(by: peerID)?.noisePublicKey
|
||||||
|
}
|
||||||
|
|
||||||
|
func ephemeralPeerID(forNoiseKey noiseKey: Data) -> PeerID? {
|
||||||
|
unifiedPeerService.peers.first(where: { $0.noisePublicKey == noiseKey })?.peerID
|
||||||
|
}
|
||||||
|
|
||||||
|
func storedFingerprint(for peerID: PeerID) -> String? {
|
||||||
|
peerIDToPublicKeyFingerprint[peerID]
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearStoredFingerprint(for peerID: PeerID) {
|
||||||
|
peerIdentityStore.setFingerprint(nil, for: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
||||||
|
identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
|
||||||
|
}
|
||||||
|
|
||||||
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||||
|
try idBridge.deriveIdentity(forGeohash: geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentNostrIdentity() -> NostrIdentity? {
|
||||||
|
try? idBridge.getCurrentNostrIdentity()
|
||||||
|
}
|
||||||
|
|
||||||
|
func routePrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
|
||||||
|
messageRouter.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
|
messageRouter.sendReadReceipt(receipt, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func routeFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
|
messageRouter.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendMeshReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
|
||||||
|
meshService.sendReadReceipt(receipt, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendGeohashPrivateMessage(_ content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||||
|
makeGeohashNostrTransport().sendPrivateMessageGeohash(
|
||||||
|
content: content,
|
||||||
|
toRecipientHex: recipientHex,
|
||||||
|
from: identity,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendGeohashDeliveryAck(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
|
makeGeohashNostrTransport().sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendGeohashReadReceipt(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||||
|
makeGeohashNostrTransport().sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSystemMessage(_ content: String) {
|
||||||
|
addSystemMessage(content, timestamp: Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
func favoriteRelationship(forNoiseKey noiseKey: Data) -> FavoritesPersistenceService.FavoriteRelationship? {
|
||||||
|
FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
func updatePeerFavoritedUs(noiseKey: Data, favorited: Bool, nickname: String, nostrPublicKey: String?) {
|
||||||
|
FavoritesPersistenceService.shared.updatePeerFavoritedUs(
|
||||||
|
peerNoisePublicKey: noiseKey,
|
||||||
|
favorited: favorited,
|
||||||
|
peerNickname: nickname,
|
||||||
|
peerNostrPublicKey: nostrPublicKey
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func notifyPrivateMessage(from senderName: String, message: String, peerID: PeerID) {
|
||||||
|
NotificationService.shared.sendPrivateMessageNotification(from: senderName, message: message, peerID: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeGeohashNostrTransport() -> NostrTransport {
|
||||||
|
let transport = NostrTransport(keychain: keychain, idBridge: idBridge)
|
||||||
|
transport.senderPeerID = meshService.myPeerID
|
||||||
|
return transport
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class ChatPrivateConversationCoordinator {
|
||||||
|
private unowned let context: any ChatPrivateConversationContext
|
||||||
|
|
||||||
|
init(context: any ChatPrivateConversationContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPrivateMessage(_ content: String, to peerID: PeerID) {
|
||||||
|
guard !content.isEmpty else { return }
|
||||||
|
|
||||||
|
if context.isPeerBlocked(peerID) {
|
||||||
|
let nickname = context.peerNickname(for: peerID) ?? "user"
|
||||||
|
context.addSystemMessage(
|
||||||
|
String(
|
||||||
|
format: String(localized: "system.dm.blocked_recipient", comment: "System message when attempting to message a blocked user"),
|
||||||
|
locale: .current,
|
||||||
|
nickname
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if peerID.isGeoDM {
|
||||||
|
sendGeohashDM(content, to: peerID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let noiseKey = Data(hexString: peerID.id) else { return }
|
||||||
|
let isConnected = context.isPeerConnected(peerID)
|
||||||
|
let isReachable = context.isPeerReachable(peerID)
|
||||||
|
let favoriteStatus = context.favoriteRelationship(forNoiseKey: noiseKey)
|
||||||
|
let isMutualFavorite = favoriteStatus?.isMutual ?? false
|
||||||
|
let hasNostrKey = favoriteStatus?.peerNostrPublicKey != nil
|
||||||
|
|
||||||
|
var recipientNickname = context.peerNickname(for: peerID)
|
||||||
|
if recipientNickname == nil && favoriteStatus != nil {
|
||||||
|
recipientNickname = favoriteStatus?.peerNickname
|
||||||
|
}
|
||||||
|
recipientNickname = recipientNickname ?? "user"
|
||||||
|
|
||||||
|
let messageID = UUID().uuidString
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: context.nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
originalSender: nil,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: recipientNickname,
|
||||||
|
senderPeerID: context.myPeerID,
|
||||||
|
mentions: nil,
|
||||||
|
deliveryStatus: .sending
|
||||||
|
)
|
||||||
|
|
||||||
|
context.appendPrivateMessage(message, to: peerID)
|
||||||
|
context.notifyUIChanged()
|
||||||
|
|
||||||
|
if isConnected || isReachable || (isMutualFavorite && hasNostrKey) {
|
||||||
|
context.routePrivateMessage(
|
||||||
|
content,
|
||||||
|
to: peerID,
|
||||||
|
recipientNickname: recipientNickname ?? "user",
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
|
||||||
|
} else {
|
||||||
|
context.setPrivateDeliveryStatus(
|
||||||
|
.failed(
|
||||||
|
reason: String(localized: "content.delivery.reason.unreachable", comment: "Failure reason when a peer is unreachable")
|
||||||
|
),
|
||||||
|
forMessageID: messageID,
|
||||||
|
peerID: peerID
|
||||||
|
)
|
||||||
|
let name = recipientNickname ?? "user"
|
||||||
|
context.addSystemMessage(
|
||||||
|
String(
|
||||||
|
format: String(localized: "system.dm.unreachable", comment: "System message when a recipient is unreachable"),
|
||||||
|
locale: .current,
|
||||||
|
name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendGeohashDM(_ content: String, to peerID: PeerID) {
|
||||||
|
guard case .location(let channel) = context.activeChannel else {
|
||||||
|
context.addSystemMessage(
|
||||||
|
String(localized: "system.location.not_in_channel", comment: "System message when attempting to send without being in a location channel")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let messageID = UUID().uuidString
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: messageID,
|
||||||
|
sender: context.nickname,
|
||||||
|
content: content,
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: context.nickname,
|
||||||
|
senderPeerID: context.myPeerID,
|
||||||
|
deliveryStatus: .sending
|
||||||
|
)
|
||||||
|
|
||||||
|
context.appendPrivateMessage(message, to: peerID)
|
||||||
|
context.notifyUIChanged()
|
||||||
|
|
||||||
|
guard let recipientHex = context.nostrKeyMapping[peerID] else {
|
||||||
|
context.setPrivateDeliveryStatus(
|
||||||
|
.failed(
|
||||||
|
reason: String(localized: "content.delivery.reason.unknown_recipient", comment: "Failure reason when the recipient is unknown")
|
||||||
|
),
|
||||||
|
forMessageID: messageID,
|
||||||
|
peerID: peerID
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if context.isNostrBlocked(pubkeyHexLowercased: recipientHex) {
|
||||||
|
context.setPrivateDeliveryStatus(
|
||||||
|
.failed(
|
||||||
|
reason: String(localized: "content.delivery.reason.blocked", comment: "Failure reason when the user is blocked")
|
||||||
|
),
|
||||||
|
forMessageID: messageID,
|
||||||
|
peerID: peerID
|
||||||
|
)
|
||||||
|
context.addSystemMessage(
|
||||||
|
String(localized: "system.dm.blocked_generic", comment: "System message when sending fails because user is blocked")
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||||
|
if recipientHex.lowercased() == identity.publicKeyHex.lowercased() {
|
||||||
|
context.setPrivateDeliveryStatus(
|
||||||
|
.failed(
|
||||||
|
reason: String(localized: "content.delivery.reason.self", comment: "Failure reason when attempting to message yourself")
|
||||||
|
),
|
||||||
|
forMessageID: messageID,
|
||||||
|
peerID: peerID
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.debug(
|
||||||
|
"GeoDM: local send mid=\(messageID.prefix(8))… to=\(recipientHex.prefix(8))… conv=\(peerID)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
context.sendGeohashPrivateMessage(
|
||||||
|
content,
|
||||||
|
toRecipientHex: recipientHex,
|
||||||
|
from: identity,
|
||||||
|
messageID: messageID
|
||||||
|
)
|
||||||
|
context.setPrivateDeliveryStatus(.sent, forMessageID: messageID, peerID: peerID)
|
||||||
|
} catch {
|
||||||
|
context.setPrivateDeliveryStatus(
|
||||||
|
.failed(
|
||||||
|
reason: String(localized: "content.delivery.reason.send_error", comment: "Failure reason for a generic send error")
|
||||||
|
),
|
||||||
|
forMessageID: messageID,
|
||||||
|
peerID: peerID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePrivateMessage(
|
||||||
|
_ payload: NoisePayload,
|
||||||
|
senderPubkey: String,
|
||||||
|
convKey: PeerID,
|
||||||
|
id: NostrIdentity,
|
||||||
|
messageTimestamp: Date
|
||||||
|
) {
|
||||||
|
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
|
||||||
|
let messageId = pm.messageID
|
||||||
|
|
||||||
|
SecureLogger.info("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", category: .session)
|
||||||
|
|
||||||
|
sendDeliveryAckIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
|
||||||
|
|
||||||
|
if context.isNostrBlocked(pubkeyHexLowercased: senderPubkey) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if context.privateChatsContainMessage(withID: messageId) { return }
|
||||||
|
|
||||||
|
let senderName = context.displayNameForNostrPubkey(senderPubkey)
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: messageId,
|
||||||
|
sender: senderName,
|
||||||
|
content: pm.content,
|
||||||
|
timestamp: messageTimestamp,
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: context.nickname,
|
||||||
|
senderPeerID: convKey,
|
||||||
|
deliveryStatus: .delivered(to: context.nickname, at: Date())
|
||||||
|
)
|
||||||
|
|
||||||
|
context.appendPrivateMessage(message, to: convKey)
|
||||||
|
|
||||||
|
let isViewing = context.selectedPrivateChatPeer == convKey
|
||||||
|
let wasReadBefore = context.sentReadReceipts.contains(messageId)
|
||||||
|
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||||
|
let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage
|
||||||
|
if shouldMarkUnread {
|
||||||
|
context.markPrivateChatUnread(convKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isViewing {
|
||||||
|
sendReadReceiptIfNeeded(to: messageId, senderPubKey: senderPubkey, from: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !isViewing && shouldMarkUnread {
|
||||||
|
context.notifyPrivateMessage(from: senderName, message: pm.content, peerID: convKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.notifyUIChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleDelivered(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
||||||
|
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
||||||
|
|
||||||
|
if context.privateChat(convKey, containsMessageWithID: messageID) {
|
||||||
|
context.setPrivateDeliveryStatus(
|
||||||
|
.delivered(to: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
|
||||||
|
forMessageID: messageID,
|
||||||
|
peerID: convKey
|
||||||
|
)
|
||||||
|
context.notifyUIChanged()
|
||||||
|
SecureLogger.info(
|
||||||
|
"GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
SecureLogger.warning("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleReadReceipt(_ payload: NoisePayload, senderPubkey: String, convKey: PeerID) {
|
||||||
|
guard let messageID = String(data: payload.data, encoding: .utf8) else { return }
|
||||||
|
|
||||||
|
if context.privateChat(convKey, containsMessageWithID: messageID) {
|
||||||
|
context.setPrivateDeliveryStatus(
|
||||||
|
.read(by: context.displayNameForNostrPubkey(senderPubkey), at: Date()),
|
||||||
|
forMessageID: messageID,
|
||||||
|
peerID: convKey
|
||||||
|
)
|
||||||
|
context.notifyUIChanged()
|
||||||
|
SecureLogger.info("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", category: .session)
|
||||||
|
} else {
|
||||||
|
SecureLogger.warning("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendDeliveryAckIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
||||||
|
guard context.markGeoDeliveryAckSent(messageId) else { return }
|
||||||
|
context.sendGeohashDeliveryAck(for: messageId, toRecipientHex: senderPubKey, from: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendReadReceiptIfNeeded(to messageId: String, senderPubKey: String, from id: NostrIdentity) {
|
||||||
|
guard context.markReadReceiptSent(messageId) else { return }
|
||||||
|
context.sendGeohashReadReceipt(messageId, toRecipientHex: senderPubKey, from: id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePrivateMessage(
|
||||||
|
_ payload: NoisePayload,
|
||||||
|
actualSenderNoiseKey: Data?,
|
||||||
|
senderNickname: String,
|
||||||
|
targetPeerID: PeerID,
|
||||||
|
messageTimestamp: Date,
|
||||||
|
senderPubkey: String
|
||||||
|
) {
|
||||||
|
guard let pm = PrivateMessagePacket.decode(from: payload.data) else { return }
|
||||||
|
let messageId = pm.messageID
|
||||||
|
let messageContent = pm.content
|
||||||
|
|
||||||
|
if messageContent.hasPrefix("[FAVORITED]") || messageContent.hasPrefix("[UNFAVORITED]") {
|
||||||
|
if let key = actualSenderNoiseKey {
|
||||||
|
handleFavoriteNotificationFromMesh(
|
||||||
|
messageContent,
|
||||||
|
from: PeerID(hexData: key),
|
||||||
|
senderNickname: senderNickname
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if isDuplicateMessage(messageId, targetPeerID: targetPeerID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let wasReadBefore = context.sentReadReceipts.contains(messageId)
|
||||||
|
|
||||||
|
var isViewingThisChat = false
|
||||||
|
if context.selectedPrivateChatPeer == targetPeerID {
|
||||||
|
isViewingThisChat = true
|
||||||
|
} else if let selectedPeer = context.selectedPrivateChatPeer,
|
||||||
|
let selectedPeerNoiseKey = context.noisePublicKey(for: selectedPeer),
|
||||||
|
let key = actualSenderNoiseKey,
|
||||||
|
selectedPeerNoiseKey == key {
|
||||||
|
isViewingThisChat = true
|
||||||
|
}
|
||||||
|
|
||||||
|
let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30
|
||||||
|
let shouldMarkAsUnread = !wasReadBefore && !isViewingThisChat && isRecentMessage
|
||||||
|
|
||||||
|
let message = BitchatMessage(
|
||||||
|
id: messageId,
|
||||||
|
sender: senderNickname,
|
||||||
|
content: messageContent,
|
||||||
|
timestamp: messageTimestamp,
|
||||||
|
isRelay: false,
|
||||||
|
isPrivate: true,
|
||||||
|
recipientNickname: context.nickname,
|
||||||
|
senderPeerID: targetPeerID,
|
||||||
|
deliveryStatus: .delivered(to: context.nickname, at: Date())
|
||||||
|
)
|
||||||
|
|
||||||
|
addMessageToPrivateChatsIfNeeded(message, targetPeerID: targetPeerID)
|
||||||
|
mirrorToEphemeralIfNeeded(message, targetPeerID: targetPeerID, key: actualSenderNoiseKey)
|
||||||
|
|
||||||
|
context.sendDeliveryAckViaNostrEmbedded(
|
||||||
|
message,
|
||||||
|
wasReadBefore: wasReadBefore,
|
||||||
|
senderPubkey: senderPubkey,
|
||||||
|
key: actualSenderNoiseKey
|
||||||
|
)
|
||||||
|
|
||||||
|
if wasReadBefore {
|
||||||
|
// No-op.
|
||||||
|
} else if isViewingThisChat {
|
||||||
|
handleViewingThisChat(
|
||||||
|
message,
|
||||||
|
targetPeerID: targetPeerID,
|
||||||
|
key: actualSenderNoiseKey,
|
||||||
|
senderPubkey: senderPubkey
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
markAsUnreadIfNeeded(
|
||||||
|
shouldMarkAsUnread: shouldMarkAsUnread,
|
||||||
|
targetPeerID: targetPeerID,
|
||||||
|
key: actualSenderNoiseKey,
|
||||||
|
isRecentMessage: isRecentMessage,
|
||||||
|
senderNickname: senderNickname,
|
||||||
|
messageContent: messageContent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.notifyUIChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePrivateMessage(_ message: BitchatMessage) {
|
||||||
|
SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session)
|
||||||
|
let senderPeerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender)
|
||||||
|
|
||||||
|
guard let peerID = senderPeerID else {
|
||||||
|
SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
|
||||||
|
handleFavoriteNotificationFromMesh(message.content, from: peerID, senderNickname: message.sender)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
migratePrivateChatsIfNeeded(for: peerID, senderNickname: message.sender)
|
||||||
|
|
||||||
|
if peerID.id.count == 16, let peerNoiseKey = context.noisePublicKey(for: peerID) {
|
||||||
|
let stableKeyHex = PeerID(hexData: peerNoiseKey)
|
||||||
|
let nostrMessages = context.privateMessages(for: stableKeyHex)
|
||||||
|
if stableKeyHex != peerID,
|
||||||
|
!nostrMessages.isEmpty {
|
||||||
|
// Store migration dedups by ID, keeps timestamp order, and
|
||||||
|
// removes the stable-key chat.
|
||||||
|
context.migratePrivateChat(from: stableKeyHex, to: peerID)
|
||||||
|
|
||||||
|
SecureLogger.info(
|
||||||
|
"📥 Consolidated \(nostrMessages.count) Nostr messages from stable key to ephemeral peer \(peerID)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isDuplicateMessage(message.id, targetPeerID: peerID) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
addMessageToPrivateChatsIfNeeded(message, targetPeerID: peerID)
|
||||||
|
let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID)
|
||||||
|
mirrorToEphemeralIfNeeded(message, targetPeerID: peerID, key: noiseKey)
|
||||||
|
|
||||||
|
let isViewing = context.selectedPrivateChatPeer == peerID
|
||||||
|
if isViewing {
|
||||||
|
let receipt = ReadReceipt(
|
||||||
|
originalMessageID: message.id,
|
||||||
|
readerID: context.myPeerID,
|
||||||
|
readerNickname: context.nickname
|
||||||
|
)
|
||||||
|
context.sendMeshReadReceipt(receipt, to: peerID)
|
||||||
|
context.markReadReceiptSent(message.id)
|
||||||
|
} else {
|
||||||
|
context.markPrivateChatUnread(peerID)
|
||||||
|
context.notifyPrivateMessage(from: message.sender, message: message.content, peerID: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.notifyUIChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// O(1)-per-conversation dedup via the store's message-ID indexes
|
||||||
|
/// (replaces the full scan over every private chat).
|
||||||
|
func isDuplicateMessage(_ messageId: String, targetPeerID: PeerID) -> Bool {
|
||||||
|
context.privateChatsContainMessage(withID: messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addMessageToPrivateChatsIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID) {
|
||||||
|
// Store upsert replaces in place by message ID or inserts in
|
||||||
|
// timestamp order; the old per-append sanitize re-sort is obsolete.
|
||||||
|
context.upsertPrivateMessage(message, in: targetPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mirrorToEphemeralIfNeeded(_ message: BitchatMessage, targetPeerID: PeerID, key: Data?) {
|
||||||
|
guard let key,
|
||||||
|
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key),
|
||||||
|
ephemeralPeerID != targetPeerID
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.upsertPrivateMessage(message, in: ephemeralPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleViewingThisChat(
|
||||||
|
_ message: BitchatMessage,
|
||||||
|
targetPeerID: PeerID,
|
||||||
|
key: Data?,
|
||||||
|
senderPubkey: String
|
||||||
|
) {
|
||||||
|
context.markPrivateChatRead(targetPeerID)
|
||||||
|
if let key,
|
||||||
|
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key) {
|
||||||
|
context.markPrivateChatRead(ephemeralPeerID)
|
||||||
|
}
|
||||||
|
guard !context.sentReadReceipts.contains(message.id) else { return }
|
||||||
|
|
||||||
|
if let key {
|
||||||
|
let receipt = ReadReceipt(
|
||||||
|
originalMessageID: message.id,
|
||||||
|
readerID: context.myPeerID,
|
||||||
|
readerNickname: context.nickname
|
||||||
|
)
|
||||||
|
SecureLogger.debug("Viewing chat; sending READ ack for \(message.id.prefix(8))… via router", category: .session)
|
||||||
|
context.routeReadReceipt(receipt, to: PeerID(hexData: key))
|
||||||
|
context.markReadReceiptSent(message.id)
|
||||||
|
} else if let identity = context.currentNostrIdentity() {
|
||||||
|
context.sendGeohashReadReceipt(message.id, toRecipientHex: senderPubkey, from: identity)
|
||||||
|
context.markReadReceiptSent(message.id)
|
||||||
|
SecureLogger.debug(
|
||||||
|
"Viewing chat; sent READ ack directly to Nostr pub=\(senderPubkey.prefix(8))… for mid=\(message.id.prefix(8))…",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func markAsUnreadIfNeeded(
|
||||||
|
shouldMarkAsUnread: Bool,
|
||||||
|
targetPeerID: PeerID,
|
||||||
|
key: Data?,
|
||||||
|
isRecentMessage: Bool,
|
||||||
|
senderNickname: String,
|
||||||
|
messageContent: String
|
||||||
|
) {
|
||||||
|
guard shouldMarkAsUnread else { return }
|
||||||
|
|
||||||
|
context.markPrivateChatUnread(targetPeerID)
|
||||||
|
if let key,
|
||||||
|
let ephemeralPeerID = context.ephemeralPeerID(forNoiseKey: key),
|
||||||
|
ephemeralPeerID != targetPeerID {
|
||||||
|
context.markPrivateChatUnread(ephemeralPeerID)
|
||||||
|
}
|
||||||
|
if isRecentMessage {
|
||||||
|
context.notifyPrivateMessage(from: senderNickname, message: messageContent, peerID: targetPeerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleFavoriteNotificationFromMesh(_ content: String, from peerID: PeerID, senderNickname: String) {
|
||||||
|
let isFavorite = content.hasPrefix("[FAVORITED]")
|
||||||
|
let parts = content.split(separator: ":")
|
||||||
|
|
||||||
|
var nostrPubkey: String?
|
||||||
|
if parts.count > 1 {
|
||||||
|
nostrPubkey = String(parts[1])
|
||||||
|
SecureLogger.info("📝 Received Nostr npub in favorite notification: \(nostrPubkey ?? "none")", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
let noiseKey = peerID.noiseKey ?? context.noisePublicKey(for: peerID)
|
||||||
|
guard let finalNoiseKey = noiseKey else {
|
||||||
|
SecureLogger.warning("⚠️ Cannot get Noise key for peer \(peerID)", category: .session)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let prior = context.favoriteRelationship(forNoiseKey: finalNoiseKey)?.theyFavoritedUs ?? false
|
||||||
|
context.updatePeerFavoritedUs(
|
||||||
|
noiseKey: finalNoiseKey,
|
||||||
|
favorited: isFavorite,
|
||||||
|
nickname: senderNickname,
|
||||||
|
nostrPublicKey: nostrPubkey
|
||||||
|
)
|
||||||
|
|
||||||
|
if isFavorite && nostrPubkey != nil {
|
||||||
|
SecureLogger.info(
|
||||||
|
"💾 Storing Nostr key association for \(senderNickname): \(nostrPubkey!.prefix(16))...",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if prior != isFavorite {
|
||||||
|
let action = isFavorite ? "favorited" : "unfavorited"
|
||||||
|
context.addMeshOnlySystemMessage("\(senderNickname) \(action) you")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {
|
||||||
|
let isActionMessage = message.content.hasPrefix("* ")
|
||||||
|
&& message.content.hasSuffix(" *")
|
||||||
|
&& (message.content.contains("🫂")
|
||||||
|
|| message.content.contains("🐟")
|
||||||
|
|| message.content.contains("took a screenshot"))
|
||||||
|
|
||||||
|
guard isActionMessage else { return message }
|
||||||
|
|
||||||
|
return BitchatMessage(
|
||||||
|
id: message.id,
|
||||||
|
sender: "system",
|
||||||
|
content: String(message.content.dropFirst(2).dropLast(2)),
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
isRelay: message.isRelay,
|
||||||
|
originalSender: message.originalSender,
|
||||||
|
isPrivate: message.isPrivate,
|
||||||
|
recipientNickname: message.recipientNickname,
|
||||||
|
senderPeerID: message.senderPeerID,
|
||||||
|
mentions: message.mentions,
|
||||||
|
deliveryStatus: message.deliveryStatus
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migratePrivateChatsIfNeeded(for peerID: PeerID, senderNickname: String) {
|
||||||
|
let currentFingerprint = context.getFingerprint(for: peerID)
|
||||||
|
|
||||||
|
if context.privateMessages(for: peerID).isEmpty {
|
||||||
|
// Chats migrated wholesale go through the store's
|
||||||
|
// `migrateConversation` intent; partially-migrated chats keep
|
||||||
|
// their non-recent tail, so the recent messages are copied in
|
||||||
|
// via ordered append (dedup by ID) instead.
|
||||||
|
var partiallyMigratedMessages: [BitchatMessage] = []
|
||||||
|
var oldPeerIDsToRemove: [PeerID] = []
|
||||||
|
var didMigrate = false
|
||||||
|
let cutoffTime = Date().addingTimeInterval(-TransportConfig.uiMigrationCutoffSeconds)
|
||||||
|
|
||||||
|
for (oldPeerID, messages) in context.privateChats where oldPeerID != peerID {
|
||||||
|
let oldFingerprint = context.storedFingerprint(for: oldPeerID)
|
||||||
|
let recentMessages = messages.filter { $0.timestamp > cutoffTime }
|
||||||
|
guard !recentMessages.isEmpty else { continue }
|
||||||
|
|
||||||
|
if let currentFp = currentFingerprint,
|
||||||
|
let oldFp = oldFingerprint,
|
||||||
|
currentFp == oldFp {
|
||||||
|
didMigrate = true
|
||||||
|
if recentMessages.count == messages.count {
|
||||||
|
oldPeerIDsToRemove.append(oldPeerID)
|
||||||
|
} else {
|
||||||
|
partiallyMigratedMessages.append(contentsOf: recentMessages)
|
||||||
|
SecureLogger.info(
|
||||||
|
"📦 Partially migrating \(recentMessages.count) of \(messages.count) messages from \(oldPeerID)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.info(
|
||||||
|
"📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (fingerprint match)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
} else if currentFingerprint == nil || oldFingerprint == nil {
|
||||||
|
let isRelevantChat = recentMessages.contains { msg in
|
||||||
|
(msg.sender == senderNickname && msg.sender != context.nickname)
|
||||||
|
|| (msg.sender == context.nickname && msg.recipientNickname == senderNickname)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isRelevantChat {
|
||||||
|
didMigrate = true
|
||||||
|
if recentMessages.count == messages.count {
|
||||||
|
oldPeerIDsToRemove.append(oldPeerID)
|
||||||
|
} else {
|
||||||
|
partiallyMigratedMessages.append(contentsOf: recentMessages)
|
||||||
|
}
|
||||||
|
|
||||||
|
SecureLogger.warning(
|
||||||
|
"📦 Migrating \(recentMessages.count) recent messages from old peer ID \(oldPeerID) to \(peerID) (nickname match)",
|
||||||
|
category: .session
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !oldPeerIDsToRemove.isEmpty {
|
||||||
|
for oldID in oldPeerIDsToRemove {
|
||||||
|
// The old behavior dropped the unread flag of removed
|
||||||
|
// chats instead of transferring it; clear it before the
|
||||||
|
// migration so the store doesn't carry it over.
|
||||||
|
context.markPrivateChatRead(oldID)
|
||||||
|
context.migratePrivateChat(from: oldID, to: peerID)
|
||||||
|
context.clearStoredFingerprint(for: oldID)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.handOffSelectedPrivateChat(from: oldPeerIDsToRemove, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
for message in partiallyMigratedMessages {
|
||||||
|
context.appendPrivateMessage(message, to: peerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if didMigrate {
|
||||||
|
context.notifyUIChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
|
||||||
|
var noiseKey: Data?
|
||||||
|
|
||||||
|
if let hexKey = Data(hexString: peerID.id) {
|
||||||
|
noiseKey = hexKey
|
||||||
|
} else if let peerNoiseKey = context.noisePublicKey(for: peerID) {
|
||||||
|
noiseKey = peerNoiseKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if context.isPeerConnected(peerID) {
|
||||||
|
context.routeFavoriteNotification(to: peerID, isFavorite: isFavorite)
|
||||||
|
SecureLogger.debug("📤 Sent favorite notification via BLE to \(peerID)", category: .session)
|
||||||
|
} else if let key = noiseKey {
|
||||||
|
context.routeFavoriteNotification(to: PeerID(hexData: key), isFavorite: isFavorite)
|
||||||
|
} else {
|
||||||
|
SecureLogger.warning("⚠️ Cannot send favorite notification - peer not connected and no Nostr pubkey", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMessageBlocked(_ message: BitchatMessage) -> Bool {
|
||||||
|
if let peerID = message.senderPeerID ?? context.getPeerIDForNickname(message.sender) {
|
||||||
|
if context.isPeerBlocked(peerID) { return true }
|
||||||
|
if peerID.isGeoChat || peerID.isGeoDM,
|
||||||
|
let full = context.nostrKeyMapping[peerID]?.lowercased(),
|
||||||
|
context.isNostrBlocked(pubkeyHexLowercased: full) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default for conforming test contexts that model chats as a dictionary;
|
||||||
|
/// `ChatViewModel` overrides with a store-direct lookup.
|
||||||
|
extension ChatPrivateConversationContext {
|
||||||
|
func privateMessages(for peerID: PeerID) -> [BitchatMessage] {
|
||||||
|
privateChats[peerID] ?? []
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,547 @@
|
|||||||
|
import BitFoundation
|
||||||
|
import BitLogger
|
||||||
|
import CoreBluetooth
|
||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
#if os(iOS)
|
||||||
|
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 context: any ChatPublicConversationContext
|
||||||
|
|
||||||
|
init(context: any ChatPublicConversationContext) {
|
||||||
|
self.context = context
|
||||||
|
}
|
||||||
|
|
||||||
|
func visibleGeohashPeople() -> [GeoPerson] {
|
||||||
|
context.visibleGeoPeople()
|
||||||
|
}
|
||||||
|
|
||||||
|
func getVisibleGeoParticipants() -> [CommandGeoParticipant] {
|
||||||
|
visibleGeohashPeople().map { CommandGeoParticipant(id: $0.id, displayName: $0.displayName) }
|
||||||
|
}
|
||||||
|
|
||||||
|
func geohashParticipantCount(for geohash: String) -> Int {
|
||||||
|
context.geoParticipantCount(for: geohash)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayNameForPubkey(_ pubkeyHex: String) -> String {
|
||||||
|
displayNameForNostrPubkey(pubkeyHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
|
||||||
|
context.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool {
|
||||||
|
context.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
|
||||||
|
}
|
||||||
|
|
||||||
|
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||||
|
let hex = pubkeyHexLowercased.lowercased()
|
||||||
|
context.setNostrBlocked(hex, isBlocked: true)
|
||||||
|
context.removeGeoParticipant(pubkeyHex: hex)
|
||||||
|
|
||||||
|
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 = context.nostrKeyMapping[senderPeerID]?.lowercased() {
|
||||||
|
return full == hex
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
context.removePublicMessages(fromGeohash: gh, where: predicate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The store intent no-ops when no such chat exists.
|
||||||
|
context.removePrivateChat(PeerID(nostr_: hex))
|
||||||
|
|
||||||
|
context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)
|
||||||
|
|
||||||
|
addSystemMessage(
|
||||||
|
String(
|
||||||
|
format: String(
|
||||||
|
localized: "system.geohash.blocked",
|
||||||
|
comment: "System message shown when a user is blocked in geohash chats"
|
||||||
|
),
|
||||||
|
locale: .current,
|
||||||
|
displayName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
||||||
|
context.setNostrBlocked(pubkeyHexLowercased, isBlocked: false)
|
||||||
|
addSystemMessage(
|
||||||
|
String(
|
||||||
|
format: String(
|
||||||
|
localized: "system.geohash.unblocked",
|
||||||
|
comment: "System message shown when a user is unblocked in geohash chats"
|
||||||
|
),
|
||||||
|
locale: .current,
|
||||||
|
displayName
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
||||||
|
let suffix = String(pubkeyHex.suffix(4))
|
||||||
|
if let geohash = context.currentGeohash,
|
||||||
|
let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: geohash),
|
||||||
|
myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() {
|
||||||
|
return context.nickname + "#" + suffix
|
||||||
|
}
|
||||||
|
if let nick = context.geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty {
|
||||||
|
return nick + "#" + suffix
|
||||||
|
}
|
||||||
|
return "anon#\(suffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func currentPublicSender() -> (name: String, peerID: PeerID) {
|
||||||
|
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 = context.nickname + "#" + suffix
|
||||||
|
senderPeerID = PeerID(nostr: identity.publicKeyHex)
|
||||||
|
}
|
||||||
|
return (displaySender, senderPeerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeMessage(withID messageID: String, cleanupFile: Bool = false) {
|
||||||
|
var removedMessage = context.removePublicMessage(withID: messageID)
|
||||||
|
|
||||||
|
if let removedPrivateMessage = context.removePrivateMessage(withID: messageID) {
|
||||||
|
removedMessage = removedMessage ?? removedPrivateMessage
|
||||||
|
}
|
||||||
|
|
||||||
|
if cleanupFile, let removedMessage {
|
||||||
|
context.cleanupLocalFile(forMessage: removedMessage)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.notifyUIChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearCurrentPublicTimeline() {
|
||||||
|
context.clearPublicConversation(ConversationID(channelID: context.activeChannel))
|
||||||
|
|
||||||
|
Task.detached(priority: .utility) {
|
||||||
|
do {
|
||||||
|
let base = try FileManager.default.url(
|
||||||
|
for: .applicationSupportDirectory,
|
||||||
|
in: .userDomainMask,
|
||||||
|
appropriateFor: nil,
|
||||||
|
create: true
|
||||||
|
)
|
||||||
|
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
||||||
|
let outgoingDirs = [
|
||||||
|
filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true),
|
||||||
|
filesDir.appendingPathComponent("images/outgoing", isDirectory: true),
|
||||||
|
filesDir.appendingPathComponent("files/outgoing", isDirectory: true)
|
||||||
|
]
|
||||||
|
|
||||||
|
for dir in outgoingDirs {
|
||||||
|
if FileManager.default.fileExists(atPath: dir.path) {
|
||||||
|
try? FileManager.default.removeItem(at: dir)
|
||||||
|
try? FileManager.default.createDirectory(
|
||||||
|
at: dir,
|
||||||
|
withIntermediateDirectories: true,
|
||||||
|
attributes: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("Failed to clear media files: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addSystemMessage(_ content: String, timestamp: Date = Date()) {
|
||||||
|
let systemMessage = BitchatMessage(
|
||||||
|
sender: "system",
|
||||||
|
content: content,
|
||||||
|
timestamp: timestamp,
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
context.appendPublicMessage(systemMessage, to: ConversationID(channelID: context.activeChannel))
|
||||||
|
}
|
||||||
|
|
||||||
|
func addMeshOnlySystemMessage(_ content: String) {
|
||||||
|
let systemMessage = BitchatMessage(
|
||||||
|
sender: "system",
|
||||||
|
content: content,
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
context.appendPublicMessage(systemMessage, to: .mesh)
|
||||||
|
}
|
||||||
|
|
||||||
|
func addPublicSystemMessage(_ content: String) {
|
||||||
|
let systemMessage = BitchatMessage(
|
||||||
|
sender: "system",
|
||||||
|
content: content,
|
||||||
|
timestamp: Date(),
|
||||||
|
isRelay: false
|
||||||
|
)
|
||||||
|
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 = context.activeChannel {
|
||||||
|
addPublicSystemMessage(content)
|
||||||
|
} else {
|
||||||
|
context.queueGeohashSystemMessage(content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendPublicRaw(_ content: String) {
|
||||||
|
if case .location(let channel) = context.activeChannel {
|
||||||
|
Task { @MainActor [weak context] in
|
||||||
|
guard let context else { return }
|
||||||
|
do {
|
||||||
|
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
||||||
|
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||||
|
content: content,
|
||||||
|
geohash: channel.geohash,
|
||||||
|
senderIdentity: identity,
|
||||||
|
nickname: context.nickname,
|
||||||
|
teleported: context.isTeleported
|
||||||
|
)
|
||||||
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
|
||||||
|
if targetRelays.isEmpty {
|
||||||
|
NostrRelayManager.shared.sendEvent(event)
|
||||||
|
} else {
|
||||||
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
SecureLogger.error("❌ Failed to send geohash raw message: \(error)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
context.sendMeshMessage(
|
||||||
|
content,
|
||||||
|
mentions: [],
|
||||||
|
messageID: UUID().uuidString,
|
||||||
|
timestamp: Date()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handlePublicMessage(_ message: BitchatMessage) {
|
||||||
|
let finalMessage = context.processActionMessage(message)
|
||||||
|
if context.isMessageBlocked(finalMessage) { return }
|
||||||
|
|
||||||
|
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
|
||||||
|
let isSystem = finalMessage.sender == "system"
|
||||||
|
let shouldRateLimit = !isSystem || finalMessage.senderPeerID != nil
|
||||||
|
if shouldRateLimit {
|
||||||
|
let senderKey = normalizedSenderKey(for: finalMessage)
|
||||||
|
let contentKey = context.normalizedContentKey(finalMessage.content)
|
||||||
|
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey) {
|
||||||
|
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 }
|
||||||
|
|
||||||
|
// 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 }
|
||||||
|
|
||||||
|
let channelMatches: Bool = {
|
||||||
|
switch context.activeChannel {
|
||||||
|
case .mesh: return !isGeo || isSystem
|
||||||
|
case .location: return isGeo || isSystem
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
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> = [context.nickname]
|
||||||
|
let meshPeers = context.meshPeerNicknames()
|
||||||
|
let collisions = meshPeers.values.filter { $0.hasPrefix(context.nickname + "#") }
|
||||||
|
if !collisions.isEmpty {
|
||||||
|
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 != context.nickname {
|
||||||
|
SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)
|
||||||
|
context.notifyMention(from: message.sender, message: message.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendHapticFeedback(for message: BitchatMessage) {
|
||||||
|
#if os(iOS)
|
||||||
|
guard UIApplication.shared.applicationState == .active else { return }
|
||||||
|
|
||||||
|
var tokens: [String] = [context.nickname]
|
||||||
|
switch context.activeChannel {
|
||||||
|
case .location(let channel):
|
||||||
|
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||||
|
tokens.append(context.nickname + "#" + String(identity.publicKeyHex.suffix(4)))
|
||||||
|
}
|
||||||
|
case .mesh:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
let hugsMe = tokens.contains { message.content.contains("hugs \($0)") } || message.content.contains("hugs you")
|
||||||
|
let slapsMe = tokens.contains { message.content.contains("slaps \($0) around") } || message.content.contains("slaps you around")
|
||||||
|
let isHugForMe = message.content.contains("🫂") && hugsMe
|
||||||
|
let isSlapForMe = message.content.contains("🐟") && slapsMe
|
||||||
|
|
||||||
|
if isHugForMe && message.sender != context.nickname {
|
||||||
|
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
|
||||||
|
impactFeedback.prepare()
|
||||||
|
|
||||||
|
for i in 0..<8 {
|
||||||
|
DispatchQueue.main.asyncAfter(
|
||||||
|
deadline: .now() + Double(i) * TransportConfig.uiBatchDispatchStaggerSeconds
|
||||||
|
) {
|
||||||
|
impactFeedback.impactOccurred()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if isSlapForMe && message.sender != context.nickname {
|
||||||
|
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
||||||
|
impactFeedback.prepare()
|
||||||
|
impactFeedback.impactOccurred()
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
|
||||||
|
context.normalizedContentKey(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? {
|
||||||
|
context.contentTimestamp(forKey: key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
|
||||||
|
context.recordContentKey(key, timestamp: timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipeline(_ pipeline: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool {
|
||||||
|
context.appendPublicMessage(message, to: conversationID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {
|
||||||
|
context.prewarmMessageFormatting(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {
|
||||||
|
context.setPublicBatching(isBatching)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private extension ChatPublicConversationCoordinator {
|
||||||
|
func normalizedSenderKey(for message: BitchatMessage) -> String {
|
||||||
|
if let senderPeerID = message.senderPeerID {
|
||||||
|
if senderPeerID.isGeoChat || senderPeerID.isGeoDM {
|
||||||
|
let full = (context.nostrKeyMapping[senderPeerID] ?? senderPeerID.bare).lowercased()
|
||||||
|
return "nostr:" + full
|
||||||
|
} else if senderPeerID.id.count == 16,
|
||||||
|
let full = context.cachedStablePeerID(for: senderPeerID)?.id.lowercased() {
|
||||||
|
return "noise:" + full
|
||||||
|
} else {
|
||||||
|
return "mesh:" + senderPeerID.id.lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "name:" + message.sender.lowercased()
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user