Compare commits

..
Author SHA1 Message Date
jack 749d80aa85 Adjust PoW policy defaults (finalize 16/15/14 mapping) 2025-08-31 17:10:05 +02:00
jack 29976f4e19 Apply PoW to geohash system/raw sends\n\n- Use same mining/signing path for screenshot and sendPublicRaw in geohash\n- Factor helper mineAndSendGeohashEvent(...) in ChatViewModel 2025-08-30 20:53:24 +02:00
jack bb67ababaf Add NIP-13 PoW for geohash with body-only mining animation\n\n- Implement PoW core and defaults (16/15/14 by precision)\n- Wire PoW into geohash send path; hide timestamp while mining\n- Use combined animated text so long messages wrap under name\n- Enforce receive-side PoW filtering for geohash events\n- Filter geohash peer list by observed PoW bits; track per pubkey\n- Stabilize miner memory (autoreleasepool + sched_yield)\n- Add unit tests for PoW 2025-08-30 16:43:13 +02:00
e79bcf531b Feature/screenshot privacy warning (#541)
* Screenshot privacy: warn on location sheet; gate screenshot broadcasts to chat only\n\n- Track sheet presentation in ChatViewModel\n- Show privacy alert when screenshot taken on location channels\n- Ignore screenshots for App Info and Location sheet for broadcast\n- ContentView wires sheet onAppear/onDisappear and presents alert\n- Fix location sheet subtitle wrapping: render as single Text to avoid orphaned bullets

* Fix duplicate messages after channel switch\n\n- Dedup on per-geohash append (skip if ID exists)\n- Dedup and sort timeline by timestamp when switching into a geohash\n- Keep content/layout unchanged; add lightweight debug logs for empty rows

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 14:37:17 +02:00
jack 3e5043f875 Harden text formatting against Unicode range mismatches
- Use NSString.length for all NSRange regex scans
- Guard string slicing when lastEnd > match.lowerBound
- Advance lastEnd safely to avoid backtracking overlaps
- Apply to formatMessageContent, formatMessage, formatMessageAsText, parseMentions
2025-08-28 09:22:34 +01:00
jack 11cd5527ed Fix crash in formatMessageAsText: use NSString length for NSRanges and guard slicing before matches to avoid invalid ranges with multi-byte content 2025-08-28 09:18:11 +01:00
cf1bfdac6b Geohash Bookmarks + Rising‑Edge Notifications + Friendly Names (#538)
* Add geohash bookmarks: persistence, sampling integration, and UI\n\n- Add GeohashBookmarksStore with UserDefaults persistence and toggle API\n- Sample union of regional + bookmarked geohashes for activity notifications\n- LocationChannelsSheet: bookmark icons on nearby rows and a Bookmarked section\n- Header toolbar: toggle bookmark for current geohash\n- Tests: GeohashBookmarksStoreTests for normalization and persistence\n\nRationale: Bookmarked geohashes are always sampled for new activity notifications and quickly selectable from the sheet.

* Notifications: remove background 'new chats!' nudge; geohash activity only on zero→alive; mesh 'nearby' only on zero→alive\n\n- Geohash sampling: notify only when previous participant count in last 5m was zero, with 30s freshness gate\n- Suppress old sampled events after (re)subscribe\n- Remove channel inactivity nudge\n- Mesh: reset rising-edge gate immediately when peer list goes empty (strict zero→alive)

* Geohash notifications: ensure triggering message appears\n\n- On zero→alive sampling, pre-populate geoTimelines[gh] with the triggering message\n- Dedup on per-geohash timeline and UI messages by message ID to avoid duplicates after subscribe\n- Keeps participant update, block/self checks, and cooldown intact

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 09:10:15 +01:00
b63a595b04 Fix geohash UX: rising-edge notify, correct teleport, sort timeline (#537)
- Remove "new chats!" background nudge; notify only when a regional geohash goes from 0 participants to activity, with content preview and cooldown.\n- Do not mark teleported when selected geohash is in regional channel list; clear persisted teleport when in-region; avoid self teleported state from historical tags; deep links don’t mark teleported for regional geohashes or during cold start.\n- Sort per-geohash timelines chronologically on re-entry and trim whitespace-only content to prevent blank lines.\n- Trim inbound public content and ignore whitespace-only sends.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
2025-08-28 00:08:01 +01:00
27 changed files with 1495 additions and 785 deletions
+40 -22
View File
@@ -7,6 +7,14 @@
objects = {
/* Begin PBXBuildFile section */
04657AB52E633C6A002D5BBE /* NostrPoW.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB42E633C6A002D5BBE /* NostrPoW.swift */; };
04657AB62E633C6A002D5BBE /* NostrPoW.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB42E633C6A002D5BBE /* NostrPoW.swift */; };
04657AB82E633C76002D5BBE /* PowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB72E633C76002D5BBE /* PowPolicy.swift */; };
04657AB92E633C76002D5BBE /* PowPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657AB72E633C76002D5BBE /* PowPolicy.swift */; };
04657ABB2E633C94002D5BBE /* MiningAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABA2E633C94002D5BBE /* MiningAnimation.swift */; };
04657ABC2E633C94002D5BBE /* MiningAnimation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABA2E633C94002D5BBE /* MiningAnimation.swift */; };
04657ABE2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */; };
04657ABF2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */; };
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 */; };
@@ -32,6 +40,10 @@
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.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 */; };
@@ -56,12 +68,6 @@
049BD3B32E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
049BD3B42E51F319001A566B /* NostrTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B12E51F319001A566B /* NostrTransport.swift */; };
049BD3B52E51F319001A566B /* MessageRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD3B02E51F319001A566B /* MessageRouter.swift */; };
ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */; };
ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */; };
ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */; };
ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */; };
ABCD00082E5CCCC300162C4E /* NostrOutboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */; };
ABCD00092E5CCCC300162C4F /* NostrOutboxService.swift in Sources */ = {isa = PBXBuildFile; fileRef = ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */; };
0AE840940F21AFC07C226636 /* PrivateChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A262EDDC04B7D7B5E31F321 /* PrivateChatE2ETests.swift */; };
0B6F25559A21F8C69C8357C6 /* BinaryProtocolTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B3CC6FA298729906109F61B /* BinaryProtocolTests.swift */; };
10E68BB889356219189E38EC /* BitchatApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF625BB3AD919322C01A46B2 /* BitchatApp.swift */; };
@@ -199,6 +205,10 @@
/* Begin PBXFileReference section */
03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
04657AB42E633C6A002D5BBE /* NostrPoW.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrPoW.swift; sourceTree = "<group>"; };
04657AB72E633C76002D5BBE /* PowPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowPolicy.swift; sourceTree = "<group>"; };
04657ABA2E633C94002D5BBE /* MiningAnimation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MiningAnimation.swift; sourceTree = "<group>"; };
04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrPoWTests.swift; sourceTree = "<group>"; };
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>"; };
@@ -211,6 +221,8 @@
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; };
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.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>"; };
@@ -219,9 +231,6 @@
049BD39E2E51DBF4001A566B /* Packets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Packets.swift; sourceTree = "<group>"; };
049BD39F2E51DBF4001A566B /* PeerID.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerID.swift; sourceTree = "<group>"; };
049BD3A42E51DC0E001A566B /* MessageDeduplicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageDeduplicator.swift; sourceTree = "<group>"; };
ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadReceiptTracker.swift; sourceTree = "<group>"; };
ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrInboxService.swift; sourceTree = "<group>"; };
ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrOutboxService.swift; sourceTree = "<group>"; };
049BD3AA2E51E38E001A566B /* PeerIDResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PeerIDResolver.swift; sourceTree = "<group>"; };
049BD3AD2E51ED60001A566B /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = "<group>"; };
049BD3B02E51F319001A566B /* MessageRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageRouter.swift; sourceTree = "<group>"; };
@@ -454,6 +463,7 @@
A55126E93155456CAA8D6656 /* Views */ = {
isa = PBXGroup;
children = (
04657ABA2E633C94002D5BBE /* MiningAnimation.swift */,
AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */,
047502B22E55FED60083520F /* GeohashPeopleList.swift */,
047502B32E55FED60083520F /* MeshPeerList.swift */,
@@ -493,6 +503,8 @@
isa = PBXGroup;
children = (
D69A18D27F9A565FD6041E12 /* Info.plist */,
04657ABD2E633CA4002D5BBE /* NostrPoWTests.swift */,
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
980B109CBA72BC996455C62B /* BLEServiceTests.swift */,
@@ -528,10 +540,9 @@
D98A3186D7E4C72E35BDF7FE /* Services */ = {
isa = PBXGroup;
children = (
04657AB72E633C76002D5BBE /* PowPolicy.swift */,
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
ABCD00012E5CCCC300162C4A /* ReadReceiptTracker.swift */,
ABCD00042E5CCCC300162C4C /* NostrInboxService.swift */,
ABCD00072E5CCCC300162C4E /* NostrOutboxService.swift */,
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
047502B82E560F690083520F /* RelayController.swift */,
0475028B2E54171C0083520F /* LocationChannelManager.swift */,
@@ -554,6 +565,7 @@
E78C7F4B6769C0A72F5DE544 /* Nostr */ = {
isa = PBXGroup;
children = (
04657AB42E633C6A002D5BBE /* NostrPoW.swift */,
A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */,
049BD39B2E51DBD9001A566B /* NostrEmbeddedBitChat.swift */,
5F8043995007F0D84438EDD9 /* NostrIdentity.swift */,
@@ -754,11 +766,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
ABCD00022E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */,
ABCD00052E5CCCC300162C4C /* NostrInboxService.swift in Sources */,
ABCD00082E5CCCC300162C4E /* NostrOutboxService.swift in Sources */,
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */,
1234567890ABCDEFFEDCBA13 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB12CC22DD33EE44FF56 /* VerificationService.swift in Sources */,
AA77BB15CC22DD33EE44FF59 /* VerificationViews.swift in Sources */,
A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */,
@@ -786,6 +795,7 @@
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */,
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */,
8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */,
@@ -798,15 +808,18 @@
749D8CF8A362B6CD0786782D /* NotificationService.swift in Sources */,
049BD3AF2E51ED60001A566B /* Transport.swift in Sources */,
E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */,
04657ABC2E633C94002D5BBE /* MiningAnimation.swift in Sources */,
049BD3A02E51DBF4001A566B /* Packets.swift in Sources */,
047502892E5416250083520F /* Geohash.swift in Sources */,
0475028A2E5416250083520F /* LocationChannel.swift in Sources */,
04657AB82E633C76002D5BBE /* PowPolicy.swift in Sources */,
049BD3A12E51DBF4001A566B /* PeerID.swift in Sources */,
049BD3942E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
049BD3952E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
049BD3962E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */,
8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */,
04657AB52E633C6A002D5BBE /* NostrPoW.swift in Sources */,
AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */,
E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */,
);
@@ -816,11 +829,8 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
ABCD00032E5CCCC300162C4A /* ReadReceiptTracker.swift in Sources */,
ABCD00062E5CCCC300162C4D /* NostrInboxService.swift in Sources */,
ABCD00092E5CCCC300162C4F /* NostrOutboxService.swift in Sources */,
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */,
1234567890ABCDEFFEDCBA14 /* PeerDisplayNameResolver.swift in Sources */,
AA77BB11CC22DD33EE44FF55 /* VerificationService.swift in Sources */,
AA77BB14CC22DD33EE44FF58 /* VerificationViews.swift in Sources */,
A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */,
@@ -848,6 +858,7 @@
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */,
92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */,
@@ -860,15 +871,18 @@
61C81ED5F679D5E973EE0C07 /* NotificationService.swift in Sources */,
049BD3AE2E51ED60001A566B /* Transport.swift in Sources */,
68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */,
04657ABB2E633C94002D5BBE /* MiningAnimation.swift in Sources */,
049BD3A22E51DBF4001A566B /* Packets.swift in Sources */,
047502872E5416250083520F /* Geohash.swift in Sources */,
047502882E5416250083520F /* LocationChannel.swift in Sources */,
04657AB92E633C76002D5BBE /* PowPolicy.swift in Sources */,
049BD3A32E51DBF4001A566B /* PeerID.swift in Sources */,
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */,
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */,
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */,
EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */,
C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */,
04657AB62E633C6A002D5BBE /* NostrPoW.swift in Sources */,
AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */,
E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */,
);
@@ -883,7 +897,9 @@
8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */,
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
04657ABF2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */,
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
@@ -905,7 +921,9 @@
686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */,
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
04657ABE2E633CA4002D5BBE /* NostrPoWTests.swift in Sources */,
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
@@ -310,7 +310,7 @@ class SecureIdentityStateManager {
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: String) -> [CryptographicIdentity] {
queue.sync {
// Defensive: ensure hex and correct length
guard PeerIDResolver.isShortID(peerID) else { return [] }
guard peerID.count == 16, peerID.allSatisfy({ $0.isHexDigit }) else { return [] }
return cryptographicIdentities.values.filter { $0.fingerprint.hasPrefix(peerID) }
}
}
+96
View File
@@ -0,0 +1,96 @@
import Foundation
import CryptoKit
import Security
import Darwin
/// NIP-13 Proof-of-Work utilities for Nostr events.
/// - Counts leading zero bits and mines a nonce tag to meet a target.
enum NostrPoW {
/// Table of leading zero bit counts for all 256 byte values.
private static let lzTable: [UInt8] = {
(0...255).map { v -> UInt8 in
var x = UInt8(v)
if x == 0 { return 8 }
var n: UInt8 = 0
while (x & 0x80) == 0 {
n &+= 1
x <<= 1
}
return n
}
}()
/// Count leading zero bits in a 32-byte hash.
static func leadingZeroBits(_ data: Data) -> Int {
var total = 0
for b in data {
let lz = Int(lzTable[Int(b)])
total += lz
if lz != 8 { break }
}
return total
}
/// Compute Nostr event id hash (SHA-256 over canonical serialization) for given fields.
private static func eventIDHash(pubkey: String, createdAt: Int, kind: Int, tags: [[String]], content: String) -> (hex: String, data: Data) {
let serialized: [Any] = [0, pubkey, createdAt, kind, tags, content]
let json = try! JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
let digest = SHA256.hash(data: json)
let data = Data(digest)
let hex = data.map { String(format: "%02x", $0) }.joined()
return (hex, data)
}
/// Mine a NIP-13 nonce tag that satisfies the target leading-zero bits.
/// - Parameters:
/// - pubkey: x-only hex pubkey (64-hex chars)
/// - createdAt: seconds since epoch
/// - kind: event kind
/// - baseTags: existing tags (e.g., [ ["g", geohash], ["n", nickname], ... ])
/// - content: event content
/// - targetBits: required leading zero bits
/// - startNonce: optional starting nonce (random if nil)
/// - Returns: (nonce, idHex)
static func mine(pubkey: String,
createdAt: Int,
kind: Int,
baseTags: [[String]],
content: String,
targetBits: Int,
startNonce: UInt64? = nil) -> (nonce: UInt64, idHex: String) {
var nonce: UInt64 = startNonce ?? randomNonce()
// Build a local tags buffer once to avoid reallocating unrelated tags
var tags = baseTags
tags.append(["nonce", "0", String(targetBits)])
var iter: UInt64 = 0
while true {
var solved = false
var idHexOut = ""
// Use an autorelease pool periodically to keep memory stable on iOS
autoreleasepool {
// Update nonce tag value (second element)
tags[tags.count - 1][1] = String(nonce)
let (idHex, idData) = eventIDHash(pubkey: pubkey, createdAt: createdAt, kind: kind, tags: tags, content: content)
if leadingZeroBits(idData) >= targetBits {
solved = true
idHexOut = idHex
}
}
if solved {
return (nonce, idHexOut)
}
nonce &+= 1
iter &+= 1
if iter & 0x3FFF == 0 { /* yield every ~16k iterations */ _ = sched_yield() }
}
}
private static func randomNonce() -> UInt64 {
var n: UInt64 = 0
withUnsafeMutableBytes(of: &n) { ptr in
_ = SecRandomCopyBytes(kSecRandomDefault, ptr.count, ptr.baseAddress!)
}
return n
}
}
+1 -11
View File
@@ -6,7 +6,6 @@
//
import Foundation
import CryptoKit
// MARK: - Hex Encoding/Decoding
@@ -36,16 +35,6 @@ extension Data {
}
}
// MARK: - Fingerprint Helpers
extension Data {
/// SHA-256 over data, hex-encoded (lowercase)
func sha256Fingerprint() -> String {
let hash = SHA256.hash(data: self)
return hash.map { String(format: "%02x", $0) }.joined()
}
}
// MARK: - Binary Encoding Utilities
extension Data {
@@ -231,3 +220,4 @@ extension Data {
return data
}
}
+11 -2
View File
@@ -409,6 +409,8 @@ class BitchatMessage: Codable {
let senderPeerID: String?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
// Mining state for Nostr geohash PoW (UI hint: hide timestamp, animate)
var isMiningPow: Bool? // default nil/false
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
@@ -421,13 +423,18 @@ class BitchatMessage: Codable {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
func clearCachedFormattedText() {
_cachedFormattedText.removeAll()
}
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
case isMiningPow
}
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil, isMiningPow: Bool? = nil) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
@@ -439,6 +446,7 @@ class BitchatMessage: Codable {
self.senderPeerID = senderPeerID
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
self.isMiningPow = isMiningPow
}
}
@@ -455,7 +463,8 @@ extension BitchatMessage: Equatable {
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
lhs.deliveryStatus == rhs.deliveryStatus &&
(lhs.isMiningPow ?? false) == (rhs.isMiningPow ?? false)
}
}
+24
View File
@@ -87,4 +87,28 @@ enum Geohash {
let lon = (lonInterval.0 + lonInterval.1) / 2
return (lat, lon)
}
/// Decodes a geohash into its latitude and longitude bounds.
/// - Parameter geohash: Base32 geohash string.
/// - Returns: (latMin, latMax, lonMin, lonMax)
static func decodeBounds(_ geohash: String) -> (latMin: Double, latMax: Double, lonMin: Double, lonMax: 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()
}
}
return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1)
}
}
+4 -3
View File
@@ -512,7 +512,7 @@ final class BLEService: NSObject {
func isPeerConnected(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = {
if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
if peerID.count == 64, let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key)
}
return peerID
@@ -523,7 +523,7 @@ final class BLEService: NSObject {
func isPeerReachable(_ peerID: String) -> Bool {
// Accept both 16-hex short IDs and 64-hex Noise keys
let shortID: String = {
if PeerIDResolver.isNoiseKeyHex(peerID), let key = Data(hexString: peerID) {
if peerID.count == 64, let key = Data(hexString: peerID) {
return PeerIDUtils.derivePeerID(fromPublicKey: key)
}
return peerID
@@ -664,7 +664,8 @@ final class BLEService: NSObject {
return collectionsQueue.sync {
if let publicKey = peers[peerID]?.noisePublicKey {
// Use the same fingerprinting method as NoiseEncryptionService/UnifiedPeerService (SHA-256 of raw key)
return publicKey.sha256Fingerprint()
let hash = SHA256.hash(data: publicKey)
return hash.map { String(format: "%02x", $0) }.joined()
}
return nil
}
@@ -181,7 +181,7 @@ class FavoritesPersistenceService: ObservableObject {
/// Falls back to scanning favorites and matching on derived peer ID.
func getFavoriteStatus(forPeerID peerID: String) -> FavoriteRelationship? {
// Quick sanity: peerID should be 16 hex chars (8 bytes)
guard PeerIDResolver.isShortID(peerID) else { return nil }
guard peerID.count == 16 else { return nil }
for (pubkey, rel) in favorites {
let derived = PeerIDUtils.derivePeerID(fromPublicKey: pubkey)
if derived == peerID { return rel }
@@ -0,0 +1,227 @@
import Foundation
import Combine
#if os(iOS) || os(macOS)
import CoreLocation
#endif
/// Stores a user-maintained list of bookmarked geohash channels.
/// - Persistence: UserDefaults (JSON string array)
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
final class GeohashBookmarksStore: ObservableObject {
static let shared = GeohashBookmarksStore()
@Published private(set) var bookmarks: [String] = []
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
private let storeKey = "locationChannel.bookmarks"
private let namesStoreKey = "locationChannel.bookmarkNames"
private var membership: Set<String> = []
#if os(iOS) || os(macOS)
private let geocoder = CLGeocoder()
private var resolving: Set<String> = []
#endif
private init() {
load()
}
// MARK: - Public API
func isBookmarked(_ geohash: String) -> Bool {
return membership.contains(Self.normalize(geohash))
}
func toggle(_ geohash: String) {
let gh = Self.normalize(geohash)
if membership.contains(gh) {
remove(gh)
} else {
add(gh)
}
}
func add(_ geohash: String) {
let gh = Self.normalize(geohash)
guard !gh.isEmpty else { return }
guard !membership.contains(gh) else { return }
bookmarks.insert(gh, at: 0)
membership.insert(gh)
persist()
// Resolve and persist a friendly name once when added
resolveNameIfNeeded(for: gh)
}
func remove(_ geohash: String) {
let gh = Self.normalize(geohash)
guard membership.contains(gh) else { return }
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
membership.remove(gh)
// Clean up stored name to avoid stale cache growth
if bookmarkNames.removeValue(forKey: gh) != nil {
persistNames()
}
persist()
}
// MARK: - Persistence
private func load() {
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
if let arr = try? JSONDecoder().decode([String].self, from: data) {
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
var seen = Set<String>()
var list: [String] = []
for raw in arr {
let gh = Self.normalize(raw)
guard !gh.isEmpty else { continue }
if !seen.contains(gh) {
seen.insert(gh)
list.append(gh)
}
}
bookmarks = list
membership = seen
}
// Load any saved names
if let namesData = UserDefaults.standard.data(forKey: namesStoreKey),
let dict = try? JSONDecoder().decode([String: String].self, from: namesData) {
bookmarkNames = dict
}
}
private func persist() {
if let data = try? JSONEncoder().encode(bookmarks) {
UserDefaults.standard.set(data, forKey: storeKey)
}
}
private func persistNames() {
if let data = try? JSONEncoder().encode(bookmarkNames) {
UserDefaults.standard.set(data, forKey: namesStoreKey)
}
}
// MARK: - Helpers
private static func normalize(_ s: String) -> String {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
return s
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased()
.replacingOccurrences(of: "#", with: "")
.filter { allowed.contains($0) }
}
// MARK: - Name Resolution
/// Attempt to resolve and persist a friendly place name for a bookmarked geohash.
func resolveNameIfNeeded(for geohash: String) {
let gh = Self.normalize(geohash)
guard !gh.isEmpty else { return }
if bookmarkNames[gh] != nil { return }
#if os(iOS) || os(macOS)
if resolving.contains(gh) { return }
resolving.insert(gh)
// For very coarse geohashes, sample multiple points to capture multiple admin areas
if gh.count <= 2 {
let b = Geohash.decodeBounds(gh)
let pts: [CLLocation] = [
CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center
CLLocation(latitude: b.latMin, longitude: b.lonMin),
CLLocation(latitude: b.latMin, longitude: b.lonMax),
CLLocation(latitude: b.latMax, longitude: b.lonMin),
CLLocation(latitude: b.latMax, longitude: b.lonMax)
]
resolveCompositeAdminName(geohash: gh, points: pts)
} else {
let center = Geohash.decodeCenter(gh)
let loc = CLLocation(latitude: center.lat, longitude: center.lon)
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard let self = self else { return }
defer { self.resolving.remove(gh) }
if let pm = placemarks?.first {
let name = Self.nameForGeohashLength(gh.count, from: pm)
if let name = name, !name.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = name
self.persistNames()
}
}
}
}
}
#endif
}
#if os(iOS) || os(macOS)
private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) {
var uniqueAdmins = OrderedSet<String>()
var idx = 0
func step() {
if idx >= points.count {
// Compose up to 2 names joined by ' and '
let finalName: String? = {
let names = uniqueAdmins.array
if names.count >= 2 { return names[0] + " and " + names[1] }
return names.first
}()
if let finalName = finalName, !finalName.isEmpty {
DispatchQueue.main.async {
self.bookmarkNames[gh] = finalName
self.persistNames()
}
}
self.resolving.remove(gh)
return
}
let loc = points[idx]
idx += 1
geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in
guard self != nil else { return }
if let pm = placemarks?.first {
if let admin = pm.administrativeArea, !admin.isEmpty {
uniqueAdmins.insert(admin)
} else if let country = pm.country, !country.isEmpty {
uniqueAdmins.insert(country)
}
}
// Proceed to next point
step()
}
}
step()
}
// Minimal ordered-set for stable joining
private struct OrderedSet<Element: Hashable> {
private var set: Set<Element> = []
private(set) var array: [Element] = []
mutating func insert(_ element: Element) {
if set.insert(element).inserted { array.append(element) }
}
}
private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? {
switch len {
case 0...2:
// Prefer administrative area if available at this coarse level
return pm.administrativeArea ?? pm.country
case 3...4:
return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country
case 5:
return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea
case 6...7:
return pm.subLocality ?? pm.locality ?? pm.administrativeArea
default:
return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country
}
}
#endif
#if DEBUG
/// Testing-only reset helper
func _resetForTesting() {
bookmarks.removeAll()
membership.removeAll()
bookmarkNames.removeAll()
persist()
persistNames()
}
#endif
}
+41 -9
View File
@@ -50,10 +50,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
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)
}
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
// This avoids showing teleported for in-region channels during cold start.
let status: CLAuthorizationStatus
if #available(iOS 14.0, macOS 11.0, *) {
status = cl.authorizationStatus
@@ -61,6 +59,15 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
status = CLLocationManager.authorizationStatus()
}
updatePermissionState(from: status)
// If we don't have location authorization at startup, fall back to persisted teleport state
switch status {
case .authorizedAlways, .authorizedWhenInUse, .authorized:
break // will compute from location
default:
if case .location(let ch) = selectedChannel {
teleported = teleportedSet.contains(ch.geohash)
}
}
}
// MARK: - Public API
@@ -129,7 +136,21 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
case .mesh:
self.teleported = false
case .location(let ch):
self.teleported = self.teleportedSet.contains(ch.geohash)
// If this geohash is in our current regional set, do NOT mark teleported.
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
if inRegional {
self.teleported = false
// Clear persisted teleport for this geohash to keep future selections clean
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
}
}
} else {
// Fall back to persisted mark (set by deep link or manual teleport)
self.teleported = self.teleportedSet.contains(ch.geohash)
}
}
}
}
@@ -202,14 +223,25 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
}
Task { @MainActor in
self.availableChannels = result
// Recompute teleported status based on persisted state OR current location vs selected channel
// Recompute teleported status based on whether the selected geohash is in our regional set
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)
// Membership check using freshly computed regional channels; avoids precision/rename drift
let inRegional = result.contains { $0.geohash == ch.geohash }
if inRegional {
self.teleported = false
// Clear persisted teleport flag if present
if self.teleportedSet.contains(ch.geohash) {
self.teleportedSet.remove(ch.geohash)
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
}
}
} else {
self.teleported = true
}
}
}
}
+4 -3
View File
@@ -4,12 +4,13 @@ import Foundation
@MainActor
final class MessageRouter {
private let mesh: Transport
private let nostr: NostrOutboxService
private let nostr: NostrTransport
private var outbox: [String: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
init(mesh: Transport, nostr: NostrOutboxService) {
init(mesh: Transport, nostr: NostrTransport) {
self.mesh = mesh
self.nostr = nostr
self.nostr.senderPeerID = mesh.myPeerID
// Observe favorites changes to learn Nostr mapping and flush queued messages
NotificationCenter.default.addObserver(
@@ -45,7 +46,7 @@ final class MessageRouter {
} else if canSendViaNostr(peerID: peerID) {
SecureLogger.log("Routing PM via Nostr to \(peerID.prefix(8))… id=\(messageID.prefix(8))",
category: SecureLogger.session, level: .debug)
nostr.sendPrivate(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
nostr.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else {
// Queue for later (when mesh connects or Nostr mapping appears)
if outbox[peerID] == nil { outbox[peerID] = [] }
@@ -548,7 +548,8 @@ class NoiseEncryptionService {
}
private func calculateFingerprint(for publicKey: Curve25519.KeyAgreement.PublicKey) -> String {
return publicKey.rawRepresentation.sha256Fingerprint()
let hash = SHA256.hash(data: publicKey.rawRepresentation)
return hash.map { String(format: "%02x", $0) }.joined()
}
// MARK: - Session Maintenance
-90
View File
@@ -1,90 +0,0 @@
//
// NostrInboxService.swift
// bitchat
//
// Centralizes Nostr subscribe/unsubscribe and event de-duplication for inbox flows.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@MainActor
final class NostrInboxService {
private var processedIDs: Set<String> = []
private var order: [String] = []
private let cap: Int
init(capacity: Int = TransportConfig.uiProcessedNostrEventsCap) {
self.cap = capacity
}
private func track(_ id: String) -> Bool {
if processedIDs.contains(id) { return false }
processedIDs.insert(id)
order.append(id)
if order.count > cap {
let overflow = order.count - cap
for _ in 0..<overflow {
if let oldest = order.first {
order.removeFirst()
processedIDs.remove(oldest)
}
}
}
return true
}
func reset() {
processedIDs.removeAll()
order.removeAll()
}
// Subscribe to public geohash ephemeral events (kind 20000)
@discardableResult
func subscribeGeohashEphemeral(
geohash: String,
lookbackSeconds: TimeInterval,
limit: Int,
relayCount: Int,
subID: String? = nil,
handler: @escaping (NostrEvent) -> Void
) -> String {
let id = subID ?? "geo-\(geohash)"
let filter = NostrFilter.geohashEphemeral(
geohash,
since: Date().addingTimeInterval(-lookbackSeconds),
limit: limit
)
let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: relayCount)
NostrRelayManager.shared.subscribe(filter: filter, id: id, relayUrls: relays) { [weak self] event in
guard let self = self else { return }
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
guard self.track(event.id) else { return }
handler(event)
}
return id
}
// Subscribe to gift wraps (DMs) for a given pubkey
@discardableResult
func subscribeGiftWrapsFor(
pubkeyHex: String,
lookbackSeconds: TimeInterval,
subID: String? = nil,
handler: @escaping (NostrEvent) -> Void
) -> String {
let id = subID ?? "dm-\(pubkeyHex.prefix(8))"
let filter = NostrFilter.giftWrapsFor(pubkey: pubkeyHex, since: Date().addingTimeInterval(-lookbackSeconds))
NostrRelayManager.shared.subscribe(filter: filter, id: id) { [weak self] event in
guard let self = self else { return }
guard self.track(event.id) else { return }
handler(event)
}
return id
}
func unsubscribe(id: String) {
NostrRelayManager.shared.unsubscribe(id: id)
}
}
-50
View File
@@ -1,50 +0,0 @@
//
// NostrOutboxService.swift
// bitchat
//
// Centralized Nostr outbox for sending PMs and ACKs.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
@MainActor
final class NostrOutboxService {
private let transport: NostrTransport
init(meshService: Transport) {
let t = NostrTransport()
t.senderPeerID = meshService.myPeerID
self.transport = t
}
func sendGeohashPM(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
transport.sendPrivateMessageGeohash(content: content, toRecipientHex: recipientHex, from: identity, messageID: messageID)
}
func sendGeohashDeliveredAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
transport.sendDeliveryAckGeohash(for: messageID, toRecipientHex: recipientHex, from: identity)
}
func sendGeohashReadAck(messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
transport.sendReadReceiptGeohash(messageID, toRecipientHex: recipientHex, from: identity)
}
// MARK: - Generic outbox via favorites mapping
func sendPrivate(_ content: String, to peerID: String, recipientNickname: String, messageID: String) {
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: String) {
transport.sendReadReceipt(receipt, to: peerID)
}
func sendDeliveryAck(for messageID: String, to peerID: String) {
transport.sendDeliveryAck(for: messageID, to: peerID)
}
func sendFavoriteNotification(to peerID: String, isFavorite: Bool) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
}
}
+1 -1
View File
@@ -167,7 +167,7 @@ final class NostrTransport: Transport {
let npub = fav.peerNostrPublicKey {
return npub
}
if PeerIDResolver.isShortID(peerID),
if peerID.count == 16,
let fav = FavoritesPersistenceService.shared.getFavoriteStatus(forPeerID: peerID),
let npub = fav.peerNostrPublicKey {
return npub
+17
View File
@@ -0,0 +1,17 @@
import Foundation
/// Simple default policy for NIP-13 difficulty on geohash chats.
/// Progressive by geohash precision; adjustable later by live activity.
enum PowPolicy {
/// Determine target bits for a given geohash string.
/// Clamped to a sane range to keep UX responsive on phones.
static func requiredBits(forGeohash geohash: String) -> Int {
let precision = geohash.count
// Start at 13 and go down with higher precision (smaller areas)
switch precision {
case ...5: return 16
case 6: return 15
default: return 14
}
}
}
+6 -7
View File
@@ -16,15 +16,14 @@ class PrivateChatManager: ObservableObject {
@Published var unreadMessages: Set<String> = []
private var selectedPeerFingerprint: String? = nil
private var readReceiptTracker: ReadReceiptTracker?
var sentReadReceipts: Set<String> = [] // Made accessible for ChatViewModel
weak var meshService: Transport?
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
weak var messageRouter: MessageRouter?
init(meshService: Transport? = nil, readReceiptTracker: ReadReceiptTracker? = nil) {
init(meshService: Transport? = nil) {
self.meshService = meshService
self.readReceiptTracker = readReceiptTracker
}
// Cap for messages stored per private chat
@@ -122,7 +121,7 @@ class PrivateChatManager: ObservableObject {
unreadMessages.insert(senderPeerID)
// Avoid notifying for messages already marked as read (dup/resubscribe cases)
if !(readReceiptTracker?.contains(message.id) ?? false) {
if !sentReadReceipts.contains(message.id) {
NotificationService.shared.sendPrivateMessageNotification(
from: message.sender,
message: message.content,
@@ -161,7 +160,7 @@ class PrivateChatManager: ObservableObject {
// Send read receipts for unread messages that haven't been sent yet
if let messages = privateChats[peerID] {
for message in messages {
if message.senderPeerID == peerID && !message.isRelay && !(readReceiptTracker?.contains(message.id) ?? false) {
if message.senderPeerID == peerID && !message.isRelay && !sentReadReceipts.contains(message.id) {
sendReadReceipt(for: message)
}
}
@@ -213,12 +212,12 @@ class PrivateChatManager: ObservableObject {
// MARK: - Private Methods
private func sendReadReceipt(for message: BitchatMessage) {
guard !(readReceiptTracker?.contains(message.id) ?? false),
guard !sentReadReceipts.contains(message.id),
let senderPeerID = message.senderPeerID else {
return
}
readReceiptTracker?.insert(message.id)
sentReadReceipts.insert(message.id)
// Create read receipt using the simplified method
let receipt = ReadReceipt(
-77
View File
@@ -1,77 +0,0 @@
//
// ReadReceiptTracker.swift
// bitchat
//
// Centralized tracker for sent read receipts with simple persistence.
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
final class ReadReceiptTracker {
private let defaults: UserDefaults
private let key = "sentReadReceipts"
private let queue = DispatchQueue(label: "chat.bitchat.readreceipts", attributes: .concurrent)
private var set: Set<String> = []
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if let data = defaults.data(forKey: key),
let arr = try? JSONDecoder().decode([String].self, from: data) {
self.set = Set(arr)
}
}
func contains(_ id: String) -> Bool {
queue.sync { set.contains(id) }
}
func insert(_ id: String) {
queue.async(flags: .barrier) {
if self.set.insert(id).inserted { self.persist() }
}
}
func insert<S: Sequence>(_ ids: S) where S.Element == String {
queue.async(flags: .barrier) {
var changed = false
for id in ids { changed = self.set.insert(id).inserted || changed }
if changed { self.persist() }
}
}
func remove(_ id: String) {
queue.async(flags: .barrier) {
if self.set.remove(id) != nil { self.persist() }
}
}
func removeAll() {
queue.async(flags: .barrier) {
if !self.set.isEmpty { self.set.removeAll(); self.persist() }
}
}
/// Keep only IDs present in the allow-list; useful for pruning stale entries.
func prune(toAllowedIDs allowed: Set<String>) {
queue.async(flags: .barrier) {
let newSet = self.set.intersection(allowed)
if newSet.count != self.set.count { self.set = newSet; self.persist() }
}
}
/// Snapshot current set for read-only operations (avoid long-lived copies in hot paths)
func snapshot() -> Set<String> { queue.sync { set } }
private func persist() {
let arr = Array(set)
if let data = try? JSONEncoder().encode(arr) {
defaults.set(data, forKey: key)
} else {
SecureLogger.log("❌ Failed to encode read receipts for persistence",
category: SecureLogger.session, level: .error)
}
}
}
+7 -1
View File
@@ -412,4 +412,10 @@ class UnifiedPeerService: ObservableObject, TransportPeerEventsDelegate {
// MARK: - Helper Extensions
// Moved sha256Fingerprint() to BinaryEncodingUtils for reuse
extension Data {
func sha256Fingerprint() -> String {
// Implementation matches existing fingerprint generation in NoiseEncryptionService
let hash = SHA256.hash(data: self)
return hash.map { String(format: "%02x", $0) }.joined()
}
}
File diff suppressed because it is too large Load Diff
+93 -8
View File
@@ -24,6 +24,7 @@ struct ContentView: View {
@EnvironmentObject var viewModel: ChatViewModel
@ObservedObject private var locationManager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@State private var messageText = ""
@State private var textFieldSelection: NSRange? = nil
@FocusState private var isTextFieldFocused: Bool
@@ -164,6 +165,8 @@ struct ContentView: View {
}
.sheet(isPresented: $showAppInfo) {
AppInfoView()
.onAppear { viewModel.isAppInfoPresented = true }
.onDisappear { viewModel.isAppInfoPresented = false }
}
.sheet(isPresented: Binding(
get: { viewModel.showingFingerprintFor != nil },
@@ -278,8 +281,10 @@ struct ContentView: View {
}
}()
let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) }
// Filter out empty/whitespace-only messages to avoid blank rows
let filteredItems = items.filter { !$0.message.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
ForEach(items, id: \.uiID) { item in
ForEach(filteredItems, id: \.uiID) { item in
let message = item.message
VStack(alignment: .leading, spacing: 0) {
// Check if current user is mentioned
@@ -298,10 +303,21 @@ struct ContentView: View {
HStack(alignment: .top, spacing: 0) {
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true)
let formatted = viewModel.formatMessageAsText(message, colorScheme: colorScheme)
let prefixAttr = viewModel.formatSenderPrefix(message, colorScheme: colorScheme)
let baseColor = viewModel.baseColorForMessage(message, colorScheme: colorScheme)
let isSelfMsg = viewModel.isSelfMessage(message)
if (message.isMiningPow ?? false) {
// Single Text combines prefix + animated body, so wrapping matches final layout
CombinedMiningText(prefix: prefixAttr, original: message.content, color: baseColor, isSelf: isSelfMsg)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading)
} else {
Text(formatted)
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading)
}
// Delivery status indicator for private messages
if message.isPrivate && message.sender == viewModel.nickname,
@@ -459,7 +475,13 @@ struct ContentView: View {
}
let level = levelForLength(gh.count)
let ch = GeohashChannel(level: level, geohash: gh)
LocationChannelManager.shared.markTeleported(for: gh, true)
// Do not mark teleported when opening a geohash that is in our regional set.
// If availableChannels is empty (e.g., cold start), defer marking and let
// LocationChannelManager compute teleported based on actual location.
let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == gh }
if !inRegional && !LocationChannelManager.shared.availableChannels.isEmpty {
LocationChannelManager.shared.markTeleported(for: gh, true)
}
LocationChannelManager.shared.select(ChannelID.location(ch))
}
.onTapGesture(count: 3) {
@@ -1112,6 +1134,15 @@ struct ContentView: View {
.buttonStyle(.plain)
.accessibilityLabel("Open unread private chat")
}
// Bookmark toggle for current geohash (not shown for mesh)
if case .location(let ch) = locationManager.selectedChannel {
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
.font(.system(size: 12))
}
.buttonStyle(.plain)
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
}
// Location channels button '#'
Button(action: { showLocationChannelsSheet = true }) {
let badgeText: String = {
@@ -1166,6 +1197,13 @@ struct ContentView: View {
.padding(.horizontal, 12)
.sheet(isPresented: $showLocationChannelsSheet) {
LocationChannelsSheet(isPresented: $showLocationChannelsSheet)
.onAppear { viewModel.isLocationChannelsSheetPresented = true }
.onDisappear { viewModel.isLocationChannelsSheetPresented = false }
}
.alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) {
Button("ok", role: .cancel) {}
} message: {
Text("screenshots of location channels will reveal your location. think before sharing publicly.")
}
.background(backgroundColor.opacity(0.95))
}
@@ -1182,7 +1220,7 @@ struct ContentView: View {
private func privateHeaderContent(for privatePeerID: String) -> some View {
// Prefer short (mesh) ID whenever available for encryption/session status; keep stable key for display resolution only.
let headerPeerID: String = {
if PeerIDResolver.isNoiseKeyHex(privatePeerID) {
if privatePeerID.count == 64 {
// Map stable Noise key to short ID if we know it (even if not directly connected)
if let short = viewModel.getShortIDForNoiseKey(privatePeerID) { return short }
}
@@ -1207,14 +1245,14 @@ struct ContentView: View {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data()),
!fav.peerNickname.isEmpty { return fav.peerNickname }
// Fallback: resolve from persisted social identity via fingerprint mapping
if PeerIDResolver.isShortID(headerPeerID) {
if headerPeerID.count == 16 {
let candidates = SecureIdentityStateManager.shared.getCryptoIdentitiesByPeerIDPrefix(headerPeerID)
if let id = candidates.first,
let social = SecureIdentityStateManager.shared.getSocialIdentity(for: id.fingerprint) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
if !social.claimedNickname.isEmpty { return social.claimedNickname }
}
} else if PeerIDResolver.isNoiseKeyHex(headerPeerID), let keyData = Data(hexString: headerPeerID) {
} else if headerPeerID.count == 64, let keyData = Data(hexString: headerPeerID) {
let fp = keyData.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
if let pet = social.localPetname, !pet.isEmpty { return pet }
@@ -1298,7 +1336,7 @@ struct ContentView: View {
if !privatePeerID.hasPrefix("nostr_") {
// Use short peer ID if available for encryption status (sessions keyed by short ID)
let statusPeerID: String = {
if PeerIDResolver.isNoiseKeyHex(privatePeerID), let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
if privatePeerID.count == 64, let short = viewModel.getShortIDForNoiseKey(privatePeerID) {
return short
}
return headerPeerID
@@ -1360,6 +1398,53 @@ struct ContentView: View {
}
// MARK: - Mining UI helpers (local to ensure inclusion in target)
fileprivate struct CombinedMiningText: View {
let prefix: AttributedString
let original: String
let color: Color
let isSelf: Bool
let fontSize: CGFloat = 14
let interval: TimeInterval = 0.06
@State private var display: String = ""
@State private var timer: Timer? = nil
private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-")
var body: some View {
Text(combinedAttributed)
.fixedSize(horizontal: false, vertical: true)
.onAppear {
display = scramble(original)
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
display = scramble(original)
}
}
.onDisappear { timer?.invalidate(); timer = nil }
}
private var combinedAttributed: AttributedString {
var res = AttributedString()
res.append(prefix)
var style = AttributeContainer()
style.foregroundColor = color
style.font = .system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced)
res.append(AttributedString(display).mergingAttributes(style))
return res
}
private func scramble(_ s: String) -> String {
var out = String()
out.reserveCapacity(s.count)
for ch in s {
if ch.isWhitespace || ch.isNewline { out.append(ch); continue }
out.append(charset.randomElement() ?? ch)
}
return out
}
}
// MARK: - Helper Views
// Rounded payment chip button
+2 -2
View File
@@ -43,14 +43,14 @@ struct FingerprintView: View {
VStack(alignment: .leading, spacing: 16) {
// Prefer short mesh ID for session/encryption status
let statusPeerID: String = {
if PeerIDResolver.isNoiseKeyHex(peerID), let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
if peerID.count == 64, let short = viewModel.getShortIDForNoiseKey(peerID) { return short }
return peerID
}()
// Resolve a friendly name
let peerNickname: String = {
if let p = viewModel.getPeer(byID: statusPeerID) { return p.displayName }
if let name = viewModel.meshService.peerNickname(peerID: statusPeerID) { return name }
if PeerIDResolver.isNoiseKeyHex(peerID), let data = Data(hexString: peerID) {
if peerID.count == 64, let data = Data(hexString: peerID) {
if let fav = FavoritesPersistenceService.shared.getFavoriteStatus(for: data), !fav.peerNickname.isEmpty { return fav.peerNickname }
let fp = data.sha256Fingerprint()
if let social = SecureIdentityStateManager.shared.getSocialIdentity(for: fp) {
+98 -27
View File
@@ -8,6 +8,7 @@ import AppKit
struct LocationChannelsSheet: View {
@Binding var isPresented: Bool
@ObservedObject private var manager = LocationChannelManager.shared
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
@EnvironmentObject var viewModel: ChatViewModel
@Environment(\.colorScheme) var colorScheme
@State private var customGeohash: String = ""
@@ -98,7 +99,7 @@ struct LocationChannelsSheet: View {
private var channelList: some View {
List {
// Mesh option first
// Mesh option first (no bookmark)
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
manager.select(ChannelID.mesh)
isPresented = false
@@ -112,7 +113,21 @@ 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, isSelected: isSelected(channel), titleBold: highlight) {
channelRow(
title: geohashTitleWithCount(for: channel),
subtitlePrefix: subtitlePrefix,
subtitleName: namePart,
isSelected: isSelected(channel),
titleBold: highlight,
trailingAccessory: {
Button(action: { bookmarks.toggle(channel.geohash) }) {
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
) {
// Selecting a suggested nearby channel is not a teleport. Persist this.
manager.markTeleported(for: channel.geohash, false)
manager.select(ChannelID.location(channel))
@@ -188,6 +203,48 @@ struct LocationChannelsSheet: View {
}
}
// Bookmarked geohashes
if !bookmarks.bookmarks.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text("bookmarked")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
}
.listRowSeparator(.hidden)
ForEach(bookmarks.bookmarks, id: \.self) { gh in
let level = levelForLength(gh.count)
let channel = GeohashChannel(level: level, geohash: gh)
let coverage = coverageString(forPrecision: gh.count)
let subtitle = "#\(gh)\(coverage)"
let name = bookmarks.bookmarkNames[gh]
channelRow(
title: geohashHashTitleWithCount(gh),
subtitlePrefix: subtitle,
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
isSelected: isSelected(channel),
trailingAccessory: {
Button(action: { bookmarks.toggle(gh) }) {
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
.font(.system(size: 14))
}
.buttonStyle(.plain)
.padding(.leading, 8)
}
) {
// For bookmarked selection, mark teleported based on regional membership
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
if !inRegional && !manager.availableChannels.isEmpty {
manager.markTeleported(for: gh, true)
} else {
manager.markTeleported(for: gh, false)
}
manager.select(ChannelID.location(channel))
isPresented = false
}
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
}
}
// Footer action inside the list
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
Button(action: {
@@ -220,14 +277,24 @@ struct LocationChannelsSheet: View {
return false
}
private func channelRow(title: String, subtitlePrefix: String, subtitleName: String? = nil, subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, 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)
@ViewBuilder
private func channelRow(
title: String,
subtitlePrefix: String,
subtitleName: String? = nil,
subtitleNameBold: Bool = false,
isSelected: Bool,
titleColor: Color? = nil,
titleBold: Bool = false,
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
action: @escaping () -> Void
) -> some View {
HStack(alignment: .center, spacing: 8) {
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))
.fontWeight(titleBold ? .bold : .regular)
.foregroundColor(titleColor ?? Color.primary)
@@ -237,20 +304,17 @@ struct LocationChannelsSheet: View {
.foregroundColor(.secondary)
}
}
HStack(spacing: 0) {
Text(subtitlePrefix)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
if let name = subtitleName {
Text("")
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
Text(name)
.font(.system(size: 12, design: .monospaced))
.fontWeight(subtitleNameBold ? .bold : .regular)
.foregroundColor(.secondary)
}
let subtitleFull: String = {
if let name = subtitleName, !name.isEmpty {
return subtitlePrefix + "" + name
}
return subtitlePrefix
}()
Text(subtitleFull)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer()
if isSelected {
@@ -258,11 +322,11 @@ struct LocationChannelsSheet: View {
.font(.system(size: 16, design: .monospaced))
.foregroundColor(standardGreen)
}
trailingAccessory()
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.onTapGesture(perform: action)
}
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
@@ -291,12 +355,19 @@ struct LocationChannelsSheet: View {
}
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
// Use ViewModel's 5-minute activity counts; may be 0 for non-selected channels
// Main list: keep level labels (block/neighborhood/city/province/region)
let count = viewModel.geohashParticipantCount(for: channel.geohash)
let noun = count == 1 ? "person" : "people"
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
}
private func geohashHashTitleWithCount(_ geohash: String) -> String {
// Bookmarked list: show the #geohash as the main label
let count = viewModel.geohashParticipantCount(for: geohash)
let noun = count == 1 ? "person" : "people"
return "#\(geohash) [\(count) \(noun)]"
}
private func validateGeohash(_ s: String) -> Bool {
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
guard !s.isEmpty, s.count <= 12 else { return false }
+87
View File
@@ -0,0 +1,87 @@
import SwiftUI
/// Mining body animation: scramble characters (matrix-like) until PoW completes.
struct MiningScrambleText: View {
let original: String
let color: Color
let isSelf: Bool
let fontSize: CGFloat = 14
let interval: TimeInterval = 0.06
@State private var display: String = ""
@State private var timer: Timer? = nil
private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-")
var body: some View {
Text(display)
.font(.system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced))
.foregroundColor(color)
.onAppear {
display = scramble(original)
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
display = scramble(original)
}
}
.onDisappear {
timer?.invalidate(); timer = nil
}
}
/// Combined prefix+body single-Text variant to ensure identical wrapping
struct MiningCombinedText: View {
let prefix: AttributedString
let original: String
let color: Color
let isSelf: Bool
let fontSize: CGFloat = 14
let interval: TimeInterval = 0.06
@State private var display: String = ""
@State private var timer: Timer? = nil
private let charset: [Character] = Array("abcdefghijklmnopqrstuvwxyz0123456789@#$%&*+-")
var body: some View {
Text(combinedAttributed)
.fixedSize(horizontal: false, vertical: true)
.onAppear {
display = scramble(original)
timer?.invalidate()
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { _ in
display = scramble(original)
}
}
.onDisappear { timer?.invalidate(); timer = nil }
}
private var combinedAttributed: AttributedString {
var res = AttributedString()
res.append(prefix)
var style = AttributeContainer()
style.foregroundColor = color
style.font = .system(size: fontSize, weight: isSelf ? .bold : .regular, design: .monospaced)
res.append(AttributedString(display).mergingAttributes(style))
return res
}
private func scramble(_ s: String) -> String {
var out = String()
out.reserveCapacity(s.count)
for ch in s {
if ch.isWhitespace || ch.isNewline { out.append(ch); continue }
out.append(charset.randomElement() ?? ch)
}
return out
}
}
private func scramble(_ s: String) -> String {
var out = String()
out.reserveCapacity(s.count)
for ch in s {
if ch.isWhitespace || ch.isNewline { out.append(ch); continue }
out.append(charset.randomElement() ?? ch)
}
return out
}
}
@@ -0,0 +1,51 @@
import XCTest
@testable import bitchat
final class GeohashBookmarksStoreTests: XCTestCase {
let storeKey = "locationChannel.bookmarks"
override func setUp() {
super.setUp()
// Clear persisted state before each test
UserDefaults.standard.removeObject(forKey: storeKey)
GeohashBookmarksStore.shared._resetForTesting()
}
override func tearDown() {
// Clean after each test
UserDefaults.standard.removeObject(forKey: storeKey)
GeohashBookmarksStore.shared._resetForTesting()
super.tearDown()
}
func testToggleAndNormalize() {
let store = GeohashBookmarksStore.shared
// Start clean
XCTAssertTrue(store.bookmarks.isEmpty)
// Add with mixed case and hash prefix
store.toggle("#U4PRUY")
XCTAssertTrue(store.isBookmarked("u4pruy"))
XCTAssertEqual(store.bookmarks.first, "u4pruy")
// Toggling again removes
store.toggle("u4pruy")
XCTAssertFalse(store.isBookmarked("u4pruy"))
XCTAssertTrue(store.bookmarks.isEmpty)
}
func testPersistenceWritten() throws {
let store = GeohashBookmarksStore.shared
store.toggle("ezs42")
store.toggle("u4pruy")
// Verify persisted JSON contains both (order not enforced here)
guard let data = UserDefaults.standard.data(forKey: storeKey) else {
XCTFail("No persisted data found")
return
}
let arr = try JSONDecoder().decode([String].self, from: data)
XCTAssertTrue(arr.contains("ezs42"))
XCTAssertTrue(arr.contains("u4pruy"))
}
}
+12 -12
View File
@@ -2,17 +2,17 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
</dict>
</plist>
+32
View File
@@ -0,0 +1,32 @@
import XCTest
import CryptoKit
@testable import bitchat
final class NostrPoWTests: XCTestCase {
func testLeadingZeroBits() {
// 0x00 -> 8, 0x00 -> 8, 0xF0 -> 0 leading zeros
let data = Data([0x00, 0x00, 0xF0]) + Data(repeating: 0x00, count: 29)
let lz = NostrPoW.leadingZeroBits(data)
XCTAssertEqual(lz, 16)
}
func testMineLowDifficulty() {
let pubkey = String(repeating: "a", count: 64)
let createdAt = Int(Date().timeIntervalSince1970)
let kind = 20000
let tags = [["g", "u4pruydqqvj"]]
let content = "hello"
let targetBits = 8 // keep it very low for test speed
let (nonce, idHex) = NostrPoW.mine(pubkey: pubkey, createdAt: createdAt, kind: kind, baseTags: tags, content: content, targetBits: targetBits)
XCTAssertGreaterThan(nonce, 0)
// Verify difficulty
let powTags = tags + [["nonce", String(nonce), String(targetBits)]]
// Recompute ID
let serialized: [Any] = [0, pubkey, createdAt, kind, powTags, content]
let json = try! JSONSerialization.data(withJSONObject: serialized, options: [.withoutEscapingSlashes])
let digest = CryptoKit.SHA256.hash(data: json)
let idData = Data(digest)
XCTAssertEqual(idHex.count, 64)
XCTAssertGreaterThanOrEqual(NostrPoW.leadingZeroBits(idData), targetBits)
}
}