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

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