Skip to content

Commit a0a973e

Browse files
authored
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 <[email protected]>
1 parent 43166bc commit a0a973e

File tree

6 files changed

+176
-27
lines changed

6 files changed

+176
-27
lines changed

bitchat.xcodeproj/project.pbxproj

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -960,7 +960,7 @@
960960
"@executable_path/Frameworks",
961961
"@executable_path/../../Frameworks",
962962
);
963-
MARKETING_VERSION = 1.3.0;
963+
MARKETING_VERSION = 1.3.1;
964964
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
965965
SDKROOT = iphoneos;
966966
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
@@ -991,7 +991,7 @@
991991
"$(inherited)",
992992
"@executable_path/Frameworks",
993993
);
994-
MARKETING_VERSION = 1.3.0;
994+
MARKETING_VERSION = 1.3.1;
995995
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
996996
PRODUCT_NAME = bitchat;
997997
SDKROOT = iphoneos;
@@ -1046,7 +1046,7 @@
10461046
"$(inherited)",
10471047
"@executable_path/Frameworks",
10481048
);
1049-
MARKETING_VERSION = 1.3.0;
1049+
MARKETING_VERSION = 1.3.1;
10501050
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
10511051
PRODUCT_NAME = bitchat;
10521052
SDKROOT = iphoneos;
@@ -1078,7 +1078,7 @@
10781078
"@executable_path/../Frameworks",
10791079
);
10801080
MACOSX_DEPLOYMENT_TARGET = 13.0;
1081-
MARKETING_VERSION = 1.3.0;
1081+
MARKETING_VERSION = 1.3.1;
10821082
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
10831083
PRODUCT_NAME = bitchat;
10841084
REGISTER_APP_GROUPS = YES;
@@ -1167,7 +1167,7 @@
11671167
"@executable_path/../Frameworks",
11681168
);
11691169
MACOSX_DEPLOYMENT_TARGET = 13.0;
1170-
MARKETING_VERSION = 1.3.0;
1170+
MARKETING_VERSION = 1.3.1;
11711171
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat;
11721172
PRODUCT_NAME = bitchat;
11731173
REGISTER_APP_GROUPS = YES;
@@ -1260,7 +1260,7 @@
12601260
"@executable_path/Frameworks",
12611261
"@executable_path/../../Frameworks",
12621262
);
1263-
MARKETING_VERSION = 1.3.0;
1263+
MARKETING_VERSION = 1.3.1;
12641264
PRODUCT_BUNDLE_IDENTIFIER = chat.bitchat.ShareExtension;
12651265
SDKROOT = iphoneos;
12661266
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";

