Compare commits

...
Author SHA1 Message Date
jack 6550a64676 project: add TransportConfig.swift to Share Extension target to fix build 2025-08-25 21:17:57 +02:00
jack 7e9d573f6d refactor(config): centralize remaining magic numbers and finalize log hygiene
Add comprehensive TransportConfig constants for UI, Nostr, and BLE; adopt across ChatViewModel, ContentView, BLEService, ShareViewController, and BitchatApp to remove scattered literals. Standardize Nostr lookbacks/limits, UI delays/animations, and BLE announce/duty-cycle/candidate caps. Preserve behavior while making tuning explicit and safe.

Highlights:\n- UI: animations, scroll throttle, long-message thresholds, batch stagger, color hue tuning, rate-limit buckets, read-receipt debounce, startup delays, share accept/dismiss windows, migration cutoff.\n- Nostr: short display length (8), conv-key prefix length (16), DM lookback (24h), geohash sample lookback/limits; consistent use throughout ChatViewModel.\n- BLE: dynamic RSSI defaults, announce intervals/base+jitter, duty cycles (dense/sparse), fragment/ingress lifetimes, expected write timings/spacing, recent packet window (30s/100), peer inactivity timeout; unified candidate caps (100).\n- Share: use constant dismiss delay; App: use constant share accept window.\n\nRisk/impact: behavior-equivalent with centralized knobs; easier to tune without code edits.
2025-08-25 21:13:17 +02:00
jack ed3583f2ff chore: centralize more BLE/Nostr delays; tighten NostrRelayManager logs to concise summaries; adopt config for location/geohash/relays 2025-08-25 20:57:52 +02:00
jack 2251afa360 chore: centralize compression, dedup, verification QR, relay backoff, georelay fetch constants; adopt across modules 2025-08-25 20:51:12 +02:00
jack c39df29b25 chore: centralize location + geohash constants (filters, lookback, relay count) and adopt in LocationChannelManager/ChatViewModel 2025-08-25 20:17:09 +02:00
jack 43e159df0e chore: centralize more constants in TransportConfig (BLE thresholds, Nostr read-ack, UI caps) and adopt in BLEService/ChatViewModel/NostrTransport 2025-08-25 20:04:37 +02:00
jack adab49154b project: remove duplicate TransportConfig.swift entries from Sources build phases 2025-08-25 19:59:08 +02:00
jack 62d259cd9c chore(BLE): trim raw hex dump on central decode failure to length+prefix 2025-08-25 19:56:27 +02:00
jack 131db9c569 chore(NostrTransport): factor recipient npub resolution into helper; reduce duplication 2025-08-25 19:50:45 +02:00
jack f5a2a1e29a chore(BLE): move connect/duty/announce constants to TransportConfig and reference them 2025-08-25 18:59:47 +02:00
jack 3a8cf4d37f project: fix TransportConfig reference path; remove recovered reference; hook correct fileRef in iOS/macOS sources 2025-08-25 18:58:19 +02:00
jack b243b930c4 chore: remove unnecessary UserDefaults.synchronize() calls (extension/app/VM) 2025-08-25 18:54:42 +02:00
jack c803f862d5 project: add TransportConfig.swift and PeerDisplayNameResolver.swift to iOS/macOS targets (no xcodegen) 2025-08-25 18:52:54 +02:00
jack 58c516075c docs(plans): add refactor plan; chore(config): introduce TransportConfig and use in BLEService/ChatViewModel/PrivateChatManager; feat: add PeerDisplayNameResolver and apply in BLEService; chore: make TransportPeerSnapshot Equatable/Hashable; perf: simplify read-receipt persistence (no synchronize) 2025-08-25 18:48:02 +02:00
680a390b2d Nostr: sign events directly with Schnorr keys; update call sites to use Schnorr and remove temporary Signing.PrivateKey conversions (#521)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-25 18:31:53 +02:00
60b0deee7b Cleanup: remove dead code, normalize fingerprints, modernize share extension, trim test noise, and drop ‘preparing to share…’ message (#520)
* Remove dead code and artifacts: drop PeerManager, unused views/types; delete LegacyTestProtocolTypes; update .gitignore; purge TestResult.xcresult and build.log

* Tests: gate verbose prints under DEBUG; ChatViewModel: remove legacy fingerprint helper and rely on UnifiedPeerService

* Share Extension: migrate to UIKit + UTTypes; drop Social/SLComposeServiceViewController

* Remove 'preparing to share …' system message; send shared content immediately

* Inline comment cleanup: drop legacy 'removed' breadcrumbs across protocols, services, view model, and views

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-25 18:01:19 +02:00
jackandGitHub 2f7c0aaaf7 Delete TestResult.xcresult directory 2025-08-25 16:36:36 +02:00
7c4c3f1391 macOS geohash parity: shared LocationChannelsSheet with permission CTA, enable CoreLocation on macOS, unify geohash participants/DMs, update ContentView (unread + QR on macOS), commands: hide/block /fav & /unfav in geohash, remove /help, make /who show geohash participants, fix ViewBuilder mutation+sheet toolbar, entitlements for mac location (#519)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-25 16:29:52 +02:00
3c06bd6386 Improve BLE mesh flooding: last-hop suppression, K-of-N fanout, and backpressure (#517)
* Improve BLE mesh relay and flooding

Add last-hop suppression using ingress-link tracking to prevent echo. Implement deterministic always-relay for handshakes and directed encrypted/fragments; widen jitter for broadcasts; keep TTL cap only for broadcast. Add deterministic K-of-N broadcast fanout to reduce amplification in dense topologies. Introduce backpressure-aware writes using canSendWriteWithoutResponse with per-peripheral queues and draining on peripheralIsReady. Minor helpers for messageID, deterministic selection, and maintenance cleanup.

* Tests: stabilize FragmentationTests and InputValidatorTests

Make _test_handlePacket mark synthetic peers verified/connected with normalized senderID to avoid drops in public-message reassembly tests. Tighten validatePeerID to reject non-hex strings when length equals 16 or 64; allow internal IDs only for other lengths. All iOS simulator tests pass locally.

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-25 11:51:50 +02:00
jack acc11101ad Fix geohash linking: avoid links in @mentions
Only link #geohash tokens when not directly attached to an @mention (e.g., @name#gh stays plain). Keeps standalone #geohash tappable.
2025-08-25 09:31:41 +02:00
5fd9140ffa QR verification: live challenge/response over Noise; persistence, offline badges, and UX/perf polish (#510)
* QR verification scaffold: add Noise verify payload types, VerificationService with QR schema/signing, placeholder MyQR/Scan views, and UI entry points in header

* QR: fix VerificationQR mutability (sigHex var) and remove duplicate Data hex helpers to resolve redeclaration; wire signed payload assembly

* QR: render actual QR images with CoreImage; add copy button; keep scanner placeholder for now

* QR: fix SwiftUI modifiers — apply .interpolation(.none) and .resizable() to platform Image inside ImageWrapper; remove from wrapper usage

* QR: add iOS camera scanner using AVFoundation; integrate into Scan view; add NSCameraUsageDescription to Info.plist

* QR: make NoisePayloadType exhaustive in ChatViewModel switches by ignoring verifyChallenge/verifyResponse for now (placeholder)

* QR verification: speed + persistence + UX

- Inject live Noise into VerificationService; prewarm QR on app start
- Keep camera active; remove intermediate responder toast
- One-shot/dupe guards and deferred send on handshake
- Persist verified status immediately; standardize fingerprint (SHA-256)
- Show verified badge for offline favorites; mutual verification toast
- VERIFY sheet styling to match peer sheet; UI polish
- Logs to diagnose verified load + favorites mapping

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 23:19:58 +02:00
e6903456ab NIP‑17/44: XChaCha20 v2 gift wraps, DM kind=14, receipts + UI fixes (#506)
* NIP-17/44: adopt NIP-44 v2 (XChaCha20-Poly1305, v2: base64url), switch rumor kind to 14, randomize BIP-340 aux; add XChaCha20Poly1305Compat and wire-up

* Nostr DMs: ensure delivered/read acks are sent even without Noise key mapping by falling back to direct Nostr (geohash-style) acks to sender pubkey

* NIP-44 v2 decrypt: try both Y parities for x-only sender pubkeys (even then odd) to fix unwrap authentication failures

* Tests: add NIP-44 v2 ACK round-trip tests (delivered/read) using bitchat1 embedding and gift-wrap decrypt path

* UI: fix DM autoscroll IDs by using dm:<peer>|<id> for private chat item IDs and preserve anchors, ensuring scrollTo targets exist when opening/switching/new messages

* UI: fix string interpolation in DM/geohash context keys (remove escaped interpolation) to resolve 'unused ch' warnings and ensure proper IDs

* UI: MeshPeerList shows transport state icon: radio for mesh-connected, purple globe for mutual favorite/Nostr; fallback dim person for others

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 17:56:43 +02:00
85f99984c4 Unify DM notifications and fix geohash self-echo hydration (#505)
* feat(notifications): unify DM notifications across transports; notify for unread+recent even in foreground; delegate suppresses if chat open

* fix(geohash): include own past geohash messages on channel load; skip only very recent self-echo (<15s) to avoid duplicates

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 14:40:06 +02:00
75c8933091 Feat/georelay (#504)
* feat(georelay): route geohash kind 20000 via nearest relays; add GeoRelayDirectory; target geohash subscriptions; avoid duplicate connections

* feat(georelay): fetch daily from remote CSV with fallback to bundled; cache to app support; prefetch on app load

* fix(georelay): make CSV parser nonisolated and call as Self.parseCSV to satisfy Swift 6 actor isolation

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 12:49:52 +02:00
39 changed files with 2742 additions and 1228 deletions
+4
View File
@@ -71,3 +71,7 @@ __pycache__/
# Local build results # Local build results
.Result*/ .Result*/
.Result*.xcresult/ .Result*.xcresult/
TestResult.xcresult/
*.xcresult/
build.log
*.log
+48 -1
View File
@@ -29,6 +29,9 @@
047502B72E55FED60083520F /* GeohashPeopleList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B22E55FED60083520F /* GeohashPeopleList.swift */; }; 047502B72E55FED60083520F /* GeohashPeopleList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B22E55FED60083520F /* GeohashPeopleList.swift */; };
047502B92E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; }; 047502B92E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
047502BA2E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; }; 047502BA2E560F690083520F /* RelayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502B82E560F690083520F /* RelayController.swift */; };
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; }; 049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; }; 049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; }; 049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
@@ -56,6 +59,8 @@
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; }; 0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; 0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; }; 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; };
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */; };
132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */; }; 132DF1E24B4E9C7DCDAD4376 /* FingerprintView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */; };
17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 17901751FD8010AFC8E750F2 /* bitchatShareExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; }; 1D9674FA5F998503831DC281 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A08E03AA0C63E97C91749AEC /* ContentView.swift */; };
@@ -102,11 +107,17 @@
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B378C16594575FCC7F9C75 /* ShareViewController.swift */; }; 9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1B378C16594575FCC7F9C75 /* ShareViewController.swift */; };
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; }; 9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; }; A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; };
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; }; A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; };
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; }; A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; };
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; }; AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; }; AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */; };
AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; }; AA6E067DB034FC0FA23C28A9 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */; };
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */; };
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; }; ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; }; ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; };
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; }; AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
@@ -126,6 +137,10 @@
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; }; D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */; }; D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */; };
D782AB596DDB5C846554F7C3 /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */; }; D782AB596DDB5C846554F7C3 /* NostrIdentity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */; };
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */; };
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */; };
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */; };
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */; }; E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */; };
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */; }; E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */; };
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE7EFB209C86BBD956B749EC /* SecureLogger.swift */; }; EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE7EFB209C86BBD956B749EC /* SecureLogger.swift */; };
@@ -189,6 +204,7 @@
047502B22E55FED60083520F /* GeohashPeopleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashPeopleList.swift; sourceTree = "<group>"; }; 047502B22E55FED60083520F /* GeohashPeopleList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashPeopleList.swift; sourceTree = "<group>"; };
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; }; 047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; }; 047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; }; 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; }; 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; }; 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
@@ -204,6 +220,7 @@
05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; }; 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentityModels.swift; sourceTree = "<group>"; };
0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; }; 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocolTests.swift; sourceTree = "<group>"; };
11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; }; 11186E29A064E8D210880E1B /* BitchatPeer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatPeer.swift; sourceTree = "<group>"; };
1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerDisplayNameResolver.swift; sourceTree = "<group>"; };
136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; }; 136696FC4436A02D98CE6A77 /* KeychainManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeychainManager.swift; sourceTree = "<group>"; };
229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; }; 229F17B68CFF7AB1BC91C847 /* BitchatProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BitchatProtocol.swift; sourceTree = "<group>"; };
2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; }; 2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestHelpers.swift; sourceTree = "<group>"; };
@@ -234,8 +251,11 @@
980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = "<group>"; }; 980B109CBA72BC996455C62B /* BLEServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEServiceTests.swift; sourceTree = "<group>"; };
9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; }; 9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = "<group>"; };
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = "<group>"; };
A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; }; A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = "<group>"; };
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; }; AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = "<group>"; };
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = "<group>"; };
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationViews.swift; sourceTree = "<group>"; };
B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; }; B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = "<group>"; };
C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; }; C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
@@ -243,6 +263,8 @@
C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBluetoothMeshService.swift; sourceTree = "<group>"; }; C27328EE574221395B2B8E87 /* MockBluetoothMeshService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBluetoothMeshService.swift; sourceTree = "<group>"; };
D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicChatE2ETests.swift; sourceTree = "<group>"; }; D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicChatE2ETests.swift; sourceTree = "<group>"; };
D69A18D27F9A565FD6041E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; D69A18D27F9A565FD6041E12 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoRelayDirectory.swift; sourceTree = "<group>"; };
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = "<group>"; };
E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; }; E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatViewModel.swift; sourceTree = "<group>"; };
E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocolTests.swift; sourceTree = "<group>"; }; E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseProtocolTests.swift; sourceTree = "<group>"; };
EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; EA706D8E5097785414646A8E /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
@@ -302,9 +324,11 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
2F82C5FC8433F4064F079D1F /* bitchat */, 2F82C5FC8433F4064F079D1F /* bitchat */,
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */,
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */, A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
C3D98EB3E1B455E321F519F4 /* bitchatTests */, C3D98EB3E1B455E321F519F4 /* bitchatTests */,
9F37F9F2C353B58AC809E93B /* Products */, 9F37F9F2C353B58AC809E93B /* Products */,
048A4BE52E5CCC5C00162C4A /* Recovered References */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@@ -321,8 +345,8 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
3A69677D382F1C3D5ED03F7D /* Assets.xcassets */, 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */,
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */,
527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */, 527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */,
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */,
EF625BB3AD919322C01A46B2 /* BitchatApp.swift */, EF625BB3AD919322C01A46B2 /* BitchatApp.swift */,
EA706D8E5097785414646A8E /* Info.plist */, EA706D8E5097785414646A8E /* Info.plist */,
95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */, 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */,
@@ -387,6 +411,7 @@
9A78348821A7D3374607D4E3 /* Utils */ = { 9A78348821A7D3374607D4E3 /* Utils */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
1234567890ABCDEFFEDCBA02 /* PeerDisplayNameResolver.swift */,
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */, 049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */,
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */, 049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */,
32F149C43D1915831B60FE09 /* CompressionUtil.swift */, 32F149C43D1915831B60FE09 /* CompressionUtil.swift */,
@@ -421,6 +446,7 @@
A55126E93155456CAA8D6656 /* Views */ = { A55126E93155456CAA8D6656 /* Views */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
047502B22E55FED60083520F /* GeohashPeopleList.swift */, 047502B22E55FED60083520F /* GeohashPeopleList.swift */,
047502B32E55FED60083520F /* MeshPeerList.swift */, 047502B32E55FED60083520F /* MeshPeerList.swift */,
0475028E2E5417660083520F /* LocationChannelsSheet.swift */, 0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
@@ -494,6 +520,8 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = { D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
047502B82E560F690083520F /* RelayController.swift */, 047502B82E560F690083520F /* RelayController.swift */,
0475028B2E54171C0083520F /* LocationChannelManager.swift */, 0475028B2E54171C0083520F /* LocationChannelManager.swift */,
049BD3B02E51F319001A566B /* MessageRouter.swift */, 049BD3B02E51F319001A566B /* MessageRouter.swift */,
@@ -515,10 +543,12 @@
E78C7F4B6769C0A72F5DE544 /* Nostr */ = { E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */, 049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */, 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */, 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
78595178957244CBDF7E79B6 /* NostrRelayManager.swift */, 78595178957244CBDF7E79B6 /* NostrRelayManager.swift */,
E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */,
); );
path = Nostr; path = Nostr;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -683,6 +713,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */, 7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */,
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -692,6 +723,7 @@
files = ( files = (
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */, BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */,
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */, E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */,
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -702,6 +734,7 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */,
9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */, 9C7D287C8E67AAE576A5ECB7 /* ShareViewController.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@@ -710,8 +743,14 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */, AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */, 9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */, 049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */,
4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */, 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */,
@@ -756,6 +795,7 @@
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */, D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */, 8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */, AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -763,8 +803,14 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */, ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */, AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */, 049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */, 049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */,
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */, F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */,
@@ -809,6 +855,7 @@
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */, EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */, C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */, AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
+13 -16
View File
@@ -21,6 +21,8 @@ struct BitchatApp: App {
init() { init() {
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 {
@@ -29,6 +31,13 @@ struct BitchatApp: App {
.environmentObject(chatViewModel) .environmentObject(chatViewModel)
.onAppear { .onAppear {
NotificationDelegate.shared.chatViewModel = chatViewModel NotificationDelegate.shared.chatViewModel = chatViewModel
// Inject live Noise service into VerificationService to avoid creating new BLE instances
VerificationService.shared.configure(with: chatViewModel.meshService.getNoiseService())
// Prewarm Nostr identity and QR to make first VERIFY sheet fast
DispatchQueue.global(qos: .utility).async {
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
_ = VerificationService.shared.buildMyQRString(nickname: chatViewModel.nickname, npub: npub)
}
#if os(iOS) #if os(iOS)
appDelegate.chatViewModel = chatViewModel appDelegate.chatViewModel = chatViewModel
#elseif os(macOS) #elseif os(macOS)
@@ -90,30 +99,18 @@ struct BitchatApp: App {
return return
} }
// Only process if shared within last 30 seconds // Only process if shared within configured window
if Date().timeIntervalSince(sharedDate) < 30 { if Date().timeIntervalSince(sharedDate) < TransportConfig.uiShareAcceptWindowSeconds {
let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text" let contentType = userDefaults.string(forKey: "sharedContentType") ?? "text"
// Clear the shared content // Clear the shared content
userDefaults.removeObject(forKey: "sharedContent") userDefaults.removeObject(forKey: "sharedContent")
userDefaults.removeObject(forKey: "sharedContentType") userDefaults.removeObject(forKey: "sharedContentType")
userDefaults.removeObject(forKey: "sharedContentDate") userDefaults.removeObject(forKey: "sharedContentDate")
userDefaults.synchronize() // No need to force synchronize here
// Show notification about shared content // Send the shared content immediately on the main queue
DispatchQueue.main.async { DispatchQueue.main.async {
// Add system message about sharing
let systemMessage = BitchatMessage(
sender: "system",
content: "preparing to share \(contentType)...",
timestamp: Date(),
isRelay: false
)
self.chatViewModel.messages.append(systemMessage)
}
// Send the shared content after a short delay
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
if contentType == "url" { if contentType == "url" {
// Try to parse as JSON first // Try to parse as JSON first
if let data = sharedContent.data(using: .utf8), if let data = sharedContent.data(using: .utf8),
+2 -51
View File
@@ -173,56 +173,7 @@ struct PendingActions {
var setPetname: String? var setPetname: String?
} }
// MARK: - Privacy Settings //
struct PrivacySettings: Codable {
// Level 1: Maximum privacy (default)
var persistIdentityCache = false
var showLastSeen = false
// Level 2: Convenience
var autoAcceptKnownFingerprints = false
var rememberNicknameHistory = false
// Level 3: Social
var shareTrustNetworkHints = false // "3 mutual contacts trust this person"
}
// MARK: - Conflict Resolution
/// Strategies for resolving identity conflicts in the decentralized network.
/// Handles cases where multiple peers claim the same nickname or when
/// identity mappings become ambiguous due to network partitions.
enum ConflictResolution {
case acceptNew(petname: String) // "John (2)"
case rejectNew
case blockFingerprint(String)
case alertUser(message: String)
}
// MARK: - UI State
struct PeerUIState {
let peerID: String
let nickname: String
var identityState: IdentityState
var connectionQuality: ConnectionQuality
enum IdentityState {
case unknown // Gray - No identity info
case unverifiedKnown(String) // Blue - Handshake done, matches cache
case verified(String) // Green - Cryptographically verified
case conflict(String, String) // Red - Nickname doesn't match fingerprint
case pending // Yellow - Handshake in progress
}
}
enum ConnectionQuality {
case excellent
case good
case poor
case disconnected
}
// MARK: - Migration Support // MARK: - Migration Support
// Removed LegacyFavorite - no longer needed //
+2
View File
@@ -37,6 +37,8 @@
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string> <string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSLocationWhenInUseUsageDescription</key> <key>NSLocationWhenInUseUsageDescription</key>
<string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string> <string>bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared.</string>
<key>NSCameraUsageDescription</key>
<string>bitchat uses the camera to scan QR codes to verify peers.</string>
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>bluetooth-central</string> <string>bluetooth-central</string>
+1 -216
View File
@@ -89,219 +89,4 @@ struct BitchatPeer: Identifiable, Equatable {
} }
} }
// MARK: - Peer Manager //
/// Manages the collection of peers and their states
@MainActor
class PeerManager: ObservableObject {
@Published var peers: [BitchatPeer] = []
@Published var favorites: [BitchatPeer] = []
@Published var mutualFavorites: [BitchatPeer] = []
private let meshService: Transport
private let favoritesService = FavoritesPersistenceService.shared
init(meshService: Transport) {
self.meshService = meshService
updatePeers()
// Listen for updates
NotificationCenter.default.addObserver(
self,
selector: #selector(handleFavoriteChanged),
name: .favoriteStatusChanged,
object: nil
)
}
@objc private func handleFavoriteChanged() {
SecureLogger.log("⭐ Favorite status changed notification received, updating peers",
category: SecureLogger.session, level: .debug)
updatePeers()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
func updatePeers() {
// Reduce log verbosity - only log when count changes
let previousCount = peers.count
// Get current mesh peers
let meshPeers = meshService.getPeerNicknames()
// Build peer list
var allPeers: [BitchatPeer] = []
var connectedNicknames: Set<String> = []
var addedPeerIDs: Set<String> = []
// Add connected mesh peers (only if actually connected or relay connected)
for (peerID, nickname) in meshPeers {
guard let noiseKey = Data(hexString: peerID) else { continue }
// Safety check: Never add our own peer ID
if peerID == meshService.myPeerID {
continue
}
// Check if this peer is actually connected
let isConnected = meshService.isPeerConnected(peerID)
// Skip disconnected peers unless they're favorites (handled later)
if !isConnected {
continue
}
if isConnected {
connectedNicknames.insert(nickname)
}
// Track that we've added this peer ID
addedPeerIDs.insert(peerID)
var peer = BitchatPeer(
id: peerID,
noisePublicKey: noiseKey,
nickname: nickname,
isConnected: isConnected
)
// Set favorite status - check both by current noise key and by nickname
if let favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey) {
peer.favoriteStatus = favoriteStatus
peer.nostrPublicKey = favoriteStatus.peerNostrPublicKey
} else {
// Check if we have a favorite for this nickname (peer may have reconnected with new ID)
let favoriteByNickname = favoritesService.favorites.values.first { $0.peerNickname == nickname }
if let favorite = favoriteByNickname {
SecureLogger.log("🔄 Found favorite for '\(nickname)' by nickname, updating noise key",
category: SecureLogger.session, level: .info)
// Update the favorite's noise key to match the current connection
favoritesService.updateNoisePublicKey(from: favorite.peerNoisePublicKey, to: noiseKey, peerNickname: nickname)
// Get the updated favorite with the new key
peer.favoriteStatus = favoritesService.getFavoriteStatus(for: noiseKey)
peer.nostrPublicKey = peer.favoriteStatus?.peerNostrPublicKey ?? favorite.peerNostrPublicKey
}
}
allPeers.append(peer)
}
// Add offline favorites (only those not currently connected AND that we actively favorite)
for (favoriteKey, favorite) in favoritesService.favorites {
let favoriteID = favorite.peerNoisePublicKey.hexEncodedString()
// Skip if this peer is already connected (by nickname)
if connectedNicknames.contains(favorite.peerNickname) {
// Skipping favorite - already connected
continue
}
// Skip if we already added a peer with this ID (prevents duplicates)
if addedPeerIDs.contains(favoriteID) {
// Skipping favorite - peer ID already added
continue
}
// Only add peers that WE favorite (not just ones who favorite us)
if !favorite.isFavorite {
// Skipping - we don't favorite them
continue
}
// Add this favorite as an offline peer
SecureLogger.log(" - Adding offline favorite '\(favorite.peerNickname)' (key: \(favoriteKey.hexEncodedString()), ID: \(favoriteID), mutual: \(favorite.isMutual))",
category: SecureLogger.session, level: .info)
var peer = BitchatPeer(
id: favoriteID,
noisePublicKey: favorite.peerNoisePublicKey,
nickname: favorite.peerNickname,
isConnected: false
)
// Set favorite status
peer.favoriteStatus = favorite
peer.nostrPublicKey = favorite.peerNostrPublicKey
addedPeerIDs.insert(favoriteID) // Track that we've added this ID
allPeers.append(peer)
}
// Filter out "Unknown" peers unless they are favorites or have a favorite relationship
allPeers = allPeers.filter { peer in
!(peer.displayName == "Unknown" && peer.favoriteStatus == nil)
}
// Sort: Connected first, then favorites, then alphabetical
allPeers.sort { lhs, rhs in
// Direct connections first
if lhs.isConnected != rhs.isConnected {
return lhs.isConnected
}
// Then favorites
if lhs.isFavorite != rhs.isFavorite {
return lhs.isFavorite
}
// Finally alphabetical
return lhs.displayName < rhs.displayName
}
// Single pass to compute all subsets and counts
var favorites: [BitchatPeer] = []
var mutualFavorites: [BitchatPeer] = []
var connectedCount = 0
var offlineCount = 0
for peer in allPeers {
if peer.isFavorite {
favorites.append(peer)
}
if peer.isMutualFavorite {
mutualFavorites.append(peer)
}
if peer.isConnected {
connectedCount += 1
} else {
offlineCount += 1
}
}
// Final safety check: ensure no duplicate IDs
var finalPeers: [BitchatPeer] = []
var seenIDs: Set<String> = []
for peer in allPeers {
if !seenIDs.contains(peer.id) {
seenIDs.insert(peer.id)
finalPeers.append(peer)
} else {
SecureLogger.log("⚠️ Removing duplicate peer ID in final check: \(peer.id) (\(peer.displayName))",
category: SecureLogger.session, level: .warning)
}
}
self.peers = finalPeers
self.favorites = favorites
self.mutualFavorites = mutualFavorites
// Log peer list summary sparingly at debug level
if favoritesService.favorites.count > 0 {
SecureLogger.log("📊 Peer list update: \(allPeers.count) total (\(connectedCount) connected, \(offlineCount) offline), \(favorites.count) favorites, \(mutualFavorites.count) mutual",
category: SecureLogger.session, level: .debug)
} else if previousCount != allPeers.count {
SecureLogger.log("✅ Updated peer list: \(allPeers.count) total peers",
category: SecureLogger.session, level: .debug)
}
}
func toggleFavorite(_ peer: BitchatPeer) {
if peer.isFavorite {
favoritesService.removeFavorite(peerNoisePublicKey: peer.noisePublicKey)
} else {
favoritesService.addFavorite(
peerNoisePublicKey: peer.noisePublicKey,
peerNostrPublicKey: peer.nostrPublicKey,
peerNickname: peer.nickname
)
}
updatePeers()
}
}
+152
View File
@@ -0,0 +1,152 @@
import Foundation
/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing.
@MainActor
final class GeoRelayDirectory {
struct Entry: Hashable {
let host: String
let lat: Double
let lon: Double
}
static let shared = GeoRelayDirectory()
private(set) var entries: [Entry] = []
private let cacheFileName = "georelays_cache.csv"
private let lastFetchKey = "georelay.lastFetchAt"
private let remoteURL = URL(string: "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv")!
private let fetchInterval: TimeInterval = TransportConfig.geoRelayFetchIntervalSeconds // 24h
private init() {
// Load cached or bundled data synchronously
self.entries = self.loadLocalEntries()
// Fire-and-forget remote refresh if stale
prefetchIfNeeded()
}
/// Returns up to `count` relay URLs (wss://) closest to the geohash center.
func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] {
let center = Geohash.decodeCenter(geohash)
return closestRelays(toLat: center.lat, lon: center.lon, count: count)
}
/// Returns up to `count` relay URLs (wss://) closest to the given coordinate.
func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] {
guard !entries.isEmpty else { return [] }
let sorted = entries
.sorted { a, b in
haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon)
}
.prefix(count)
return sorted.map { "wss://\($0.host)" }
}
// MARK: - Remote Fetch
func prefetchIfNeeded() {
let now = Date()
let last = UserDefaults.standard.object(forKey: lastFetchKey) as? Date ?? .distantPast
guard now.timeIntervalSince(last) >= fetchInterval else { return }
fetchRemote()
}
private func fetchRemote() {
let req = URLRequest(url: remoteURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 15)
let task = URLSession.shared.dataTask(with: req) { [weak self] data, _, error in
guard let self = self else { return }
if let data = data, error == nil, let text = String(data: data, encoding: .utf8) {
let parsed = GeoRelayDirectory.parseCSV(text)
if !parsed.isEmpty {
Task { @MainActor in
self.entries = parsed
self.persistCache(text)
UserDefaults.standard.set(Date(), forKey: self.lastFetchKey)
SecureLogger.log("GeoRelayDirectory: refreshed \(parsed.count) relays from remote", category: SecureLogger.session, level: .info)
}
return
}
}
SecureLogger.log("GeoRelayDirectory: remote fetch failed; keeping local entries", category: SecureLogger.session, level: .warning)
}
task.resume()
}
private func persistCache(_ text: String) {
guard let url = cacheURL() else { return }
do {
try text.data(using: .utf8)?.write(to: url, options: .atomic)
} catch {
SecureLogger.log("GeoRelayDirectory: failed to write cache: \(error)", category: SecureLogger.session, level: .warning)
}
}
// MARK: - Loading
private func loadLocalEntries() -> [Entry] {
// Prefer cached file if present
if let cache = self.cacheURL(),
let data = try? Data(contentsOf: cache),
let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr }
}
// Try bundled resource(s)
let bundleCandidates = [
Bundle.main.url(forResource: "nostr_relays", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv"),
Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays")
].compactMap { $0 }
for url in bundleCandidates {
if let data = try? Data(contentsOf: url), let text = String(data: data, encoding: .utf8) {
let arr = Self.parseCSV(text)
if !arr.isEmpty { return arr }
}
}
// Try filesystem path (development/test)
if let cwd = FileManager.default.currentDirectoryPath as String?,
let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")),
let text = String(data: data, encoding: .utf8) {
return Self.parseCSV(text)
}
SecureLogger.log("GeoRelayDirectory: no local CSV found; entries empty", category: SecureLogger.session, level: .warning)
return []
}
nonisolated static func parseCSV(_ text: String) -> [Entry] {
var result: Set<Entry> = []
let lines = text.split(whereSeparator: { $0.isNewline })
// Skip header if present
for (idx, raw) in lines.enumerated() {
let line = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if line.isEmpty { continue }
if idx == 0 && line.lowercased().contains("relay url") { continue }
let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }
guard parts.count >= 3 else { continue }
var host = parts[0]
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 }
result.insert(Entry(host: host, lat: lat, lon: lon))
}
return Array(result)
}
private func cacheURL() -> URL? {
do {
let base = try FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let dir = base.appendingPathComponent("bitchat", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent(cacheFileName)
} catch { return nil }
}
}
// MARK: - Distance
private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double {
let r = 6371.0 // Earth radius in km
let dLat = (lat2 - lat1) * .pi / 180
let dLon = (lon2 - lon1) * .pi / 180
let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2)
let c = 2 * atan2(sqrt(a), sqrt(1 - a))
return r * c
}
+101 -131
View File
@@ -1,6 +1,7 @@
import Foundation import Foundation
import CryptoKit import CryptoKit
import P256K import P256K
import Security
// Note: This file depends on Data extension from BinaryEncodingUtils.swift // Note: This file depends on Data extension from BinaryEncodingUtils.swift
// Make sure BinaryEncodingUtils.swift is included in the target // Make sure BinaryEncodingUtils.swift is included in the target
@@ -12,8 +13,9 @@ struct NostrProtocol {
enum EventKind: Int { enum EventKind: Int {
case metadata = 0 case metadata = 0
case textNote = 1 case textNote = 1
case dm = 14 // NIP-17 DM rumor kind
case seal = 13 // NIP-17 sealed event case seal = 13 // NIP-17 sealed event
case giftWrap = 1059 // NIP-17 gift wrap case giftWrap = 1059 // NIP-59 gift wrap
case ephemeralEvent = 20000 case ephemeralEvent = 20000
} }
@@ -30,7 +32,7 @@ struct NostrProtocol {
let rumor = NostrEvent( let rumor = NostrEvent(
pubkey: senderIdentity.publicKeyHex, pubkey: senderIdentity.publicKeyHex,
createdAt: Date(), createdAt: Date(),
kind: .textNote, kind: .dm, // NIP-17: DM rumor kind 14
tags: [], tags: [],
content: content content: content
) )
@@ -120,8 +122,8 @@ struct NostrProtocol {
tags: tags, tags: tags,
content: content content: content
) )
let signingKey = try senderIdentity.signingKey() let schnorrKey = try senderIdentity.schnorrSigningKey()
return try event.sign(with: signingKey) return try event.sign(with: schnorrKey)
} }
// MARK: - Private Methods // MARK: - Private Methods
@@ -147,9 +149,8 @@ struct NostrProtocol {
content: encrypted content: encrypted
) )
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method) // Sign the seal with the sender's Schnorr private key
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: senderKey.dataRepresentation) return try seal.sign(with: senderKey)
return try seal.sign(with: signingKey)
} }
private static func createGiftWrap( private static func createGiftWrap(
@@ -179,9 +180,8 @@ struct NostrProtocol {
content: encrypted content: encrypted
) )
// Convert to P256K.Signing.PrivateKey for signing (temporary until we update sign method) // Sign the gift wrap with the wrap Schnorr private key
let signingKey = try P256K.Signing.PrivateKey(dataRepresentation: wrapKey.dataRepresentation) return try giftWrap.sign(with: wrapKey)
return try giftWrap.sign(with: signingKey)
} }
private static func unwrapGiftWrap( private static func unwrapGiftWrap(
@@ -227,7 +227,7 @@ struct NostrProtocol {
return try NostrEvent(from: rumorDict) return try NostrEvent(from: rumorDict)
} }
// MARK: - Encryption (NIP-44 style) // MARK: - Encryption (NIP-44 v2)
private static func encrypt( private static func encrypt(
plaintext: String, plaintext: String,
@@ -239,33 +239,31 @@ struct NostrProtocol {
throw NostrError.invalidPublicKey throw NostrError.invalidPublicKey
} }
// Encrypting message // Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Derive shared secret // Derive shared secret
let sharedSecret = try deriveSharedSecret( let sharedSecret = try deriveSharedSecret(
privateKey: senderKey, privateKey: senderKey,
publicKey: recipientPubkeyData publicKey: recipientPubkeyData
) )
// Derive NIP-44 v2 symmetric key (HKDF-SHA256 with label in info)
let key = try deriveNIP44V2Key(from: sharedSecret)
// Derived shared secret // 24-byte random nonce for XChaCha20-Poly1305
var nonce24 = Data(count: 24)
_ = nonce24.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 24, ptr.baseAddress!)
}
// Generate nonce let pt = Data(plaintext.utf8)
let nonce = AES.GCM.Nonce() let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
// Encrypt // v2: base64url(nonce24 || ciphertext || tag)
let sealed = try AES.GCM.seal( var combined = Data()
plaintext.data(using: .utf8)!, combined.append(nonce24)
using: SymmetricKey(data: sharedSecret), combined.append(sealed.ciphertext)
nonce: nonce combined.append(sealed.tag)
) return "v2:" + base64URLEncode(combined)
// Combine nonce + ciphertext + tag
var result = Data()
result.append(nonce.withUnsafeBytes { Data($0) })
result.append(sealed.ciphertext)
result.append(sealed.tag)
return result.base64EncodedString()
} }
private static func decrypt( private static func decrypt(
@@ -273,86 +271,45 @@ struct NostrProtocol {
senderPubkey: String, senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey recipientKey: P256K.Schnorr.PrivateKey
) throws -> String { ) throws -> String {
// Expect NIP-44 v2 format
// Decrypting message guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let encoded = String(ciphertext.dropFirst(3))
guard let data = Data(base64Encoded: ciphertext), guard let data = base64URLDecode(encoded),
data.count > (24 + 16),
let senderPubkeyData = Data(hexString: senderPubkey) else { let senderPubkeyData = Data(hexString: senderPubkey) else {
SecureLogger.log("❌ Invalid ciphertext or sender pubkey format",
category: SecureLogger.session, level: .error)
throw NostrError.invalidCiphertext throw NostrError.invalidCiphertext
} }
// Ciphertext data parsed let nonce24 = data.prefix(24)
let rest = data.dropFirst(24)
// Extract components let tag = rest.suffix(16)
let nonceData = data.prefix(12) let ct = rest.dropLast(16)
let ciphertextData = data.dropFirst(12).dropLast(16)
let tagData = data.suffix(16) // Try decryption with even-Y then odd-Y when sender pubkey is x-only
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
// Components parsed let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
let key = try deriveNIP44V2Key(from: ss)
// Derive shared secret - try with default Y coordinate first return try XChaCha20Poly1305Compat.open(
var sharedSecret: Data ciphertext: Data(ct),
var decrypted: Data? = nil tag: Data(tag),
key: key,
do { nonce24: Data(nonce24)
sharedSecret = try deriveSharedSecret(
privateKey: recipientKey,
publicKey: senderPubkeyData
) )
// Derived shared secret with first Y coordinate }
// Try to decrypt // If 32 bytes (x-only) try both parities, otherwise single try
let sealedBox = try AES.GCM.SealedBox( if senderPubkeyData.count == 32 {
nonce: AES.GCM.Nonce(data: nonceData), let even = Data([0x02]) + senderPubkeyData
ciphertext: ciphertextData, if let pt = try? attemptDecrypt(using: even) {
tag: tagData return String(data: pt, encoding: .utf8) ?? ""
)
do {
decrypted = try AES.GCM.open(
sealedBox,
using: SymmetricKey(data: sharedSecret)
)
// AES-GCM decryption successful
} catch {
// AES-GCM decryption failed, trying alternate
// If the sender pubkey is x-only (32 bytes), try the other Y coordinate
if senderPubkeyData.count == 32 {
// Trying alternate Y coordinate
// Force deriveSharedSecret to use odd Y by manipulating the data
var altPubkey = Data()
altPubkey.append(0x03) // Force odd Y
altPubkey.append(senderPubkeyData)
sharedSecret = try deriveSharedSecretDirect(
privateKey: recipientKey,
publicKey: altPubkey
)
decrypted = try AES.GCM.open(
sealedBox,
using: SymmetricKey(data: sharedSecret)
)
// AES-GCM decryption successful with alternate Y
} else {
throw error
}
} }
} catch { let odd = Data([0x03]) + senderPubkeyData
SecureLogger.log("❌ Failed to derive shared secret or decrypt: \(error)", let pt = try attemptDecrypt(using: odd)
category: SecureLogger.session, level: .error) return String(data: pt, encoding: .utf8) ?? ""
throw error } else {
let pt = try attemptDecrypt(using: senderPubkeyData)
return String(data: pt, encoding: .utf8) ?? ""
} }
guard let finalDecrypted = decrypted else {
throw NostrError.encryptionFailed
}
return String(data: finalDecrypted, encoding: .utf8) ?? ""
} }
private static func deriveSharedSecret( private static func deriveSharedSecret(
@@ -412,17 +369,8 @@ struct NostrProtocol {
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// ECDH shared secret derived // ECDH shared secret derived
// Derive key using HKDF for NIP-44 v2 // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey( return sharedSecretData
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: "nip44-v2".data(using: .utf8)!,
info: Data(),
outputByteCount: 32
)
let result = derivedKey.withUnsafeBytes { Data($0) }
// Final derived key ready
return result
} }
// Direct version that doesn't try to add prefixes // Direct version that doesn't try to add prefixes
@@ -452,15 +400,8 @@ struct NostrProtocol {
// Convert SharedSecret to Data // Convert SharedSecret to Data
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) } let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// Derive key using HKDF for NIP-44 v2 // Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey( return sharedSecretData
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: "nip44-v2".data(using: .utf8)!,
info: Data(),
outputByteCount: 32
)
return derivedKey.withUnsafeBytes { Data($0) }
} }
private static func randomizedTimestamp() -> Date { private static func randomizedTimestamp() -> Date {
@@ -473,7 +414,7 @@ struct NostrProtocol {
// Log with explicit UTC and local time for debugging // Log with explicit UTC and local time for debugging
let formatter = DateFormatter() let formatter = DateFormatter()
// Removed unnecessary date formatting operations //
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.timeZone = TimeZone(abbreviation: "UTC") formatter.timeZone = TimeZone(abbreviation: "UTC")
@@ -529,16 +470,16 @@ struct NostrEvent: Codable {
self.sig = dict["sig"] as? String self.sig = dict["sig"] as? String
} }
func sign(with key: P256K.Signing.PrivateKey) throws -> NostrEvent { func sign(with key: P256K.Schnorr.PrivateKey) throws -> NostrEvent {
let (eventId, eventIdHash) = try calculateEventId() let (eventId, eventIdHash) = try calculateEventId()
// Convert to Schnorr key for Nostr signing // Sign with Schnorr (BIP-340)
let schnorrKey = try P256K.Schnorr.PrivateKey(dataRepresentation: key.dataRepresentation)
// Sign with Schnorr
var messageBytes = [UInt8](eventIdHash) var messageBytes = [UInt8](eventIdHash)
var auxRand = [UInt8](repeating: 0, count: 32) // Zero auxiliary randomness for deterministic signing var auxRand = [UInt8](repeating: 0, count: 32)
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand) _ = auxRand.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
let schnorrSignature = try key.signature(message: &messageBytes, auxiliaryRand: &auxRand)
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString() let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
@@ -581,3 +522,32 @@ enum NostrError: Error {
case signingFailed case signingFailed
case encryptionFailed case encryptionFailed
} }
// MARK: - NIP-44 v2 helpers (XChaCha20-Poly1305 + base64url)
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 {
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: Data(),
info: "nip44-v2".data(using: .utf8)!,
outputByteCount: 32
)
return derivedKey.withUnsafeBytes { Data($0) }
}
}
+34 -13
View File
@@ -48,10 +48,10 @@ class NostrRelayManager: ObservableObject {
private let messageQueueLock = NSLock() private let messageQueueLock = NSLock()
// Exponential backoff configuration // Exponential backoff configuration
private let initialBackoffInterval: TimeInterval = 1.0 // Start with 1 second private let initialBackoffInterval: TimeInterval = TransportConfig.nostrRelayInitialBackoffSeconds
private let maxBackoffInterval: TimeInterval = 300.0 // Max 5 minutes private let maxBackoffInterval: TimeInterval = TransportConfig.nostrRelayMaxBackoffSeconds
private let backoffMultiplier: Double = 2.0 // Double each time private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
private let maxReconnectAttempts = 10 // Stop after 10 attempts private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
// Reconnection timer // Reconnection timer
private var reconnectionTimer: Timer? private var reconnectionTimer: Timer?
@@ -78,9 +78,23 @@ class NostrRelayManager: ObservableObject {
updateConnectionStatus() updateConnectionStatus()
} }
/// Ensure connections exist to the given relay URLs (idempotent).
func ensureConnections(to relayUrls: [String]) {
let existing = Set(relays.map { $0.url })
for url in Set(relayUrls) {
if !existing.contains(url) {
relays.append(Relay(url: url))
}
if connections[url] == nil {
connectToRelay(url)
}
}
}
/// Send an event to specified relays (or all if none specified) /// Send an event to specified relays (or all if none specified)
func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) {
let targetRelays = relayUrls ?? relays.map { $0.url } let targetRelays = relayUrls ?? Self.defaultRelays
ensureConnections(to: targetRelays)
// Add to queue for reliability // Add to queue for reliability
messageQueueLock.lock() messageQueueLock.lock()
@@ -95,10 +109,11 @@ class NostrRelayManager: ObservableObject {
} }
} }
/// Subscribe to events matching a filter /// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays.
func subscribe( func subscribe(
filter: NostrFilter, filter: NostrFilter,
id: String = UUID().uuidString, id: String = UUID().uuidString,
relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void handler: @escaping (NostrEvent) -> Void
) { ) {
messageHandlers[id] = handler messageHandlers[id] = handler
@@ -120,8 +135,14 @@ class NostrRelayManager: ObservableObject {
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...", // SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
// category: SecureLogger.session, level: .debug) // category: SecureLogger.session, level: .debug)
// Send subscription to all connected relays // Target specific relays if provided; else all connections
for (relayUrl, connection) in connections { let urls = relayUrls ?? Self.defaultRelays
ensureConnections(to: urls)
let targets: [(String, URLSessionWebSocketTask)] = urls.compactMap { url in
connections[url].map { (url, $0) }
}
for (relayUrl, connection) in targets {
connection.send(.string(messageString)) { error in connection.send(.string(messageString)) { error in
if let error = error { if let error = error {
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)", SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
@@ -268,7 +289,7 @@ class NostrRelayManager: ObservableObject {
// Only log non-gift-wrap events to reduce noise // Only log non-gift-wrap events to reduce noise
if event.kind != 1059 { if event.kind != 1059 {
SecureLogger.log("📥 Received Nostr event (kind: \(event.kind)) from relay: \(relayUrl)", SecureLogger.log("📥 Event kind=\(event.kind) id=\(event.id.prefix(16)) relay=\(relayUrl)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
} }
@@ -300,11 +321,11 @@ class NostrRelayManager: ObservableObject {
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given" let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
if success { if success {
_ = Self.pendingGiftWrapIDs.remove(eventId) _ = Self.pendingGiftWrapIDs.remove(eventId)
SecureLogger.log("Event accepted id=\(eventId.prefix(16))... by relay: \(relayUrl)", SecureLogger.log("Accepted id=\(eventId.prefix(16)) relay=\(relayUrl)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
} else { } else {
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
SecureLogger.log("📮 Event \(eventId.prefix(16))... rejected by relay: \(reason)", SecureLogger.log("📮 Rejected id=\(eventId.prefix(16))… reason=\(reason)",
category: SecureLogger.session, level: isGiftWrap ? .warning : .error) category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
} }
} }
@@ -332,7 +353,7 @@ class NostrRelayManager: ObservableObject {
let data = try encoder.encode(req) let data = try encoder.encode(req)
let message = String(data: data, encoding: .utf8) ?? "" let message = String(data: data, encoding: .utf8) ?? ""
SecureLogger.log("📤 Sending Nostr event (kind: \(event.kind)) to relay: \(relayUrl)", SecureLogger.log("📤 Send kind=\(event.kind) id=\(event.id.prefix(16)) relay=\(relayUrl)",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
connection.send(.string(message)) { [weak self] error in connection.send(.string(message)) { [weak self] error in
@@ -547,7 +568,7 @@ struct NostrFilter: Encodable {
filter.kinds = [1059] // Gift wrap kind filter.kinds = [1059] // Gift wrap kind
filter.since = since?.timeIntervalSince1970.toInt() filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["p": [pubkey]] filter.tagFilters = ["p": [pubkey]]
filter.limit = 100 // Add a reasonable limit filter.limit = TransportConfig.nostrRelayDefaultFetchLimit // reasonable limit
return filter return filter
} }
+116
View File
@@ -0,0 +1,116 @@
import Foundation
import CryptoKit
/// Minimal XChaCha20-Poly1305 compatibility wrapper using CryptoKit's ChaChaPoly.
/// Implements HChaCha20 to derive a subkey and reduces the 24-byte nonce to a 12-byte nonce
/// as per XChaCha20 construction.
enum XChaCha20Poly1305Compat {
struct SealBox {
let ciphertext: Data
let tag: Data
}
static func seal(plaintext: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> SealBox {
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
let nonce12 = derive12ByteNonce(from24: nonce24)
let chachaKey = SymmetricKey(data: subkey)
let nonce = try ChaChaPoly.Nonce(data: nonce12)
let sealed = try ChaChaPoly.seal(plaintext, using: chachaKey, nonce: nonce, authenticating: aad ?? Data())
return SealBox(ciphertext: sealed.ciphertext, tag: sealed.tag)
}
static func open(ciphertext: Data, tag: Data, key: Data, nonce24: Data, aad: Data? = nil) throws -> Data {
precondition(key.count == 32, "XChaCha20 key must be 32 bytes")
precondition(nonce24.count == 24, "XChaCha20 nonce must be 24 bytes")
let subkey = hchacha20(key: key, nonce16: nonce24.prefix(16))
let nonce12 = derive12ByteNonce(from24: nonce24)
let chachaKey = SymmetricKey(data: subkey)
let box = try ChaChaPoly.SealedBox(nonce: ChaChaPoly.Nonce(data: nonce12), ciphertext: ciphertext, tag: tag)
return try ChaChaPoly.open(box, using: chachaKey, authenticating: aad ?? Data())
}
// MARK: - Internals
private static func derive12ByteNonce(from24 nonce24: Data) -> Data {
// XChaCha20-Poly1305: 12-byte nonce = 4 zero bytes || last 8 bytes of the 24-byte nonce
var out = Data(count: 12)
out.replaceSubrange(0..<4, with: [0, 0, 0, 0])
out.replaceSubrange(4..<12, with: nonce24.suffix(8))
return out
}
private static func hchacha20(key: Data, nonce16: Data) -> Data {
// HChaCha20 based on the original ChaCha20 core with a 16-byte nonce.
precondition(key.count == 32)
precondition(nonce16.count == 16)
// Constants "expand 32-byte k"
var state: [UInt32] = [
0x61707865, 0x3320646e, 0x79622d32, 0x6b206574,
// key (8 words)
key.loadLEWord(0), key.loadLEWord(4), key.loadLEWord(8), key.loadLEWord(12),
key.loadLEWord(16), key.loadLEWord(20), key.loadLEWord(24), key.loadLEWord(28),
// nonce (4 words)
nonce16.loadLEWord(0), nonce16.loadLEWord(4), nonce16.loadLEWord(8), nonce16.loadLEWord(12)
]
// 20 rounds (10 double rounds)
for _ in 0..<10 {
// Column rounds
quarterRound(&state, 0, 4, 8, 12)
quarterRound(&state, 1, 5, 9, 13)
quarterRound(&state, 2, 6, 10, 14)
quarterRound(&state, 3, 7, 11, 15)
// Diagonal rounds
quarterRound(&state, 0, 5, 10, 15)
quarterRound(&state, 1, 6, 11, 12)
quarterRound(&state, 2, 7, 8, 13)
quarterRound(&state, 3, 4, 9, 14)
}
// Output subkey: state[0..3] and state[12..15]
var out = Data(count: 32)
out.storeLEWord(state[0], at: 0)
out.storeLEWord(state[1], at: 4)
out.storeLEWord(state[2], at: 8)
out.storeLEWord(state[3], at: 12)
out.storeLEWord(state[12], at: 16)
out.storeLEWord(state[13], at: 20)
out.storeLEWord(state[14], at: 24)
out.storeLEWord(state[15], at: 28)
return out
}
private static func quarterRound(_ s: inout [UInt32], _ a: Int, _ b: Int, _ c: Int, _ d: Int) {
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 16) | (s[d] >> 16)
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 12) | (s[b] >> 20)
s[a] = s[a] &+ s[b]; s[d] ^= s[a]; s[d] = (s[d] << 8) | (s[d] >> 24)
s[c] = s[c] &+ s[d]; s[b] ^= s[c]; s[b] = (s[b] << 7) | (s[b] >> 25)
}
}
private extension Data {
func loadLEWord(_ offset: Int) -> UInt32 {
let range = offset..<(offset+4)
let bytes = self[range]
return bytes.withUnsafeBytes { ptr -> UInt32 in
let b = ptr.bindMemory(to: UInt8.self)
return UInt32(b[0]) | (UInt32(b[1]) << 8) | (UInt32(b[2]) << 16) | (UInt32(b[3]) << 24)
}
}
mutating func storeLEWord(_ value: UInt32, at offset: Int) {
let bytes: [UInt8] = [
UInt8(value & 0xff),
UInt8((value >> 8) & 0xff),
UInt8((value >> 16) & 0xff),
UInt8((value >> 24) & 0xff)
]
replaceSubrange(offset..<(offset+4), with: bytes)
}
}
+8 -5
View File
@@ -155,12 +155,17 @@ enum NoisePayloadType: UInt8 {
case privateMessage = 0x01 // Private chat message case privateMessage = 0x01 // Private chat message
case readReceipt = 0x02 // Message was read case readReceipt = 0x02 // Message was read
case delivered = 0x03 // Message was delivered case delivered = 0x03 // Message was delivered
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
var description: String { var description: String {
switch self { switch self {
case .privateMessage: return "privateMessage" case .privateMessage: return "privateMessage"
case .readReceipt: return "readReceipt" case .readReceipt: return "readReceipt"
case .delivered: return "delivered" case .delivered: return "delivered"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
} }
} }
} }
@@ -176,8 +181,7 @@ enum LazyHandshakeState {
case failed(Error) // Handshake failed case failed(Error) // Handshake failed
} }
// MARK: - Special Recipients (removed) //
// Previously defined broadcast identifiers were unused; removed for simplicity.
// MARK: - Core Protocol Structures // MARK: - Core Protocol Structures
@@ -263,8 +267,7 @@ struct BitchatPacket: Codable {
} }
} }
// MARK: - Delivery Acknowledgments (removed) //
// Legacy DeliveryAck structures are no longer used; delivery status flows via Noise payloads.
// MARK: - Read Receipts // MARK: - Read Receipts
@@ -356,7 +359,7 @@ struct ReadReceipt: Codable {
} }
// PeerIdentityBinding removed (unused). //
// MARK: - Delivery Status // MARK: - Delivery Status
+31 -1
View File
@@ -4,6 +4,11 @@ import Foundation
/// Encodes latitude/longitude to base32 geohash with a fixed precision. /// Encodes latitude/longitude to base32 geohash with a fixed precision.
enum Geohash { enum Geohash {
private static let base32Chars = Array("0123456789bcdefghjkmnpqrstuvwxyz") private static let base32Chars = Array("0123456789bcdefghjkmnpqrstuvwxyz")
private static let base32Map: [Character: Int] = {
var map: [Character: Int] = [:]
for (i, c) in base32Chars.enumerated() { map[c] = i }
return map
}()
/// Encodes the provided coordinates into a geohash string. /// Encodes the provided coordinates into a geohash string.
/// - Parameters: /// - Parameters:
@@ -56,5 +61,30 @@ enum Geohash {
return String(geohash) return String(geohash)
} }
}
/// Decodes a geohash into the center latitude/longitude of its bounding box.
/// - Parameter geohash: Base32 geohash string.
/// - Returns: (lat, lon) center coordinate.
static func decodeCenter(_ geohash: String) -> (lat: Double, lon: Double) {
var latInterval: (Double, Double) = (-90.0, 90.0)
var lonInterval: (Double, Double) = (-180.0, 180.0)
var isEven = true
for ch in geohash.lowercased() {
guard let cd = base32Map[ch] else { continue }
for mask in [16, 8, 4, 2, 1] {
if isEven {
let mid = (lonInterval.0 + lonInterval.1) / 2
if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid }
} else {
let mid = (latInterval.0 + latInterval.1) / 2
if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid }
}
isEven.toggle()
}
}
let lat = (latInterval.0 + latInterval.1) / 2
let lon = (lonInterval.0 + lonInterval.1) / 2
return (lat, lon)
}
}
+3 -3
View File
@@ -14,7 +14,7 @@ class AutocompleteService {
private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: []) private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: [])
private let commands = [ private let commands = [
"/msg", "/who", "/clear", "/help", "/msg", "/who", "/clear",
"/hug", "/slap", "/fav", "/unfav", "/hug", "/slap", "/fav", "/unfav",
"/block", "/unblock" "/block", "/unblock"
] ]
@@ -95,10 +95,10 @@ class AutocompleteService {
private func needsArgument(command: String) -> Bool { private func needsArgument(command: String) -> Bool {
switch command { switch command {
case "/who", "/clear", "/help": case "/who", "/clear":
return false return false
default: default:
return true return true
} }
} }
} }
+324 -94
View File
@@ -22,12 +22,12 @@ final class BLEService: NSObject {
static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D") static let characteristicUUID = CBUUID(string: "A1B2C3D4-E5F6-4A5B-8C9D-0E1F2A3B4C5D")
// Default per-fragment chunk size when link limits are unknown // Default per-fragment chunk size when link limits are unknown
private let defaultFragmentSize = 469 // ~512 MTU minus protocol overhead private let defaultFragmentSize = TransportConfig.bleDefaultFragmentSize
private let maxMessageLength = 10_000 private let maxMessageLength = InputValidator.Limits.maxMessageLength
private let messageTTL: UInt8 = 7 private let messageTTL: UInt8 = TransportConfig.messageTTLDefault
// Flood/battery controls // Flood/battery controls
private let maxInFlightAssemblies = 128 // cap concurrent fragment assemblies private let maxInFlightAssemblies = TransportConfig.bleMaxInFlightAssemblies // cap concurrent fragment assemblies
private let highDegreeThreshold = 6 // for adaptive TTL/probabilistic relays private let highDegreeThreshold = TransportConfig.bleHighDegreeThreshold // for adaptive TTL/probabilistic relays
// MARK: - Core State (5 Essential Collections) // MARK: - Core State (5 Essential Collections)
@@ -70,7 +70,7 @@ final class BLEService: NSObject {
// Simple announce throttling // Simple announce throttling
private var lastAnnounceSent = Date.distantPast private var lastAnnounceSent = Date.distantPast
private let announceMinInterval: TimeInterval = 1.0 private let announceMinInterval: TimeInterval = TransportConfig.bleAnnounceMinInterval
// Application state tracking (thread-safe) // Application state tracking (thread-safe)
#if os(iOS) #if os(iOS)
@@ -112,6 +112,16 @@ final class BLEService: NSObject {
private var scheduledRelays: [String: DispatchWorkItem] = [:] private var scheduledRelays: [String: DispatchWorkItem] = [:]
// Track short-lived traffic bursts to adapt announces/scanning under load // Track short-lived traffic bursts to adapt announces/scanning under load
private var recentPacketTimestamps: [Date] = [] private var recentPacketTimestamps: [Date] = []
// Ingress link tracking for last-hop suppression
private enum LinkID: Hashable {
case peripheral(String)
case central(String)
}
private var ingressByMessageID: [String: (link: LinkID, timestamp: Date)] = [:]
// Backpressure-aware write queue per peripheral
private var pendingPeripheralWrites: [String: [Data]] = [:]
// MARK: - Maintenance Timer // MARK: - Maintenance Timer
@@ -119,8 +129,8 @@ final class BLEService: NSObject {
private var maintenanceCounter = 0 // Track maintenance cycles private var maintenanceCounter = 0 // Track maintenance cycles
// MARK: - Connection budget & scheduling (central role) // MARK: - Connection budget & scheduling (central role)
private let maxCentralLinks = 6 private let maxCentralLinks = TransportConfig.bleMaxCentralLinks
private let connectRateLimitInterval: TimeInterval = 0.5 private let connectRateLimitInterval: TimeInterval = TransportConfig.bleConnectRateLimitInterval
private var lastGlobalConnectAttempt: Date = .distantPast private var lastGlobalConnectAttempt: Date = .distantPast
private struct ConnectionCandidate { private struct ConnectionCandidate {
let peripheral: CBPeripheral let peripheral: CBPeripheral
@@ -132,13 +142,13 @@ final class BLEService: NSObject {
private var connectionCandidates: [ConnectionCandidate] = [] private var connectionCandidates: [ConnectionCandidate] = []
private var failureCounts: [String: Int] = [:] // Peripheral UUID -> failures private var failureCounts: [String: Int] = [:] // Peripheral UUID -> failures
private var lastIsolatedAt: Date? = nil private var lastIsolatedAt: Date? = nil
private var dynamicRSSIThreshold: Int = -90 private var dynamicRSSIThreshold: Int = TransportConfig.bleDynamicRSSIThresholdDefault
// MARK: - Adaptive scanning duty-cycle // MARK: - Adaptive scanning duty-cycle
private var scanDutyTimer: DispatchSourceTimer? private var scanDutyTimer: DispatchSourceTimer?
private var dutyEnabled: Bool = true private var dutyEnabled: Bool = true
private var dutyOnDuration: TimeInterval = 5 private var dutyOnDuration: TimeInterval = TransportConfig.bleDutyOnDuration
private var dutyOffDuration: TimeInterval = 10 private var dutyOffDuration: TimeInterval = TransportConfig.bleDutyOffDuration
private var dutyActive: Bool = false private var dutyActive: Bool = false
// MARK: - Link capability snapshots (thread-safe via bleQueue) // MARK: - Link capability snapshots (thread-safe via bleQueue)
@@ -156,6 +166,88 @@ final class BLEService: NSObject {
return bleQueue.sync { (self.subscribedCentrals, self.centralToPeerID) } return bleQueue.sync { (self.subscribedCentrals, self.centralToPeerID) }
} }
} }
// MARK: - Helpers: IDs, selection, and write backpressure
private func makeMessageID(for packet: BitchatPacket) -> String {
let senderID = packet.senderID.hexEncodedString()
return "\(senderID)-\(packet.timestamp)-\(packet.type)"
}
private func subsetSizeForFanout(_ n: Int) -> Int {
guard n > 0 else { return 0 }
if n <= 2 { return n }
// approx ceil(log2(n)) + 1 without floating point
var v = n - 1
var bits = 0
while v > 0 { v >>= 1; bits += 1 }
return min(n, max(1, bits + 1))
}
private func selectDeterministicSubset(ids: [String], k: Int, seed: String) -> Set<String> {
guard k > 0 && ids.count > k else { return Set(ids) }
// Stable order by SHA256(seed || "::" || id)
var scored: [(score: [UInt8], id: String)] = []
for id in ids {
let msg = (seed + "::" + id).data(using: .utf8) ?? Data()
let digest = Array(SHA256.hash(data: msg))
scored.append((digest, id))
}
scored.sort { a, b in
for i in 0..<min(a.score.count, b.score.count) {
if a.score[i] != b.score[i] { return a.score[i] < b.score[i] }
}
return a.id < b.id
}
return Set(scored.prefix(k).map { $0.id })
}
private func writeOrEnqueue(_ data: Data, to peripheral: CBPeripheral, characteristic: CBCharacteristic) {
// BLE operations run on bleQueue; keep queue affinity
bleQueue.async { [weak self] in
guard let self = self else { return }
let uuid = peripheral.identifier.uuidString
if peripheral.canSendWriteWithoutResponse {
peripheral.writeValue(data, for: characteristic, type: .withoutResponse)
} else {
self.collectionsQueue.async(flags: .barrier) {
self.pendingPeripheralWrites[uuid, default: []].append(data)
}
}
}
}
private func drainPendingWrites(for peripheral: CBPeripheral) {
let uuid = peripheral.identifier.uuidString
bleQueue.async { [weak self] in
guard let self = self else { return }
guard let state = self.peripherals[uuid], let ch = state.characteristic else { return }
var queueCopy: [Data] = []
self.collectionsQueue.sync {
queueCopy = self.pendingPeripheralWrites[uuid] ?? []
}
guard !queueCopy.isEmpty else { return }
var sent = 0
for item in queueCopy {
if peripheral.canSendWriteWithoutResponse {
peripheral.writeValue(item, for: ch, type: .withoutResponse)
sent += 1
} else {
break
}
}
if sent > 0 {
self.collectionsQueue.async(flags: .barrier) {
var q = self.pendingPeripheralWrites[uuid] ?? []
if sent <= q.count {
q.removeFirst(sent)
} else {
q.removeAll()
}
self.pendingPeripheralWrites[uuid] = q.isEmpty ? nil : q
}
}
}
}
// MARK: - Peer snapshots publisher (non-UI convenience) // MARK: - Peer snapshots publisher (non-UI convenience)
private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>() private let peerSnapshotSubject = PassthroughSubject<[TransportPeerSnapshot], Never>()
@@ -165,20 +257,15 @@ final class BLEService: NSObject {
func currentPeerSnapshots() -> [TransportPeerSnapshot] { func currentPeerSnapshots() -> [TransportPeerSnapshot] {
collectionsQueue.sync { collectionsQueue.sync {
// Compute nickname collision counts for connected peers let snapshot = Array(peers.values)
let connected = peers.values.filter { $0.isConnected } let resolvedNames = PeerDisplayNameResolver.resolve(
var counts: [String: Int] = [:] snapshot.map { ($0.id, $0.nickname, $0.isConnected) },
for p in connected { counts[p.nickname, default: 0] += 1 } selfNickname: myNickname
// Include our own nickname in collision counts so remote matching ours gets suffixed )
counts[myNickname, default: 0] += 1 return snapshot.map { info in
return peers.values.map { info in TransportPeerSnapshot(
var display = info.nickname
if info.isConnected, (counts[info.nickname] ?? 0) > 1 {
display += "#" + String(info.id.prefix(4))
}
return TransportPeerSnapshot(
id: info.id, id: info.id,
nickname: display, nickname: resolvedNames[info.id] ?? info.nickname,
isConnected: info.isConnected, isConnected: info.isConnected,
noisePublicKey: info.noisePublicKey, noisePublicKey: info.noisePublicKey,
lastSeen: info.lastSeen lastSeen: info.lastSeen
@@ -259,7 +346,9 @@ final class BLEService: NSObject {
// Single maintenance timer for all periodic tasks (dispatch-based for determinism) // Single maintenance timer for all periodic tasks (dispatch-based for determinism)
let timer = DispatchSource.makeTimerSource(queue: bleQueue) let timer = DispatchSource.makeTimerSource(queue: bleQueue)
timer.schedule(deadline: .now() + 10.0, repeating: 10.0, leeway: .seconds(1)) timer.schedule(deadline: .now() + TransportConfig.bleMaintenanceInterval,
repeating: TransportConfig.bleMaintenanceInterval,
leeway: .seconds(TransportConfig.bleMaintenanceLeewaySeconds))
timer.setEventHandler { [weak self] in timer.setEventHandler { [weak self] in
self?.performMaintenance() self?.performMaintenance()
} }
@@ -341,7 +430,7 @@ final class BLEService: NSObject {
// Send initial announce after services are ready // Send initial announce after services are ready
// Use longer delay to avoid conflicts with other announces // Use longer delay to avoid conflicts with other announces
messageQueue.asyncAfter(deadline: .now() + 2.0) { [weak self] in messageQueue.asyncAfter(deadline: .now() + TransportConfig.bleInitialAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true) self?.sendAnnounce(forceSend: true)
} }
} }
@@ -369,7 +458,7 @@ final class BLEService: NSObject {
// Send to peripherals we're connected to as central // Send to peripherals we're connected to as central
for state in peripherals.values where state.isConnected { for state in peripherals.values where state.isConnected {
if let characteristic = state.characteristic { if let characteristic = state.characteristic {
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse) writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
} }
} }
@@ -380,7 +469,7 @@ final class BLEService: NSObject {
} }
// Give leave message a moment to send // Give leave message a moment to send
Thread.sleep(forTimeInterval: 0.05) Thread.sleep(forTimeInterval: TransportConfig.bleThreadSleepWriteShortDelaySeconds)
// Clear pending notifications // Clear pending notifications
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
@@ -422,22 +511,9 @@ final class BLEService: NSObject {
func getPeerNicknames() -> [String: String] { func getPeerNicknames() -> [String: String] {
return collectionsQueue.sync { return collectionsQueue.sync {
// Only connected peers
let connected = peers.filter { $0.value.isConnected } let connected = peers.filter { $0.value.isConnected }
// Count collisions by nickname (include our own nickname) let tuples = connected.map { ($0.key, $0.value.nickname, true) }
var counts: [String: Int] = [:] return PeerDisplayNameResolver.resolve(tuples, selfNickname: myNickname)
for (_, info) in connected { counts[info.nickname, default: 0] += 1 }
counts[myNickname, default: 0] += 1
// Build map with suffix for collisions
var result: [String: String] = [:]
for (id, info) in connected {
var name = info.nickname
if (counts[info.nickname] ?? 0) > 1 {
name += "#" + String(id.prefix(4))
}
result[id] = name
}
return result
} }
} }
@@ -508,11 +584,51 @@ final class BLEService: NSObject {
func sendBroadcastAnnounce() { func sendBroadcastAnnounce() {
sendAnnounce() sendAnnounce()
} }
// MARK: - QR Verification over Noise
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {
let payload = VerificationService.shared.buildVerifyChallenge(noiseKeyHex: noiseKeyHex, nonceA: nonceA)
sendNoisePayload(payload, to: peerID)
}
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {
guard let payload = VerificationService.shared.buildVerifyResponse(noiseKeyHex: noiseKeyHex, nonceA: nonceA) else { return }
sendNoisePayload(payload, to: peerID)
}
private func sendNoisePayload(_ typedPayload: Data, to peerID: String) {
guard noiseService.hasSession(with: peerID) else {
// Lazy-handshake path: queue? For now, initiate handshake and drop
initiateNoiseHandshake(with: peerID)
return
}
do {
let encrypted = try noiseService.encrypt(typedPayload, for: peerID)
let packet = BitchatPacket(
type: MessageType.noiseEncrypted.rawValue,
senderID: Data(hexString: myPeerID) ?? Data(),
recipientID: Data(hexString: peerID),
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
payload: encrypted,
signature: nil,
ttl: messageTTL
)
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
broadcastPacket(packet)
} else {
messageQueue.async { [weak self] in self?.broadcastPacket(packet) }
}
} catch {
SecureLogger.log("Failed to send verification payload: \(error)", category: SecureLogger.noise, level: .error)
}
}
func getPeerFingerprint(_ peerID: String) -> String? { func getPeerFingerprint(_ peerID: String) -> String? {
return collectionsQueue.sync { return collectionsQueue.sync {
if let publicKey = peers[peerID]?.noisePublicKey { if let publicKey = peers[peerID]?.noisePublicKey {
return publicKey.hexEncodedString() // Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key)
let hash = SHA256.hash(data: publicKey)
return hash.map { String(format: "%02x", $0) }.joined()
} }
return nil return nil
} }
@@ -598,7 +714,7 @@ final class BLEService: NSObject {
} }
} }
// Removed unused getPeers(): use getPeerNicknames() from Transport //
// MARK: - Private Message Handling // MARK: - Private Message Handling
@@ -837,7 +953,7 @@ final class BLEService: NSObject {
let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }), let state = (DispatchQueue.getSpecific(key: bleQueueKey) != nil) ? peripherals[peripheralUUID] : bleQueue.sync(execute: { peripherals[peripheralUUID] }),
state.isConnected, state.isConnected,
let characteristic = state.characteristic { let characteristic = state.characteristic {
state.peripheral.writeValue(data, for: characteristic, type: .withoutResponse) writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic)
sentEncrypted = true sentEncrypted = true
} }
@@ -849,7 +965,7 @@ final class BLEService: NSObject {
if success { sentEncrypted = true; break } if success { sentEncrypted = true; break }
collectionsQueue.async(flags: .barrier) { [weak self] in collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return } guard let self = self else { return }
if self.pendingNotifications.count < 20 { if self.pendingNotifications.count < TransportConfig.blePendingNotificationsCapCount {
self.pendingNotifications.append((data: data, centrals: [central])) self.pendingNotifications.append((data: data, centrals: [central]))
SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)", category: SecureLogger.session, level: .debug) SecureLogger.log("📋 Queued encrypted packet for retry (notification queue full)", category: SecureLogger.session, level: .debug)
} }
@@ -868,6 +984,10 @@ final class BLEService: NSObject {
} }
private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: String?) { private func sendOnAllLinks(packet: BitchatPacket, data: Data, pad: Bool, directedOnlyPeer: String?) {
// Determine last-hop link for this message to avoid echoing back
let messageID = makeMessageID(for: packet)
let ingressLink: LinkID? = collectionsQueue.sync { ingressByMessageID[messageID]?.link }
let states = snapshotPeripheralStates() let states = snapshotPeripheralStates()
var minCentralWriteLen: Int? var minCentralWriteLen: Int?
for s in states where s.isConnected { for s in states where s.isConnected {
@@ -892,15 +1012,54 @@ final class BLEService: NSObject {
sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer) sendFragmentedPacket(packet, pad: pad, maxChunk: chunk, directedOnlyPeer: directedOnlyPeer)
return return
} }
// Writes to connected peripherals // Build link lists and apply K-of-N fanout for broadcasts; always exclude ingress link
for s in states where s.isConnected { let connectedPeripheralIDs: [String] = states.filter { $0.isConnected }.map { $0.peripheral.identifier.uuidString }
if let ch = s.characteristic { let subscribedCentrals: [CBCentral]
s.peripheral.writeValue(data, for: ch, type: .withoutResponse) var centralIDs: [String] = []
if let _ = characteristic {
let (centrals, _) = snapshotSubscribedCentrals()
subscribedCentrals = centrals
centralIDs = centrals.map { $0.identifier.uuidString }
} else {
subscribedCentrals = []
}
// Exclude ingress link
var allowedPeripheralIDs = connectedPeripheralIDs
var allowedCentralIDs = centralIDs
if let ingress = ingressLink {
switch ingress {
case .peripheral(let id):
allowedPeripheralIDs.removeAll { $0 == id }
case .central(let id):
allowedCentralIDs.removeAll { $0 == id }
} }
} }
// Notify all subscribed centrals
// For broadcast (no directed peer) and non-fragment, choose a subset deterministically
var selectedPeripheralIDs = Set(allowedPeripheralIDs)
var selectedCentralIDs = Set(allowedCentralIDs)
if directedOnlyPeer == nil && packet.type != MessageType.fragment.rawValue {
let kp = subsetSizeForFanout(allowedPeripheralIDs.count)
let kc = subsetSizeForFanout(allowedCentralIDs.count)
selectedPeripheralIDs = selectDeterministicSubset(ids: allowedPeripheralIDs, k: kp, seed: messageID)
selectedCentralIDs = selectDeterministicSubset(ids: allowedCentralIDs, k: kc, seed: messageID)
}
// Writes to selected connected peripherals
for s in states where s.isConnected {
let pid = s.peripheral.identifier.uuidString
guard selectedPeripheralIDs.contains(pid) else { continue }
if let ch = s.characteristic {
writeOrEnqueue(data, to: s.peripheral, characteristic: ch)
}
}
// Notify selected subscribed centrals
if let ch = characteristic { if let ch = characteristic {
_ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil) let targets = subscribedCentrals.filter { selectedCentralIDs.contains($0.identifier.uuidString) }
if !targets.isEmpty {
_ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets)
}
} }
} }
@@ -914,7 +1073,7 @@ final class BLEService: NSObject {
// Fire-and-forget principle: always use .withoutResponse for speed // Fire-and-forget principle: always use .withoutResponse for speed
// CoreBluetooth will handle fragmentation at L2CAP layer // CoreBluetooth will handle fragmentation at L2CAP layer
peripheral.writeValue(data, for: characteristic, type: .withoutResponse) writeOrEnqueue(data, to: peripheral, characteristic: characteristic)
} }
// MARK: - Fragmentation (Required for messages > BLE MTU) // MARK: - Fragmentation (Required for messages > BLE MTU)
@@ -937,7 +1096,7 @@ final class BLEService: NSObject {
guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return } guard let self = self, let c = self.centralManager, c.state == .poweredOn else { return }
if c.isScanning { c.stopScan() } if c.isScanning { c.stopScan() }
// Resume scanning after we expect last fragment to be sent // Resume scanning after we expect last fragment to be sent
let expectedMs = min(2000, totalFragments * 8) // ~8ms per fragment let expectedMs = min(TransportConfig.bleExpectedWriteMaxMs, totalFragments * TransportConfig.bleExpectedWritePerFragmentMs) // ~8ms per fragment
self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in self.bleQueue.asyncAfter(deadline: .now() + .milliseconds(expectedMs)) { [weak self] in
self?.startScanning() self?.startScanning()
} }
@@ -968,7 +1127,7 @@ final class BLEService: NSObject {
ttl: packet.ttl ttl: packet.ttl
) )
// Pace fragments with small jitter to avoid bursts // Pace fragments with small jitter to avoid bursts
let delayMs = index * 6 // ~6ms spacing per fragment let delayMs = index * TransportConfig.bleFragmentSpacingMs // ~6ms spacing per fragment
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
self?.broadcastPacket(fragmentPacket) self?.broadcastPacket(fragmentPacket)
} }
@@ -1080,10 +1239,10 @@ final class BLEService: NSObject {
guard let self = self else { return } guard let self = self else { return }
let now = Date() let now = Date()
self.recentPacketTimestamps.append(now) self.recentPacketTimestamps.append(now)
// keep last 100 timestamps within 30s window // keep last N timestamps within window
let cutoff = now.addingTimeInterval(-30) let cutoff = now.addingTimeInterval(-TransportConfig.bleRecentPacketWindowSeconds)
if self.recentPacketTimestamps.count > 100 { if self.recentPacketTimestamps.count > TransportConfig.bleRecentPacketWindowMaxCount {
self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - 100) self.recentPacketTimestamps.removeFirst(self.recentPacketTimestamps.count - TransportConfig.bleRecentPacketWindowMaxCount)
} }
self.recentPacketTimestamps.removeAll { $0 < cutoff } self.recentPacketTimestamps.removeAll { $0 < cutoff }
} }
@@ -1122,6 +1281,7 @@ final class BLEService: NSObject {
ttl: packet.ttl, ttl: packet.ttl,
senderIsSelf: senderID == myPeerID, senderIsSelf: senderID == myPeerID,
isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue, isEncrypted: packet.type == MessageType.noiseEncrypted.rawValue,
isDirectedEncrypted: (packet.type == MessageType.noiseEncrypted.rawValue) && (packet.recipientID != nil),
isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil, isDirectedFragment: packet.type == MessageType.fragment.rawValue && packet.recipientID != nil,
isHandshake: packet.type == MessageType.noiseHandshake.rawValue, isHandshake: packet.type == MessageType.noiseHandshake.rawValue,
degree: degree, degree: degree,
@@ -1383,6 +1543,16 @@ final class BLEService: NSObject {
notifyUI { [weak self] in notifyUI { [weak self] in
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts) self?.delegate?.didReceiveNoisePayload(from: peerID, type: .readReceipt, payload: Data(payloadData), timestamp: ts)
} }
case .verifyChallenge:
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyChallenge, payload: Data(payloadData), timestamp: ts)
}
case .verifyResponse:
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
notifyUI { [weak self] in
self?.delegate?.didReceiveNoisePayload(from: peerID, type: .verifyResponse, payload: Data(payloadData), timestamp: ts)
}
default: default:
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning) SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning)
} }
@@ -1436,7 +1606,7 @@ final class BLEService: NSObject {
let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent) let timeSinceLastAnnounce = now.timeIntervalSince(lastAnnounceSent)
// Even forced sends should respect a minimum interval to avoid overwhelming BLE // Even forced sends should respect a minimum interval to avoid overwhelming BLE
let minInterval = forceSend ? 0.2 : announceMinInterval let minInterval = forceSend ? TransportConfig.bleForceAnnounceMinIntervalSeconds : announceMinInterval
if timeSinceLastAnnounce < minInterval { if timeSinceLastAnnounce < minInterval {
// Skipping announce (rate limited) // Skipping announce (rate limited)
@@ -1567,12 +1737,14 @@ final class BLEService: NSObject {
let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count } let connectedCount = collectionsQueue.sync { peers.values.filter { $0.isConnected }.count }
let elapsed = now.timeIntervalSince(lastAnnounceSent) let elapsed = now.timeIntervalSince(lastAnnounceSent)
if connectedCount == 0 { if connectedCount == 0 {
// Discovery mode: keep frequent announces (~10s) // Discovery mode: keep frequent announces
if elapsed >= 10.0 { sendAnnounce(forceSend: true) } if elapsed >= TransportConfig.bleAnnounceIntervalSeconds { sendAnnounce(forceSend: true) }
} else { } else {
// Connected mode: announce less often; much less in dense networks // Connected mode: announce less often; much less in dense networks
let base = connectedCount >= 6 ? 90.0 : 45.0 let base = connectedCount >= TransportConfig.bleHighDegreeThreshold ?
let jitter = connectedCount >= 6 ? 20.0 : 7.5 TransportConfig.bleConnectedAnnounceBaseSecondsDense : TransportConfig.bleConnectedAnnounceBaseSecondsSparse
let jitter = connectedCount >= TransportConfig.bleHighDegreeThreshold ?
TransportConfig.bleConnectedAnnounceJitterDense : TransportConfig.bleConnectedAnnounceJitterSparse
let target = base + Double.random(in: -jitter...jitter) let target = base + Double.random(in: -jitter...jitter)
if elapsed >= target { sendAnnounce(forceSend: true) } if elapsed >= target { sendAnnounce(forceSend: true) }
} }
@@ -1613,7 +1785,7 @@ final class BLEService: NSObject {
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
for (peerID, peer) in peers { for (peerID, peer) in peers {
if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > 20 { if peer.isConnected && now.timeIntervalSince(peer.lastSeen) > TransportConfig.blePeerInactivityTimeoutSeconds {
// Check if we still have an active BLE connection to this peer // Check if we still have an active BLE connection to this peer
let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil && let hasPeripheralConnection = peerToPeripheralUUID[peerID] != nil &&
peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true peripherals[peerToPeripheralUUID[peerID]!]?.isConnected == true
@@ -1653,9 +1825,9 @@ final class BLEService: NSObject {
// Clean old processed messages efficiently // Clean old processed messages efficiently
messageDeduplicator.cleanup() messageDeduplicator.cleanup()
// Clean old fragments (> 30 seconds old) // Clean old fragments (> configured seconds old)
collectionsQueue.sync(flags: .barrier) { collectionsQueue.sync(flags: .barrier) {
let cutoff = now.addingTimeInterval(-30) let cutoff = now.addingTimeInterval(-TransportConfig.bleFragmentLifetimeSeconds)
let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key } let oldFragments = fragmentMetadata.filter { $0.value.timestamp < cutoff }.map { $0.key }
for fragmentID in oldFragments { for fragmentID in oldFragments {
incomingFragments.removeValue(forKey: fragmentID) incomingFragments.removeValue(forKey: fragmentID)
@@ -1663,8 +1835,8 @@ final class BLEService: NSObject {
} }
} }
// Clean old connection timeout backoff entries (> 2 minutes) // Clean old connection timeout backoff entries (> window)
let timeoutCutoff = now.addingTimeInterval(-120) let timeoutCutoff = now.addingTimeInterval(-TransportConfig.bleConnectTimeoutBackoffWindowSeconds)
recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff } recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff }
// Clean up stale scheduled relays that somehow persisted (> 2s) // Clean up stale scheduled relays that somehow persisted (> 2s)
@@ -1677,6 +1849,15 @@ final class BLEService: NSObject {
} }
} }
} }
// Clean ingress link records older than configured seconds
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
let cutoff = now.addingTimeInterval(-TransportConfig.bleIngressRecordLifetimeSeconds)
if !self.ingressByMessageID.isEmpty {
self.ingressByMessageID = self.ingressByMessageID.filter { $0.value.timestamp >= cutoff }
}
}
} }
private func updateScanningDutyCycle(connectedCount: Int) { private func updateScanningDutyCycle(connectedCount: Int) {
@@ -1696,12 +1877,12 @@ final class BLEService: NSObject {
if !central.isScanning { startScanning() } if !central.isScanning { startScanning() }
dutyActive = true dutyActive = true
// Adjust duty cycle under dense networks to save battery // Adjust duty cycle under dense networks to save battery
if connectedCount >= 6 { if connectedCount >= TransportConfig.bleHighDegreeThreshold {
dutyOnDuration = 3 dutyOnDuration = TransportConfig.bleDutyOnDurationDense
dutyOffDuration = 15 dutyOffDuration = TransportConfig.bleDutyOffDurationDense
} else { } else {
dutyOnDuration = 5 dutyOnDuration = TransportConfig.bleDutyOnDuration
dutyOffDuration = 10 dutyOffDuration = TransportConfig.bleDutyOffDuration
} }
t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration) t.schedule(deadline: .now() + dutyOnDuration, repeating: dutyOnDuration + dutyOffDuration)
t.setEventHandler { [weak self] in t.setEventHandler { [weak self] in
@@ -1735,25 +1916,25 @@ final class BLEService: NSObject {
if lastIsolatedAt == nil { lastIsolatedAt = Date() } if lastIsolatedAt == nil { lastIsolatedAt = Date() }
let iso = lastIsolatedAt ?? Date() let iso = lastIsolatedAt ?? Date()
let elapsed = Date().timeIntervalSince(iso) let elapsed = Date().timeIntervalSince(iso)
if elapsed > 60 { if elapsed > TransportConfig.bleIsolationRelaxThresholdSeconds {
dynamicRSSIThreshold = -92 dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedRelaxed
} else { } else {
dynamicRSSIThreshold = -90 dynamicRSSIThreshold = TransportConfig.bleRSSIIsolatedBase
} }
return return
} }
lastIsolatedAt = nil lastIsolatedAt = nil
// Base threshold when connected // Base threshold when connected
var threshold = -90 var threshold = TransportConfig.bleDynamicRSSIThresholdDefault
// If we're at budget or queue is large, prefer closer peers // If we're at budget or queue is large, prefer closer peers
let linkCount = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count let linkCount = peripherals.values.filter { $0.isConnected || $0.isConnecting }.count
if linkCount >= maxCentralLinks || connectionCandidates.count > 20 { if linkCount >= maxCentralLinks || connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax {
threshold = -85 threshold = TransportConfig.bleRSSIConnectedThreshold
} }
// If we have many recent timeouts, raise further // If we have many recent timeouts, raise further
let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < 60 }.count let recentTimeouts = recentConnectTimeouts.filter { Date().timeIntervalSince($0.value) < TransportConfig.bleRecentTimeoutWindowSeconds }.count
if recentTimeouts >= 3 { if recentTimeouts >= TransportConfig.bleRecentTimeoutCountThreshold {
threshold = max(threshold, -80) threshold = max(threshold, TransportConfig.bleRSSIHighTimeoutThreshold)
} }
dynamicRSSIThreshold = threshold dynamicRSSIThreshold = threshold
} }
@@ -1807,7 +1988,9 @@ extension BLEService: CBCentralManagerDelegate {
if a.rssi != b.rssi { return a.rssi > b.rssi } if a.rssi != b.rssi { return a.rssi > b.rssi }
return a.discoveredAt < b.discoveredAt return a.discoveredAt < b.discoveredAt
} }
if connectionCandidates.count > 100 { connectionCandidates.removeLast(connectionCandidates.count - 100) } if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax {
connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax)
}
return return
} }
@@ -1821,7 +2004,9 @@ extension BLEService: CBCentralManagerDelegate {
if a.rssi != b.rssi { return a.rssi > b.rssi } if a.rssi != b.rssi { return a.rssi > b.rssi }
return a.discoveredAt < b.discoveredAt return a.discoveredAt < b.discoveredAt
} }
if connectionCandidates.count > 100 { connectionCandidates.removeLast(connectionCandidates.count - 100) } if connectionCandidates.count > TransportConfig.bleConnectionCandidatesMax {
connectionCandidates.removeLast(connectionCandidates.count - TransportConfig.bleConnectionCandidatesMax)
}
return return
} }
@@ -1898,7 +2083,7 @@ extension BLEService: CBCentralManagerDelegate {
// Set a timeout for the connection attempt (slightly longer for reliability) // Set a timeout for the connection attempt (slightly longer for reliability)
// Use BLE queue to mutate BLE-related state consistently // Use BLE queue to mutate BLE-related state consistently
bleQueue.asyncAfter(deadline: .now() + 8.0) { [weak self] in bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleConnectTimeoutSeconds) { [weak self] in
guard let self = self, guard let self = self,
let state = self.peripherals[peripheralID], let state = self.peripherals[peripheralID],
state.isConnecting && !state.isConnected else { return } state.isConnecting && !state.isConnected else { return }
@@ -1970,7 +2155,7 @@ func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeriph
if centralManager?.state == .poweredOn { if centralManager?.state == .poweredOn {
// Stop and restart scanning to ensure we get fresh discovery events // Stop and restart scanning to ensure we get fresh discovery events
centralManager?.stopScan() centralManager?.stopScan()
bleQueue.asyncAfter(deadline: .now() + 0.1) { [weak self] in bleQueue.asyncAfter(deadline: .now() + TransportConfig.bleRestartScanDelaySeconds) { [weak self] in
self?.startScanning() self?.startScanning()
} }
} }
@@ -2067,6 +2252,27 @@ extension BLEService {
// Test-only helper to inject packets into the receive pipeline // Test-only helper to inject packets into the receive pipeline
extension BLEService { extension BLEService {
func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: String) { func _test_handlePacket(_ packet: BitchatPacket, fromPeerID: String) {
// Ensure the synthetic peer is known and marked verified for public-message tests
let normalizedID = packet.senderID.hexEncodedString()
collectionsQueue.sync(flags: .barrier) {
if peers[normalizedID] == nil {
peers[normalizedID] = PeerInfo(
id: normalizedID,
nickname: "TestPeer_\(fromPeerID.prefix(4))",
isConnected: true,
noisePublicKey: packet.senderID,
signingPublicKey: nil,
isVerifiedNickname: true,
lastSeen: Date()
)
} else {
var p = peers[normalizedID]!
p.isConnected = true
p.isVerifiedNickname = true
p.lastSeen = Date()
peers[normalizedID] = p
}
}
if DispatchQueue.getSpecific(key: messageQueueKey) != nil { if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
handleReceivedPacket(packet, from: fromPeerID) handleReceivedPacket(packet, from: fromPeerID)
} else { } else {
@@ -2147,7 +2353,7 @@ extension BLEService: CBPeripheralDelegate {
SecureLogger.log("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .debug) SecureLogger.log("🔔 Subscribed to notifications from \(peripheral.name ?? "Unknown")", category: SecureLogger.session, level: .debug)
// Send announce after subscription is confirmed (force send for new connection) // Send announce after subscription is confirmed (force send for new connection)
messageQueue.asyncAfter(deadline: .now() + 0.1) { [weak self] in messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostSubscribeAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true) self?.sendAnnounce(forceSend: true)
} }
} else { } else {
@@ -2170,7 +2376,9 @@ extension BLEService: CBPeripheralDelegate {
// Process directly on main thread to avoid deadlocks (matches original implementation) // Process directly on main thread to avoid deadlocks (matches original implementation)
guard let packet = BinaryProtocol.decode(data) else { guard let packet = BinaryProtocol.decode(data) else {
SecureLogger.log("❌ Failed to decode notification packet, full data: \(data.map { String(format: "%02x", $0) }.joined(separator: " "))", // Avoid dumping entire payload; log size and short prefix for diagnostics
let prefix = data.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.log("❌ Failed to decode notification packet (len=\(data.count), prefix=\(prefix))",
category: SecureLogger.session, level: .error) category: SecureLogger.session, level: .error)
return return
} }
@@ -2195,12 +2403,22 @@ extension BLEService: CBPeripheralDelegate {
peerToPeripheralUUID[senderID] = peripheralUUID peerToPeripheralUUID[senderID] = peripheralUUID
// Mapping update - direct announce from peer // Mapping update - direct announce from peer
} }
// Record ingress link for last-hop suppression and process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
}
// Process the announce packet regardless of whether we updated the mapping // Process the announce packet regardless of whether we updated the mapping
handleReceivedPacket(packet, from: senderID) handleReceivedPacket(packet, from: senderID)
} else { } else {
// For non-announce packets, DO NOT update mappings // For non-announce packets, DO NOT update mappings
// These could be relayed packets from other peers // These could be relayed packets from other peers
// Always use the packet's original senderID // Always use the packet's original senderID
// Record ingress link for last-hop suppression and process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.peripheral(peripheralUUID), Date())
}
handleReceivedPacket(packet, from: senderID) handleReceivedPacket(packet, from: senderID)
} }
} }
@@ -2215,7 +2433,8 @@ extension BLEService: CBPeripheralDelegate {
} }
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) { func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
// Suppress verbose ready logs // Resume queued writes for this peripheral
drainPendingWrites(for: peripheral)
} }
func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) { func peripheral(_ peripheral: CBPeripheral, didModifyServices invalidatedServices: [CBService]) {
@@ -2296,7 +2515,7 @@ extension BLEService: CBPeripheralManagerDelegate {
SecureLogger.log("📥 Central subscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug) SecureLogger.log("📥 Central subscribed: \(central.identifier.uuidString)", category: SecureLogger.session, level: .debug)
subscribedCentrals.append(central) subscribedCentrals.append(central)
// Send announce to the newly subscribed central after a small delay to avoid overwhelming // Send announce to the newly subscribed central after a small delay to avoid overwhelming
messageQueue.asyncAfter(deadline: .now() + 0.4) { [weak self] in messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
self?.sendAnnounce(forceSend: true) self?.sendAnnounce(forceSend: true)
} }
} }
@@ -2439,19 +2658,30 @@ extension BLEService: CBPeripheralManagerDelegate {
} }
if packet.type == MessageType.announce.rawValue { if packet.type == MessageType.announce.rawValue {
if packet.ttl == messageTTL { centralToPeerID[centralUUID] = senderID } if packet.ttl == messageTTL { centralToPeerID[centralUUID] = senderID }
// Record ingress link for last-hop suppression then process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
}
handleReceivedPacket(packet, from: senderID) handleReceivedPacket(packet, from: senderID)
} else { } else {
// Record ingress link for last-hop suppression then process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
}
handleReceivedPacket(packet, from: senderID) handleReceivedPacket(packet, from: senderID)
} }
} else { } else {
// If buffer grows suspiciously large, reset to avoid memory leak // If buffer grows suspiciously large, reset to avoid memory leak
if combined.count > 1_000_000 { // 1MB cap for safety if combined.count > TransportConfig.blePendingWriteBufferCapBytes { // cap for safety
pendingWriteBuffers.removeValue(forKey: centralUUID) pendingWriteBuffers.removeValue(forKey: centralUUID)
SecureLogger.log("⚠️ Dropping oversized pending write buffer (\(combined.count) bytes) for central \(centralUUID)", category: SecureLogger.session, level: .warning) SecureLogger.log("⚠️ Dropping oversized pending write buffer (\(combined.count) bytes) for central \(centralUUID)", category: SecureLogger.session, level: .warning)
} }
// If this was a single short write and still failed, log the raw chunk for debugging // If this was a single short write and still failed, log the raw chunk for debugging
if !hasMultiple, let only = sorted.first, let raw = only.value { if !hasMultiple, let only = sorted.first, let raw = only.value {
SecureLogger.log("❌ Failed to decode packet from central, full data: \(raw.map { String(format: "%02x", $0) }.joined(separator: " "))", category: SecureLogger.session, level: .error) let prefix = raw.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
SecureLogger.log("❌ Failed to decode packet from central (len=\(raw.count), prefix=\(prefix))", category: SecureLogger.session, level: .error)
} }
} }
} }
+34 -7
View File
@@ -33,6 +33,15 @@ class CommandProcessor {
guard let cmd = parts.first else { return .error(message: "Invalid command") } guard let cmd = parts.first else { return .error(message: "Invalid command") }
let args = parts.count > 1 ? String(parts[1]) : "" let args = parts.count > 1 ? String(parts[1]) : ""
// Geohash context: disable favoriting in public geohash or GeoDM
let inGeoPublic: Bool = {
switch LocationChannelManager.shared.selectedChannel {
case .mesh: return false
case .location: return true
}
}()
let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
switch cmd { switch cmd {
case "/m", "/msg": case "/m", "/msg":
return handleMessage(args) return handleMessage(args)
@@ -49,11 +58,14 @@ class CommandProcessor {
case "/unblock": case "/unblock":
return handleUnblock(args) return handleUnblock(args)
case "/fav": case "/fav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: true) return handleFavorite(args, add: true)
case "/unfav": case "/unfav":
if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") }
return handleFavorite(args, add: false) return handleFavorite(args, add: false)
//
case "/help", "/h": case "/help", "/h":
return handleHelp() return .error(message: "unknown command: \(cmd)")
default: default:
return .error(message: "unknown command: \(cmd)") return .error(message: "unknown command: \(cmd)")
} }
@@ -85,19 +97,34 @@ class CommandProcessor {
} }
private func handleWho() -> CommandResult { private func handleWho() -> CommandResult {
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else { // Show geohash participants when in a geohash channel; otherwise mesh peers
return .success(message: "no one else is online right now") switch LocationChannelManager.shared.selectedChannel {
case .location(let ch):
// Geohash context: show visible geohash participants (exclude self)
guard let vm = chatViewModel else { return .success(message: "nobody around") }
let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased()
let people = vm.visibleGeohashPeople().filter { person in
if let me = myHex { return person.id.lowercased() != me }
return true
}
let names = people.map { $0.displayName }
if names.isEmpty { return .success(message: "no one else is online right now") }
return .success(message: "online: " + names.sorted().joined(separator: ", "))
case .mesh:
// Mesh context: show connected peer nicknames
guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else {
return .success(message: "no one else is online right now")
}
let onlineList = peers.values.sorted().joined(separator: ", ")
return .success(message: "online: \(onlineList)")
} }
let onlineList = peers.values.sorted().joined(separator: ", ")
return .success(message: "online: \(onlineList)")
} }
private func handleClear() -> CommandResult { private func handleClear() -> CommandResult {
if let peerID = chatViewModel?.selectedPrivateChatPeer { if let peerID = chatViewModel?.selectedPrivateChatPeer {
chatViewModel?.privateChats[peerID]?.removeAll() chatViewModel?.privateChats[peerID]?.removeAll()
} else { } else {
chatViewModel?.messages.removeAll() chatViewModel?.clearCurrentPublicTimeline()
} }
return .handled return .handled
} }
+11 -12
View File
@@ -1,9 +1,9 @@
import Foundation import Foundation
#if os(iOS)
import CoreLocation
import Combine import Combine
#if os(iOS) || os(macOS)
import CoreLocation
/// Manages location permissions, one-shot location retrieval, and computing geohash channels. /// Manages location permissions, one-shot location retrieval, and computing geohash channels.
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor. /// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject { final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
@@ -39,7 +39,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
super.init() super.init()
cl.delegate = self cl.delegate = self
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
cl.distanceFilter = 1000 // meters; we're not tracking continuously cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
// Load selection // Load selection
if let data = UserDefaults.standard.data(forKey: userDefaultsKey), if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) { let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
@@ -55,7 +55,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
teleported = teleportedSet.contains(ch.geohash) teleported = teleportedSet.contains(ch.geohash)
} }
let status: CLAuthorizationStatus let status: CLAuthorizationStatus
if #available(iOS 14.0, *) { if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus status = cl.authorizationStatus
} else { } else {
status = CLLocationManager.authorizationStatus() status = CLLocationManager.authorizationStatus()
@@ -66,7 +66,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
// MARK: - Public API // MARK: - Public API
func enableLocationChannels() { func enableLocationChannels() {
let status: CLAuthorizationStatus let status: CLAuthorizationStatus
if #available(iOS 14.0, *) { if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus status = cl.authorizationStatus
} else { } else {
status = CLLocationManager.authorizationStatus() status = CLLocationManager.authorizationStatus()
@@ -78,7 +78,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
Task { @MainActor in self.permissionState = .restricted } Task { @MainActor in self.permissionState = .restricted }
case .denied: case .denied:
Task { @MainActor in self.permissionState = .denied } Task { @MainActor in self.permissionState = .denied }
case .authorizedAlways, .authorizedWhenInUse: case .authorizedAlways, .authorizedWhenInUse, .authorized:
Task { @MainActor in self.permissionState = .authorized } Task { @MainActor in self.permissionState = .authorized }
requestOneShotLocation() requestOneShotLocation()
@unknown default: @unknown default:
@@ -93,7 +93,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} }
/// Begin periodic one-shot location refreshes while a selector UI is visible. /// Begin periodic one-shot location refreshes while a selector UI is visible.
func beginLiveRefresh(interval: TimeInterval = 5.0) { func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
guard permissionState == .authorized else { return } guard permissionState == .authorized else { return }
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open // Switch to a lightweight periodic one-shot request (polling) while the sheet is open
refreshTimer?.invalidate() refreshTimer?.invalidate()
@@ -151,8 +151,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
} }
} }
// iOS 14+ // iOS 14+ / macOS 11+
@available(iOS 14.0, *) @available(iOS 14.0, macOS 11.0, *)
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
updatePermissionState(from: manager.authorizationStatus) updatePermissionState(from: manager.authorizationStatus)
if case .authorized = permissionState { if case .authorized = permissionState {
@@ -180,7 +180,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
case .notDetermined: newState = .notDetermined case .notDetermined: newState = .notDetermined
case .restricted: newState = .restricted case .restricted: newState = .restricted
case .denied: newState = .denied case .denied: newState = .denied
case .authorizedAlways, .authorizedWhenInUse: newState = .authorized case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
@unknown default: newState = .restricted @unknown default: newState = .restricted
} }
Task { @MainActor in self.permissionState = newState } Task { @MainActor in self.permissionState = newState }
@@ -256,5 +256,4 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
return dict return dict
} }
} }
#endif #endif
+21 -38
View File
@@ -20,7 +20,7 @@ final class NostrTransport: Transport {
} }
private var readQueue: [QueuedRead] = [] private var readQueue: [QueuedRead] = []
private var isSendingReadAcks = false private var isSendingReadAcks = false
private let readAckInterval: TimeInterval = 0.35 // ~3 per second private let readAckInterval: TimeInterval = TransportConfig.nostrReadAckInterval
var myPeerID: String { senderPeerID } var myPeerID: String { senderPeerID }
var myNickname: String { "" } var myNickname: String { "" }
@@ -48,16 +48,7 @@ final class NostrTransport: Transport {
func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) { func sendPrivateMessage(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
Task { @MainActor in Task { @MainActor in
// Resolve favorite by full noise key or by short peerID fallback guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
var recipientNostrPubkey: String?
if let noiseKey = Data(hexString: peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
recipientNostrPubkey = fav.peerNostrPublicKey
}
if recipientNostrPubkey == nil, peerID.count == 16 {
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
}
guard let recipientNpub = recipientNostrPubkey else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))", SecureLogger.log("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
@@ -105,15 +96,7 @@ final class NostrTransport: Transport {
guard !readQueue.isEmpty else { isSendingReadAcks = false; return } guard !readQueue.isEmpty else { isSendingReadAcks = false; return }
let item = readQueue.removeFirst() let item = readQueue.removeFirst()
Task { @MainActor in Task { @MainActor in
var recipientNostrPubkey: String? guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
if let noiseKey = Data(hexString: item.peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
recipientNostrPubkey = fav.peerNostrPublicKey
}
if recipientNostrPubkey == nil, item.peerID.count == 16 {
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: item.peerID)?.peerNostrPublicKey
}
guard let recipientNpub = recipientNostrPubkey else { scheduleNextReadAck(); return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", SecureLogger.log("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
@@ -149,15 +132,7 @@ final class NostrTransport: Transport {
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) { func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
Task { @MainActor in Task { @MainActor in
var recipientNostrPubkey: String? guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
if let noiseKey = Data(hexString: peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
recipientNostrPubkey = fav.peerNostrPublicKey
}
if recipientNostrPubkey == nil, peerID.count == 16 {
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
}
guard let recipientNpub = recipientNostrPubkey else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)" let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", SecureLogger.log("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))",
@@ -183,18 +158,26 @@ final class NostrTransport: Transport {
} }
} }
// MARK: - Helpers
@MainActor
private func resolveRecipientNpub(for peerID: String) -> String? {
if let noiseKey = Data(hexString: peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey),
let npub = fav.peerNostrPublicKey {
return npub
}
if peerID.count == 16,
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let npub = fav.peerNostrPublicKey {
return npub
}
return nil
}
func sendBroadcastAnnounce() { /* no-op for Nostr */ } func sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: String) { func sendDeliveryAck(for messageID: String, to peerID: String) {
Task { @MainActor in Task { @MainActor in
var recipientNostrPubkey: String? guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
if let noiseKey = Data(hexString: peerID),
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: noiseKey) {
recipientNostrPubkey = fav.peerNostrPublicKey
}
if recipientNostrPubkey == nil, peerID.count == 16 {
recipientNostrPubkey = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID)?.peerNostrPublicKey
}
guard let recipientNpub = recipientNostrPubkey else { return }
guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return } guard let senderIdentity = try? NostrIdentityBridge.getCurrentNostrIdentity() else { return }
SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", SecureLogger.log("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))",
category: SecureLogger.session, level: .debug) category: SecureLogger.session, level: .debug)
+1 -1
View File
@@ -27,7 +27,7 @@ class PrivateChatManager: ObservableObject {
} }
// Cap for messages stored per private chat // Cap for messages stored per private chat
private let privateChatCap = 1337 private let privateChatCap = TransportConfig.privateChatCap
/// Start a private chat with a peer /// Start a private chat with a peer
func startChat(with peerID: String) { func startChat(with peerID: String) {
+22 -9
View File
@@ -12,6 +12,7 @@ struct RelayController {
static func decide(ttl: UInt8, static func decide(ttl: UInt8,
senderIsSelf: Bool, senderIsSelf: Bool,
isEncrypted: Bool, isEncrypted: Bool,
isDirectedEncrypted: Bool,
isDirectedFragment: Bool, isDirectedFragment: Bool,
isHandshake: Bool, isHandshake: Bool,
degree: Int, degree: Int,
@@ -19,7 +20,17 @@ struct RelayController {
// Suppress obvious non-relays // Suppress obvious non-relays
if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) } if ttl <= 1 || senderIsSelf { return RelayDecision(shouldRelay: false, newTTL: ttl, delayMs: 0) }
// Degree-aware probability to reduce floods in dense graphs // For session-critical or directed traffic, be deterministic and reliable
if isHandshake || isDirectedFragment || isDirectedEncrypted {
// Always relay with no TTL cap for these types
let newTTL = (ttl &- 1)
// Slight jitter to desynchronize without adding too much latency
let delayRange: ClosedRange<Int> = isHandshake ? 20...60 : 40...120
let delayMs = Int.random(in: delayRange)
return RelayDecision(shouldRelay: true, newTTL: newTTL, delayMs: delayMs)
}
// Degree-aware probability to reduce floods in dense graphs (broadcast/public)
let baseProb: Double let baseProb: Double
switch degree { switch degree {
case 0...2: baseProb = 1.0 case 0...2: baseProb = 1.0
@@ -28,20 +39,22 @@ struct RelayController {
case 7...9: baseProb = 0.55 case 7...9: baseProb = 0.55
default: baseProb = 0.45 default: baseProb = 0.45
} }
var prob = baseProb let prob = baseProb
if isHandshake { prob = max(0.3, baseProb - 0.2) }
// Sample a forwarding decision
let shouldRelay = Double.random(in: 0...1) <= prob let shouldRelay = Double.random(in: 0...1) <= prob
// TTL clamping in dense graphs // TTL clamping in dense graphs (only for broadcast)
let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5 let ttlCap: UInt8 = degree >= highDegreeThreshold ? 3 : 5
let clamped = max(1, min(ttl, ttlCap)) let clamped = max(1, min(ttl, ttlCap))
let newTTL = clamped &- 1 let newTTL = clamped &- 1
// Short jitter to desynchronize rebroadcasts // Wider jitter window to allow duplicate suppression to win more often
let delayMs = Int.random(in: 20...80) let delayMs: Int
switch degree {
case 0...2: delayMs = Int.random(in: 40...100)
case 3...5: delayMs = Int.random(in: 60...150)
case 6...9: delayMs = Int.random(in: 80...180)
default: delayMs = Int.random(in: 100...220)
}
return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs) return RelayDecision(shouldRelay: shouldRelay, newTTL: newTTL, delayMs: delayMs)
} }
} }
+10 -1
View File
@@ -3,7 +3,7 @@ import Combine
/// 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.
struct TransportPeerSnapshot { struct TransportPeerSnapshot: Equatable, Hashable {
let id: String let id: String
let nickname: String let nickname: String
let isConnected: Bool let isConnected: Bool
@@ -46,11 +46,20 @@ protocol Transport: AnyObject {
func sendBroadcastAnnounce() func sendBroadcastAnnounce()
func sendDeliveryAck(for messageID: String, to peerID: String) func sendDeliveryAck(for messageID: String, to peerID: String)
// QR verification (optional for transports)
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data)
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data)
// Peer snapshots (for non-UI services) // Peer snapshots (for non-UI services)
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get } var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
func currentPeerSnapshots() -> [TransportPeerSnapshot] func currentPeerSnapshots() -> [TransportPeerSnapshot]
} }
extension Transport {
func sendVerifyChallenge(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
func sendVerifyResponse(to peerID: String, noiseKeyHex: String, nonceA: Data) {}
}
protocol TransportPeerEventsDelegate: AnyObject { protocol TransportPeerEventsDelegate: AnyObject {
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot]) @MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
} }
+161
View File
@@ -0,0 +1,161 @@
import Foundation
/// Centralized knobs for transport- and UI-related limits.
/// Keep values aligned with existing behavior when replacing magic numbers.
enum TransportConfig {
// BLE / Protocol
static let bleDefaultFragmentSize: Int = 469 // ~512 MTU minus protocol overhead
static let messageTTLDefault: UInt8 = 7 // Default TTL for mesh flooding
static let bleMaxInFlightAssemblies: Int = 128 // Cap concurrent fragment assemblies
static let bleHighDegreeThreshold: Int = 6 // For adaptive TTL/probabilistic relays
// UI / Storage Caps
static let privateChatCap: Int = 1337
static let meshTimelineCap: Int = 1337
static let geoTimelineCap: Int = 1337
static let contentLRUCap: Int = 2000
// Timers
static let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes
static let basePublicFlushInterval: TimeInterval = 0.08 // ~12.5 fps batching
// BLE duty/announce/connect
static let bleConnectRateLimitInterval: TimeInterval = 0.5
static let bleMaxCentralLinks: Int = 6
static let bleDutyOnDuration: TimeInterval = 5.0
static let bleDutyOffDuration: TimeInterval = 10.0
static let bleAnnounceMinInterval: TimeInterval = 1.0
// BLE discovery/quality thresholds
static let bleDynamicRSSIThresholdDefault: Int = -90
static let bleConnectionCandidatesMax: Int = 100
static let blePendingWriteBufferCapBytes: Int = 1_000_000
static let blePendingNotificationsCapCount: Int = 20
// Nostr
static let nostrReadAckInterval: TimeInterval = 0.35 // ~3 per second
// UI thresholds
static let uiLateInsertThreshold: TimeInterval = 15.0
static let uiProcessedNostrEventsCap: Int = 2000
static let uiChannelInactivityThresholdSeconds: TimeInterval = 9 * 60
// UI rate limiters (token buckets)
static let uiSenderRateBucketCapacity: Double = 5
static let uiSenderRateBucketRefillPerSec: Double = 1.0
static let uiContentRateBucketCapacity: Double = 3
static let uiContentRateBucketRefillPerSec: Double = 0.5
// UI sleeps/delays
static let uiStartupInitialDelaySeconds: TimeInterval = 1.0
static let uiStartupShortSleepNs: UInt64 = 200_000_000
static let uiStartupPhaseDurationSeconds: TimeInterval = 2.0
static let uiAsyncShortSleepNs: UInt64 = 100_000_000
static let uiAsyncMediumSleepNs: UInt64 = 500_000_000
static let uiReadReceiptRetryShortSeconds: TimeInterval = 0.1
static let uiReadReceiptRetryLongSeconds: TimeInterval = 0.5
static let uiBatchDispatchStaggerSeconds: TimeInterval = 0.15
static let uiScrollThrottleSeconds: TimeInterval = 0.5
static let uiAnimationShortSeconds: TimeInterval = 0.15
static let uiAnimationMediumSeconds: TimeInterval = 0.2
static let uiAnimationSidebarSeconds: TimeInterval = 0.25
static let uiRecentCutoffFiveMinutesSeconds: TimeInterval = 5 * 60
// BLE maintenance & thresholds
static let bleMaintenanceInterval: TimeInterval = 10.0
static let bleMaintenanceLeewaySeconds: Int = 1
static let bleIsolationRelaxThresholdSeconds: TimeInterval = 60
static let bleRecentTimeoutWindowSeconds: TimeInterval = 60
static let bleRecentTimeoutCountThreshold: Int = 3
static let bleRSSIIsolatedBase: Int = -90
static let bleRSSIIsolatedRelaxed: Int = -92
static let bleRSSIConnectedThreshold: Int = -85
static let bleRSSIHighTimeoutThreshold: Int = -80
static let blePeerInactivityTimeoutSeconds: TimeInterval = 20.0
static let bleFragmentLifetimeSeconds: TimeInterval = 30.0
static let bleIngressRecordLifetimeSeconds: TimeInterval = 3.0
static let bleConnectTimeoutBackoffWindowSeconds: TimeInterval = 120.0
static let bleRecentPacketWindowSeconds: TimeInterval = 30.0
static let bleRecentPacketWindowMaxCount: Int = 100
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
static let bleExpectedWritePerFragmentMs: Int = 8
static let bleExpectedWriteMaxMs: Int = 2000
static let bleFragmentSpacingMs: Int = 6
static let bleAnnounceIntervalSeconds: TimeInterval = 10.0
static let bleDutyOnDurationDense: TimeInterval = 3.0
static let bleDutyOffDurationDense: TimeInterval = 15.0
static let bleConnectedAnnounceBaseSecondsDense: TimeInterval = 90.0
static let bleConnectedAnnounceBaseSecondsSparse: TimeInterval = 45.0
static let bleConnectedAnnounceJitterDense: TimeInterval = 20.0
static let bleConnectedAnnounceJitterSparse: TimeInterval = 7.5
// Location
static let locationDistanceFilterMeters: Double = 1000
static let locationLiveRefreshInterval: TimeInterval = 5.0
// Nostr geohash
static let nostrGeohashInitialLookbackSeconds: TimeInterval = 3600
static let nostrGeohashInitialLimit: Int = 200
static let nostrGeoRelayCount: Int = 5
static let nostrGeohashSampleLookbackSeconds: TimeInterval = 300
static let nostrGeohashSampleLimit: Int = 100
static let nostrDMSubscribeLookbackSeconds: TimeInterval = 86400
// Nostr helpers
static let nostrShortKeyDisplayLength: Int = 8
static let nostrConvKeyPrefixLength: Int = 16
// Compression
static let compressionThresholdBytes: Int = 100
// Message deduplication
static let messageDedupMaxAgeSeconds: TimeInterval = 300
static let messageDedupMaxCount: Int = 1000
// Verification QR
static let verificationQRMaxAgeSeconds: TimeInterval = 5 * 60
// Nostr relay backoff
static let nostrRelayInitialBackoffSeconds: TimeInterval = 1.0
static let nostrRelayMaxBackoffSeconds: TimeInterval = 300.0
static let nostrRelayBackoffMultiplier: Double = 2.0
static let nostrRelayMaxReconnectAttempts: Int = 10
static let nostrRelayDefaultFetchLimit: Int = 100
// Geo relay directory
static let geoRelayFetchIntervalSeconds: TimeInterval = 60 * 60 * 24
// BLE operational delays
static let bleInitialAnnounceDelaySeconds: TimeInterval = 2.0
static let bleConnectTimeoutSeconds: TimeInterval = 8.0
static let bleRestartScanDelaySeconds: TimeInterval = 0.1
static let blePostSubscribeAnnounceDelaySeconds: TimeInterval = 0.1
static let blePostAnnounceDelaySeconds: TimeInterval = 0.4
static let bleForceAnnounceMinIntervalSeconds: TimeInterval = 0.2
// Content hashing / formatting
static let contentKeyPrefixLength: Int = 256
static let uiLongMessageLengthThreshold: Int = 2000
static let uiVeryLongTokenThreshold: Int = 512
static let uiLongMessageLineLimit: Int = 30
static let uiFingerprintSampleCount: Int = 3
// UI swipe/gesture thresholds
static let uiBackSwipeTranslationLarge: CGFloat = 50
static let uiBackSwipeTranslationSmall: CGFloat = 30
static let uiBackSwipeVelocityThreshold: CGFloat = 300
// UI color tuning
static let uiColorHueAvoidanceDelta: Double = 0.05
static let uiColorHueOffset: Double = 0.12
// UI windowing (infinite scroll)
static let uiWindowInitialCountPublic: Int = 300
static let uiWindowInitialCountPrivate: Int = 300
static let uiWindowStepCount: Int = 200
// Share extension
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 0.3
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
}
+185
View File
@@ -0,0 +1,185 @@
import Foundation
import CryptoKit
/// QR verification scaffolding: schema, signing, and basic challenge/response helpers.
final class VerificationService {
static let shared = VerificationService()
// Injected Noise service from the running transport (do NOT create new BLEService)
private var noise: NoiseEncryptionService?
func configure(with noise: NoiseEncryptionService) { self.noise = noise }
/// Encapsulates the data encoded into a verification QR
struct VerificationQR: Codable {
let v: Int
let noiseKeyHex: String
let signKeyHex: String
let npub: String?
let nickname: String
let ts: Int64
let nonceB64: String
var sigHex: String
static let context = "bitchat-verify-v1"
/// Canonical bytes used for signature (deterministic ordering)
func canonicalBytes() -> Data {
var out = Data()
func appendField(_ s: String) {
let d = s.data(using: .utf8) ?? Data()
out.append(UInt8(min(d.count, 255)))
out.append(d.prefix(255))
}
appendField(Self.context)
appendField(String(v))
appendField(noiseKeyHex.lowercased())
appendField(signKeyHex.lowercased())
appendField(npub ?? "")
appendField(nickname)
appendField(String(ts))
appendField(nonceB64)
return out
}
func toURLString() -> String {
var comps = URLComponents()
comps.scheme = "bitchat"
comps.host = "verify"
comps.queryItems = [
URLQueryItem(name: "v", value: String(v)),
URLQueryItem(name: "noise", value: noiseKeyHex),
URLQueryItem(name: "sign", value: signKeyHex),
URLQueryItem(name: "nick", value: nickname),
URLQueryItem(name: "ts", value: String(ts)),
URLQueryItem(name: "nonce", value: nonceB64),
URLQueryItem(name: "sig", value: sigHex)
] + (npub != nil ? [URLQueryItem(name: "npub", value: npub)] : [])
return comps.string ?? ""
}
static func fromURL(_ url: URL) -> VerificationQR? {
guard url.scheme == "bitchat", url.host == "verify",
let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil }
func val(_ name: String) -> String? { items.first(where: { $0.name == name })?.value }
guard let vStr = val("v"), let v = Int(vStr),
let noise = val("noise"), let sign = val("sign"),
let nick = val("nick"), let tsStr = val("ts"), let ts = Int64(tsStr),
let nonce = val("nonce"), let sig = val("sig") else { return nil }
return VerificationQR(v: v, noiseKeyHex: noise, signKeyHex: sign, npub: val("npub"), nickname: nick, ts: ts, nonceB64: nonce, sigHex: sig)
}
}
// MARK: - Public API
/// Build a signed QR string for the current identity
func buildMyQRString(nickname: String, npub: String?) -> String? {
// Simple short-lived cache to speed up sheet opening
struct Cache { static var last: (nick: String, npub: String?, builtAt: Date, value: String)? }
if let c = Cache.last, c.nick == nickname, c.npub == npub, Date().timeIntervalSince(c.builtAt) < 60 {
return c.value
}
guard let noise = noise else { return nil }
let noiseKey = noise.getStaticPublicKeyData().hexEncodedString()
let signKey = noise.getSigningPublicKeyData().hexEncodedString()
let ts = Int64(Date().timeIntervalSince1970)
var nonce = Data(count: 16)
_ = nonce.withUnsafeMutableBytes { SecRandomCopyBytes(kSecRandomDefault, 16, $0.baseAddress!) }
let nonceB64 = nonce.base64EncodedString().replacingOccurrences(of: "+", with: "-").replacingOccurrences(of: "/", with: "_").replacingOccurrences(of: "=", with: "")
let payload = VerificationQR(v: 1, noiseKeyHex: noiseKey, signKeyHex: signKey, npub: npub, nickname: nickname, ts: ts, nonceB64: nonceB64, sigHex: "")
let msg = payload.canonicalBytes()
guard let sig = noise.signData(msg) else { return nil }
let signed = VerificationQR(v: payload.v,
noiseKeyHex: payload.noiseKeyHex,
signKeyHex: payload.signKeyHex,
npub: payload.npub,
nickname: payload.nickname,
ts: payload.ts,
nonceB64: payload.nonceB64,
sigHex: sig.map { String(format: "%02x", $0) }.joined())
let out = signed.toURLString()
Cache.last = (nickname, npub, Date(), out)
return out
}
/// Verify a scanned QR and return the parsed payload if valid (signature + freshness checks)
func verifyScannedQR(_ urlString: String, maxAge: TimeInterval = TransportConfig.verificationQRMaxAgeSeconds) -> VerificationQR? {
guard let url = URL(string: urlString), let qr = VerificationQR.fromURL(url) else { return nil }
// Freshness
let now = Date().timeIntervalSince1970
if now - Double(qr.ts) > maxAge { return nil }
// Verify signature using embedded ed25519 signKey
guard let sig = Data(hexString: qr.sigHex), let signKey = Data(hexString: qr.signKeyHex) else { return nil }
guard let noise = noise else { return nil }
let ok = noise.verifySignature(sig, for: qr.canonicalBytes(), publicKey: signKey)
return ok ? qr : nil
}
// MARK: - Noise payloads (scaffold only)
func buildVerifyChallenge(noiseKeyHex: String, nonceA: Data) -> Data {
// TLV: [0x01 len noiseKeyHex ascii] [0x02 len nonceA]
var tlv = Data()
let n0: [UInt8] = [0x01, UInt8(min(noiseKeyHex.count, 255))]
tlv.append(contentsOf: n0)
tlv.append(noiseKeyHex.data(using: .utf8)!.prefix(255))
tlv.append(0x02)
tlv.append(UInt8(min(nonceA.count, 255)))
tlv.append(nonceA.prefix(255))
return NoisePayload(type: .verifyChallenge, data: tlv).encode()
}
func buildVerifyResponse(noiseKeyHex: String, nonceA: Data) -> Data? {
// Sign context: verify-response | noiseKeyHex | nonceA
var msg = Data("bitchat-verify-resp-v1".utf8)
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
msg.append(nonceA)
guard let noise = noise, let sig = noise.signData(msg) else { return nil }
var tlv = Data()
tlv.append(0x01); tlv.append(UInt8(min(nk.count, 255))); tlv.append(nk.prefix(255))
tlv.append(0x02); tlv.append(UInt8(min(nonceA.count, 255))); tlv.append(nonceA.prefix(255))
tlv.append(0x03); tlv.append(UInt8(min(sig.count, 255))); tlv.append(sig.prefix(255))
return NoisePayload(type: .verifyResponse, data: tlv).encode()
}
func parseVerifyChallenge(_ data: Data) -> (noiseKeyHex: String, nonceA: Data)? {
var idx = 0
func take(_ n: Int) -> Data? {
guard idx + n <= data.count else { return nil }
let d = data[idx..<(idx+n)]
idx += n
return Data(d)
}
// Expect type already stripped; we receive only TLV here
// TLV 0x01 noiseKeyHex
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
// TLV 0x02 nonceA
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
return (noiseStr, nA)
}
func parseVerifyResponse(_ data: Data) -> (noiseKeyHex: String, nonceA: Data, signature: Data)? {
var idx = 0
func take(_ n: Int) -> Data? {
guard idx + n <= data.count else { return nil }
let d = data[idx..<(idx+n)]
idx += n
return Data(d)
}
guard let t1 = take(1), t1[0] == 0x01, let l1 = take(1), let s1 = take(Int(l1[0])),
let noiseStr = String(data: s1, encoding: .utf8) else { return nil }
guard let t2 = take(1), t2[0] == 0x02, let l2 = take(1), let nA = take(Int(l2[0])) else { return nil }
guard let t3 = take(1), t3[0] == 0x03, let l3 = take(1), let sig = take(Int(l3[0])) else { return nil }
return (noiseStr, nA, sig)
}
func verifyResponseSignature(noiseKeyHex: String, nonceA: Data, signature: Data, signerPublicKeyHex: String) -> Bool {
var msg = Data("bitchat-verify-resp-v1".utf8)
let nk = noiseKeyHex.data(using: .utf8) ?? Data()
msg.append(UInt8(min(nk.count, 255))); msg.append(nk.prefix(255))
msg.append(nonceA)
guard let noise = noise, let pub = Data(hexString: signerPublicKeyHex) else { return false }
return noise.verifySignature(signature, for: msg, publicKey: pub)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@ import Compression
struct CompressionUtil { struct CompressionUtil {
// Compression threshold - don't compress if data is smaller than this // Compression threshold - don't compress if data is smaller than this
static let compressionThreshold = 100 // bytes static let compressionThreshold = TransportConfig.compressionThresholdBytes // bytes
// Compress data using zlib algorithm (most compatible) // Compress data using zlib algorithm (most compatible)
static func compress(_ data: Data) -> Data? { static func compress(_ data: Data) -> Data? {
+8 -4
View File
@@ -18,14 +18,18 @@ struct InputValidator {
/// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64) /// Validates a peer ID from any source (short 16-hex, full 64-hex, or internal alnum/-/_ up to 64)
static func validatePeerID(_ peerID: String) -> Bool { static func validatePeerID(_ peerID: String) -> Bool {
// Accept short routing IDs (16-hex) // Accept short routing IDs (exact 16-hex)
if PeerIDResolver.isShortID(peerID) { return true } if PeerIDResolver.isShortID(peerID) { return true }
// Accept full Noise key hex (64-hex) // If length equals short-hex length but isn't valid hex, reject
if peerID.count == Limits.hexPeerIDLength { return false }
// Accept full Noise key hex (exact 64-hex)
if PeerIDResolver.isNoiseKeyHex(peerID) { return true } if PeerIDResolver.isNoiseKeyHex(peerID) { return true }
// Internal format: alphanumeric + dash/underscore up to 64 // If length equals full key length but isn't valid hex, reject
if peerID.count == Limits.maxPeerIDLength { return false }
// Internal format: alphanumeric + dash/underscore up to 63 (not 16 or 64)
let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_")) let validCharset = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
return !peerID.isEmpty && return !peerID.isEmpty &&
peerID.count <= Limits.maxPeerIDLength && peerID.count < Limits.maxPeerIDLength &&
peerID.rangeOfCharacter(from: validCharset.inverted) == nil peerID.rangeOfCharacter(from: validCharset.inverted) == nil
} }
+2 -3
View File
@@ -11,8 +11,8 @@ final class MessageDeduplicator {
private var entries: [Entry] = [] private var entries: [Entry] = []
private var lookup = Set<String>() private var lookup = Set<String>()
private let lock = NSLock() private let lock = NSLock()
private let maxAge: TimeInterval = 300 // 5 minutes private let maxAge: TimeInterval = TransportConfig.messageDedupMaxAgeSeconds // 5 minutes
private let maxCount = 1000 private let maxCount = TransportConfig.messageDedupMaxCount
/// Check if message is duplicate and add if not /// Check if message is duplicate and add if not
func isDuplicate(_ messageID: String) -> Bool { func isDuplicate(_ messageID: String) -> Bool {
@@ -84,4 +84,3 @@ final class MessageDeduplicator {
} }
} }
} }
@@ -0,0 +1,29 @@
import Foundation
/// Resolves a stable display name for peers, adding a short suffix when collisions exist.
struct PeerDisplayNameResolver {
/// Computes display names with a `#xxxx` suffix for connected peers when nickname collisions occur.
/// - Parameters:
/// - peers: Array of tuples (id, nickname, isConnected).
/// - selfNickname: The local user's current nickname, included in collision counts to suffix remotes matching it.
/// - Returns: Map of peerID -> displayName.
static func resolve(_ peers: [(id: String, nickname: String, isConnected: Bool)], selfNickname: String) -> [String: String] {
// Count collisions among connected peers and include our own nickname
var counts: [String: Int] = [:]
for p in peers where p.isConnected {
counts[p.nickname, default: 0] += 1
}
counts[selfNickname, default: 0] += 1
var result: [String: String] = [:]
for p in peers {
var name = p.nickname
if p.isConnected, (counts[p.nickname] ?? 0) > 1 {
name += "#" + String(p.id.prefix(4))
}
result[p.id] = name
}
return result
}
}
File diff suppressed because it is too large Load Diff
+100 -181
View File
@@ -13,19 +13,9 @@ import UIKit
// MARK: - Supporting Types // MARK: - Supporting Types
// Pre-computed peer data for performance //
struct PeerDisplayData: Identifiable {
let id: String
let displayName: String
let isFavorite: Bool
let isMe: Bool
let hasUnreadMessages: Bool
let encryptionStatus: EncryptionStatus
let connectionState: BitchatPeer.ConnectionState
let isMutualFavorite: Bool
}
// (Link previews removed; URLs are now clickable inline) //
// MARK: - Main Content View // MARK: - Main Content View
@@ -33,9 +23,7 @@ struct ContentView: View {
// MARK: - Properties // MARK: - Properties
@EnvironmentObject var viewModel: ChatViewModel @EnvironmentObject var viewModel: ChatViewModel
#if os(iOS)
@ObservedObject private var locationManager = LocationChannelManager.shared @ObservedObject private var locationManager = LocationChannelManager.shared
#endif
@State private var messageText = "" @State private var messageText = ""
@State private var textFieldSelection: NSRange? = nil @State private var textFieldSelection: NSRange? = nil
@FocusState private var isTextFieldFocused: Bool @FocusState private var isTextFieldFocused: Bool
@@ -58,6 +46,7 @@ struct ContentView: View {
@State private var scrollThrottleTimer: Timer? @State private var scrollThrottleTimer: Timer?
@State private var autocompleteDebounceTimer: Timer? @State private var autocompleteDebounceTimer: Timer?
@State private var showLocationChannelsSheet = false @State private var showLocationChannelsSheet = false
@State private var showVerifySheet = false
@State private var expandedMessageIDs: Set<String> = [] @State private var expandedMessageIDs: Set<String> = []
// Window sizes for rendering (infinite scroll up) // Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300 @State private var windowCountPublic: Int = 300
@@ -111,14 +100,14 @@ struct ContentView: View {
.onEnded { value in .onEnded { value in
let translation = value.translation.width.isNaN ? 0 : value.translation.width let translation = value.translation.width.isNaN ? 0 : value.translation.width
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
if translation > 50 || (translation > 30 && velocity > 300) { if translation > TransportConfig.uiBackSwipeTranslationLarge || (translation > TransportConfig.uiBackSwipeTranslationSmall && velocity > TransportConfig.uiBackSwipeVelocityThreshold) {
withAnimation(.easeOut(duration: 0.2)) { withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showPrivateChat = false showPrivateChat = false
backSwipeOffset = 0 backSwipeOffset = 0
viewModel.endPrivateChat() viewModel.endPrivateChat()
} }
} else { } else {
withAnimation(.easeOut(duration: 0.15)) { withAnimation(.easeOut(duration: TransportConfig.uiAnimationShortSeconds)) {
backSwipeOffset = 0 backSwipeOffset = 0
} }
} }
@@ -132,7 +121,7 @@ struct ContentView: View {
Color.clear Color.clear
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { .onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar = false showSidebar = false
sidebarDragOffset = 0 sidebarDragOffset = 0
} }
@@ -162,14 +151,14 @@ struct ContentView: View {
let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width) let width = geometry.size.width.isNaN ? 0 : max(0, geometry.size.width)
return showSidebar ? -dragOffset : width - dragOffset return showSidebar ? -dragOffset : width - dragOffset
}()) }())
.animation(.easeInOut(duration: 0.25), value: showSidebar) .animation(.easeInOut(duration: TransportConfig.uiAnimationSidebarSeconds), value: showSidebar)
} }
} }
#if os(macOS) #if os(macOS)
.frame(minWidth: 600, minHeight: 400) .frame(minWidth: 600, minHeight: 400)
#endif #endif
.onChange(of: viewModel.selectedPrivateChatPeer) { newValue in .onChange(of: viewModel.selectedPrivateChatPeer) { newValue in
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showPrivateChat = newValue != nil showPrivateChat = newValue != nil
} }
} }
@@ -199,7 +188,6 @@ struct ContentView: View {
Button("direct message") { Button("direct message") {
if let peerID = selectedMessageSenderID { if let peerID = selectedMessageSenderID {
#if os(iOS)
if peerID.hasPrefix("nostr:") { if peerID.hasPrefix("nostr:") {
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) { if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
viewModel.startGeohashDM(withPubkeyHex: full) viewModel.startGeohashDM(withPubkeyHex: full)
@@ -207,10 +195,7 @@ struct ContentView: View {
} else { } else {
viewModel.startPrivateChat(with: peerID) viewModel.startPrivateChat(with: peerID)
} }
#else withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
viewModel.startPrivateChat(with: peerID)
#endif
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false showSidebar = false
sidebarDragOffset = 0 sidebarDragOffset = 0
} }
@@ -231,7 +216,6 @@ struct ContentView: View {
Button("BLOCK", role: .destructive) { Button("BLOCK", role: .destructive) {
// Prefer direct geohash block when we have a Nostr sender ID // Prefer direct geohash block when we have a Nostr sender ID
#if os(iOS)
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"), if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
let full = viewModel.fullNostrHex(forSenderPeerID: peerID), let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
let sender = selectedMessageSender { let sender = selectedMessageSender {
@@ -239,9 +223,6 @@ struct ContentView: View {
} else if let sender = selectedMessageSender { } else if let sender = selectedMessageSender {
viewModel.sendMessage("/block \(sender)") viewModel.sendMessage("/block \(sender)")
} }
#else
if let sender = selectedMessageSender { viewModel.sendMessage("/block \(sender)") }
#endif
} }
Button("cancel", role: .cancel) {} Button("cancel", role: .cancel) {}
@@ -283,22 +264,19 @@ struct ContentView: View {
// Implement windowing with adjustable window count per chat // Implement windowing with adjustable window count per chat
let currentWindowCount: Int = { let currentWindowCount: Int = {
if let peer = privatePeer { return windowCountPrivate[peer] ?? 300 } if let peer = privatePeer { return windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate }
return windowCountPublic return windowCountPublic
}() }()
let windowedMessages = messages.suffix(currentWindowCount) let windowedMessages = messages.suffix(currentWindowCount)
// Build stable UI IDs with a context key to avoid ID collisions when switching channels // Build stable UI IDs with a context key to avoid ID collisions when switching channels
#if os(iOS)
let contextKey: String = { let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: return "mesh" case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)" case .location(let ch): return "geo:\(ch.geohash)"
} }
}() }()
#else
let contextKey: String = "mesh"
#endif
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) } let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
ForEach(items, id: \.uiID) { item in ForEach(items, id: \.uiID) { item in
@@ -318,11 +296,11 @@ struct ContentView: View {
let cashuTokens = message.content.extractCashuTokens() let cashuTokens = message.content.extractCashuTokens()
let lightningLinks = message.content.extractLightningLinks() let lightningLinks = message.content.extractLightningLinks()
HStack(alignment: .top, spacing: 0) { HStack(alignment: .top, spacing: 0) {
let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id) let isExpanded = expandedMessageIDs.contains(message.id)
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme)) Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? 30 : nil) .lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages // Delivery status indicator for private messages
@@ -334,7 +312,7 @@ struct ContentView: View {
} }
// Expand/Collapse for very long messages // Expand/Collapse for very long messages
if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty { if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty {
let isExpanded = expandedMessageIDs.contains(message.id) let isExpanded = expandedMessageIDs.contains(message.id)
Button(isExpanded ? "show less" : "show more") { Button(isExpanded ? "show less" : "show more") {
if isExpanded { expandedMessageIDs.remove(message.id) } if isExpanded { expandedMessageIDs.remove(message.id) }
@@ -393,20 +371,17 @@ struct ContentView: View {
} }
// Infinite scroll up: when top row appears, increase window and preserve anchor // Infinite scroll up: when top row appears, increase window and preserve anchor
if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count { if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count {
let step = 200 let step = TransportConfig.uiWindowStepCount
#if os(iOS)
let contextKey: String = { let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: return "mesh" case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)" case .location(let ch): return "geo:\(ch.geohash)"
} }
}() }()
#else
let contextKey: String = "mesh"
#endif
let preserveID = "\(contextKey)|\(message.id)" let preserveID = "\(contextKey)|\(message.id)"
if let peer = privatePeer { if let peer = privatePeer {
let current = windowCountPrivate[peer] ?? 300 let current = windowCountPrivate[peer] ?? TransportConfig.uiWindowInitialCountPrivate
let newCount = min(messages.count, current + step) let newCount = min(messages.count, current + step)
if newCount != current { if newCount != current {
windowCountPrivate[peer] = newCount windowCountPrivate[peer] = newCount
@@ -472,7 +447,6 @@ struct ContentView: View {
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return } guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
#if os(iOS)
func levelForLength(_ len: Int) -> GeohashChannelLevel { func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len { switch len {
case 0...2: return .region case 0...2: return .region
@@ -487,7 +461,6 @@ struct ContentView: View {
let ch = GeohashChannel(level: level, geohash: gh) let ch = GeohashChannel(level: level, geohash: gh)
LocationChannelManager.shared.markTeleported(for: gh, true) LocationChannelManager.shared.markTeleported(for: gh, true)
LocationChannelManager.shared.select(ChannelID.location(ch)) LocationChannelManager.shared.select(ChannelID.location(ch))
#endif
} }
.onTapGesture(count: 3) { .onTapGesture(count: 3) {
// Triple-tap to clear current chat // Triple-tap to clear current chat
@@ -500,16 +473,12 @@ struct ContentView: View {
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)" return "dm:\(peer)|\(last)"
} }
#if os(iOS)
let contextKey: String = { let contextKey: String = {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: return "mesh" case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)" case .location(let ch): return "geo:\(ch.geohash)"
} }
}() }()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil return nil
}() }()
@@ -524,16 +493,12 @@ struct ContentView: View {
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)" return "dm:\(peer)|\(last)"
} }
#if os(iOS)
let contextKey: String = { let contextKey: String = {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: return "mesh" case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)" case .location(let ch): return "geo:\(ch.geohash)"
} }
}() }()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil return nil
}() }()
@@ -547,16 +512,12 @@ struct ContentView: View {
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)" return "dm:\(peer)|\(last)"
} }
#if os(iOS)
let contextKey: String = { let contextKey: String = {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: return "mesh" case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)" case .location(let ch): return "geo:\(ch.geohash)"
} }
}() }()
#else
let contextKey: String = "mesh"
#endif
if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" }
return nil return nil
}() }()
@@ -579,19 +540,15 @@ struct ContentView: View {
} }
// Throttle scroll animations to prevent excessive UI updates // Throttle scroll animations to prevent excessive UI updates
let now = Date() let now = Date()
if now.timeIntervalSince(lastScrollTime) > 0.5 { if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
// Immediate scroll if enough time has passed // Immediate scroll if enough time has passed
lastScrollTime = now lastScrollTime = now
#if os(iOS)
let contextKey: String = { let contextKey: String = {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .mesh: return "mesh" case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)" case .location(let ch): return "geo:\(ch.geohash)"
} }
}() }()
#else
let contextKey: String = "mesh"
#endif
let count = windowCountPublic let count = windowCountPublic
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -600,18 +557,14 @@ struct ContentView: View {
} else { } else {
// Schedule a delayed scroll // Schedule a delayed scroll
scrollThrottleTimer?.invalidate() scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
lastScrollTime = Date() lastScrollTime = Date()
#if os(iOS) let contextKey: String = {
let contextKey: String = { switch locationManager.selectedChannel {
switch locationManager.selectedChannel { case .mesh: return "mesh"
case .mesh: return "mesh" case .location(let ch): return "geo:\(ch.geohash)"
case .location(let ch): return "geo:\(ch.geohash)" }
} }()
}()
#else
let contextKey: String = "mesh"
#endif
let count = windowCountPublic let count = windowCountPublic
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async { DispatchQueue.main.async {
@@ -636,7 +589,7 @@ struct ContentView: View {
} }
// Same throttling for private chats // Same throttling for private chats
let now = Date() let now = Date()
if now.timeIntervalSince(lastScrollTime) > 0.5 { if now.timeIntervalSince(lastScrollTime) > TransportConfig.uiScrollThrottleSeconds {
lastScrollTime = now lastScrollTime = now
let contextKey = "dm:\(peerID)" let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300 let count = windowCountPrivate[peerID] ?? 300
@@ -646,7 +599,7 @@ struct ContentView: View {
} }
} else { } else {
scrollThrottleTimer?.invalidate() scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: TransportConfig.uiScrollThrottleSeconds, repeats: false) { _ in
lastScrollTime = Date() lastScrollTime = Date()
let contextKey = "dm:\(peerID)" let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300 let count = windowCountPrivate[peerID] ?? 300
@@ -658,7 +611,6 @@ struct ContentView: View {
} }
} }
} }
#if os(iOS)
.onChange(of: locationManager.selectedChannel) { newChannel in .onChange(of: locationManager.selectedChannel) { newChannel in
// When switching to a new geohash channel, scroll to the bottom // When switching to a new geohash channel, scroll to the bottom
guard privatePeer == nil else { return } guard privatePeer == nil else { return }
@@ -667,7 +619,7 @@ struct ContentView: View {
break break
case .location(let ch): case .location(let ch):
// Reset window size // Reset window size
windowCountPublic = 300 windowCountPublic = TransportConfig.uiWindowInitialCountPublic
let contextKey = "geo:\(ch.geohash)" let contextKey = "geo:\(ch.geohash)"
let last = viewModel.messages.suffix(windowCountPublic).last?.id let last = viewModel.messages.suffix(windowCountPublic).last?.id
let target = last.map { "\(contextKey)|\($0)" } let target = last.map { "\(contextKey)|\($0)" }
@@ -677,18 +629,17 @@ struct ContentView: View {
} }
} }
} }
#endif
.onAppear { .onAppear {
// Also check when view appears // Also check when view appears
if let peerID = privatePeer { if let peerID = privatePeer {
// Try multiple times to ensure read receipts are sent // Try multiple times to ensure read receipts are sent
viewModel.markPrivateMessagesAsRead(from: peerID) viewModel.markPrivateMessagesAsRead(from: peerID)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
viewModel.markPrivateMessagesAsRead(from: peerID) viewModel.markPrivateMessagesAsRead(from: peerID)
} }
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryLongSeconds) {
viewModel.markPrivateMessagesAsRead(from: peerID) viewModel.markPrivateMessagesAsRead(from: peerID)
} }
} }
@@ -747,18 +698,22 @@ struct ContentView: View {
if showCommandSuggestions && !commandSuggestions.isEmpty { if showCommandSuggestions && !commandSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
// Define commands with aliases and syntax // Define commands with aliases and syntax
let commandInfo: [(commands: [String], syntax: String?, description: String)] = [ let baseInfo: [(commands: [String], syntax: String?, description: String)] = [
(["/block"], "[nickname]", "block or list blocked peers"), (["/block"], "[nickname]", "block or list blocked peers"),
(["/clear"], nil, "clear chat messages"), (["/clear"], nil, "clear chat messages"),
(["/fav"], "<nickname>", "add to favorites"),
(["/help"], nil, "show this help"),
(["/hug"], "<nickname>", "send someone a warm hug"), (["/hug"], "<nickname>", "send someone a warm hug"),
(["/m", "/msg"], "<nickname> [message]", "send private message"), (["/m", "/msg"], "<nickname> [message]", "send private message"),
(["/slap"], "<nickname>", "slap someone with a trout"), (["/slap"], "<nickname>", "slap someone with a trout"),
(["/unblock"], "<nickname>", "unblock a peer"), (["/unblock"], "<nickname>", "unblock a peer"),
(["/unfav"], "<nickname>", "remove from favorites"),
(["/w"], nil, "see who's online") (["/w"], nil, "see who's online")
] ]
let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }()
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
let favInfo: [(commands: [String], syntax: String?, description: String)] = [
(["/fav"], "<nickname>", "add to favorites"),
(["/unfav"], "<nickname>", "remove from favorites")
]
let commandInfo = baseInfo + ((isGeoPublic || isGeoDM) ? [] : favInfo)
// Build the display // Build the display
let allCommands = commandInfo let allCommands = commandInfo
@@ -833,18 +788,25 @@ struct ContentView: View {
// Check for command autocomplete (instant, no debounce needed) // Check for command autocomplete (instant, no debounce needed)
if newValue.hasPrefix("/") && newValue.count >= 1 { if newValue.hasPrefix("/") && newValue.count >= 1 {
// Build context-aware command list // Build context-aware command list
let commandDescriptions = [ let isGeoPublic: Bool = {
if case .location = locationManager.selectedChannel { return true }
return false
}()
let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true)
var commandDescriptions = [
("/block", "block or list blocked peers"), ("/block", "block or list blocked peers"),
("/clear", "clear chat messages"), ("/clear", "clear chat messages"),
("/fav", "add to favorites"),
("/help", "show this help"),
("/hug", "send someone a warm hug"), ("/hug", "send someone a warm hug"),
("/m", "send private message"), ("/m", "send private message"),
("/slap", "slap someone with a trout"), ("/slap", "slap someone with a trout"),
("/unblock", "unblock a peer"), ("/unblock", "unblock a peer"),
("/unfav", "remove from favorites"),
("/w", "see who's online") ("/w", "see who's online")
] ]
// Only show favorites commands when not in geohash context
if !(isGeoPublic || isGeoDM) {
commandDescriptions.append(("/fav", "add to favorites"))
commandDescriptions.append(("/unfav", "remove from favorites"))
}
let input = newValue.lowercased() let input = newValue.lowercased()
@@ -897,7 +859,7 @@ struct ContentView: View {
} }
.onAppear { .onAppear {
// Delay keyboard focus to avoid iOS constraint warnings // Delay keyboard focus to avoid iOS constraint warnings
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiReadReceiptRetryShortSeconds) {
isTextFieldFocused = true isTextFieldFocused = true
} }
} }
@@ -926,6 +888,15 @@ struct ContentView: View {
.font(.system(size: 16, weight: .bold, design: .monospaced)) .font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor) .foregroundColor(textColor)
Spacer() Spacer()
// Show QR in mesh on all platforms
if case .mesh = locationManager.selectedChannel {
Button(action: { showVerifySheet = true }) {
Image(systemName: "qrcode")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.help("Verification: show my QR or scan a friend")
}
} }
.frame(height: 44) // Match header height .frame(height: 44) // Match header height
.padding(.horizontal, 12) .padding(.horizontal, 12)
@@ -938,53 +909,34 @@ struct ContentView: View {
VStack(alignment: .leading, spacing: 6) { VStack(alignment: .leading, spacing: 6) {
// People section // People section
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
#if os(iOS) if case .location = locationManager.selectedChannel {
if case .location = locationManager.selectedChannel { GeohashPeopleList(viewModel: viewModel,
GeohashPeopleList(viewModel: viewModel, textColor: textColor,
textColor: textColor, secondaryTextColor: secondaryTextColor,
secondaryTextColor: secondaryTextColor, onTapPerson: {
onTapPerson: { withAnimation(.easeInOut(duration: 0.2)) {
withAnimation(.easeInOut(duration: 0.2)) { showSidebar = false
showSidebar = false sidebarDragOffset = 0
sidebarDragOffset = 0 }
} })
}) } else {
} else { MeshPeerList(viewModel: viewModel,
MeshPeerList(viewModel: viewModel, textColor: textColor,
textColor: textColor, secondaryTextColor: secondaryTextColor,
secondaryTextColor: secondaryTextColor, onTapPeer: { peerID in
onTapPeer: { peerID in viewModel.startPrivateChat(with: peerID)
viewModel.startPrivateChat(with: peerID) withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
withAnimation(.easeInOut(duration: 0.2)) { showSidebar = false
showSidebar = false sidebarDragOffset = 0
sidebarDragOffset = 0 }
} },
}, onToggleFavorite: { peerID in
onToggleFavorite: { peerID in viewModel.toggleFavorite(peerID: peerID)
viewModel.toggleFavorite(peerID: peerID) },
}, onShowFingerprint: { peerID in
onShowFingerprint: { peerID in viewModel.showFingerprint(for: peerID)
viewModel.showFingerprint(for: peerID) })
}) }
}
#else
MeshPeerList(viewModel: viewModel,
textColor: textColor,
secondaryTextColor: secondaryTextColor,
onTapPeer: { peerID in
viewModel.startPrivateChat(with: peerID)
withAnimation(.easeInOut(duration: 0.2)) {
showSidebar = false
sidebarDragOffset = 0
}
},
onToggleFavorite: { peerID in
viewModel.toggleFavorite(peerID: peerID)
},
onShowFingerprint: { peerID in
viewModel.showFingerprint(for: peerID)
})
#endif
} }
} }
.id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined()) .id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined())
@@ -1021,7 +973,7 @@ struct ContentView: View {
.onEnded { value in .onEnded { value in
let translation = value.translation.width.isNaN ? 0 : value.translation.width let translation = value.translation.width.isNaN ? 0 : value.translation.width
let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width let velocity = value.velocity.width.isNaN ? 0 : value.velocity.width
withAnimation(.easeOut(duration: 0.2)) { withAnimation(.easeOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
if !showSidebar { if !showSidebar {
if translation < -100 || (translation < -50 && velocity < -500) { if translation < -100 || (translation < -50 && velocity < -500) {
showSidebar = true showSidebar = true
@@ -1074,13 +1026,11 @@ struct ContentView: View {
return (name, "") return (name, "")
} }
#if os(iOS) // Compute channel-aware people count and color for toolbar (cross-platform)
// Compute channel-aware people count and color for toolbar
private func channelPeopleCountAndColor() -> (Int, Color) { private func channelPeopleCountAndColor() -> (Int, Color) {
switch locationManager.selectedChannel { switch locationManager.selectedChannel {
case .location: case .location:
let n = viewModel.geohashPeople.count let n = viewModel.geohashPeople.count
// Use standard green (dark: system green; light: custom darker green)
let standardGreen = (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) let standardGreen = (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
return (n, n > 0 ? standardGreen : Color.secondary) return (n, n > 0 ? standardGreen : Color.secondary)
case .mesh: case .mesh:
@@ -1090,13 +1040,11 @@ struct ContentView: View {
if isMeshConnected { counts.mesh += 1; counts.others += 1 } if isMeshConnected { counts.mesh += 1; counts.others += 1 }
else if peer.isMutualFavorite { counts.others += 1 } else if peer.isMutualFavorite { counts.others += 1 }
} }
// Darker, more neutral blue (less purple hue)
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary
return (counts.others, color) return (counts.others, color)
} }
} }
#endif
private var mainHeaderView: some View { private var mainHeaderView: some View {
@@ -1143,7 +1091,6 @@ struct ContentView: View {
// Channel badge + dynamic spacing + people counter // Channel badge + dynamic spacing + people counter
// Precompute header count and color outside the ViewBuilder expressions // Precompute header count and color outside the ViewBuilder expressions
#if os(iOS)
let cc = channelPeopleCountAndColor() let cc = channelPeopleCountAndColor()
let headerCountColor: Color = cc.1 let headerCountColor: Color = cc.1
let headerOtherPeersCount: Int = { let headerOtherPeersCount: Int = {
@@ -1152,24 +1099,11 @@ struct ContentView: View {
} }
return cc.0 return cc.0
}() }()
#else
let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in
guard peer.id != viewModel.meshService.myPeerID else { return }
let isMeshConnected = peer.isConnected
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
else if peer.isMutualFavorite { counts.others += 1 }
}
let headerOtherPeersCount = peerCounts.others
// Darker, more neutral blue (less purple hue)
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let headerCountColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary
#endif
HStack(spacing: 10) { HStack(spacing: 10) {
// Unread icon immediately to the left of the channel badge (independent from channel button) // Unread icon immediately to the left of the channel badge (independent from channel button)
// Unread indicator // Unread indicator (now shown on iOS and macOS)
#if os(iOS)
if viewModel.hasAnyUnreadMessages { if viewModel.hasAnyUnreadMessages {
Button(action: { viewModel.openMostRelevantPrivateChat() }) { Button(action: { viewModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill") Image(systemName: "envelope.fill")
@@ -1204,7 +1138,6 @@ struct ContentView: View {
.accessibilityLabel("location channels") .accessibilityLabel("location channels")
} }
.buttonStyle(.plain) .buttonStyle(.plain)
#endif
HStack(spacing: 4) { HStack(spacing: 4) {
// People icon with count // People icon with count
@@ -1216,21 +1149,25 @@ struct ContentView: View {
.accessibilityHidden(true) .accessibilityHidden(true)
} }
.foregroundColor(headerCountColor) .foregroundColor(headerCountColor)
// QR moved to the PEOPLE header in the sidebar when on mesh channel
} }
.onTapGesture { .onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showSidebar.toggle() showSidebar.toggle()
sidebarDragOffset = 0 sidebarDragOffset = 0
} }
} }
.sheet(isPresented: $showVerifySheet) {
VerificationSheetView(isPresented: $showVerifySheet)
.environmentObject(viewModel)
}
} }
.frame(height: 44) .frame(height: 44)
.padding(.horizontal, 12) .padding(.horizontal, 12)
#if os(iOS)
.sheet(isPresented: $showLocationChannelsSheet) { .sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(isPresented: $showLocationChannelsSheet) LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
} }
#endif
.background(backgroundColor.opacity(0.95)) .background(backgroundColor.opacity(0.95))
} }
@@ -1266,13 +1203,11 @@ struct ContentView: View {
let peer = viewModel.getPeer(byID: headerPeerID) let peer = viewModel.getPeer(byID: headerPeerID)
let privatePeerNick: String = { let privatePeerNick: String = {
if privatePeerID.hasPrefix("nostr_") { if privatePeerID.hasPrefix("nostr_") {
#if os(iOS)
// Build geohash DM header: "#<ghash>/@name#abcd" // Build geohash DM header: "#<ghash>/@name#abcd"
if case .location(let ch) = locationManager.selectedChannel { if case .location(let ch) = locationManager.selectedChannel {
let disp = viewModel.geohashDisplayName(for: privatePeerID) let disp = viewModel.geohashDisplayName(for: privatePeerID)
return "#\(ch.geohash)/@\(disp)" return "#\(ch.geohash)/@\(disp)"
} }
#endif
} }
return peer?.displayName ?? return peer?.displayName ??
viewModel.meshService.peerNickname(peerID: headerPeerID) ?? viewModel.meshService.peerNickname(peerID: headerPeerID) ??
@@ -1359,7 +1294,7 @@ struct ContentView: View {
// Left and right buttons positioned with HStack // Left and right buttons positioned with HStack
HStack { HStack {
Button(action: { Button(action: {
withAnimation(.easeInOut(duration: 0.2)) { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) {
showPrivateChat = false showPrivateChat = false
viewModel.endPrivateChat() viewModel.endPrivateChat()
} }
@@ -1437,23 +1372,7 @@ private struct PaymentChipView: View {
} }
} }
// Helper view for rendering message content (plain, no hashtag/mention formatting) //
struct MessageContentView: View {
let message: BitchatMessage
let viewModel: ChatViewModel
let colorScheme: ColorScheme
let isMentioned: Bool
var body: some View {
Text(message.content)
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular)
}
// MARK: - Helper Methods
// buildTextSegments removed: content is rendered plain.
}
// Delivery status indicator view // Delivery status indicator view
struct DeliveryStatusView: View { struct DeliveryStatusView: View {
+17 -2
View File
@@ -32,8 +32,9 @@ struct FingerprintView: View {
Spacer() Spacer()
Button("DONE") { Button(action: { dismiss() }) {
dismiss() Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
} }
.foregroundColor(textColor) .foregroundColor(textColor)
} }
@@ -165,6 +166,20 @@ struct FingerprintView: View {
.cornerRadius(8) .cornerRadius(8)
} }
.buttonStyle(PlainButtonStyle()) .buttonStyle(PlainButtonStyle())
} else {
Button(action: {
viewModel.unverifyFingerprint(for: peerID)
dismiss()
}) {
Text("REMOVE VERIFICATION")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(.white)
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(Color.red)
.cornerRadius(8)
}
.buttonStyle(PlainButtonStyle())
} }
} }
.padding(.top) .padding(.top)
-12
View File
@@ -1,6 +1,5 @@
import SwiftUI import SwiftUI
#if os(iOS)
struct GeohashPeopleList: View { struct GeohashPeopleList: View {
@ObservedObject var viewModel: ChatViewModel @ObservedObject var viewModel: ChatViewModel
let textColor: Color let textColor: Color
@@ -29,16 +28,12 @@ struct GeohashPeopleList: View {
let people = viewModel.visibleGeohashPeople() let people = viewModel.visibleGeohashPeople()
let currentIDs = people.map { $0.id } let currentIDs = people.map { $0.id }
#if os(iOS)
let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() }) let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() })
let isTeleportedID: (String) -> Bool = { id in let isTeleportedID: (String) -> Bool = { id in
if teleportedSet.contains(id.lowercased()) { return true } if teleportedSet.contains(id.lowercased()) { return true }
if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true } if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true }
return false return false
} }
#else
let isTeleportedID: (String) -> Bool = { _ in false }
#endif
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
let nonTele = displayIDs.filter { !isTeleportedID($0) } let nonTele = displayIDs.filter { !isTeleportedID($0) }
@@ -52,11 +47,7 @@ struct GeohashPeopleList: View {
let person = personByID[pid]! let person = personByID[pid]!
HStack(spacing: 4) { HStack(spacing: 4) {
let isMe = (person.id == myHex) let isMe = (person.id == myHex)
#if os(iOS)
let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported) let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported)
#else
let teleported = false
#endif
let icon = teleported ? "face.dashed" : "mappin.and.ellipse" let icon = teleported ? "face.dashed" : "mappin.and.ellipse"
let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark) let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
let rowColor: Color = isMe ? .orange : assignedColor let rowColor: Color = isMe ? .orange : assignedColor
@@ -127,10 +118,8 @@ struct GeohashPeopleList: View {
} }
} }
} }
#endif
// Helper to split a trailing #abcd suffix // Helper to split a trailing #abcd suffix
#if os(iOS)
private func splitSuffix(from name: String) -> (String, String) { private func splitSuffix(from name: String) -> (String, String) {
guard name.count >= 5 else { return (name, "") } guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5)) let suffix = String(name.suffix(5))
@@ -142,4 +131,3 @@ private func splitSuffix(from name: String) -> (String, String) {
} }
return (name, "") return (name, "")
} }
#endif
+39 -13
View File
@@ -1,7 +1,10 @@
import SwiftUI import SwiftUI
import CoreLocation
#if os(iOS) #if os(iOS)
import UIKit import UIKit
#else
import AppKit
#endif
struct LocationChannelsSheet: View { struct LocationChannelsSheet: View {
@Binding var isPresented: Bool @Binding var isPresented: Bool
@ObservedObject private var manager = LocationChannelManager.shared @ObservedObject private var manager = LocationChannelManager.shared
@@ -37,11 +40,7 @@ struct LocationChannelsSheet: View {
Text("location permission denied. enable in settings to use location channels.") Text("location permission denied. enable in settings to use location channels.")
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
Button("open settings") { Button("open settings") { openSystemLocationSettings() }
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
}
.buttonStyle(.plain) .buttonStyle(.plain)
} }
case LocationChannelManager.PermissionState.authorized: case LocationChannelManager.PermissionState.authorized:
@@ -54,6 +53,7 @@ struct LocationChannelsSheet: View {
} }
.padding(.horizontal, 16) .padding(.horizontal, 16)
.padding(.vertical, 12) .padding(.vertical, 12)
#if os(iOS)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
.toolbar { .toolbar {
ToolbarItem(placement: .navigationBarTrailing) { ToolbarItem(placement: .navigationBarTrailing) {
@@ -61,8 +61,21 @@ struct LocationChannelsSheet: View {
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
} }
} }
#else
.toolbar {
ToolbarItem(placement: .automatic) {
Button("close") { isPresented = false }
.font(.system(size: 14, design: .monospaced))
}
}
#endif
} }
#if os(iOS)
.presentationDetents([.large]) .presentationDetents([.large])
#endif
#if os(macOS)
.frame(minWidth: 420, minHeight: 520)
#endif
.onAppear { .onAppear {
// Refresh channels when opening // Refresh channels when opening
if manager.permissionState == LocationChannelManager.PermissionState.authorized { if manager.permissionState == LocationChannelManager.PermissionState.authorized {
@@ -128,10 +141,12 @@ struct LocationChannelsSheet: View {
.font(.system(size: 14, design: .monospaced)) .font(.system(size: 14, design: .monospaced))
.foregroundColor(.secondary) .foregroundColor(.secondary)
TextField("geohash", text: $customGeohash) TextField("geohash", text: $customGeohash)
#if os(iOS)
.textInputAutocapitalization(.never) .textInputAutocapitalization(.never)
.autocorrectionDisabled(true) .autocorrectionDisabled(true)
.font(.system(size: 14, design: .monospaced))
.keyboardType(.asciiCapable) .keyboardType(.asciiCapable)
#endif
.font(.system(size: 14, design: .monospaced))
.onChange(of: customGeohash) { newValue in .onChange(of: customGeohash) { newValue in
// Allow only geohash base32 characters, strip '#', limit length // Allow only geohash base32 characters, strip '#', limit length
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
@@ -183,9 +198,7 @@ struct LocationChannelsSheet: View {
// Footer action inside the list // Footer action inside the list
if manager.permissionState == LocationChannelManager.PermissionState.authorized { if manager.permissionState == LocationChannelManager.PermissionState.authorized {
Button(action: { Button(action: {
if let url = URL(string: UIApplication.openSettingsURLString) { openSystemLocationSettings()
UIApplication.shared.open(url)
}
}) { }) {
Text("remove location access") Text("remove location access")
.font(.system(size: 12, design: .monospaced)) .font(.system(size: 12, design: .monospaced))
@@ -343,7 +356,7 @@ extension LocationChannelsSheet {
}() }()
let usesMetric: Bool = { let usesMetric: Bool = {
if #available(iOS 16.0, *) { if #available(iOS 16.0, macOS 13.0, *) {
return Locale.current.measurementSystem == .metric return Locale.current.measurementSystem == .metric
} else { } else {
return Locale.current.usesMetricSystem return Locale.current.usesMetricSystem
@@ -366,7 +379,7 @@ extension LocationChannelsSheet {
private func bluetoothRangeString() -> String { private func bluetoothRangeString() -> String {
let usesMetric: Bool = { let usesMetric: Bool = {
if #available(iOS 16.0, *) { if #available(iOS 16.0, macOS 13.0, *) {
return Locale.current.measurementSystem == .metric return Locale.current.measurementSystem == .metric
} else { } else {
return Locale.current.usesMetricSystem return Locale.current.usesMetricSystem
@@ -390,4 +403,17 @@ extension LocationChannelsSheet {
} }
} }
#endif // MARK: - Open Settings helper
private func openSystemLocationSettings() {
#if os(iOS)
if let url = URL(string: UIApplication.openSettingsURLString) {
UIApplication.shared.open(url)
}
#else
if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") {
NSWorkspace.shared.open(url)
} else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") {
NSWorkspace.shared.open(url)
}
#endif
}
+38 -6
View File
@@ -44,9 +44,24 @@ struct MeshPeerList: View {
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark) let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
let baseColor = isMe ? Color.orange : assigned let baseColor = isMe ? Color.orange : assigned
if isMe { if isMe {
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(baseColor) Image(systemName: "person.fill")
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if peer.isConnected {
// Mesh-connected peer: radio icon
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if peer.isMutualFavorite {
// Mutual favorite reachable via Nostr: globe icon (purple)
Image(systemName: "globe")
.font(.system(size: 10))
.foregroundColor(.purple)
} else { } else {
Image(systemName: "mappin.and.ellipse").font(.system(size: 10)).foregroundColor(baseColor) // Fallback icon for others (dimmed)
Image(systemName: "person")
.font(.system(size: 10))
.foregroundColor(secondaryTextColor)
} }
let displayName = isMe ? viewModel.nickname : peer.nickname let displayName = isMe ? viewModel.nickname : peer.nickname
@@ -70,10 +85,27 @@ struct MeshPeerList: View {
.help("Blocked") .help("Blocked")
} }
if let icon = item.enc.icon, !isMe { if !isMe {
Image(systemName: icon) if peer.isConnected {
.font(.system(size: 10)) if let icon = item.enc.icon {
.foregroundColor(baseColor) Image(systemName: icon)
.font(.system(size: 10))
.foregroundColor(baseColor)
}
} else {
// Offline: prefer showing verified badge from persisted fingerprints
if let fp = viewModel.getFingerprint(for: peer.id),
viewModel.verifiedFingerprints.contains(fp) {
Image(systemName: "checkmark.seal.fill")
.font(.system(size: 10))
.foregroundColor(baseColor)
} else if let icon = item.enc.icon {
// Fallback to whatever status says (likely lock if we had a past session)
Image(systemName: icon)
.font(.system(size: 10))
.foregroundColor(baseColor)
}
}
} }
Spacer() Spacer()
+347
View File
@@ -0,0 +1,347 @@
import SwiftUI
import CoreImage
import CoreImage.CIFilterBuiltins
#if os(iOS)
import UIKit
#else
import AppKit
#endif
/// Placeholder view to display the user's verification QR payload as text.
struct MyQRView: View {
let qrString: String
@Environment(\.colorScheme) var colorScheme
private var boxColor: Color { Color.gray.opacity(0.1) }
var body: some View {
VStack(spacing: 12) {
Text("scan to verify me")
.font(.system(size: 16, weight: .bold, design: .monospaced))
VStack(spacing: 10) {
QRCodeImage(data: qrString, size: 240)
.accessibilityLabel("verification qr code")
// Non-scrolling, fully visible URL (wraps across lines)
Text(qrString)
.font(.system(size: 11, design: .monospaced))
.textSelection(.enabled)
.multilineTextAlignment(.leading)
.fixedSize(horizontal: false, vertical: true)
.padding(8)
.background(boxColor)
.cornerRadius(8)
}
.padding()
.frame(maxWidth: .infinity)
.background(boxColor)
.cornerRadius(8)
}
.padding()
}
}
// Render a QR code image for a given string using CoreImage
struct QRCodeImage: View {
let data: String
let size: CGFloat
private let context = CIContext()
private let filter = CIFilter.qrCodeGenerator()
var body: some View {
Group {
if let image = generateImage() {
ImageWrapper(image: image)
.frame(width: size, height: size)
} else {
RoundedRectangle(cornerRadius: 8)
.stroke(Color.gray.opacity(0.5), lineWidth: 1)
.frame(width: size, height: size)
.overlay(
Text("qr unavailable")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.gray)
)
}
}
}
private func generateImage() -> CGImage? {
let inputData = Data(data.utf8)
filter.message = inputData
filter.correctionLevel = "M"
guard let outputImage = filter.outputImage else { return nil }
let scale = max(1, Int(size / 32))
let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale)))
return context.createCGImage(transformed, from: transformed.extent)
}
}
// Platform-specific wrapper to display CGImage in SwiftUI
struct ImageWrapper: View {
let image: CGImage
var body: some View {
#if os(iOS)
let ui = UIImage(cgImage: image)
return Image(uiImage: ui)
.interpolation(.none)
.resizable()
#else
let ns = NSImage(cgImage: image, size: .zero)
return Image(nsImage: ns)
.interpolation(.none)
.resizable()
#endif
}
}
/// Placeholder scanner UI; real camera scanning will be added later.
struct QRScanView: View {
@EnvironmentObject var viewModel: ChatViewModel
var isActive: Bool = true
@State private var input = ""
@State private var result: String = "" // not shown for iOS scanner
@State private var lastValid: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 12) {
#if os(iOS)
CameraScannerView(isActive: isActive) { code in
if let qr = VerificationService.shared.verifyScannedQR(code) {
let ok = viewModel.beginQRVerification(with: qr)
if !ok { /* already pending; continue scanning */ }
lastValid = code
} else {
// ignore invalid reads; continue scanning
}
}
.frame(height: 260)
.clipShape(RoundedRectangle(cornerRadius: 8))
#else
Text("paste qr content to validate:")
.font(.system(size: 14, weight: .medium, design: .monospaced))
TextEditor(text: $input)
.frame(height: 100)
.border(Color.gray.opacity(0.4))
Button("validate") {
if let qr = VerificationService.shared.verifyScannedQR(input) {
let ok = viewModel.beginQRVerification(with: qr)
result = ok ? "verification requested for \(qr.nickname)" : "could not find matching peer"
} else {
result = "invalid or expired qr payload"
}
}
.buttonStyle(.bordered)
#endif
// No status text under camera per design
Spacer()
}
.padding()
}
}
#if os(iOS)
import AVFoundation
struct CameraScannerView: UIViewRepresentable {
typealias UIViewType = PreviewView
var isActive: Bool
var onCode: (String) -> Void
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
context.coordinator.setup(sessionOwner: view, onCode: onCode)
context.coordinator.setActive(isActive)
return view
}
func updateUIView(_ uiView: PreviewView, context: Context) {
context.coordinator.setActive(isActive)
}
func makeCoordinator() -> Coordinator { Coordinator() }
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
private var onCode: ((String) -> Void)?
private weak var owner: PreviewView?
private let session = AVCaptureSession()
private var isRunning = false
private var permissionGranted = false
private var desiredActive = false
func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
self.owner = sessionOwner
self.onCode = onCode
session.beginConfiguration()
session.sessionPreset = .high
guard let device = AVCaptureDevice.default(for: .video),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else { return }
session.addInput(input)
let output = AVCaptureMetadataOutput()
guard session.canAddOutput(output) else { return }
session.addOutput(output)
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
if output.availableMetadataObjectTypes.contains(.qr) {
output.metadataObjectTypes = [.qr]
}
session.commitConfiguration()
sessionOwner.videoPreviewLayer.session = session
// Request permission and start
AVCaptureDevice.requestAccess(for: .video) { granted in
self.permissionGranted = granted
if granted && self.desiredActive && !self.isRunning {
self.setActive(true)
}
}
}
func setActive(_ active: Bool) {
desiredActive = active
guard permissionGranted else { return }
if active && !isRunning {
isRunning = true
DispatchQueue.global(qos: .userInitiated).async {
if !self.session.isRunning { self.session.startRunning() }
}
} else if !active && isRunning {
isRunning = false
DispatchQueue.global(qos: .userInitiated).async {
if self.session.isRunning { self.session.stopRunning() }
}
}
}
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
for obj in metadataObjects {
guard let m = obj as? AVMetadataMachineReadableCodeObject,
m.type == .qr,
let str = m.stringValue else { continue }
onCode?(str)
}
}
}
final class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
override init(frame: CGRect) {
super.init(frame: frame)
videoPreviewLayer.videoGravity = .resizeAspectFill
}
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
}
}
#endif
// Combined sheet: shows my QR by default with a button to scan instead
struct VerificationSheetView: View {
@EnvironmentObject var viewModel: ChatViewModel
@Binding var isPresented: Bool
@State private var showingScanner = false
@Environment(\.colorScheme) var colorScheme
private var backgroundColor: Color { colorScheme == .dark ? Color.black : Color.white }
private var accentColor: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) }
private var boxColor: Color { Color.gray.opacity(0.1) }
private func myQRString() -> String {
let npub = try? NostrIdentityBridge.getCurrentNostrIdentity()?.npub
return VerificationService.shared.buildMyQRString(nickname: viewModel.nickname, npub: npub) ?? ""
}
var body: some View {
VStack(spacing: 0) {
// Top header (always at top)
HStack {
Text("VERIFY")
.font(.system(size: 14, weight: .bold, design: .monospaced))
.foregroundColor(accentColor)
Spacer()
Button(action: {
showingScanner = false
isPresented = false
}) {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(accentColor)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.top, 12)
.padding(.bottom, 8)
Divider()
// Content area
Group {
if showingScanner {
VStack(alignment: .leading, spacing: 12) {
Text("scan a friend's qr")
.font(.system(size: 16, weight: .bold, design: .monospaced))
.frame(maxWidth: .infinity)
.multilineTextAlignment(.center)
.foregroundColor(accentColor)
#if os(iOS)
QRScanView(isActive: showingScanner)
.environmentObject(viewModel)
.frame(height: 280)
.clipShape(RoundedRectangle(cornerRadius: 10))
#else
QRScanView()
.environmentObject(viewModel)
#endif
}
.padding()
.frame(maxWidth: .infinity)
.background(boxColor)
.cornerRadius(8)
} else {
let qr = myQRString()
MyQRView(qrString: qr)
}
}
.padding(16)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
// Centered controls moved up
VStack(spacing: 10) {
if showingScanner {
Button(action: { showingScanner = false }) {
Label("show my qr", systemImage: "qrcode")
.font(.system(size: 13, design: .monospaced))
}
.buttonStyle(.bordered)
} else {
Button(action: { showingScanner = true }) {
Label("scan someone else's qr", systemImage: "camera.viewfinder")
.font(.system(size: 13, weight: .medium, design: .monospaced))
}
.buttonStyle(.bordered)
.tint(.gray)
}
// Optional: Remove verification for selected peer (if verified)
if let pid = viewModel.selectedPrivateChatPeer,
let fp = viewModel.getFingerprint(for: pid),
viewModel.verifiedFingerprints.contains(fp) {
Button(action: { viewModel.unverifyFingerprint(for: pid) }) {
Label("remove verification", systemImage: "minus.circle")
.font(.system(size: 12, design: .monospaced))
}
.buttonStyle(.bordered)
.tint(.gray)
}
}
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.background(backgroundColor)
#if os(iOS)
.presentationDetents([.large])
.presentationDragIndicator(.visible)
#endif
.onDisappear { showingScanner = false }
}
}
+3 -1
View File
@@ -10,7 +10,9 @@
</array> </array>
<key>com.apple.security.device.bluetooth</key> <key>com.apple.security.device.bluetooth</key>
<true/> <true/>
<key>com.apple.security.personal-information.location</key>
<true/>
<key>com.apple.security.network.client</key> <key>com.apple.security.network.client</key>
<true/> <true/>
</dict> </dict>
</plist> </plist>
+131 -131
View File
@@ -7,160 +7,160 @@
// //
import UIKit import UIKit
import Social
import UniformTypeIdentifiers import UniformTypeIdentifiers
class ShareViewController: SLComposeServiceViewController { /// Modern share extension using UIKit + UTTypes.
/// Avoids deprecated Social framework and SLComposeServiceViewController.
final class ShareViewController: UIViewController {
private let statusLabel: UILabel = {
let l = UILabel()
l.translatesAutoresizingMaskIntoConstraints = false
l.font = .systemFont(ofSize: 15, weight: .semibold)
l.textAlignment = .center
l.numberOfLines = 0
l.textColor = .label
return l
}()
override func viewDidLoad() { override func viewDidLoad() {
super.viewDidLoad() super.viewDidLoad()
// Set placeholder text view.backgroundColor = .systemBackground
placeholder = "Share to bitchat..." view.addSubview(statusLabel)
// Set character limit (optional) NSLayoutConstraint.activate([
charactersRemaining = 500 statusLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
statusLabel.centerYAnchor.constraint(equalTo: view.centerYAnchor),
statusLabel.leadingAnchor.constraint(greaterThanOrEqualTo: view.layoutMarginsGuide.leadingAnchor),
statusLabel.trailingAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.trailingAnchor)
])
processShare()
} }
override func isContentValid() -> Bool { // MARK: - Processing
// Validate that we have text content or attachments private func processShare() {
if let text = contentText, !text.isEmpty { guard let ctx = self.extensionContext,
return true let item = ctx.inputItems.first as? NSExtensionItem else {
} finishWithMessage("Nothing to share")
// Check if we have attachments
if let item = extensionContext?.inputItems.first as? NSExtensionItem,
let attachments = item.attachments,
!attachments.isEmpty {
return true
}
return false
}
override func didSelectPost() {
guard let extensionItem = extensionContext?.inputItems.first as? NSExtensionItem else {
self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
return return
} }
// Try content from attributed text first (Safari often passes URL here)
// Get the page title from the compose view or extension item if let url = detectURL(in: item.attributedContentText?.string ?? "") {
let pageTitle = self.contentText ?? extensionItem.attributedContentText?.string ?? extensionItem.attributedTitle?.string saveAndFinish(url: url, title: item.attributedTitle?.string)
return
var foundURL: URL? = nil
let group = DispatchGroup()
// IMPORTANT: Check if the NSExtensionItem itself has a URL
// Safari often provides the URL as an attributedString with a link
if let attributedText = extensionItem.attributedContentText {
let text = attributedText.string
let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
let matches = detector?.matches(in: text, options: [], range: NSRange(location: 0, length: text.utf16.count))
if let firstMatch = matches?.first, let url = firstMatch.url {
foundURL = url
}
} }
// Only check attachments if we haven't found a URL yet // Scan attachments for URL/text
if foundURL == nil { let providers = item.attachments ?? []
for (_, itemProvider) in (extensionItem.attachments ?? []).enumerated() { if providers.isEmpty {
// Fallback: use attributed title as plain text
// Try multiple URL type identifiers that Safari might use if let title = item.attributedTitle?.string, !title.isEmpty {
let urlTypes = [ saveAndFinish(text: title)
UTType.url.identifier, } else {
"public.url", finishWithMessage("No shareable content")
"public.file-url"
]
for urlType in urlTypes {
if itemProvider.hasItemConformingToTypeIdentifier(urlType) {
group.enter()
itemProvider.loadItem(forTypeIdentifier: urlType, options: nil) { (item, error) in
defer { group.leave() }
if let url = item as? URL {
foundURL = url
} else if let data = item as? Data,
let urlString = String(data: data, encoding: .utf8),
let url = URL(string: urlString) {
foundURL = url
} else if let string = item as? String,
let url = URL(string: string) {
foundURL = url
}
}
break // Found a URL type, no need to check other types
}
} }
return
// Also check for plain text that might be a URL }
if foundURL == nil && itemProvider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
group.enter() // Load URL or text asynchronously
itemProvider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { (item, error) in loadFirstURL(from: providers) { [weak self] url in
defer { group.leave() } guard let self = self else { return }
if let url = url {
if let text = item as? String { self.saveAndFinish(url: url, title: item.attributedTitle?.string)
// Check if the text is actually a URL } else {
if let url = URL(string: text), self.loadFirstPlainText(from: providers) { text in
(url.scheme == "http" || url.scheme == "https") { if let t = text, !t.isEmpty {
foundURL = url // Treat as URL if parseable http(s), else plain text
if let u = URL(string: t), ["http","https"].contains(u.scheme?.lowercased() ?? "") {
self.saveAndFinish(url: u, title: item.attributedTitle?.string)
} else {
self.saveAndFinish(text: t)
} }
} else {
self.finishWithMessage("No shareable content")
} }
} }
} }
} }
} // End of if foundURL == nil }
// Process after all checks complete private func detectURL(in text: String) -> URL? {
group.notify(queue: .main) { [weak self] in guard !text.isEmpty else { return nil }
if let url = foundURL { let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
// We have a URL! Create the JSON data let range = NSRange(location: 0, length: (text as NSString).length)
let urlData: [String: String] = [ let match = detector?.matches(in: text, options: [], range: range).first
"url": url.absoluteString, return match?.url
"title": pageTitle ?? url.host ?? "Shared Link" }
]
private func loadFirstURL(from providers: [NSItemProvider], completion: @escaping (URL?) -> Void) {
let identifiers = [UTType.url.identifier, "public.url", "public.file-url"]
if let jsonData = try? JSONSerialization.data(withJSONObject: urlData), let grp = DispatchGroup()
let jsonString = String(data: jsonData, encoding: .utf8) { var found: URL?
self?.saveToSharedDefaults(content: jsonString, type: "url")
for p in providers where found == nil {
for id in identifiers where p.hasItemConformingToTypeIdentifier(id) {
grp.enter()
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
defer { grp.leave() }
if let u = item as? URL { found = u; return }
if let s = item as? String, let u = URL(string: s) { found = u; return }
if let d = item as? Data, let s = String(data: d, encoding: .utf8), let u = URL(string: s) { found = u; return }
} }
} else if let title = pageTitle, !title.isEmpty { break
// No URL found, just share the text
self?.saveToSharedDefaults(content: title, type: "text")
} }
}
self?.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil) grp.notify(queue: .main) { completion(found) }
}
private func loadFirstPlainText(from providers: [NSItemProvider], completion: @escaping (String?) -> Void) {
let id = UTType.plainText.identifier
let grp = DispatchGroup()
var text: String?
for p in providers where p.hasItemConformingToTypeIdentifier(id) {
grp.enter()
p.loadItem(forTypeIdentifier: id, options: nil) { item, _ in
defer { grp.leave() }
if let s = item as? String { text = s }
else if let d = item as? Data, let s = String(data: d, encoding: .utf8) { text = s }
}
break
}
grp.notify(queue: .main) { completion(text) }
}
// MARK: - Save + Finish
private func saveAndFinish(url: URL, title: String?) {
let payload: [String: String] = [
"url": url.absoluteString,
"title": title ?? url.host ?? "Shared Link"
]
if let json = try? JSONSerialization.data(withJSONObject: payload),
let s = String(data: json, encoding: .utf8) {
saveToSharedDefaults(content: s, type: "url")
finishWithMessage("✓ Shared link to bitchat")
} else {
finishWithMessage("Failed to encode link")
} }
} }
override func configurationItems() -> [Any]! { private func saveAndFinish(text: String) {
// No configuration items needed saveToSharedDefaults(content: text, type: "text")
return [] finishWithMessage("✓ Shared text to bitchat")
} }
// MARK: - Helper Methods
private func saveToSharedDefaults(content: String, type: String) { private func saveToSharedDefaults(content: String, type: String) {
// Use app groups to share data between extension and main app guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else { return }
guard let userDefaults = UserDefaults(suiteName: "group.chat.bitchat") else {
return
}
userDefaults.set(content, forKey: "sharedContent") userDefaults.set(content, forKey: "sharedContent")
userDefaults.set(type, forKey: "sharedContentType") userDefaults.set(type, forKey: "sharedContentType")
userDefaults.set(Date(), forKey: "sharedContentDate") userDefaults.set(Date(), forKey: "sharedContentDate")
userDefaults.synchronize() // No need to force synchronize; the system persists changes
// Force open the main app
self.openMainApp()
} }
private func openMainApp() { private func finishWithMessage(_ msg: String) {
// Share extensions cannot directly open the containing app statusLabel.text = msg
// The app will check for shared content when it becomes active // Complete shortly after showing status
// Show success feedback to user DispatchQueue.main.asyncAfter(deadline: .now() + TransportConfig.uiShareExtensionDismissDelaySeconds) {
DispatchQueue.main.async { self.extensionContext?.completeRequest(returningItems: nil, completionHandler: nil)
self.textView.text = "✓ Shared to bitchat"
self.textView.isEditable = false
} }
} }
} }
+116 -1
View File
@@ -21,8 +21,10 @@ final class NostrProtocolTests: XCTestCase {
let sender = try NostrIdentity.generate() let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate() let recipient = try NostrIdentity.generate()
#if DEBUG
print("Sender pubkey: \(sender.publicKeyHex)") print("Sender pubkey: \(sender.publicKeyHex)")
print("Recipient pubkey: \(recipient.publicKeyHex)") print("Recipient pubkey: \(recipient.publicKeyHex)")
#endif
// Create a test message // Create a test message
let originalContent = "Hello from NIP-17 test!" let originalContent = "Hello from NIP-17 test!"
@@ -34,8 +36,10 @@ final class NostrProtocolTests: XCTestCase {
senderIdentity: sender senderIdentity: sender
) )
#if DEBUG
print("Gift wrap created with ID: \(giftWrap.id)") print("Gift wrap created with ID: \(giftWrap.id)")
print("Gift wrap pubkey: \(giftWrap.pubkey)") print("Gift wrap pubkey: \(giftWrap.pubkey)")
#endif
// Decrypt the gift wrap // Decrypt the gift wrap
let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage( let (decryptedContent, senderPubkey, timestamp) = try NostrProtocol.decryptPrivateMessage(
@@ -52,7 +56,9 @@ final class NostrProtocolTests: XCTestCase {
let timeDiff = abs(messageDate.timeIntervalSinceNow) let timeDiff = abs(messageDate.timeIntervalSinceNow)
XCTAssertLessThan(timeDiff, 60, "Message timestamp should be recent") XCTAssertLessThan(timeDiff, 60, "Message timestamp should be recent")
#if DEBUG
print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)") print("✅ Successfully decrypted message: '\(decryptedContent)' from \(senderPubkey) at \(messageDate)")
#endif
} }
func testGiftWrapUsesUniqueEphemeralKeys() throws { func testGiftWrapUsesUniqueEphemeralKeys() throws {
@@ -75,8 +81,10 @@ final class NostrProtocolTests: XCTestCase {
// Gift wrap pubkeys should be different (unique ephemeral keys) // Gift wrap pubkeys should be different (unique ephemeral keys)
XCTAssertNotEqual(message1.pubkey, message2.pubkey) XCTAssertNotEqual(message1.pubkey, message2.pubkey)
#if DEBUG
print("Message 1 gift wrap pubkey: \(message1.pubkey)") print("Message 1 gift wrap pubkey: \(message1.pubkey)")
print("Message 2 gift wrap pubkey: \(message2.pubkey)") print("Message 2 gift wrap pubkey: \(message2.pubkey)")
#endif
// Both should decrypt successfully // Both should decrypt successfully
let (content1, _, _) = try NostrProtocol.decryptPrivateMessage( let (content1, _, _) = try NostrProtocol.decryptPrivateMessage(
@@ -109,7 +117,114 @@ final class NostrProtocolTests: XCTestCase {
giftWrap: giftWrap, giftWrap: giftWrap,
recipientIdentity: wrongRecipient recipientIdentity: wrongRecipient
)) { error in )) { error in
#if DEBUG
print("Expected error when decrypting with wrong key: \(error)") print("Expected error when decrypting with wrong key: \(error)")
#endif
} }
} }
}
func testAckRoundTripNIP44V2_Delivered() throws {
// Identities
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
// Build a DELIVERED ack embedded payload (geohash-style, no recipient peer ID)
let messageID = "TEST-MSG-DELIVERED-1"
let senderPeerID = "0123456789abcdef" // 8-byte hex peer ID
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else {
XCTFail("Failed to embed delivered ack")
return
}
// Create NIP-17 gift wrap to recipient (uses NIP-44 v2 internally)
let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
// Ensure v2 format was used for ciphertext
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
// Decrypt as recipient
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: recipient
)
// Verify sender is correct
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
// Parse BitChat payload
XCTAssertTrue(content.hasPrefix("bitchat1:"))
let base64url = String(content.dropFirst("bitchat1:".count))
guard let packetData = Self.base64URLDecode(base64url),
let packet = BitchatPacket.from(packetData) else {
return XCTFail("Failed to decode bitchat packet")
}
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
guard let payload = NoisePayload.decode(packet.payload) else {
return XCTFail("Failed to decode NoisePayload")
}
switch payload.type {
case .delivered:
let mid = String(data: payload.data, encoding: .utf8)
XCTAssertEqual(mid, messageID)
default:
XCTFail("Unexpected payload type: \(payload.type)")
}
}
func testAckRoundTripNIP44V2_ReadReceipt() throws {
// Identities
let sender = try NostrIdentity.generate()
let recipient = try NostrIdentity.generate()
let messageID = "TEST-MSG-READ-1"
let senderPeerID = "fedcba9876543210" // 8-byte hex peer ID
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else {
XCTFail("Failed to embed read ack")
return
}
let giftWrap = try NostrProtocol.createPrivateMessage(
content: embedded,
recipientPubkey: recipient.publicKeyHex,
senderIdentity: sender
)
XCTAssertTrue(giftWrap.content.hasPrefix("v2:"))
let (content, senderPubkey, _) = try NostrProtocol.decryptPrivateMessage(
giftWrap: giftWrap,
recipientIdentity: recipient
)
XCTAssertEqual(senderPubkey, sender.publicKeyHex)
XCTAssertTrue(content.hasPrefix("bitchat1:"))
let base64url = String(content.dropFirst("bitchat1:".count))
guard let packetData = Self.base64URLDecode(base64url),
let packet = BitchatPacket.from(packetData) else {
return XCTFail("Failed to decode bitchat packet")
}
XCTAssertEqual(packet.type, MessageType.noiseEncrypted.rawValue)
guard let payload = NoisePayload.decode(packet.payload) else {
return XCTFail("Failed to decode NoisePayload")
}
switch payload.type {
case .readReceipt:
let mid = String(data: payload.data, encoding: .utf8)
XCTAssertEqual(mid, messageID)
default:
XCTFail("Unexpected payload type: \(payload.type)")
}
}
// MARK: - Helpers
private static func base64URLDecode(_ s: String) -> Data? {
var str = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
let rem = str.count % 4
if rem > 0 { str.append(String(repeating: "=", count: 4 - rem)) }
return Data(base64Encoded: str)
}
}
@@ -1,45 +0,0 @@
//
// LegacyTestProtocolTypes.swift
// bitchatTests
//
// Minimal legacy protocol types used only by tests to simulate old flows.
// These are not part of production code anymore.
import Foundation
struct ProtocolNack {
let originalPacketID: String
let nackID: String
let senderID: String
let receiverID: String
let packetType: UInt8
let reason: String
let errorCode: UInt8
enum ErrorCode: UInt8 {
case unknown = 0
case decryptionFailed = 2
}
init(originalPacketID: String, senderID: String, receiverID: String, packetType: UInt8, reason: String, errorCode: ErrorCode = .unknown) {
self.originalPacketID = originalPacketID
self.nackID = UUID().uuidString
self.senderID = senderID
self.receiverID = receiverID
self.packetType = packetType
self.reason = reason
self.errorCode = errorCode.rawValue
}
func toBinaryData() -> Data {
// Tests don't parse the payload; return a compact encoding for completeness
var data = Data()
data.appendUUID(originalPacketID)
data.appendUUID(nackID)
data.append(UInt8(packetType))
data.append(UInt8(errorCode))
data.appendString(reason)
return data
}
}
-13
View File
@@ -1,13 +0,0 @@
Command line invocation:
/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild -project bitchat.xcodeproj -scheme bitchat -configuration Debug -sdk iphonesimulator CODE_SIGNING_ALLOWED=NO ONLY_ACTIVE_ARCH=YES
Build settings from command line:
CODE_SIGNING_ALLOWED = NO
ONLY_ACTIVE_ARCH = YES
SDKROOT = iphonesimulator18.5
Resolve Package Graph
/Users/jack/Library/org.swift.swiftpm/configuration is not accessible or not writable, disabling user-level cache features./Users/jack/Library/org.swift.swiftpm/security is not accessible or not writable, disabling user-level cache features./Users/jack/Library/Caches/org.swift.swiftpm is not accessible or not writable, disabling user-level cache features.
Package: swift-secp256k1
fatalError
+293
View File
@@ -0,0 +1,293 @@
Relay URL,Latitude,Longitude
relay.damus.io,37.7621,-122.3971
nostr-pub.wellorder.net,45.5229,-122.9898
nostr.mom,50.4779,12.3713
nostr.slothy.win,37.7621,-122.3971
nostr.einundzwanzig.space,50.1155,8.6842
nos.lol,50.4779,12.3713
relay.nostr.band,60.1695,24.9354
no.str.cr,9.9339,-84.0849
nostr.massmux.com,50.1155,8.6842
nostr-relay.schnitzel.world,39.0437,-77.4875
relay.nostr.com.au,37.7621,-122.3971
knostr.neutrine.com,48.8534,2.3488
nostr.nodeofsven.com,47.4875,8.2965
nostr.vulpem.com,49.4542,11.0775
nostr-verif.slothy.win,37.7621,-122.3971
relay.lexingtonbitcoin.org,37.7621,-122.3971
nostr-1.nbo.angani.co,-1.2615,36.7903
relay.wellorder.net,45.5229,-122.9898
nostr.easydns.ca,43.7064,-79.3986
relay.dwadziesciajeden.pl,52.2298,21.0118
nostr.data.haus,50.4779,12.3713
nostr.einundzwanzig.space,50.1155,8.6842
nostr.mom,50.4779,12.3713
nos.lol,50.4779,12.3713
relay.nostr.band,60.1695,24.9354
nostr.massmux.com,50.1155,8.6842
nostr.vulpem.com,49.4542,11.0775
relay.damus.io,37.7621,-122.3971
nostr-pub.wellorder.net,45.5229,-122.9898
no.str.cr,9.9339,-84.0849
relay.dwadziesciajeden.pl,52.2298,21.0118
nostr.data.haus,50.4779,12.3713
relay.wellorder.net,45.5229,-122.9898
relay.nostromo.social,49.4542,11.0775
offchain.pub,34.0522,-118.2437
relay.nostr.wirednet.jp,35.9356,139.3044
relay.nostrcheck.me,37.7621,-122.3971
nostrue.com,40.8043,-74.0121
nostr-relay.schnitzel.world,39.0437,-77.4875
nproxy.kristapsk.lv,60.1695,24.9354
nostr.spaceshell.xyz,37.7621,-122.3971
nostr-dev.wellorder.net,45.5229,-122.9898
nostr-verified.wellorder.net,45.5229,-122.9898
nostr.roundrockbitcoiners.com,40.8043,-74.0121
slick.mjex.me,39.0437,-77.4875
nostr.yael.at,52.3740,4.8897
relay.primal.net,37.7621,-122.3971
nostr.oxtr.dev,50.4779,12.3713
nostr.21crypto.ch,46.5160,6.6328
nostr.liberty.fans,38.8003,-90.6265
nostr-02.dorafactory.org,1.2897,103.8501
relay.hodl.ar,-32.9468,-60.6393
nostr.middling.mydns.jp,35.8089,140.1185
nostr.namek.link,37.7621,-122.3971
nostrja-kari.heguro.com,37.7621,-122.3971
nostr.hifish.org,47.3667,8.5500
nostr.rikmeijer.nl,50.4779,12.3713
black.nostrcity.club,41.8119,-87.6873
nostr.hekster.org,37.3924,-121.9623
relay.wavlake.com,41.2619,-95.8608
nostr.sagaciousd.com,49.2497,-123.1193
nostr.fbxl.net,43.7064,-79.3986
ithurtswhenip.ee,50.7990,-1.0913
relay2.nostrchat.io,49.4542,11.0775
relay1.nostrchat.io,60.1695,24.9354
nostr-01.yakihonne.com,1.3215,103.6957
nostr.sathoarder.com,48.5839,7.7455
nostr.overmind.lol,37.7621,-122.3971
relay.verified-nostr.com,37.7621,-122.3971
purplerelay.com,50.1155,8.6842
relay.orangepill.ovh,49.0127,1.9694
nostr-relay.psfoundation.info,39.0437,-77.4875
soloco.nl,37.7621,-122.3971
relay.froth.zone,60.1695,24.9354
nostr.stakey.net,52.5250,5.7181
nostr.2b9t.xyz,34.0522,-118.2437
pyramid.fiatjaf.com,50.1155,8.6842
a.nos.lol,50.4779,12.3713
relay.magiccity.live,25.8130,-80.2320
nostr.notribe.net,40.8344,-74.1377
freelay.sovbit.host,64.1355,-21.8954
relay.credenso.cafe,43.4254,-80.5112
nostr.huszonegy.world,47.4984,19.0404
multiplexer.huszonegy.world,47.4984,19.0404
bucket.coracle.social,37.7621,-122.3971
nostr.kungfu-g.rip,33.7865,-84.4454
relay.artx.market,43.7064,-79.3986
relay.notoshi.win,13.3622,100.9835
vitor.nostr1.com,40.7143,-74.0060
nostr-02.yakihonne.com,1.3215,103.6957
nostr-03.dorafactory.org,1.2897,103.8501
n.ok0.org,-36.8485,174.7635
nostr.0x7e.xyz,47.5056,8.7241
relay.nostr.net,50.4779,12.3713
strfry.openhoofd.nl,51.5717,3.7042
relay.fountain.fm,39.0997,-94.5786
relay.usefusion.ai,38.7135,-78.1594
relay.varke.eu,52.6958,6.1944
nostr.satstralia.com,64.1355,-21.8954
relay.13room.space,37.7621,-122.3971
nostr.myshosholoza.co.za,52.3710,4.9042
nostr.carroarmato0.be,50.8517,3.6089
nostr.dbtc.link,37.7621,-122.3971
orangepiller.org,60.1695,24.9354
adre.su,59.9386,30.3141
relay.sincensura.org,37.7621,-122.3971
relay.freeplace.nl,52.3740,4.8897
bostr.bitcointxoko.com,64.1355,-21.8954
nostr.plantroon.com,50.1025,8.6299
srtrelay.c-stellar.net,37.7621,-122.3971
nostr.jfischer.org,49.4453,11.0222
nostr.novacisko.cz,52.2298,21.0118
relay.lumina.rocks,49.4453,11.0222
nostr.tavux.tech,50.9519,1.8563
relay.nostrhub.fr,50.1155,8.6842
relay.agorist.space,52.3740,4.8897
chorus.pjv.me,45.5229,-122.9898
relay.cosmicbolt.net,37.3924,-121.9623
santo.iguanatech.net,40.8344,-74.1377
relay.tagayasu.xyz,45.4112,-75.6981
relay.mostro.network,40.8344,-74.1377
relay.zone667.com,60.1695,24.9354
relay5.bitransfer.org,37.7621,-122.3971
relay.illuminodes.com,47.6062,-122.3321
relay2.angor.io,50.1155,8.6842
relay.satsdays.com,1.2897,103.8501
relay.angor.io,50.1155,8.6842
orangesync.tech,50.9333,6.9500
nostr-relay.cbrx.io,37.7621,-122.3971
relay.21e6.cz,50.0880,14.4208
nostr.chaima.info,50.1155,8.6842
relay.satlantis.io,32.8546,-79.9748
relay.digitalezukunft.cyou,45.5088,-73.5878
relay.tapestry.ninja,40.8043,-74.0121
relay.minibolt.info,37.7621,-122.3971
nostr.bilthon.dev,25.8130,-80.2320
nostr.makibisskey.work,37.7621,-122.3971
relay.mattybs.lol,37.7621,-122.3971
noxir.kpherox.dev,34.8436,135.5084
sendit.nosflare.com,37.7621,-122.3971
relay.coinos.io,37.7621,-122.3971
relay.nostraddress.com,37.7621,-122.3971
wot.nostr.party,36.1659,-86.7844
nostrelites.org,41.8500,-87.6500
relay.nostriot.com,43.7064,-79.3986
prl.plus,55.7522,37.6156
zap.watch,45.5088,-73.5878
wot.codingarena.top,50.4779,12.3713
nostr.azzamo.net,52.2284,21.0522
wot.sudocarlos.com,43.7064,-79.3986
relay.lnfi.network,45.6241,8.7851
wot.nostr.net,37.7621,-122.3971
relay.nostrdice.com,-33.8678,151.2073
wot.sebastix.social,51.3700,6.1681
wheat.happytavern.co,37.7621,-122.3971
relay.sigit.io,50.4779,12.3713
strfry.bonsai.com,37.8716,-122.2728
travis-shears-nostr-relay-v2.fly.dev,41.8119,-87.6873
satsage.xyz,37.3924,-121.9623
relay.degmods.com,50.4779,12.3713
nostr.community.ath.cx,45.5088,-73.5878
nostr.coincrowd.fund,39.0437,-77.4875
strfry.shock.network,41.8847,-88.2040
cyberspace.nostr1.com,40.7143,-74.0060
relay02.lnfi.network,39.0997,-94.5786
nostr-rs-relay.dev.fedibtc.com,39.0437,-77.4875
relay.davidebtc.me,50.1155,8.6842
wot.dtonon.com,37.7621,-122.3971
relay.goodmorningbitcoin.com,37.7621,-122.3971
articles.layer3.news,37.3394,-121.8950
bostr.syobon.net,37.7621,-122.3971
nostr.agentcampfire.com,52.3740,4.8897
nostr.thebiglake.org,32.7244,-96.6755
schnorr.me,37.7621,-122.3971
relay.wolfcoil.com,35.6090,139.7302
nostr.camalolo.com,24.1469,120.6839
nostr.tac.lol,47.4740,-122.2610
dev-relay.lnfi.network,39.0997,-94.5786
relay.bitcoinveneto.org,64.1355,-21.8954
nostr.red5d.dev,37.7621,-122.3971
relay-testnet.k8s.layer3.news,37.3394,-121.8950
promenade.fiatjaf.com,50.1155,8.6842
nostrelay.memory-art.xyz,37.7621,-122.3971
inbox.azzamo.net,52.2284,21.0522
social.proxymana.net,60.1695,24.9354
relay.netstr.io,53.3331,-6.2489
premium.primal.net,37.7621,-122.3971
nostr.lojong.info,37.7621,-122.3971
nostr-rs-relay-ishosta.phamthanh.me,37.7621,-122.3971
relay.stream.labs.h3.se,59.3294,18.0687
tollbooth.stens.dev,51.4566,7.0123
relay.chakany.systems,37.7621,-122.3971
relay.mwaters.net,50.9519,1.8563
nostr-relay.shirogaku.xyz,37.7621,-122.3971
kitchen.zap.cooking,37.7621,-122.3971
relay.arx-ccn.com,50.4779,12.3713
relay.fr13nd5.com,50.1155,8.6842
nostr.tegila.com.br,39.0437,-77.4875
relay.jeffg.fyi,43.7064,-79.3986
relay.bullishbounty.com,37.7621,-122.3971
nostr.spicyz.io,37.7621,-122.3971
relay04.lnfi.network,39.0997,-94.5786
vidono.apps.slidestr.net,48.8534,2.3488
relay03.lnfi.network,39.0997,-94.5786
communities.nos.social,40.8344,-74.1377
relay.evanverma.com,40.8344,-74.1377
nostrelay.circum.space,51.4566,7.0123
wot.brightbolt.net,47.6928,-116.7850
relayrs.notoshi.win,37.7621,-122.3971
fenrir-s.notoshi.win,37.7621,-122.3971
relay.nsnip.io,60.1695,24.9354
x.kojira.io,37.7621,-122.3971
relay.hasenpfeffr.com,39.0437,-77.4875
relay01.lnfi.network,39.0997,-94.5786
nostr.rtvslawenia.com,49.4542,11.0775
relay.g1sms.fr,43.9298,2.1480
nostr.kalf.org,52.3740,4.8897
nostr.rblb.it,37.7621,-122.3971
nostr.4rs.nl,49.4453,11.0222
relay.vrtmrz.net,37.7621,-122.3971
nostr.hoppe-relay.it.com,45.5946,-121.1787
relay-rpi.edufeed.org,49.4453,11.0222
relay.copylaradio.com,50.7990,-1.0913
relay.ru.ac.th,13.7540,100.5014
relay.bitcoinartclock.com,50.4779,12.3713
wot.downisontheup.ca,47.6062,-122.3321
nostr.coincards.com,43.7064,-79.3986
relay.etch.social,41.2619,-95.8608
relay.mess.ch,47.1345,9.0964
relay.holzeis.me,37.7621,-122.3971
relay-admin.thaliyal.com,40.8220,-74.4488
nostr.thaliyal.com,40.8220,-74.4488
strfry.felixzieger.de,50.1025,8.6299
nostr.smut.cloud,37.7621,-122.3971
r.bitcoinhold.net,37.7621,-122.3971
nostr.blankfors.se,60.1695,24.9354
portal-relay.pareto.space,49.4453,11.0222
relay.getsafebox.app,43.7064,-79.3986
relay.anzenkodo.workers.dev,37.7621,-122.3971
relay.nostrhub.tech,49.4453,11.0222
nostr.prl.plus,52.3740,4.8897
nostr-2.21crypto.ch,46.5160,6.6328
nostr.zenon.network,40.7143,-74.0060
nostr-relay.amethyst.name,35.7721,-78.6386
relayone.geektank.ai,17.1210,-61.8433
fanfares.nostr1.com,40.7143,-74.0060
wot.geektank.ai,17.1210,-61.8433
relay-dev.satlantis.io,40.8344,-74.1377
relay.siamdev.cc,13.9178,100.4240
relay.nosto.re,51.3700,6.1681
wot.soundhsa.com,39.0997,-94.5786
nostr.n7ekb.net,47.5707,-122.2221
relayone.soundhsa.com,39.0997,-94.5786
relay.puresignal.news,37.7621,-122.3971
relay.nostx.io,37.7621,-122.3971
nostr.now,35.6090,139.7302
relay.artiostr.ch,37.7621,-122.3971
relay.oldenburg.cool,50.1155,8.6842
theoutpost.life,64.1355,-21.8954
khatru.nostrver.se,51.3700,6.1681
relay.wavefunc.live,37.7915,-122.4018
nostr-relay.zimage.com,34.0522,-118.2437
relay.javi.space,43.4628,11.8807
bostr.shop,42.8865,-78.8784
relay.letsfo.com,52.2298,21.0118
alien.macneilmediagroup.com,37.7621,-122.3971
rn1.sotiras.org,37.7621,-122.3971
gnostr.com,42.6975,23.3241
relay.conduit.market,37.7621,-122.3971
relay.hivetalk.org,37.3924,-121.9623
nostr.l484.com,30.2960,-97.6396
relay.chorus.community,50.1155,8.6842
nostr-relay.moe.gift,37.7621,-122.3971
relay.nostrcal.com,37.7621,-122.3971
temp.iris.to,37.7621,-122.3971
librerelay.aaroniumii.com,37.7621,-122.3971
nostr-relay-1.trustlessenterprise.com,37.7621,-122.3971
relay.barine.co,37.7621,-122.3971
nostr.rohoss.com,48.1374,11.5755
wot.nostr.place,30.2672,-97.7431
relay.utxo.farm,34.7331,135.8183
relay.bankless.at,37.7621,-122.3971
relay.toastr.net,40.8043,-74.0121
nostr.excentered.com,52.5244,13.4105
relay.mccormick.cx,52.3740,4.8897
relay.cypherflow.ai,48.8534,2.3488
relay.laantungir.net,45.3134,-73.8725
nostr.veladan.dev,37.7621,-122.3971
nostr.tadryanom.me,37.7621,-122.3971
nostr-relay.online,37.7621,-122.3971
nostr.night7.space,50.4779,12.3713
dev-nostr.bityacht.io,25.0531,121.5264
1 Relay URL Latitude Longitude
2 relay.damus.io 37.7621 -122.3971
3 nostr-pub.wellorder.net 45.5229 -122.9898
4 nostr.mom 50.4779 12.3713
5 nostr.slothy.win 37.7621 -122.3971
6 nostr.einundzwanzig.space 50.1155 8.6842
7 nos.lol 50.4779 12.3713
8 relay.nostr.band 60.1695 24.9354
9 no.str.cr 9.9339 -84.0849
10 nostr.massmux.com 50.1155 8.6842
11 nostr-relay.schnitzel.world 39.0437 -77.4875
12 relay.nostr.com.au 37.7621 -122.3971
13 knostr.neutrine.com 48.8534 2.3488
14 nostr.nodeofsven.com 47.4875 8.2965
15 nostr.vulpem.com 49.4542 11.0775
16 nostr-verif.slothy.win 37.7621 -122.3971
17 relay.lexingtonbitcoin.org 37.7621 -122.3971
18 nostr-1.nbo.angani.co -1.2615 36.7903
19 relay.wellorder.net 45.5229 -122.9898
20 nostr.easydns.ca 43.7064 -79.3986
21 relay.dwadziesciajeden.pl 52.2298 21.0118
22 nostr.data.haus 50.4779 12.3713
23 nostr.einundzwanzig.space 50.1155 8.6842
24 nostr.mom 50.4779 12.3713
25 nos.lol 50.4779 12.3713
26 relay.nostr.band 60.1695 24.9354
27 nostr.massmux.com 50.1155 8.6842
28 nostr.vulpem.com 49.4542 11.0775
29 relay.damus.io 37.7621 -122.3971
30 nostr-pub.wellorder.net 45.5229 -122.9898
31 no.str.cr 9.9339 -84.0849
32 relay.dwadziesciajeden.pl 52.2298 21.0118
33 nostr.data.haus 50.4779 12.3713
34 relay.wellorder.net 45.5229 -122.9898
35 relay.nostromo.social 49.4542 11.0775
36 offchain.pub 34.0522 -118.2437
37 relay.nostr.wirednet.jp 35.9356 139.3044
38 relay.nostrcheck.me 37.7621 -122.3971
39 nostrue.com 40.8043 -74.0121
40 nostr-relay.schnitzel.world 39.0437 -77.4875
41 nproxy.kristapsk.lv 60.1695 24.9354
42 nostr.spaceshell.xyz 37.7621 -122.3971
43 nostr-dev.wellorder.net 45.5229 -122.9898
44 nostr-verified.wellorder.net 45.5229 -122.9898
45 nostr.roundrockbitcoiners.com 40.8043 -74.0121
46 slick.mjex.me 39.0437 -77.4875
47 nostr.yael.at 52.3740 4.8897
48 relay.primal.net 37.7621 -122.3971
49 nostr.oxtr.dev 50.4779 12.3713
50 nostr.21crypto.ch 46.5160 6.6328
51 nostr.liberty.fans 38.8003 -90.6265
52 nostr-02.dorafactory.org 1.2897 103.8501
53 relay.hodl.ar -32.9468 -60.6393
54 nostr.middling.mydns.jp 35.8089 140.1185
55 nostr.namek.link 37.7621 -122.3971
56 nostrja-kari.heguro.com 37.7621 -122.3971
57 nostr.hifish.org 47.3667 8.5500
58 nostr.rikmeijer.nl 50.4779 12.3713
59 black.nostrcity.club 41.8119 -87.6873
60 nostr.hekster.org 37.3924 -121.9623
61 relay.wavlake.com 41.2619 -95.8608
62 nostr.sagaciousd.com 49.2497 -123.1193
63 nostr.fbxl.net 43.7064 -79.3986
64 ithurtswhenip.ee 50.7990 -1.0913
65 relay2.nostrchat.io 49.4542 11.0775
66 relay1.nostrchat.io 60.1695 24.9354
67 nostr-01.yakihonne.com 1.3215 103.6957
68 nostr.sathoarder.com 48.5839 7.7455
69 nostr.overmind.lol 37.7621 -122.3971
70 relay.verified-nostr.com 37.7621 -122.3971
71 purplerelay.com 50.1155 8.6842
72 relay.orangepill.ovh 49.0127 1.9694
73 nostr-relay.psfoundation.info 39.0437 -77.4875
74 soloco.nl 37.7621 -122.3971
75 relay.froth.zone 60.1695 24.9354
76 nostr.stakey.net 52.5250 5.7181
77 nostr.2b9t.xyz 34.0522 -118.2437
78 pyramid.fiatjaf.com 50.1155 8.6842
79 a.nos.lol 50.4779 12.3713
80 relay.magiccity.live 25.8130 -80.2320
81 nostr.notribe.net 40.8344 -74.1377
82 freelay.sovbit.host 64.1355 -21.8954
83 relay.credenso.cafe 43.4254 -80.5112
84 nostr.huszonegy.world 47.4984 19.0404
85 multiplexer.huszonegy.world 47.4984 19.0404
86 bucket.coracle.social 37.7621 -122.3971
87 nostr.kungfu-g.rip 33.7865 -84.4454
88 relay.artx.market 43.7064 -79.3986
89 relay.notoshi.win 13.3622 100.9835
90 vitor.nostr1.com 40.7143 -74.0060
91 nostr-02.yakihonne.com 1.3215 103.6957
92 nostr-03.dorafactory.org 1.2897 103.8501
93 n.ok0.org -36.8485 174.7635
94 nostr.0x7e.xyz 47.5056 8.7241
95 relay.nostr.net 50.4779 12.3713
96 strfry.openhoofd.nl 51.5717 3.7042
97 relay.fountain.fm 39.0997 -94.5786
98 relay.usefusion.ai 38.7135 -78.1594
99 relay.varke.eu 52.6958 6.1944
100 nostr.satstralia.com 64.1355 -21.8954
101 relay.13room.space 37.7621 -122.3971
102 nostr.myshosholoza.co.za 52.3710 4.9042
103 nostr.carroarmato0.be 50.8517 3.6089
104 nostr.dbtc.link 37.7621 -122.3971
105 orangepiller.org 60.1695 24.9354
106 adre.su 59.9386 30.3141
107 relay.sincensura.org 37.7621 -122.3971
108 relay.freeplace.nl 52.3740 4.8897
109 bostr.bitcointxoko.com 64.1355 -21.8954
110 nostr.plantroon.com 50.1025 8.6299
111 srtrelay.c-stellar.net 37.7621 -122.3971
112 nostr.jfischer.org 49.4453 11.0222
113 nostr.novacisko.cz 52.2298 21.0118
114 relay.lumina.rocks 49.4453 11.0222
115 nostr.tavux.tech 50.9519 1.8563
116 relay.nostrhub.fr 50.1155 8.6842
117 relay.agorist.space 52.3740 4.8897
118 chorus.pjv.me 45.5229 -122.9898
119 relay.cosmicbolt.net 37.3924 -121.9623
120 santo.iguanatech.net 40.8344 -74.1377
121 relay.tagayasu.xyz 45.4112 -75.6981
122 relay.mostro.network 40.8344 -74.1377
123 relay.zone667.com 60.1695 24.9354
124 relay5.bitransfer.org 37.7621 -122.3971
125 relay.illuminodes.com 47.6062 -122.3321
126 relay2.angor.io 50.1155 8.6842
127 relay.satsdays.com 1.2897 103.8501
128 relay.angor.io 50.1155 8.6842
129 orangesync.tech 50.9333 6.9500
130 nostr-relay.cbrx.io 37.7621 -122.3971
131 relay.21e6.cz 50.0880 14.4208
132 nostr.chaima.info 50.1155 8.6842
133 relay.satlantis.io 32.8546 -79.9748
134 relay.digitalezukunft.cyou 45.5088 -73.5878
135 relay.tapestry.ninja 40.8043 -74.0121
136 relay.minibolt.info 37.7621 -122.3971
137 nostr.bilthon.dev 25.8130 -80.2320
138 nostr.makibisskey.work 37.7621 -122.3971
139 relay.mattybs.lol 37.7621 -122.3971
140 noxir.kpherox.dev 34.8436 135.5084
141 sendit.nosflare.com 37.7621 -122.3971
142 relay.coinos.io 37.7621 -122.3971
143 relay.nostraddress.com 37.7621 -122.3971
144 wot.nostr.party 36.1659 -86.7844
145 nostrelites.org 41.8500 -87.6500
146 relay.nostriot.com 43.7064 -79.3986
147 prl.plus 55.7522 37.6156
148 zap.watch 45.5088 -73.5878
149 wot.codingarena.top 50.4779 12.3713
150 nostr.azzamo.net 52.2284 21.0522
151 wot.sudocarlos.com 43.7064 -79.3986
152 relay.lnfi.network 45.6241 8.7851
153 wot.nostr.net 37.7621 -122.3971
154 relay.nostrdice.com -33.8678 151.2073
155 wot.sebastix.social 51.3700 6.1681
156 wheat.happytavern.co 37.7621 -122.3971
157 relay.sigit.io 50.4779 12.3713
158 strfry.bonsai.com 37.8716 -122.2728
159 travis-shears-nostr-relay-v2.fly.dev 41.8119 -87.6873
160 satsage.xyz 37.3924 -121.9623
161 relay.degmods.com 50.4779 12.3713
162 nostr.community.ath.cx 45.5088 -73.5878
163 nostr.coincrowd.fund 39.0437 -77.4875
164 strfry.shock.network 41.8847 -88.2040
165 cyberspace.nostr1.com 40.7143 -74.0060
166 relay02.lnfi.network 39.0997 -94.5786
167 nostr-rs-relay.dev.fedibtc.com 39.0437 -77.4875
168 relay.davidebtc.me 50.1155 8.6842
169 wot.dtonon.com 37.7621 -122.3971
170 relay.goodmorningbitcoin.com 37.7621 -122.3971
171 articles.layer3.news 37.3394 -121.8950
172 bostr.syobon.net 37.7621 -122.3971
173 nostr.agentcampfire.com 52.3740 4.8897
174 nostr.thebiglake.org 32.7244 -96.6755
175 schnorr.me 37.7621 -122.3971
176 relay.wolfcoil.com 35.6090 139.7302
177 nostr.camalolo.com 24.1469 120.6839
178 nostr.tac.lol 47.4740 -122.2610
179 dev-relay.lnfi.network 39.0997 -94.5786
180 relay.bitcoinveneto.org 64.1355 -21.8954
181 nostr.red5d.dev 37.7621 -122.3971
182 relay-testnet.k8s.layer3.news 37.3394 -121.8950
183 promenade.fiatjaf.com 50.1155 8.6842
184 nostrelay.memory-art.xyz 37.7621 -122.3971
185 inbox.azzamo.net 52.2284 21.0522
186 social.proxymana.net 60.1695 24.9354
187 relay.netstr.io 53.3331 -6.2489
188 premium.primal.net 37.7621 -122.3971
189 nostr.lojong.info 37.7621 -122.3971
190 nostr-rs-relay-ishosta.phamthanh.me 37.7621 -122.3971
191 relay.stream.labs.h3.se 59.3294 18.0687
192 tollbooth.stens.dev 51.4566 7.0123
193 relay.chakany.systems 37.7621 -122.3971
194 relay.mwaters.net 50.9519 1.8563
195 nostr-relay.shirogaku.xyz 37.7621 -122.3971
196 kitchen.zap.cooking 37.7621 -122.3971
197 relay.arx-ccn.com 50.4779 12.3713
198 relay.fr13nd5.com 50.1155 8.6842
199 nostr.tegila.com.br 39.0437 -77.4875
200 relay.jeffg.fyi 43.7064 -79.3986
201 relay.bullishbounty.com 37.7621 -122.3971
202 nostr.spicyz.io 37.7621 -122.3971
203 relay04.lnfi.network 39.0997 -94.5786
204 vidono.apps.slidestr.net 48.8534 2.3488
205 relay03.lnfi.network 39.0997 -94.5786
206 communities.nos.social 40.8344 -74.1377
207 relay.evanverma.com 40.8344 -74.1377
208 nostrelay.circum.space 51.4566 7.0123
209 wot.brightbolt.net 47.6928 -116.7850
210 relayrs.notoshi.win 37.7621 -122.3971
211 fenrir-s.notoshi.win 37.7621 -122.3971
212 relay.nsnip.io 60.1695 24.9354
213 x.kojira.io 37.7621 -122.3971
214 relay.hasenpfeffr.com 39.0437 -77.4875
215 relay01.lnfi.network 39.0997 -94.5786
216 nostr.rtvslawenia.com 49.4542 11.0775
217 relay.g1sms.fr 43.9298 2.1480
218 nostr.kalf.org 52.3740 4.8897
219 nostr.rblb.it 37.7621 -122.3971
220 nostr.4rs.nl 49.4453 11.0222
221 relay.vrtmrz.net 37.7621 -122.3971
222 nostr.hoppe-relay.it.com 45.5946 -121.1787
223 relay-rpi.edufeed.org 49.4453 11.0222
224 relay.copylaradio.com 50.7990 -1.0913
225 relay.ru.ac.th 13.7540 100.5014
226 relay.bitcoinartclock.com 50.4779 12.3713
227 wot.downisontheup.ca 47.6062 -122.3321
228 nostr.coincards.com 43.7064 -79.3986
229 relay.etch.social 41.2619 -95.8608
230 relay.mess.ch 47.1345 9.0964
231 relay.holzeis.me 37.7621 -122.3971
232 relay-admin.thaliyal.com 40.8220 -74.4488
233 nostr.thaliyal.com 40.8220 -74.4488
234 strfry.felixzieger.de 50.1025 8.6299
235 nostr.smut.cloud 37.7621 -122.3971
236 r.bitcoinhold.net 37.7621 -122.3971
237 nostr.blankfors.se 60.1695 24.9354
238 portal-relay.pareto.space 49.4453 11.0222
239 relay.getsafebox.app 43.7064 -79.3986
240 relay.anzenkodo.workers.dev 37.7621 -122.3971
241 relay.nostrhub.tech 49.4453 11.0222
242 nostr.prl.plus 52.3740 4.8897
243 nostr-2.21crypto.ch 46.5160 6.6328
244 nostr.zenon.network 40.7143 -74.0060
245 nostr-relay.amethyst.name 35.7721 -78.6386
246 relayone.geektank.ai 17.1210 -61.8433
247 fanfares.nostr1.com 40.7143 -74.0060
248 wot.geektank.ai 17.1210 -61.8433
249 relay-dev.satlantis.io 40.8344 -74.1377
250 relay.siamdev.cc 13.9178 100.4240
251 relay.nosto.re 51.3700 6.1681
252 wot.soundhsa.com 39.0997 -94.5786
253 nostr.n7ekb.net 47.5707 -122.2221
254 relayone.soundhsa.com 39.0997 -94.5786
255 relay.puresignal.news 37.7621 -122.3971
256 relay.nostx.io 37.7621 -122.3971
257 nostr.now 35.6090 139.7302
258 relay.artiostr.ch 37.7621 -122.3971
259 relay.oldenburg.cool 50.1155 8.6842
260 theoutpost.life 64.1355 -21.8954
261 khatru.nostrver.se 51.3700 6.1681
262 relay.wavefunc.live 37.7915 -122.4018
263 nostr-relay.zimage.com 34.0522 -118.2437
264 relay.javi.space 43.4628 11.8807
265 bostr.shop 42.8865 -78.8784
266 relay.letsfo.com 52.2298 21.0118
267 alien.macneilmediagroup.com 37.7621 -122.3971
268 rn1.sotiras.org 37.7621 -122.3971
269 gnostr.com 42.6975 23.3241
270 relay.conduit.market 37.7621 -122.3971
271 relay.hivetalk.org 37.3924 -121.9623
272 nostr.l484.com 30.2960 -97.6396
273 relay.chorus.community 50.1155 8.6842
274 nostr-relay.moe.gift 37.7621 -122.3971
275 relay.nostrcal.com 37.7621 -122.3971
276 temp.iris.to 37.7621 -122.3971
277 librerelay.aaroniumii.com 37.7621 -122.3971
278 nostr-relay-1.trustlessenterprise.com 37.7621 -122.3971
279 relay.barine.co 37.7621 -122.3971
280 nostr.rohoss.com 48.1374 11.5755
281 wot.nostr.place 30.2672 -97.7431
282 relay.utxo.farm 34.7331 135.8183
283 relay.bankless.at 37.7621 -122.3971
284 relay.toastr.net 40.8043 -74.0121
285 nostr.excentered.com 52.5244 13.4105
286 relay.mccormick.cx 52.3740 4.8897
287 relay.cypherflow.ai 48.8534 2.3488
288 relay.laantungir.net 45.3134 -73.8725
289 nostr.veladan.dev 37.7621 -122.3971
290 nostr.tadryanom.me 37.7621 -122.3971
291 nostr-relay.online 37.7621 -122.3971
292 nostr.night7.space 50.4779 12.3713
293 dev-nostr.bityacht.io 25.0531 121.5264