diff --git a/bitchat/Protocols/Geohash.swift b/bitchat/Protocols/Geohash.swift index b109bb7f..bdd0a299 100644 --- a/bitchat/Protocols/Geohash.swift +++ b/bitchat/Protocols/Geohash.swift @@ -87,4 +87,28 @@ enum Geohash { let lon = (lonInterval.0 + lonInterval.1) / 2 return (lat, lon) } + + /// Decodes a geohash into its latitude and longitude bounds. + /// - Parameter geohash: Base32 geohash string. + /// - Returns: (latMin, latMax, lonMin, lonMax) + static func decodeBounds(_ geohash: String) -> (latMin: Double, latMax: Double, lonMin: Double, lonMax: Double) { + var latInterval: (Double, Double) = (-90.0, 90.0) + var lonInterval: (Double, Double) = (-180.0, 180.0) + + var isEven = true + for ch in geohash.lowercased() { + guard let cd = base32Map[ch] else { continue } + for mask in [16, 8, 4, 2, 1] { + if isEven { + let mid = (lonInterval.0 + lonInterval.1) / 2 + if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid } + } else { + let mid = (latInterval.0 + latInterval.1) / 2 + if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid } + } + isEven.toggle() + } + } + return (latInterval.0, latInterval.1, lonInterval.0, lonInterval.1) + } } diff --git a/bitchat/Services/GeohashBookmarksStore.swift b/bitchat/Services/GeohashBookmarksStore.swift index 2c2c735f..39e2d7a1 100644 --- a/bitchat/Services/GeohashBookmarksStore.swift +++ b/bitchat/Services/GeohashBookmarksStore.swift @@ -1,5 +1,8 @@ import Foundation import Combine +#if os(iOS) || os(macOS) +import CoreLocation +#endif /// Stores a user-maintained list of bookmarked geohash channels. /// - Persistence: UserDefaults (JSON string array) @@ -8,9 +11,15 @@ final class GeohashBookmarksStore: ObservableObject { static let shared = GeohashBookmarksStore() @Published private(set) var bookmarks: [String] = [] + @Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name private let storeKey = "locationChannel.bookmarks" + private let namesStoreKey = "locationChannel.bookmarkNames" private var membership: Set = [] + #if os(iOS) || os(macOS) + private let geocoder = CLGeocoder() + private var resolving: Set = [] + #endif private init() { load() @@ -37,6 +46,8 @@ final class GeohashBookmarksStore: ObservableObject { bookmarks.insert(gh, at: 0) membership.insert(gh) persist() + // Resolve and persist a friendly name once when added + resolveNameIfNeeded(for: gh) } func remove(_ geohash: String) { @@ -44,6 +55,10 @@ final class GeohashBookmarksStore: ObservableObject { guard membership.contains(gh) else { return } if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) } membership.remove(gh) + // Clean up stored name to avoid stale cache growth + if bookmarkNames.removeValue(forKey: gh) != nil { + persistNames() + } persist() } @@ -65,6 +80,11 @@ final class GeohashBookmarksStore: ObservableObject { bookmarks = list membership = seen } + // Load any saved names + if let namesData = UserDefaults.standard.data(forKey: namesStoreKey), + let dict = try? JSONDecoder().decode([String: String].self, from: namesData) { + bookmarkNames = dict + } } private func persist() { @@ -73,6 +93,12 @@ final class GeohashBookmarksStore: ObservableObject { } } + private func persistNames() { + if let data = try? JSONEncoder().encode(bookmarkNames) { + UserDefaults.standard.set(data, forKey: namesStoreKey) + } + } + // MARK: - Helpers private static func normalize(_ s: String) -> String { let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") @@ -83,12 +109,119 @@ final class GeohashBookmarksStore: ObservableObject { .filter { allowed.contains($0) } } + // MARK: - Name Resolution + /// Attempt to resolve and persist a friendly place name for a bookmarked geohash. + func resolveNameIfNeeded(for geohash: String) { + let gh = Self.normalize(geohash) + guard !gh.isEmpty else { return } + if bookmarkNames[gh] != nil { return } + #if os(iOS) || os(macOS) + if resolving.contains(gh) { return } + resolving.insert(gh) + // For very coarse geohashes, sample multiple points to capture multiple admin areas + if gh.count <= 2 { + let b = Geohash.decodeBounds(gh) + let pts: [CLLocation] = [ + CLLocation(latitude: (b.latMin + b.latMax) / 2, longitude: (b.lonMin + b.lonMax) / 2), // center + CLLocation(latitude: b.latMin, longitude: b.lonMin), + CLLocation(latitude: b.latMin, longitude: b.lonMax), + CLLocation(latitude: b.latMax, longitude: b.lonMin), + CLLocation(latitude: b.latMax, longitude: b.lonMax) + ] + resolveCompositeAdminName(geohash: gh, points: pts) + } else { + let center = Geohash.decodeCenter(gh) + let loc = CLLocation(latitude: center.lat, longitude: center.lon) + geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in + guard let self = self else { return } + defer { self.resolving.remove(gh) } + if let pm = placemarks?.first { + let name = Self.nameForGeohashLength(gh.count, from: pm) + if let name = name, !name.isEmpty { + DispatchQueue.main.async { + self.bookmarkNames[gh] = name + self.persistNames() + } + } + } + } + } + #endif + } + + #if os(iOS) || os(macOS) + private func resolveCompositeAdminName(geohash gh: String, points: [CLLocation]) { + var uniqueAdmins = OrderedSet() + var idx = 0 + func step() { + if idx >= points.count { + // Compose up to 2 names joined by ' and ' + let finalName: String? = { + let names = uniqueAdmins.array + if names.count >= 2 { return names[0] + " and " + names[1] } + return names.first + }() + if let finalName = finalName, !finalName.isEmpty { + DispatchQueue.main.async { + self.bookmarkNames[gh] = finalName + self.persistNames() + } + } + self.resolving.remove(gh) + return + } + let loc = points[idx] + idx += 1 + geocoder.reverseGeocodeLocation(loc) { [weak self] placemarks, _ in + guard self != nil else { return } + if let pm = placemarks?.first { + if let admin = pm.administrativeArea, !admin.isEmpty { + uniqueAdmins.insert(admin) + } else if let country = pm.country, !country.isEmpty { + uniqueAdmins.insert(country) + } + } + // Proceed to next point + step() + } + } + step() + } + + // Minimal ordered-set for stable joining + private struct OrderedSet { + private var set: Set = [] + private(set) var array: [Element] = [] + mutating func insert(_ element: Element) { + if set.insert(element).inserted { array.append(element) } + } + } + + private static func nameForGeohashLength(_ len: Int, from pm: CLPlacemark) -> String? { + switch len { + case 0...2: + // Prefer administrative area if available at this coarse level + return pm.administrativeArea ?? pm.country + case 3...4: + return pm.administrativeArea ?? pm.subAdministrativeArea ?? pm.country + case 5: + return pm.locality ?? pm.subAdministrativeArea ?? pm.administrativeArea + case 6...7: + return pm.subLocality ?? pm.locality ?? pm.administrativeArea + default: + return pm.subLocality ?? pm.locality ?? pm.administrativeArea ?? pm.country + } + } + #endif + #if DEBUG /// Testing-only reset helper func _resetForTesting() { bookmarks.removeAll() membership.removeAll() + bookmarkNames.removeAll() persist() + persistNames() } #endif } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 8bcfc470..9578468a 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -1791,17 +1791,27 @@ class ChatViewModel: ObservableObject, BitchatDelegate { NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in guard let self = self else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } + // Compute existing participant count (5-minute window) BEFORE updating + let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) + let existingCount: Int = { + let map = self.geoParticipants[gh] ?? [:] + return map.values.filter { $0 >= cutoff }.count + }() // Update participants for this specific geohash self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) - // Notify on new message activity in this geohash (sampling across channels) + // Notify only on rising-edge: previously zero people, now someone chats let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) guard !content.isEmpty else { return } // Respect geohash blocks if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return } // Skip self identity for this geohash if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return } + // Only trigger when there were zero participants in this geohash recently + guard existingCount == 0 else { return } + // Avoid notifications for old sampled events when launching or (re)subscribing + let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at)) + if Date().timeIntervalSince(eventTime) > 30 { return } // Foreground policy: allow if it's a different geohash than the one currently open - // Suppress only when app is active AND we're already in this same geohash channel #if os(iOS) if UIApplication.shared.applicationState == .active { if case .location(let ch) = self.activeChannel, ch.geohash == gh { return } @@ -4491,14 +4501,10 @@ class ChatViewModel: ObservableObject, BitchatDelegate { self.meshService.isPeerConnected(peerID) || self.meshService.isPeerReachable(peerID) } - // Check if we have new mesh peers we haven't seen recently + // Rising-edge only: previously zero peers, now > 0 peers let currentPeerSet = Set(meshPeers) - let newPeers = currentPeerSet.subtracting(self.recentlySeenPeers) - // Send notification if: - // 1. We have mesh peers (not just Nostr-only) - // 2. There are new peers we haven't seen (rising-edge) - // 3. We haven't already notified since the last sustained-empty period - if meshPeers.count > 0 && !newPeers.isEmpty && !self.hasNotifiedNetworkAvailable { + let hadNone = self.recentlySeenPeers.isEmpty + if meshPeers.count > 0 && hadNone && !self.hasNotifiedNetworkAvailable { self.hasNotifiedNetworkAvailable = true self.lastNetworkNotificationTime = Date() self.recentlySeenPeers = currentPeerSet @@ -4507,16 +4513,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate { category: SecureLogger.session, level: .info) } } else { - // No peers - schedule a graceful reset to avoid refiring on brief drops - if self.networkResetTimer == nil { - self.networkResetTimer = Timer.scheduledTimer(withTimeInterval: self.networkResetGraceSeconds, repeats: false) { [weak self] _ in - guard let self = self else { return } - self.hasNotifiedNetworkAvailable = false - self.recentlySeenPeers.removeAll() - self.networkResetTimer = nil - SecureLogger.log("⏳ Mesh empty for \(Int(self.networkResetGraceSeconds))s — reset network notification state", category: SecureLogger.session, level: .debug) - } + // No peers — immediately reset to allow next rising-edge to notify + self.hasNotifiedNetworkAvailable = false + self.recentlySeenPeers.removeAll() + if self.networkResetTimer != nil { + self.networkResetTimer?.invalidate() + self.networkResetTimer = nil } + SecureLogger.log("⏳ Mesh empty — reset network notification state", category: SecureLogger.session, level: .debug) } // Register ephemeral sessions for all connected peers @@ -5702,34 +5706,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate { guard channelMatches else { return } - // Background nudge: notify on new activity after inactivity threshold in current channel - #if os(iOS) - if UIApplication.shared.applicationState != .active { - let channelKey: String = { - switch activeChannel { - case .mesh: return "mesh" - case .location(let ch): return "geo:\(ch.geohash)" - } - }() - let now = Date() - if let last = lastPublicActivityAt[channelKey], now.timeIntervalSince(last) >= channelInactivityThreshold { - // Optional: simple cooldown to avoid duplicate bursts - let lastNotified = lastPublicActivityNotifyAt[channelKey] ?? .distantPast - if now.timeIntervalSince(lastNotified) >= 60 { - let title = activeChannelDisplayName() - let body = "new chats!" - if case .location(let ch) = activeChannel { - // Attach deep link to open this geohash directly - NotificationService.shared.sendGeohashActivityNotification(geohash: ch.geohash, titlePrefix: title + " ", bodyPreview: body) - } else { - NotificationService.shared.sendLocalNotification(title: title, body: body, identifier: "channel-activity-\(channelKey)-\(now.timeIntervalSince1970)") - } - lastPublicActivityNotifyAt[channelKey] = now - } - } - lastPublicActivityAt[channelKey] = now - } - #endif + // Removed background nudge notification for generic "new chats!" // Append via batching buffer (skip empty content) if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index c30e2244..44a6438e 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -119,13 +119,13 @@ struct LocationChannelsSheet: View { subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight, - leadingAccessory: { + trailingAccessory: { Button(action: { bookmarks.toggle(channel.geohash) }) { Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") .font(.system(size: 14)) } .buttonStyle(.plain) - .padding(.trailing, 6) + .padding(.leading, 8) } ) { // Selecting a suggested nearby channel is not a teleport. Persist this. @@ -216,17 +216,19 @@ struct LocationChannelsSheet: View { let channel = GeohashChannel(level: level, geohash: gh) let coverage = coverageString(forPrecision: gh.count) let subtitle = "#\(gh) • \(coverage)" + let name = bookmarks.bookmarkNames[gh] channelRow( - title: geohashTitleWithCount(for: channel), + title: geohashHashTitleWithCount(gh), subtitlePrefix: subtitle, + subtitleName: name.map { formattedNamePrefix(for: level) + $0 }, isSelected: isSelected(channel), - leadingAccessory: { + trailingAccessory: { Button(action: { bookmarks.toggle(gh) }) { Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark") .font(.system(size: 14)) } .buttonStyle(.plain) - .padding(.trailing, 6) + .padding(.leading, 8) } ) { // For bookmarked selection, mark teleported based on regional membership @@ -239,6 +241,7 @@ struct LocationChannelsSheet: View { manager.select(ChannelID.location(channel)) isPresented = false } + .onAppear { bookmarks.resolveNameIfNeeded(for: gh) } } } @@ -283,11 +286,10 @@ struct LocationChannelsSheet: View { isSelected: Bool, titleColor: Color? = nil, titleBold: Bool = false, - @ViewBuilder leadingAccessory: () -> some View = { EmptyView() }, + @ViewBuilder trailingAccessory: () -> some View = { EmptyView() }, action: @escaping () -> Void ) -> some View { HStack(alignment: .center, spacing: 8) { - leadingAccessory() VStack(alignment: .leading) { // Render title with smaller font for trailing count in parentheses let parts = splitTitleAndCount(title) @@ -314,6 +316,8 @@ struct LocationChannelsSheet: View { .font(.system(size: 12, design: .monospaced)) .fontWeight(subtitleNameBold ? .bold : .regular) .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) } } } @@ -323,6 +327,7 @@ struct LocationChannelsSheet: View { .font(.system(size: 16, design: .monospaced)) .foregroundColor(standardGreen) } + trailingAccessory() } .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) @@ -355,12 +360,19 @@ struct LocationChannelsSheet: View { } private func geohashTitleWithCount(for channel: GeohashChannel) -> String { - // Use ViewModel's 5-minute activity counts; may be 0 for non-selected channels + // Main list: keep level labels (block/neighborhood/city/province/region) let count = viewModel.geohashParticipantCount(for: channel.geohash) let noun = count == 1 ? "person" : "people" return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]" } + private func geohashHashTitleWithCount(_ geohash: String) -> String { + // Bookmarked list: show the #geohash as the main label + let count = viewModel.geohashParticipantCount(for: geohash) + let noun = count == 1 ? "person" : "people" + return "#\(geohash) [\(count) \(noun)]" + } + private func validateGeohash(_ s: String) -> Bool { let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz") guard !s.isEmpty, s.count <= 12 else { return false }