Compare commits

..
Author SHA1 Message Date
jack 0bc5cbe0b7 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
2025-08-24 23:11:20 +02:00
jack 77f0fa46c7 QR: make NoisePayloadType exhaustive in ChatViewModel switches by ignoring verifyChallenge/verifyResponse for now (placeholder) 2025-08-24 18:26:19 +02:00
jack 81fa77b761 QR: add iOS camera scanner using AVFoundation; integrate into Scan view; add NSCameraUsageDescription to Info.plist 2025-08-24 18:23:37 +02:00
jack 0c7054ce30 QR: fix SwiftUI modifiers — apply .interpolation(.none) and .resizable() to platform Image inside ImageWrapper; remove from wrapper usage 2025-08-24 18:16:42 +02:00
jack 921b9f1be6 QR: render actual QR images with CoreImage; add copy button; keep scanner placeholder for now 2025-08-24 18:13:55 +02:00
jack e86dbc8d38 QR: fix VerificationQR mutability (sigHex var) and remove duplicate Data hex helpers to resolve redeclaration; wire signed payload assembly 2025-08-24 18:09:24 +02:00
jack 652deab8a5 QR verification scaffold: add Noise verify payload types, VerificationService with QR schema/signing, placeholder MyQR/Scan views, and UI entry points in header 2025-08-24 18:06:22 +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
d2bfbbcfd4 Fix/chat perf (#502)
* perf(chat): batch public inserts, sort batch by ts; add per-sender + per-content token buckets; content-based near-dup suppression; reuse compiled regexes; single token scans per row; disable list animations during batches

* perf(chat): conditional animations via isBatchingPublic; late-arrival binary insert; flush public buffer on channel switch; prewarm formatting on flush

* perf(chat): batching, spam rate-limits, near-dup LRU, adaptive flush, faster trims, regex/detector reuse, conditional animations, late-insert, current-mode prewarm, Swift 6-safe timer/closures

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 11:11:49 +02:00
0260798712 Fix/UI (#500)
* Remove link previews: link URLs inline\n\n- Delete LinkPreviewView and all references\n- Strip preview usage from ContentView\n- Make detected URLs tappable via .link attribute\n- Add MessageTextHelpers for tokens/length checks\n- Wire helpers into iOS/macOS targets

* Peer list UX: match message colors, use map pin icon, stable ordering\n\n- Color list names and icons with same peer color as messages\n- Default icon mappin; teleported uses face.dashed (iOS)\n- Geohash: move teleported peers to bottom\n- Maintain stable order: append new peers, remove missing\n- Mesh list: replace state icons with colored mappin/person

* Fix peer list build errors: remove ambiguous Set type, avoid guard in ViewBuilder, remove duplicate bindings, and simplify person lookup

* Peer list: remove onChange expressions to avoid type-check issues; rely on ObservedObject updates

* Peer lists: simplify view structure to satisfy SwiftUI type checker (replace Group with VStack, flatten body)

* Peer lists: move ordering mutations to onAppear/onChange, keep body pure; fix result builder errors

* Fix: remove trailing modifiers outside if/else to avoid ViewBuilder type issues

* Color parity: fix seed cache to include color scheme; normalize Nostr seeds; switch to mappin.circle icon

* Peer colors: ensure exact match with message colors\n\n- Mesh list uses same seed logic as messages (getNoiseKeyForShortID fallback)\n- Color cache keyed by theme; normalize nostr seeds\n- Icons: use mappin.and.ellipse (default) and face.dashed for teleported\n- Teleport tag: accept "teleported" in addition to "teleport" and presence events

* Fix geohash color mismatch: populate nostrKeyMapping before building messages in resubscribe; also honor t,teleport tag for teleported state

* Message actions: username tap opens sheet via clickable AttributedString; message tap inserts @mention; rename to 'direct message'

* Cashu UX: suppress 'Show more' when message contains a Cashu token (chips handle rendering)

* Cashu detection: broaden regex (allow '.') + shorter min length; avoid long-text fallback when Cashu present so chips render and no full blob

* Geohash levels: rename region->province, country->region; update labels, precision, UI, manager mapping, tests; add Codable backward-compat for renamed cases

* Fix braces in LocationChannel.swift: close enum before extension to satisfy file-scope declarations

* Geohash links: make #geohash tappable and open that channel (bitchat://geohash/<gh>); handle in ContentView.onOpenURL with validation and selection

* Underline tappable #geohash mentions for clarity

* Geochat: sanitize timeline on channel switch to remove any blank-content entries (prevents blank rows)

* Xcode project: sync sources after UI changes (remove LinkPreview, add helpers, update build refs)

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-24 02:05:02 +02:00
jack 2758afa126 ux(chat): scroll to bottom on geohash open; add infinite scroll up with stable anchor; track window size per chat 2025-08-22 19:01:29 +02:00
jack 2229a28bb4 perf(chat): reduce rendering cost and improve stability for large chats\n\n- Window last 300 messages (up from 100) while keeping timeline cap at 1337+\n- Remove .textSelection from message rows to avoid expensive layout\n- Limit link previews per message to 2\n- Keep deferred autoscroll and channel-aware IDs for smooth scrolling 2025-08-22 18:56:26 +02:00
dadc896ed8 Fix/geohash blocks (#483)
* fix(geo-block): enable block/unblock for geohash users and enforce blocks\n\n- Add persisted set of blocked Nostr pubkeys in SecureIdentityStateManager\n- Check Nostr blocks for incoming messages (public + geo DMs)\n- Prevent sending geo DMs to blocked users\n- Extend /block and /unblock to resolve geohash display names to pubkeys and act accordingly\n- Improve /block list to show geohash blocks (visible names or #suffix)

* fix(geo-block): enforce blocks on geohash public and DM receive; add block/unblock actions to geohash people list

* fix: mark handlePublicMessage as @MainActor to call isMessageBlocked safely

* ui(block): show blocked indicator (nosign icon) next to blocked peers in mesh and geohash lists

* fix(geo-block): early-drop blocked pubkeys on geohash receive; filter blocked users from participants list

* fix(geo-block): purge existing geohash messages/DMs on block; block directly from chat using Nostr sender ID when available

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-22 18:44:11 +02:00
b2504a7ff5 Feat/b links (#481)
* feat(cashu): auto-link cashu tokens in chat

- Detect cashuA/cashuB tokens via regex and render as tappable links
- Style like URLs (underline; blue for others, orange for self)
- Add openURL handler for cashu: scheme to delegate to wallet on iOS
- Respect existing heavy-content gating and long-message collapsing

* feat(ln): auto-link Lightning invoices and LNURL + lightning: scheme\n\n- Detect BOLT11 (lnbc/lntb/lnbcrt...), LNURL bech32, and lightning: URIs\n- Render as tappable links with lightning: scheme; consistent styling\n- Handle lightning: in openURL alongside cashu:

* feat(links): replace raw Cashu/Lightning tokens with compact chips (🥜 pay via cashu /  pay via lightning) while keeping them tappable

* style(links): add subtle background highlight to cashu/lightning chips

* ui(links): render Lightning/Cashu as rounded chips under message with padding; remove inline chip text

* ui(links): increase chip padding and corner radius; add extra top padding for chip row

* scroll: auto-scroll to bottom when sending a new message (public + private), regardless of current scroll position

* scroll(geo): when switching geohashes, scroll to top of chat (first visible message)

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-22 17:20:26 +02:00
7f2cbd6621 chat: fix blanking and gaps when switching geohashes; channel-aware row IDs + deferred autoscroll\n\n- Add channel-aware UI IDs (mesh|id, geo:<gh>|id, dm:<peer>|id) to prevent SwiftUI reuse gaps\n- Defer scrollTo to next runloop for stability; auto-scroll on appear/switch\n- Collapse very long messages with Show more/less; skip heavy parsing for huge content\n- Simplify LazyLinkPreviewView (remove GeometryReader in list) (#480)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-22 16:38:40 +02:00
jack 60a42ffecb Merge feature/peer-colors: per-peer colors, mention action, teleport persistence + logs, geohash count consistency, scroll gating, hashtag styling, copy message context menu 2025-08-22 13:08:15 +02:00
jack 27e5e2962c Scroll: move bottom detection to View state and pass Binding to messagesView; autoscroll only when bound is true to prevent jumps when user scrolled up 2025-08-22 13:04:56 +02:00
jack 9d5105a8bf Geohash list: gate empty/render using visibleGeohashPeople() so header count and list visibility are consistent 2025-08-22 13:02:34 +02:00
jack d4b779080a Fix header string interpolation: simplify accessibilityLabel to avoid unterminated string; use '\(headerOtherPeersCount) people' 2025-08-22 12:58:08 +02:00
jack 4bfe9ac80d Geohash count: use the same pruned/sorted list for toolbar and peer list (visibleGeohashPeople) to ensure consistency 2025-08-22 12:51:40 +02:00
adffe7dfd6 Feature/peer colors (#476)
* Feature: assign stable per-peer colors (non-self) across mesh/geohash/DM; keep self orange and mentions-to-me orange; cache colors

* Fix compile warnings: remove unused primaryColor in formatMessageAsText; remove unused CryptoKit import; mark peerColor/formatMessageAsText @MainActor to call main-actor helpers

* Fix: re-import CryptoKit for SHA256 usage in ChatViewModel

* Peer lists: apply same per-peer colors as chat (self orange); suffix uses lighter variant; geohash uses Nostr pubkey, mesh uses Noise key

* Fix warning: remove unused peerNicknames in MeshPeerList

* UI: add 'mention' action in message actions to prefill input with @nick#abcd and focus input

* UX: long-press a message to prefill @mention with sender's full nick#abcd and focus input

* UX: remove long-press mention; add context menu 'Copy message' that copies only the message body (no nick/timestamp)

* Geo teleported: robust tag detection (accept 't', 'teleport', and boolean-like values); mark self on send when tagging; should fix peer list icons

* Logs: add GeoTeleport diagnostics (incoming tags, self/peer marking, counts) to debug peer list dashed icon behavior

* Teleport persistence: store per-geohash teleported state in UserDefaults; initialize on startup; OR with location-derived status; sheet writes persisted flag on select/teleport

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-22 12:41:26 +02:00
jack 16fdb7b49e Teleport persistence: store per-geohash teleported state in UserDefaults; initialize on startup; OR with location-derived status; sheet writes persisted flag on select/teleport 2025-08-22 12:34:44 +02:00
jack ff5fd3f3fe Logs: add GeoTeleport diagnostics (incoming tags, self/peer marking, counts) to debug peer list dashed icon behavior 2025-08-22 12:29:23 +02:00
jack f8a955214b Geo teleported: robust tag detection (accept 't', 'teleport', and boolean-like values); mark self on send when tagging; should fix peer list icons 2025-08-22 12:24:17 +02:00
jack d5e712e27f UX: remove long-press mention; add context menu 'Copy message' that copies only the message body (no nick/timestamp) 2025-08-22 12:18:33 +02:00
jack 244c8cdf81 UX: long-press a message to prefill @mention with sender's full nick#abcd and focus input 2025-08-22 12:13:59 +02:00
jack 0d064084aa UI: add 'mention' action in message actions to prefill input with @nick#abcd and focus input 2025-08-22 12:12:49 +02:00
jack 7b49268694 Fix warning: remove unused peerNicknames in MeshPeerList 2025-08-22 11:51:17 +02:00
jack 5650e1f2c2 Peer lists: apply same per-peer colors as chat (self orange); suffix uses lighter variant; geohash uses Nostr pubkey, mesh uses Noise key 2025-08-22 11:49:44 +02:00
jack c4b422ad3f Fix: re-import CryptoKit for SHA256 usage in ChatViewModel 2025-08-22 11:47:21 +02:00
jack e82fda1093 Fix compile warnings: remove unused primaryColor in formatMessageAsText; remove unused CryptoKit import; mark peerColor/formatMessageAsText @MainActor to call main-actor helpers 2025-08-22 11:45:49 +02:00
jack 806d451135 Feature: assign stable per-peer colors (non-self) across mesh/geohash/DM; keep self orange and mentions-to-me orange; cache colors 2025-08-22 11:43:44 +02:00
13f8b0c636 Fix/geohash work (#475)
* Scroll UX: auto-scroll only when last item visible; preserve user position when scrolled up for mesh/geohash/DM; reduce blanking after very long messages

* iOS: re-enable keyboard autocomplete and default capitalization for message input

* Styling: stop blue/underline styling for #hashtags; render as normal text color (self=orange, others=green)

* Geo UI: ensure self shows teleported (face.dashed) if either per-session tag or manager flag is true

* Geo teleported: publish UI updates by assigning @Published Set instead of in-place insert; update on tag receipt and channel switch

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-22 11:38:44 +02:00
2014andGitHub 2d0f9aff0a fixed quote and period (#471)
moved period outside quotes for consistency, as appears elsewhere.
2025-08-22 01:18:46 +02:00
83ee5abb60 Fix: stabilize per-geohash identity seed by storing in Keychain with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly and caching in memory to avoid transient regenerations (#473)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-22 01:05:44 +02:00
bc27e16899 Fix/visuals (#469)
* Geohash peers: show face.dashed for self when channel selected via teleport; face.smiling otherwise

* Geo presence: broadcast 'teleport' tag on geochat join; track teleported participants and show face.dashed for them in peer list

* Teleport tag: attach to actual geohash chat events (sendMessage, emotes, screenshots) instead of separate presence; remove presence emit

* Fix: show face.dashed for any teleported peer (not just self) in geohash list

* Geo list: show self immediately on channel switch and mark teleported state; clear teleported flags on leaving geochat

* Teleport persistence: recompute teleported based on current location vs selected geohash; add face.dashed icon to Teleport button label

* Styling: use lighter green/orange for #abcd suffix after nicknames in all chats (senders and @mentions)

* Peer lists: render #abcd suffix as lighter green/orange (self orange) in geohash and mesh lists

* Toolbar: move unread icon next to #channel badge and allow dynamic width; Peer lists: increase top spacing before first item

* Toolbar: prevent geohash channel badge from truncating; give it layout priority and fixed width

* Toolbar: make unread envelope independent from channel button (sits left of badge); fix accidental taps opening channel selector

* Toolbar: make unread envelope open most recent unread/private chat directly

* Geohash peer list: render self row fully orange (icon, base, suffix, '(you)')

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-21 11:15:18 +02:00
jack d3d9a22757 Mentions: color @mentions orange only when directed at me; otherwise use normal color (respect geohash suffix) 2025-08-21 03:26:46 +02:00
jack 8a269d4fec UI: grey #abcd suffix in geohash peer list; keep base name (and '(you)') styled normally 2025-08-21 03:02:42 +02:00
jack 33fbca67d6 UI: render self-authored messages in orange (sender and content); keep links/hashtags orange for self 2025-08-21 03:01:14 +02:00
jack c63350a4d3 UI: bold entire message text for self in mesh, DM, and geohash; adjust caching to include self flag 2025-08-21 02:51:14 +02:00
jack 222854c60a UI: rename AppInfoView toolbar button to 'close' for consistency 2025-08-21 02:45:20 +02:00
jack c624611e7d Merge fixes/location-channels into main: resolve LocationChannelsSheet conflict (retain title bolding only) 2025-08-21 02:23:03 +02:00
jack eb0debd52a Fix: route /hug and /slap to active public channel using sendPublicRaw (geohash when selected) 2025-08-21 01:59:47 +02:00
jack 916f535503 Fix: send screenshot notice to active public channel (geohash when selected), not always mesh 2025-08-21 01:52:54 +02:00
jack de39ab6687 UI: stop bolding location subtitle names; only bold the channel label when count > 0 2025-08-21 01:18:44 +02:00
jack ba1dd100ec UI: bold mesh/level titles when participant count > 0; factor meshCount() 2025-08-21 00:56:29 +02:00
jack 97b1463b30 UI: bold location name in sheet when geohash has >0 people; refactor row to render subtitle pieces 2025-08-21 00:54:14 +02:00
jack 28c87a41c1 UI: remove leading '#' from mesh title in location sheet 2025-08-21 00:51:18 +02:00
jack 68c61476d8 UI: show Bluetooth range next to #bluetooth in location sheet 2025-08-21 00:49:12 +02:00
jack 602e93d1b2 Location sheet: poll for location at regular intervals while open (replace significant-move updates) 2025-08-21 00:46:53 +02:00
jack e7706fc9cb UI: use '~' without trailing space before location name in sheet 2025-08-21 00:44:37 +02:00
jack f30677403f Project: update Xcode project (auto) 2025-08-21 00:41:28 +02:00
jack b4fcea2672 Remove "street" location channel; add coverage + names; style mesh
- Drop GeohashChannelLevel.street and update mappings/tests\n- Map geohash lengths >=8 to Block in teleport, no Street\n- Show ~distance coverage (mi/km via Locale.measurementSystem) next to each #geohash\n- Add reverse geocoding to display coarse names (country/region/city/neighborhood) per level\n- Style "#mesh" row title with toolbar blue\n- Fix iOS 16 deprecation: use Locale.measurementSystem
2025-08-21 00:34:17 +02:00
32 changed files with 3452 additions and 956 deletions
+1 -1
View File
@@ -98,7 +98,7 @@ While the Noise handshake cryptographically authenticates a peer's key, it doesn
### 4.2. Favorites and Blocking
To improve the user experience and provide control over interactions, the protocol supports:
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites." This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
* **Favorites:** Users can mark trusted or frequently contacted peers as "favorites". This is a local designation that can be used by the application to prioritize notifications or display peers more prominently.
* **Blocking:** Users can block peers. When a peer is blocked, the application will discard any incoming packets from that peer's fingerprint at the earliest possible stage, effectively silencing them without notifying the blocked peer.
---
+41 -11
View File
@@ -60,7 +60,6 @@
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 */; };
2EFCCAA297B16FA2B56747C7 /* TestConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC75901A0F0073B5BB8356E7 /* TestConstants.swift */; };
31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */; };
37DDF3D09E2BAB92A5A8A9C1 /* TestHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E346DF8E026FD34EE3DD038 /* TestHelpers.swift */; };
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */; };
38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */; };
@@ -82,7 +81,6 @@
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3448F84BF86A42A3CC4A9379 /* NotificationService.swift */; };
7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6B8F7B7D55092C2540A7996 /* ChatViewModel.swift */; };
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95DBE6A48626C5AE287245E /* NoiseProtocolTests.swift */; };
7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */; };
7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32F149C43D1915831B60FE09 /* CompressionUtil.swift */; };
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 3A69677D382F1C3D5ED03F7D /* Assets.xcassets */; };
84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 11186E29A064E8D210880E1B /* BitchatPeer.swift */; };
@@ -106,6 +104,8 @@
A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; };
A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; };
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; };
AA11BB22CC33DD44EE55FF66 /* 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 */; };
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */; };
ACE2ED172C37F01561E50B71 /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; };
@@ -126,6 +126,10 @@
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; };
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */ = {isa = PBXBuildFile; fileRef = C27328EE574221395B2B8E87 /* MockBluetoothMeshService.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 */; };
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */; };
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE7EFB209C86BBD956B749EC /* SecureLogger.swift */; };
@@ -136,6 +140,12 @@
F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; };
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; };
FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; };
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; };
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.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 */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -233,9 +243,9 @@
96D0D41CA19EE5A772AA8434 /* bitchat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = bitchat.app; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; };
9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinkPreviewView.swift; sourceTree = "<group>"; };
A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.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>"; };
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; };
C1B378C16594575FCC7F9C75 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
@@ -243,6 +253,8 @@
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>"; };
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>"; };
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>"; };
@@ -252,6 +264,9 @@
FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = "<group>"; };
FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = "<group>"; };
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = "<group>"; };
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.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>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -302,6 +317,7 @@
isa = PBXGroup;
children = (
2F82C5FC8433F4064F079D1F /* bitchat */,
E0A1B2C3D4E5F6012345678A /* relays/online_relays_gps.csv */,
A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */,
C3D98EB3E1B455E321F519F4 /* bitchatTests */,
9F37F9F2C353B58AC809E93B /* Products */,
@@ -321,8 +337,8 @@
isa = PBXGroup;
children = (
3A69677D382F1C3D5ED03F7D /* Assets.xcassets */,
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */,
527EB217EFDFAD4CF1C91F07 /* bitchat.entitlements */,
FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */,
EF625BB3AD919322C01A46B2 /* BitchatApp.swift */,
EA706D8E5097785414646A8E /* Info.plist */,
95F16C3A4A5621C74461D8D3 /* LaunchScreen.storyboard */,
@@ -421,13 +437,14 @@
A55126E93155456CAA8D6656 /* Views */ = {
isa = PBXGroup;
children = (
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
047502B32E55FED60083520F /* MeshPeerList.swift */,
0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */,
A08E03AA0C63E97C91749AEC /* ContentView.swift */,
9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */,
9AC141774F6671FCDC347DC7 /* LinkPreviewView.swift */,
AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */,
);
path = Views;
sourceTree = "<group>";
@@ -494,6 +511,7 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup;
children = (
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
047502B82E560F690083520F /* RelayController.swift */,
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
049BD3B02E51F319001A566B /* MessageRouter.swift */,
@@ -515,10 +533,12 @@
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
isa = PBXGroup;
children = (
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */,
78595178957244CBDF7E79B6 /* NostrRelayManager.swift */,
E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */,
);
path = Nostr;
sourceTree = "<group>";
@@ -683,6 +703,7 @@
buildActionMask = 2147483647;
files = (
7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */,
E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -692,6 +713,7 @@
files = (
BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */,
E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */,
E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -710,6 +732,9 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
AD11E46940D742AEAF547EB2 /* AppInfoView.swift in Sources */,
9B51E9B63A3EA59B1A7874BD /* BinaryEncodingUtils.swift in Sources */,
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */,
@@ -729,7 +754,6 @@
38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */,
7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */,
FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */,
31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */,
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */,
501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */,
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
@@ -756,6 +780,8 @@
049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -763,6 +789,9 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
ABAF130D88561F4A646F0430 /* AppInfoView.swift in Sources */,
AFB6AEFCABBE97441CB3102B /* BinaryEncodingUtils.swift in Sources */,
049BD3B22E51F319001A566B /* NostrTransport.swift in Sources */,
@@ -782,7 +811,6 @@
B909706CD38FC56C0C8EB7BF /* IdentityModels.swift in Sources */,
EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */,
8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */,
7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */,
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */,
5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */,
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
@@ -809,6 +837,8 @@
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -960,7 +990,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -991,7 +1021,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -1046,7 +1076,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
PRODUCT_NAME = bitchat;
SDKROOT = iphoneos;
@@ -1260,7 +1290,7 @@
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.3.1;
MARKETING_VERSION = 1.3.2;
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
+9
View File
@@ -21,6 +21,8 @@ struct BitchatApp: App {
init() {
UNUserNotificationCenter.current().delegate = NotificationDelegate.shared
// Warm up georelay directory and refresh if stale (once/day)
GeoRelayDirectory.shared.prefetchIfNeeded()
}
var body: some Scene {
@@ -29,6 +31,13 @@ struct BitchatApp: App {
.environmentObject(chatViewModel)
.onAppear {
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)
appDelegate.chatViewModel = chatViewModel
#elseif os(macOS)
+4 -1
View File
@@ -149,6 +149,9 @@ struct IdentityCache: Codable {
// Last interaction timestamps (privacy: optional)
var lastInteractions: [String: Date] = [:]
// Blocked Nostr pubkeys (lowercased hex) for geohash chats
var blockedNostrPubkeys: Set<String> = []
// Schema version for future migrations
var version: Int = 1
}
@@ -222,4 +225,4 @@ enum ConnectionQuality {
}
// MARK: - Migration Support
// Removed LegacyFavorite - no longer needed
// Removed LegacyFavorite - no longer needed
@@ -335,6 +335,30 @@ class SecureIdentityStateManager {
self.saveIdentityCache()
}
}
// MARK: - Geohash (Nostr) Blocking
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
queue.sync {
return cache.blockedNostrPubkeys.contains(pubkeyHexLowercased.lowercased())
}
}
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
let key = pubkeyHexLowercased.lowercased()
queue.async(flags: .barrier) {
if isBlocked {
self.cache.blockedNostrPubkeys.insert(key)
} else {
self.cache.blockedNostrPubkeys.remove(key)
}
self.saveIdentityCache()
}
}
func getBlockedNostrPubkeys() -> Set<String> {
queue.sync { cache.blockedNostrPubkeys }
}
// MARK: - Ephemeral Session Management
@@ -464,4 +488,4 @@ class SecureIdentityStateManager {
return cache.verifiedFingerprints
}
}
}
}
+2
View File
@@ -37,6 +37,8 @@
<string>bitchat uses Bluetooth to discover and connect with other bitchat users nearby.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<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>
<array>
<string>bluetooth-central</string>
+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 = 60 * 60 * 24 // 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
}
+14 -3
View File
@@ -5,13 +5,16 @@ import Security
// Keychain helper for secure storage
struct KeychainHelper {
static func save(key: String, data: Data, service: String) {
let query: [String: Any] = [
static func save(key: String, data: Data, service: String, accessible: CFString? = nil) {
var query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrService as String: service,
kSecAttrAccount as String: key,
kSecValueData as String: data
]
if let accessible = accessible {
query[kSecAttrAccessible as String] = accessible
}
SecItemDelete(query as CFDictionary)
SecItemAdd(query as CFDictionary, nil)
@@ -106,6 +109,8 @@ struct NostrIdentityBridge {
private static let keychainService = "chat.bitchat.nostr"
private static let currentIdentityKey = "nostr-current-identity"
private static let deviceSeedKey = "nostr-device-seed"
// In-memory cache to avoid transient keychain access issues
private static var deviceSeedCache: Data?
/// Get or create the current Nostr identity
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
@@ -159,14 +164,20 @@ struct NostrIdentityBridge {
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
/// Stored only on device keychain.
private static func getOrCreateDeviceSeed() -> Data {
if let cached = deviceSeedCache { return cached }
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
// Migrate to AfterFirstUnlockThisDeviceOnly for stability during lock
KeychainHelper.save(key: deviceSeedKey, data: existing, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = existing
return existing
}
var seed = Data(count: 32)
_ = seed.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService)
// Ensure availability after first unlock to prevent unintended rotation when locked
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService, accessible: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly)
deviceSeedCache = seed
return seed
}
+96 -117
View File
@@ -1,6 +1,7 @@
import Foundation
import CryptoKit
import P256K
import Security
// Note: This file depends on Data extension from BinaryEncodingUtils.swift
// Make sure BinaryEncodingUtils.swift is included in the target
@@ -12,8 +13,9 @@ struct NostrProtocol {
enum EventKind: Int {
case metadata = 0
case textNote = 1
case dm = 14 // NIP-17 DM rumor kind
case seal = 13 // NIP-17 sealed event
case giftWrap = 1059 // NIP-17 gift wrap
case giftWrap = 1059 // NIP-59 gift wrap
case ephemeralEvent = 20000
}
@@ -30,7 +32,7 @@ struct NostrProtocol {
let rumor = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
kind: .textNote,
kind: .dm, // NIP-17: DM rumor kind 14
tags: [],
content: content
)
@@ -103,12 +105,16 @@ struct NostrProtocol {
content: String,
geohash: String,
senderIdentity: NostrIdentity,
nickname: String? = nil
nickname: String? = nil,
teleported: Bool = false
) throws -> NostrEvent {
var tags = [["g", geohash]]
if let nickname = nickname, !nickname.isEmpty {
tags.append(["n", nickname])
}
if teleported {
tags.append(["t", "teleport"])
}
let event = NostrEvent(
pubkey: senderIdentity.publicKeyHex,
createdAt: Date(),
@@ -223,7 +229,7 @@ struct NostrProtocol {
return try NostrEvent(from: rumorDict)
}
// MARK: - Encryption (NIP-44 style)
// MARK: - Encryption (NIP-44 v2)
private static func encrypt(
plaintext: String,
@@ -235,33 +241,31 @@ struct NostrProtocol {
throw NostrError.invalidPublicKey
}
// Encrypting message
// Encrypting message (NIP-44 v2: XChaCha20-Poly1305, versioned)
// Derive shared secret
let sharedSecret = try deriveSharedSecret(
privateKey: senderKey,
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 nonce = AES.GCM.Nonce()
let pt = Data(plaintext.utf8)
let sealed = try XChaCha20Poly1305Compat.seal(plaintext: pt, key: key, nonce24: nonce24)
// Encrypt
let sealed = try AES.GCM.seal(
plaintext.data(using: .utf8)!,
using: SymmetricKey(data: sharedSecret),
nonce: nonce
)
// Combine nonce + ciphertext + tag
var result = Data()
result.append(nonce.withUnsafeBytes { Data($0) })
result.append(sealed.ciphertext)
result.append(sealed.tag)
return result.base64EncodedString()
// v2: base64url(nonce24 || ciphertext || tag)
var combined = Data()
combined.append(nonce24)
combined.append(sealed.ciphertext)
combined.append(sealed.tag)
return "v2:" + base64URLEncode(combined)
}
private static func decrypt(
@@ -269,86 +273,45 @@ struct NostrProtocol {
senderPubkey: String,
recipientKey: P256K.Schnorr.PrivateKey
) throws -> String {
// Decrypting message
guard let data = Data(base64Encoded: ciphertext),
// Expect NIP-44 v2 format
guard ciphertext.hasPrefix("v2:") else { throw NostrError.invalidCiphertext }
let encoded = String(ciphertext.dropFirst(3))
guard let data = base64URLDecode(encoded),
data.count > (24 + 16),
let senderPubkeyData = Data(hexString: senderPubkey) else {
SecureLogger.log("❌ Invalid ciphertext or sender pubkey format",
category: SecureLogger.session, level: .error)
throw NostrError.invalidCiphertext
}
// Ciphertext data parsed
// Extract components
let nonceData = data.prefix(12)
let ciphertextData = data.dropFirst(12).dropLast(16)
let tagData = data.suffix(16)
// Components parsed
// Derive shared secret - try with default Y coordinate first
var sharedSecret: Data
var decrypted: Data? = nil
do {
sharedSecret = try deriveSharedSecret(
privateKey: recipientKey,
publicKey: senderPubkeyData
let nonce24 = data.prefix(24)
let rest = data.dropFirst(24)
let tag = rest.suffix(16)
let ct = rest.dropLast(16)
// Try decryption with even-Y then odd-Y when sender pubkey is x-only
func attemptDecrypt(using pubKeyData: Data) throws -> Data {
let ss = try deriveSharedSecret(privateKey: recipientKey, publicKey: pubKeyData)
let key = try deriveNIP44V2Key(from: ss)
return try XChaCha20Poly1305Compat.open(
ciphertext: Data(ct),
tag: Data(tag),
key: key,
nonce24: Data(nonce24)
)
// Derived shared secret with first Y coordinate
// Try to decrypt
let sealedBox = try AES.GCM.SealedBox(
nonce: AES.GCM.Nonce(data: nonceData),
ciphertext: ciphertextData,
tag: tagData
)
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
}
}
// If 32 bytes (x-only) try both parities, otherwise single try
if senderPubkeyData.count == 32 {
let even = Data([0x02]) + senderPubkeyData
if let pt = try? attemptDecrypt(using: even) {
return String(data: pt, encoding: .utf8) ?? ""
}
} catch {
SecureLogger.log("❌ Failed to derive shared secret or decrypt: \(error)",
category: SecureLogger.session, level: .error)
throw error
let odd = Data([0x03]) + senderPubkeyData
let pt = try attemptDecrypt(using: odd)
return String(data: pt, encoding: .utf8) ?? ""
} 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(
@@ -408,17 +371,8 @@ struct NostrProtocol {
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// ECDH shared secret derived
// Derive key using HKDF for NIP-44 v2
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
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
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
return sharedSecretData
}
// Direct version that doesn't try to add prefixes
@@ -448,15 +402,8 @@ struct NostrProtocol {
// Convert SharedSecret to Data
let sharedSecretData = sharedSecret.withUnsafeBytes { Data($0) }
// Derive key using HKDF for NIP-44 v2
let derivedKey = HKDF<CryptoKit.SHA256>.deriveKey(
inputKeyMaterial: SymmetricKey(data: sharedSecretData),
salt: "nip44-v2".data(using: .utf8)!,
info: Data(),
outputByteCount: 32
)
return derivedKey.withUnsafeBytes { Data($0) }
// Return raw ECDH shared secret; HKDF is applied by deriveNIP44V2Key
return sharedSecretData
}
private static func randomizedTimestamp() -> Date {
@@ -533,7 +480,10 @@ struct NostrEvent: Codable {
// Sign with Schnorr
var messageBytes = [UInt8](eventIdHash)
var auxRand = [UInt8](repeating: 0, count: 32) // Zero auxiliary randomness for deterministic signing
var auxRand = [UInt8](repeating: 0, count: 32)
_ = auxRand.withUnsafeMutableBytes { ptr in
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
}
let schnorrSignature = try schnorrKey.signature(message: &messageBytes, auxiliaryRand: &auxRand)
let signatureHex = schnorrSignature.dataRepresentation.hexEncodedString()
@@ -577,3 +527,32 @@ enum NostrError: Error {
case signingFailed
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) }
}
}
+25 -4
View File
@@ -78,9 +78,23 @@ class NostrRelayManager: ObservableObject {
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)
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
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(
filter: NostrFilter,
id: String = UUID().uuidString,
relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void
) {
messageHandlers[id] = handler
@@ -120,8 +135,14 @@ class NostrRelayManager: ObservableObject {
// SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...",
// category: SecureLogger.session, level: .debug)
// Send subscription to all connected relays
for (relayUrl, connection) in connections {
// Target specific relays if provided; else all 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
if let error = error {
SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)",
+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)
}
}
+9 -4
View File
@@ -155,12 +155,17 @@ enum NoisePayloadType: UInt8 {
case privateMessage = 0x01 // Private chat message
case readReceipt = 0x02 // Message was read
case delivered = 0x03 // Message was delivered
// Verification (QR-based OOB binding)
case verifyChallenge = 0x10 // Verification challenge
case verifyResponse = 0x11 // Verification response
var description: String {
switch self {
case .privateMessage: return "privateMessage"
case .readReceipt: return "readReceipt"
case .delivered: return "delivered"
case .verifyChallenge: return "verifyChallenge"
case .verifyResponse: return "verifyResponse"
}
}
}
@@ -410,12 +415,12 @@ class BitchatMessage: Codable {
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
func getCachedFormattedText(isDark: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)"]
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"]
}
func setCachedFormattedText(_ text: AttributedString, isDark: Bool) {
_cachedFormattedText["\(isDark)"] = text
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
// Codable implementation
+31 -1
View File
@@ -4,6 +4,11 @@ import Foundation
/// Encodes latitude/longitude to base32 geohash with a fixed precision.
enum Geohash {
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.
/// - Parameters:
@@ -56,5 +61,30 @@ enum 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)
}
}
+46 -6
View File
@@ -5,8 +5,8 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case block
case neighborhood
case city
case region
case country
case province // previously .region
case region // previously .country
/// Geohash length used for this level.
var precision: Int {
@@ -14,9 +14,9 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case .block: return 7
case .neighborhood: return 6
case .city: return 5
case .region: return 4
case .country: return 2
}
case .province: return 4
case .region: return 2
}
}
var displayName: String {
@@ -24,8 +24,48 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
case .block: return "Block"
case .neighborhood: return "Neighborhood"
case .city: return "City"
case .province: return "Province"
case .region: return "Region"
case .country: return "Country"
}
}
}
// Backward-compatible Codable for renamed cases
extension GeohashChannelLevel {
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
if let raw = try? container.decode(String.self) {
switch raw {
case "block": self = .block
case "neighborhood": self = .neighborhood
case "city": self = .city
case "region": self = .province // old "region" maps to new .province
case "country": self = .region // old "country" maps to new .region
case "province": self = .province
default:
self = .block
}
} else if let precision = try? container.decode(Int.self) {
switch precision {
case 7: self = .block
case 6: self = .neighborhood
case 5: self = .city
case 4: self = .province
case 0...3: self = .region
default: self = .block
}
} else {
self = .block
}
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch self {
case .block: try container.encode("block")
case .neighborhood: try container.encode("neighborhood")
case .city: try container.encode("city")
case .province: try container.encode("province")
case .region: try container.encode("region")
}
}
}
+51 -1
View File
@@ -508,11 +508,51 @@ final class BLEService: NSObject {
func sendBroadcastAnnounce() {
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? {
return collectionsQueue.sync {
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
}
@@ -1383,6 +1423,16 @@ final class BLEService: NSObject {
notifyUI { [weak self] in
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:
SecureLogger.log("⚠️ Unknown noise payload type: \(payloadType)", category: SecureLogger.noise, level: .warning)
}
+76 -51
View File
@@ -136,8 +136,8 @@ class CommandProcessor {
chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID)
}
} else {
// In public chat: send to mesh and also add a local system echo so sender sees it immediately
meshService?.sendMessage(emoteContent, mentions: [])
// In public chat: send to active public channel (mesh or geohash)
chatViewModel?.sendPublicRaw(emoteContent)
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
chatViewModel?.addPublicSystemMessage(publicEcho)
}
@@ -149,58 +149,79 @@ class CommandProcessor {
let targetName = args.trimmingCharacters(in: .whitespaces)
if targetName.isEmpty {
// List blocked users
guard let blockedUsers = chatViewModel?.blockedUsers, !blockedUsers.isEmpty else {
return .success(message: "no blocked peers")
}
// List blocked users (mesh) and geohash (Nostr) blocks
let meshBlocked = chatViewModel?.blockedUsers ?? []
var blockedNicknames: [String] = []
if let peers = meshService?.getPeerNicknames() {
for (peerID, nickname) in peers {
if let fingerprint = meshService?.getFingerprint(for: peerID),
blockedUsers.contains(fingerprint) {
meshBlocked.contains(fingerprint) {
blockedNicknames.append(nickname)
}
}
}
let list = blockedNicknames.isEmpty ? "blocked peers (not currently online)"
: blockedNicknames.sorted().joined(separator: ", ")
return .success(message: "blocked peers: \(list)")
// Geohash blocked names (prefer visible display names; fallback to #suffix)
let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys())
var geoNames: [String] = []
if let vm = chatViewModel {
#if os(iOS)
let visible = vm.visibleGeohashPeople()
let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) })
#else
let visibleIndex: [String: String] = [:]
#endif
for pk in geoBlocked {
if let name = visibleIndex[pk.lowercased()] {
geoNames.append(name)
} else {
let suffix = String(pk.suffix(4))
geoNames.append("anon#\(suffix)")
}
}
}
let meshList = blockedNicknames.isEmpty ? "none" : blockedNicknames.sorted().joined(separator: ", ")
let geoList = geoNames.isEmpty ? "none" : geoNames.sorted().joined(separator: ", ")
return .success(message: "blocked peers: \(meshList) | geohash blocks: \(geoList)")
}
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) else {
return .error(message: "cannot block \(nickname): not found or unable to verify identity")
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) {
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is already blocked")
}
// Block the user (mesh/noise identity)
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
identity.isBlocked = true
identity.isFavorite = false
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
} else {
let blockedIdentity = SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: nickname,
trustLevel: .unknown,
isFavorite: false,
isBlocked: true,
notes: nil
)
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
}
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
}
// Mesh lookup failed; try geohash (Nostr) participant by display name
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) {
return .success(message: "\(nickname) is already blocked")
}
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: true)
return .success(message: "blocked \(nickname) in geohash chats")
}
if SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is already blocked")
}
// Block the user
if var identity = SecureIdentityStateManager.shared.getSocialIdentity(for: fingerprint) {
identity.isBlocked = true
identity.isFavorite = false
SecureIdentityStateManager.shared.updateSocialIdentity(identity)
} else {
let blockedIdentity = SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: nickname,
trustLevel: .unknown,
isFavorite: false,
isBlocked: true,
notes: nil
)
SecureIdentityStateManager.shared.updateSocialIdentity(blockedIdentity)
}
// The peerStateManager and SecureIdentityStateManager handle the blocking state
return .success(message: "blocked \(nickname). you will no longer receive messages from them")
return .error(message: "cannot block \(nickname): not found or unable to verify identity")
}
private func handleUnblock(_ args: String) -> CommandResult {
@@ -211,19 +232,23 @@ class CommandProcessor {
let nickname = targetName.hasPrefix("@") ? String(targetName.dropFirst()) : targetName
guard let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) else {
return .error(message: "cannot unblock \(nickname): not found")
if let peerID = chatViewModel?.getPeerIDForNickname(nickname),
let fingerprint = meshService?.getFingerprint(for: peerID) {
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is not blocked")
}
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
return .success(message: "unblocked \(nickname)")
}
if !SecureIdentityStateManager.shared.isBlocked(fingerprint: fingerprint) {
return .success(message: "\(nickname) is not blocked")
// Try geohash unblock
if let pub = chatViewModel?.nostrPubkeyForDisplayName(nickname) {
if !SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: pub) {
return .success(message: "\(nickname) is not blocked")
}
SecureIdentityStateManager.shared.setNostrBlocked(pub, isBlocked: false)
return .success(message: "unblocked \(nickname) in geohash chats")
}
SecureIdentityStateManager.shared.setBlocked(fingerprint, isBlocked: false)
// The SecureIdentityStateManager handles the unblocking state
return .success(message: "unblocked \(nickname)")
return .error(message: "cannot unblock \(nickname): not found")
}
private func handleFavorite(_ args: String, add: Bool) -> CommandResult {
+50 -6
View File
@@ -21,14 +21,20 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
private var lastLocation: CLLocation?
private var refreshTimer: Timer?
private let userDefaultsKey = "locationChannel.selected"
private let teleportedStoreKey = "locationChannel.teleportedSet"
private var isGeocoding: Bool = false
// Published state for UI bindings
@Published private(set) var permissionState: PermissionState = .notDetermined
@Published private(set) var availableChannels: [GeohashChannel] = []
@Published private(set) var selectedChannel: ChannelID = .mesh
// True when the current location channel was selected via manual teleport
@Published var teleported: Bool = false
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
// Persisted set of geohashes that were selected via teleport
private var teleportedSet: Set<String> = []
private override init() {
super.init()
cl.delegate = self
@@ -39,6 +45,15 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
selectedChannel = channel
}
// Load persisted teleported set
if let data = UserDefaults.standard.data(forKey: teleportedStoreKey),
let arr = try? JSONDecoder().decode([String].self, from: data) {
teleportedSet = Set(arr)
}
// Initialize teleported flag from persisted state if a location channel is selected
if case .location(let ch) = selectedChannel {
teleported = teleportedSet.contains(ch.geohash)
}
let status: CLAuthorizationStatus
if #available(iOS 14.0, *) {
status = cl.authorizationStatus
@@ -102,6 +117,24 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
if let data = try? JSONEncoder().encode(channel) {
UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
}
// Update teleported flag based on persisted state for immediate UI behavior
switch channel {
case .mesh:
self.teleported = false
case .location(let ch):
self.teleported = self.teleportedSet.contains(ch.geohash)
}
}
}
// Mark or unmark a geohash as teleported in persistence and update current flag if relevant
func markTeleported(for geohash: String, _ flag: Bool) {
if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) }
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
UserDefaults.standard.set(data, forKey: teleportedStoreKey)
}
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
Task { @MainActor in self.teleported = flag }
}
}
@@ -160,7 +193,18 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
result.append(GeohashChannel(level: level, geohash: gh))
}
Task { @MainActor in self.availableChannels = result }
Task { @MainActor in
self.availableChannels = result
// Recompute teleported status based on persisted state OR current location vs selected channel
switch self.selectedChannel {
case .mesh:
self.teleported = false
case .location(let ch):
let persisted = self.teleportedSet.contains(ch.geohash)
let currentGH = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: ch.level.precision)
self.teleported = persisted || (currentGH != ch.geohash)
}
}
}
private func reverseGeocodeIfNeeded(location: CLLocation) {
@@ -179,15 +223,15 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
var dict: [GeohashChannelLevel: String] = [:]
// Country
// Region (country)
if let country = pm.country, !country.isEmpty {
dict[.country] = country
dict[.region] = country
}
// Region (state/province or county)
// Province (state/province or county)
if let admin = pm.administrativeArea, !admin.isEmpty {
dict[.region] = admin
dict[.province] = admin
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
dict[.region] = subAdmin
dict[.province] = subAdmin
}
// City (locality)
if let locality = pm.locality, !locality.isEmpty {
+9
View File
@@ -46,11 +46,20 @@ protocol Transport: AnyObject {
func sendBroadcastAnnounce()
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)
var peerSnapshotPublisher: AnyPublisher<[TransportPeerSnapshot], Never> { get }
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 {
@MainActor func didUpdatePeerSnapshots(_ peers: [TransportPeerSnapshot])
}
+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 = 5 * 60) -> 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)
}
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -86,7 +86,7 @@ struct AppInfoView: View {
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("DONE") {
Button("close") {
dismiss()
}
.foregroundColor(textColor)
+480 -106
View File
@@ -25,32 +25,7 @@ struct PeerDisplayData: Identifiable {
let isMutualFavorite: Bool
}
// MARK: - Lazy Link Preview
// Lazy loading wrapper for link previews
struct LazyLinkPreviewView: View {
let url: URL
let title: String?
@State private var isVisible = false
var body: some View {
GeometryReader { geometry in
if isVisible {
LinkPreviewView(url: url, title: title)
} else {
// Placeholder while not visible
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.1))
.frame(height: 80)
.onAppear {
// Only load when view appears on screen
isVisible = true
}
}
}
.frame(height: 80)
}
}
// (Link previews removed; URLs are now clickable inline)
// MARK: - Main Content View
@@ -77,10 +52,17 @@ struct ContentView: View {
@State private var selectedMessageSender: String?
@State private var selectedMessageSenderID: String?
@FocusState private var isNicknameFieldFocused: Bool
@State private var isAtBottomPublic: Bool = true
@State private var isAtBottomPrivate: Bool = true
@State private var lastScrollTime: Date = .distantPast
@State private var scrollThrottleTimer: Timer?
@State private var autocompleteDebounceTimer: Timer?
@State private var showLocationChannelsSheet = false
@State private var showVerifySheet = false
@State private var expandedMessageIDs: Set<String> = []
// Window sizes for rendering (infinite scroll up)
@State private var windowCountPublic: Int = 300
@State private var windowCountPrivate: [String: Int] = [:]
// MARK: - Computed Properties
@@ -103,6 +85,10 @@ struct ContentView: View {
ZStack {
// Base layer - Main public chat (always visible)
mainChatView
.onAppear { viewModel.currentColorScheme = colorScheme }
.onChange(of: colorScheme) { newValue in
viewModel.currentColorScheme = newValue
}
// Private chat slide-over
if viewModel.selectedPrivateChatPeer != nil {
@@ -204,7 +190,15 @@ struct ContentView: View {
isPresented: $showMessageActions,
titleVisibility: .visible
) {
Button("private message") {
Button("mention") {
if let sender = selectedMessageSender {
// Pre-fill the input with an @mention and focus the field
messageText = "@\(sender) "
isTextFieldFocused = true
}
}
Button("direct message") {
if let peerID = selectedMessageSenderID {
#if os(iOS)
if peerID.hasPrefix("nostr:") {
@@ -237,9 +231,18 @@ struct ContentView: View {
}
Button("BLOCK", role: .destructive) {
if let sender = selectedMessageSender {
// Prefer direct geohash block when we have a Nostr sender ID
#if os(iOS)
if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"),
let full = viewModel.fullNostrHex(forSenderPeerID: peerID),
let sender = selectedMessageSender {
viewModel.blockGeohashUser(pubkeyHexLowercased: full, displayName: sender)
} else if let sender = selectedMessageSender {
viewModel.sendMessage("/block \(sender)")
}
#else
if let sender = selectedMessageSender { viewModel.sendMessage("/block \(sender)") }
#endif
}
Button("cancel", role: .cancel) {}
@@ -265,7 +268,7 @@ struct ContentView: View {
// MARK: - Message List View
private func messagesView(privatePeer: String?) -> some View {
private func messagesView(privatePeer: String?, isAtBottom: Binding<Bool>) -> some View {
ScrollViewReader { proxy in
ScrollView {
LazyVStack(alignment: .leading, spacing: 0) {
@@ -279,27 +282,52 @@ struct ContentView: View {
}
}()
// Implement windowing - show last 100 messages for performance
let windowedMessages = messages.suffix(100)
// Implement windowing with adjustable window count per chat
let currentWindowCount: Int = {
if let peer = privatePeer { return windowCountPrivate[peer] ?? 300 }
return windowCountPublic
}()
let windowedMessages = messages.suffix(currentWindowCount)
// Build stable UI IDs with a context key to avoid ID collisions when switching channels
#if os(iOS)
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
return "mesh"
}()
#endif
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
ForEach(windowedMessages, id: \.id) { message in
ForEach(items, id: \.uiID) { item in
let message = item.message
VStack(alignment: .leading, spacing: 0) {
// Check if current user is mentioned
if message.sender == "system" {
// System messages
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
// Regular messages with natural text wrapping
VStack(alignment: .leading, spacing: 0) {
// Precompute heavy token scans once per row
let cashuTokens = message.content.extractCashuTokens()
let lightningLinks = message.content.extractLightningLinks()
HStack(alignment: .top, spacing: 0) {
// Single text view for natural wrapping
let isLong = (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? 30 : nil)
.frame(maxWidth: .infinity, alignment: .leading)
// Delivery status indicator for private messages
@@ -310,55 +338,294 @@ struct ContentView: View {
}
}
// Check for plain URLs
let urls = message.content.extractURLs()
if !urls.isEmpty {
ForEach(urls.prefix(3).indices, id: \.self) { index in
let urlInfo = urls[index]
LazyLinkPreviewView(url: urlInfo.url, title: nil)
.padding(.top, 3)
.padding(.horizontal, 1)
.id("\(message.id)-\(urlInfo.url.absoluteString)")
// Expand/Collapse for very long messages
if (message.content.count > 2000 || message.content.hasVeryLongToken(threshold: 512)) && cashuTokens.isEmpty {
let isExpanded = expandedMessageIDs.contains(message.id)
Button(isExpanded ? "show less" : "show more") {
if isExpanded { expandedMessageIDs.remove(message.id) }
else { expandedMessageIDs.insert(message.id) }
}
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(Color.blue)
.padding(.top, 4)
}
// Render payment chips (Lightning / Cashu) with rounded background
if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
HStack(spacing: 8) {
ForEach(Array(lightningLinks.prefix(3)).indices, id: \.self) { i in
let link = lightningLinks[i]
PaymentChipView(
emoji: "",
label: "pay via lightning",
colorScheme: colorScheme
) {
#if os(iOS)
if let url = URL(string: link) { UIApplication.shared.open(url) }
#else
if let url = URL(string: link) { NSWorkspace.shared.open(url) }
#endif
}
}
ForEach(Array(cashuTokens.prefix(3)).indices, id: \.self) { i in
let token = cashuTokens[i]
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
let urlStr = "cashu:\(enc)"
PaymentChipView(
emoji: "🥜",
label: "pay via cashu",
colorScheme: colorScheme
) {
#if os(iOS)
if let url = URL(string: urlStr) { UIApplication.shared.open(url) }
#else
if let url = URL(string: urlStr) { NSWorkspace.shared.open(url) }
#endif
}
}
}
.padding(.top, 6)
.padding(.leading, 2)
}
}
}
}
.id(item.uiID)
.onAppear {
// Track if last item is visible to enable auto-scroll only when near bottom
if message.id == windowedMessages.last?.id {
isAtBottom.wrappedValue = true
}
// Infinite scroll up: when top row appears, increase window and preserve anchor
if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count {
let step = 200
#if os(iOS)
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = {
if let peer = privatePeer { return "dm:\(peer)" }
return "mesh"
}()
#endif
let preserveID = "\(contextKey)|\(message.id)"
if let peer = privatePeer {
let current = windowCountPrivate[peer] ?? 300
let newCount = min(messages.count, current + step)
if newCount != current {
windowCountPrivate[peer] = newCount
DispatchQueue.main.async {
proxy.scrollTo(preserveID, anchor: .top)
}
}
} else {
let current = windowCountPublic
let newCount = min(messages.count, current + step)
if newCount != current {
windowCountPublic = newCount
DispatchQueue.main.async {
proxy.scrollTo(preserveID, anchor: .top)
}
}
}
}
}
.id(message.id)
.onDisappear {
if message.id == windowedMessages.last?.id {
isAtBottom.wrappedValue = false
}
}
.contentShape(Rectangle())
.onTapGesture {
// Only show actions for messages from other users (not system or self)
if message.sender != "system" && message.sender != viewModel.nickname {
selectedMessageSender = message.sender
selectedMessageSenderID = message.senderPeerID
showMessageActions = true
// Tap on message body: insert @mention for this sender
if message.sender != "system" {
let name = message.sender
messageText = "@\(name) "
isTextFieldFocused = true
}
}
.contextMenu {
Button("Copy message") {
#if os(iOS)
UIPasteboard.general.string = message.content
#else
let pb = NSPasteboard.general
pb.clearContents()
pb.setString(message.content, forType: .string)
#endif
}
}
.padding(.horizontal, 12)
.padding(.vertical, 2)
}
}
.transaction { tx in if viewModel.isBatchingPublic { tx.disablesAnimations = true } }
.padding(.vertical, 4)
}
.background(backgroundColor)
.onOpenURL { url in
guard url.scheme == "bitchat", url.host == "user" else { return }
let id = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
let peerID = id.removingPercentEncoding ?? id
selectedMessageSenderID = peerID
selectedMessageSender = viewModel.messages.last(where: { $0.senderPeerID == peerID })?.sender
showMessageActions = true
}
.onOpenURL { url in
guard url.scheme == "bitchat", url.host == "geohash" else { return }
let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased()
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return }
#if os(iOS)
func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len {
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
default: return .block
}
}
let level = levelForLength(gh.count)
let ch = GeohashChannel(level: level, geohash: gh)
LocationChannelManager.shared.markTeleported(for: gh, true)
LocationChannelManager.shared.select(ChannelID.location(ch))
#endif
}
.onTapGesture(count: 3) {
// Triple-tap to clear current chat
viewModel.sendMessage("/clear")
}
.onAppear {
// Force scroll to bottom when opening a chat view
let targetID: String? = {
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
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)" }
return nil
}()
isAtBottom.wrappedValue = true
DispatchQueue.main.async {
if let target = targetID { proxy.scrollTo(target, anchor: .bottom) }
}
// Second pass after a brief delay to handle late layout
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
let targetID2: String? = {
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
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)" }
return nil
}()
if let t2 = targetID2 { proxy.scrollTo(t2, anchor: .bottom) }
}
}
.onChange(of: privatePeer) { _ in
// When switching to a different private chat, jump to bottom
let targetID: String? = {
if let peer = privatePeer,
let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id {
return "dm:\(peer)|\(last)"
}
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
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)" }
return nil
}()
isAtBottom.wrappedValue = true
DispatchQueue.main.async {
if let target = targetID { proxy.scrollTo(target, anchor: .bottom) }
}
}
.onChange(of: viewModel.messages.count) { _ in
if privatePeer == nil && !viewModel.messages.isEmpty {
// If the newest message is from me, always scroll to bottom
let lastMsg = viewModel.messages.last!
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
if !isFromSelf {
// Only autoscroll when user is at/near bottom
guard isAtBottom.wrappedValue else { return }
} else {
// Ensure we consider ourselves at bottom for subsequent messages
isAtBottom.wrappedValue = true
}
// Throttle scroll animations to prevent excessive UI updates
let now = Date()
if now.timeIntervalSince(lastScrollTime) > 0.5 {
// Immediate scroll if enough time has passed
lastScrollTime = now
proxy.scrollTo(viewModel.messages.suffix(100).last?.id, anchor: .bottom)
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
let count = windowCountPublic
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
}
} else {
// Schedule a delayed scroll
scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
lastScrollTime = Date()
proxy.scrollTo(viewModel.messages.suffix(100).last?.id, anchor: .bottom)
#if os(iOS)
let contextKey: String = {
switch locationManager.selectedChannel {
case .mesh: return "mesh"
case .location(let ch): return "geo:\(ch.geohash)"
}
}()
#else
let contextKey: String = "mesh"
#endif
let count = windowCountPublic
let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
}
}
}
}
@@ -367,20 +634,59 @@ struct ContentView: View {
if let peerID = privatePeer,
let messages = viewModel.privateChats[peerID],
!messages.isEmpty {
// If the newest private message is from me, always scroll
let lastMsg = messages.last!
let isFromSelf = (lastMsg.sender == viewModel.nickname) || lastMsg.sender.hasPrefix(viewModel.nickname + "#")
if !isFromSelf {
// Only autoscroll when user is at/near bottom
guard isAtBottom.wrappedValue else { return }
} else {
isAtBottom.wrappedValue = true
}
// Same throttling for private chats
let now = Date()
if now.timeIntervalSince(lastScrollTime) > 0.5 {
lastScrollTime = now
proxy.scrollTo(messages.suffix(100).last?.id, anchor: .bottom)
let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
}
} else {
scrollThrottleTimer?.invalidate()
scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in
lastScrollTime = Date()
proxy.scrollTo(messages.suffix(100).last?.id, anchor: .bottom)
let contextKey = "dm:\(peerID)"
let count = windowCountPrivate[peerID] ?? 300
let target = messages.suffix(count).last.map { "\(contextKey)|\($0.id)" }
DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
}
}
}
}
}
#if os(iOS)
.onChange(of: locationManager.selectedChannel) { newChannel in
// When switching to a new geohash channel, scroll to the bottom
guard privatePeer == nil else { return }
switch newChannel {
case .mesh:
break
case .location(let ch):
// Reset window size
windowCountPublic = 300
let contextKey = "geo:\(ch.geohash)"
let last = viewModel.messages.suffix(windowCountPublic).last?.id
let target = last.map { "\(contextKey)|\($0)" }
isAtBottom.wrappedValue = true
DispatchQueue.main.async {
if let target = target { proxy.scrollTo(target, anchor: .bottom) }
}
}
}
#endif
.onAppear {
// Also check when view appears
if let peerID = privatePeer {
@@ -397,6 +703,19 @@ struct ContentView: View {
}
}
}
.environment(\.openURL, OpenURLAction { url in
// Intercept custom cashu: links created in attributed text
if let scheme = url.scheme?.lowercased(), scheme == "cashu" || scheme == "lightning" {
#if os(iOS)
UIApplication.shared.open(url)
return .handled
#else
// On non-iOS platforms, let the system handle or ignore
return .systemAction
#endif
}
return .systemAction
})
}
// MARK: - Input View
@@ -508,10 +827,7 @@ struct ContentView: View {
.foregroundColor(textColor)
.focused($isTextFieldFocused)
.padding(.leading, 12)
.autocorrectionDisabled(true)
#if os(iOS)
.textInputAutocapitalization(.never)
#endif
// iOS keyboard autocomplete and capitalization enabled by default
.onChange(of: messageText) { newValue in
// Cancel previous debounce timer
autocompleteDebounceTimer?.invalidate()
@@ -619,6 +935,24 @@ struct ContentView: View {
.font(.system(size: 16, weight: .bold, design: .monospaced))
.foregroundColor(textColor)
Spacer()
// Show QR only on mesh channel's peer list
#if os(iOS)
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")
}
#else
Button(action: { showVerifySheet = true }) {
Image(systemName: "qrcode")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.help("Verification: show my QR or scan a friend")
#endif
}
.frame(height: 44) // Match header height
.padding(.horizontal, 12)
@@ -695,7 +1029,7 @@ struct ContentView: View {
VStack(spacing: 0) {
mainHeaderView
Divider()
messagesView(privatePeer: nil)
messagesView(privatePeer: nil, isAtBottom: $isAtBottomPublic)
Divider()
inputView
}
@@ -745,7 +1079,7 @@ struct ContentView: View {
VStack(spacing: 0) {
privateHeaderView
Divider()
messagesView(privatePeer: viewModel.selectedPrivateChatPeer)
messagesView(privatePeer: viewModel.selectedPrivateChatPeer, isAtBottom: $isAtBottomPrivate)
Divider()
inputView
}
@@ -834,52 +1168,57 @@ struct ContentView: View {
Spacer()
// People counter with unread indicator
HStack(spacing: 4) {
if viewModel.hasAnyUnreadMessages {
Image(systemName: "envelope.fill")
.font(.system(size: 12))
.foregroundColor(Color.orange)
.accessibilityLabel("Unread private messages")
// Channel badge + dynamic spacing + people counter
// Precompute header count and color outside the ViewBuilder expressions
#if os(iOS)
let cc = channelPeopleCountAndColor()
let headerCountColor: Color = cc.1
let headerOtherPeersCount: Int = {
if case .location = locationManager.selectedChannel {
return viewModel.visibleGeohashPeople().count
}
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
// People count depends on active channel
#if os(iOS)
let cc = channelPeopleCountAndColor()
let otherPeersCount = cc.0
let countColor = cc.1
#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 otherPeersCount = peerCounts.others
// Darker, more neutral blue (less purple hue)
let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
let countColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary
#endif
HStack(spacing: 10) {
// Unread icon immediately to the left of the channel badge (independent from channel button)
// Location channels button '#'
// Unread indicator
#if os(iOS)
if viewModel.hasAnyUnreadMessages {
Button(action: { viewModel.openMostRelevantPrivateChat() }) {
Image(systemName: "envelope.fill")
.font(.system(size: 12))
.foregroundColor(Color.orange)
}
.buttonStyle(.plain)
.accessibilityLabel("Open unread private chat")
}
// Location channels button '#'
Button(action: { showLocationChannelsSheet = true }) {
#if os(iOS)
let badgeText: String = {
switch locationManager.selectedChannel {
case .mesh:
return "#mesh"
case .location(let ch):
return "#\(ch.geohash)"
case .mesh: return "#mesh"
case .location(let ch): return "#\(ch.geohash)"
}
}()
let badgeColor: Color = {
switch locationManager.selectedChannel {
case .mesh:
// Darker, more neutral blue (less purple hue)
return Color(hue: 0.60, saturation: 0.85, brightness: 0.82)
case .location:
// Standard green to avoid overly bright appearance in light mode
return (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
}()
@@ -887,31 +1226,25 @@ struct ContentView: View {
.font(.system(size: 14, design: .monospaced))
.foregroundColor(badgeColor)
.lineLimit(1)
.truncationMode(.head)
.frame(minWidth: 60, maxWidth: 160, alignment: .trailing)
.fixedSize(horizontal: false, vertical: false)
.fixedSize(horizontal: true, vertical: false)
.layoutPriority(2)
.accessibilityLabel("location channels")
#else
Text("#")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.accessibilityLabel("location channels")
#endif
}
.buttonStyle(.plain)
.padding(.trailing, 6)
#endif
HStack(spacing: 4) {
// People icon with count
Image(systemName: "person.2.fill")
.font(.system(size: 11))
.accessibilityLabel("\(otherPeersCount) \(otherPeersCount == 1 ? "person" : "people")")
Text("\(otherPeersCount)")
.accessibilityLabel("\(headerOtherPeersCount) people")
Text("\(headerOtherPeersCount)")
.font(.system(size: 12, design: .monospaced))
.accessibilityHidden(true)
}
.foregroundColor(countColor)
.foregroundColor(headerCountColor)
// QR moved to the PEOPLE header in the sidebar when on mesh channel
}
.onTapGesture {
withAnimation(.easeInOut(duration: 0.2)) {
@@ -919,6 +1252,10 @@ struct ContentView: View {
sidebarDragOffset = 0
}
}
.sheet(isPresented: $showVerifySheet) {
VerificationSheetView(isPresented: $showVerifySheet)
.environmentObject(viewModel)
}
}
.frame(height: 44)
.padding(.horizontal, 12)
@@ -1095,6 +1432,44 @@ struct ContentView: View {
// MARK: - Helper Views
// Rounded payment chip button
private struct PaymentChipView: View {
let emoji: String
let label: String
let colorScheme: ColorScheme
let action: () -> Void
private var fgColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var bgColor: Color {
colorScheme == .dark ? Color.gray.opacity(0.18) : Color.gray.opacity(0.12)
}
private var border: Color { fgColor.opacity(0.25) }
var body: some View {
Button(action: action) {
HStack(spacing: 6) {
Text(emoji)
Text(label)
.font(.system(size: 12, weight: .semibold, design: .monospaced))
}
.padding(.vertical, 6)
.padding(.horizontal, 12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(bgColor)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(border, lineWidth: 1)
)
.foregroundColor(fgColor)
}
.buttonStyle(.plain)
}
}
// Helper view for rendering message content (plain, no hashtag/mention formatting)
struct MessageContentView: View {
let message: BitchatMessage
@@ -1106,7 +1481,6 @@ struct MessageContentView: View {
Text(message.content)
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMentioned ? .bold : .regular)
.textSelection(.enabled)
}
// MARK: - Helper Methods
+17 -2
View File
@@ -32,8 +32,9 @@ struct FingerprintView: View {
Spacer()
Button("DONE") {
dismiss()
Button(action: { dismiss() }) {
Image(systemName: "xmark")
.font(.system(size: 14, weight: .semibold))
}
.foregroundColor(textColor)
}
@@ -165,6 +166,20 @@ struct FingerprintView: View {
.cornerRadius(8)
}
.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)
+109 -26
View File
@@ -6,46 +6,93 @@ struct GeohashPeopleList: View {
let textColor: Color
let secondaryTextColor: Color
let onTapPerson: () -> Void
@Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = []
var body: some View {
Group {
if viewModel.geohashPeople.isEmpty {
if viewModel.visibleGeohashPeople().isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text("nobody around...")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
.padding(.top, 12)
} else {
let myHex: String? = {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased()
}
return nil
}()
let ordered = viewModel.geohashPeople.sorted { a, b in
if let me = myHex {
if a.id == me && b.id != me { return true }
if b.id == me && a.id != me { return false }
}
return a.lastSeen > b.lastSeen
}
} else {
let myHex: String? = {
if case .location(let ch) = LocationChannelManager.shared.selectedChannel,
let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
return id.publicKeyHex.lowercased()
}
ForEach(ordered) { person in
return nil
}()
let people = viewModel.visibleGeohashPeople()
let currentIDs = people.map { $0.id }
#if os(iOS)
let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() })
let isTeleportedID: (String) -> Bool = { id in
if teleportedSet.contains(id.lowercased()) { return true }
if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true }
return false
}
#else
let isTeleportedID: (String) -> Bool = { _ in false }
#endif
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
let nonTele = displayIDs.filter { !isTeleportedID($0) }
let tele = displayIDs.filter { isTeleportedID($0) }
let finalOrder: [String] = nonTele + tele
let firstID = finalOrder.first
let personByID = Dictionary(uniqueKeysWithValues: people.map { ($0.id, $0) })
VStack(alignment: .leading, spacing: 0) {
ForEach(finalOrder.filter { personByID[$0] != nil }, id: \.self) { pid in
let person = personByID[pid]!
HStack(spacing: 4) {
let convKey = "nostr_" + String(person.id.prefix(16))
if viewModel.unreadPrivateMessages.contains(convKey) {
Image(systemName: "envelope.fill").font(.system(size: 12)).foregroundColor(.orange)
} else {
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(textColor)
let isMe = (person.id == myHex)
#if os(iOS)
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 assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark)
let rowColor: Color = isMe ? .orange : assignedColor
Image(systemName: icon).font(.system(size: 12)).foregroundColor(rowColor)
let (base, suffix) = splitSuffix(from: person.displayName)
HStack(spacing: 0) {
Text(base)
.font(.system(size: 14, design: .monospaced))
.fontWeight(isMe ? .bold : .regular)
.foregroundColor(rowColor)
if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : rowColor.opacity(0.6)
Text(suffix)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(suffixColor)
}
if isMe {
Text(" (you)")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(rowColor)
}
}
if let me = myHex, person.id != me {
if viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id) {
Image(systemName: "nosign")
.font(.system(size: 10))
.foregroundColor(.red)
.help("Blocked in geochash")
}
}
Text(person.displayName + (person.id == myHex ? " (you)" : ""))
.font(.system(size: 14, design: .monospaced))
.fontWeight(person.id == myHex ? .bold : .regular)
.foregroundColor(textColor)
Spacer()
}
.padding(.horizontal)
.padding(.vertical, 4)
.padding(.top, person.id == firstID ? 10 : 0)
.contentShape(Rectangle())
.onTapGesture {
if person.id != myHex {
@@ -53,10 +100,46 @@ struct GeohashPeopleList: View {
onTapPerson()
}
}
.contextMenu {
if let me = myHex, person.id == me {
EmptyView()
} else {
let blocked = viewModel.isGeohashUserBlocked(pubkeyHexLowercased: person.id)
if blocked {
Button("Unblock") { viewModel.unblockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
} else {
Button("Block") { viewModel.blockGeohashUser(pubkeyHexLowercased: person.id, displayName: person.displayName) }
}
}
}
}
}
// Seed and update order outside result builder
.onAppear {
orderedIDs = currentIDs
}
.onChange(of: currentIDs) { ids in
var newOrder = orderedIDs
newOrder.removeAll { !ids.contains($0) }
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
if newOrder != orderedIDs { orderedIDs = newOrder }
}
}
}
}
#endif
// Helper to split a trailing #abcd suffix
#if os(iOS)
private func splitSuffix(from name: String) -> (String, String) {
guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5))
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
}) {
let base = String(name.dropLast(5))
return (base, suffix)
}
return (name, "")
}
#endif
-434
View File
@@ -1,434 +0,0 @@
//
// LinkPreviewView.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import SwiftUI
#if os(iOS)
import LinkPresentation
import UIKit
#endif
// MARK: - Link Metadata Cache
/// Cache for link metadata to prevent repeated network requests
private class LinkMetadataCache {
static let shared = LinkMetadataCache()
#if os(iOS)
private let cache = NSCache<NSURL, CachedMetadata>()
private let imageCache = NSCache<NSURL, UIImage>()
#endif
private let queue = DispatchQueue(label: "chat.bitchat.linkmetadata.cache", attributes: .concurrent)
private init() {
#if os(iOS)
cache.countLimit = 100 // Keep metadata for up to 100 URLs
imageCache.countLimit = 50 // Keep images for up to 50 URLs
imageCache.totalCostLimit = 50 * 1024 * 1024 // 50MB limit for images
#endif
}
#if os(iOS)
class CachedMetadata {
let metadata: LPLinkMetadata?
let title: String?
let host: String?
let error: Error?
let timestamp: Date
init(metadata: LPLinkMetadata? = nil, title: String? = nil, host: String? = nil, error: Error? = nil) {
self.metadata = metadata
self.title = title
self.host = host
self.error = error
self.timestamp = Date()
}
}
func getCachedMetadata(for url: URL) -> (metadata: LPLinkMetadata?, title: String?, host: String?, image: UIImage?)? {
return queue.sync {
guard let cached = cache.object(forKey: url as NSURL) else { return nil }
// Check if cache is older than 24 hours
if Date().timeIntervalSince(cached.timestamp) > 86400 {
cache.removeObject(forKey: url as NSURL)
imageCache.removeObject(forKey: url as NSURL)
return nil
}
let image = imageCache.object(forKey: url as NSURL)
return (cached.metadata, cached.title, cached.host, image)
}
}
func cacheMetadata(_ metadata: LPLinkMetadata?, title: String?, host: String?, image: UIImage?, for url: URL) {
queue.async(flags: .barrier) {
let cached = CachedMetadata(metadata: metadata, title: title, host: host)
self.cache.setObject(cached, forKey: url as NSURL)
if let image = image {
let cost = Int(image.size.width * image.size.height * 4) // Approximate memory usage
self.imageCache.setObject(image, forKey: url as NSURL, cost: cost)
}
}
}
func cacheError(_ error: Error, for url: URL) {
queue.async(flags: .barrier) {
let cached = CachedMetadata(error: error)
self.cache.setObject(cached, forKey: url as NSURL)
}
}
#endif
func clearCache() {
queue.async(flags: .barrier) {
#if os(iOS)
self.cache.removeAllObjects()
self.imageCache.removeAllObjects()
#endif
}
}
}
// MARK: - Link Preview View
struct LinkPreviewView: View {
let url: URL
let title: String?
@Environment(\.colorScheme) var colorScheme
#if os(iOS)
@State private var metadata: LPLinkMetadata?
@State private var cachedTitle: String?
@State private var cachedHost: String?
@State private var isLoading = false
#endif
private var textColor: Color {
colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
}
private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white
}
private var borderColor: Color {
textColor.opacity(0.3)
}
var body: some View {
// Always use our custom compact view for consistent appearance
compactLinkView
.onAppear {
loadFromCacheOrFetch()
}
}
#if os(iOS)
@State private var previewImage: UIImage? = nil
#endif
private var compactLinkView: some View {
Button(action: {
#if os(iOS)
UIApplication.shared.open(url)
#else
NSWorkspace.shared.open(url)
#endif
}) {
HStack(spacing: 12) {
// Preview image or icon
Group {
#if os(iOS)
if let image = previewImage {
Image(uiImage: image)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 60, height: 60)
.clipped()
.cornerRadius(8)
} else {
// Favicon or default icon
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue.opacity(0.1))
.frame(width: 60, height: 60)
.overlay(
Image(systemName: "link")
.font(.system(size: 24))
.foregroundColor(Color.blue)
)
}
#else
RoundedRectangle(cornerRadius: 8)
.fill(Color.blue.opacity(0.1))
.frame(width: 60, height: 60)
.overlay(
Image(systemName: "link")
.font(.system(size: 24))
.foregroundColor(Color.blue)
)
#endif
}
VStack(alignment: .leading, spacing: 4) {
// Title
#if os(iOS)
Text(cachedTitle ?? metadata?.title ?? title ?? url.host ?? "Link")
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.lineLimit(2)
.multilineTextAlignment(.leading)
#else
Text(title ?? url.host ?? "Link")
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.lineLimit(2)
.multilineTextAlignment(.leading)
#endif
// Host
#if os(iOS)
Text(cachedHost ?? url.host ?? url.absoluteString)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(textColor.opacity(0.6))
.lineLimit(1)
#else
Text(url.host ?? url.absoluteString)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(textColor.opacity(0.6))
.lineLimit(1)
#endif
}
Spacer()
}
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(colorScheme == .dark ? Color.gray.opacity(0.15) : Color.gray.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(borderColor, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
private var simpleLinkView: some View {
Button(action: {
#if os(iOS)
UIApplication.shared.open(url)
#else
NSWorkspace.shared.open(url)
#endif
}) {
HStack(spacing: 12) {
// Link icon
Image(systemName: "link.circle.fill")
.font(.system(size: 32))
.foregroundColor(Color.blue.opacity(0.8))
.frame(width: 40, height: 40)
VStack(alignment: .leading, spacing: 4) {
// Title
Text(title ?? url.host ?? "Link")
.font(.system(size: 14, weight: .semibold, design: .monospaced))
.foregroundColor(textColor)
.lineLimit(2)
.multilineTextAlignment(.leading)
// URL
Text(url.absoluteString)
.font(.system(size: 11, design: .monospaced))
.foregroundColor(Color.blue)
.lineLimit(1)
.truncationMode(.middle)
}
Spacer()
// Arrow indicator
Image(systemName: "chevron.right")
.font(.system(size: 14))
.foregroundColor(textColor.opacity(0.5))
}
.padding(12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(colorScheme == .dark ? Color.gray.opacity(0.15) : Color.gray.opacity(0.08))
)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(borderColor, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
private func loadFromCacheOrFetch() {
#if os(iOS)
// Check if we already have data in state
guard metadata == nil && !isLoading else {
return
}
// Check cache first
if let cached = LinkMetadataCache.shared.getCachedMetadata(for: url) {
// print("🔗 LinkPreviewView: Using CACHED metadata for: \(url.absoluteString)")
self.metadata = cached.metadata
self.cachedTitle = cached.title ?? cached.metadata?.title
self.cachedHost = cached.host ?? url.host
self.previewImage = cached.image
return
}
// Not in cache, fetch it
// print("🔗 LinkPreviewView: FETCHING metadata for: \(url.absoluteString)")
isLoading = true
let provider = LPMetadataProvider()
provider.startFetchingMetadata(for: url) { fetchedMetadata, error in
DispatchQueue.main.async {
self.isLoading = false
if let error = error {
// Check if it's an ATS error for subresources (non-critical)
let errorString = error.localizedDescription.lowercased()
let isATSError = errorString.contains("app transport security") ||
errorString.contains("secure connection")
if !isATSError {
// Only log non-ATS errors
// print("🔗 LinkPreviewView: Error fetching metadata: \(error)")
}
// Still try to show basic preview with URL info
self.cachedTitle = self.title ?? self.url.host
self.cachedHost = self.url.host
// Cache even failed attempts to avoid repeated fetches
LinkMetadataCache.shared.cacheMetadata(
nil,
title: self.cachedTitle,
host: self.cachedHost,
image: nil,
for: self.url
)
return
}
if let fetchedMetadata = fetchedMetadata {
// Use the fetched metadata, or create new with our title
if let title = self.title, !title.isEmpty {
fetchedMetadata.title = title
}
self.metadata = fetchedMetadata
self.cachedTitle = fetchedMetadata.title ?? self.title
self.cachedHost = self.url.host
// Try to extract image
if let imageProvider = fetchedMetadata.imageProvider {
imageProvider.loadObject(ofClass: UIImage.self) { image, error in
DispatchQueue.main.async {
if let image = image as? UIImage {
self.previewImage = image
// Cache everything including the image
LinkMetadataCache.shared.cacheMetadata(
fetchedMetadata,
title: self.cachedTitle,
host: self.cachedHost,
image: image,
for: self.url
)
} else {
// Cache without image
LinkMetadataCache.shared.cacheMetadata(
fetchedMetadata,
title: self.cachedTitle,
host: self.cachedHost,
image: nil,
for: self.url
)
}
}
}
} else {
// No image, cache what we have
LinkMetadataCache.shared.cacheMetadata(
fetchedMetadata,
title: self.cachedTitle,
host: self.cachedHost,
image: nil,
for: self.url
)
}
}
}
}
#endif
}
}
#if os(iOS)
// UIViewRepresentable wrapper for LPLinkView
struct LinkPreview: UIViewRepresentable {
let metadata: LPLinkMetadata
func makeUIView(context: Context) -> UIView {
let containerView = UIView()
containerView.backgroundColor = .clear
let linkView = LPLinkView(metadata: metadata)
linkView.isUserInteractionEnabled = false // We handle taps at the SwiftUI level
linkView.translatesAutoresizingMaskIntoConstraints = false
containerView.addSubview(linkView)
NSLayoutConstraint.activate([
linkView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor),
linkView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor),
linkView.topAnchor.constraint(equalTo: containerView.topAnchor),
linkView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor)
])
return containerView
}
func updateUIView(_ uiView: UIView, context: Context) {
// Update if needed
}
}
#endif
// Helper to extract URLs from text
extension String {
func extractURLs() -> [(url: URL, range: Range<String.Index>)] {
var urls: [(URL, Range<String.Index>)] = []
// Check for plain URLs
let types: NSTextCheckingResult.CheckingType = .link
if let detector = try? NSDataDetector(types: types.rawValue) {
let matches = detector.matches(in: self, range: NSRange(location: 0, length: self.utf16.count))
for match in matches {
if let range = Range(match.range, in: self),
let url = match.url {
urls.append((url, range))
}
}
}
return urls
}
}
#Preview {
VStack {
LinkPreviewView(url: URL(string: "https://example.com")!, title: "Example Website")
.padding()
}
}
+16 -5
View File
@@ -106,7 +106,9 @@ struct LocationChannelsSheet: View {
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
let subtitlePrefix = "#\(channel.geohash)\(coverage)"
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, subtitleNameBold: highlight, isSelected: isSelected(channel), titleBold: highlight) {
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) {
// Selecting a suggested nearby channel is not a teleport. Persist this.
manager.markTeleported(for: channel.geohash, false)
manager.select(ChannelID.location(channel))
isPresented = false
}
@@ -145,13 +147,22 @@ struct LocationChannelsSheet: View {
}
let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
let isValid = validateGeohash(normalized)
Button("teleport") {
Button(action: {
let gh = normalized
guard isValid else { customError = "invalid geohash"; return }
let level = levelForLength(gh.count)
let ch = GeohashChannel(level: level, geohash: gh)
// Mark this selection as a manual teleport
manager.markTeleported(for: ch.geohash, true)
manager.select(ChannelID.location(ch))
isPresented = false
}) {
HStack(spacing: 6) {
Text("teleport")
.font(.system(size: 14, design: .monospaced))
Image(systemName: "face.dashed")
.font(.system(size: 14))
}
}
.buttonStyle(.plain)
.font(.system(size: 14, design: .monospaced))
@@ -287,8 +298,8 @@ struct LocationChannelsSheet: View {
private func levelForLength(_ len: Int) -> GeohashChannelLevel {
switch len {
case 0...2: return .country
case 3...4: return .region
case 0...2: return .region
case 3...4: return .province
case 5: return .city
case 6: return .neighborhood
case 7: return .block
@@ -371,7 +382,7 @@ extension LocationChannelsSheet {
private func formattedNamePrefix(for level: GeohashChannelLevel) -> String {
switch level {
case .country:
case .region:
return ""
default:
return "~"
+104 -42
View File
@@ -7,67 +7,105 @@ struct MeshPeerList: View {
let onTapPeer: (String) -> Void
let onToggleFavorite: (String) -> Void
let onShowFingerprint: (String) -> Void
@Environment(\.colorScheme) var colorScheme
@State private var orderedIDs: [String] = []
var body: some View {
Group {
if viewModel.allPeers.isEmpty {
if viewModel.allPeers.isEmpty {
VStack(alignment: .leading, spacing: 0) {
Text("nobody around...")
.font(.system(size: 14, design: .monospaced))
.foregroundColor(secondaryTextColor)
.padding(.horizontal)
.padding(.top, 12)
} else {
let peerNicknames = viewModel.meshService.getPeerNicknames()
let myPeerID = viewModel.meshService.myPeerID
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
let isMe = peer.id == myPeerID
let hasUnread = viewModel.hasUnreadMessages(for: peer.id)
let enc = viewModel.getEncryptionStatus(for: peer.id)
return (peer, isMe, hasUnread, enc)
}
let peers = mapped.sorted { lhs, rhs in
let lFav = lhs.peer.favoriteStatus?.isFavorite ?? false
let rFav = rhs.peer.favoriteStatus?.isFavorite ?? false
if lFav != rFav { return lFav }
let lhsName = lhs.isMe ? viewModel.nickname : lhs.peer.nickname
let rhsName = rhs.isMe ? viewModel.nickname : rhs.peer.nickname
return lhsName < rhsName
}
}
} else {
let myPeerID = viewModel.meshService.myPeerID
let mapped: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = viewModel.allPeers.map { peer in
let isMe = peer.id == myPeerID
let hasUnread = viewModel.hasUnreadMessages(for: peer.id)
let enc = viewModel.getEncryptionStatus(for: peer.id)
return (peer, isMe, hasUnread, enc)
}
// Stable visual order without mutating state here
let currentIDs = mapped.map { $0.peer.id }
let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) }
let peers: [(peer: BitchatPeer, isMe: Bool, hasUnread: Bool, enc: EncryptionStatus)] = displayIDs.compactMap { id in
mapped.first(where: { $0.peer.id == id })
}
VStack(alignment: .leading, spacing: 0) {
ForEach(0..<peers.count, id: \.self) { idx in
let item = peers[idx]
let peer = item.peer
let isMe = item.isMe
let hasUnread = item.hasUnread
HStack(spacing: 4) {
let assigned = viewModel.colorForMeshPeer(id: peer.id, isDark: colorScheme == .dark)
let baseColor = isMe ? Color.orange : assigned
if isMe {
Image(systemName: "person.fill").font(.system(size: 10)).foregroundColor(textColor)
} else if hasUnread {
Image(systemName: "envelope.fill").font(.system(size: 12)).foregroundColor(.orange)
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 {
switch peer.connectionState {
case .bluetoothConnected:
Image(systemName: "dot.radiowaves.left.and.right").font(.system(size: 10)).foregroundColor(textColor)
case .nostrAvailable:
Image(systemName: "globe").font(.system(size: 10)).foregroundColor(.purple)
case .offline:
if peer.favoriteStatus?.isFavorite ?? false {
Image(systemName: "moon.fill").font(.system(size: 10)).foregroundColor(.gray)
} else {
Image(systemName: "person").font(.system(size: 10)).foregroundColor(.gray)
}
}
// Fallback icon for others (dimmed)
Image(systemName: "person")
.font(.system(size: 10))
.foregroundColor(secondaryTextColor)
}
let displayName = isMe ? viewModel.nickname : peer.nickname
Text(displayName)
.font(.system(size: 14, design: .monospaced))
.foregroundColor((peer.favoriteStatus?.isFavorite ?? false) || peerNicknames[peer.id] != nil ? textColor : secondaryTextColor)
let (base, suffix) = splitSuffix(from: displayName)
HStack(spacing: 0) {
Text(base)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(baseColor)
if !suffix.isEmpty {
let suffixColor = isMe ? Color.orange.opacity(0.6) : baseColor.opacity(0.6)
Text(suffix)
.font(.system(size: 14, design: .monospaced))
.foregroundColor(suffixColor)
}
}
if let icon = item.enc.icon, !isMe {
Image(systemName: icon)
if !isMe, viewModel.isPeerBlocked(peer.id) {
Image(systemName: "nosign")
.font(.system(size: 10))
.foregroundColor(item.enc == .noiseVerified || item.enc == .noiseSecured ? textColor : (item.enc == .noiseHandshaking ? .orange : .red))
.foregroundColor(.red)
.help("Blocked")
}
if !isMe {
if peer.isConnected {
if let icon = item.enc.icon {
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()
@@ -83,12 +121,36 @@ struct MeshPeerList: View {
}
.padding(.horizontal)
.padding(.vertical, 4)
.padding(.top, idx == 0 ? 6 : 0)
.padding(.top, idx == 0 ? 10 : 0)
.contentShape(Rectangle())
.onTapGesture { if !isMe { onTapPeer(peer.id) } }
.onTapGesture(count: 2) { if !isMe { onShowFingerprint(peer.id) } }
}
}
// Seed and update order outside result builder
.onAppear {
let currentIDs = mapped.map { $0.peer.id }
orderedIDs = currentIDs
}
.onChange(of: mapped.map { $0.peer.id }) { ids in
var newOrder = orderedIDs
newOrder.removeAll { !ids.contains($0) }
for id in ids where !newOrder.contains(id) { newOrder.append(id) }
if newOrder != orderedIDs { orderedIDs = newOrder }
}
}
}
}
// Helper to split a trailing #abcd suffix
private func splitSuffix(from name: String) -> (String, String) {
guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5))
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
}) {
let base = String(name.dropLast(5))
return (base, suffix)
}
return (name, "")
}
+80
View File
@@ -0,0 +1,80 @@
//
// MessageTextHelpers.swift
// Shared text parsing helpers for message rendering.
//
import Foundation
private enum RegexCache {
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
}
extension String {
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
func hasVeryLongToken(threshold: Int) -> Bool {
var current = 0
for ch in self {
if ch.isWhitespace || ch.isNewline {
if current >= threshold { return true }
current = 0
} else {
current += 1
if current >= threshold { return true }
}
}
return current >= threshold
}
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
func extractCashuTokens(max: Int = 3) -> [String] {
let regex = RegexCache.cashu
let ns = self as NSString
let range = NSRange(location: 0, length: ns.length)
var found: [String] = []
for m in regex.matches(in: self, options: [], range: range) {
if m.numberOfRanges > 0 {
let token = ns.substring(with: m.range(at: 0))
found.append(token)
if found.count >= max { break }
}
}
return found
}
// Extract Lightning payloads (scheme, BOLT11, LNURL). Returned as lightning:<payload>
func extractLightningLinks(max: Int = 3) -> [String] {
var results: [String] = []
let ns = self as NSString
let full = NSRange(location: 0, length: ns.length)
// lightning: scheme
for m in RegexCache.lightningScheme.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append(s)
if results.count >= max { return results }
}
// BOLT11
for m in RegexCache.bolt11.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)")
if results.count >= max { return results }
}
// LNURL bech32
for m in RegexCache.lnurl.matches(in: self, options: [], range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)")
if results.count >= max { return results }
}
return results
}
}
+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 }
}
}
+2 -2
View File
@@ -9,8 +9,8 @@ final class LocationChannelsTests: XCTestCase {
let block = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.block.precision)
let neighborhood = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.neighborhood.precision)
let city = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.city.precision)
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.country.precision)
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.province.precision)
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
XCTAssertEqual(block.count, 7)
XCTAssertEqual(neighborhood.count, 6)
+106 -1
View File
@@ -112,4 +112,109 @@ final class NostrProtocolTests: XCTestCase {
print("Expected error when decrypting with wrong key: \(error)")
}
}
}
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)
}
}
+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