mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 16:45:19 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19da74d724 |
@@ -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 */,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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) ??
|
||||||
|
|||||||
@@ -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
|
|
||||||
|
|||||||
@@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
#endif
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user