mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 18:45:22 +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
|
let lon = (lonInterval.0 + lonInterval.1) / 2
|
||||||
return (lat, lon)
|
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 Foundation
|
||||||
import Combine
|
import Combine
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
import CoreLocation
|
||||||
|
#endif
|
||||||
|
|
||||||
/// Stores a user-maintained list of bookmarked geohash channels.
|
/// Stores a user-maintained list of bookmarked geohash channels.
|
||||||
/// - Persistence: UserDefaults (JSON string array)
|
/// - Persistence: UserDefaults (JSON string array)
|
||||||
@@ -8,9 +11,15 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
static let shared = GeohashBookmarksStore()
|
static let shared = GeohashBookmarksStore()
|
||||||
|
|
||||||
@Published private(set) var bookmarks: [String] = []
|
@Published private(set) var bookmarks: [String] = []
|
||||||
|
@Published private(set) var bookmarkNames: [String: String] = [:] // geohash -> friendly name
|
||||||
|
|
||||||
private let storeKey = "locationChannel.bookmarks"
|
private let storeKey = "locationChannel.bookmarks"
|
||||||
|
private let namesStoreKey = "locationChannel.bookmarkNames"
|
||||||
private var membership: Set<String> = []
|
private var membership: Set<String> = []
|
||||||
|
#if os(iOS) || os(macOS)
|
||||||
|
private let geocoder = CLGeocoder()
|
||||||
|
private var resolving: Set<String> = []
|
||||||
|
#endif
|
||||||
|
|
||||||
private init() {
|
private init() {
|
||||||
load()
|
load()
|
||||||
@@ -37,6 +46,8 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
bookmarks.insert(gh, at: 0)
|
bookmarks.insert(gh, at: 0)
|
||||||
membership.insert(gh)
|
membership.insert(gh)
|
||||||
persist()
|
persist()
|
||||||
|
// Resolve and persist a friendly name once when added
|
||||||
|
resolveNameIfNeeded(for: gh)
|
||||||
}
|
}
|
||||||
|
|
||||||
func remove(_ geohash: String) {
|
func remove(_ geohash: String) {
|
||||||
@@ -44,6 +55,10 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
guard membership.contains(gh) else { return }
|
guard membership.contains(gh) else { return }
|
||||||
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
|
if let idx = bookmarks.firstIndex(of: gh) { bookmarks.remove(at: idx) }
|
||||||
membership.remove(gh)
|
membership.remove(gh)
|
||||||
|
// Clean up stored name to avoid stale cache growth
|
||||||
|
if bookmarkNames.removeValue(forKey: gh) != nil {
|
||||||
|
persistNames()
|
||||||
|
}
|
||||||
persist()
|
persist()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +80,11 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
bookmarks = list
|
bookmarks = list
|
||||||
membership = seen
|
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() {
|
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
|
// MARK: - Helpers
|
||||||
private static func normalize(_ s: String) -> String {
|
private static func normalize(_ s: String) -> String {
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
@@ -83,12 +109,119 @@ final class GeohashBookmarksStore: ObservableObject {
|
|||||||
.filter { allowed.contains($0) }
|
.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
|
#if DEBUG
|
||||||
/// Testing-only reset helper
|
/// Testing-only reset helper
|
||||||
func _resetForTesting() {
|
func _resetForTesting() {
|
||||||
bookmarks.removeAll()
|
bookmarks.removeAll()
|
||||||
membership.removeAll()
|
membership.removeAll()
|
||||||
|
bookmarkNames.removeAll()
|
||||||
persist()
|
persist()
|
||||||
|
persistNames()
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1791,17 +1791,27 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||||
guard let self = self else { return }
|
guard let self = self else { return }
|
||||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue 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
|
// Update participants for this specific geohash
|
||||||
self.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
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)
|
let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !content.isEmpty else { return }
|
guard !content.isEmpty else { return }
|
||||||
// Respect geohash blocks
|
// Respect geohash blocks
|
||||||
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
if SecureIdentityStateManager.shared.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||||
// Skip self identity for this geohash
|
// Skip self identity for this geohash
|
||||||
if let my = try? NostrIdentityBridge.deriveIdentity(forGeohash: gh), my.publicKeyHex.lowercased() == event.pubkey.lowercased() { return }
|
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
|
// 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 os(iOS)
|
||||||
if UIApplication.shared.applicationState == .active {
|
if UIApplication.shared.applicationState == .active {
|
||||||
if case .location(let ch) = self.activeChannel, ch.geohash == gh { return }
|
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)
|
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 currentPeerSet = Set(meshPeers)
|
||||||
let newPeers = currentPeerSet.subtracting(self.recentlySeenPeers)
|
let hadNone = self.recentlySeenPeers.isEmpty
|
||||||
// Send notification if:
|
if meshPeers.count > 0 && hadNone && !self.hasNotifiedNetworkAvailable {
|
||||||
// 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 {
|
|
||||||
self.hasNotifiedNetworkAvailable = true
|
self.hasNotifiedNetworkAvailable = true
|
||||||
self.lastNetworkNotificationTime = Date()
|
self.lastNetworkNotificationTime = Date()
|
||||||
self.recentlySeenPeers = currentPeerSet
|
self.recentlySeenPeers = currentPeerSet
|
||||||
@@ -4507,16 +4513,14 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .info)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No peers - schedule a graceful reset to avoid refiring on brief drops
|
// No peers — immediately reset to allow next rising-edge to notify
|
||||||
if self.networkResetTimer == nil {
|
self.hasNotifiedNetworkAvailable = false
|
||||||
self.networkResetTimer = Timer.scheduledTimer(withTimeInterval: self.networkResetGraceSeconds, repeats: false) { [weak self] _ in
|
self.recentlySeenPeers.removeAll()
|
||||||
guard let self = self else { return }
|
if self.networkResetTimer != nil {
|
||||||
self.hasNotifiedNetworkAvailable = false
|
self.networkResetTimer?.invalidate()
|
||||||
self.recentlySeenPeers.removeAll()
|
self.networkResetTimer = nil
|
||||||
self.networkResetTimer = nil
|
|
||||||
SecureLogger.log("⏳ Mesh empty for \(Int(self.networkResetGraceSeconds))s — reset network notification state", category: SecureLogger.session, level: .debug)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
SecureLogger.log("⏳ Mesh empty — reset network notification state", category: SecureLogger.session, level: .debug)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register ephemeral sessions for all connected peers
|
// Register ephemeral sessions for all connected peers
|
||||||
@@ -5702,34 +5706,7 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
guard channelMatches else { return }
|
guard channelMatches else { return }
|
||||||
|
|
||||||
// Background nudge: notify on new activity after inactivity threshold in current channel
|
// Removed background nudge notification for generic "new chats!"
|
||||||
#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
|
|
||||||
|
|
||||||
// Append via batching buffer (skip empty content)
|
// Append via batching buffer (skip empty content)
|
||||||
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
|||||||
@@ -119,13 +119,13 @@ struct LocationChannelsSheet: View {
|
|||||||
subtitleName: namePart,
|
subtitleName: namePart,
|
||||||
isSelected: isSelected(channel),
|
isSelected: isSelected(channel),
|
||||||
titleBold: highlight,
|
titleBold: highlight,
|
||||||
leadingAccessory: {
|
trailingAccessory: {
|
||||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.trailing, 6)
|
.padding(.leading, 8)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
// 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 channel = GeohashChannel(level: level, geohash: gh)
|
||||||
let coverage = coverageString(forPrecision: gh.count)
|
let coverage = coverageString(forPrecision: gh.count)
|
||||||
let subtitle = "#\(gh) • \(coverage)"
|
let subtitle = "#\(gh) • \(coverage)"
|
||||||
|
let name = bookmarks.bookmarkNames[gh]
|
||||||
channelRow(
|
channelRow(
|
||||||
title: geohashTitleWithCount(for: channel),
|
title: geohashHashTitleWithCount(gh),
|
||||||
subtitlePrefix: subtitle,
|
subtitlePrefix: subtitle,
|
||||||
|
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||||||
isSelected: isSelected(channel),
|
isSelected: isSelected(channel),
|
||||||
leadingAccessory: {
|
trailingAccessory: {
|
||||||
Button(action: { bookmarks.toggle(gh) }) {
|
Button(action: { bookmarks.toggle(gh) }) {
|
||||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||||
.font(.system(size: 14))
|
.font(.system(size: 14))
|
||||||
}
|
}
|
||||||
.buttonStyle(.plain)
|
.buttonStyle(.plain)
|
||||||
.padding(.trailing, 6)
|
.padding(.leading, 8)
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
// For bookmarked selection, mark teleported based on regional membership
|
// For bookmarked selection, mark teleported based on regional membership
|
||||||
@@ -239,6 +241,7 @@ struct LocationChannelsSheet: View {
|
|||||||
manager.select(ChannelID.location(channel))
|
manager.select(ChannelID.location(channel))
|
||||||
isPresented = false
|
isPresented = false
|
||||||
}
|
}
|
||||||
|
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,11 +286,10 @@ struct LocationChannelsSheet: View {
|
|||||||
isSelected: Bool,
|
isSelected: Bool,
|
||||||
titleColor: Color? = nil,
|
titleColor: Color? = nil,
|
||||||
titleBold: Bool = false,
|
titleBold: Bool = false,
|
||||||
@ViewBuilder leadingAccessory: () -> some View = { EmptyView() },
|
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
|
||||||
action: @escaping () -> Void
|
action: @escaping () -> Void
|
||||||
) -> some View {
|
) -> some View {
|
||||||
HStack(alignment: .center, spacing: 8) {
|
HStack(alignment: .center, spacing: 8) {
|
||||||
leadingAccessory()
|
|
||||||
VStack(alignment: .leading) {
|
VStack(alignment: .leading) {
|
||||||
// Render title with smaller font for trailing count in parentheses
|
// Render title with smaller font for trailing count in parentheses
|
||||||
let parts = splitTitleAndCount(title)
|
let parts = splitTitleAndCount(title)
|
||||||
@@ -314,6 +316,8 @@ struct LocationChannelsSheet: View {
|
|||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
.fontWeight(subtitleNameBold ? .bold : .regular)
|
.fontWeight(subtitleNameBold ? .bold : .regular)
|
||||||
.foregroundColor(.secondary)
|
.foregroundColor(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -323,6 +327,7 @@ struct LocationChannelsSheet: View {
|
|||||||
.font(.system(size: 16, design: .monospaced))
|
.font(.system(size: 16, design: .monospaced))
|
||||||
.foregroundColor(standardGreen)
|
.foregroundColor(standardGreen)
|
||||||
}
|
}
|
||||||
|
trailingAccessory()
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.contentShape(Rectangle())
|
.contentShape(Rectangle())
|
||||||
@@ -355,12 +360,19 @@ struct LocationChannelsSheet: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func geohashTitleWithCount(for channel: GeohashChannel) -> String {
|
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 count = viewModel.geohashParticipantCount(for: channel.geohash)
|
||||||
let noun = count == 1 ? "person" : "people"
|
let noun = count == 1 ? "person" : "people"
|
||||||
return "\(channel.level.displayName.lowercased()) [\(count) \(noun)]"
|
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 {
|
private func validateGeohash(_ s: String) -> Bool {
|
||||||
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
let allowed = Set("0123456789bcdefghjkmnpqrstuvwxyz")
|
||||||
guard !s.isEmpty, s.count <= 12 else { return false }
|
guard !s.isEmpty, s.count <= 12 else { return false }
|
||||||
|
|||||||
Reference in New Issue
Block a user