From 7c4c3f13915abd109f2687d65d3db2272d45a611 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Mon, 25 Aug 2025 16:29:52 +0200 Subject: [PATCH] macOS geohash parity: shared LocationChannelsSheet with permission CTA, enable CoreLocation on macOS, unify geohash participants/DMs, update ContentView (unread + QR on macOS), commands: hide/block /fav & /unfav in geohash, remove /help, make /who show geohash participants, fix ViewBuilder mutation+sheet toolbar, entitlements for mac location (#519) Co-authored-by: jack --- bitchat.xcodeproj/project.pbxproj | 5 + bitchat/Services/AutocompleteService.swift | 6 +- bitchat/Services/CommandProcessor.swift | 45 +++- bitchat/Services/LocationChannelManager.swift | 19 +- bitchat/ViewModels/ChatViewModel.swift | 101 ++------- bitchat/Views/ContentView.swift | 200 +++++------------- bitchat/Views/GeohashPeopleList.swift | 12 -- bitchat/Views/LocationChannelsSheet.swift | 52 +++-- bitchat/bitchat-macOS.entitlements | 4 +- 9 files changed, 163 insertions(+), 281 deletions(-) diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 117e8629..37f1f683 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -17,6 +17,7 @@ 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 */; }; 047502AC2E55E8360083520F /* BinaryProtocolPaddingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 047502AB2E55E8360083520F /* BinaryProtocolPaddingTests.swift */; }; @@ -245,6 +246,7 @@ 9AB6BE4ABD7F5088E9865E56 /* NoiseSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseSession.swift; sourceTree = ""; }; A08E03AA0C63E97C91749AEC /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryProtocol.swift; sourceTree = ""; }; + AA11BB22CC33DD44EE55FF68 /* MessageTextHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageTextHelpers.swift; sourceTree = ""; }; B1D6A89B36A3D31E590B94E5 /* NoiseHandshakeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoiseHandshakeCoordinator.swift; sourceTree = ""; }; C0DB1DE27F0AAB5092663E8E /* bitchatTests_iOS.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = bitchatTests_iOS.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -441,6 +443,7 @@ 047502B22E55FED60083520F /* GeohashPeopleList.swift */, 047502B32E55FED60083520F /* MeshPeerList.swift */, 0475028E2E5417660083520F /* LocationChannelsSheet.swift */, + 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */, A08E03AA0C63E97C91749AEC /* ContentView.swift */, 9195CDC7EB236AFBC9A4D41A /* FingerprintView.swift */, @@ -755,6 +758,7 @@ 7241FFD6CFFB875B864FA223 /* InputValidator.swift in Sources */, FB8819B4C84FAFEF5C36B216 /* KeychainManager.swift in Sources */, 0475028F2E5417660083520F /* LocationChannelsSheet.swift in Sources */, + 501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */, 0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */, AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */, @@ -812,6 +816,7 @@ EF49C600C1E464710DD6CA29 /* InputValidator.swift in Sources */, 8F737CE0435792CC2AD65FCB /* KeychainManager.swift in Sources */, 047502902E5417660083520F /* LocationChannelsSheet.swift in Sources */, + 5EE49E150BBF0488E7473687 /* NoiseEncryptionService.swift in Sources */, 0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */, 6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */, diff --git a/bitchat/Services/AutocompleteService.swift b/bitchat/Services/AutocompleteService.swift index 465567a5..0b8752c6 100644 --- a/bitchat/Services/AutocompleteService.swift +++ b/bitchat/Services/AutocompleteService.swift @@ -14,7 +14,7 @@ class AutocompleteService { private let commandRegex = try? NSRegularExpression(pattern: "^/([a-z]*)$", options: []) private let commands = [ - "/msg", "/who", "/clear", "/help", + "/msg", "/who", "/clear", "/hug", "/slap", "/fav", "/unfav", "/block", "/unblock" ] @@ -95,10 +95,10 @@ class AutocompleteService { private func needsArgument(command: String) -> Bool { switch command { - case "/who", "/clear", "/help": + case "/who", "/clear": return false default: return true } } -} \ No newline at end of file +} diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 2b1e1cba..662a4fd4 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -33,6 +33,15 @@ class CommandProcessor { guard let cmd = parts.first else { return .error(message: "Invalid command") } let args = parts.count > 1 ? String(parts[1]) : "" + // Geohash context: disable favoriting in public geohash or GeoDM + let inGeoPublic: Bool = { + switch LocationChannelManager.shared.selectedChannel { + case .mesh: return false + case .location: return true + } + }() + let inGeoDM = (chatViewModel?.selectedPrivateChatPeer?.hasPrefix("nostr_") == true) + switch cmd { case "/m", "/msg": return handleMessage(args) @@ -49,11 +58,14 @@ class CommandProcessor { case "/unblock": return handleUnblock(args) case "/fav": + if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: true) case "/unfav": + if inGeoPublic || inGeoDM { return .error(message: "favorites are only for mesh peers in #mesh") } return handleFavorite(args, add: false) + // /help removed case "/help", "/h": - return handleHelp() + return .error(message: "unknown command: \(cmd)") default: return .error(message: "unknown command: \(cmd)") } @@ -85,19 +97,34 @@ class CommandProcessor { } private func handleWho() -> CommandResult { - guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else { - return .success(message: "no one else is online right now") + // Show geohash participants when in a geohash channel; otherwise mesh peers + switch LocationChannelManager.shared.selectedChannel { + case .location(let ch): + // Geohash context: show visible geohash participants (exclude self) + guard let vm = chatViewModel else { return .success(message: "nobody around") } + let myHex = (try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash))?.publicKeyHex.lowercased() + let people = vm.visibleGeohashPeople().filter { person in + if let me = myHex { return person.id.lowercased() != me } + return true + } + let names = people.map { $0.displayName } + if names.isEmpty { return .success(message: "no one else is online right now") } + return .success(message: "online: " + names.sorted().joined(separator: ", ")) + case .mesh: + // Mesh context: show connected peer nicknames + guard let peers = meshService?.getPeerNicknames(), !peers.isEmpty else { + return .success(message: "no one else is online right now") + } + let onlineList = peers.values.sorted().joined(separator: ", ") + return .success(message: "online: \(onlineList)") } - - let onlineList = peers.values.sorted().joined(separator: ", ") - return .success(message: "online: \(onlineList)") } private func handleClear() -> CommandResult { if let peerID = chatViewModel?.selectedPrivateChatPeer { chatViewModel?.privateChats[peerID]?.removeAll() } else { - chatViewModel?.messages.removeAll() + chatViewModel?.clearCurrentPublicTimeline() } return .handled } @@ -165,12 +192,8 @@ class CommandProcessor { let geoBlocked = Array(SecureIdentityStateManager.shared.getBlockedNostrPubkeys()) var geoNames: [String] = [] if let vm = chatViewModel { - #if os(iOS) let visible = vm.visibleGeohashPeople() let visibleIndex = Dictionary(uniqueKeysWithValues: visible.map { ($0.id.lowercased(), $0.displayName) }) - #else - let visibleIndex: [String: String] = [:] - #endif for pk in geoBlocked { if let name = visibleIndex[pk.lowercased()] { geoNames.append(name) diff --git a/bitchat/Services/LocationChannelManager.swift b/bitchat/Services/LocationChannelManager.swift index 3bffc320..e33d9671 100644 --- a/bitchat/Services/LocationChannelManager.swift +++ b/bitchat/Services/LocationChannelManager.swift @@ -1,9 +1,9 @@ import Foundation - -#if os(iOS) -import CoreLocation import Combine +#if os(iOS) || os(macOS) +import CoreLocation + /// 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 { @@ -55,7 +55,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa teleported = teleportedSet.contains(ch.geohash) } let status: CLAuthorizationStatus - if #available(iOS 14.0, *) { + if #available(iOS 14.0, macOS 11.0, *) { status = cl.authorizationStatus } else { status = CLLocationManager.authorizationStatus() @@ -66,7 +66,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa // MARK: - Public API func enableLocationChannels() { let status: CLAuthorizationStatus - if #available(iOS 14.0, *) { + if #available(iOS 14.0, macOS 11.0, *) { status = cl.authorizationStatus } else { status = CLLocationManager.authorizationStatus() @@ -78,7 +78,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa Task { @MainActor in self.permissionState = .restricted } case .denied: Task { @MainActor in self.permissionState = .denied } - case .authorizedAlways, .authorizedWhenInUse: + case .authorizedAlways, .authorizedWhenInUse, .authorized: Task { @MainActor in self.permissionState = .authorized } requestOneShotLocation() @unknown default: @@ -151,8 +151,8 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa } } - // iOS 14+ - @available(iOS 14.0, *) + // iOS 14+ / macOS 11+ + @available(iOS 14.0, macOS 11.0, *) func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) { updatePermissionState(from: manager.authorizationStatus) if case .authorized = permissionState { @@ -180,7 +180,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa case .notDetermined: newState = .notDetermined case .restricted: newState = .restricted case .denied: newState = .denied - case .authorizedAlways, .authorizedWhenInUse: newState = .authorized + case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized @unknown default: newState = .restricted } Task { @MainActor in self.permissionState = newState } @@ -256,5 +256,4 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa return dict } } - #endif diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index f0238095..260d5308 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -352,14 +352,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { private let maxProcessedNostrEvents = 2000 private let userDefaults = UserDefaults.standard private let nicknameKey = "bitchat.nickname" - // Location channel state - #if os(iOS) + // Location channel state (macOS supports manual geohash selection) @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 @@ -389,7 +387,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // 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 @@ -405,7 +402,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { @Published private(set) var teleportedGeo: Set = [] // lowercased pubkey hex // Sampling subscriptions for multiple geohashes (when channel sheet is open) private var geoSamplingSubs: [String: String] = [:] // subID -> geohash - #endif // MARK: - Message Delivery Tracking @@ -592,8 +588,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { self.cancellables.insert(cancellable) - // Resubscribe geohash on relay reconnect (iOS only) - #if os(iOS) + // Resubscribe geohash on relay reconnect if let relayMgr = self.nostrRelayManager { relayMgr.$isConnected .receive(on: DispatchQueue.main) @@ -607,13 +602,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } .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) @@ -628,7 +621,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { Task { @MainActor in self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel) } - #endif // Request notification permission NotificationService.shared.requestAuthorization() @@ -714,8 +706,7 @@ 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() { @@ -897,7 +888,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } catch { } } - #endif // MARK: - Nickname Management @@ -1225,7 +1215,6 @@ 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, @@ -1234,10 +1223,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { displaySender = nickname + "#" + suffix localSenderPeerID = "nostr:\(myGeoIdentity.publicKeyHex.prefix(8))" } - #else - let displaySender = nickname - let localSenderPeerID = meshService.myPeerID - #endif let message = BitchatMessage( sender: displaySender, @@ -1257,7 +1242,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let ckey = normalizedContentKey(message.content) recordContentKey(ckey, timestamp: message.timestamp) // Persist to channel-specific timelines - #if os(iOS) switch activeChannel { case .mesh: meshTimeline.append(message) @@ -1268,26 +1252,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate { if arr.count > geoTimelineCap { arr = Array(arr.suffix(geoTimelineCap)) } 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 - #if os(iOS) if case .location(let ch) = activeChannel { // Send to geohash channel via Nostr ephemeral Task { @MainActor in @@ -1326,14 +1303,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // 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) { // Flush pending public buffer to avoid cross-channel bleed @@ -1376,14 +1349,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Ensure self appears immediately in the people list; mark teleported state if applicable if let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { self.recordGeoParticipant(pubkeyHex: id.publicKeyHex) - #if os(iOS) if LocationChannelManager.shared.teleported { let key = id.publicKeyHex.lowercased() teleportedGeo = teleportedGeo.union([key]) SecureLogger.log("GeoTeleport: channel switch mark self teleported key=\(key.prefix(8))… total=\(teleportedGeo.count)", category: SecureLogger.session, level: .info) } - #endif } let subID = "geo-\(ch.geohash)" geoSubscriptionID = subID @@ -1593,8 +1564,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Presence announcement removed; we will tag actual chat events instead } - // MARK: - Geohash Participants (iOS) - #if os(iOS) + // MARK: - Geohash Participants struct GeoPerson: Identifiable, Equatable { let id: String // pubkey hex (lowercased) let displayName: String @@ -1652,10 +1622,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { geoParticipantsTimer?.invalidate() geoParticipantsTimer = nil } - #endif - - // MARK: - Public helpers (iOS) - #if os(iOS) + + // MARK: - Public helpers /// Return the current, pruned, sorted people list for the active geohash without mutating state. @MainActor func visibleGeohashPeople() -> [GeoPerson] { @@ -1773,7 +1741,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { 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)) @@ -1793,16 +1760,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // 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 @@ -1819,7 +1782,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } } - #endif /// Sends an encrypted private message to a specific peer. /// - Parameters: @@ -1831,7 +1793,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { guard !content.isEmpty else { return } // Geohash DM routing: conversation keys start with "nostr_" - #if os(iOS) if peerID.hasPrefix("nostr_") { guard case .location(let ch) = activeChannel else { addSystemMessage("cannot send: not in a location channel") @@ -1897,7 +1858,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } return } - #endif // Check if blocked if unifiedPeerService.isBlocked(peerID) { @@ -1964,7 +1924,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } - #if os(iOS) // MARK: - Geohash DMs initiation @MainActor func startGeohashDM(withPubkeyHex hex: String) { @@ -1990,7 +1949,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } return "anon#\(suffix)" } - #endif /// Add a local system message to a private chat (no network send) @MainActor func addLocalPrivateSystemMessage(_ content: String, to peerID: String) { @@ -2444,9 +2402,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } // Also resubscribe the current geohash channel if active - #if os(iOS) resubscribeCurrentGeohash() - #endif } @MainActor @@ -2491,7 +2447,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } else { // In public chat - send to active public channel - #if os(iOS) switch activeChannel { case .mesh: meshService.sendMessage(screenshotMessage, mentions: []) @@ -2525,9 +2480,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } } } - #else - meshService.sendMessage(screenshotMessage, mentions: []) - #endif + // Show local notification immediately as system message let localNotification = BitchatMessage( @@ -2607,7 +2560,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { 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, @@ -2625,7 +2577,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } return } - #endif // Get the peer's Noise key to check for Nostr messages var noiseKeyHex: String? = nil @@ -2722,7 +2673,6 @@ 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: @@ -2736,9 +2686,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { nostrKeyMapping[convKey] = pub return convKey } - default: break + default: + break } - #endif + // Fallback to mesh nickname resolution return unifiedPeerService.getPeerID(for: nickname) } @@ -2846,7 +2797,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { func updateAutocomplete(for text: String, cursorPosition: Int) { // Build candidate list based on active channel let peerCandidates: [String] = { - #if os(iOS) switch activeChannel { case .mesh: let values = meshService.getPeerNicknames().values @@ -2865,10 +2815,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } return Array(tokens) } - #else - let values = meshService.getPeerNicknames().values - return Array(values.filter { $0 != meshService.myNickname }) - #endif }() let (suggestions, range) = autocompleteService.getSuggestions( @@ -2989,13 +2935,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Determine if this message was sent by self (mesh, geo, or DM) let isSelf: Bool = { if let spid = message.senderPeerID { - #if os(iOS) + // In geohash channels, compare against our per-geohash nostr short ID if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") { if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { return spid == "nostr:\(myGeo.publicKeyHex.prefix(8))" } } - #endif return spid == meshService.myPeerID } // Fallback by nickname @@ -3146,11 +3091,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let (mBase, mSuffix) = splitSuffix(from: matchText.replacingOccurrences(of: "@", with: "")) // Determine if this mention targets me (resolves with optional suffix per active channel) let mySuffix: String? = { - #if os(iOS) if case .location(let ch) = activeChannel, let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { return String(id.publicKeyHex.suffix(4)) } - #endif return String(meshService.myPeerID.prefix(4)) }() let isMentionToMe: Bool = { @@ -3602,7 +3545,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Clear the current public channel's timeline (visible + persistent buffer) @MainActor func clearCurrentPublicTimeline() { - #if os(iOS) switch activeChannel { case .mesh: messages.removeAll() @@ -3611,10 +3553,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { messages.removeAll() geoTimelines[ch.geohash] = [] } - #else - messages.removeAll() - meshTimeline.removeAll() - #endif } private func trimPrivateChatMessagesIfNeeded(for peerID: String) { @@ -4447,7 +4385,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // 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 { @@ -4471,7 +4408,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } return } - #endif // Default: send over mesh meshService.sendMessage(content, mentions: []) } @@ -5074,12 +5010,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // MARK: - Geohash Nickname Resolution (for /block in geohash) @MainActor func nostrPubkeyForDisplayName(_ name: String) -> String? { - // Look up current visible geohash participants for an exact displayName match (iOS only) - #if os(iOS) + // Look up current visible geohash participants for an exact displayName match for p in visibleGeohashPeople() { if p.displayName == name { return p.id } } - #endif return nil } @@ -5381,8 +5315,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { trimMeshTimelineIfNeeded() } - // Persist geochat messages to per-geohash timeline (iOS-only) - #if os(iOS) + // Persist geochat messages to per-geohash timeline if isGeo && finalMessage.sender != "system" { if let gh = currentGeohash { var arr = geoTimelines[gh] ?? [] @@ -5391,20 +5324,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate { 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 } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index cbc01efd..5f16f016 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -33,9 +33,7 @@ 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 @@ -200,7 +198,6 @@ struct ContentView: View { Button("direct message") { if let peerID = selectedMessageSenderID { - #if os(iOS) if peerID.hasPrefix("nostr:") { if let full = viewModel.fullNostrHex(forSenderPeerID: peerID) { viewModel.startGeohashDM(withPubkeyHex: full) @@ -208,9 +205,6 @@ struct ContentView: View { } else { viewModel.startPrivateChat(with: peerID) } - #else - viewModel.startPrivateChat(with: peerID) - #endif withAnimation(.easeInOut(duration: 0.2)) { showSidebar = false sidebarDragOffset = 0 @@ -232,7 +226,6 @@ struct ContentView: View { Button("BLOCK", role: .destructive) { // Prefer direct geohash block when we have a Nostr sender ID - #if os(iOS) if let peerID = selectedMessageSenderID, peerID.hasPrefix("nostr:"), let full = viewModel.fullNostrHex(forSenderPeerID: peerID), let sender = selectedMessageSender { @@ -240,9 +233,6 @@ struct ContentView: View { } else if let sender = selectedMessageSender { viewModel.sendMessage("/block \(sender)") } - #else - if let sender = selectedMessageSender { viewModel.sendMessage("/block \(sender)") } - #endif } Button("cancel", role: .cancel) {} @@ -290,7 +280,6 @@ struct ContentView: View { let windowedMessages = messages.suffix(currentWindowCount) // Build stable UI IDs with a context key to avoid ID collisions when switching channels - #if os(iOS) let contextKey: String = { if let peer = privatePeer { return "dm:\(peer)" } switch locationManager.selectedChannel { @@ -298,12 +287,6 @@ struct ContentView: View { case .location(let ch): return "geo:\(ch.geohash)" } }() - #else - let contextKey: String = { - if let peer = privatePeer { return "dm:\(peer)" } - return "mesh" - }() - #endif let items = windowedMessages.map { (uiID: "\(contextKey)|\($0.id)", message: $0) } ForEach(items, id: \.uiID) { item in @@ -399,7 +382,6 @@ struct ContentView: View { // Infinite scroll up: when top row appears, increase window and preserve anchor if message.id == windowedMessages.first?.id, messages.count > windowedMessages.count { let step = 200 - #if os(iOS) let contextKey: String = { if let peer = privatePeer { return "dm:\(peer)" } switch locationManager.selectedChannel { @@ -407,12 +389,6 @@ struct ContentView: View { case .location(let ch): return "geo:\(ch.geohash)" } }() - #else - let contextKey: String = { - if let peer = privatePeer { return "dm:\(peer)" } - return "mesh" - }() - #endif let preserveID = "\(contextKey)|\(message.id)" if let peer = privatePeer { let current = windowCountPrivate[peer] ?? 300 @@ -481,7 +457,6 @@ struct ContentView: View { let gh = url.path.trimmingCharacters(in: CharacterSet(charactersIn: "/")).lowercased() let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") guard (2...12).contains(gh.count), gh.allSatisfy({ allowed.contains($0) }) else { return } - #if os(iOS) func levelForLength(_ len: Int) -> GeohashChannelLevel { switch len { case 0...2: return .region @@ -496,7 +471,6 @@ struct ContentView: View { let ch = GeohashChannel(level: level, geohash: gh) LocationChannelManager.shared.markTeleported(for: gh, true) LocationChannelManager.shared.select(ChannelID.location(ch)) - #endif } .onTapGesture(count: 3) { // Triple-tap to clear current chat @@ -509,16 +483,12 @@ struct ContentView: View { let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { return "dm:\(peer)|\(last)" } - #if os(iOS) let contextKey: String = { switch locationManager.selectedChannel { case .mesh: return "mesh" case .location(let ch): return "geo:\(ch.geohash)" } }() - #else - let contextKey: String = "mesh" - #endif if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } return nil }() @@ -533,16 +503,12 @@ struct ContentView: View { let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { return "dm:\(peer)|\(last)" } - #if os(iOS) let contextKey: String = { switch locationManager.selectedChannel { case .mesh: return "mesh" case .location(let ch): return "geo:\(ch.geohash)" } }() - #else - let contextKey: String = "mesh" - #endif if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } return nil }() @@ -556,16 +522,12 @@ struct ContentView: View { let last = viewModel.getPrivateChatMessages(for: peer).suffix(300).last?.id { return "dm:\(peer)|\(last)" } - #if os(iOS) let contextKey: String = { switch locationManager.selectedChannel { case .mesh: return "mesh" case .location(let ch): return "geo:\(ch.geohash)" } }() - #else - let contextKey: String = "mesh" - #endif if let last = viewModel.messages.suffix(300).last?.id { return "\(contextKey)|\(last)" } return nil }() @@ -591,16 +553,12 @@ struct ContentView: View { if now.timeIntervalSince(lastScrollTime) > 0.5 { // Immediate scroll if enough time has passed lastScrollTime = now - #if os(iOS) let contextKey: String = { switch locationManager.selectedChannel { case .mesh: return "mesh" case .location(let ch): return "geo:\(ch.geohash)" } }() - #else - let contextKey: String = "mesh" - #endif let count = windowCountPublic let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } DispatchQueue.main.async { @@ -611,16 +569,12 @@ struct ContentView: View { scrollThrottleTimer?.invalidate() scrollThrottleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { _ in lastScrollTime = Date() - #if os(iOS) - let contextKey: String = { - switch locationManager.selectedChannel { - case .mesh: return "mesh" - case .location(let ch): return "geo:\(ch.geohash)" - } - }() - #else - let contextKey: String = "mesh" - #endif + let contextKey: String = { + switch locationManager.selectedChannel { + case .mesh: return "mesh" + case .location(let ch): return "geo:\(ch.geohash)" + } + }() let count = windowCountPublic let target = viewModel.messages.suffix(count).last.map { "\(contextKey)|\($0.id)" } DispatchQueue.main.async { @@ -667,7 +621,6 @@ struct ContentView: View { } } } - #if os(iOS) .onChange(of: locationManager.selectedChannel) { newChannel in // When switching to a new geohash channel, scroll to the bottom guard privatePeer == nil else { return } @@ -686,7 +639,6 @@ struct ContentView: View { } } } - #endif .onAppear { // Also check when view appears if let peerID = privatePeer { @@ -756,18 +708,22 @@ struct ContentView: View { if showCommandSuggestions && !commandSuggestions.isEmpty { VStack(alignment: .leading, spacing: 0) { // Define commands with aliases and syntax - let commandInfo: [(commands: [String], syntax: String?, description: String)] = [ + let baseInfo: [(commands: [String], syntax: String?, description: String)] = [ (["/block"], "[nickname]", "block or list blocked peers"), (["/clear"], nil, "clear chat messages"), - (["/fav"], "", "add to favorites"), - (["/help"], nil, "show this help"), (["/hug"], "", "send someone a warm hug"), (["/m", "/msg"], " [message]", "send private message"), (["/slap"], "", "slap someone with a trout"), (["/unblock"], "", "unblock a peer"), - (["/unfav"], "", "remove from favorites"), (["/w"], nil, "see who's online") ] + let isGeoPublic: Bool = { if case .location = locationManager.selectedChannel { return true }; return false }() + let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true) + let favInfo: [(commands: [String], syntax: String?, description: String)] = [ + (["/fav"], "", "add to favorites"), + (["/unfav"], "", "remove from favorites") + ] + let commandInfo = baseInfo + ((isGeoPublic || isGeoDM) ? [] : favInfo) // Build the display let allCommands = commandInfo @@ -842,18 +798,25 @@ struct ContentView: View { // Check for command autocomplete (instant, no debounce needed) if newValue.hasPrefix("/") && newValue.count >= 1 { // Build context-aware command list - let commandDescriptions = [ + let isGeoPublic: Bool = { + if case .location = locationManager.selectedChannel { return true } + return false + }() + let isGeoDM: Bool = (viewModel.selectedPrivateChatPeer?.hasPrefix("nostr_") == true) + var commandDescriptions = [ ("/block", "block or list blocked peers"), ("/clear", "clear chat messages"), - ("/fav", "add to favorites"), - ("/help", "show this help"), ("/hug", "send someone a warm hug"), ("/m", "send private message"), ("/slap", "slap someone with a trout"), ("/unblock", "unblock a peer"), - ("/unfav", "remove from favorites"), ("/w", "see who's online") ] + // Only show favorites commands when not in geohash context + if !(isGeoPublic || isGeoDM) { + commandDescriptions.append(("/fav", "add to favorites")) + commandDescriptions.append(("/unfav", "remove from favorites")) + } let input = newValue.lowercased() @@ -935,8 +898,7 @@ struct ContentView: View { .font(.system(size: 16, weight: .bold, design: .monospaced)) .foregroundColor(textColor) Spacer() - // Show QR only on mesh channel's peer list - #if os(iOS) + // Show QR in mesh on all platforms if case .mesh = locationManager.selectedChannel { Button(action: { showVerifySheet = true }) { Image(systemName: "qrcode") @@ -945,14 +907,6 @@ struct ContentView: View { .buttonStyle(.plain) .help("Verification: show my QR or scan a friend") } - #else - Button(action: { showVerifySheet = true }) { - Image(systemName: "qrcode") - .font(.system(size: 14)) - } - .buttonStyle(.plain) - .help("Verification: show my QR or scan a friend") - #endif } .frame(height: 44) // Match header height .padding(.horizontal, 12) @@ -965,53 +919,34 @@ struct ContentView: View { VStack(alignment: .leading, spacing: 6) { // People section VStack(alignment: .leading, spacing: 4) { - #if os(iOS) - if case .location = locationManager.selectedChannel { - GeohashPeopleList(viewModel: viewModel, - textColor: textColor, - secondaryTextColor: secondaryTextColor, - onTapPerson: { - withAnimation(.easeInOut(duration: 0.2)) { - showSidebar = false - sidebarDragOffset = 0 - } - }) - } else { - MeshPeerList(viewModel: viewModel, - textColor: textColor, - secondaryTextColor: secondaryTextColor, - onTapPeer: { peerID in - viewModel.startPrivateChat(with: peerID) - withAnimation(.easeInOut(duration: 0.2)) { - showSidebar = false - sidebarDragOffset = 0 - } - }, - onToggleFavorite: { peerID in - viewModel.toggleFavorite(peerID: peerID) - }, - onShowFingerprint: { peerID in - viewModel.showFingerprint(for: peerID) - }) - } - #else - MeshPeerList(viewModel: viewModel, - textColor: textColor, - secondaryTextColor: secondaryTextColor, - onTapPeer: { peerID in - viewModel.startPrivateChat(with: peerID) - withAnimation(.easeInOut(duration: 0.2)) { - showSidebar = false - sidebarDragOffset = 0 - } - }, - onToggleFavorite: { peerID in - viewModel.toggleFavorite(peerID: peerID) - }, - onShowFingerprint: { peerID in - viewModel.showFingerprint(for: peerID) - }) - #endif + if case .location = locationManager.selectedChannel { + GeohashPeopleList(viewModel: viewModel, + textColor: textColor, + secondaryTextColor: secondaryTextColor, + onTapPerson: { + withAnimation(.easeInOut(duration: 0.2)) { + showSidebar = false + sidebarDragOffset = 0 + } + }) + } else { + MeshPeerList(viewModel: viewModel, + textColor: textColor, + secondaryTextColor: secondaryTextColor, + onTapPeer: { peerID in + viewModel.startPrivateChat(with: peerID) + withAnimation(.easeInOut(duration: 0.2)) { + showSidebar = false + sidebarDragOffset = 0 + } + }, + onToggleFavorite: { peerID in + viewModel.toggleFavorite(peerID: peerID) + }, + onShowFingerprint: { peerID in + viewModel.showFingerprint(for: peerID) + }) + } } } .id(viewModel.allPeers.map { "\($0.id)-\($0.isConnected)" }.joined()) @@ -1101,13 +1036,11 @@ struct ContentView: View { return (name, "") } - #if os(iOS) - // Compute channel-aware people count and color for toolbar + // Compute channel-aware people count and color for toolbar (cross-platform) private func channelPeopleCountAndColor() -> (Int, Color) { switch locationManager.selectedChannel { case .location: let n = viewModel.geohashPeople.count - // Use standard green (dark: system green; light: custom darker green) let standardGreen = (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) return (n, n > 0 ? standardGreen : Color.secondary) case .mesh: @@ -1117,13 +1050,11 @@ struct ContentView: View { if isMeshConnected { counts.mesh += 1; counts.others += 1 } else if peer.isMutualFavorite { counts.others += 1 } } - // Darker, more neutral blue (less purple hue) let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) let color: Color = counts.mesh > 0 ? meshBlue : Color.secondary return (counts.others, color) } } - #endif private var mainHeaderView: some View { @@ -1170,7 +1101,6 @@ struct ContentView: View { // Channel badge + dynamic spacing + people counter // Precompute header count and color outside the ViewBuilder expressions - #if os(iOS) let cc = channelPeopleCountAndColor() let headerCountColor: Color = cc.1 let headerOtherPeersCount: Int = { @@ -1179,24 +1109,11 @@ struct ContentView: View { } return cc.0 }() - #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 } - } - let headerOtherPeersCount = peerCounts.others - // Darker, more neutral blue (less purple hue) - let meshBlue = Color(hue: 0.60, saturation: 0.85, brightness: 0.82) - let headerCountColor: Color = (peerCounts.mesh > 0) ? meshBlue : Color.secondary - #endif HStack(spacing: 10) { // Unread icon immediately to the left of the channel badge (independent from channel button) - // Unread indicator - #if os(iOS) + // Unread indicator (now shown on iOS and macOS) if viewModel.hasAnyUnreadMessages { Button(action: { viewModel.openMostRelevantPrivateChat() }) { Image(systemName: "envelope.fill") @@ -1231,7 +1148,6 @@ struct ContentView: View { .accessibilityLabel("location channels") } .buttonStyle(.plain) - #endif HStack(spacing: 4) { // People icon with count @@ -1259,11 +1175,9 @@ struct ContentView: View { } .frame(height: 44) .padding(.horizontal, 12) - #if os(iOS) .sheet(isPresented: $showLocationChannelsSheet) { LocationChannelsSheet(isPresented: $showLocationChannelsSheet) } - #endif .background(backgroundColor.opacity(0.95)) } @@ -1299,13 +1213,11 @@ struct ContentView: View { let peer = viewModel.getPeer(byID: headerPeerID) 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) ?? diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index 09af0e5a..f646733f 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -1,6 +1,5 @@ import SwiftUI -#if os(iOS) struct GeohashPeopleList: View { @ObservedObject var viewModel: ChatViewModel let textColor: Color @@ -29,16 +28,12 @@ struct GeohashPeopleList: View { let people = viewModel.visibleGeohashPeople() let currentIDs = people.map { $0.id } - #if os(iOS) let teleportedSet = Set(viewModel.teleportedGeo.map { $0.lowercased() }) let isTeleportedID: (String) -> Bool = { id in if teleportedSet.contains(id.lowercased()) { return true } if let me = myHex, id == me, LocationChannelManager.shared.teleported { return true } return false } - #else - let isTeleportedID: (String) -> Bool = { _ in false } - #endif let displayIDs = orderedIDs.filter { currentIDs.contains($0) } + currentIDs.filter { !orderedIDs.contains($0) } let nonTele = displayIDs.filter { !isTeleportedID($0) } @@ -52,11 +47,7 @@ struct GeohashPeopleList: View { let person = personByID[pid]! HStack(spacing: 4) { let isMe = (person.id == myHex) - #if os(iOS) let teleported = viewModel.teleportedGeo.contains(person.id.lowercased()) || (isMe && LocationChannelManager.shared.teleported) - #else - let teleported = false - #endif let icon = teleported ? "face.dashed" : "mappin.and.ellipse" let assignedColor = viewModel.colorForNostrPubkey(person.id, isDark: colorScheme == .dark) let rowColor: Color = isMe ? .orange : assignedColor @@ -127,10 +118,8 @@ struct GeohashPeopleList: View { } } } -#endif // Helper to split a trailing #abcd suffix -#if os(iOS) private func splitSuffix(from name: String) -> (String, String) { guard name.count >= 5 else { return (name, "") } let suffix = String(name.suffix(5)) @@ -142,4 +131,3 @@ private func splitSuffix(from name: String) -> (String, String) { } return (name, "") } -#endif diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 29467355..c878d8be 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -1,7 +1,10 @@ import SwiftUI - +import CoreLocation #if os(iOS) import UIKit +#else +import AppKit +#endif struct LocationChannelsSheet: View { @Binding var isPresented: Bool @ObservedObject private var manager = LocationChannelManager.shared @@ -37,11 +40,7 @@ struct LocationChannelsSheet: View { Text("location permission denied. enable in settings to use location channels.") .font(.system(size: 12, design: .monospaced)) .foregroundColor(.secondary) - Button("open settings") { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } - } + Button("open settings") { openSystemLocationSettings() } .buttonStyle(.plain) } case LocationChannelManager.PermissionState.authorized: @@ -54,6 +53,7 @@ struct LocationChannelsSheet: View { } .padding(.horizontal, 16) .padding(.vertical, 12) + #if os(iOS) .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { @@ -61,8 +61,21 @@ struct LocationChannelsSheet: View { .font(.system(size: 14, design: .monospaced)) } } + #else + .toolbar { + ToolbarItem(placement: .automatic) { + Button("close") { isPresented = false } + .font(.system(size: 14, design: .monospaced)) + } + } + #endif } + #if os(iOS) .presentationDetents([.large]) + #endif + #if os(macOS) + .frame(minWidth: 420, minHeight: 520) + #endif .onAppear { // Refresh channels when opening if manager.permissionState == LocationChannelManager.PermissionState.authorized { @@ -128,10 +141,12 @@ struct LocationChannelsSheet: View { .font(.system(size: 14, design: .monospaced)) .foregroundColor(.secondary) TextField("geohash", text: $customGeohash) + #if os(iOS) .textInputAutocapitalization(.never) .autocorrectionDisabled(true) - .font(.system(size: 14, design: .monospaced)) .keyboardType(.asciiCapable) + #endif + .font(.system(size: 14, design: .monospaced)) .onChange(of: customGeohash) { newValue in // Allow only geohash base32 characters, strip '#', limit length let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") @@ -183,9 +198,7 @@ struct LocationChannelsSheet: View { // Footer action inside the list if manager.permissionState == LocationChannelManager.PermissionState.authorized { Button(action: { - if let url = URL(string: UIApplication.openSettingsURLString) { - UIApplication.shared.open(url) - } + openSystemLocationSettings() }) { Text("remove location access") .font(.system(size: 12, design: .monospaced)) @@ -343,7 +356,7 @@ extension LocationChannelsSheet { }() let usesMetric: Bool = { - if #available(iOS 16.0, *) { + if #available(iOS 16.0, macOS 13.0, *) { return Locale.current.measurementSystem == .metric } else { return Locale.current.usesMetricSystem @@ -366,7 +379,7 @@ extension LocationChannelsSheet { private func bluetoothRangeString() -> String { let usesMetric: Bool = { - if #available(iOS 16.0, *) { + if #available(iOS 16.0, macOS 13.0, *) { return Locale.current.measurementSystem == .metric } else { return Locale.current.usesMetricSystem @@ -390,4 +403,17 @@ extension LocationChannelsSheet { } } -#endif +// MARK: - Open Settings helper +private func openSystemLocationSettings() { + #if os(iOS) + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + #else + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_LocationServices") { + NSWorkspace.shared.open(url) + } else if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security") { + NSWorkspace.shared.open(url) + } + #endif +} diff --git a/bitchat/bitchat-macOS.entitlements b/bitchat/bitchat-macOS.entitlements index 346f374c..3163e770 100644 --- a/bitchat/bitchat-macOS.entitlements +++ b/bitchat/bitchat-macOS.entitlements @@ -10,7 +10,9 @@ com.apple.security.device.bluetooth + com.apple.security.personal-information.location + com.apple.security.network.client - \ No newline at end of file +