From a0a973eb81688ae6ec6b688c868bb0f62ff99a64 Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Thu, 21 Aug 2025 01:01:29 +0200 Subject: [PATCH] Fixes/location channels (#465) * Remove "street" location channel; add coverage + names; style mesh - Drop GeohashChannelLevel.street and update mappings/tests\n- Map geohash lengths >=8 to Block in teleport, no Street\n- Show ~distance coverage (mi/km via Locale.measurementSystem) next to each #geohash\n- Add reverse geocoding to display coarse names (country/region/city/neighborhood) per level\n- Style "#mesh" row title with toolbar blue\n- Fix iOS 16 deprecation: use Locale.measurementSystem * Project: update Xcode project (auto) * UI: use '~' without trailing space before location name in sheet * Location sheet: poll for location at regular intervals while open (replace significant-move updates) * UI: show Bluetooth range next to #bluetooth in location sheet * UI: remove leading '#' from mesh title in location sheet * UI: bold location name in sheet when geohash has >0 people; refactor row to render subtitle pieces * UI: bold mesh/level titles when participant count > 0; factor meshCount() --------- Co-authored-by: jack --- bitchat.xcodeproj/project.pbxproj | 12 +- bitchat/Nostr/NostrRelayManager.swift | 1 + bitchat/Protocols/LocationChannel.swift | 4 - bitchat/Services/LocationChannelManager.swift | 66 +++++++++- bitchat/Views/LocationChannelsSheet.swift | 117 ++++++++++++++++-- bitchatTests/LocationChannelsTests.swift | 3 - 6 files changed, 176 insertions(+), 27 deletions(-) diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index 6b8c8738..8b51b4fc 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -960,7 +960,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; @@ -991,7 +991,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -1046,7 +1046,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; SDKROOT = iphoneos; @@ -1078,7 +1078,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -1167,7 +1167,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 13.0; - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat; PRODUCT_NAME = bitchat; REGISTER_APP_GROUPS = YES; @@ -1260,7 +1260,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 1.3.0; + MARKETING_VERSION = 1.3.1; PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 3592982c..40b7446c 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -28,6 +28,7 @@ class NostrRelayManager: ObservableObject { // Default relay list (can be customized) private static let defaultRelays = [ "wss://relay.damus.io", + "wss://nos.lol", "wss://relay.primal.net", "wss://offchain.pub", "wss://nostr21.com" diff --git a/bitchat/Protocols/LocationChannel.swift b/bitchat/Protocols/LocationChannel.swift index b5e9d4c8..fa2ff23a 100644 --- a/bitchat/Protocols/LocationChannel.swift +++ b/bitchat/Protocols/LocationChannel.swift @@ -2,7 +2,6 @@ import Foundation /// Levels of location channels mapped to geohash precisions. enum GeohashChannelLevel: CaseIterable, Codable, Equatable { - case street case block case neighborhood case city @@ -12,7 +11,6 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable { /// Geohash length used for this level. var precision: Int { switch self { - case .street: return 8 case .block: return 7 case .neighborhood: return 6 case .city: return 5 @@ -23,7 +21,6 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable { var displayName: String { switch self { - case .street: return "Street" case .block: return "Block" case .neighborhood: return "Neighborhood" case .city: return "City" @@ -68,4 +65,3 @@ enum ChannelID: Equatable, Codable { } } } - diff --git a/bitchat/Services/LocationChannelManager.swift b/bitchat/Services/LocationChannelManager.swift index 3965227f..85e1eb63 100644 --- a/bitchat/Services/LocationChannelManager.swift +++ b/bitchat/Services/LocationChannelManager.swift @@ -17,14 +17,17 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa } private let cl = CLLocationManager() + private let geocoder = CLGeocoder() private var lastLocation: CLLocation? private var refreshTimer: Timer? private let userDefaultsKey = "locationChannel.selected" + 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 + @Published private(set) var locationNames: [GeohashChannelLevel: String] = [:] private override init() { super.init() @@ -76,15 +79,20 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa /// Begin periodic one-shot location refreshes while a selector UI is visible. func beginLiveRefresh(interval: TimeInterval = 5.0) { - // Prefer continuous updates with a significant distance filter rather than polling guard permissionState == .authorized else { return } - cl.desiredAccuracy = kCLLocationAccuracyHundredMeters - cl.distanceFilter = 21 // meters; update on small moves - cl.startUpdatingLocation() + // Switch to a lightweight periodic one-shot request (polling) while the sheet is open + refreshTimer?.invalidate() + refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + self?.requestOneShotLocation() + } + // Kick off immediately + requestOneShotLocation() } /// Stop periodic refreshes when selector UI is dismissed. func endLiveRefresh() { + refreshTimer?.invalidate() + refreshTimer = nil cl.stopUpdatingLocation() } @@ -123,6 +131,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa guard let loc = locations.last else { return } lastLocation = loc computeChannels(from: loc.coordinate) + reverseGeocodeIfNeeded(location: loc) } func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) { @@ -153,6 +162,55 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa } Task { @MainActor in self.availableChannels = result } } + + 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] = [:] + // Country + if let country = pm.country, !country.isEmpty { + dict[.country] = country + } + // Region (state/province or county) + if let admin = pm.administrativeArea, !admin.isEmpty { + dict[.region] = admin + } else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty { + dict[.region] = 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 without exposing street level + if let subLocality = pm.subLocality, !subLocality.isEmpty { + dict[.block] = subLocality + } else if let locality = pm.locality, !locality.isEmpty { + dict[.block] = locality + } + return dict + } } #endif diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 90b35e7e..8977654b 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -93,7 +93,7 @@ struct LocationChannelsSheet: View { private var channelList: some View { List { // Mesh option first - channelRow(title: meshTitleWithCount(), subtitle: "#bluetooth", isSelected: isMeshSelected) { + channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) { manager.select(ChannelID.mesh) isPresented = false } @@ -101,7 +101,12 @@ struct LocationChannelsSheet: View { // Nearby options if !manager.availableChannels.isEmpty { ForEach(manager.availableChannels) { channel in - channelRow(title: geohashTitleWithCount(for: channel), subtitle: "#\(channel.geohash)", isSelected: isSelected(channel)) { + let coverage = coverageString(forPrecision: channel.geohash.count) + let nameBase = locationName(for: channel.level) + let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 } + let subtitlePrefix = "#\(channel.geohash) • \(coverage)" + let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0 + channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, subtitleNameBold: highlight, isSelected: isSelected(channel), titleBold: highlight) { manager.select(ChannelID.location(channel)) isPresented = false } @@ -198,7 +203,7 @@ struct LocationChannelsSheet: View { return false } - private func channelRow(title: String, subtitle: String, isSelected: Bool, action: @escaping () -> Void) -> some View { + private func channelRow(title: String, subtitlePrefix: String, subtitleName: String? = nil, subtitleNameBold: Bool = false, isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, action: @escaping () -> Void) -> some View { Button(action: action) { HStack { VStack(alignment: .leading) { @@ -207,15 +212,28 @@ struct LocationChannelsSheet: View { HStack(spacing: 4) { Text(parts.base) .font(.system(size: 14, design: .monospaced)) + .fontWeight(titleBold ? .bold : .regular) + .foregroundColor(titleColor ?? Color.primary) if let count = parts.countSuffix, !count.isEmpty { Text(count) .font(.system(size: 11, design: .monospaced)) .foregroundColor(.secondary) } } - Text(subtitle) - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(.secondary) + HStack(spacing: 0) { + Text(subtitlePrefix) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + if let name = subtitleName { + Text(" • ") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + Text(name) + .font(.system(size: 12, design: .monospaced)) + .fontWeight(subtitleNameBold ? .bold : .regular) + .foregroundColor(.secondary) + } + } } Spacer() if isSelected { @@ -241,13 +259,17 @@ struct LocationChannelsSheet: View { // MARK: - Helpers for counts private func meshTitleWithCount() -> String { // Count currently connected mesh peers (excluding self) + let meshCount = meshCount() + let noun = meshCount == 1 ? "person" : "people" + return "mesh [\(meshCount) \(noun)]" + } + + private func meshCount() -> Int { let myID = viewModel.meshService.myPeerID - let meshCount = viewModel.allPeers.reduce(0) { acc, peer in + return viewModel.allPeers.reduce(0) { acc, peer in if peer.id != myID && peer.isConnected { return acc + 1 } return acc } - let noun = meshCount == 1 ? "person" : "people" - return "#mesh [\(meshCount) \(noun)]" } private func geohashTitleWithCount(for channel: GeohashChannel) -> String { @@ -270,7 +292,7 @@ struct LocationChannelsSheet: View { case 5: return .city case 6: return .neighborhood case 7: return .block - default: return .street + default: return .block } } } @@ -280,6 +302,81 @@ extension LocationChannelsSheet { private var standardGreen: Color { (colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0) } + private var standardBlue: Color { + Color(red: 0.0, green: 0.478, blue: 1.0) + } +} + +// MARK: - Coverage helpers +extension LocationChannelsSheet { + private func coverageString(forPrecision len: Int) -> String { + // Approximate max cell dimension at equator for a given geohash length. + // Values sourced from common geohash dimension tables. + let maxMeters: Double = { + switch len { + case 2: return 1_250_000 + case 3: return 156_000 + case 4: return 39_100 + case 5: return 4_890 + case 6: return 1_220 + case 7: return 153 + case 8: return 38.2 + case 9: return 4.77 + case 10: return 1.19 + default: + if len <= 1 { return 5_000_000 } + // For >10, scale down conservatively by ~1/4 each char + let over = len - 10 + return 1.19 * pow(0.25, Double(over)) + } + }() + + let usesMetric: Bool = { + if #available(iOS 16.0, *) { + return Locale.current.measurementSystem == .metric + } else { + return Locale.current.usesMetricSystem + } + }() + if usesMetric { + let km = maxMeters / 1000.0 + return "~\(formatDistance(km)) km" + } else { + let miles = maxMeters / 1609.344 + return "~\(formatDistance(miles)) mi" + } + } + + private func formatDistance(_ value: Double) -> String { + if value >= 100 { return String(format: "%.0f", value.rounded()) } + if value >= 10 { return String(format: "%.1f", value) } + return String(format: "%.1f", value) + } + + private func bluetoothRangeString() -> String { + let usesMetric: Bool = { + if #available(iOS 16.0, *) { + return Locale.current.measurementSystem == .metric + } else { + return Locale.current.usesMetricSystem + } + }() + // Approximate Bluetooth LE range for typical mobile devices; environment dependent + return usesMetric ? "~10–50 m" : "~30–160 ft" + } + + private func locationName(for level: GeohashChannelLevel) -> String? { + manager.locationNames[level] + } + + private func formattedNamePrefix(for level: GeohashChannelLevel) -> String { + switch level { + case .country: + return "" + default: + return "~" + } + } } #endif diff --git a/bitchatTests/LocationChannelsTests.swift b/bitchatTests/LocationChannelsTests.swift index 22906625..3f0a0a8c 100644 --- a/bitchatTests/LocationChannelsTests.swift +++ b/bitchatTests/LocationChannelsTests.swift @@ -6,14 +6,12 @@ final class LocationChannelsTests: XCTestCase { // Sanity: known coords (Statue of Liberty approx) let lat = 40.6892 let lon = -74.0445 - let street = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.street.precision) let block = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.block.precision) let neighborhood = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.neighborhood.precision) let city = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.city.precision) let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision) let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.country.precision) - XCTAssertEqual(street.count, 8) XCTAssertEqual(block.count, 7) XCTAssertEqual(neighborhood.count, 6) XCTAssertEqual(city.count, 5) @@ -21,7 +19,6 @@ final class LocationChannelsTests: XCTestCase { XCTAssertEqual(country.count, 2) // All prefixes must match progressively - XCTAssertTrue(street.hasPrefix(block)) XCTAssertTrue(block.hasPrefix(neighborhood)) XCTAssertTrue(neighborhood.hasPrefix(city)) XCTAssertTrue(city.hasPrefix(region))