From 2870acdcc732c04b1f143a3606300c3124e61baf Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Sat, 13 Sep 2025 14:55:04 +0200 Subject: [PATCH] Location Notes (kind 1) at Building Precision + Live Updates (#598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Location notes: Matrix loader with 1–3s minimum display; allow multiple notes; wire notes UI from mesh toolbar * Location notes: prevent duplicate loading/subscriptions (StateObject manager, geohash captured at open, guard subscribe) * Location notes: fix duplicate state redeclaration in ContentView * Location notes: render note bodies with monospaced font in notes list * Location notes: add close (x) button in sheet header (upper-right) using @Environment(\.dismiss) * Location notes: acquire geohash on open (force refresh) and show Matrix loader until block-level geohash resolves; add LocationNotesSheet wrapper * Location notes: inline sheet wrapper to avoid missing file in target; force location refresh and show Matrix loader until block geohash resolves * Location notes: remove Matrix animation; use simple spinner when acquiring location; simplify notes view layout * Location notes: remove per-note relative timestamp from sheet (no seconds display) * Location notes: fix intermittent first load by ensuring resubscribe after geohash change, removing over-eager subscribe guard, and widening note fetch window (no since filter) * Location notes: add background counter service and show count next to notes icon on mesh; auto-subscribe to current block geohash * Location notes: move notes icon to the right of #mesh badge in toolbar * Location notes: restore timestamps in notes list; remove loading state from manager and sheet (no spinner/animation) * Location notes: drop 'teleport' tag from kind-1 events; simplify note builder API and usage * Location notes: show timestamp as relative within 7 days, else absolute date (MMM d or MMM d, y); keep monospaced styling * Location notes: append 'ago' to relative timestamps (within 7 days) * Switch location notes and UI helpers to building-level geohash (precision 8): add GeohashChannelLevel.building, map length 8, use building for notes selection and counter, add building name mapping * Location notes: change notes toolbar icon to SF Symbol 'long.text.page.and.pencil' * Revert notes geohash selection back to block-level (precision 7); keep building level available but unused; update counter and sheet accordingly * Location notes: show block name (from reverse geocode) in header instead of 'street-level notes' * Nostr: add EOSE handling with callback support for subscriptions; wire to LocationNotesManager/Counter (initialLoadComplete). Remove 'building' level from channel enum and geocoder mapping; geohash list shows block/neighborhood/city/province/region only * Fix Swift 6 isolation: hop to @MainActor inside Timer callback for EOSE tracker mutations * Notes counter: show 0 by default; subscribe at building-level (precision 8) for notes and counter; keep building hidden in channel list * Notes header: show building name when available (fallback to block name) * Notes: subscribe counter to building + parent block (merge results); hide notes icon unless location is authorized; replace 10s timer with live location updates while sheet open * Notes counter: subscribe only to building geohash (precision 8) so count reflects current 8-cell; auto-begin live location refresh on mesh (and permission) to update as you walk * Notes header: show count in parentheses; icon shows blue if any notes, grey if none; only start live location updates while sheet is open; reduce nickname width; set live distance filter to 10m * Notes header: show count as '(N note/notes)' with non-bold, secondary styling next to title * Notes header: move count before title as ' note(s) @ #gh'; remove parentheses; keep count non-bold * Notes header: bold count text; ensure sheet updates when building geohash changes by calling manager.setGeohash on geohash change * Notes sheet: add light haptic feedback when building geohash changes (iOS only) * Notes icon: force a one-shot location refresh before (re)subscribing the counter so icon turns blue immediately on current 8-cell * Notes subscribe: fallback to default relays when GeoRelayDirectory has no entries (pass nil relayUrls) so counter/icon turn blue and sheet loads even before georelay fetch * Notes icon: turn blue immediately when sheet shows notes by passing count up from sheet (closure) and OR-ing with counter; reset on sheet close --------- Co-authored-by: jack --- bitchat.xcodeproj/project.pbxproj | 49 +++-- bitchat/Nostr/NostrProtocol.swift | 22 +++ bitchat/Nostr/NostrRelayManager.swift | 51 ++++- bitchat/Protocols/LocationChannel.swift | 6 + bitchat/Services/LocationChannelManager.swift | 8 +- bitchat/Services/LocationNotesCounter.swift | 52 ++++++ bitchat/Services/LocationNotesManager.swift | 98 ++++++++++ bitchat/Services/TransportConfig.swift | 2 +- bitchat/ViewModels/ChatViewModel.swift | 2 +- bitchat/Views/ContentView.swift | 148 ++++++++++++++- bitchat/Views/LocationChannelsSheet.swift | 3 +- bitchat/Views/LocationNotesSheet.swift | 65 +++++++ bitchat/Views/LocationNotesView.swift | 176 ++++++++++++++++++ 13 files changed, 649 insertions(+), 33 deletions(-) create mode 100644 bitchat/Services/LocationNotesCounter.swift create mode 100644 bitchat/Services/LocationNotesManager.swift create mode 100644 bitchat/Views/LocationNotesSheet.swift create mode 100644 bitchat/Views/LocationNotesView.swift diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index d010c13a..74d25e76 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -33,14 +33,18 @@ 0481A3472E6D869F00FC845E /* TorURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3442E6D869F00FC845E /* TorURLSession.swift */; }; 0481A3482E6D869F00FC845E /* TorManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3432E6D869F00FC845E /* TorManager.swift */; }; 0481A3492E6D869F00FC845E /* TorURLSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3442E6D869F00FC845E /* TorURLSession.swift */; }; - 0481A3552E6D877600FC845E /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 0481A3532E6D877600FC845E /* README.md */; }; - 0481A3562E6D877600FC845E /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = 0481A3532E6D877600FC845E /* README.md */; }; 0481A3582E6D929E00FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */; }; 0481A3592E6D929E00FC845E /* tor-nolzma.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */; }; 0481A35B2E6D9BEF00FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35A2E6D9BEF00FC845E /* libz.tbd */; }; 0481A35D2E6DA18600FC845E /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 0481A35C2E6DA18600FC845E /* libz.tbd */; }; 0481A3902E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; }; 0481A3912E734CAE00FC845E /* CommandProcessorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */; }; + 0481A3A92E74D28800FC845E /* LocationNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3A82E74D28800FC845E /* LocationNotesView.swift */; }; + 0481A3AA2E74D28800FC845E /* LocationNotesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3A82E74D28800FC845E /* LocationNotesView.swift */; }; + 0481A3AC2E74D29400FC845E /* LocationNotesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3AB2E74D29400FC845E /* LocationNotesManager.swift */; }; + 0481A3AD2E74D29400FC845E /* LocationNotesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3AB2E74D29400FC845E /* LocationNotesManager.swift */; }; + 0481A3AF2E74E06300FC845E /* LocationNotesCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3AE2E74E06300FC845E /* LocationNotesCounter.swift */; }; + 0481A3B02E74E06300FC845E /* LocationNotesCounter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0481A3AE2E74E06300FC845E /* LocationNotesCounter.swift */; }; 048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; 048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; }; @@ -128,6 +132,10 @@ 9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43B4548DAFC9F7AA8873DA53 /* NoiseSecurityConsiderations.swift */; }; A0A1C26EFBFDD5B8EFEEDE57 /* PublicChatE2ETests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D22BF09A49010947CEFE45E2 /* PublicChatE2ETests.swift */; }; A1B2C3D44E5F60718293A4B5 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; }; + A1B2C3D4E5F60123456789BA /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AA /* MockKeychain.swift */; }; + A1B2C3D4E5F60123456789BB /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AA /* MockKeychain.swift */; }; + A1B2C3D4E5F60123456789BC /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */; }; + A1B2C3D4E5F60123456789BD /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */; }; A1B2C3D54E5F60718293A4B6 /* XChaCha20Poly1305Compat.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */; }; A2977428C1D9EF9944C4BFAF /* BLEServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 980B109CBA72BC996455C62B /* BLEServiceTests.swift */; }; A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43613045E63D21D429396805 /* NoiseProtocol.swift */; }; @@ -173,10 +181,6 @@ F455F011B3B648ADA233F998 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 136696FC4436A02D98CE6A77 /* KeychainManager.swift */; }; FBC409E105493C491531B59A /* NostrProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */; }; - A1B2C3D4E5F60123456789BA /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AA /* MockKeychain.swift */; }; - A1B2C3D4E5F60123456789BB /* MockKeychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AA /* MockKeychain.swift */; }; - A1B2C3D4E5F60123456789BC /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */; }; - A1B2C3D4E5F60123456789BD /* MockIdentityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -232,11 +236,13 @@ 047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = ""; }; 0481A3432E6D869F00FC845E /* TorManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorManager.swift; sourceTree = ""; }; 0481A3442E6D869F00FC845E /* TorURLSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TorURLSession.swift; sourceTree = ""; }; - 0481A3532E6D877600FC845E /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = "tor-nolzma.xcframework"; sourceTree = ""; }; 0481A35A2E6D9BEF00FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; 0481A35C2E6DA18600FC845E /* libz.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.tbd; path = usr/lib/libz.tbd; sourceTree = SDKROOT; }; 0481A38F2E734CAE00FC845E /* CommandProcessorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessorTests.swift; sourceTree = ""; }; + 0481A3A82E74D28800FC845E /* LocationNotesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationNotesView.swift; sourceTree = ""; }; + 0481A3AB2E74D29400FC845E /* LocationNotesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationNotesManager.swift; sourceTree = ""; }; + 0481A3AE2E74E06300FC845E /* LocationNotesCounter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationNotesCounter.swift; sourceTree = ""; }; 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = ""; }; 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = ""; }; 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = ""; }; @@ -289,6 +295,8 @@ 9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = ""; }; A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A1B2C3D44E5F60718293A4B4 /* XChaCha20Poly1305Compat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XChaCha20Poly1305Compat.swift; sourceTree = ""; }; + A1B2C3D4E5F60123456789AA /* MockKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychain.swift; sourceTree = ""; }; + A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIdentityManager.swift; sourceTree = ""; }; A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = ""; }; AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = ""; }; AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VerificationService.swift; sourceTree = ""; }; @@ -311,8 +319,6 @@ FC75901A0F0073B5BB8356E7 /* TestConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = ""; }; FDC18D910D6FF2E8B1B6C885 /* SecureIdentityStateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureIdentityStateManager.swift; sourceTree = ""; }; FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockBLEService.swift; sourceTree = ""; }; - A1B2C3D4E5F60123456789AA /* MockKeychain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockKeychain.swift; sourceTree = ""; }; - A1B2C3D4E5F60123456789AB /* MockIdentityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockIdentityManager.swift; sourceTree = ""; }; FF7AF93D874001FBD94C8306 /* bitchat-macOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "bitchat-macOS.entitlements"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -372,7 +378,6 @@ children = ( 0481A35A2E6D9BEF00FC845E /* libz.tbd */, 0481A35C2E6DA18600FC845E /* libz.tbd */, - 0481A3532E6D877600FC845E /* README.md */, 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */, ); path = Frameworks; @@ -515,6 +520,7 @@ A55126E93155456CAA8D6656 /* Views */ = { isa = PBXGroup; children = ( + 0481A3A82E74D28800FC845E /* LocationNotesView.swift */, AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */, 047502B22E55FED60083520F /* GeohashPeopleList.swift */, 047502B32E55FED60083520F /* MeshPeerList.swift */, @@ -592,6 +598,8 @@ isa = PBXGroup; children = ( 0481A3452E6D869F00FC845E /* Tor */, + 0481A3AE2E74E06300FC845E /* LocationNotesCounter.swift */, + 0481A3AB2E74D29400FC845E /* LocationNotesManager.swift */, 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */, 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */, AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */, @@ -785,7 +793,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 0481A3552E6D877600FC845E /* README.md in Resources */, 7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */, E0A1B2C3D4E5F6012345678D /* relays/online_relays_gps.csv in Resources */, ); @@ -797,7 +804,6 @@ files = ( BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */, E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */, - 0481A3562E6D877600FC845E /* README.md in Resources */, E0A1B2C3D4E5F6012345678E /* relays/online_relays_gps.csv in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -834,6 +840,7 @@ 047502B92E560F690083520F /* RelayController.swift in Sources */, 6E7761E21C99F28AE2F9BE5F /* BitchatApp.swift in Sources */, 84E3F9B64FB7FB4A140BD0A8 /* BitchatPeer.swift in Sources */, + 0481A3B02E74E06300FC845E /* LocationNotesCounter.swift in Sources */, 923027D6F2F417AFA2488127 /* BitchatProtocol.swift in Sources */, D450CF41F207BDE1A1AAA56E /* ChatViewModel.swift in Sources */, B0CA7796B2B2AC2B33F84548 /* CompressionUtil.swift in Sources */, @@ -864,6 +871,8 @@ 049BD3AF2E51ED60001A566B /* Transport.swift in Sources */, E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */, 049BD3A02E51DBF4001A566B /* Packets.swift in Sources */, + 0481A3AD2E74D29400FC845E /* LocationNotesManager.swift in Sources */, + 0481A3AA2E74D28800FC845E /* LocationNotesView.swift in Sources */, 047502892E5416250083520F /* Geohash.swift in Sources */, 0475028A2E5416250083520F /* LocationChannel.swift in Sources */, 049BD3A12E51DBF4001A566B /* PeerID.swift in Sources */, @@ -899,6 +908,7 @@ 047502BA2E560F690083520F /* RelayController.swift in Sources */, 10E68BB889356219189E38EC /* BitchatApp.swift in Sources */, 84D13329AB7EE1D65A37438A /* BitchatPeer.swift in Sources */, + 0481A3AF2E74E06300FC845E /* LocationNotesCounter.swift in Sources */, 6DE056E1EE9850E9FBF50157 /* BitchatProtocol.swift in Sources */, 7576A357B278E5733E9D9F33 /* ChatViewModel.swift in Sources */, 7DCA0DBCB8884E3B31C7BCE3 /* CompressionUtil.swift in Sources */, @@ -929,6 +939,8 @@ 049BD3AE2E51ED60001A566B /* Transport.swift in Sources */, 68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */, 049BD3A22E51DBF4001A566B /* Packets.swift in Sources */, + 0481A3AC2E74D29400FC845E /* LocationNotesManager.swift in Sources */, + 0481A3A92E74D28800FC845E /* LocationNotesView.swift in Sources */, 047502872E5416250083520F /* Geohash.swift in Sources */, 047502882E5416250083520F /* LocationChannel.swift in Sources */, 049BD3A32E51DBF4001A566B /* PeerID.swift in Sources */, @@ -984,7 +996,6 @@ 048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */, 3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */, A1B2C3D4E5F60123456789BA /* MockKeychain.swift in Sources */, - A1B2C3D4E5F60123456789BC /* MockIdentityManager.swift in Sources */, BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */, EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */, @@ -1101,7 +1112,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.4; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -1132,7 +1143,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.4; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -1187,7 +1198,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.4; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -1219,7 +1230,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.3.4; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -1308,7 +1319,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.3.4; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -1401,7 +1412,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.4; + MARKETING_VERSION = 1.4.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 7ca4300a..374feed5 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -123,6 +123,28 @@ struct NostrProtocol { let schnorrKey = try senderIdentity.schnorrSigningKey() return try event.sign(with: schnorrKey) } + + /// Create a persistent location note (kind 1: text note) tagged to a street-level geohash. + static func createGeohashTextNote( + content: String, + geohash: String, + senderIdentity: NostrIdentity, + nickname: String? = nil + ) throws -> NostrEvent { + var tags = [["g", geohash]] + if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty { + tags.append(["n", nickname]) + } + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .textNote, + tags: tags, + content: content + ) + let schnorrKey = try senderIdentity.schnorrSigningKey() + return try event.sign(with: schnorrKey) + } // MARK: - Private Methods diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 04a06deb..8830d684 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -45,6 +45,14 @@ final class NostrRelayManager: ObservableObject { // Coalesce duplicate subscribe requests for the same id within a short window private var subscribeCoalesce: [String: Date] = [:] private var cancellables = Set() + + // Track EOSE per subscription to signal when initial stored events are done + private struct EOSETracker { + var pendingRelays: Set + var callback: () -> Void + var timer: Timer? + } + private var eoseTrackers: [String: EOSETracker] = [:] // Message queue for reliability // Pending sends held only for relays that are not yet connected. @@ -201,7 +209,8 @@ final class NostrRelayManager: ObservableObject { filter: NostrFilter, id: String = UUID().uuidString, relayUrls: [String]? = nil, - handler: @escaping (NostrEvent) -> Void + handler: @escaping (NostrEvent) -> Void, + onEOSE: (() -> Void)? = nil ) { // Coalesce rapid duplicate subscribe requests only if a handler already exists let now = Date() @@ -250,6 +259,22 @@ final class NostrRelayManager: ObservableObject { map[id] = messageString self.pendingSubscriptions[url] = map } + // Initialize EOSE tracking if requested + if let onEOSE = onEOSE { + var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil) + // Fallback timeout to avoid hanging if a relay never sends EOSE + tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in + Task { @MainActor in + guard let self = self else { return } + if let t = self.eoseTrackers[id] { + t.timer?.invalidate() + self.eoseTrackers.removeValue(forKey: id) + onEOSE() + } + } + } + eoseTrackers[id] = tracker + } SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session) // Ensure we actually have sockets opening to these relays so queued REQs can flush ensureConnections(to: urls) @@ -418,9 +443,17 @@ final class NostrRelayManager: ObservableObject { } else { SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session) } - case .eose: - // No-op for now - break + case .eose(let subId): + if var tracker = eoseTrackers[subId] { + tracker.pendingRelays.remove(relayUrl) + if tracker.pendingRelays.isEmpty { + tracker.timer?.invalidate() + eoseTrackers.removeValue(forKey: subId) + tracker.callback() + } else { + eoseTrackers[subId] = tracker + } + } case .ok(let eventId, let success, let reason): if success { _ = Self.pendingGiftWrapIDs.remove(eventId) @@ -755,6 +788,16 @@ struct NostrFilter: Encodable { filter.limit = limit return filter } + + // For location notes: persistent text notes (kind 1) tagged with geohash + static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter { + var filter = NostrFilter() + filter.kinds = [1] + filter.since = since?.timeIntervalSince1970.toInt() + filter.tagFilters = ["g": [geohash]] + filter.limit = limit + return filter + } } // Dynamic coding key for tag filters diff --git a/bitchat/Protocols/LocationChannel.swift b/bitchat/Protocols/LocationChannel.swift index ac58946d..5aea0575 100644 --- a/bitchat/Protocols/LocationChannel.swift +++ b/bitchat/Protocols/LocationChannel.swift @@ -2,6 +2,7 @@ import Foundation /// Levels of location channels mapped to geohash precisions. enum GeohashChannelLevel: CaseIterable, Codable, Equatable { + case building case block case neighborhood case city @@ -11,6 +12,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable { /// Geohash length used for this level. var precision: Int { switch self { + case .building: return 8 case .block: return 7 case .neighborhood: return 6 case .city: return 5 @@ -21,6 +23,7 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable { var displayName: String { switch self { + case .building: return "Building" case .block: return "Block" case .neighborhood: return "Neighborhood" case .city: return "City" @@ -35,6 +38,7 @@ extension GeohashChannelLevel { let container = try decoder.singleValueContainer() if let raw = try? container.decode(String.self) { switch raw { + case "building": self = .building case "block": self = .block case "neighborhood": self = .neighborhood case "city": self = .city @@ -46,6 +50,7 @@ extension GeohashChannelLevel { } } else if let precision = try? container.decode(Int.self) { switch precision { + case 8: self = .building case 7: self = .block case 6: self = .neighborhood case 5: self = .city @@ -61,6 +66,7 @@ extension GeohashChannelLevel { func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self { + case .building: try container.encode("building") case .block: try container.encode("block") case .neighborhood: try container.encode("neighborhood") case .city: try container.encode("city") diff --git a/bitchat/Services/LocationChannelManager.swift b/bitchat/Services/LocationChannelManager.swift index 4b311528..dbcdda57 100644 --- a/bitchat/Services/LocationChannelManager.swift +++ b/bitchat/Services/LocationChannelManager.swift @@ -285,12 +285,18 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa } else if let locality = pm.locality, !locality.isEmpty { dict[.neighborhood] = locality } - // Block: reuse neighborhood/locality granularity without exposing street level + // Block: reuse neighborhood/locality granularity if let subLocality = pm.subLocality, !subLocality.isEmpty { dict[.block] = subLocality } else if let locality = pm.locality, !locality.isEmpty { dict[.block] = locality } + // Building: prefer place name/street/venue when available + if let name = pm.name, !name.isEmpty { + dict[.building] = name + } else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty { + dict[.building] = thoroughfare + } return dict } } diff --git a/bitchat/Services/LocationNotesCounter.swift b/bitchat/Services/LocationNotesCounter.swift new file mode 100644 index 00000000..6e51a0ff --- /dev/null +++ b/bitchat/Services/LocationNotesCounter.swift @@ -0,0 +1,52 @@ +import Foundation + +/// Lightweight background counter for location notes (kind 1) at block-level geohash. +@MainActor +final class LocationNotesCounter: ObservableObject { + static let shared = LocationNotesCounter() + + @Published private(set) var geohash: String? = nil + @Published private(set) var count: Int? = 0 + @Published private(set) var initialLoadComplete: Bool = false + + private var subscriptionID: String? = nil + private var noteIDs = Set() + + private init() {} + + func subscribe(geohash gh: String) { + let norm = gh.lowercased() + if geohash == norm { return } + cancel() + geohash = norm + count = 0 + noteIDs.removeAll() + initialLoadComplete = false + + // Subscribe only to the building geohash (precision 8) + let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))" + subscriptionID = subID + let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500) + let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: norm, count: TransportConfig.nostrGeoRelayCount) + let relayUrls: [String]? = relays.isEmpty ? nil : relays + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in + guard let self = self else { return } + guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } + guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return } + if !self.noteIDs.contains(event.id) { + self.noteIDs.insert(event.id) + self.count = self.noteIDs.count + } + }, onEOSE: { [weak self] in + self?.initialLoadComplete = true + }) + } + + func cancel() { + if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) } + subscriptionID = nil + geohash = nil + count = 0 + noteIDs.removeAll() + } +} diff --git a/bitchat/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift new file mode 100644 index 00000000..e337c555 --- /dev/null +++ b/bitchat/Services/LocationNotesManager.swift @@ -0,0 +1,98 @@ +import Foundation + +/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7). +/// Subscribes to and publishes notes for a given geohash and provides a send API. +@MainActor +final class LocationNotesManager: ObservableObject { + struct Note: Identifiable, Equatable { + let id: String + let pubkey: String + let content: String + let createdAt: Date + let nickname: String? + + var displayName: String { + let suffix = String(pubkey.suffix(4)) + if let nick = nickname, !nick.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return "\(nick)#\(suffix)" + } + return "anon#\(suffix)" + } + } + + @Published private(set) var notes: [Note] = [] // reverse-chron sorted + @Published private(set) var geohash: String + @Published private(set) var initialLoadComplete: Bool = false + private var subscriptionID: String? + + init(geohash: String) { + self.geohash = geohash.lowercased() + subscribe() + } + + func setGeohash(_ newGeohash: String) { + let norm = newGeohash.lowercased() + guard norm != geohash else { return } + if let sub = subscriptionID { + NostrRelayManager.shared.unsubscribe(id: sub) + subscriptionID = nil + } + geohash = norm + notes.removeAll() + subscribe() + } + + private func subscribe() { + let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))" + subscriptionID = subID + // For persistent notes, allow relays to return recent history without an aggressive time cutoff + let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200) + let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) + let relayUrls: [String]? = relays.isEmpty ? nil : relays + initialLoadComplete = false + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in + guard let self = self else { return } + guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } + // Ensure matching tag + guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == self.geohash }) else { return } + if self.notes.contains(where: { $0.id == event.id }) { return } + let nick = event.tags.first(where: { $0.first?.lowercased() == "n" && $0.count >= 2 })?.dropFirst().first + let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick) + self.notes.append(note) + self.notes.sort { $0.createdAt > $1.createdAt } + }, onEOSE: { [weak self] in + self?.initialLoadComplete = true + }) + } + + /// Send a location note for the current geohash using the per-geohash identity. + func send(content: String, nickname: String) { + let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + do { + let id = try NostrIdentityBridge.deriveIdentity(forGeohash: geohash) + let event = try NostrProtocol.createGeohashTextNote( + content: trimmed, + geohash: geohash, + senderIdentity: id, + nickname: nickname + ) + let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) + NostrRelayManager.shared.sendEvent(event, to: relays) + // Optimistic local-echo + let echo = Note(id: event.id, pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(), nickname: nickname) + self.notes.insert(echo, at: 0) + } catch { + SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session) + } + } + + /// Explicitly cancel subscription and release resources. + func cancel() { + if let sub = subscriptionID { + NostrRelayManager.shared.unsubscribe(id: sub) + subscriptionID = nil + } + } +} diff --git a/bitchat/Services/TransportConfig.swift b/bitchat/Services/TransportConfig.swift index 9203a548..8de7a70f 100644 --- a/bitchat/Services/TransportConfig.swift +++ b/bitchat/Services/TransportConfig.swift @@ -103,7 +103,7 @@ enum TransportConfig { // Location static let locationDistanceFilterMeters: Double = 1000 // Live (channel sheet open) distance threshold for meaningful updates - static let locationDistanceFilterLiveMeters: Double = 21.0 + static let locationDistanceFilterLiveMeters: Double = 10.0 static let locationLiveRefreshInterval: TimeInterval = 5.0 // Notifications (geohash) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index ff537ec1..266e2b2e 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -858,7 +858,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { Task { @MainActor in self.torRestartPending = true // Post only in geohash channels (queue if not active) - self.addGeohashOnlySystemMessage("tor restarting to recover connectivity…") + self.addGeohashOnlySystemMessage("tor restarting to recover connectivity...") } } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index dbc4285c..b2572b64 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -25,6 +25,7 @@ struct ContentView: View { @EnvironmentObject var viewModel: ChatViewModel @ObservedObject private var locationManager = LocationChannelManager.shared @ObservedObject private var bookmarks = GeohashBookmarksStore.shared + @ObservedObject private var notesCounter = LocationNotesCounter.shared @State private var messageText = "" @State private var textFieldSelection: NSRange? = nil @FocusState private var isTextFieldFocused: Bool @@ -49,6 +50,10 @@ struct ContentView: View { @State private var showLocationChannelsSheet = false @State private var showVerifySheet = false @State private var expandedMessageIDs: Set = [] + @State private var showLocationNotes = false + @State private var notesGeohash: String? = nil + @State private var sheetNotesCount: Int = 0 + // Timer-based refresh removed; use LocationChannelManager live updates instead // Window sizes for rendering (infinite scroll up) @State private var windowCountPublic: Int = 300 @State private var windowCountPrivate: [String: Int] = [:] @@ -466,14 +471,14 @@ struct ContentView: View { guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return } func levelForLength(_ len: Int) -> GeohashChannelLevel { switch len { - case 0...2: return .region - case 3...4: return .province - case 5: return .city - case 6: return .neighborhood + case 0...2: return .region + case 3...4: return .province + case 5: return .city + case 6: return .neighborhood case 7: return .block default: return .block + } } - } let level = levelForLength(gh.count) let ch = GeohashChannel(level: level, geohash: gh) // Do not mark teleported when opening a geohash that is in our regional set. @@ -1091,7 +1096,7 @@ struct ContentView: View { TextField("nickname", text: $viewModel.nickname) .textFieldStyle(.plain) .font(.system(size: 14, design: .monospaced)) - .frame(maxWidth: 100) + .frame(maxWidth: 80) .foregroundColor(textColor) .focused($isNicknameFieldFocused) .autocorrectionDisabled(true) @@ -1170,6 +1175,29 @@ struct ContentView: View { } .buttonStyle(.plain) + // Notes icon (mesh only and when location is authorized), to the right of #mesh + if case .mesh = locationManager.selectedChannel, locationManager.permissionState == .authorized { + Button(action: { + // Kick a one-shot refresh and show the sheet immediately. + LocationChannelManager.shared.enableLocationChannels() + LocationChannelManager.shared.refreshChannels() + // If we already have a block geohash, pass it; otherwise wait in the sheet. + notesGeohash = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash + showLocationNotes = true + }) { + HStack(alignment: .center, spacing: 4) { + let hasNotes = ((notesCounter.count ?? 0) > 0) || (sheetNotesCount > 0) + Image(systemName: "long.text.page.and.pencil") + .font(.system(size: 12)) + .foregroundColor(hasNotes ? Color(hue: 0.60, saturation: 0.85, brightness: 0.82) : Color.gray) + .padding(.top, 1) + } + .fixedSize(horizontal: true, vertical: false) + } + .buttonStyle(.plain) + .accessibilityLabel("Location notes for this place") + } + HStack(spacing: 4) { // People icon with count Image(systemName: "person.2.fill") @@ -1180,9 +1208,12 @@ struct ContentView: View { .accessibilityHidden(true) } .foregroundColor(headerCountColor) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) // QR moved to the PEOPLE header in the sidebar when on mesh channel } + .layoutPriority(3) .onTapGesture { withAnimation(.easeInOut(duration: TransportConfig.uiAnimationMediumSeconds)) { showSidebar.toggle() @@ -1201,6 +1232,91 @@ struct ContentView: View { .onAppear { viewModel.isLocationChannelsSheetPresented = true } .onDisappear { viewModel.isLocationChannelsSheetPresented = false } } + .sheet(isPresented: $showLocationNotes) { + Group { + if let gh = notesGeohash ?? LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash { + LocationNotesView(geohash: gh, onNotesCountChanged: { cnt in sheetNotesCount = cnt }) + .environmentObject(viewModel) + } else { + VStack(spacing: 12) { + HStack { + Text("notes") + .font(.system(size: 16, weight: .bold, design: .monospaced)) + Spacer() + Button(action: { showLocationNotes = false }) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(textColor) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel("Close") + } + .frame(height: 44) + .padding(.horizontal, 12) + .background(backgroundColor.opacity(0.95)) + Text("location unavailable") + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(secondaryTextColor) + Button("enable location") { + LocationChannelManager.shared.enableLocationChannels() + LocationChannelManager.shared.refreshChannels() + } + .buttonStyle(.bordered) + Spacer() + } + .background(backgroundColor) + .foregroundColor(textColor) + // per-sheet global onChange added below + } + } + .onAppear { + // Ensure we are authorized and start live location updates (distance-filtered) + LocationChannelManager.shared.enableLocationChannels() + LocationChannelManager.shared.beginLiveRefresh() + } + .onDisappear { + LocationChannelManager.shared.endLiveRefresh() + sheetNotesCount = 0 + } + .onChange(of: locationManager.availableChannels) { channels in + if let current = channels.first(where: { $0.level == .building })?.geohash, + notesGeohash != current { + notesGeohash = current + #if os(iOS) + // Light taptic when geohash changes while the sheet is open + let generator = UIImpactFeedbackGenerator(style: .light) + generator.prepare() + generator.impactOccurred() + #endif + } + } + } + .onAppear { + updateNotesCounterSubscription() + if case .mesh = locationManager.selectedChannel, + locationManager.permissionState == .authorized, + LocationChannelManager.shared.availableChannels.isEmpty { + LocationChannelManager.shared.refreshChannels() + } + } + .onChange(of: locationManager.selectedChannel) { _ in + updateNotesCounterSubscription() + if case .mesh = locationManager.selectedChannel, + locationManager.permissionState == .authorized, + LocationChannelManager.shared.availableChannels.isEmpty { + LocationChannelManager.shared.refreshChannels() + } + } + .onChange(of: locationManager.availableChannels) { _ in updateNotesCounterSubscription() } + .onChange(of: locationManager.permissionState) { _ in + updateNotesCounterSubscription() + if case .mesh = locationManager.selectedChannel, + locationManager.permissionState == .authorized, + LocationChannelManager.shared.availableChannels.isEmpty { + LocationChannelManager.shared.refreshChannels() + } + } .alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) { Button("ok", role: .cancel) {} } message: { @@ -1399,6 +1515,26 @@ struct ContentView: View { } +// MARK: - Notes Counter Subscription Helper +extension ContentView { + private func updateNotesCounterSubscription() { + switch locationManager.selectedChannel { + case .mesh: + // Ensure we have a fresh one-shot location fix so building geohash is current + if locationManager.permissionState == .authorized { + LocationChannelManager.shared.refreshChannels() + } + if let building = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .building })?.geohash { + LocationNotesCounter.shared.subscribe(geohash: building) + } else { + LocationNotesCounter.shared.cancel() + } + case .location: + LocationNotesCounter.shared.cancel() + } + } +} + // MARK: - Helper Views // Rounded payment chip button diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 19ab4a26..162bff2e 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -107,7 +107,7 @@ struct LocationChannelsSheet: View { // Nearby options if !manager.availableChannels.isEmpty { - ForEach(manager.availableChannels) { channel in + ForEach(manager.availableChannels.filter { $0.level != .building }) { channel in let coverage = coverageString(forPrecision: channel.geohash.count) let nameBase = locationName(for: channel.level) let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 } @@ -381,6 +381,7 @@ struct LocationChannelsSheet: View { case 5: return .city case 6: return .neighborhood case 7: return .block + case 8: return .building default: return .block } } diff --git a/bitchat/Views/LocationNotesSheet.swift b/bitchat/Views/LocationNotesSheet.swift new file mode 100644 index 00000000..8def19b2 --- /dev/null +++ b/bitchat/Views/LocationNotesSheet.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct LocationNotesSheet: View { + @EnvironmentObject var viewModel: ChatViewModel + @ObservedObject private var locationManager = LocationChannelManager.shared + @Binding var notesGeohash: String? + @Environment(\.dismiss) private var dismiss + @Environment(\.colorScheme) private var colorScheme + + private var backgroundColor: Color { colorScheme == .dark ? .black : .white } + private var textColor: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) } + private var secondaryTextColor: Color { textColor.opacity(0.8) } + + var body: some View { + Group { + if let gh = notesGeohash ?? locationManager.availableChannels.first(where: { $0.level == .block })?.geohash { + // Found block geohash: show notes view + LocationNotesView(geohash: gh) + .environmentObject(viewModel) + } else { + // Acquire location: keep a loading overlay (Matrix) until we either get a block geohash + ZStack { + VStack(spacing: 0) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("notes") + .font(.system(size: 16, weight: .bold, design: .monospaced)) + Text("acquiring location…") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(secondaryTextColor) + } + Spacer() + Button(action: { dismiss() }) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(textColor) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel("Close") + } + .frame(height: 44) + .padding(.horizontal, 12) + .background(backgroundColor.opacity(0.95)) + Spacer() + } + MatrixRainView() + .transition(.opacity) + } + .background(backgroundColor) + .foregroundColor(textColor) + .onAppear { + LocationChannelManager.shared.enableLocationChannels() + // Nudge a fresh fix + LocationChannelManager.shared.refreshChannels() + } + .onChange(of: locationManager.availableChannels) { channels in + if notesGeohash == nil, let block = channels.first(where: { $0.level == .block }) { + notesGeohash = block.geohash + } + } + } + } + } +} diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift new file mode 100644 index 00000000..8f05ca90 --- /dev/null +++ b/bitchat/Views/LocationNotesView.swift @@ -0,0 +1,176 @@ +import SwiftUI + +struct LocationNotesView: View { + @EnvironmentObject var viewModel: ChatViewModel + @StateObject private var manager: LocationNotesManager + let geohash: String + let onNotesCountChanged: ((Int) -> Void)? + + @Environment(\.colorScheme) var colorScheme + @ObservedObject private var locationManager = LocationChannelManager.shared + @Environment(\.dismiss) private var dismiss + @State private var draft: String = "" + + init(geohash: String, onNotesCountChanged: ((Int) -> Void)? = nil) { + let gh = geohash.lowercased() + self.geohash = gh + self.onNotesCountChanged = onNotesCountChanged + _manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh)) + } + + private var backgroundColor: Color { + colorScheme == .dark ? Color.black : Color.white + } + private var textColor: Color { + colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) + } + private var secondaryTextColor: Color { + colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) + } + + var body: some View { + VStack(spacing: 0) { + header + Divider() + list + Divider() + input + } + .background(backgroundColor) + .foregroundColor(textColor) + .onDisappear { manager.cancel() } + .onChange(of: geohash) { newValue in + manager.setGeohash(newValue) + } + .onAppear { onNotesCountChanged?(manager.notes.count) } + .onChange(of: manager.notes.count) { newValue in + onNotesCountChanged?(newValue) + } + } + + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + let c = manager.notes.count + Text("\(c) \(c == 1 ? "note" : "notes") ") + .font(.system(size: 16, weight: .bold, design: .monospaced)) + Text("@ #\(geohash)") + .font(.system(size: 16, weight: .bold, design: .monospaced)) + } + if let buildingName = locationManager.locationNames[.building], !buildingName.isEmpty { + Text(buildingName) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(secondaryTextColor) + } else if let blockName = locationManager.locationNames[.block], !blockName.isEmpty { + Text(blockName) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(secondaryTextColor) + } + } + Spacer() + Button(action: { dismiss() }) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .foregroundColor(textColor) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Close") + } + .frame(height: 44) + .padding(.horizontal, 12) + .background(backgroundColor.opacity(0.95)) + } + + private var list: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 8) { + ForEach(manager.notes) { note in + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(note.displayName) + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + .foregroundColor(secondaryTextColor) + Text(timestampText(for: note.createdAt)) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(secondaryTextColor.opacity(0.8)) + } + Text(note.content) + .font(.system(size: 14, design: .monospaced)) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 12) + } + } + .padding(.vertical, 8) + } + .background(backgroundColor) + } + + private var input: some View { + HStack(alignment: .center, spacing: 8) { + TextField("add a note for this place", text: $draft, axis: .vertical) + .textFieldStyle(.plain) + .font(.system(size: 14, design: .monospaced)) + .lineLimit(3, reservesSpace: true) + .padding(.horizontal, 12) + + Button(action: send) { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 20)) + .foregroundColor(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : textColor) + } + .buttonStyle(.plain) + .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .padding(.trailing, 12) + } + .frame(minHeight: 44) + .padding(.vertical, 8) + .background(backgroundColor.opacity(0.95)) + } + + private func send() { + let content = draft.trimmingCharacters(in: .whitespacesAndNewlines) + guard !content.isEmpty else { return } + manager.send(content: content, nickname: viewModel.nickname) + draft = "" + } + + // MARK: - Timestamp Formatting + private func timestampText(for date: Date) -> String { + let now = Date() + if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { + // Relative (minute/hour/day), no seconds + let rel = Self.relativeFormatter.string(from: date, to: now) ?? "" + return rel.isEmpty ? "" : "\(rel) ago" + } else { + // Absolute date (MMM d or MMM d, yyyy if different year) + let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year) + let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter + return fmt.string(from: date) + } + } + + private static let relativeFormatter: DateComponentsFormatter = { + let f = DateComponentsFormatter() + f.allowedUnits = [.day, .hour, .minute] + f.maximumUnitCount = 1 + f.unitsStyle = .abbreviated + f.collapsesLargestUnit = true + return f + }() + + private static let absDateFormatter: DateFormatter = { + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("MMM d") + return f + }() + + private static let absDateYearFormatter: DateFormatter = { + let f = DateFormatter() + f.setLocalizedDateFormatFromTemplate("MMM d, y") + return f + }() +}