mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:45:18 +00:00
Feature/location channels (#459)
* Require signed announces; add Ed25519 key/signature/timestamp TLVs and verify
- Protocol: AnnouncementPacket now requires ed25519PublicKey (0x03), announceSignature (0x04), and announceTimestamp (0x05). Encoder emits, decoder requires; unknown TLVs still tolerated.
- NoiseEncryptionService: add canonical announce sign/verify helpers using context 'bitchat-announce-v1'.
- BLEService: sign Announce, include TLVs; on receive verify (±5 min skew) and ignore unverified; store ed25519 key and isVerifiedNickname in PeerInfo.
- Preserve 8-byte on-wire IDs to keep BLE headers small; no per-message bloat.
* Fix Data.append usage for timestamp bytes in Packets and NoiseEncryptionService (use append(contentsOf:) on UnsafeRawBufferPointer)
* BLEService: fix non-optional announce fields and unwrap signature result; enforce required verification path
* Enforce verified-only public messages; append peerID suffix on nickname collisions in handleMessage
* Suffix duplicate nicknames with peerID prefix in snapshots and getPeerNicknames; ensures lists and messages disambiguate 'jack' vs 'jack'
* Include self nickname in collision detection: suffix remote 'jack' when it matches our nickname in lists and public chat
* UI labels: remove space before '#abcd' suffix in names (public chat, peer lists, snapshots)
* Style '#abcd' suffix light gray:
- Public chat: color suffix in sender via AttributedString segments
- People list: split name and color suffix segment; add helper splitNameSuffix()
* Debounce 'bitchatters nearby' notification: add 60s grace before reset when mesh goes empty; cancel reset when peers return
* Lighten '#abcd' suffix: use Color.secondary.opacity(0.6) in public chat sender and People list
* Toolbar peers indicator: use blue when Bluetooth peers present (was default text color)
* Toolbar peers indicator: use grey when zero peers (was red)
* Toolbar peers indicator: use system grey (Color.secondary) when zero peers, not green
* Fix crash: mutate peripherals dict on BLE queue (not main). Move scan restart to BLE queue as well.
* Reduce connect timeout churn: back off peripherals that time out (15s), increase timeout to 8s, skip non-connectable adverts, and demote timeout log to debug; clean backoff map periodically
* Mentions: support '@name#abcd' disambiguation; filter hashtag/URL styling inside mentions; deliver notifications only to the specified suffixed target when collisions exist
* Fix string interpolation in mention log (escape sequence)
* Mentions: parse '@name#abcd' in sender/receiver; render mention suffix grey (not orange/blue/underlined); ensure receiver notifications trigger for suffixed tokens
* Mentions: include own suffixed token 'name#<myIDprefix>' in valid tokens so '@name#abcd' to self is recognized and notified
* Public actions: show own system message by processing own public messages (remove early-return). Private /hug: add local system message for sender's chat.
* Grammar: change local '/hug' message to past tense ('you hugged/slapped'). Notifications: only fire 'bitchatters nearby' on rising-edge; remove 5-min re-fire and increase empty reset grace to 10m.
* Public /hug echo: add local system message so sender sees action immediately (no reliance on echo)
* Fix visibility: expose addPublicSystemMessage on ChatViewModel and use it from CommandProcessor
* Implement iOS Location Channels (geohash public chats)
- Domain: add ChannelID, GeohashChannel(Level), lightweight geohash encoder
- Identity: derive per-geohash Nostr keys (HMAC-SHA256 device seed)
- Nostr: kind 20000 + #g tag, nickname tag (n), filter helper
- iOS: LocationChannelManager (permissions, one-shot location), Info.plist string
- UI: toolbar # badge (#mesh or #<level>), sheet (irc style, full-row taps, settings link)
- VM: subscribe/send for active channel, clear timeline on switch, nickname cache
- Emotes: route public via Nostr/mesh without echo, local system confirmation, emojis restored
- Haptics: detect hugs/slaps for nickname#abcd and trigger on receiver
- Rendering: remove hashtag/mention formatting (plain monospaced)
Note: iOS-only; macOS unaffected.
* Lifecycle and persistence: resubscribe on foreground/relay connect, cap dedup set, persist mesh timeline.
- ChatViewModel: resubscribe geohash on app foreground and relay reconnect
- Add processed Nostr events cap (2k) with eviction
- Persist mesh public messages in meshTimeline; hydrate on switching to #mesh
- Local geochat messages use nostr: senderPeerID for classification
- Tests: fix JSON contains assertion for #g tag
* Fix Swift 6 concurrency warnings: remove closure-based NC observer, wrap relay reconnect resubscribe in Task @MainActor, resubscribe in appDidBecomeActive selector.
* Location Channels polish: live geohash refresh while sheet open; keep selection stable; toolbar shows #<geohash>; sheet labels show human level + #geohash.
* Add per-geohash in-memory timelines and cap private chats to 1337.
- ChatViewModel: persist geohash messages in geoTimelines[geohash] with cap; hydrate timeline on channel switch
- PrivateChatManager: trim per-peer chats to 1337 on send/receive
- Toolbar badge: prevent wrapping with lineLimit(1)/truncation
* Toolbar badge layout + brand color; Teleport custom geohash; Live refresh uses startUpdatingLocation with significant distance.
- Toolbar: right-justify geohash, head truncation, use brand green
- Sheet: add #custom geohash textfield + join; keep minimal IRClike style
- Manager: startUpdatingLocation()/stopUpdatingLocation() with distanceFilter=250 while sheet is open
* Location sheet live updates: reduce distanceFilter to 21m for more frequent geohash refreshes while open.
* Toolbar badge spacing/width; Sheet: rename to #geohash and button 'teleport'.
- Move # label closer to peer count and widen to avoid truncation
- Change custom field placeholder to '#geohash' and action button to 'teleport' with monospaced font
* Sheet polish: style 'teleport' button with subtle background, disable until valid; prefix '#' label and placeholder 'geohash'; center + style 'remove location permission' and hide separators for these rows. Toolbar: adjust spacing/width for # label.
* Sheet UX: restrict custom geohash input to valid base32 (lowercase, max 12), reduce spacing so placeholder sits closer to '#', add divider under country row, style 'remove permission' and hide row separators as before. Toolbar: minor spacing/width already adjusted.
* Sheet behavior: show channel list even without permission; add green 'get location and my geohash' button; ensure only one separator between country and teleport by removing manual Divider and showing default row separator; refine teleport input filtering and spacing.
* Channel activity nudges: notify after 9 minutes inactivity only in background; triple-tap clear also clears persistent timelines.
- ChatViewModel: track last activity per channel; send local notification when new activity resumes while app in background (with small cooldown)
- CommandProcessor: /clear clears current public channel persistence via viewModel helper
* Fix compile: add activeChannelDisplayName() and clearCurrentPublicTimeline() helpers in ChatViewModel.
* Fix concurrent dictionary access in BLEService.broadcastPacket: snapshot shared collections under collectionsQueue to avoid CocoaDictionary iterator crashes.
* People: channel-aware list and counts
- Sidebar: NETWORK → PEOPLE; removed PEOPLE subheader/icon
- Toolbar: blue #mesh badge; green #<geohash> badge; count color by channel
- Geohash participants: track per-geohash unique senders with 5m decay; publish list
- Update on geohash send/receive; 30s prune timer; channel switch hooks
No changes to mesh peer UX; mesh list retained as-is.
* BLE: snapshot collections for thread-safe access; stopServices uses snapshot; safe characteristic snapshot in broadcast path
LocationChannelsSheet: rename button label to 'remove location access'
* Channel sheet: show current peer counts
- Mesh row shows connected mesh peers count
- Geohash rows show 5m-active participant counts via ViewModel
- Live-updates while sheet is open
* Channel sheet: pluralize counts as 'person/people' in titles
* Geohash sampling: subscribe to all available channels while sheet open
- ViewModel: add multi-channel sampling subscriptions and per-geohash participant updates
- Sheet: start sampling on appear, sync on list changes, stop on disappear
* Channel sheet: render counts '(N person/people)' in smaller font next to label
* Channel sheet: use square brackets for counts and adjust parsing; fix mesh title to '#mesh'
* Geohash DMs: implement send/receive via NIP-17 with per-geohash identity
- NostrEmbeddedBitChat: add no-recipient encoder for geohash DMs
- NostrTransport: add sendPrivateMessageGeohash(using provided identity)
- ChatViewModel:
• startGeohashDM + mapping helpers
• subscribe to per-geohash gift wraps; store DMs in conversations keyed by 'nostr_<prefix>'
• route sendPrivateMessage() for 'nostr_' to Nostr geohash send; local echo and status
• map pubkeys from geohash public events for DM initiation
• resubscribe DM feed on reconnect and channel switch; unsubscribe on leave
- ContentView: long-press 'private message' starts geohash DM when sender is nostr
* Geohash DMs UX: tap participants to DM; support /msg nickname in geohash; DM notifications
- People (geohash) list: bold 'you', sort to top, tap to open DM
- ChatViewModel.getPeerIDForNickname resolves geohash names to nostr_ conv keys
- Geohash DM receive: send local notification when not viewing
* Geohash DMs polish: header title, star hidden, self-DM blocked, message icon, delivered/read ACKs
- Header shows '#<geohash>/@name#abcd' for geohash DMs; hide favorite star
- Geohash people row: add small message icon; prevent self tap
- Prevent sending geohash DM to self
- Send delivery/read ACKs on receive; handle delivered/read to update status
* Geohash DMs: hide encryption status icon in DM header
* BLE: fix data race in broadcast path
- Snapshot peripherals via Array(values) and filter outside sync
- Snapshot subscribedCentrals and centralToPeerID under collectionsQueue
- Mutate subscribedCentrals under collectionsQueue barriers in didSubscribe/unsubscribe
* LocationChannelsSheet: open fully by default (large detent only)
* BLE: remove unused centralMapSnapshot variable
* Geohash DMs: only notify on incoming PM when app is backgrounded; avoid foreground noise
* GeoDM: reliable delivered/read receipts, background-only notifications, and UI tweak
- Implement delivered + read receipts for geohash DMs; send READ on open\n- Handle relay OK acks for gift-wrap sends; resubscribe processes PM/DELIVERED/READ\n- Prevent mesh Noise handshakes for virtual geohash peers (nostr_*)\n- Notify GeoDMs only in background; suppress alerts for already-read msgs\n- Logging: promote key GeoDM receive logs; demote/remove verbose noise\n- UI: remove trailing envelope button from geohash People list
* Fix: remove duplicate variable declarations in AnnouncementPacket.decode()
---------
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -9,6 +9,16 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; };
|
||||
047502812E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; };
|
||||
047502872E5416250083520F /* Geohash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502852E5416250083520F /* Geohash.swift */; };
|
||||
047502882E5416250083520F /* LocationChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502862E5416250083520F /* LocationChannel.swift */; };
|
||||
047502892E5416250083520F /* Geohash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502852E5416250083520F /* Geohash.swift */; };
|
||||
0475028A2E5416250083520F /* LocationChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502862E5416250083520F /* LocationChannel.swift */; };
|
||||
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028B2E54171C0083520F /* LocationChannelManager.swift */; };
|
||||
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028B2E54171C0083520F /* LocationChannelManager.swift */; };
|
||||
0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; };
|
||||
047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; };
|
||||
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; };
|
||||
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; };
|
||||
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
|
||||
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
|
||||
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
|
||||
@@ -159,6 +169,11 @@
|
||||
/* Begin PBXFileReference section */
|
||||
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
0475027E2E53A0FC0083520F /* FragmentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FragmentationTests.swift; sourceTree = "<group>"; };
|
||||
047502852E5416250083520F /* Geohash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geohash.swift; sourceTree = "<group>"; };
|
||||
047502862E5416250083520F /* LocationChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannel.swift; sourceTree = "<group>"; };
|
||||
0475028B2E54171C0083520F /* LocationChannelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannelManager.swift; sourceTree = "<group>"; };
|
||||
0475028E2E5417660083520F /* LocationChannelsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannelsSheet.swift; sourceTree = "<group>"; };
|
||||
047502912E547ACC0083520F /* LocationChannelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannelsTests.swift; sourceTree = "<group>"; };
|
||||
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
|
||||
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
|
||||
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
|
||||
@@ -382,6 +397,7 @@
|
||||
A55126E93155456CAA8D6656 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0475028E2E5417660083520F /* LocationChannelsSheet.swift */,
|
||||
763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */,
|
||||
A08E03AA0C63E97C91749AEC /* ContentView.swift */,
|
||||
9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */,
|
||||
@@ -393,6 +409,8 @@
|
||||
ADD53BCDA233C02E53458926 /* Protocols */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
047502852E5416250083520F /* Geohash.swift */,
|
||||
047502862E5416250083520F /* LocationChannel.swift */,
|
||||
049BD39E2E51DBF4001A566B /* Packets.swift */,
|
||||
049BD39F2E51DBF4001A566B /* PeerID.swift */,
|
||||
5318B743C64628A125261163 /* BinaryEncodingUtils.swift */,
|
||||
@@ -415,6 +433,7 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D69A18D27F9A565FD6041E12 /* Info.plist */,
|
||||
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
|
||||
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
|
||||
980B109CBA72BC996455C62B /* BLEServiceTests.swift */,
|
||||
0475027F2E53A0FC0083520F /* Fragmentation */,
|
||||
@@ -448,6 +467,7 @@
|
||||
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
|
||||
049BD3B02E51F319001A566B /* MessageRouter.swift */,
|
||||
049BD3B12E51F319001A566B /* NostrTransport.swift */,
|
||||
049BD3AD2E51ED60001A566B /* Transport.swift */,
|
||||
@@ -679,7 +699,9 @@
|
||||
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 */,
|
||||
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */,
|
||||
049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||
@@ -695,6 +717,8 @@
|
||||
049BD3AF2E51ED60001A566B /* Transport.swift in Sources */,
|
||||
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */,
|
||||
049BD3A02E51DBF4001A566B /* Packets.swift in Sources */,
|
||||
047502892E5416250083520F /* Geohash.swift in Sources */,
|
||||
0475028A2E5416250083520F /* LocationChannel.swift in Sources */,
|
||||
049BD3A12E51DBF4001A566B /* PeerID.swift in Sources */,
|
||||
049BD3942E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
|
||||
049BD3952E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
|
||||
@@ -725,7 +749,9 @@
|
||||
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 */,
|
||||
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
|
||||
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||
@@ -741,6 +767,8 @@
|
||||
049BD3AE2E51ED60001A566B /* Transport.swift in Sources */,
|
||||
68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */,
|
||||
049BD3A22E51DBF4001A566B /* Packets.swift in Sources */,
|
||||
047502872E5416250083520F /* Geohash.swift in Sources */,
|
||||
047502882E5416250083520F /* LocationChannel.swift in Sources */,
|
||||
049BD3A32E51DBF4001A566B /* PeerID.swift in Sources */,
|
||||
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
|
||||
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
|
||||
@@ -758,6 +786,7 @@
|
||||
047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */,
|
||||
8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */,
|
||||
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
|
||||
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
|
||||
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
|
||||
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
|
||||
@@ -777,6 +806,7 @@
|
||||
047502812E53A0FC0083520F /* FragmentationTests.swift in Sources */,
|
||||
686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */,
|
||||
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
|
||||
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
|
||||
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
|
||||
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
|
||||
@@ -892,7 +922,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
@@ -923,7 +953,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -978,7 +1008,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -1010,7 +1040,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -1099,7 +1129,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
|
||||
PRODUCT_NAME = bitchat;
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -1192,7 +1222,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2.1;
|
||||
MARKETING_VERSION = 1.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
|
||||
SDKROOT = iphoneos;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
|
||||
@@ -35,6 +35,8 @@
|
||||
<string>bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users.</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<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>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>bluetooth-central</string>
|
||||
|
||||
@@ -53,6 +53,49 @@ struct NostrEmbeddedBitChat {
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs).
|
||||
static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? {
|
||||
guard type == .delivered || type == .readReceipt else { return nil }
|
||||
|
||||
var payload = Data([type.rawValue])
|
||||
payload.append(Data(messageID.utf8))
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
|
||||
guard let data = packet.toBinaryData() else { return nil }
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
}
|
||||
|
||||
/// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs).
|
||||
static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? {
|
||||
let pm = PrivateMessagePacket(messageID: messageID, content: content)
|
||||
guard let tlv = pm.encode() else { return nil }
|
||||
|
||||
var payload = Data([NoisePayloadType.privateMessage.rawValue])
|
||||
payload.append(tlv)
|
||||
|
||||
let packet = BitchatPacket(
|
||||
type: MessageType.noiseEncrypted.rawValue,
|
||||
senderID: Data(hexString: senderPeerID) ?? Data(),
|
||||
recipientID: nil,
|
||||
timestamp: UInt64(Date().timeIntervalSince1970 * 1000),
|
||||
payload: payload,
|
||||
signature: nil,
|
||||
ttl: 7
|
||||
)
|
||||
|
||||
guard let data = packet.toBinaryData() else { return nil }
|
||||
return "bitchat1:" + base64URLEncode(data)
|
||||
}
|
||||
|
||||
private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String {
|
||||
if let maybeData = Data(hexString: recipientPeerID) {
|
||||
if maybeData.count == 32 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Foundation
|
||||
import CryptoKit
|
||||
import P256K
|
||||
import Security
|
||||
|
||||
// Keychain helper for secure storage
|
||||
struct KeychainHelper {
|
||||
@@ -104,6 +105,7 @@ struct NostrIdentity: Codable {
|
||||
struct NostrIdentityBridge {
|
||||
private static let keychainService = "chat.bitchat.nostr"
|
||||
private static let currentIdentityKey = "nostr-current-identity"
|
||||
private static let deviceSeedKey = "nostr-device-seed"
|
||||
|
||||
/// Get or create the current Nostr identity
|
||||
static func getCurrentNostrIdentity() throws -> NostrIdentity? {
|
||||
@@ -145,11 +147,62 @@ struct NostrIdentityBridge {
|
||||
static func clearAllAssociations() {
|
||||
// Delete current Nostr identity
|
||||
KeychainHelper.delete(key: currentIdentityKey, service: keychainService)
|
||||
KeychainHelper.delete(key: deviceSeedKey, service: keychainService)
|
||||
|
||||
// Note: We can't efficiently delete all noise-nostr associations
|
||||
// without tracking them, but they'll be orphaned and eventually cleaned up
|
||||
// The important part is deleting the current identity so a new one is generated
|
||||
}
|
||||
|
||||
// MARK: - Per-Geohash Identities (Location Channels)
|
||||
|
||||
/// Returns a stable device seed used to derive unlinkable per-geohash identities.
|
||||
/// Stored only on device keychain.
|
||||
private static func getOrCreateDeviceSeed() -> Data {
|
||||
if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) {
|
||||
return existing
|
||||
}
|
||||
var seed = Data(count: 32)
|
||||
_ = seed.withUnsafeMutableBytes { ptr in
|
||||
SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!)
|
||||
}
|
||||
KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService)
|
||||
return seed
|
||||
}
|
||||
|
||||
/// Derive a deterministic, unlinkable Nostr identity for a given geohash.
|
||||
/// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing
|
||||
/// if the candidate is not a valid secp256k1 private key.
|
||||
static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
||||
let seed = getOrCreateDeviceSeed()
|
||||
guard let msg = geohash.data(using: .utf8) else {
|
||||
throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"])
|
||||
}
|
||||
|
||||
func candidateKey(iteration: UInt32) -> Data {
|
||||
var input = Data(msg)
|
||||
var iterBE = iteration.bigEndian
|
||||
withUnsafeBytes(of: &iterBE) { bytes in
|
||||
input.append(contentsOf: bytes)
|
||||
}
|
||||
let code = CryptoKit.HMAC<CryptoKit.SHA256>.authenticationCode(for: input, using: SymmetricKey(data: seed))
|
||||
return Data(code)
|
||||
}
|
||||
|
||||
// Try a few iterations to ensure a valid key can be formed
|
||||
for i in 0..<10 {
|
||||
let keyData = candidateKey(iteration: UInt32(i))
|
||||
if let identity = try? NostrIdentity(privateKeyData: keyData) {
|
||||
return identity
|
||||
}
|
||||
}
|
||||
// As a final fallback, hash the seed+msg and try again
|
||||
var combined = Data()
|
||||
combined.append(seed)
|
||||
combined.append(msg)
|
||||
let fallback = Data(CryptoKit.SHA256.hash(data: combined))
|
||||
return try NostrIdentity(privateKeyData: fallback)
|
||||
}
|
||||
}
|
||||
|
||||
// Bech32 encoding for Nostr (minimal implementation)
|
||||
|
||||
@@ -97,6 +97,28 @@ struct NostrProtocol {
|
||||
|
||||
return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at)
|
||||
}
|
||||
|
||||
/// Create a geohash-scoped ephemeral public message (kind 20000)
|
||||
static func createEphemeralGeohashEvent(
|
||||
content: String,
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity,
|
||||
nickname: String? = nil
|
||||
) throws -> NostrEvent {
|
||||
var tags = [["g", geohash]]
|
||||
if let nickname = nickname, !nickname.isEmpty {
|
||||
tags.append(["n", nickname])
|
||||
}
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .ephemeralEvent,
|
||||
tags: tags,
|
||||
content: content
|
||||
)
|
||||
let signingKey = try senderIdentity.signingKey()
|
||||
return try event.sign(with: signingKey)
|
||||
}
|
||||
|
||||
// MARK: - Private Methods
|
||||
|
||||
@@ -554,4 +576,4 @@ enum NostrError: Error {
|
||||
case invalidCiphertext
|
||||
case signingFailed
|
||||
case encryptionFailed
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,11 @@ import Combine
|
||||
@MainActor
|
||||
class NostrRelayManager: ObservableObject {
|
||||
static let shared = NostrRelayManager()
|
||||
// Track gift-wraps (kind 1059) we initiated so we can log OK acks at info
|
||||
private(set) static var pendingGiftWrapIDs = Set<String>()
|
||||
static func registerPendingGiftWrap(id: String) {
|
||||
pendingGiftWrapIDs.insert(id)
|
||||
}
|
||||
|
||||
struct Relay: Identifiable {
|
||||
let id = UUID()
|
||||
@@ -260,8 +265,8 @@ class NostrRelayManager: ObservableObject {
|
||||
|
||||
let event = try NostrEvent(from: eventDict)
|
||||
|
||||
// Only log critical events
|
||||
if event.kind != 1059 { // Don't log every gift wrap
|
||||
// Only log non-gift-wrap events to reduce noise
|
||||
if event.kind != 1059 {
|
||||
SecureLogger.log("📥 Received Nostr event (kind: \(event.kind)) from relay: \(relayUrl)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
}
|
||||
@@ -293,11 +298,13 @@ class NostrRelayManager: ObservableObject {
|
||||
let success = array[2] as? Bool {
|
||||
let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given"
|
||||
if success {
|
||||
SecureLogger.log("✅ Event \(eventId.prefix(16))... accepted by relay: \(relayUrl)",
|
||||
_ = Self.pendingGiftWrapIDs.remove(eventId)
|
||||
SecureLogger.log("✅ Event accepted id=\(eventId.prefix(16))... by relay: \(relayUrl)",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
} else {
|
||||
SecureLogger.log("📮 Event \(eventId) rejected by relay: \(reason)",
|
||||
category: SecureLogger.session, level: .error)
|
||||
let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil
|
||||
SecureLogger.log("📮 Event \(eventId.prefix(16))... rejected by relay: \(reason)",
|
||||
category: SecureLogger.session, level: isGiftWrap ? .warning : .error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,6 +549,16 @@ struct NostrFilter: Encodable {
|
||||
filter.limit = 100 // Add a reasonable limit
|
||||
return filter
|
||||
}
|
||||
|
||||
// For location channels: geohash-scoped ephemeral events (kind 20000)
|
||||
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [20000]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["g": [geohash]]
|
||||
filter.limit = limit
|
||||
return filter
|
||||
}
|
||||
}
|
||||
|
||||
// Dynamic coding key for tag filters
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import Foundation
|
||||
|
||||
/// Lightweight Geohash encoder used for Location Channels.
|
||||
/// Encodes latitude/longitude to base32 geohash with a fixed precision.
|
||||
enum Geohash {
|
||||
private static let base32Chars = Array("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
|
||||
/// Encodes the provided coordinates into a geohash string.
|
||||
/// - Parameters:
|
||||
/// - latitude: Latitude in degrees (-90...90)
|
||||
/// - longitude: Longitude in degrees (-180...180)
|
||||
/// - precision: Number of geohash characters (2-12 typical). Values <= 0 return an empty string.
|
||||
/// - Returns: Base32 geohash string of length `precision`.
|
||||
static func encode(latitude: Double, longitude: Double, precision: Int) -> String {
|
||||
guard precision > 0 else { return "" }
|
||||
|
||||
var latInterval: (Double, Double) = (-90.0, 90.0)
|
||||
var lonInterval: (Double, Double) = (-180.0, 180.0)
|
||||
|
||||
var isEven = true
|
||||
var bit = 0
|
||||
var ch = 0
|
||||
var geohash: [Character] = []
|
||||
|
||||
let lat = max(-90.0, min(90.0, latitude))
|
||||
let lon = max(-180.0, min(180.0, longitude))
|
||||
|
||||
while geohash.count < precision {
|
||||
if isEven {
|
||||
let mid = (lonInterval.0 + lonInterval.1) / 2
|
||||
if lon >= mid {
|
||||
ch |= (1 << (4 - bit))
|
||||
lonInterval.0 = mid
|
||||
} else {
|
||||
lonInterval.1 = mid
|
||||
}
|
||||
} else {
|
||||
let mid = (latInterval.0 + latInterval.1) / 2
|
||||
if lat >= mid {
|
||||
ch |= (1 << (4 - bit))
|
||||
latInterval.0 = mid
|
||||
} else {
|
||||
latInterval.1 = mid
|
||||
}
|
||||
}
|
||||
|
||||
isEven.toggle()
|
||||
if bit < 4 {
|
||||
bit += 1
|
||||
} else {
|
||||
geohash.append(base32Chars[ch])
|
||||
bit = 0
|
||||
ch = 0
|
||||
}
|
||||
}
|
||||
|
||||
return String(geohash)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import Foundation
|
||||
|
||||
/// Levels of location channels mapped to geohash precisions.
|
||||
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
|
||||
case street
|
||||
case block
|
||||
case neighborhood
|
||||
case city
|
||||
case region
|
||||
case country
|
||||
|
||||
/// Geohash length used for this level.
|
||||
var precision: Int {
|
||||
switch self {
|
||||
case .street: return 8
|
||||
case .block: return 7
|
||||
case .neighborhood: return 6
|
||||
case .city: return 5
|
||||
case .region: return 4
|
||||
case .country: return 2
|
||||
}
|
||||
}
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .street: return "Street"
|
||||
case .block: return "Block"
|
||||
case .neighborhood: return "'hood"
|
||||
case .city: return "City"
|
||||
case .region: return "Region"
|
||||
case .country: return "Country"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A computed geohash channel option.
|
||||
struct GeohashChannel: Codable, Equatable, Hashable, Identifiable {
|
||||
let level: GeohashChannelLevel
|
||||
let geohash: String
|
||||
|
||||
var id: String { "\(level)-\(geohash)" }
|
||||
|
||||
var displayName: String {
|
||||
"\(level.displayName) • \(geohash)"
|
||||
}
|
||||
}
|
||||
|
||||
/// Identifier for current public chat channel (mesh or a location geohash).
|
||||
enum ChannelID: Equatable, Codable {
|
||||
case mesh
|
||||
case location(GeohashChannel)
|
||||
|
||||
/// Human readable name for UI.
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .mesh:
|
||||
return "Mesh"
|
||||
case .location(let ch):
|
||||
return ch.displayName
|
||||
}
|
||||
}
|
||||
|
||||
/// Nostr tag value for scoping (geohash), if applicable.
|
||||
var nostrGeohashTag: String? {
|
||||
switch self {
|
||||
case .mesh: return nil
|
||||
case .location(let ch): return ch.geohash
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import Foundation
|
||||
|
||||
struct AnnouncementPacket {
|
||||
let nickname: String
|
||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||
let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement)
|
||||
let signingPublicKey: Data // Ed25519 public key for signing
|
||||
|
||||
private enum TLVType: UInt8 {
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import CoreLocation
|
||||
import Combine
|
||||
|
||||
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
||||
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
|
||||
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
||||
static let shared = LocationChannelManager()
|
||||
|
||||
enum PermissionState: Equatable {
|
||||
case notDetermined
|
||||
case denied
|
||||
case restricted
|
||||
case authorized
|
||||
}
|
||||
|
||||
private let cl = CLLocationManager()
|
||||
private var lastLocation: CLLocation?
|
||||
private var refreshTimer: Timer?
|
||||
private let userDefaultsKey = "locationChannel.selected"
|
||||
|
||||
// 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
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
cl.delegate = self
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = 1000 // meters; we're not tracking continuously
|
||||
// Load selection
|
||||
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
||||
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
||||
selectedChannel = channel
|
||||
}
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
updatePermissionState(from: status)
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
func enableLocationChannels() {
|
||||
let status: CLAuthorizationStatus
|
||||
if #available(iOS 14.0, *) {
|
||||
status = cl.authorizationStatus
|
||||
} else {
|
||||
status = CLLocationManager.authorizationStatus()
|
||||
}
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
cl.requestWhenInUseAuthorization()
|
||||
case .restricted:
|
||||
Task { @MainActor in self.permissionState = .restricted }
|
||||
case .denied:
|
||||
Task { @MainActor in self.permissionState = .denied }
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
Task { @MainActor in self.permissionState = .authorized }
|
||||
requestOneShotLocation()
|
||||
@unknown default:
|
||||
Task { @MainActor in self.permissionState = .restricted }
|
||||
}
|
||||
}
|
||||
|
||||
func refreshChannels() {
|
||||
if permissionState == .authorized {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin periodic one-shot location refreshes while a selector UI is visible.
|
||||
func beginLiveRefresh(interval: TimeInterval = 5.0) {
|
||||
// Prefer continuous updates with a significant distance filter rather than polling
|
||||
guard permissionState == .authorized else { return }
|
||||
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
||||
cl.distanceFilter = 21 // meters; update on small moves
|
||||
cl.startUpdatingLocation()
|
||||
}
|
||||
|
||||
/// Stop periodic refreshes when selector UI is dismissed.
|
||||
func endLiveRefresh() {
|
||||
cl.stopUpdatingLocation()
|
||||
}
|
||||
|
||||
func select(_ channel: ChannelID) {
|
||||
Task { @MainActor in
|
||||
self.selectedChannel = channel
|
||||
if let data = try? JSONEncoder().encode(channel) {
|
||||
UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - CoreLocation
|
||||
private func requestOneShotLocation() {
|
||||
cl.requestLocation()
|
||||
}
|
||||
|
||||
// iOS < 14
|
||||
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
||||
updatePermissionState(from: status)
|
||||
if case .authorized = permissionState {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
// iOS 14+
|
||||
@available(iOS 14.0, *)
|
||||
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
updatePermissionState(from: manager.authorizationStatus)
|
||||
if case .authorized = permissionState {
|
||||
requestOneShotLocation()
|
||||
}
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
||||
guard let loc = locations.last else { return }
|
||||
lastLocation = loc
|
||||
computeChannels(from: loc.coordinate)
|
||||
}
|
||||
|
||||
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
// Surface as denied/restricted if relevant; otherwise keep previous state
|
||||
SecureLogger.log("LocationChannelManager: location error: \(error.localizedDescription)",
|
||||
category: SecureLogger.session, level: .error)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
private func updatePermissionState(from status: CLAuthorizationStatus) {
|
||||
let newState: PermissionState
|
||||
switch status {
|
||||
case .notDetermined: newState = .notDetermined
|
||||
case .restricted: newState = .restricted
|
||||
case .denied: newState = .denied
|
||||
case .authorizedAlways, .authorizedWhenInUse: newState = .authorized
|
||||
@unknown default: newState = .restricted
|
||||
}
|
||||
Task { @MainActor in self.permissionState = newState }
|
||||
}
|
||||
|
||||
private func computeChannels(from coord: CLLocationCoordinate2D) {
|
||||
let levels = GeohashChannelLevel.allCases
|
||||
var result: [GeohashChannel] = []
|
||||
for level in levels {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -296,6 +296,56 @@ class NoiseEncryptionService {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Announce Signature Helpers
|
||||
|
||||
/// Build the canonical announce binding message bytes and sign with our Ed25519 key
|
||||
/// - Parameters:
|
||||
/// - peerID: 8-byte routing ID (as in packet header)
|
||||
/// - noiseKey: 32-byte Curve25519.KeyAgreement public key
|
||||
/// - ed25519Key: 32-byte Ed25519 public key (self)
|
||||
/// - nickname: UTF-8 nickname (<=255 bytes)
|
||||
/// - timestampMs: UInt64 milliseconds since epoch
|
||||
/// - Returns: Ed25519 signature over the canonical bytes, or nil on failure
|
||||
func buildAnnounceSignature(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data? {
|
||||
let message = canonicalAnnounceBytes(peerID: peerID, noiseKey: noiseKey, ed25519Key: ed25519Key, nickname: nickname, timestampMs: timestampMs)
|
||||
return signData(message)
|
||||
}
|
||||
|
||||
/// Verify an announce signature
|
||||
func verifyAnnounceSignature(signature: Data, peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64, publicKey: Data) -> Bool {
|
||||
let message = canonicalAnnounceBytes(peerID: peerID, noiseKey: noiseKey, ed25519Key: ed25519Key, nickname: nickname, timestampMs: timestampMs)
|
||||
return verifySignature(signature, for: message, publicKey: publicKey)
|
||||
}
|
||||
|
||||
/// Build canonical bytes for announce signing.
|
||||
private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data {
|
||||
var out = Data()
|
||||
// context
|
||||
let context = "bitchat-announce-v1".data(using: .utf8) ?? Data()
|
||||
out.append(UInt8(min(context.count, 255)))
|
||||
out.append(context.prefix(255))
|
||||
// peerID (expect 8 bytes; pad/truncate to 8 for canonicalization)
|
||||
let peerID8 = peerID.prefix(8)
|
||||
out.append(peerID8)
|
||||
if peerID8.count < 8 { out.append(Data(repeating: 0, count: 8 - peerID8.count)) }
|
||||
// noise static key (expect 32)
|
||||
let noise32 = noiseKey.prefix(32)
|
||||
out.append(noise32)
|
||||
if noise32.count < 32 { out.append(Data(repeating: 0, count: 32 - noise32.count)) }
|
||||
// ed25519 public key (expect 32)
|
||||
let ed32 = ed25519Key.prefix(32)
|
||||
out.append(ed32)
|
||||
if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) }
|
||||
// nickname length + bytes
|
||||
let nickData = nickname.data(using: .utf8) ?? Data()
|
||||
out.append(UInt8(min(nickData.count, 255)))
|
||||
out.append(nickData.prefix(255))
|
||||
// timestamp
|
||||
var ts = timestampMs.bigEndian
|
||||
withUnsafeBytes(of: &ts) { raw in out.append(contentsOf: raw) }
|
||||
return out
|
||||
}
|
||||
|
||||
// MARK: - Packet Signing/Verification
|
||||
|
||||
|
||||
@@ -213,4 +213,49 @@ final class NostrTransport: Transport {
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash ACK helpers
|
||||
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.log("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
|
||||
Task { @MainActor in
|
||||
SecureLogger.log("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return }
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Geohash DMs (per-geohash identity)
|
||||
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
|
||||
Task { @MainActor in
|
||||
guard !recipientHex.isEmpty else { return }
|
||||
SecureLogger.log("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
// Build embedded BitChat packet without recipient peer ID
|
||||
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
|
||||
SecureLogger.log("NostrTransport: failed to embed geohash PM packet", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
|
||||
SecureLogger.log("NostrTransport: failed to build Nostr event for geohash PM", category: SecureLogger.session, level: .error)
|
||||
return
|
||||
}
|
||||
SecureLogger.log("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…",
|
||||
category: SecureLogger.session, level: .debug)
|
||||
NostrRelayManager.registerPendingGiftWrap(id: event.id)
|
||||
NostrRelayManager.shared.sendEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ class PrivateChatManager: ObservableObject {
|
||||
init(meshService: Transport? = nil) {
|
||||
self.meshService = meshService
|
||||
}
|
||||
|
||||
// Cap for messages stored per private chat
|
||||
private let privateChatCap = 1337
|
||||
|
||||
/// Start a private chat with a peer
|
||||
func startChat(with peerID: String) {
|
||||
@@ -75,10 +78,14 @@ class PrivateChatManager: ObservableObject {
|
||||
)
|
||||
|
||||
// Add to chat
|
||||
if privateChats[peerID] == nil {
|
||||
privateChats[peerID] = []
|
||||
}
|
||||
if privateChats[peerID] == nil { privateChats[peerID] = [] }
|
||||
privateChats[peerID]?.append(message)
|
||||
// Enforce per-chat cap on local append
|
||||
if var arr = privateChats[peerID], arr.count > privateChatCap {
|
||||
let remove = arr.count - privateChatCap
|
||||
arr.removeFirst(remove)
|
||||
privateChats[peerID] = arr
|
||||
}
|
||||
|
||||
// Send via mesh service
|
||||
meshService.sendPrivateMessage(content, to: peerID, recipientNickname: peerNickname, messageID: messageID)
|
||||
@@ -102,17 +109,25 @@ class PrivateChatManager: ObservableObject {
|
||||
|
||||
// Sanitize chat to avoid duplicate IDs and sort by timestamp
|
||||
sanitizeChat(for: senderPeerID)
|
||||
// Enforce cap after sanitize
|
||||
if var arr = privateChats[senderPeerID], arr.count > privateChatCap {
|
||||
let remove = arr.count - privateChatCap
|
||||
arr.removeFirst(remove)
|
||||
privateChats[senderPeerID] = arr
|
||||
}
|
||||
|
||||
// Mark as unread if not in this chat
|
||||
if selectedPeer != senderPeerID {
|
||||
unreadMessages.insert(senderPeerID)
|
||||
|
||||
// Send notification
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: message.sender,
|
||||
message: message.content,
|
||||
peerID: senderPeerID
|
||||
)
|
||||
// Avoid notifying for messages already marked as read (dup/resubscribe cases)
|
||||
if !sentReadReceipts.contains(message.id) {
|
||||
NotificationService.shared.sendPrivateMessageNotification(
|
||||
from: message.sender,
|
||||
message: message.content,
|
||||
peerID: senderPeerID
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Send read receipt if viewing this chat
|
||||
sendReadReceipt(for: message)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,7 +19,7 @@ struct AppInfoView: View {
|
||||
// MARK: - Constants
|
||||
private enum Strings {
|
||||
static let appName = "bitchat"
|
||||
static let tagline = "mesh sidegroupchat"
|
||||
static let tagline = "sidegroupchat"
|
||||
|
||||
enum Features {
|
||||
static let title = "FEATURES"
|
||||
@@ -28,7 +28,7 @@ struct AppInfoView: View {
|
||||
static let extendedRange = ("antenna.radiowaves.left.and.right", "extended range", "messages relay through peers, going the distance")
|
||||
static let mentions = ("at", "mentions", "use @nickname to notify specific people")
|
||||
static let favorites = ("star.fill", "favorites", "get notified when your favorite people join")
|
||||
static let mutualFavorites = ("globe", "mutual favorites", "private message each other via nostr when out of mesh range")
|
||||
static let geohash = ("number", "local channels", "geohash channels to chat with people in nearby regions over decentralized anonymous relays")
|
||||
}
|
||||
|
||||
enum Privacy {
|
||||
@@ -42,10 +42,11 @@ struct AppInfoView: View {
|
||||
static let title = "HOW TO USE"
|
||||
static let instructions = [
|
||||
"• set your nickname by tapping it",
|
||||
"• swipe left for sidebar",
|
||||
"• tap a peer to start a private chat",
|
||||
"• use @nickname to mention someone",
|
||||
"• triple-tap chat to clear"
|
||||
"• tap #mesh to change channels",
|
||||
"• tap people icon for sidebar",
|
||||
"• tap a peer's name to start a DM",
|
||||
"• triple-tap chat to clear",
|
||||
"• type / for commands"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -131,9 +132,9 @@ struct AppInfoView: View {
|
||||
title: Strings.Features.favorites.1,
|
||||
description: Strings.Features.favorites.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.mutualFavorites.0,
|
||||
title: Strings.Features.mutualFavorites.1,
|
||||
description: Strings.Features.mutualFavorites.2)
|
||||
FeatureRow(icon: Strings.Features.geohash.0,
|
||||
title: Strings.Features.geohash.1,
|
||||
description: Strings.Features.geohash.2)
|
||||
|
||||
FeatureRow(icon: Strings.Features.mentions.0,
|
||||
title: Strings.Features.mentions.1,
|
||||
|
||||
+231
-185
@@ -58,6 +58,9 @@ struct ContentView: View {
|
||||
// MARK: - Properties
|
||||
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
#if os(iOS)
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
#endif
|
||||
@State private var messageText = ""
|
||||
@State private var textFieldSelection: NSRange? = nil
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@@ -77,6 +80,7 @@ struct ContentView: View {
|
||||
@State private var lastScrollTime: Date = .distantPast
|
||||
@State private var scrollThrottleTimer: Timer?
|
||||
@State private var autocompleteDebounceTimer: Timer?
|
||||
@State private var showLocationChannelsSheet = false
|
||||
|
||||
// MARK: - Computed Properties
|
||||
|
||||
@@ -202,7 +206,17 @@ struct ContentView: View {
|
||||
) {
|
||||
Button("private message") {
|
||||
if let peerID = selectedMessageSenderID {
|
||||
#if os(iOS)
|
||||
if peerID.hasPrefix("nostr:") {
|
||||
if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) {
|
||||
viewModel.startGeohashDM(withPubkeyHex: full)
|
||||
}
|
||||
} else {
|
||||
viewModel.startPrivateChat(with: peerID)
|
||||
}
|
||||
#else
|
||||
viewModel.startPrivateChat(with: peerID)
|
||||
#endif
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showSidebar = false
|
||||
sidebarDragOffset = 0
|
||||
@@ -601,7 +615,7 @@ struct ContentView: View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header - match main toolbar height
|
||||
HStack {
|
||||
Text("NETWORK")
|
||||
Text("PEOPLE")
|
||||
.font(.system(size: 16, weight: .bold, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
Spacer()
|
||||
@@ -617,54 +631,102 @@ struct ContentView: View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// People section
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Show appropriate header based on context
|
||||
if !viewModel.allPeers.isEmpty {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "person.2.fill")
|
||||
.font(.system(size: 10))
|
||||
.accessibilityHidden(true)
|
||||
Text("PEOPLE")
|
||||
.font(.system(size: 11, weight: .bold, design: .monospaced))
|
||||
}
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 12)
|
||||
}
|
||||
|
||||
if viewModel.allPeers.isEmpty {
|
||||
Text("nobody around...")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
} else {
|
||||
// Extract peer data for display
|
||||
let peerNicknames = viewModel.meshService.getPeerNicknames()
|
||||
|
||||
// Show all peers (connected and favorites)
|
||||
// Pre-compute peer data outside ForEach to reduce overhead
|
||||
let peerData = viewModel.allPeers.map { peer in
|
||||
// Get current myPeerID for each peer to avoid stale values
|
||||
let currentMyPeerID = viewModel.meshService.myPeerID
|
||||
return PeerDisplayData(
|
||||
id: peer.id,
|
||||
displayName: peer.id == currentMyPeerID ? viewModel.nickname : peer.nickname,
|
||||
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
|
||||
isMe: peer.id == currentMyPeerID,
|
||||
hasUnreadMessages: viewModel.hasUnreadMessages(for: peer.id),
|
||||
encryptionStatus: viewModel.getEncryptionStatus(for: peer.id),
|
||||
connectionState: peer.connectionState,
|
||||
isMutualFavorite: peer.favoriteStatus?.isMutual ?? false
|
||||
)
|
||||
}.sorted { (peer1: PeerDisplayData, peer2: PeerDisplayData) in
|
||||
// Sort: favorites first, then alphabetically by nickname
|
||||
if peer1.isFavorite != peer2.isFavorite {
|
||||
return peer1.isFavorite
|
||||
#if os(iOS)
|
||||
switch locationManager.selectedChannel {
|
||||
case .location:
|
||||
if viewModel.geohashPeople.isEmpty {
|
||||
Text("nobody around...")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
} else {
|
||||
// Show 'you' at top and bold
|
||||
let myHex: String? = {
|
||||
if case .location(let ch) = locationManager.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
|
||||
}
|
||||
ForEach(ordered) { person in
|
||||
HStack(spacing: 4) {
|
||||
// Unread indicator matches mesh behavior
|
||||
let convKey = "nostr_" + String(person.id.prefix(16))
|
||||
if viewModel.unreadPrivateMessages.contains(convKey) {
|
||||
Image(systemName: "envelope.fill")
|
||||
.font(.system(size: 12))
|
||||
.foregroundColor(Color.orange)
|
||||
.accessibilityLabel("Unread message from \(person.displayName)")
|
||||
} else {
|
||||
Image(systemName: "person.fill")
|
||||
.font(.system(size: 10))
|
||||
.foregroundColor(textColor)
|
||||
}
|
||||
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)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
// Open DM with this participant (not for self)
|
||||
if person.id != myHex {
|
||||
viewModel.startGeohashDM(withPubkeyHex: person.id)
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
showSidebar = false
|
||||
sidebarDragOffset = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return peer1.displayName < peer2.displayName
|
||||
}
|
||||
|
||||
ForEach(peerData) { peer in
|
||||
default:
|
||||
// Mesh peers list (original)
|
||||
if viewModel.allPeers.isEmpty {
|
||||
Text("nobody around...")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(secondaryTextColor)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 12)
|
||||
} else {
|
||||
// Extract peer data for display
|
||||
let peerNicknames = viewModel.meshService.getPeerNicknames()
|
||||
|
||||
// Show all peers (connected and favorites)
|
||||
// Pre-compute peer data outside ForEach to reduce overhead
|
||||
let peerData = viewModel.allPeers.map { peer in
|
||||
// Get current myPeerID for each peer to avoid stale values
|
||||
let currentMyPeerID = viewModel.meshService.myPeerID
|
||||
return PeerDisplayData(
|
||||
id: peer.id,
|
||||
displayName: peer.id == currentMyPeerID ? viewModel.nickname : peer.nickname,
|
||||
isFavorite: peer.favoriteStatus?.isFavorite ?? false,
|
||||
isMe: peer.id == currentMyPeerID,
|
||||
hasUnreadMessages: viewModel.hasUnreadMessages(for: peer.id),
|
||||
encryptionStatus: viewModel.getEncryptionStatus(for: peer.id),
|
||||
connectionState: peer.connectionState,
|
||||
isMutualFavorite: peer.favoriteStatus?.isMutual ?? false
|
||||
)
|
||||
}.sorted { (peer1: PeerDisplayData, peer2: PeerDisplayData) in
|
||||
// Sort: favorites first, then alphabetically by nickname
|
||||
if peer1.isFavorite != peer2.isFavorite {
|
||||
return peer1.isFavorite
|
||||
}
|
||||
return peer1.displayName < peer2.displayName
|
||||
}
|
||||
|
||||
ForEach(peerData) { peer in
|
||||
HStack(spacing: 4) {
|
||||
// Signal strength indicator or unread message icon
|
||||
if peer.isMe {
|
||||
@@ -778,6 +840,9 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
// Close switch
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined())
|
||||
@@ -867,6 +932,26 @@ struct ContentView: View {
|
||||
return (name, "")
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
// Compute channel-aware people count and color for toolbar
|
||||
private func channelPeopleCountAndColor() -> (Int, Color) {
|
||||
switch locationManager.selectedChannel {
|
||||
case .location:
|
||||
let n = viewModel.geohashPeople.count
|
||||
return (n, n > 0 ? Color.green : Color.secondary)
|
||||
case .mesh:
|
||||
let counts = 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 color: Color = counts.mesh > 0 ? Color.blue : Color.secondary
|
||||
return (counts.others, color)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
private var mainHeaderView: some View {
|
||||
HStack(spacing: 0) {
|
||||
@@ -918,26 +1003,62 @@ struct ContentView: View {
|
||||
.foregroundColor(Color.orange)
|
||||
.accessibilityLabel("Unread private messages")
|
||||
}
|
||||
|
||||
// Single pass to count both metrics
|
||||
|
||||
// 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
|
||||
}
|
||||
if isMeshConnected { counts.mesh += 1; counts.others += 1 }
|
||||
else if peer.isMutualFavorite { counts.others += 1 }
|
||||
}
|
||||
|
||||
let otherPeersCount = peerCounts.others
|
||||
let meshPeerCount = peerCounts.mesh
|
||||
|
||||
// Purple only if we have peers but none are reachable via mesh (only via Nostr)
|
||||
let isNostrOnly = otherPeersCount > 0 && meshPeerCount == 0
|
||||
let countColor: Color = (peerCounts.mesh > 0) ? Color.blue : Color.secondary
|
||||
#endif
|
||||
|
||||
// Location channels button '#'
|
||||
#if os(iOS)
|
||||
Button(action: { showLocationChannelsSheet = true }) {
|
||||
#if os(iOS)
|
||||
let badgeText: String = {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh:
|
||||
return "#mesh"
|
||||
case .location(let ch):
|
||||
return "#\(ch.geohash)"
|
||||
}
|
||||
}()
|
||||
let badgeColor: Color = {
|
||||
switch locationManager.selectedChannel {
|
||||
case .mesh:
|
||||
return Color.blue
|
||||
case .location:
|
||||
return Color.green
|
||||
}
|
||||
}()
|
||||
Text(badgeText)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(badgeColor)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.head)
|
||||
.frame(minWidth: 60, maxWidth: 160, alignment: .trailing)
|
||||
.fixedSize(horizontal: false, vertical: false)
|
||||
.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")
|
||||
@@ -947,7 +1068,7 @@ struct ContentView: View {
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.foregroundColor(isNostrOnly ? Color.purple : (meshPeerCount > 0 ? Color.blue : Color.secondary))
|
||||
.foregroundColor(countColor)
|
||||
}
|
||||
.onTapGesture {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
@@ -958,6 +1079,11 @@ struct ContentView: View {
|
||||
}
|
||||
.frame(height: 44)
|
||||
.padding(.horizontal, 12)
|
||||
#if os(iOS)
|
||||
.sheet(isPresented: $showLocationChannelsSheet) {
|
||||
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
|
||||
}
|
||||
#endif
|
||||
.background(backgroundColor.opacity(0.95))
|
||||
}
|
||||
|
||||
@@ -991,12 +1117,21 @@ struct ContentView: View {
|
||||
|
||||
// Resolve peer object for header context (may be offline favorite)
|
||||
let peer = viewModel.getPeer(byID: headerPeerID)
|
||||
let privatePeerNick = peer?.displayName ??
|
||||
viewModel.meshService.peerNickname(peerID: headerPeerID) ??
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ??
|
||||
// getFavoriteStatusByNostrKey not implemented
|
||||
// FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(privatePeerID)?.peerNickname ??
|
||||
"Unknown"
|
||||
let privatePeerNick: String = {
|
||||
if privatePeerID.hasPrefix("nostr_") {
|
||||
#if os(iOS)
|
||||
// Build geohash DM header: "#<ghash>/@name#abcd"
|
||||
if case .location(let ch) = locationManager.selectedChannel {
|
||||
let disp = viewModel.geohashDisplayName(for: privatePeerID)
|
||||
return "#\(ch.geohash)/@\(disp)"
|
||||
}
|
||||
#endif
|
||||
}
|
||||
return peer?.displayName ??
|
||||
viewModel.meshService.peerNickname(peerID: headerPeerID) ??
|
||||
FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ??
|
||||
"Unknown"
|
||||
}()
|
||||
let isNostrAvailable: Bool = {
|
||||
guard let connectionState = peer?.connectionState else {
|
||||
// Check if we can reach this peer via Nostr even if not in allPeers
|
||||
@@ -1056,17 +1191,17 @@ struct ContentView: View {
|
||||
|
||||
Text("\(privatePeerNick)")
|
||||
.font(.system(size: 16, weight: .medium, design: .monospaced))
|
||||
.foregroundColor(textColor)
|
||||
|
||||
// Dynamic encryption status icon
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID)
|
||||
if let icon = encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
|
||||
encryptionStatus == .noiseSecured ? textColor :
|
||||
Color.red)
|
||||
.accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")")
|
||||
.foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs)
|
||||
if !privatePeerID.hasPrefix("nostr_") {
|
||||
let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID)
|
||||
if let icon = encryptionStatus.icon {
|
||||
Image(systemName: icon)
|
||||
.font(.system(size: 14))
|
||||
.foregroundColor(encryptionStatus == .noiseVerified ? textColor :
|
||||
encryptionStatus == .noiseSecured ? textColor :
|
||||
Color.red)
|
||||
.accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")")
|
||||
}
|
||||
}
|
||||
}
|
||||
.accessibilityLabel("Private chat with \(privatePeerNick)")
|
||||
@@ -1093,17 +1228,19 @@ struct ContentView: View {
|
||||
|
||||
Spacer()
|
||||
|
||||
// Favorite button
|
||||
Button(action: {
|
||||
viewModel.toggleFavorite(peerID: headerPeerID)
|
||||
}) {
|
||||
Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
|
||||
// Favorite button (hidden for geohash DMs)
|
||||
if !(privatePeerID.hasPrefix("nostr_")) {
|
||||
Button(action: {
|
||||
viewModel.toggleFavorite(peerID: headerPeerID)
|
||||
}) {
|
||||
Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star")
|
||||
.font(.system(size: 16))
|
||||
.foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites")
|
||||
.accessibilityHint("Double tap to toggle favorite status")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites")
|
||||
.accessibilityHint("Double tap to toggle favorite status")
|
||||
}
|
||||
}
|
||||
.frame(height: 44)
|
||||
@@ -1115,7 +1252,7 @@ struct ContentView: View {
|
||||
|
||||
// MARK: - Helper Views
|
||||
|
||||
// Helper view for rendering message content with clickable hashtags
|
||||
// Helper view for rendering message content (plain, no hashtag/mention formatting)
|
||||
struct MessageContentView: View {
|
||||
let message: BitchatMessage
|
||||
let viewModel: ChatViewModel
|
||||
@@ -1123,106 +1260,15 @@ struct MessageContentView: View {
|
||||
let isMentioned: Bool
|
||||
|
||||
var body: some View {
|
||||
let content = message.content
|
||||
let hashtagPattern = "#([a-zA-Z0-9_]+)"
|
||||
let mentionPattern = "@([a-zA-Z0-9_]+)"
|
||||
|
||||
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
|
||||
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
|
||||
|
||||
let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
||||
let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
||||
|
||||
// Combine all matches and sort by location
|
||||
var allMatches: [(range: NSRange, type: String)] = []
|
||||
for match in hashtagMatches {
|
||||
allMatches.append((match.range(at: 0), "hashtag"))
|
||||
}
|
||||
for match in mentionMatches {
|
||||
allMatches.append((match.range(at: 0), "mention"))
|
||||
}
|
||||
allMatches.sort { $0.range.location < $1.range.location }
|
||||
|
||||
// Build the text as a concatenated Text view for natural wrapping
|
||||
let segments = buildTextSegments()
|
||||
var result = Text("")
|
||||
|
||||
for segment in segments {
|
||||
if segment.type == "hashtag" {
|
||||
// Note: We can't have clickable links in concatenated Text, so hashtags won't be clickable
|
||||
result = result + Text(segment.text)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(Color.blue)
|
||||
.underline()
|
||||
} else if segment.type == "mention" {
|
||||
result = result + Text(segment.text)
|
||||
.font(.system(size: 14, weight: .semibold, design: .monospaced))
|
||||
.foregroundColor(Color.orange)
|
||||
} else {
|
||||
result = result + Text(segment.text)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(isMentioned ? .bold : .regular)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
Text(message.content)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(isMentioned ? .bold : .regular)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
private func buildTextSegments() -> [(text: String, type: String)] {
|
||||
var segments: [(text: String, type: String)] = []
|
||||
let content = message.content
|
||||
var lastEnd = content.startIndex
|
||||
|
||||
let hashtagPattern = "#([a-zA-Z0-9_]+)"
|
||||
let mentionPattern = "@([a-zA-Z0-9_]+)"
|
||||
|
||||
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
|
||||
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
|
||||
|
||||
let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
||||
let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
||||
|
||||
// Combine all matches and sort by location
|
||||
var allMatches: [(range: NSRange, type: String)] = []
|
||||
for match in hashtagMatches {
|
||||
allMatches.append((match.range(at: 0), "hashtag"))
|
||||
}
|
||||
for match in mentionMatches {
|
||||
allMatches.append((match.range(at: 0), "mention"))
|
||||
}
|
||||
allMatches.sort { $0.range.location < $1.range.location }
|
||||
|
||||
for (matchRange, matchType) in allMatches {
|
||||
if let range = Range(matchRange, in: content) {
|
||||
// Add text before the match
|
||||
if lastEnd < range.lowerBound {
|
||||
let beforeText = String(content[lastEnd..<range.lowerBound])
|
||||
if !beforeText.isEmpty {
|
||||
segments.append((beforeText, "text"))
|
||||
}
|
||||
}
|
||||
|
||||
// Add the match
|
||||
let matchText = String(content[range])
|
||||
segments.append((matchText, matchType))
|
||||
|
||||
lastEnd = range.upperBound
|
||||
}
|
||||
}
|
||||
|
||||
// Add any remaining text
|
||||
if lastEnd < content.endIndex {
|
||||
let remainingText = String(content[lastEnd...])
|
||||
if !remainingText.isEmpty {
|
||||
segments.append((remainingText, "text"))
|
||||
}
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
// buildTextSegments removed: content is rendered plain.
|
||||
}
|
||||
|
||||
// Delivery status indicator view
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
import SwiftUI
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
struct LocationChannelsSheet: View {
|
||||
@Binding var isPresented: Bool
|
||||
@ObservedObject private var manager = LocationChannelManager.shared
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@State private var customGeohash: String = ""
|
||||
@State private var customError: String? = nil
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("#location channels")
|
||||
.font(.system(size: 18, design: .monospaced))
|
||||
Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps.")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Group {
|
||||
switch manager.permissionState {
|
||||
case LocationChannelManager.PermissionState.notDetermined:
|
||||
Button(action: { manager.enableLocationChannels() }) {
|
||||
Text("get location and my geohashes")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(Color.green)
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.green.opacity(0.12))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
case LocationChannelManager.PermissionState.denied, LocationChannelManager.PermissionState.restricted:
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("location permission denied. enable in settings to use location channels.")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
Button("open settings") {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
case LocationChannelManager.PermissionState.authorized:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
channelList
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button("close") { isPresented = false }
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
}
|
||||
}
|
||||
}
|
||||
.presentationDetents([.large])
|
||||
.onAppear {
|
||||
// Refresh channels when opening
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
manager.refreshChannels()
|
||||
}
|
||||
// Begin periodic refresh while sheet is open
|
||||
manager.beginLiveRefresh()
|
||||
// Begin multi-channel sampling for counts
|
||||
let ghs = manager.availableChannels.map { $0.geohash }
|
||||
viewModel.beginGeohashSampling(for: ghs)
|
||||
}
|
||||
.onDisappear {
|
||||
manager.endLiveRefresh()
|
||||
viewModel.endGeohashSampling()
|
||||
}
|
||||
.onChange(of: manager.permissionState) { newValue in
|
||||
if newValue == LocationChannelManager.PermissionState.authorized {
|
||||
manager.refreshChannels()
|
||||
}
|
||||
}
|
||||
.onChange(of: manager.availableChannels) { newValue in
|
||||
// Keep sampling list in sync with available channels as they refresh live
|
||||
let ghs = newValue.map { $0.geohash }
|
||||
viewModel.beginGeohashSampling(for: ghs)
|
||||
}
|
||||
}
|
||||
|
||||
private var channelList: some View {
|
||||
List {
|
||||
// Mesh option first
|
||||
channelRow(title: meshTitleWithCount(), subtitle: "#bluetooth", isSelected: isMeshSelected) {
|
||||
manager.select(ChannelID.mesh)
|
||||
isPresented = false
|
||||
}
|
||||
|
||||
// Nearby options
|
||||
if !manager.availableChannels.isEmpty {
|
||||
ForEach(manager.availableChannels) { channel in
|
||||
channelRow(title: geohashTitleWithCount(for: channel), subtitle: "#\(channel.geohash)", isSelected: isSelected(channel)) {
|
||||
manager.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
ProgressView()
|
||||
Text("finding nearby channels…")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
}
|
||||
}
|
||||
|
||||
// Custom geohash teleport
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 2) {
|
||||
Text("#")
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
TextField("geohash", text: $customGeohash)
|
||||
.textInputAutocapitalization(.never)
|
||||
.autocorrectionDisabled(true)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.keyboardType(.asciiCapable)
|
||||
.onChange(of: customGeohash) { newValue in
|
||||
// Allow only geohash base32 characters, strip '#', limit length
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
let filtered = newValue
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.filter { allowed.contains($0) }
|
||||
if filtered.count > 12 {
|
||||
customGeohash = String(filtered.prefix(12))
|
||||
} else if filtered != newValue {
|
||||
customGeohash = filtered
|
||||
}
|
||||
}
|
||||
let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "")
|
||||
let isValid = validateGeohash(normalized)
|
||||
Button("teleport") {
|
||||
let gh = normalized
|
||||
guard isValid else { customError = "invalid geohash"; return }
|
||||
let level = levelForLength(gh.count)
|
||||
let ch = GeohashChannel(level: level, geohash: gh)
|
||||
manager.select(ChannelID.location(ch))
|
||||
isPresented = false
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.secondary.opacity(0.12))
|
||||
.cornerRadius(6)
|
||||
.opacity(isValid ? 1.0 : 0.4)
|
||||
.disabled(!isValid)
|
||||
}
|
||||
if let err = customError {
|
||||
Text(err)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.red)
|
||||
}
|
||||
}
|
||||
|
||||
// Footer action inside the list
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
Button(action: {
|
||||
if let url = URL(string: UIApplication.openSettingsURLString) {
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Text("remove location access")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1))
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 6)
|
||||
.background(Color.red.opacity(0.08))
|
||||
.cornerRadius(6)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
|
||||
private func isSelected(_ channel: GeohashChannel) -> Bool {
|
||||
if case .location(let ch) = manager.selectedChannel {
|
||||
return ch == channel
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private var isMeshSelected: Bool {
|
||||
if case .mesh = manager.selectedChannel { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
private func channelRow(title: String, subtitle: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
// Render title with smaller font for trailing count in parentheses
|
||||
let parts = splitTitleAndCount(title)
|
||||
HStack(spacing: 4) {
|
||||
Text(parts.base)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
if let count = parts.countSuffix, !count.isEmpty {
|
||||
Text(count)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
Text(subtitle)
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if isSelected {
|
||||
Text("✔︎")
|
||||
.font(.system(size: 16, design: .monospaced))
|
||||
.foregroundColor(.green)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||||
private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) {
|
||||
guard let idx = s.lastIndex(of: "[") else { return (s, nil) }
|
||||
let prefix = String(s[..<idx]).trimmingCharacters(in: .whitespaces)
|
||||
let suffix = String(s[idx...])
|
||||
return (prefix, suffix)
|
||||
}
|
||||
|
||||
// MARK: - Helpers for counts
|
||||
private func meshTitleWithCount() -> String {
|
||||
// Count currently connected mesh peers (excluding self)
|
||||
let myID = viewModel.meshService.myPeerID
|
||||
let meshCount = viewModel.allPeers.reduce(0) { acc, peer in
|
||||
if peer.id != myID && peer.isConnected { return acc + 1 }
|
||||
return acc
|
||||
}
|
||||
let noun = meshCount == 1 ? "person" : "people"
|
||||
return "#mesh [\(meshCount) \(noun)]"
|
||||
}
|
||||
|
||||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
||||
// Use ViewModel's 5-minute activity counts; may be 0 for non-selected channels
|
||||
let count = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||
let noun = count == 1 ? "person" : "people"
|
||||
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
|
||||
}
|
||||
|
||||
private func validateGeohash(_ s: String) -> Bool {
|
||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||
guard !s.isEmpty, s.count <= 12 else { return false }
|
||||
return s.allSatisfy { allowed.contains($0) }
|
||||
}
|
||||
|
||||
private func levelForLength(_ len: Int) -> GeohashChannelLevel {
|
||||
switch len {
|
||||
case 0...2: return .country
|
||||
case 3...4: return .region
|
||||
case 5: return .city
|
||||
case 6: return .neighborhood
|
||||
case 7: return .block
|
||||
default: return .street
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endif
|
||||
@@ -0,0 +1,48 @@
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
final class LocationChannelsTests: XCTestCase {
|
||||
func testGeohashEncoderPrecisionMapping() {
|
||||
// Sanity: known coords (Statue of Liberty approx)
|
||||
let lat = 40.6892
|
||||
let lon = -74.0445
|
||||
let street = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.street.precision)
|
||||
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)
|
||||
|
||||
XCTAssertEqual(street.count, 8)
|
||||
XCTAssertEqual(block.count, 7)
|
||||
XCTAssertEqual(neighborhood.count, 6)
|
||||
XCTAssertEqual(city.count, 5)
|
||||
XCTAssertEqual(region.count, 4)
|
||||
XCTAssertEqual(country.count, 2)
|
||||
|
||||
// All prefixes must match progressively
|
||||
XCTAssertTrue(street.hasPrefix(block))
|
||||
XCTAssertTrue(block.hasPrefix(neighborhood))
|
||||
XCTAssertTrue(neighborhood.hasPrefix(city))
|
||||
XCTAssertTrue(city.hasPrefix(region))
|
||||
XCTAssertTrue(region.hasPrefix(country))
|
||||
}
|
||||
|
||||
func testNostrGeohashFilterEncoding() throws {
|
||||
let gh = "u4pruy"
|
||||
let filter = NostrFilter.geohashEphemeral(gh)
|
||||
let data = try JSONEncoder().encode(filter)
|
||||
let json = String(data: data, encoding: .utf8) ?? ""
|
||||
// Expect kinds includes 20000 and tag filter '#g':[gh]
|
||||
XCTAssertTrue(json.contains("20000"))
|
||||
XCTAssertTrue(json.contains("\"#g\":[\"\(gh)\"]"))
|
||||
}
|
||||
|
||||
func testPerGeohashIdentityDeterministic() throws {
|
||||
// Derive twice for same geohash; should be identical
|
||||
let gh = "u4pruy"
|
||||
let id1 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh)
|
||||
let id2 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh)
|
||||
XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user