diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 4cea821e..94b921ed 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -9,6 +9,16 @@ /* Begin PBXBuildFile section */ 047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; }; 047502812E53A0FC0083520F /* FragmentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475027E2E53A0FC0083520F /* FragmentationTests.swift */; }; + 047502872E5416250083520F /* Geohash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502852E5416250083520F /* Geohash.swift */; }; + 047502882E5416250083520F /* LocationChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502862E5416250083520F /* LocationChannel.swift */; }; + 047502892E5416250083520F /* Geohash.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502852E5416250083520F /* Geohash.swift */; }; + 0475028A2E5416250083520F /* LocationChannel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502862E5416250083520F /* LocationChannel.swift */; }; + 0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028B2E54171C0083520F /* LocationChannelManager.swift */; }; + 0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028B2E54171C0083520F /* LocationChannelManager.swift */; }; + 0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; }; + 047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0475028E2E5417660083520F /* LocationChannelsSheet.swift */; }; + 047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; }; + 047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502912E547ACC0083520F /* LocationChannelsTests.swift */; }; 049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; }; 049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; }; 049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; }; @@ -159,6 +169,11 @@ /* Begin PBXFileReference section */ 03C57F452B55FD0FD8F51421 /* bitchatTests_macOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_macOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 0475027E2E53A0FC0083520F /* FragmentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FragmentationTests.swift; sourceTree = ""; }; + 047502852E5416250083520F /* Geohash.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Geohash.swift; sourceTree = ""; }; + 047502862E5416250083520F /* LocationChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannel.swift; sourceTree = ""; }; + 0475028B2E54171C0083520F /* LocationChannelManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannelManager.swift; sourceTree = ""; }; + 0475028E2E5417660083520F /* LocationChannelsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannelsSheet.swift; sourceTree = ""; }; + 047502912E547ACC0083520F /* LocationChannelsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationChannelsTests.swift; sourceTree = ""; }; 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = ""; }; 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = ""; }; 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = ""; }; @@ -382,6 +397,7 @@ A55126E93155456CAA8D6656 /* Views */ = { isa = PBXGroup; children = ( + 0475028E2E5417660083520F /* LocationChannelsSheet.swift */, 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */, A08E03AA0C63E97C91749AEC /* ContentView.swift */, 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */, @@ -393,6 +409,8 @@ ADD53BCDA233C02E53458926 /* Protocols */ = { isa = PBXGroup; children = ( + 047502852E5416250083520F /* Geohash.swift */, + 047502862E5416250083520F /* LocationChannel.swift */, 049BD39E2E51DBF4001A566B /* Packets.swift */, 049BD39F2E51DBF4001A566B /* PeerID.swift */, 5318B743C64628A125261163 /* BinaryEncodingUtils.swift */, @@ -415,6 +433,7 @@ isa = PBXGroup; children = ( D69A18D27F9A565FD6041E12 /* Info.plist */, + 047502912E547ACC0083520F /* LocationChannelsTests.swift */, C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */, 980B109CBA72BC996455C62B /* BLEServiceTests.swift */, 0475027F2E53A0FC0083520F /* Fragmentation */, @@ -448,6 +467,7 @@ D98A3186D7E4C72E35BDF7FE /* Services */ = { isa = PBXGroup; children = ( + 0475028B2E54171C0083520F /* LocationChannelManager.swift */, 049BD3B02E51F319001A566B /* MessageRouter.swift */, 049BD3B12E51F319001A566B /* NostrTransport.swift */, 049BD3AD2E51ED60001A566B /* Transport.swift */, @@ -679,7 +699,9 @@ 7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */, FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */, 31D147471B9F4E2815352DDA /* LinkPreviewView.swift in Sources */, + 0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */, 501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */, + 0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */, AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */, 8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */, 049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */, @@ -695,6 +717,8 @@ 049BD3AF2E51ED60001A566B /* Transport.swift in Sources */, E2DCF7817344F1CCDB8B7B2F /* SecureIdentityStateManager.swift in Sources */, 049BD3A02E51DBF4001A566B /* Packets.swift in Sources */, + 047502892E5416250083520F /* Geohash.swift in Sources */, + 0475028A2E5416250083520F /* LocationChannel.swift in Sources */, 049BD3A12E51DBF4001A566B /* PeerID.swift in Sources */, 049BD3942E4EC4F0001A566B /* PrivateChatManager.swift in Sources */, 049BD3952E4EC4F0001A566B /* AutocompleteService.swift in Sources */, @@ -725,7 +749,9 @@ EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */, 8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */, 7A5B1AB5642FEC168E917949 /* LinkPreviewView.swift in Sources */, + 047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */, 5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */, + 0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */, 6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */, A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */, 049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */, @@ -741,6 +767,8 @@ 049BD3AE2E51ED60001A566B /* Transport.swift in Sources */, 68C4BE564735F6E7915274A2 /* SecureIdentityStateManager.swift in Sources */, 049BD3A22E51DBF4001A566B /* Packets.swift in Sources */, + 047502872E5416250083520F /* Geohash.swift in Sources */, + 047502882E5416250083520F /* LocationChannel.swift in Sources */, 049BD3A32E51DBF4001A566B /* PeerID.swift in Sources */, 049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */, 049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */, @@ -758,6 +786,7 @@ 047502802E53A0FC0083520F /* FragmentationTests.swift in Sources */, 8F282E9CCA5AE1ECC001D2E4 /* IntegrationTests.swift in Sources */, D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */, + 047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */, 765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */, 968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */, @@ -777,6 +806,7 @@ 047502812E53A0FC0083520F /* FragmentationTests.swift in Sources */, 686441ABC2AF83EE98E6ECF2 /* IntegrationTests.swift in Sources */, 8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */, + 047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */, 3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */, BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */, EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */, @@ -892,7 +922,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.1; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -923,7 +953,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.1; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -978,7 +1008,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.2.1; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -1010,7 +1040,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.2.1; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -1099,7 +1129,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.2.1; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -1192,7 +1222,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.2.1; + MARKETING_VERSION = 1.3.0; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/bitchat/Info.plist b/bitchat/Info.plist index dfd79024..f8cd5912 100644 --- a/bitchat/Info.plist +++ b/bitchat/Info.plist @@ -35,6 +35,8 @@ bitchat uses Bluetooth to create a secure mesh network for chatting with nearby users. NSBluetoothPeripheralUsageDescription bitchat uses Bluetooth to discover and connect with other bitchat users nearby. + NSLocationWhenInUseUsageDescription + bitchat uses your approximate location to compute local geohash channels for optional public chats. Exact GPS is never shared. UIBackgroundModes bluetooth-central diff --git a/bitchat/Nostr/NostrEmbeddedBitChat.swift b/bitchat/Nostr/NostrEmbeddedBitChat.swift index 077861e8..44807eff 100644 --- a/bitchat/Nostr/NostrEmbeddedBitChat.swift +++ b/bitchat/Nostr/NostrEmbeddedBitChat.swift @@ -53,6 +53,49 @@ struct NostrEmbeddedBitChat { return "bitchat1:" + base64URLEncode(data) } + /// Build a `bitchat1:` ACK (delivered/read) without an embedded recipient peer ID (geohash DMs). + static func encodeAckForNostrNoRecipient(type: NoisePayloadType, messageID: String, senderPeerID: String) -> String? { + guard type == .delivered || type == .readReceipt else { return nil } + + var payload = Data([type.rawValue]) + payload.append(Data(messageID.utf8)) + + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: senderPeerID) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 7 + ) + + guard let data = packet.toBinaryData() else { return nil } + return "bitchat1:" + base64URLEncode(data) + } + + /// Build a `bitchat1:` payload without an embedded recipient peer ID (used for geohash DMs). + static func encodePMForNostrNoRecipient(content: String, messageID: String, senderPeerID: String) -> String? { + let pm = PrivateMessagePacket(messageID: messageID, content: content) + guard let tlv = pm.encode() else { return nil } + + var payload = Data([NoisePayloadType.privateMessage.rawValue]) + payload.append(tlv) + + let packet = BitchatPacket( + type: MessageType.noiseEncrypted.rawValue, + senderID: Data(hexString: senderPeerID) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, + ttl: 7 + ) + + guard let data = packet.toBinaryData() else { return nil } + return "bitchat1:" + base64URLEncode(data) + } + private static func normalizeRecipientPeerID(_ recipientPeerID: String) -> String { if let maybeData = Data(hexString: recipientPeerID) { if maybeData.count == 32 { diff --git a/bitchat/Nostr/NostrIdentity.swift b/bitchat/Nostr/NostrIdentity.swift index 86d9d1fd..d038fb00 100644 --- a/bitchat/Nostr/NostrIdentity.swift +++ b/bitchat/Nostr/NostrIdentity.swift @@ -1,6 +1,7 @@ import Foundation import CryptoKit import P256K +import Security // Keychain helper for secure storage struct KeychainHelper { @@ -104,6 +105,7 @@ struct NostrIdentity: Codable { struct NostrIdentityBridge { private static let keychainService = "chat.bitchat.nostr" private static let currentIdentityKey = "nostr-current-identity" + private static let deviceSeedKey = "nostr-device-seed" /// Get or create the current Nostr identity static func getCurrentNostrIdentity() throws -> NostrIdentity? { @@ -145,11 +147,62 @@ struct NostrIdentityBridge { static func clearAllAssociations() { // Delete current Nostr identity KeychainHelper.delete(key: currentIdentityKey, service: keychainService) + KeychainHelper.delete(key: deviceSeedKey, service: keychainService) // Note: We can't efficiently delete all noise-nostr associations // without tracking them, but they'll be orphaned and eventually cleaned up // The important part is deleting the current identity so a new one is generated } + + // MARK: - Per-Geohash Identities (Location Channels) + + /// Returns a stable device seed used to derive unlinkable per-geohash identities. + /// Stored only on device keychain. + private static func getOrCreateDeviceSeed() -> Data { + if let existing = KeychainHelper.load(key: deviceSeedKey, service: keychainService) { + return existing + } + var seed = Data(count: 32) + _ = seed.withUnsafeMutableBytes { ptr in + SecRandomCopyBytes(kSecRandomDefault, 32, ptr.baseAddress!) + } + KeychainHelper.save(key: deviceSeedKey, data: seed, service: keychainService) + return seed + } + + /// Derive a deterministic, unlinkable Nostr identity for a given geohash. + /// Uses HMAC-SHA256(deviceSeed, geohash) as private key material, with fallback rehashing + /// if the candidate is not a valid secp256k1 private key. + static func deriveIdentity(forGeohash geohash: String) throws -> NostrIdentity { + let seed = getOrCreateDeviceSeed() + guard let msg = geohash.data(using: .utf8) else { + throw NSError(domain: "NostrIdentity", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid geohash string"]) + } + + func candidateKey(iteration: UInt32) -> Data { + var input = Data(msg) + var iterBE = iteration.bigEndian + withUnsafeBytes(of: &iterBE) { bytes in + input.append(contentsOf: bytes) + } + let code = CryptoKit.HMAC.authenticationCode(for: input, using: SymmetricKey(data: seed)) + return Data(code) + } + + // Try a few iterations to ensure a valid key can be formed + for i in 0..<10 { + let keyData = candidateKey(iteration: UInt32(i)) + if let identity = try? NostrIdentity(privateKeyData: keyData) { + return identity + } + } + // As a final fallback, hash the seed+msg and try again + var combined = Data() + combined.append(seed) + combined.append(msg) + let fallback = Data(CryptoKit.SHA256.hash(data: combined)) + return try NostrIdentity(privateKeyData: fallback) + } } // Bech32 encoding for Nostr (minimal implementation) diff --git a/bitchat/Nostr/NostrProtocol.swift b/bitchat/Nostr/NostrProtocol.swift index cddcbd55..449c236f 100644 --- a/bitchat/Nostr/NostrProtocol.swift +++ b/bitchat/Nostr/NostrProtocol.swift @@ -97,6 +97,28 @@ struct NostrProtocol { return (content: rumor.content, senderPubkey: rumor.pubkey, timestamp: rumor.created_at) } + + /// Create a geohash-scoped ephemeral public message (kind 20000) + static func createEphemeralGeohashEvent( + content: String, + geohash: String, + senderIdentity: NostrIdentity, + nickname: String? = nil + ) throws -> NostrEvent { + var tags = [["g", geohash]] + if let nickname = nickname, !nickname.isEmpty { + tags.append(["n", nickname]) + } + let event = NostrEvent( + pubkey: senderIdentity.publicKeyHex, + createdAt: Date(), + kind: .ephemeralEvent, + tags: tags, + content: content + ) + let signingKey = try senderIdentity.signingKey() + return try event.sign(with: signingKey) + } // MARK: - Private Methods @@ -554,4 +576,4 @@ enum NostrError: Error { case invalidCiphertext case signingFailed case encryptionFailed -} \ No newline at end of file +} diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 0b812f2d..3592982c 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -6,6 +6,11 @@ import Combine @MainActor class NostrRelayManager: ObservableObject { static let shared = NostrRelayManager() + // Track gift-wraps (kind 1059) we initiated so we can log OK acks at info + private(set) static var pendingGiftWrapIDs = Set() + static func registerPendingGiftWrap(id: String) { + pendingGiftWrapIDs.insert(id) + } struct Relay: Identifiable { let id = UUID() @@ -260,8 +265,8 @@ class NostrRelayManager: ObservableObject { let event = try NostrEvent(from: eventDict) - // Only log critical events - if event.kind != 1059 { // Don't log every gift wrap + // Only log non-gift-wrap events to reduce noise + if event.kind != 1059 { SecureLogger.log("📥 Received Nostr event (kind: \(event.kind)) from relay: \(relayUrl)", category: SecureLogger.session, level: .debug) } @@ -293,11 +298,13 @@ class NostrRelayManager: ObservableObject { let success = array[2] as? Bool { let reason = array.count >= 4 ? (array[3] as? String ?? "no reason given") : "no reason given" if success { - SecureLogger.log("✅ Event \(eventId.prefix(16))... accepted by relay: \(relayUrl)", + _ = Self.pendingGiftWrapIDs.remove(eventId) + SecureLogger.log("✅ Event accepted id=\(eventId.prefix(16))... by relay: \(relayUrl)", category: SecureLogger.session, level: .debug) } else { - SecureLogger.log("📮 Event \(eventId) rejected by relay: \(reason)", - category: SecureLogger.session, level: .error) + let isGiftWrap = Self.pendingGiftWrapIDs.remove(eventId) != nil + SecureLogger.log("📮 Event \(eventId.prefix(16))... rejected by relay: \(reason)", + category: SecureLogger.session, level: isGiftWrap ? .warning : .error) } } @@ -542,6 +549,16 @@ struct NostrFilter: Encodable { filter.limit = 100 // Add a reasonable limit return filter } + + // For location channels: geohash-scoped ephemeral events (kind 20000) + static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter { + var filter = NostrFilter() + filter.kinds = [20000] + filter.since = since?.timeIntervalSince1970.toInt() + filter.tagFilters = ["g": [geohash]] + filter.limit = limit + return filter + } } // Dynamic coding key for tag filters diff --git a/bitchat/Protocols/Geohash.swift b/bitchat/Protocols/Geohash.swift new file mode 100644 index 00000000..815880ac --- /dev/null +++ b/bitchat/Protocols/Geohash.swift @@ -0,0 +1,60 @@ +import Foundation + +/// Lightweight Geohash encoder used for Location Channels. +/// Encodes latitude/longitude to base32 geohash with a fixed precision. +enum Geohash { + private static let base32Chars = Array("0123456789bcdefghjkmnpqrstuvwxyz") + + /// Encodes the provided coordinates into a geohash string. + /// - Parameters: + /// - latitude: Latitude in degrees (-90...90) + /// - longitude: Longitude in degrees (-180...180) + /// - precision: Number of geohash characters (2-12 typical). Values <= 0 return an empty string. + /// - Returns: Base32 geohash string of length `precision`. + static func encode(latitude: Double, longitude: Double, precision: Int) -> String { + guard precision > 0 else { return "" } + + var latInterval: (Double, Double) = (-90.0, 90.0) + var lonInterval: (Double, Double) = (-180.0, 180.0) + + var isEven = true + var bit = 0 + var ch = 0 + var geohash: [Character] = [] + + let lat = max(-90.0, min(90.0, latitude)) + let lon = max(-180.0, min(180.0, longitude)) + + while geohash.count < precision { + if isEven { + let mid = (lonInterval.0 + lonInterval.1) / 2 + if lon >= mid { + ch |= (1 << (4 - bit)) + lonInterval.0 = mid + } else { + lonInterval.1 = mid + } + } else { + let mid = (latInterval.0 + latInterval.1) / 2 + if lat >= mid { + ch |= (1 << (4 - bit)) + latInterval.0 = mid + } else { + latInterval.1 = mid + } + } + + isEven.toggle() + if bit < 4 { + bit += 1 + } else { + geohash.append(base32Chars[ch]) + bit = 0 + ch = 0 + } + } + + return String(geohash) + } +} + diff --git a/bitchat/Protocols/LocationChannel.swift b/bitchat/Protocols/LocationChannel.swift new file mode 100644 index 00000000..33b2e718 --- /dev/null +++ b/bitchat/Protocols/LocationChannel.swift @@ -0,0 +1,71 @@ +import Foundation + +/// Levels of location channels mapped to geohash precisions. +enum GeohashChannelLevel: CaseIterable, Codable, Equatable { + case street + case block + case neighborhood + case city + case region + case country + + /// Geohash length used for this level. + var precision: Int { + switch self { + case .street: return 8 + case .block: return 7 + case .neighborhood: return 6 + case .city: return 5 + case .region: return 4 + case .country: return 2 + } + } + + var displayName: String { + switch self { + case .street: return "Street" + case .block: return "Block" + case .neighborhood: return "'hood" + case .city: return "City" + case .region: return "Region" + case .country: return "Country" + } + } +} + +/// A computed geohash channel option. +struct GeohashChannel: Codable, Equatable, Hashable, Identifiable { + let level: GeohashChannelLevel + let geohash: String + + var id: String { "\(level)-\(geohash)" } + + var displayName: String { + "\(level.displayName) • \(geohash)" + } +} + +/// Identifier for current public chat channel (mesh or a location geohash). +enum ChannelID: Equatable, Codable { + case mesh + case location(GeohashChannel) + + /// Human readable name for UI. + var displayName: String { + switch self { + case .mesh: + return "Mesh" + case .location(let ch): + return ch.displayName + } + } + + /// Nostr tag value for scoping (geohash), if applicable. + var nostrGeohashTag: String? { + switch self { + case .mesh: return nil + case .location(let ch): return ch.geohash + } + } +} + diff --git a/bitchat/Protocols/Packets.swift b/bitchat/Protocols/Packets.swift index b735f486..10b02ac4 100644 --- a/bitchat/Protocols/Packets.swift +++ b/bitchat/Protocols/Packets.swift @@ -4,7 +4,7 @@ import Foundation struct AnnouncementPacket { let nickname: String - let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement) + let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement) let signingPublicKey: Data // Ed25519 public key for signing private enum TLVType: UInt8 { diff --git a/bitchat/Services/LocationChannelManager.swift b/bitchat/Services/LocationChannelManager.swift new file mode 100644 index 00000000..3965227f --- /dev/null +++ b/bitchat/Services/LocationChannelManager.swift @@ -0,0 +1,158 @@ +import Foundation + +#if os(iOS) +import CoreLocation +import Combine + +/// Manages location permissions, one-shot location retrieval, and computing geohash channels. +/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor. +final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject { + static let shared = LocationChannelManager() + + enum PermissionState: Equatable { + case notDetermined + case denied + case restricted + case authorized + } + + private let cl = CLLocationManager() + private var lastLocation: CLLocation? + private var refreshTimer: Timer? + private let userDefaultsKey = "locationChannel.selected" + + // Published state for UI bindings + @Published private(set) var permissionState: PermissionState = .notDetermined + @Published private(set) var availableChannels: [GeohashChannel] = [] + @Published private(set) var selectedChannel: ChannelID = .mesh + + private override init() { + super.init() + cl.delegate = self + cl.desiredAccuracy = kCLLocationAccuracyHundredMeters + cl.distanceFilter = 1000 // meters; we're not tracking continuously + // Load selection + if let data = UserDefaults.standard.data(forKey: userDefaultsKey), + let channel = try? JSONDecoder().decode(ChannelID.self, from: data) { + selectedChannel = channel + } + let status: CLAuthorizationStatus + if #available(iOS 14.0, *) { + status = cl.authorizationStatus + } else { + status = CLLocationManager.authorizationStatus() + } + updatePermissionState(from: status) + } + + // MARK: - Public API + func enableLocationChannels() { + let status: CLAuthorizationStatus + if #available(iOS 14.0, *) { + status = cl.authorizationStatus + } else { + status = CLLocationManager.authorizationStatus() + } + switch status { + case .notDetermined: + cl.requestWhenInUseAuthorization() + case .restricted: + Task { @MainActor in self.permissionState = .restricted } + case .denied: + Task { @MainActor in self.permissionState = .denied } + case .authorizedAlways, .authorizedWhenInUse: + Task { @MainActor in self.permissionState = .authorized } + requestOneShotLocation() + @unknown default: + Task { @MainActor in self.permissionState = .restricted } + } + } + + func refreshChannels() { + if permissionState == .authorized { + requestOneShotLocation() + } + } + + /// Begin periodic one-shot location refreshes while a selector UI is visible. + func beginLiveRefresh(interval: TimeInterval = 5.0) { + // Prefer continuous updates with a significant distance filter rather than polling + guard permissionState == .authorized else { return } + cl.desiredAccuracy = kCLLocationAccuracyHundredMeters + cl.distanceFilter = 21 // meters; update on small moves + cl.startUpdatingLocation() + } + + /// Stop periodic refreshes when selector UI is dismissed. + func endLiveRefresh() { + cl.stopUpdatingLocation() + } + + func select(_ channel: ChannelID) { + Task { @MainActor in + self.selectedChannel = channel + if let data = try? JSONEncoder().encode(channel) { + UserDefaults.standard.set(data, forKey: self.userDefaultsKey) + } + } + } + + // MARK: - CoreLocation + private func requestOneShotLocation() { + cl.requestLocation() + } + + // iOS < 14 + func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) { + updatePermissionState(from: status) + if case .authorized = permissionState { + requestOneShotLocation() + } + } + + // iOS 14+ + @available(iOS 14.0, *) + func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { + updatePermissionState(from: manager.authorizationStatus) + if case .authorized = permissionState { + requestOneShotLocation() + } + } + + func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) { + guard let loc = locations.last else { return } + lastLocation = loc + computeChannels(from: loc.coordinate) + } + + func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { + // Surface as denied/restricted if relevant; otherwise keep previous state + SecureLogger.log("LocationChannelManager: location error: \(error.localizedDescription)", + category: SecureLogger.session, level: .error) + } + + // MARK: - Helpers + private func updatePermissionState(from status: CLAuthorizationStatus) { + let newState: PermissionState + switch status { + case .notDetermined: newState = .notDetermined + case .restricted: newState = .restricted + case .denied: newState = .denied + case .authorizedAlways, .authorizedWhenInUse: newState = .authorized + @unknown default: newState = .restricted + } + Task { @MainActor in self.permissionState = newState } + } + + private func computeChannels(from coord: CLLocationCoordinate2D) { + let levels = GeohashChannelLevel.allCases + var result: [GeohashChannel] = [] + for level in levels { + let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision) + result.append(GeohashChannel(level: level, geohash: gh)) + } + Task { @MainActor in self.availableChannels = result } + } +} + +#endif diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index eaf4f176..a484fee7 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -296,6 +296,56 @@ class NoiseEncryptionService { return false } } + + // MARK: - Announce Signature Helpers + + /// Build the canonical announce binding message bytes and sign with our Ed25519 key + /// - Parameters: + /// - peerID: 8-byte routing ID (as in packet header) + /// - noiseKey: 32-byte Curve25519.KeyAgreement public key + /// - ed25519Key: 32-byte Ed25519 public key (self) + /// - nickname: UTF-8 nickname (<=255 bytes) + /// - timestampMs: UInt64 milliseconds since epoch + /// - Returns: Ed25519 signature over the canonical bytes, or nil on failure + func buildAnnounceSignature(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data? { + let message = canonicalAnnounceBytes(peerID: peerID, noiseKey: noiseKey, ed25519Key: ed25519Key, nickname: nickname, timestampMs: timestampMs) + return signData(message) + } + + /// Verify an announce signature + func verifyAnnounceSignature(signature: Data, peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64, publicKey: Data) -> Bool { + let message = canonicalAnnounceBytes(peerID: peerID, noiseKey: noiseKey, ed25519Key: ed25519Key, nickname: nickname, timestampMs: timestampMs) + return verifySignature(signature, for: message, publicKey: publicKey) + } + + /// Build canonical bytes for announce signing. + private func canonicalAnnounceBytes(peerID: Data, noiseKey: Data, ed25519Key: Data, nickname: String, timestampMs: UInt64) -> Data { + var out = Data() + // context + let context = "bitchat-announce-v1".data(using: .utf8) ?? Data() + out.append(UInt8(min(context.count, 255))) + out.append(context.prefix(255)) + // peerID (expect 8 bytes; pad/truncate to 8 for canonicalization) + let peerID8 = peerID.prefix(8) + out.append(peerID8) + if peerID8.count < 8 { out.append(Data(repeating: 0, count: 8 - peerID8.count)) } + // noise static key (expect 32) + let noise32 = noiseKey.prefix(32) + out.append(noise32) + if noise32.count < 32 { out.append(Data(repeating: 0, count: 32 - noise32.count)) } + // ed25519 public key (expect 32) + let ed32 = ed25519Key.prefix(32) + out.append(ed32) + if ed32.count < 32 { out.append(Data(repeating: 0, count: 32 - ed32.count)) } + // nickname length + bytes + let nickData = nickname.data(using: .utf8) ?? Data() + out.append(UInt8(min(nickData.count, 255))) + out.append(nickData.prefix(255)) + // timestamp + var ts = timestampMs.bigEndian + withUnsafeBytes(of: &ts) { raw in out.append(contentsOf: raw) } + return out + } // MARK: - Packet Signing/Verification diff --git a/bitchat/Services/NostrTransport.swift b/bitchat/Services/NostrTransport.swift index 4cc959e3..c313c50c 100644 --- a/bitchat/Services/NostrTransport.swift +++ b/bitchat/Services/NostrTransport.swift @@ -213,4 +213,49 @@ final class NostrTransport: Transport { NostrRelayManager.shared.sendEvent(event) } } + + // MARK: - Geohash ACK helpers + func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { + Task { @MainActor in + SecureLogger.log("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", + category: SecureLogger.session, level: .debug) + guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .delivered, messageID: messageID, senderPeerID: senderPeerID) else { return } + guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return } + NostrRelayManager.registerPendingGiftWrap(id: event.id) + NostrRelayManager.shared.sendEvent(event) + } + } + + func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) { + Task { @MainActor in + SecureLogger.log("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", + category: SecureLogger.session, level: .debug) + guard let embedded = NostrEmbeddedBitChat.encodeAckForNostrNoRecipient(type: .readReceipt, messageID: messageID, senderPeerID: senderPeerID) else { return } + guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return } + NostrRelayManager.registerPendingGiftWrap(id: event.id) + NostrRelayManager.shared.sendEvent(event) + } + } + + // MARK: - Geohash DMs (per-geohash identity) + func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) { + Task { @MainActor in + guard !recipientHex.isEmpty else { return } + SecureLogger.log("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))…", + category: SecureLogger.session, level: .debug) + // Build embedded BitChat packet without recipient peer ID + guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else { + SecureLogger.log("NostrTransport: failed to embed geohash PM packet", category: SecureLogger.session, level: .error) + return + } + guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { + SecureLogger.log("NostrTransport: failed to build Nostr event for geohash PM", category: SecureLogger.session, level: .error) + return + } + SecureLogger.log("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))…", + category: SecureLogger.session, level: .debug) + NostrRelayManager.registerPendingGiftWrap(id: event.id) + NostrRelayManager.shared.sendEvent(event) + } + } } diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index 14a99c2b..0bddc6c0 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -25,6 +25,9 @@ class PrivateChatManager: ObservableObject { init(meshService: Transport? = nil) { self.meshService = meshService } + + // Cap for messages stored per private chat + private let privateChatCap = 1337 /// Start a private chat with a peer func startChat(with peerID: String) { @@ -75,10 +78,14 @@ class PrivateChatManager: ObservableObject { ) // Add to chat - if privateChats[peerID] == nil { - privateChats[peerID] = [] - } + if privateChats[peerID] == nil { privateChats[peerID] = [] } privateChats[peerID]?.append(message) + // Enforce per-chat cap on local append + if var arr = privateChats[peerID], arr.count > privateChatCap { + let remove = arr.count - privateChatCap + arr.removeFirst(remove) + privateChats[peerID] = arr + } // Send via mesh service meshService.sendPrivateMessage(content, to: peerID, recipientNickname: peerNickname, messageID: messageID) @@ -102,17 +109,25 @@ class PrivateChatManager: ObservableObject { // Sanitize chat to avoid duplicate IDs and sort by timestamp sanitizeChat(for: senderPeerID) + // Enforce cap after sanitize + if var arr = privateChats[senderPeerID], arr.count > privateChatCap { + let remove = arr.count - privateChatCap + arr.removeFirst(remove) + privateChats[senderPeerID] = arr + } // Mark as unread if not in this chat if selectedPeer != senderPeerID { unreadMessages.insert(senderPeerID) - // Send notification - NotificationService.shared.sendPrivateMessageNotification( - from: message.sender, - message: message.content, - peerID: senderPeerID - ) + // Avoid notifying for messages already marked as read (dup/resubscribe cases) + if !sentReadReceipts.contains(message.id) { + NotificationService.shared.sendPrivateMessageNotification( + from: message.sender, + message: message.content, + peerID: senderPeerID + ) + } } else { // Send read receipt if viewing this chat sendReadReceipt(for: message) diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index fe23a387..76f69051 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -202,8 +202,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate { private var nostrRelayManager: NostrRelayManager? // PeerManager replaced by UnifiedPeerService private var processedNostrEvents = Set() // Simple deduplication + private var processedNostrEventOrder: [String] = [] + private let maxProcessedNostrEvents = 2000 private let userDefaults = UserDefaults.standard private let nicknameKey = "bitchat.nickname" + // Location channel state + #if os(iOS) + @Published private var activeChannel: ChannelID = .mesh + private var geoSubscriptionID: String? = nil + private var geoDmSubscriptionID: String? = nil + private var currentGeohash: String? = nil + private var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname + #endif // MARK: - Caches @@ -230,6 +240,24 @@ class ChatViewModel: ObservableObject, BitchatDelegate { @Published var bluetoothState: CBManagerState = .unknown // Messages are naturally ephemeral - no persistent storage + // Persist mesh public timeline across channel switches + private var meshTimeline: [BitchatMessage] = [] + private let meshTimelineCap = 1337 + #if os(iOS) + // Persist per-geohash public timelines across switches + private var geoTimelines: [String: [BitchatMessage]] = [:] // geohash -> messages + private let geoTimelineCap = 1337 + // Channel activity tracking for background nudges + private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time + private var lastPublicActivityNotifyAt: [String: Date] = [:] + private let channelInactivityThreshold: TimeInterval = 9 * 60 + // Geohash participants (per geohash: pubkey -> lastSeen) + private var geoParticipants: [String: [String: Date]] = [:] + @Published private(set) var geohashPeople: [GeoPerson] = [] + private var geoParticipantsTimer: Timer? = nil + // Sampling subscriptions for multiple geohashes (when channel sheet is open) + private var geoSamplingSubs: [String: String] = [:] // subID -> geohash + #endif // MARK: - Message Delivery Tracking @@ -267,6 +295,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Track processed Nostr ACKs to avoid duplicate processing private var processedNostrAcks: Set = [] // "messageId:ackType:senderPubkey" format + // Track which GeoDM messages we've already sent a delivery ACK for (by messageID) + private var sentGeoDeliveryAcks: Set = [] // Track app startup phase to prevent marking old messages as unread private var isStartupPhase = true @@ -386,10 +416,44 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } self.cancellables.insert(cancellable) + + // Resubscribe geohash on relay reconnect (iOS only) + #if os(iOS) + if let relayMgr = self.nostrRelayManager { + relayMgr.$isConnected + .receive(on: DispatchQueue.main) + .sink { [weak self] connected in + guard let self = self else { return } + if connected { + Task { @MainActor in + self.resubscribeCurrentGeohash() + } + } + } + .store(in: &self.cancellables) + } + #endif } - + // Set up Noise encryption callbacks setupNoiseCallbacks() + + #if os(iOS) + // Observe location channel selection + LocationChannelManager.shared.$selectedChannel + .receive(on: DispatchQueue.main) + .sink { [weak self] channel in + guard let self = self else { return } + Task { @MainActor in + self.switchLocationChannel(to: channel) + } + } + .store(in: &cancellables) + // Initialize with current selection + Task { @MainActor in + self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel) + } + #endif // Request notification permission NotificationService.shared.requestAuthorization() @@ -442,6 +506,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { name: UIApplication.didBecomeActiveNotification, object: nil ) + // Resubscribe geohash on app foreground + // Resubscribe handled via appDidBecomeActive selector // Add screenshot detection for iOS NotificationCenter.default.addObserver( @@ -473,7 +539,166 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Force immediate save userDefaults.synchronize() } - + + #if os(iOS) + // Resubscribe to the active geohash channel without clearing timeline + @MainActor + private func resubscribeCurrentGeohash() { + guard case .location(let ch) = activeChannel else { return } + guard let subID = geoSubscriptionID else { + // No existing subscription; set it up + switchLocationChannel(to: activeChannel) + return + } + // Ensure participant decay timer is running + startGeoParticipantsTimer() + // Unsubscribe + resubscribe + NostrRelayManager.shared.unsubscribe(id: subID) + let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200) + NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in + guard let self = self else { return } + guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } + if self.processedNostrEvents.contains(event.id) { return } + self.processedNostrEvents.insert(event.id) + if let gh = self.currentGeohash, + let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), + myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { return } + if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { + let nick = nickTag[1] + self.geoNicknames[event.pubkey.lowercased()] = nick + } + // Update participants last-seen for this pubkey + self.recordGeoParticipant(pubkeyHex: event.pubkey) + let senderName = self.displayNameForNostrPubkey(event.pubkey) + let content = event.content + let timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + let mentions = self.parseMentions(from: content) + let msg = BitchatMessage( + id: event.id, + sender: senderName, + content: content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: "nostr:\(event.pubkey.prefix(8))", + mentions: mentions.isEmpty ? nil : mentions + ) + Task { @MainActor in + self.handlePublicMessage(msg) + self.checkForMentions(msg) + self.sendHapticFeedback(for: msg) + } + } + // Resubscribe geohash DMs for this identity + if let dmSub = geoDmSubscriptionID { NostrRelayManager.shared.unsubscribe(id: dmSub); geoDmSubscriptionID = nil } + do { + let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) + let dmSub = "geo-dm-\(ch.geohash)" + geoDmSubscriptionID = dmSub + let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-86400)) + NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in + guard let self = self else { return } + if self.processedNostrEvents.contains(giftWrap.id) { return } + self.recordProcessedEvent(giftWrap.id) + guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else { return } + guard content.hasPrefix("bitchat1:") else { return } + guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))), + let packet = BitchatPacket.from(packetData) else { return } + guard packet.type == MessageType.noiseEncrypted.rawValue else { return } + guard let noisePayload = NoisePayload.decode(packet.payload) else { return } + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) + let convKey = "nostr_" + String(senderPubkey.prefix(16)) + self.nostrKeyMapping[convKey] = senderPubkey + switch noisePayload.type { + case .privateMessage: + guard let pm = PrivateMessagePacket.decode(from: noisePayload.data) else { return } + let messageId = pm.messageID + // Send delivery ACK immediately (once per message ID) + if !self.sentGeoDeliveryAcks.contains(messageId) { + let nt = NostrTransport() + nt.senderPeerID = self.meshService.myPeerID + nt.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id) + self.sentGeoDeliveryAcks.insert(messageId) + } + // Dedup storage + if self.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return } + for (_, arr) in self.privateChats { if arr.contains(where: { $0.id == messageId }) { return } } + let senderName = self.displayNameForNostrPubkey(senderPubkey) + let isViewing = (self.selectedPrivateChatPeer == convKey) + // pared back: omit view-state log + let wasReadBefore = self.sentReadReceipts.contains(messageId) + let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 + let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage + let msg = BitchatMessage( + id: messageId, + sender: senderName, + content: pm.content, + timestamp: messageTimestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: self.nickname, + senderPeerID: convKey, + mentions: nil, + deliveryStatus: .delivered(to: self.nickname, at: Date()) + ) + if self.privateChats[convKey] == nil { self.privateChats[convKey] = [] } + self.privateChats[convKey]?.append(msg) + self.trimPrivateChatMessagesIfNeeded(for: convKey) + if shouldMarkUnread { self.unreadPrivateMessages.insert(convKey) } + if isViewing { + // pared back: omit pre-send READ log + if !wasReadBefore { + let nt = NostrTransport() + nt.senderPeerID = self.meshService.myPeerID + nt.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id) + self.sentReadReceipts.insert(messageId) + } + } else { + // Optionally notify if app backgrounded and message is truly unread + #if os(iOS) + if shouldMarkUnread && UIApplication.shared.applicationState != .active { + NotificationService.shared.sendPrivateMessageNotification( + from: senderName, + message: pm.content, + peerID: convKey + ) + } + #endif + } + self.objectWillChange.send() + case .delivered: + if let messageID = String(data: noisePayload.data, encoding: .utf8) { + if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { + self.privateChats[convKey]?[idx].deliveryStatus = .delivered(to: self.displayNameForNostrPubkey(senderPubkey), at: Date()) + self.objectWillChange.send() + SecureLogger.log("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", + category: SecureLogger.session, level: .info) + } else { + SecureLogger.log("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", + category: SecureLogger.session, level: .warning) + } + } + case .readReceipt: + if let messageID = String(data: noisePayload.data, encoding: .utf8) { + if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { + self.privateChats[convKey]?[idx].deliveryStatus = .read(by: self.displayNameForNostrPubkey(senderPubkey), at: Date()) + self.objectWillChange.send() + SecureLogger.log("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", + category: SecureLogger.session, level: .info) + } else { + SecureLogger.log("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", + category: SecureLogger.session, level: .warning) + } + } + } + } + } catch { } + } + #endif + // MARK: - Nickname Management private func loadNickname() { @@ -800,29 +1025,466 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let mentions = parseMentions(from: content) // Add message to local display + #if os(iOS) + var displaySender = nickname + var localSenderPeerID = meshService.myPeerID + if case .location(let ch) = activeChannel, + let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + let suffix = String(myGeoIdentity.publicKeyHex.suffix(4)) + displaySender = nickname + "#" + suffix + localSenderPeerID = "nostr:\(myGeoIdentity.publicKeyHex.prefix(8))" + } + #else + let displaySender = nickname + let localSenderPeerID = meshService.myPeerID + #endif + let message = BitchatMessage( - sender: nickname, + sender: displaySender, content: content, timestamp: Date(), isRelay: false, originalSender: nil, isPrivate: false, recipientNickname: nil, - senderPeerID: meshService.myPeerID, + senderPeerID: localSenderPeerID, mentions: mentions.isEmpty ? nil : mentions ) // Add to main messages immediately for user feedback messages.append(message) + // Persist to channel-specific timelines + #if os(iOS) + switch activeChannel { + case .mesh: + meshTimeline.append(message) + trimMeshTimelineIfNeeded() + case .location(let ch): + var arr = geoTimelines[ch.geohash] ?? [] + arr.append(message) + if arr.count > geoTimelineCap { + let remove = arr.count - geoTimelineCap + arr.removeFirst(remove) + } + geoTimelines[ch.geohash] = arr + } + #else + meshTimeline.append(message) + trimMeshTimelineIfNeeded() + #endif trimMessagesIfNeeded() // Force immediate UI update for user's own messages objectWillChange.send() + + // Update channel activity time on send + #if os(iOS) + switch activeChannel { + case .mesh: + lastPublicActivityAt["mesh"] = Date() + case .location(let ch): + lastPublicActivityAt["geo:\(ch.geohash)"] = Date() + } + #endif - // Send via mesh with mentions + #if os(iOS) + if case .location(let ch) = activeChannel { + // Send to geohash channel via Nostr ephemeral + Task { @MainActor in + do { + let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: content, + geohash: ch.geohash, + senderIdentity: identity, + nickname: self.nickname + ) + NostrRelayManager.shared.sendEvent(event) + // Track ourselves as active participant + self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) + } catch { + SecureLogger.log("❌ Failed to send geohash message: \(error)", category: SecureLogger.session, level: .error) + self.addSystemMessage("failed to send to location channel") + } + } + } else { + // Send via mesh with mentions + meshService.sendMessage(content, mentions: mentions) + } + #else + // Send via mesh with mentions (non-iOS) meshService.sendMessage(content, mentions: mentions) + #endif } } + + #if os(iOS) + @MainActor + private func switchLocationChannel(to channel: ChannelID) { + activeChannel = channel + // Reset deduplication set and optionally hydrate timeline for mesh + processedNostrEvents.removeAll() + processedNostrEventOrder.removeAll() + switch channel { + case .mesh: + messages = meshTimeline + stopGeoParticipantsTimer() + geohashPeople = [] + case .location(let ch): + messages = geoTimelines[ch.geohash] ?? [] + } + // Unsubscribe previous + if let sub = geoSubscriptionID { + NostrRelayManager.shared.unsubscribe(id: sub) + geoSubscriptionID = nil + } + if let dmSub = geoDmSubscriptionID { + NostrRelayManager.shared.unsubscribe(id: dmSub) + geoDmSubscriptionID = nil + } + currentGeohash = nil + // Reset nickname cache for geochat participants + geoNicknames.removeAll() + geohashPeople = [] + + guard case .location(let ch) = channel else { return } + currentGeohash = ch.geohash + let subID = "geo-\(ch.geohash)" + geoSubscriptionID = subID + startGeoParticipantsTimer() + let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200) + NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in + guard let self = self else { return } + // Only handle ephemeral kind 20000 with matching tag + guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } + // Deduplicate + if self.processedNostrEvents.contains(event.id) { return } + self.recordProcessedEvent(event.id) + // Skip our own events (we already locally echoed) + if let gh = self.currentGeohash, + let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), + myGeoIdentity.publicKeyHex.lowercased() == event.pubkey.lowercased() { + return + } + // Cache nickname from tag if present + if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 { + let nick = nickTag[1] + self.geoNicknames[event.pubkey.lowercased()] = nick + } + // Store mapping for geohash DM initiation + let key16 = "nostr_" + String(event.pubkey.prefix(16)) + self.nostrKeyMapping[key16] = event.pubkey + let key8 = "nostr:" + String(event.pubkey.prefix(8)) + self.nostrKeyMapping[key8] = event.pubkey + // Update participants last-seen for this pubkey + self.recordGeoParticipant(pubkeyHex: event.pubkey) + + let senderName = self.displayNameForNostrPubkey(event.pubkey) + let content = event.content + let timestamp = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + let mentions = self.parseMentions(from: content) + let msg = BitchatMessage( + id: event.id, + sender: senderName, + content: content, + timestamp: timestamp, + isRelay: false, + originalSender: nil, + isPrivate: false, + recipientNickname: nil, + senderPeerID: "nostr:\(event.pubkey.prefix(8))", + mentions: mentions.isEmpty ? nil : mentions + ) + Task { @MainActor in + self.handlePublicMessage(msg) + self.checkForMentions(msg) + self.sendHapticFeedback(for: msg) + } + } + + // Subscribe to Nostr gift wraps (DMs) for this geohash identity + do { + let id = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) + let dmSub = "geo-dm-\(ch.geohash)" + geoDmSubscriptionID = dmSub + // pared back logging: subscribe debug only + SecureLogger.log("GeoDM: subscribing DMs pub=\(id.publicKeyHex.prefix(8))… sub=\(dmSub)", + category: SecureLogger.session, level: .debug) + let dmFilter = NostrFilter.giftWrapsFor(pubkey: id.publicKeyHex, since: Date().addingTimeInterval(-86400)) + NostrRelayManager.shared.subscribe(filter: dmFilter, id: dmSub) { [weak self] giftWrap in + guard let self = self else { return } + // Dedup basic + if self.processedNostrEvents.contains(giftWrap.id) { return } + self.recordProcessedEvent(giftWrap.id) + // Decrypt with per-geohash identity + guard let (content, senderPubkey, rumorTs) = try? NostrProtocol.decryptPrivateMessage(giftWrap: giftWrap, recipientIdentity: id) else { + SecureLogger.log("GeoDM: failed decrypt giftWrap id=\(giftWrap.id.prefix(8))…", + category: SecureLogger.session, level: .warning) + return + } + SecureLogger.log("GeoDM: decrypted gift-wrap id=\(giftWrap.id.prefix(16))... from=\(senderPubkey.prefix(8))...", + category: SecureLogger.session, level: .debug) + guard content.hasPrefix("bitchat1:") else { return } + guard let packetData = Self.base64URLDecode(String(content.dropFirst("bitchat1:".count))), + let packet = BitchatPacket.from(packetData) else { return } + guard packet.type == MessageType.noiseEncrypted.rawValue else { return } + guard let noisePayload = NoisePayload.decode(packet.payload) else { return } + let messageTimestamp = Date(timeIntervalSince1970: TimeInterval(rumorTs)) + let convKey = "nostr_" + String(senderPubkey.prefix(16)) + self.nostrKeyMapping[convKey] = senderPubkey + switch noisePayload.type { + case .privateMessage: + guard let pm = PrivateMessagePacket.decode(from: noisePayload.data) else { return } + let messageId = pm.messageID + SecureLogger.log("GeoDM: recv PM <- sender=\(senderPubkey.prefix(8))… mid=\(messageId.prefix(8))…", + category: SecureLogger.session, level: .info) + // Send delivery ACK immediately (even if duplicate), once per messageID + if !self.sentGeoDeliveryAcks.contains(messageId) { + let nostrTransport = NostrTransport() + nostrTransport.senderPeerID = self.meshService.myPeerID + // pared back: omit pre-send log + nostrTransport.sendDeliveryAckGeohash(for: messageId, toRecipientHex: senderPubkey, from: id) + self.sentGeoDeliveryAcks.insert(messageId) + } + // Duplicate check + if self.privateChats[convKey]?.contains(where: { $0.id == messageId }) == true { return } + for (_, arr) in self.privateChats { if arr.contains(where: { $0.id == messageId }) { return } } + let senderName = self.displayNameForNostrPubkey(senderPubkey) + let isViewing = (self.selectedPrivateChatPeer == convKey) + let wasReadBefore = self.sentReadReceipts.contains(messageId) + let isRecentMessage = Date().timeIntervalSince(messageTimestamp) < 30 + let shouldMarkUnread = !wasReadBefore && !isViewing && isRecentMessage + let msg = BitchatMessage( + id: messageId, + sender: senderName, + content: pm.content, + timestamp: messageTimestamp, + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: self.nickname, + senderPeerID: convKey, + mentions: nil, + deliveryStatus: .delivered(to: self.nickname, at: Date()) + ) + if self.privateChats[convKey] == nil { self.privateChats[convKey] = [] } + self.privateChats[convKey]?.append(msg) + self.trimPrivateChatMessagesIfNeeded(for: convKey) + if shouldMarkUnread { self.unreadPrivateMessages.insert(convKey) } + // Send READ if viewing this conversation + if isViewing { + // pared back: omit pre-send READ log + if !wasReadBefore { + let nostrTransport = NostrTransport() + nostrTransport.senderPeerID = self.meshService.myPeerID + nostrTransport.sendReadReceiptGeohash(messageId, toRecipientHex: senderPubkey, from: id) + self.sentReadReceipts.insert(messageId) + } + } else { + // pared back: omit defer READ log + } + // Notify only when app is backgrounded and not viewing, and only if not already read + #if os(iOS) + if !isViewing && shouldMarkUnread { + if UIApplication.shared.applicationState != .active { + NotificationService.shared.sendPrivateMessageNotification( + from: senderName, + message: pm.content, + peerID: convKey + ) + // pared back: omit notification log + } + } + #endif + self.objectWillChange.send() + default: + // Handle delivered/read receipts for our sent messages + switch noisePayload.type { + case .delivered: + if let messageID = String(data: noisePayload.data, encoding: .utf8) { + if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { + self.privateChats[convKey]?[idx].deliveryStatus = .delivered(to: self.displayNameForNostrPubkey(senderPubkey), at: Date()) + self.objectWillChange.send() + SecureLogger.log("GeoDM: recv DELIVERED for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", + category: SecureLogger.session, level: .info) + } else { + SecureLogger.log("GeoDM: delivered ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", + category: SecureLogger.session, level: .warning) + } + } + case .readReceipt: + if let messageID = String(data: noisePayload.data, encoding: .utf8) { + if let idx = self.privateChats[convKey]?.firstIndex(where: { $0.id == messageID }) { + self.privateChats[convKey]?[idx].deliveryStatus = .read(by: self.displayNameForNostrPubkey(senderPubkey), at: Date()) + self.objectWillChange.send() + SecureLogger.log("GeoDM: recv READ for mid=\(messageID.prefix(8))… from=\(senderPubkey.prefix(8))…", + category: SecureLogger.session, level: .info) + } else { + SecureLogger.log("GeoDM: read ack for unknown mid=\(messageID.prefix(8))… conv=\(convKey)", + category: SecureLogger.session, level: .warning) + } + } + default: + break + } + } + } + } catch { + // ignore + } + } + + // MARK: - Geohash Participants (iOS) + #if os(iOS) + struct GeoPerson: Identifiable, Equatable { + let id: String // pubkey hex (lowercased) + let displayName: String + let lastSeen: Date + } + + private func recordGeoParticipant(pubkeyHex: String) { + guard let gh = currentGeohash else { return } + let key = pubkeyHex.lowercased() + var map = geoParticipants[gh] ?? [:] + map[key] = Date() + geoParticipants[gh] = map + refreshGeohashPeople() + } + + private func recordGeoParticipant(pubkeyHex: String, geohash: String) { + let key = pubkeyHex.lowercased() + var map = geoParticipants[geohash] ?? [:] + map[key] = Date() + geoParticipants[geohash] = map + // Only refresh list if this geohash is currently selected + if currentGeohash == geohash { + refreshGeohashPeople() + } + } + + private func refreshGeohashPeople() { + guard let gh = currentGeohash else { geohashPeople = []; return } + let cutoff = Date().addingTimeInterval(-5 * 60) + var map = geoParticipants[gh] ?? [:] + // Prune expired entries + map = map.filter { $0.value >= cutoff } + geoParticipants[gh] = map + // Build display list + let people = map + .map { (pub, seen) in + GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) + } + .sorted { $0.lastSeen > $1.lastSeen } + geohashPeople = people + } + + private func startGeoParticipantsTimer() { + stopGeoParticipantsTimer() + geoParticipantsTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.refreshGeohashPeople() + } + } + } + + private func stopGeoParticipantsTimer() { + geoParticipantsTimer?.invalidate() + geoParticipantsTimer = nil + } + #endif + + // MARK: - Public helpers (iOS) + #if os(iOS) + /// Returns the current participant count for a specific geohash, using the 5-minute activity window. + @MainActor + func geohashParticipantCount(for geohash: String) -> Int { + let cutoff = Date().addingTimeInterval(-5 * 60) + let map = geoParticipants[geohash] ?? [:] + return map.values.filter { $0 >= cutoff }.count + } + + /// Begin sampling multiple geohashes (used by channel sheet) without changing active channel. + @MainActor + func beginGeohashSampling(for geohashes: [String]) { + // Determine which to add and which to remove + let desired = Set(geohashes) + let current = Set(geoSamplingSubs.values) + let toAdd = desired.subtracting(current) + let toRemove = current.subtracting(desired) + + // Unsubscribe removed + for (subID, gh) in geoSamplingSubs where toRemove.contains(gh) { + NostrRelayManager.shared.unsubscribe(id: subID) + geoSamplingSubs.removeValue(forKey: subID) + } + + // Subscribe new + for gh in toAdd { + let subID = "geo-sample-\(gh)" + geoSamplingSubs[subID] = gh + let filter = NostrFilter.geohashEphemeral(gh, since: Date().addingTimeInterval(-300), limit: 100) + NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in + guard let self = self else { return } + guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } + // Update participants for this specific geohash + self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) + } + } + } + + /// Stop sampling all extra geohashes. + @MainActor + func endGeohashSampling() { + for subID in geoSamplingSubs.keys { NostrRelayManager.shared.unsubscribe(id: subID) } + geoSamplingSubs.removeAll() + } + #endif + + private func displayNameForNostrPubkey(_ pubkeyHex: String) -> String { + let suffix = String(pubkeyHex.suffix(4)) + // If this is our per-geohash identity, use our nickname + if let gh = currentGeohash, let myGeoIdentity = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh) { + if myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() { + return nickname + "#" + suffix + } + } + // If we have a known nickname tag for this pubkey, use it + if let nick = geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty { + return nick + "#" + suffix + } + // Otherwise, anonymous with collision-resistant suffix + return "anon#\(suffix)" + } + + // Helper: display name for current active channel (for notifications) + private func activeChannelDisplayName() -> String { + #if os(iOS) + switch activeChannel { + case .mesh: + return "#mesh" + case .location(let ch): + return "#\(ch.geohash)" + } + #else + return "#mesh" + #endif + } + + // Dedup helper with small memory cap + private func recordProcessedEvent(_ id: String) { + processedNostrEvents.insert(id) + processedNostrEventOrder.append(id) + if processedNostrEventOrder.count > maxProcessedNostrEvents { + let overflow = processedNostrEventOrder.count - maxProcessedNostrEvents + for _ in 0.. String? { + return nostrKeyMapping[senderID] + } + + @MainActor + func geohashDisplayName(for convKey: String) -> String { + guard let full = nostrKeyMapping[convKey] else { + let suffix = String(convKey.suffix(4)) + return "anon#\(suffix)" + } + let suffix = String(full.suffix(4)) + if let nick = geoNicknames[full.lowercased()], !nick.isEmpty { + return nick + "#" + suffix + } + return "anon#\(suffix)" + } + #endif /// Add a local system message to a private chat (no network send) @MainActor func addLocalPrivateSystemMessage(_ content: String, to peerID: String) { @@ -1125,13 +1875,18 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } - // Trigger handshake if needed - let sessionState = meshService.getNoiseSessionState(for: peerID) - switch sessionState { - case .none, .failed: - meshService.triggerHandshake(with: peerID) - default: - break + // Trigger handshake if needed (mesh peers only). Skip for Nostr geohash conv keys. + if !peerID.hasPrefix("nostr_") && !peerID.hasPrefix("nostr:") { + let sessionState = meshService.getNoiseSessionState(for: peerID) + switch sessionState { + case .none, .failed: + meshService.triggerHandshake(with: peerID) + default: + break + } + } else { + SecureLogger.log("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", + category: SecureLogger.session, level: .debug) } // Delegate to private chat manager but add already-acked messages first @@ -1345,6 +2100,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { self.markPrivateMessagesAsRead(from: peerID) } } + // Also resubscribe the current geohash channel if active + #if os(iOS) + resubscribeCurrentGeohash() + #endif } @MainActor @@ -1468,6 +2227,27 @@ class ChatViewModel: ObservableObject, BitchatDelegate { func markPrivateMessagesAsRead(from peerID: String) { privateChatManager.markAsRead(from: peerID) + // Handle GeoDM (nostr_*) read receipts directly via per-geohash identity + #if os(iOS) + if peerID.hasPrefix("nostr_"), + let recipientHex = nostrKeyMapping[peerID], + case .location(let ch) = LocationChannelManager.shared.selectedChannel, + let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + let messages = privateChats[peerID] ?? [] + for message in messages where message.senderPeerID == peerID && !message.isRelay { + if !sentReadReceipts.contains(message.id) { + SecureLogger.log("GeoDM: sending READ for mid=\(message.id.prefix(8))… to=\(recipientHex.prefix(8))…", + category: SecureLogger.session, level: .debug) + let nostrTransport = NostrTransport() + nostrTransport.senderPeerID = meshService.myPeerID + nostrTransport.sendReadReceiptGeohash(message.id, toRecipientHex: recipientHex, from: id) + sentReadReceipts.insert(message.id) + } + } + return + } + #endif + // Get the peer's Noise key to check for Nostr messages var noiseKeyHex: String? = nil var peerNostrPubkey: String? = nil @@ -1563,6 +2343,23 @@ class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor func getPeerIDForNickname(_ nickname: String) -> String? { + #if os(iOS) + // When in a geohash channel, allow resolving by geohash participant nickname + switch LocationChannelManager.shared.selectedChannel { + case .location: + let base: String = { + if let hashIndex = nickname.firstIndex(of: "#") { return String(nickname[.. nickname) + if let pub = geoNicknames.first(where: { (_, nick) in nick.lowercased() == base })?.key { + let convKey = "nostr_" + String(pub.prefix(16)) + nostrKeyMapping[convKey] = pub + return convKey + } + default: break + } + #endif return unifiedPeerService.getPeerID(for: nickname) } @@ -1668,12 +2465,36 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Autocomplete func updateAutocomplete(for text: String, cursorPosition: Int) { - // Get peer nicknames but exclude self - let allPeers = meshService.getPeerNicknames().values - let peers = allPeers.filter { $0 != meshService.myNickname } + // Build candidate list based on active channel + let peerCandidates: [String] = { + #if os(iOS) + switch activeChannel { + case .mesh: + let values = meshService.getPeerNicknames().values + return Array(values.filter { $0 != meshService.myNickname }) + case .location(let ch): + // From geochash participants we have seen via Nostr events + var tokens = Set() + for (pubkey, nick) in geoNicknames { + let suffix = String(pubkey.suffix(4)) + tokens.insert("\(nick)#\(suffix)") + } + // Optionally exclude self nick#abcd from suggestions + if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + let myToken = nickname + "#" + String(id.publicKeyHex.suffix(4)) + tokens.remove(myToken) + } + return Array(tokens) + } + #else + let values = meshService.getPeerNicknames().values + return Array(values.filter { $0 != meshService.myNickname }) + #endif + }() + let (suggestions, range) = autocompleteService.getSuggestions( for: text, - peers: Array(peers), + peers: peerCandidates, cursorPosition: cursorPosition ) @@ -2189,6 +3010,31 @@ class ChatViewModel: ObservableObject, BitchatDelegate { messages.removeFirst(removeCount) } } + + private func trimMeshTimelineIfNeeded() { + if meshTimeline.count > meshTimelineCap { + let removeCount = meshTimeline.count - meshTimelineCap + meshTimeline.removeFirst(removeCount) + } + } + + // Clear the current public channel's timeline (visible + persistent buffer) + @MainActor + func clearCurrentPublicTimeline() { + #if os(iOS) + switch activeChannel { + case .mesh: + messages.removeAll() + meshTimeline.removeAll() + case .location(let ch): + messages.removeAll() + geoTimelines[ch.geohash] = [] + } + #else + messages.removeAll() + meshTimeline.removeAll() + #endif + } private func trimPrivateChatMessagesIfNeeded(for peerID: String) { // Handled by PrivateChatManager @@ -2208,7 +3054,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { private func addPrivateMessage(_ message: BitchatMessage, for peerID: String) { // Deprecated - messages are now added directly in didReceiveMessage to avoid double processing } - + // Update encryption status in appropriate places, not during view updates @MainActor private func updateEncryptionStatus(for peerID: String) { @@ -2889,6 +3735,32 @@ class ChatViewModel: ObservableObject, BitchatDelegate { addSystemMessage(content) objectWillChange.send() } + // Send a public message without adding a local user echo. + // Used for emotes where we want a local system-style confirmation instead. + @MainActor + func sendPublicRaw(_ content: String) { + #if os(iOS) + if case .location(let ch) = activeChannel { + Task { @MainActor in + do { + let identity = try NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) + let event = try NostrProtocol.createEphemeralGeohashEvent( + content: content, + geohash: ch.geohash, + senderIdentity: identity, + nickname: self.nickname + ) + NostrRelayManager.shared.sendEvent(event) + } catch { + SecureLogger.log("❌ Failed to send geohash raw message: \(error)", category: SecureLogger.session, level: .error) + } + } + return + } + #endif + // Default: send over mesh + meshService.sendMessage(content, mentions: []) + } // MARK: - Simplified Nostr Integration (Inlined from MessageRouter) @@ -3673,15 +4545,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate { updatePrivateChatPeerIfNeeded() // Handle notifications and read receipts - // Check if we should send notification + // Check if we should send notification (only for truly unread and recent messages) if selectedPrivateChatPeer != peerID { unreadPrivateMessages.insert(peerID) - - NotificationService.shared.sendPrivateMessageNotification( - from: message.sender, - message: message.content, - peerID: peerID - ) + // Avoid notifying for messages that have been marked read already (resubscribe/dup cases) + if !sentReadReceipts.contains(message.id) { + NotificationService.shared.sendPrivateMessageNotification( + from: message.sender, + message: message.content, + peerID: peerID + ) + } } else { // User is viewing this chat - no notification needed unreadPrivateMessages.remove(peerID) @@ -3724,32 +4598,93 @@ class ChatViewModel: ObservableObject, BitchatDelegate { /// Handle incoming public message private func handlePublicMessage(_ message: BitchatMessage) { let finalMessage = processActionMessage(message) - - // Check if this is our own message being echoed back + + // Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system) + let isGeo = finalMessage.senderPeerID?.hasPrefix("nostr:") == true + + // Persist mesh messages to mesh timeline always + if !isGeo && finalMessage.sender != "system" { + meshTimeline.append(finalMessage) + trimMeshTimelineIfNeeded() + } + + // Persist geochat messages to per-geohash timeline (iOS-only) + #if os(iOS) + if isGeo && finalMessage.sender != "system" { + if let gh = currentGeohash { + var arr = geoTimelines[gh] ?? [] + arr.append(finalMessage) + if arr.count > geoTimelineCap { + let remove = arr.count - geoTimelineCap + arr.removeFirst(remove) + } + geoTimelines[gh] = arr + } + } + #endif + + // Only add message to current timeline if it matches active channel or is system + let isSystem = finalMessage.sender == "system" + let channelMatches: Bool = { + #if os(iOS) + switch activeChannel { + case .mesh: return !isGeo || isSystem + case .location: return isGeo || isSystem + } + #else + // On non-iOS builds, we don't have location channels; accept all + return true + #endif + }() + + guard channelMatches else { return } + + // Background nudge: notify on new activity after inactivity threshold in current channel + #if os(iOS) + if UIApplication.shared.applicationState != .active { + let channelKey: String = { + switch activeChannel { + case .mesh: return "mesh" + case .location(let ch): return "geo:\(ch.geohash)" + } + }() + let now = Date() + if let last = lastPublicActivityAt[channelKey], now.timeIntervalSince(last) >= channelInactivityThreshold { + // Optional: simple cooldown to avoid duplicate bursts + let lastNotified = lastPublicActivityNotifyAt[channelKey] ?? .distantPast + if now.timeIntervalSince(lastNotified) >= 60 { + let title = activeChannelDisplayName() + let body = "new chats!" + NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: "channel-activity-\(channelKey)-\(now.timeIntervalSince1970)") + lastPublicActivityNotifyAt[channelKey] = now + } + } + lastPublicActivityAt[channelKey] = now + } + #endif + + // Check if this is our own message being echoed back (avoid dup) if finalMessage.sender != nickname && finalMessage.sender != "system" { - // Skip empty messages if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { addMessage(finalMessage) } - } else if finalMessage.sender != "system" { + return + } + + if finalMessage.sender != "system" { // Check for duplicates let messageExists = messages.contains { existingMsg in - if existingMsg.id == finalMessage.id { - return true - } - if existingMsg.content == finalMessage.content && - existingMsg.sender == finalMessage.sender { + if existingMsg.id == finalMessage.id { return true } + if existingMsg.content == finalMessage.content && existingMsg.sender == finalMessage.sender { let timeDiff = abs(existingMsg.timestamp.timeIntervalSince(finalMessage.timestamp)) return timeDiff < 1.0 } return false } - if !messageExists && !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { addMessage(finalMessage) } } else { - // System message if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { addMessage(finalMessage) } @@ -3782,13 +4717,25 @@ private func checkForMentions(_ message: BitchatMessage) { #if os(iOS) guard UIApplication.shared.applicationState == .active else { return } - let isHugForMe = message.content.contains("🫂") && - (message.content.contains("hugs \(nickname)") || - message.content.contains("hugs you")) - - let isSlapForMe = message.content.contains("🐟") && - (message.content.contains("slaps \(nickname) around") || - message.content.contains("slaps you around")) + // Build acceptable target tokens: base nickname and, if in a location channel, nickname with '#abcd' + var tokens: [String] = [nickname] + #if os(iOS) + switch activeChannel { + case .location(let ch): + if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + let d = String(id.publicKeyHex.suffix(4)) + tokens.append(nickname + "#" + d) + } + default: + break + } + #endif + + let hugsMe = tokens.contains { message.content.contains("hugs \($0)") } || message.content.contains("hugs you") + let slapsMe = tokens.contains { message.content.contains("slaps \($0) around") } || message.content.contains("slaps you around") + + let isHugForMe = message.content.contains("🫂") && hugsMe + let isSlapForMe = message.content.contains("🐟") && slapsMe if isHugForMe && message.sender != nickname { // Long warm haptic for hugs diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index dbf59fe3..8e953a6d 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -19,7 +19,7 @@ struct AppInfoView: View { // MARK: - Constants private enum Strings { static let appName = "bitchat" - static let tagline = "mesh sidegroupchat" + static let tagline = "sidegroupchat" enum Features { static let title = "FEATURES" @@ -28,7 +28,7 @@ struct AppInfoView: View { static let extendedRange = ("antenna.radiowaves.left.and.right", "extended range", "messages relay through peers, going the distance") static let mentions = ("at", "mentions", "use @nickname to notify specific people") static let favorites = ("star.fill", "favorites", "get notified when your favorite people join") - static let mutualFavorites = ("globe", "mutual favorites", "private message each other via nostr when out of mesh range") + static let geohash = ("number", "local channels", "geohash channels to chat with people in nearby regions over decentralized anonymous relays") } enum Privacy { @@ -42,10 +42,11 @@ struct AppInfoView: View { static let title = "HOW TO USE" static let instructions = [ "• set your nickname by tapping it", - "• swipe left for sidebar", - "• tap a peer to start a private chat", - "• use @nickname to mention someone", - "• triple-tap chat to clear" + "• tap #mesh to change channels", + "• tap people icon for sidebar", + "• tap a peer's name to start a DM", + "• triple-tap chat to clear", + "• type / for commands" ] } @@ -131,9 +132,9 @@ struct AppInfoView: View { title: Strings.Features.favorites.1, description: Strings.Features.favorites.2) - FeatureRow(icon: Strings.Features.mutualFavorites.0, - title: Strings.Features.mutualFavorites.1, - description: Strings.Features.mutualFavorites.2) + FeatureRow(icon: Strings.Features.geohash.0, + title: Strings.Features.geohash.1, + description: Strings.Features.geohash.2) FeatureRow(icon: Strings.Features.mentions.0, title: Strings.Features.mentions.1, diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 045f3bdf..88b60e1f 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -58,6 +58,9 @@ struct ContentView: View { // MARK: - Properties @EnvironmentObject var viewModel: ChatViewModel + #if os(iOS) + @ObservedObject private var locationManager = LocationChannelManager.shared + #endif @State private var messageText = "" @State private var textFieldSelection: NSRange? = nil @FocusState private var isTextFieldFocused: Bool @@ -77,6 +80,7 @@ struct ContentView: View { @State private var lastScrollTime: Date = .distantPast @State private var scrollThrottleTimer: Timer? @State private var autocompleteDebounceTimer: Timer? + @State private var showLocationChannelsSheet = false // MARK: - Computed Properties @@ -202,7 +206,17 @@ struct ContentView: View { ) { Button("private message") { if let peerID = selectedMessageSenderID { + #if os(iOS) + if peerID.hasPrefix("nostr:") { + if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) { + viewModel.startGeohashDM(withPubkeyHex: full) + } + } else { + viewModel.startPrivateChat(with: peerID) + } + #else viewModel.startPrivateChat(with: peerID) + #endif withAnimation(.easeInOut(duration: 0.2)) { showSidebar = false sidebarDragOffset = 0 @@ -601,7 +615,7 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 0) { // Header - match main toolbar height HStack { - Text("NETWORK") + Text("PEOPLE") .font(.system(size: 16, weight: .bold, design: .monospaced)) .foregroundColor(textColor) Spacer() @@ -617,54 +631,102 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 6) { // People section VStack(alignment: .leading, spacing: 4) { - // Show appropriate header based on context - if !viewModel.allPeers.isEmpty { - HStack(spacing: 4) { - Image(systemName: "person.2.fill") - .font(.system(size: 10)) - .accessibilityHidden(true) - Text("PEOPLE") - .font(.system(size: 11, weight: .bold, design: .monospaced)) - } - .foregroundColor(secondaryTextColor) - .padding(.horizontal, 12) - .padding(.top, 12) - } - - if viewModel.allPeers.isEmpty { - Text("nobody around...") - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(secondaryTextColor) - .padding(.horizontal) - .padding(.top, 12) - } else { - // Extract peer data for display - let peerNicknames = viewModel.meshService.getPeerNicknames() - - // Show all peers (connected and favorites) - // Pre-compute peer data outside ForEach to reduce overhead - let peerData = viewModel.allPeers.map { peer in - // Get current myPeerID for each peer to avoid stale values - let currentMyPeerID = viewModel.meshService.myPeerID - return PeerDisplayData( - id: peer.id, - displayName: peer.id == currentMyPeerID ? viewModel.nickname : peer.nickname, - isFavorite: peer.favoriteStatus?.isFavorite ?? false, - isMe: peer.id == currentMyPeerID, - hasUnreadMessages: viewModel.hasUnreadMessages(for: peer.id), - encryptionStatus: viewModel.getEncryptionStatus(for: peer.id), - connectionState: peer.connectionState, - isMutualFavorite: peer.favoriteStatus?.isMutual ?? false - ) - }.sorted { (peer1: PeerDisplayData, peer2: PeerDisplayData) in - // Sort: favorites first, then alphabetically by nickname - if peer1.isFavorite != peer2.isFavorite { - return peer1.isFavorite + #if os(iOS) + switch locationManager.selectedChannel { + case .location: + if viewModel.geohashPeople.isEmpty { + Text("nobody around...") + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(secondaryTextColor) + .padding(.horizontal) + .padding(.top, 12) + } else { + // Show 'you' at top and bold + let myHex: String? = { + if case .location(let ch) = locationManager.selectedChannel, + let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { + return id.publicKeyHex.lowercased() + } + return nil + }() + let ordered = viewModel.geohashPeople.sorted { a, b in + if let me = myHex { + if a.id == me && b.id != me { return true } + if b.id == me && a.id != me { return false } + } + return a.lastSeen > b.lastSeen + } + ForEach(ordered) { person in + HStack(spacing: 4) { + // Unread indicator matches mesh behavior + let convKey = "nostr_" + String(person.id.prefix(16)) + if viewModel.unreadPrivateMessages.contains(convKey) { + Image(systemName: "envelope.fill") + .font(.system(size: 12)) + .foregroundColor(Color.orange) + .accessibilityLabel("Unread message from \(person.displayName)") + } else { + Image(systemName: "person.fill") + .font(.system(size: 10)) + .foregroundColor(textColor) + } + Text(person.displayName + (person.id == myHex ? " (you)" : "")) + .font(.system(size: 14, design: .monospaced)) + .fontWeight(person.id == myHex ? .bold : .regular) + .foregroundColor(textColor) + Spacer() + } + .padding(.horizontal) + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { + // Open DM with this participant (not for self) + if person.id != myHex { + viewModel.startGeohashDM(withPubkeyHex: person.id) + withAnimation(.easeInOut(duration: 0.2)) { + showSidebar = false + sidebarDragOffset = 0 + } + } + } } - return peer1.displayName < peer2.displayName } - - ForEach(peerData) { peer in + default: + // Mesh peers list (original) + if viewModel.allPeers.isEmpty { + Text("nobody around...") + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(secondaryTextColor) + .padding(.horizontal) + .padding(.top, 12) + } else { + // Extract peer data for display + let peerNicknames = viewModel.meshService.getPeerNicknames() + + // Show all peers (connected and favorites) + // Pre-compute peer data outside ForEach to reduce overhead + let peerData = viewModel.allPeers.map { peer in + // Get current myPeerID for each peer to avoid stale values + let currentMyPeerID = viewModel.meshService.myPeerID + return PeerDisplayData( + id: peer.id, + displayName: peer.id == currentMyPeerID ? viewModel.nickname : peer.nickname, + isFavorite: peer.favoriteStatus?.isFavorite ?? false, + isMe: peer.id == currentMyPeerID, + hasUnreadMessages: viewModel.hasUnreadMessages(for: peer.id), + encryptionStatus: viewModel.getEncryptionStatus(for: peer.id), + connectionState: peer.connectionState, + isMutualFavorite: peer.favoriteStatus?.isMutual ?? false + ) + }.sorted { (peer1: PeerDisplayData, peer2: PeerDisplayData) in + // Sort: favorites first, then alphabetically by nickname + if peer1.isFavorite != peer2.isFavorite { + return peer1.isFavorite + } + return peer1.displayName < peer2.displayName + } + + ForEach(peerData) { peer in HStack(spacing: 4) { // Signal strength indicator or unread message icon if peer.isMe { @@ -778,6 +840,9 @@ struct ContentView: View { } } } + // Close switch + } + #endif } } .id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined()) @@ -867,6 +932,26 @@ struct ContentView: View { return (name, "") } + #if os(iOS) + // Compute channel-aware people count and color for toolbar + private func channelPeopleCountAndColor() -> (Int, Color) { + switch locationManager.selectedChannel { + case .location: + let n = viewModel.geohashPeople.count + return (n, n > 0 ? Color.green : Color.secondary) + case .mesh: + let counts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in + guard peer.id != viewModel.meshService.myPeerID else { return } + let isMeshConnected = peer.isConnected + if isMeshConnected { counts.mesh += 1; counts.others += 1 } + else if peer.isMutualFavorite { counts.others += 1 } + } + let color: Color = counts.mesh > 0 ? Color.blue : Color.secondary + return (counts.others, color) + } + } + #endif + private var mainHeaderView: some View { HStack(spacing: 0) { @@ -918,26 +1003,62 @@ struct ContentView: View { .foregroundColor(Color.orange) .accessibilityLabel("Unread private messages") } - - // Single pass to count both metrics + + // People count depends on active channel + #if os(iOS) + let cc = channelPeopleCountAndColor() + let otherPeersCount = cc.0 + let countColor = cc.1 + #else let peerCounts = viewModel.allPeers.reduce(into: (others: 0, mesh: 0)) { counts, peer in guard peer.id != viewModel.meshService.myPeerID else { return } - let isMeshConnected = peer.isConnected - if isMeshConnected { - counts.mesh += 1 - counts.others += 1 - } else if peer.isMutualFavorite { - counts.others += 1 - } + if isMeshConnected { counts.mesh += 1; counts.others += 1 } + else if peer.isMutualFavorite { counts.others += 1 } } - let otherPeersCount = peerCounts.others - let meshPeerCount = peerCounts.mesh - - // Purple only if we have peers but none are reachable via mesh (only via Nostr) - let isNostrOnly = otherPeersCount > 0 && meshPeerCount == 0 + let countColor: Color = (peerCounts.mesh > 0) ? Color.blue : Color.secondary + #endif + // Location channels button '#' + #if os(iOS) + Button(action: { showLocationChannelsSheet = true }) { + #if os(iOS) + let badgeText: String = { + switch locationManager.selectedChannel { + case .mesh: + return "#mesh" + case .location(let ch): + return "#\(ch.geohash)" + } + }() + let badgeColor: Color = { + switch locationManager.selectedChannel { + case .mesh: + return Color.blue + case .location: + return Color.green + } + }() + Text(badgeText) + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(badgeColor) + .lineLimit(1) + .truncationMode(.head) + .frame(minWidth: 60, maxWidth: 160, alignment: .trailing) + .fixedSize(horizontal: false, vertical: false) + .accessibilityLabel("location channels") + #else + Text("#") + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(secondaryTextColor) + .accessibilityLabel("location channels") + #endif + } + .buttonStyle(.plain) + .padding(.trailing, 6) + #endif + HStack(spacing: 4) { // People icon with count Image(systemName: "person.2.fill") @@ -947,7 +1068,7 @@ struct ContentView: View { .font(.system(size: 12, design: .monospaced)) .accessibilityHidden(true) } - .foregroundColor(isNostrOnly ? Color.purple : (meshPeerCount > 0 ? Color.blue : Color.secondary)) + .foregroundColor(countColor) } .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { @@ -958,6 +1079,11 @@ struct ContentView: View { } .frame(height: 44) .padding(.horizontal, 12) + #if os(iOS) + .sheet(isPresented: $showLocationChannelsSheet) { + LocationChannelsSheet(isPresented: $showLocationChannelsSheet) + } + #endif .background(backgroundColor.opacity(0.95)) } @@ -991,12 +1117,21 @@ struct ContentView: View { // Resolve peer object for header context (may be offline favorite) let peer = viewModel.getPeer(byID: headerPeerID) - let privatePeerNick = peer?.displayName ?? - viewModel.meshService.peerNickname(peerID: headerPeerID) ?? - FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ?? - // getFavoriteStatusByNostrKey not implemented - // FavoritesPersistenceService.shared.getFavoriteStatusByNostrKey(privatePeerID)?.peerNickname ?? - "Unknown" + let privatePeerNick: String = { + if privatePeerID.hasPrefix("nostr_") { + #if os(iOS) + // Build geohash DM header: "#/@name#abcd" + if case .location(let ch) = locationManager.selectedChannel { + let disp = viewModel.geohashDisplayName(for: privatePeerID) + return "#\(ch.geohash)/@\(disp)" + } + #endif + } + return peer?.displayName ?? + viewModel.meshService.peerNickname(peerID: headerPeerID) ?? + FavoritesPersistenceService.shared.getFavoriteStatus(for: Data(hexString: headerPeerID) ?? Data())?.peerNickname ?? + "Unknown" + }() let isNostrAvailable: Bool = { guard let connectionState = peer?.connectionState else { // Check if we can reach this peer via Nostr even if not in allPeers @@ -1056,17 +1191,17 @@ struct ContentView: View { Text("\(privatePeerNick)") .font(.system(size: 16, weight: .medium, design: .monospaced)) - .foregroundColor(textColor) - - // Dynamic encryption status icon - let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID) - if let icon = encryptionStatus.icon { - Image(systemName: icon) - .font(.system(size: 14)) - .foregroundColor(encryptionStatus == .noiseVerified ? textColor : - encryptionStatus == .noiseSecured ? textColor : - Color.red) - .accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")") + .foregroundColor(textColor) // Dynamic encryption status icon (hide for geohash DMs) + if !privatePeerID.hasPrefix("nostr_") { + let encryptionStatus = viewModel.getEncryptionStatus(for: headerPeerID) + if let icon = encryptionStatus.icon { + Image(systemName: icon) + .font(.system(size: 14)) + .foregroundColor(encryptionStatus == .noiseVerified ? textColor : + encryptionStatus == .noiseSecured ? textColor : + Color.red) + .accessibilityLabel("Encryption status: \(encryptionStatus == .noiseVerified ? "verified" : encryptionStatus == .noiseSecured ? "secured" : "not encrypted")") + } } } .accessibilityLabel("Private chat with \(privatePeerNick)") @@ -1093,17 +1228,19 @@ struct ContentView: View { Spacer() - // Favorite button - Button(action: { - viewModel.toggleFavorite(peerID: headerPeerID) - }) { - Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star") - .font(.system(size: 16)) - .foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor) + // Favorite button (hidden for geohash DMs) + if !(privatePeerID.hasPrefix("nostr_")) { + Button(action: { + viewModel.toggleFavorite(peerID: headerPeerID) + }) { + Image(systemName: viewModel.isFavorite(peerID: headerPeerID) ? "star.fill" : "star") + .font(.system(size: 16)) + .foregroundColor(viewModel.isFavorite(peerID: headerPeerID) ? Color.yellow : textColor) + } + .buttonStyle(.plain) + .accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites") + .accessibilityHint("Double tap to toggle favorite status") } - .buttonStyle(.plain) - .accessibilityLabel(viewModel.isFavorite(peerID: privatePeerID) ? "Remove from favorites" : "Add to favorites") - .accessibilityHint("Double tap to toggle favorite status") } } .frame(height: 44) @@ -1115,7 +1252,7 @@ struct ContentView: View { // MARK: - Helper Views -// Helper view for rendering message content with clickable hashtags +// Helper view for rendering message content (plain, no hashtag/mention formatting) struct MessageContentView: View { let message: BitchatMessage let viewModel: ChatViewModel @@ -1123,106 +1260,15 @@ struct MessageContentView: View { let isMentioned: Bool var body: some View { - let content = message.content - let hashtagPattern = "#([a-zA-Z0-9_]+)" - let mentionPattern = "@([a-zA-Z0-9_]+)" - - let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: []) - let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: []) - - let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] - let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] - - // Combine all matches and sort by location - var allMatches: [(range: NSRange, type: String)] = [] - for match in hashtagMatches { - allMatches.append((match.range(at: 0), "hashtag")) - } - for match in mentionMatches { - allMatches.append((match.range(at: 0), "mention")) - } - allMatches.sort { $0.range.location < $1.range.location } - - // Build the text as a concatenated Text view for natural wrapping - let segments = buildTextSegments() - var result = Text("") - - for segment in segments { - if segment.type == "hashtag" { - // Note: We can't have clickable links in concatenated Text, so hashtags won't be clickable - result = result + Text(segment.text) - .font(.system(size: 14, weight: .semibold, design: .monospaced)) - .foregroundColor(Color.blue) - .underline() - } else if segment.type == "mention" { - result = result + Text(segment.text) - .font(.system(size: 14, weight: .semibold, design: .monospaced)) - .foregroundColor(Color.orange) - } else { - result = result + Text(segment.text) - .font(.system(size: 14, design: .monospaced)) - .fontWeight(isMentioned ? .bold : .regular) - } - } - - return result + Text(message.content) + .font(.system(size: 14, design: .monospaced)) + .fontWeight(isMentioned ? .bold : .regular) .textSelection(.enabled) } // MARK: - Helper Methods - private func buildTextSegments() -> [(text: String, type: String)] { - var segments: [(text: String, type: String)] = [] - let content = message.content - var lastEnd = content.startIndex - - let hashtagPattern = "#([a-zA-Z0-9_]+)" - let mentionPattern = "@([a-zA-Z0-9_]+)" - - let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: []) - let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: []) - - let hashtagMatches = hashtagRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] - let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] - - // Combine all matches and sort by location - var allMatches: [(range: NSRange, type: String)] = [] - for match in hashtagMatches { - allMatches.append((match.range(at: 0), "hashtag")) - } - for match in mentionMatches { - allMatches.append((match.range(at: 0), "mention")) - } - allMatches.sort { $0.range.location < $1.range.location } - - for (matchRange, matchType) in allMatches { - if let range = Range(matchRange, in: content) { - // Add text before the match - if lastEnd < range.lowerBound { - let beforeText = String(content[lastEnd.. 12 { + customGeohash = String(filtered.prefix(12)) + } else if filtered != newValue { + customGeohash = filtered + } + } + let normalized = customGeohash.trimmingCharacters(in: .whitespacesAndNewlines).lowercased().replacingOccurrences(of: "#", with: "") + let isValid = validateGeohash(normalized) + Button("teleport") { + let gh = normalized + guard isValid else { customError = "invalid geohash"; return } + let level = levelForLength(gh.count) + let ch = GeohashChannel(level: level, geohash: gh) + manager.select(ChannelID.location(ch)) + isPresented = false + } + .buttonStyle(.plain) + .font(.system(size: 14, design: .monospaced)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.secondary.opacity(0.12)) + .cornerRadius(6) + .opacity(isValid ? 1.0 : 0.4) + .disabled(!isValid) + } + if let err = customError { + Text(err) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.red) + } + } + + // Footer action inside the list + if manager.permissionState == LocationChannelManager.PermissionState.authorized { + Button(action: { + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }) { + Text("remove location access") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(Color(red: 0.75, green: 0.1, blue: 0.1)) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .background(Color.red.opacity(0.08)) + .cornerRadius(6) + } + .buttonStyle(.plain) + .listRowSeparator(.hidden) + } + } + .listStyle(.plain) + } + + private func isSelected(_ channel: GeohashChannel) -> Bool { + if case .location(let ch) = manager.selectedChannel { + return ch == channel + } + return false + } + + private var isMeshSelected: Bool { + if case .mesh = manager.selectedChannel { return true } + return false + } + + private func channelRow(title: String, subtitle: String, isSelected: Bool, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack { + VStack(alignment: .leading) { + // Render title with smaller font for trailing count in parentheses + let parts = splitTitleAndCount(title) + HStack(spacing: 4) { + Text(parts.base) + .font(.system(size: 14, design: .monospaced)) + if let count = parts.countSuffix, !count.isEmpty { + Text(count) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + } + } + Text(subtitle) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + } + Spacer() + if isSelected { + Text("✔︎") + .font(.system(size: 16, design: .monospaced)) + .foregroundColor(.green) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + // Split a title like "#mesh [3 people]" into base and suffix "[3 people]" + private func splitTitleAndCount(_ s: String) -> (base: String, countSuffix: String?) { + guard let idx = s.lastIndex(of: "[") else { return (s, nil) } + let prefix = String(s[.. String { + // Count currently connected mesh peers (excluding self) + let myID = viewModel.meshService.myPeerID + let meshCount = viewModel.allPeers.reduce(0) { acc, peer in + if peer.id != myID && peer.isConnected { return acc + 1 } + return acc + } + let noun = meshCount == 1 ? "person" : "people" + return "#mesh [\(meshCount) \(noun)]" + } + + private func geohashTitleWithCount(for channel: GeohashChannel) -> String { + // Use ViewModel's 5-minute activity counts; may be 0 for non-selected channels + let count = viewModel.geohashParticipantCount(for: channel.geohash) + let noun = count == 1 ? "person" : "people" + return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]" + } + + private func validateGeohash(_ s: String) -> Bool { + let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") + guard !s.isEmpty, s.count <= 12 else { return false } + return s.allSatisfy { allowed.contains($0) } + } + + private func levelForLength(_ len: Int) -> GeohashChannelLevel { + switch len { + case 0...2: return .country + case 3...4: return .region + case 5: return .city + case 6: return .neighborhood + case 7: return .block + default: return .street + } + } +} + +#endif diff --git a/bitchatTests/LocationChannelsTests.swift b/bitchatTests/LocationChannelsTests.swift new file mode 100644 index 00000000..22906625 --- /dev/null +++ b/bitchatTests/LocationChannelsTests.swift @@ -0,0 +1,48 @@ +import XCTest +@testable import bitchat + +final class LocationChannelsTests: XCTestCase { + func testGeohashEncoderPrecisionMapping() { + // Sanity: known coords (Statue of Liberty approx) + let lat = 40.6892 + let lon = -74.0445 + let street = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.street.precision) + let block = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.block.precision) + let neighborhood = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.neighborhood.precision) + let city = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.city.precision) + let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision) + let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.country.precision) + + XCTAssertEqual(street.count, 8) + XCTAssertEqual(block.count, 7) + XCTAssertEqual(neighborhood.count, 6) + XCTAssertEqual(city.count, 5) + XCTAssertEqual(region.count, 4) + XCTAssertEqual(country.count, 2) + + // All prefixes must match progressively + XCTAssertTrue(street.hasPrefix(block)) + XCTAssertTrue(block.hasPrefix(neighborhood)) + XCTAssertTrue(neighborhood.hasPrefix(city)) + XCTAssertTrue(city.hasPrefix(region)) + XCTAssertTrue(region.hasPrefix(country)) + } + + func testNostrGeohashFilterEncoding() throws { + let gh = "u4pruy" + let filter = NostrFilter.geohashEphemeral(gh) + let data = try JSONEncoder().encode(filter) + let json = String(data: data, encoding: .utf8) ?? "" + // Expect kinds includes 20000 and tag filter '#g':[gh] + XCTAssertTrue(json.contains("20000")) + XCTAssertTrue(json.contains("\"#g\":[\"\(gh)\"]")) + } + + func testPerGeohashIdentityDeterministic() throws { + // Derive twice for same geohash; should be identical + let gh = "u4pruy" + let id1 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh) + let id2 = try NostrIdentityBridge.deriveIdentity(forGeohash: gh) + XCTAssertEqual(id1.publicKeyHex, id2.publicKeyHex) + } +}