From b4fcea2672901b2c756c8f77a330ff03abcd82a2 Mon Sep 17 00:00:00 2001 From: jack Date: Thu, 21 Aug 2025 00:34:17 +0200 Subject: [PATCH] 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 --- bitchat/Nostr/NostrRelayManager.swift | 1 + bitchat/Protocols/LocationChannel.swift | 4 - bitchat/Services/LocationChannelManager.swift | 53 +++++++++++++ bitchat/Views/LocationChannelsSheet.swift | 76 ++++++++++++++++++- bitchatTests/LocationChannelsTests.swift | 3 - 5 files changed, 126 insertions(+), 11 deletions(-) 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..5c725221 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() @@ -123,6 +126,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 +157,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..146e4958 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(), subtitle: "#bluetooth", isSelected: isMeshSelected, titleColor: standardBlue) { manager.select(ChannelID.mesh) isPresented = false } @@ -101,7 +101,11 @@ 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 name = locationName(for: channel.level) + let namePart = name.map { formattedNamePrefix(for: channel.level) + $0 } + let subtitle = ["#\(channel.geohash)", coverage, namePart].compactMap { $0 }.joined(separator: " • ") + channelRow(title: geohashTitleWithCount(for: channel), subtitle: subtitle, isSelected: isSelected(channel)) { manager.select(ChannelID.location(channel)) isPresented = false } @@ -198,7 +202,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, subtitle: String, isSelected: Bool, titleColor: Color? = nil, action: @escaping () -> Void) -> some View { Button(action: action) { HStack { VStack(alignment: .leading) { @@ -207,6 +211,7 @@ struct LocationChannelsSheet: View { HStack(spacing: 4) { Text(parts.base) .font(.system(size: 14, design: .monospaced)) + .foregroundColor(titleColor ?? Color.primary) if let count = parts.countSuffix, !count.isEmpty { Text(count) .font(.system(size: 11, design: .monospaced)) @@ -270,7 +275,7 @@ struct LocationChannelsSheet: View { case 5: return .city case 6: return .neighborhood case 7: return .block - default: return .street + default: return .block } } } @@ -280,6 +285,69 @@ 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 locationName(for level: GeohashChannelLevel) -> String? { + manager.locationNames[level] + } + + private func formattedNamePrefix(for level: GeohashChannelLevel) -> String { + switch level { + case .country: + return "" + default: + return "around " + } + } } #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))