mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 10:05:20 +00:00
* Location notes: Matrix loader with 1–3s minimum display; allow multiple notes; wire notes UI from mesh toolbar * Location notes: prevent duplicate loading/subscriptions (StateObject manager, geohash captured at open, guard subscribe) * Location notes: fix duplicate state redeclaration in ContentView * Location notes: render note bodies with monospaced font in notes list * Location notes: add close (x) button in sheet header (upper-right) using @Environment(\.dismiss) * Location notes: acquire geohash on open (force refresh) and show Matrix loader until block-level geohash resolves; add LocationNotesSheet wrapper * Location notes: inline sheet wrapper to avoid missing file in target; force location refresh and show Matrix loader until block geohash resolves * Location notes: remove Matrix animation; use simple spinner when acquiring location; simplify notes view layout * Location notes: remove per-note relative timestamp from sheet (no seconds display) * Location notes: fix intermittent first load by ensuring resubscribe after geohash change, removing over-eager subscribe guard, and widening note fetch window (no since filter) * Location notes: add background counter service and show count next to notes icon on mesh; auto-subscribe to current block geohash * Location notes: move notes icon to the right of #mesh badge in toolbar * Location notes: restore timestamps in notes list; remove loading state from manager and sheet (no spinner/animation) * Location notes: drop 'teleport' tag from kind-1 events; simplify note builder API and usage * Location notes: show timestamp as relative within 7 days, else absolute date (MMM d or MMM d, y); keep monospaced styling * Location notes: append 'ago' to relative timestamps (within 7 days) * Switch location notes and UI helpers to building-level geohash (precision 8): add GeohashChannelLevel.building, map length 8, use building for notes selection and counter, add building name mapping * Location notes: change notes toolbar icon to SF Symbol 'long.text.page.and.pencil' * Revert notes geohash selection back to block-level (precision 7); keep building level available but unused; update counter and sheet accordingly * Location notes: show block name (from reverse geocode) in header instead of 'street-level notes' * Nostr: add EOSE handling with callback support for subscriptions; wire to LocationNotesManager/Counter (initialLoadComplete). Remove 'building' level from channel enum and geocoder mapping; geohash list shows block/neighborhood/city/province/region only * Fix Swift 6 isolation: hop to @MainActor inside Timer callback for EOSE tracker mutations * Notes counter: show 0 by default; subscribe at building-level (precision 8) for notes and counter; keep building hidden in channel list * Notes header: show building name when available (fallback to block name) * Notes: subscribe counter to building + parent block (merge results); hide notes icon unless location is authorized; replace 10s timer with live location updates while sheet open * Notes counter: subscribe only to building geohash (precision 8) so count reflects current 8-cell; auto-begin live location refresh on mesh (and permission) to update as you walk * Notes header: show count in parentheses; icon shows blue if any notes, grey if none; only start live location updates while sheet is open; reduce nickname width; set live distance filter to 10m * Notes header: show count as '(N note/notes)' with non-bold, secondary styling next to title * Notes header: move count before title as '<N> note(s) @ #gh'; remove parentheses; keep count non-bold * Notes header: bold count text; ensure sheet updates when building geohash changes by calling manager.setGeohash on geohash change * Notes sheet: add light haptic feedback when building geohash changes (iOS only) * Notes icon: force a one-shot location refresh before (re)subscribing the counter so icon turns blue immediately on current 8-cell * Notes subscribe: fallback to default relays when GeoRelayDirectory has no entries (pass nil relayUrls) so counter/icon turn blue and sheet loads even before georelay fetch * Notes icon: turn blue immediately when sheet shows notes by passing count up from sheet (closure) and OR-ing with counter; reset on sheet close --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com>
304 lines
13 KiB
Swift
304 lines
13 KiB
Swift
import Foundation
|
|
import Combine
|
|
|
|
#if os(iOS) || os(macOS)
|
|
import CoreLocation
|
|
|
|
/// Manages location permissions, one-shot location retrieval, and computing geohash channels.
|
|
/// Not main-actor isolated to satisfy CLLocationManagerDelegate in Swift 6; state updates hop to MainActor.
|
|
final class LocationChannelManager: NSObject, CLLocationManagerDelegate, ObservableObject {
|
|
static let shared = LocationChannelManager()
|
|
|
|
enum PermissionState: Equatable {
|
|
case notDetermined
|
|
case denied
|
|
case restricted
|
|
case authorized
|
|
}
|
|
|
|
private let cl = CLLocationManager()
|
|
private let geocoder = CLGeocoder()
|
|
private var lastLocation: CLLocation?
|
|
private var refreshTimer: Timer?
|
|
private let userDefaultsKey = "locationChannel.selected"
|
|
private let teleportedStoreKey = "locationChannel.teleportedSet"
|
|
private var isGeocoding: Bool = false
|
|
|
|
// Published state for UI bindings
|
|
@Published private(set) var permissionState: PermissionState = .notDetermined
|
|
@Published private(set) var availableChannels: [GeohashChannel] = []
|
|
@Published private(set) var selectedChannel: ChannelID = .mesh
|
|
// True when the current location channel was selected via manual teleport
|
|
@Published var teleported: Bool = false
|
|
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
|
|
|
|
// Persisted set of geohashes that were selected via teleport
|
|
private var teleportedSet: Set<String> = []
|
|
|
|
private override init() {
|
|
super.init()
|
|
cl.delegate = self
|
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters // meters; we're not tracking continuously
|
|
// Load selection
|
|
if let data = UserDefaults.standard.data(forKey: userDefaultsKey),
|
|
let channel = try? JSONDecoder().decode(ChannelID.self, from: data) {
|
|
selectedChannel = channel
|
|
}
|
|
// Load persisted teleported set
|
|
if let data = UserDefaults.standard.data(forKey: teleportedStoreKey),
|
|
let arr = try? JSONDecoder().decode([String].self, from: data) {
|
|
teleportedSet = Set(arr)
|
|
}
|
|
// Do not eagerly mark teleported on startup; wait for location to compute regional set.
|
|
// This avoids showing teleported for in-region channels during cold start.
|
|
let status: CLAuthorizationStatus
|
|
if #available(iOS 14.0, macOS 11.0, *) {
|
|
status = cl.authorizationStatus
|
|
} else {
|
|
status = CLLocationManager.authorizationStatus()
|
|
}
|
|
updatePermissionState(from: status)
|
|
// If we don't have location authorization at startup, fall back to persisted teleport state
|
|
switch status {
|
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
|
break // will compute from location
|
|
default:
|
|
if case .location(let ch) = selectedChannel {
|
|
teleported = teleportedSet.contains(ch.geohash)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Public API
|
|
func enableLocationChannels() {
|
|
let status: CLAuthorizationStatus
|
|
if #available(iOS 14.0, macOS 11.0, *) {
|
|
status = cl.authorizationStatus
|
|
} else {
|
|
status = CLLocationManager.authorizationStatus()
|
|
}
|
|
switch status {
|
|
case .notDetermined:
|
|
cl.requestWhenInUseAuthorization()
|
|
case .restricted:
|
|
Task { @MainActor in self.permissionState = .restricted }
|
|
case .denied:
|
|
Task { @MainActor in self.permissionState = .denied }
|
|
case .authorizedAlways, .authorizedWhenInUse, .authorized:
|
|
Task { @MainActor in self.permissionState = .authorized }
|
|
requestOneShotLocation()
|
|
@unknown default:
|
|
Task { @MainActor in self.permissionState = .restricted }
|
|
}
|
|
}
|
|
|
|
func refreshChannels() {
|
|
if permissionState == .authorized {
|
|
requestOneShotLocation()
|
|
}
|
|
}
|
|
|
|
/// Begin continuous, distance-filtered updates while the channel sheet is visible.
|
|
/// Uses a 21m filter (configurable) to only refresh on meaningful movement.
|
|
func beginLiveRefresh(interval: TimeInterval = TransportConfig.locationLiveRefreshInterval) {
|
|
guard permissionState == .authorized else { return }
|
|
// Stop any previous polling timer
|
|
refreshTimer?.invalidate()
|
|
refreshTimer = nil
|
|
// Tighten accuracy and distance filter for live view
|
|
cl.desiredAccuracy = kCLLocationAccuracyNearestTenMeters
|
|
cl.distanceFilter = TransportConfig.locationDistanceFilterLiveMeters
|
|
// Start continuous updates
|
|
cl.startUpdatingLocation()
|
|
// Request an immediate fix to populate UI without waiting for movement
|
|
requestOneShotLocation()
|
|
}
|
|
|
|
/// Stop continuous refreshes when selector UI is dismissed.
|
|
func endLiveRefresh() {
|
|
refreshTimer?.invalidate()
|
|
refreshTimer = nil
|
|
cl.stopUpdatingLocation()
|
|
// Restore more relaxed defaults for background/idle state
|
|
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
|
|
cl.distanceFilter = TransportConfig.locationDistanceFilterMeters
|
|
}
|
|
|
|
func select(_ channel: ChannelID) {
|
|
Task { @MainActor in
|
|
self.selectedChannel = channel
|
|
if let data = try? JSONEncoder().encode(channel) {
|
|
UserDefaults.standard.set(data, forKey: self.userDefaultsKey)
|
|
}
|
|
// Update teleported flag based on persisted state for immediate UI behavior
|
|
switch channel {
|
|
case .mesh:
|
|
self.teleported = false
|
|
case .location(let ch):
|
|
// If this geohash is in our current regional set, do NOT mark teleported.
|
|
let inRegional = self.availableChannels.contains { $0.geohash == ch.geohash }
|
|
if inRegional {
|
|
self.teleported = false
|
|
// Clear persisted teleport for this geohash to keep future selections clean
|
|
if self.teleportedSet.contains(ch.geohash) {
|
|
self.teleportedSet.remove(ch.geohash)
|
|
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
|
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
|
}
|
|
}
|
|
} else {
|
|
// Fall back to persisted mark (set by deep link or manual teleport)
|
|
self.teleported = self.teleportedSet.contains(ch.geohash)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark or unmark a geohash as teleported in persistence and update current flag if relevant
|
|
func markTeleported(for geohash: String, _ flag: Bool) {
|
|
if flag { teleportedSet.insert(geohash) } else { teleportedSet.remove(geohash) }
|
|
if let data = try? JSONEncoder().encode(Array(teleportedSet)) {
|
|
UserDefaults.standard.set(data, forKey: teleportedStoreKey)
|
|
}
|
|
if case .location(let ch) = selectedChannel, ch.geohash == geohash {
|
|
Task { @MainActor in self.teleported = flag }
|
|
}
|
|
}
|
|
|
|
// MARK: - CoreLocation
|
|
private func requestOneShotLocation() {
|
|
cl.requestLocation()
|
|
}
|
|
|
|
// iOS < 14
|
|
func locationManager(_ manager: CLLocationManager, didChangeAuthorization status: CLAuthorizationStatus) {
|
|
updatePermissionState(from: status)
|
|
if case .authorized = permissionState {
|
|
requestOneShotLocation()
|
|
}
|
|
}
|
|
|
|
// iOS 14+ / macOS 11+
|
|
@available(iOS 14.0, macOS 11.0, *)
|
|
func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
|
updatePermissionState(from: manager.authorizationStatus)
|
|
if case .authorized = permissionState {
|
|
requestOneShotLocation()
|
|
}
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didUpdateLocations locations: [CLLocation]) {
|
|
guard let loc = locations.last else { return }
|
|
lastLocation = loc
|
|
computeChannels(from: loc.coordinate)
|
|
reverseGeocodeIfNeeded(location: loc)
|
|
}
|
|
|
|
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
|
// Surface as denied/restricted if relevant; otherwise keep previous state
|
|
SecureLogger.error("LocationChannelManager: location error: \(error.localizedDescription)", category: .session)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
private func updatePermissionState(from status: CLAuthorizationStatus) {
|
|
let newState: PermissionState
|
|
switch status {
|
|
case .notDetermined: newState = .notDetermined
|
|
case .restricted: newState = .restricted
|
|
case .denied: newState = .denied
|
|
case .authorizedAlways, .authorizedWhenInUse, .authorized: newState = .authorized
|
|
@unknown default: newState = .restricted
|
|
}
|
|
Task { @MainActor in self.permissionState = newState }
|
|
}
|
|
|
|
private func computeChannels(from coord: CLLocationCoordinate2D) {
|
|
let levels = GeohashChannelLevel.allCases
|
|
var result: [GeohashChannel] = []
|
|
for level in levels {
|
|
let gh = Geohash.encode(latitude: coord.latitude, longitude: coord.longitude, precision: level.precision)
|
|
result.append(GeohashChannel(level: level, geohash: gh))
|
|
}
|
|
Task { @MainActor in
|
|
self.availableChannels = result
|
|
// Recompute teleported status based on whether the selected geohash is in our regional set
|
|
switch self.selectedChannel {
|
|
case .mesh:
|
|
self.teleported = false
|
|
case .location(let ch):
|
|
// Membership check using freshly computed regional channels; avoids precision/rename drift
|
|
let inRegional = result.contains { $0.geohash == ch.geohash }
|
|
if inRegional {
|
|
self.teleported = false
|
|
// Clear persisted teleport flag if present
|
|
if self.teleportedSet.contains(ch.geohash) {
|
|
self.teleportedSet.remove(ch.geohash)
|
|
if let data = try? JSONEncoder().encode(Array(self.teleportedSet)) {
|
|
UserDefaults.standard.set(data, forKey: self.teleportedStoreKey)
|
|
}
|
|
}
|
|
} else {
|
|
self.teleported = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func reverseGeocodeIfNeeded(location: CLLocation) {
|
|
// Always cancel previous to keep latest fresh while user moves
|
|
geocoder.cancelGeocode()
|
|
isGeocoding = true
|
|
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
|
|
guard let self = self else { return }
|
|
self.isGeocoding = false
|
|
if let pm = placemarks?.first {
|
|
let names = self.namesByLevel(from: pm)
|
|
Task { @MainActor in self.locationNames = names }
|
|
}
|
|
}
|
|
}
|
|
|
|
private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
|
|
var dict: [GeohashChannelLevel: String] = [:]
|
|
// Region (country)
|
|
if let country = pm.country, !country.isEmpty {
|
|
dict[.region] = country
|
|
}
|
|
// Province (state/province or county)
|
|
if let admin = pm.administrativeArea, !admin.isEmpty {
|
|
dict[.province] = admin
|
|
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
|
dict[.province] = subAdmin
|
|
}
|
|
// City (locality)
|
|
if let locality = pm.locality, !locality.isEmpty {
|
|
dict[.city] = locality
|
|
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
|
|
dict[.city] = subAdmin
|
|
} else if let admin = pm.administrativeArea, !admin.isEmpty {
|
|
dict[.city] = admin
|
|
}
|
|
// Neighborhood
|
|
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
|
dict[.neighborhood] = subLocality
|
|
} else if let locality = pm.locality, !locality.isEmpty {
|
|
dict[.neighborhood] = locality
|
|
}
|
|
// Block: reuse neighborhood/locality granularity
|
|
if let subLocality = pm.subLocality, !subLocality.isEmpty {
|
|
dict[.block] = subLocality
|
|
} else if let locality = pm.locality, !locality.isEmpty {
|
|
dict[.block] = locality
|
|
}
|
|
// Building: prefer place name/street/venue when available
|
|
if let name = pm.name, !name.isEmpty {
|
|
dict[.building] = name
|
|
} else if let thoroughfare = pm.thoroughfare, !thoroughfare.isEmpty {
|
|
dict[.building] = thoroughfare
|
|
}
|
|
return dict
|
|
}
|
|
}
|
|
#endif
|