From d809351ab3538fb15def98e523b9ca9192e7b143 Mon Sep 17 00:00:00 2001 From: jack Date: Sat, 13 Sep 2025 00:40:41 +0200 Subject: [PATCH] =?UTF-8?q?Location=20notes:=20Matrix=20loader=20with=201?= =?UTF-8?q?=E2=80=933s=20minimum=20display;=20allow=20multiple=20notes;=20?= =?UTF-8?q?wire=20notes=20UI=20from=20mesh=20toolbar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bitchat.xcodeproj/project.pbxproj | 31 +-- bitchat/Nostr/NostrProtocol.swift | 26 +++ bitchat/Nostr/NostrRelayManager.swift | 10 + bitchat/Services/LocationNotesManager.swift | 133 ++++++++++++ bitchat/Views/ContentView.swift | 33 +++ bitchat/Views/LocationNotesView.swift | 216 ++++++++++++++++++++ 6 files changed, 436 insertions(+), 13 deletions(-) create mode 100644 bitchat/Services/LocationNotesManager.swift create mode 100644 bitchat/Views/LocationNotesView.swift diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index d010c13a..ebeca1c4 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -33,14 +33,16 @@ 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 */; }; 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 +130,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 +179,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 +234,12 @@ 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 = ""; }; 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 +292,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 +316,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 +375,6 @@ children = ( 0481A35A2E6D9BEF00FC845E /* libz.tbd */, 0481A35C2E6DA18600FC845E /* libz.tbd */, - 0481A3532E6D877600FC845E /* README.md */, 0481A3572E6D929E00FC845E /* tor-nolzma.xcframework */, ); path = Frameworks; @@ -515,6 +517,7 @@ A55126E93155456CAA8D6656 /* Views */ = { isa = PBXGroup; children = ( + 0481A3A82E74D28800FC845E /* LocationNotesView.swift */, AA77BB13CC22DD33EE44FF57 /* VerificationViews.swift */, 047502B22E55FED60083520F /* GeohashPeopleList.swift */, 047502B32E55FED60083520F /* MeshPeerList.swift */, @@ -591,6 +594,7 @@ D98A3186D7E4C72E35BDF7FE /* Services */ = { isa = PBXGroup; children = ( + 0481A3AB2E74D29400FC845E /* LocationNotesManager.swift */, 0481A3452E6D869F00FC845E /* Tor */, 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */, 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */, @@ -785,7 +789,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 +800,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; @@ -864,6 +866,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 */, @@ -929,6 +933,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 +990,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 */, diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index 7ca4300a..42a590a0 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -123,6 +123,32 @@ 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, + teleported: Bool = false + ) throws -> NostrEvent { + var tags = [["g", geohash]] + if let nickname = nickname?.trimmingCharacters(in: .whitespacesAndNewlines), !nickname.isEmpty { + tags.append(["n", nickname]) + } + if teleported { + tags.append(["t", "teleport"]) + } + 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..0b94df13 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -755,6 +755,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/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift new file mode 100644 index 00000000..7208ed11 --- /dev/null +++ b/bitchat/Services/LocationNotesManager.swift @@ -0,0 +1,133 @@ +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 isLoading: Bool = true + @Published private(set) var geohash: String + private var subscriptionID: String? + private var loadingWorkItem: DispatchWorkItem? + private var loadingStartedAt: Date? + private var loadingMinDuration: TimeInterval = 0 + + 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) + } + geohash = norm + notes.removeAll() + subscribe() + } + + private func subscribe() { + // Begin loading: always display Matrix for a randomized 1–3 seconds + isLoading = true + loadingStartedAt = Date() + loadingMinDuration = Double.random(in: 1.0...3.0) + loadingWorkItem?.cancel() + let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))" + subscriptionID = subID + let lookback = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) + let filter = NostrFilter.geohashNotes(geohash, since: lookback, limit: 200) + let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relays) { [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 } + // Respect minimum loader time + if self.isLoading { + let elapsed = Date().timeIntervalSince(self.loadingStartedAt ?? Date()) + if elapsed >= self.loadingMinDuration { + self.isLoading = false + } else { + self.loadingWorkItem?.cancel() + let remaining = self.loadingMinDuration - elapsed + let wi = DispatchWorkItem { [weak self] in self?.isLoading = false } + self.loadingWorkItem = wi + DispatchQueue.main.asyncAfter(deadline: .now() + remaining, execute: wi) + } + } + } + // Ensure we hide after the minimum loader duration even if no events arrive + let wi = DispatchWorkItem { [weak self] in self?.isLoading = false } + loadingWorkItem = wi + DispatchQueue.main.asyncAfter(deadline: .now() + loadingMinDuration, execute: wi) + } + + /// 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, + teleported: LocationChannelManager.shared.teleported + ) + 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) + if self.isLoading { + let elapsed = Date().timeIntervalSince(self.loadingStartedAt ?? Date()) + if elapsed >= self.loadingMinDuration { + self.isLoading = false + } else { + self.loadingWorkItem?.cancel() + let remaining = self.loadingMinDuration - elapsed + let wi = DispatchWorkItem { [weak self] in self?.isLoading = false } + self.loadingWorkItem = wi + DispatchQueue.main.asyncAfter(deadline: .now() + remaining, execute: wi) + } + } + } 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 + } + loadingWorkItem?.cancel() + loadingWorkItem = nil + } +} diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index dbc4285c..030df4d9 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -49,6 +49,7 @@ struct ContentView: View { @State private var showLocationChannelsSheet = false @State private var showVerifySheet = false @State private var expandedMessageIDs: Set = [] + @State private var showLocationNotes = false // Window sizes for rendering (infinite scroll up) @State private var windowCountPublic: Int = 300 @State private var windowCountPrivate: [String: Int] = [:] @@ -1144,6 +1145,16 @@ struct ContentView: View { .buttonStyle(.plain) .accessibilityLabel("Toggle bookmark for #\(ch.geohash)") } + // Notes icon (mesh only), left of channel badge + if case .mesh = locationManager.selectedChannel { + Button(action: { showLocationNotes = true }) { + Image(systemName: "note.text") + .font(.system(size: 12)) + .foregroundColor(Color(hue: 0.60, saturation: 0.85, brightness: 0.82)) + } + .buttonStyle(.plain) + .accessibilityLabel("Location notes for this place") + } // Location channels button '#' Button(action: { showLocationChannelsSheet = true }) { let badgeText: String = { @@ -1201,6 +1212,28 @@ struct ContentView: View { .onAppear { viewModel.isLocationChannelsSheetPresented = true } .onDisappear { viewModel.isLocationChannelsSheetPresented = false } } + .sheet(isPresented: $showLocationNotes) { + // Determine current block-level geohash for this place + if let block = LocationChannelManager.shared.availableChannels.first(where: { $0.level == .block })?.geohash { + LocationNotesView(geohash: block) + .environmentObject(viewModel) + } else { + VStack(spacing: 12) { + Text("location unavailable") + .font(.system(size: 16, weight: .bold, design: .monospaced)) + Text("enable location to view notes for your street-level area") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(secondaryTextColor) + Button("enable location") { + LocationChannelManager.shared.enableLocationChannels() + } + .buttonStyle(.bordered) + } + .padding(20) + .background(backgroundColor) + .foregroundColor(textColor) + } + } .alert("heads up", isPresented: $viewModel.showScreenshotPrivacyWarning) { Button("ok", role: .cancel) {} } message: { diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift new file mode 100644 index 00000000..55359eb6 --- /dev/null +++ b/bitchat/Views/LocationNotesView.swift @@ -0,0 +1,216 @@ +import SwiftUI + +struct LocationNotesView: View { + @EnvironmentObject var viewModel: ChatViewModel + @ObservedObject var manager: LocationNotesManager + let geohash: String + + @Environment(\.colorScheme) var colorScheme + @State private var draft: String = "" + + init(geohash: String) { + self.geohash = geohash.lowercased() + self.manager = LocationNotesManager(geohash: self.geohash) + } + + 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 { + ZStack { + VStack(spacing: 0) { + header + Divider() + list + Divider() + input + } + if manager.isLoading { + MatrixRainView() + .transition(.opacity) + } + } + .background(backgroundColor) + .foregroundColor(textColor) + .onDisappear { manager.cancel() } + } + + private var header: some View { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("notes @ #\(geohash)") + .font(.system(size: 16, weight: .bold, design: .monospaced)) + Text("street-level notes") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(secondaryTextColor) + } + Spacer() + } + .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(note.createdAt, style: .relative) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(secondaryTextColor.opacity(0.8)) + } + Text(note.content) + .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: - Matrix Rain Loader +private struct MatrixRainView: View { + @Environment(\.colorScheme) var colorScheme + private var fg: Color { colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) } + private let charset = Array("01abcdefghijklmnopqrstuvwxyzアイウエオカキクケコハヒフヘホ0123456789") + + var body: some View { + GeometryReader { geo in + let w = max(geo.size.width, 1) + let h = max(geo.size.height, 1) + let colWidth: CGFloat = 14 + let cols = max(Int(w / colWidth), 6) + ZStack { + ForEach(0.. Double { + let p = Double(idx) / Double(max(glyphs.count, 1)) + return 0.15 + (1.0 - p) * 0.85 + } + + private func randomGlyphs(_ n: Int) -> [String] { + (0..