bitchat/Nostr/NostrRelayManager.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ class NostrRelayManager: ObservableObject {
2828
// Default relay list (can be customized)
2929
private static let defaultRelays = [
3030
"wss://relay.damus.io",
31+
"wss://nos.lol",
3132
"wss://relay.primal.net",
3233
"wss://offchain.pub",
3334
"wss://nostr21.com"

bitchat/Protocols/LocationChannel.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import Foundation
22

33
/// Levels of location channels mapped to geohash precisions.
44
enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
5-
case street
65
case block
76
case neighborhood
87
case city
@@ -12,7 +11,6 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
1211
/// Geohash length used for this level.
1312
var precision: Int {
1413
switch self {
15-
case .street: return 8
1614
case .block: return 7
1715
case .neighborhood: return 6
1816
case .city: return 5
@@ -23,7 +21,6 @@ enum GeohashChannelLevel: CaseIterable, Codable, Equatable {
2321

2422
var displayName: String {
2523
switch self {
26-
case .street: return "Street"
2724
case .block: return "Block"
2825
case .neighborhood: return "Neighborhood"
2926
case .city: return "City"
@@ -68,4 +65,3 @@ enum ChannelID: Equatable, Codable {
6865
}
6966
}
7067
}
71-

bitchat/Services/LocationChannelManager.swift

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,17 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
1717
}
1818

1919
private let cl = CLLocationManager()
20+
private let geocoder = CLGeocoder()
2021
private var lastLocation: CLLocation?
2122
private var refreshTimer: Timer?
2223
private let userDefaultsKey = "locationChannel.selected"
24+
private var isGeocoding: Bool = false
2325

2426
// Published state for UI bindings
2527
@Published private(set) var permissionState: PermissionState = .notDetermined
2628
@Published private(set) var availableChannels: [GeohashChannel] = []
2729
@Published private(set) var selectedChannel: ChannelID = .mesh
30+
@Published private(set) var locationNames: [GeohashChannelLevel: String] = [:]
2831

2932
private override init() {
3033
super.init()
@@ -76,15 +79,20 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
7679

7780
/// Begin periodic one-shot location refreshes while a selector UI is visible.
7881
func beginLiveRefresh(interval: TimeInterval = 5.0) {
79-
// Prefer continuous updates with a significant distance filter rather than polling
8082
guard permissionState == .authorized else { return }
81-
cl.desiredAccuracy = kCLLocationAccuracyHundredMeters
82-
cl.distanceFilter = 21 // meters; update on small moves
83-
cl.startUpdatingLocation()
83+
// Switch to a lightweight periodic one-shot request (polling) while the sheet is open
84+
refreshTimer?.invalidate()
85+
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
86+
self?.requestOneShotLocation()
87+
}
88+
// Kick off immediately
89+
requestOneShotLocation()
8490
}
8591

8692
/// Stop periodic refreshes when selector UI is dismissed.
8793
func endLiveRefresh() {
94+
refreshTimer?.invalidate()
95+
refreshTimer = nil
8896
cl.stopUpdatingLocation()
8997
}
9098

@@ -123,6 +131,7 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
123131
guard let loc = locations.last else { return }
124132
lastLocation = loc
125133
computeChannels(from: loc.coordinate)
134+
reverseGeocodeIfNeeded(location: loc)
126135
}
127136

128137
func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
@@ -153,6 +162,55 @@ final class LocationChannelManager: NSObject, CLLocationManagerDelegate, Observa
153162
}
154163
Task { @MainActor in self.availableChannels = result }
155164
}
165+
166+
private func reverseGeocodeIfNeeded(location: CLLocation) {
167+
// Always cancel previous to keep latest fresh while user moves
168+
geocoder.cancelGeocode()
169+
isGeocoding = true
170+
geocoder.reverseGeocodeLocation(location) { [weak self] placemarks, error in
171+
guard let self = self else { return }
172+
self.isGeocoding = false
173+
if let pm = placemarks?.first {
174+
let names = self.namesByLevel(from: pm)
175+
Task { @MainActor in self.locationNames = names }
176+
}
177+
}
178+
}
179+
180+
private func namesByLevel(from pm: CLPlacemark) -> [GeohashChannelLevel: String] {
181+
var dict: [GeohashChannelLevel: String] = [:]
182+
// Country
183+
if let country = pm.country, !country.isEmpty {
184+
dict[.country] = country
185+
}
186+
// Region (state/province or county)
187+
if let admin = pm.administrativeArea, !admin.isEmpty {
188+
dict[.region] = admin
189+
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
190+
dict[.region] = subAdmin
191+
}
192+
// City (locality)
193+
if let locality = pm.locality, !locality.isEmpty {
194+
dict[.city] = locality
195+
} else if let subAdmin = pm.subAdministrativeArea, !subAdmin.isEmpty {
196+
dict[.city] = subAdmin
197+
} else if let admin = pm.administrativeArea, !admin.isEmpty {
198+
dict[.city] = admin
199+
}
200+
// Neighborhood
201+
if let subLocality = pm.subLocality, !subLocality.isEmpty {
202+
dict[.neighborhood] = subLocality
203+
} else if let locality = pm.locality, !locality.isEmpty {
204+
dict[.neighborhood] = locality
205+
}
206+
// Block: reuse neighborhood/locality granularity without exposing street level
207+
if let subLocality = pm.subLocality, !subLocality.isEmpty {
208+
dict[.block] = subLocality
209+
} else if let locality = pm.locality, !locality.isEmpty {
210+
dict[.block] = locality
211+
}
212+
return dict
213+
}
156214
}
157215

