mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 09:45:18 +00:00
Notifications: remove background 'new chats!' nudge; geohash activity only on zero→alive; mesh 'nearby' only on zero→alive\n\n- Geohash sampling: notify only when previous participant count in last 5m was zero, with 30s freshness gate\n- Suppress old sampled events after (re)subscribe\n- Remove channel inactivity nudge\n- Mesh: reset rising-edge gate immediately when peer list goes empty (strict zero→alive)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> = []
|
||||
#if os(iOS) || os(macOS)
|
||||
private let geocoder = CLGeocoder()
|
||||
private var resolving: Set<String> = []
|
||||
#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<String>()
|
||||
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<Element: Hashable> {
|
||||
private var set: Set<Element> = []
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
Reference in New Issue
Block a user