158216
#endif

bitchat/Views/LocationChannelsSheet.swift

Lines changed: 107 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -93,15 +93,20 @@ struct LocationChannelsSheet: View {
9393
private var channelList: some View {
9494
List {
9595
// Mesh option first
96-
channelRow(title: meshTitleWithCount(), subtitle: "#bluetooth", isSelected: isMeshSelected) {
96+
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth\(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
9797
manager.select(ChannelID.mesh)
9898
isPresented = false
9999
}
100100

101101
// Nearby options
102102
if !manager.availableChannels.isEmpty {
103103
ForEach(manager.availableChannels) { channel in
104-
channelRow(title: geohashTitleWithCount(for: channel), subtitle: "#\(channel.geohash)", isSelected: isSelected(channel)) {
104+
let coverage = coverageString(forPrecision: channel.geohash.count)
105+
let nameBase = locationName(for: channel.level)
106+
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
107+
let subtitlePrefix = "#\(channel.geohash)\(coverage)"
108+
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
109+
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, subtitleNameBold: highlight, isSelected: isSelected(channel), titleBold: highlight) {
105110
manager.select(ChannelID.location(channel))
106111
isPresented = false
107112
}
@@ -198,7 +203,7 @@ struct LocationChannelsSheet: View {
198203
return false
199204
}
200205

201-
private func channelRow(title: String, subtitle: String, isSelected: Bool, action: @escaping () -> Void) -> some View {
206+
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 {
202207
Button(action: action) {
203208
HStack {
204209
VStack(alignment: .leading) {
@@ -207,15 +212,28 @@ struct LocationChannelsSheet: View {
207212
HStack(spacing: 4) {
208213
Text(parts.base)
209214
.font(.system(size: 14, design: .monospaced))
215+
.fontWeight(titleBold ? .bold : .regular)
216+
.foregroundColor(titleColor ?? Color.primary)
210217
if let count = parts.countSuffix, !count.isEmpty {
211218
Text(count)
212219
.font(.system(size: 11, design: .monospaced))
213220
.foregroundColor(.secondary)
214221
}
215222
}
216-
Text(subtitle)
217-
.font(.system(size: 12, design: .monospaced))
218-
.foregroundColor(.secondary)
223+
HStack(spacing: 0) {
224+
Text(subtitlePrefix)
225+
.font(.system(size: 12, design: .monospaced))
226+
.foregroundColor(.secondary)
227+
if let name = subtitleName {
228+
Text("")
229+
.font(.system(size: 12, design: .monospaced))
230+
.foregroundColor(.secondary)
231+
Text(name)
232+
.font(.system(size: 12, design: .monospaced))
233+
.fontWeight(subtitleNameBold ? .bold : .regular)
234+
.foregroundColor(.secondary)
235+
}
236+
}
219237
}
220238
Spacer()
221239
if isSelected {
@@ -241,13 +259,17 @@ struct LocationChannelsSheet: View {
241259
// MARK: - Helpers for counts
242260
private func meshTitleWithCount() -> String {
243261
// Count currently connected mesh peers (excluding self)
262+
let meshCount = meshCount()
263+
let noun = meshCount == 1 ? "person" : "people"
264+
return "mesh [\(meshCount) \(noun)]"
265+
}
266+
267+
private func meshCount() -> Int {
244268
let myID = viewModel.meshService.myPeerID
245-
let meshCount = viewModel.allPeers.reduce(0) { acc, peer in
269+
return viewModel.allPeers.reduce(0) { acc, peer in
246270
if peer.id != myID && peer.isConnected { return acc + 1 }
247271
return acc
248272
}
249-
let noun = meshCount == 1 ? "person" : "people"
250-
return "#mesh [\(meshCount) \(noun)]"
251273
}
252274

253275
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
@@ -270,7 +292,7 @@ struct LocationChannelsSheet: View {
270292
case 5: return .city
271293
case 6: return .neighborhood
272294
case 7: return .block
273-
default: return .street
295+
default: return .block
274296
}
275297
}
276298
}
@@ -280,6 +302,81 @@ extension LocationChannelsSheet {
280302
private var standardGreen: Color {
281303
(colorScheme == .dark) ? Color.green : Color(red: 0, green: 0.5, blue: 0)
282304
}
305+
private var standardBlue: Color {
306+
Color(red: 0.0, green: 0.478, blue: 1.0)
307+
}
308+
}
309+
310+
// MARK: - Coverage helpers
311+
extension LocationChannelsSheet {
312+
private func coverageString(forPrecision len: Int) -> String {
313+
// Approximate max cell dimension at equator for a given geohash length.
314+
// Values sourced from common geohash dimension tables.
315+
let maxMeters: Double = {
316+
switch len {
317+
case 2: return 1_250_000
318+
case 3: return 156_000
319+
case 4: return 39_100
320+
case 5: return 4_890
321+
case 6: return 1_220
322+
case 7: return 153
323+
case 8: return 38.2
324+
case 9: return 4.77
325+
case 10: return 1.19
326+
default:
327+
if len <= 1 { return 5_000_000 }
328+
// For >10, scale down conservatively by ~1/4 each char
329+
let over = len - 10
330+
return 1.19 * pow(0.25, Double(over))
331+
}
332+
}()
333+
334+
let usesMetric: Bool = {
335+
if #available(iOS 16.0, *) {
336+
return Locale.current.measurementSystem == .metric
337+
} else {
338+
return Locale.current.usesMetricSystem
339+
}
340+
}()
341+
if usesMetric {
342+
let km = maxMeters / 1000.0
343+
return "~\(formatDistance(km)) km"
344+
} else {
345+
let miles = maxMeters / 1609.344
346+
return "~\(formatDistance(miles)) mi"
347+
}
348+
}
349+
350+
private func formatDistance(_ value: Double) -> String {
351+
if value >= 100 { return String(format: "%.0f", value.rounded()) }
352+
if value >= 10 { return String(format: "%.1f", value) }
353+
return String(format: "%.1f", value)
354+
}
355+
356+
private func bluetoothRangeString() -> String {
357+
let usesMetric: Bool = {
358+
if #available(iOS 16.0, *) {
359+
return Locale.current.measurementSystem == .metric
360+
} else {
361+
return Locale.current.usesMetricSystem
362+
}
363+
}()
364+
// Approximate Bluetooth LE range for typical mobile devices; environment dependent
365+
return usesMetric ? "~10–50 m" : "~30–160 ft"
366+
}
367+
368+
private func locationName(for level: GeohashChannelLevel) -> String? {
369+
manager.locationNames[level]
370+
}
371+
372+
private func formattedNamePrefix(for level: GeohashChannelLevel) -> String {
373+
switch level {
374+
case .country:
375+
return ""
376+
default:
377+
return "~"
378+
}
379+
}
283380
}
284381

285382
#endif

bitchatTests/LocationChannelsTests.swift

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,22 +6,19 @@ final class LocationChannelsTests: XCTestCase {
66
// Sanity: known coords (Statue of Liberty approx)
77
let lat = 40.6892
88
let lon = -74.0445
9-
let street = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.street.precision)
109
let block = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.block.precision)
1110
let neighborhood = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.neighborhood.precision)
1211
let city = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.city.precision)
1312
let region = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.region.precision)
1413
let country = Geohash.encode(latitude: lat, longitude: lon, precision: GeohashChannelLevel.country.precision)
1514

16-
XCTAssertEqual(street.count, 8)
1715
XCTAssertEqual(block.count, 7)
1816
XCTAssertEqual(neighborhood.count, 6)
1917
XCTAssertEqual(city.count, 5)
2018
XCTAssertEqual(region.count, 4)
2119
XCTAssertEqual(country.count, 2)
2220

2321
// All prefixes must match progressively
24-
XCTAssertTrue(street.hasPrefix(block))
2522
XCTAssertTrue(block.hasPrefix(neighborhood))
2623
XCTAssertTrue(neighborhood.hasPrefix(city))
2724
XCTAssertTrue(city.hasPrefix(region))

0 commit comments

Comments
 (0)