mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 00:45:21 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b363b7062 | ||
|
|
7d672f2d69 | ||
|
|
d35d3f9612 | ||
|
|
9e79b18dcb |
@@ -32,6 +32,10 @@
|
||||
048A4BE72E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
||||
048A4BE82E5CCCC300162C4A /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
||||
048A4BE92E5CCCC300162C4B /* TransportConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */; };
|
||||
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
|
||||
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */; };
|
||||
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
|
||||
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */; };
|
||||
049BD3902E4EC4F0001A566B /* PrivateChatManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */; };
|
||||
049BD3912E4EC4F0001A566B /* AutocompleteService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */; };
|
||||
049BD3922E4EC4F0001A566B /* CommandProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */; };
|
||||
@@ -205,6 +209,8 @@
|
||||
047502B32E55FED60083520F /* MeshPeerList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshPeerList.swift; sourceTree = "<group>"; };
|
||||
047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = "<group>"; };
|
||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = "<group>"; };
|
||||
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = "<group>"; };
|
||||
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = "<group>"; };
|
||||
049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = "<group>"; };
|
||||
049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = "<group>"; };
|
||||
049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = "<group>"; };
|
||||
@@ -483,6 +489,7 @@
|
||||
C3D98EB3E1B455E321F519F4 /* bitchatTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */,
|
||||
D69A18D27F9A565FD6041E12 /* Info.plist */,
|
||||
047502912E547ACC0083520F /* LocationChannelsTests.swift */,
|
||||
C272F137CE00FC5A96E0CC06 /* NostrProtocolTests.swift */,
|
||||
@@ -519,6 +526,7 @@
|
||||
D98A3186D7E4C72E35BDF7FE /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */,
|
||||
048A4BE62E5CCCC300162C4A /* TransportConfig.swift */,
|
||||
AA77BB10CC22DD33EE44FF55 /* VerificationService.swift */,
|
||||
047502B82E560F690083520F /* RelayController.swift */,
|
||||
@@ -771,6 +779,7 @@
|
||||
0475028C2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||
AFF33EF44626EF0579D17EB1 /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
8C1AB0F2D48207E0755DA91A /* NoiseProtocol.swift in Sources */,
|
||||
048A4C282E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
|
||||
049BD3AC2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||
D691938B4029A04CC905FDC8 /* NoiseSecurityConsiderations.swift in Sources */,
|
||||
8A14ADADF5CD7A79919CB655 /* NoiseSession.swift in Sources */,
|
||||
@@ -830,6 +839,7 @@
|
||||
0475028D2E54171C0083520F /* LocationChannelManager.swift in Sources */,
|
||||
6D0D4A0B1D8B659DCBAE7C9C /* NoiseHandshakeCoordinator.swift in Sources */,
|
||||
A7187D48B07C6857DE01D0ED /* NoiseProtocol.swift in Sources */,
|
||||
048A4C292E5FCD6600162C4A /* GeohashBookmarksStore.swift in Sources */,
|
||||
049BD3AB2E51E38E001A566B /* PeerIDResolver.swift in Sources */,
|
||||
9CCF09F7527EC681A13FC246 /* NoiseSecurityConsiderations.swift in Sources */,
|
||||
92D1CF17DF88EA298F6E5E8E /* NoiseSession.swift in Sources */,
|
||||
@@ -866,6 +876,7 @@
|
||||
047502B12E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||
D727EA273CB214FC32612469 /* MockBluetoothMeshService.swift in Sources */,
|
||||
047502932E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||
048A4C2B2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
||||
6C803BF930E7E19BE6E99EAA /* MockBLEService.swift in Sources */,
|
||||
765254F56997F01054699AC0 /* NoiseProtocolTests.swift in Sources */,
|
||||
968181D255CA7A804340B4DA /* NostrProtocolTests.swift in Sources */,
|
||||
@@ -888,6 +899,7 @@
|
||||
047502B02E55E8450083520F /* InputValidatorTests.swift in Sources */,
|
||||
8851F08D88C5B1DE7B9F55C6 /* MockBluetoothMeshService.swift in Sources */,
|
||||
047502922E547ACC0083520F /* LocationChannelsTests.swift in Sources */,
|
||||
048A4C2C2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift in Sources */,
|
||||
3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */,
|
||||
BC4DC75F4FB823FF40569676 /* NoiseProtocolTests.swift in Sources */,
|
||||
EE8C3ECADAB3083A2687D50B /* NostrProtocolTests.swift in Sources */,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
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)
|
||||
/// - Semantics: geohashes are normalized to lowercase base32 and de-duplicated
|
||||
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()
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
func isBookmarked(_ geohash: String) -> Bool {
|
||||
return membership.contains(Self.normalize(geohash))
|
||||
}
|
||||
|
||||
func toggle(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
if membership.contains(gh) {
|
||||
remove(gh)
|
||||
} else {
|
||||
add(gh)
|
||||
}
|
||||
}
|
||||
|
||||
func add(_ geohash: String) {
|
||||
let gh = Self.normalize(geohash)
|
||||
guard !gh.isEmpty else { return }
|
||||
guard !membership.contains(gh) else { return }
|
||||
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) {
|
||||
let gh = Self.normalize(geohash)
|
||||
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()
|
||||
}
|
||||
|
||||
// MARK: - Persistence
|
||||
private func load() {
|
||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else { return }
|
||||
if let arr = try? JSONDecoder().decode([String].self, from: data) {
|
||||
// Sanitize, normalize, dedupe while preserving order (first occurrence wins)
|
||||
var seen = Set<String>()
|
||||
var list: [String] = []
|
||||
for raw in arr {
|
||||
let gh = Self.normalize(raw)
|
||||
guard !gh.isEmpty else { continue }
|
||||
if !seen.contains(gh) {
|
||||
seen.insert(gh)
|
||||
list.append(gh)
|
||||
}
|
||||
}
|
||||
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() {
|
||||
if let data = try? JSONEncoder().encode(bookmarks) {
|
||||
UserDefaults.standard.set(data, forKey: storeKey)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
return s
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
.replacingOccurrences(of: "#", with: "")
|
||||
.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
|
||||
}
|
||||
@@ -586,6 +586,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
if connected {
|
||||
Task { @MainActor in
|
||||
self.resubscribeCurrentGeohash()
|
||||
// Re-init sampling for regional + bookmarked geohashes after reconnect
|
||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
self.beginGeohashSampling(for: union)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -611,21 +616,41 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
self.switchLocationChannel(to: LocationChannelManager.shared.selectedChannel)
|
||||
}
|
||||
|
||||
// Background: keep sampling nearby geohashes for notifications even when sheet is closed
|
||||
// Background: keep sampling nearby geohashes + bookmarks for notifications even when sheet is closed
|
||||
LocationChannelManager.shared.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] channels in
|
||||
guard let self = self else { return }
|
||||
let ghs = channels.map { $0.geohash }
|
||||
let regional = channels.map { $0.geohash }
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
Task { @MainActor in
|
||||
self.beginGeohashSampling(for: ghs)
|
||||
self.beginGeohashSampling(for: union)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
// Kick off initial sampling if we already have channels
|
||||
if !LocationChannelManager.shared.availableChannels.isEmpty {
|
||||
let ghs = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
Task { @MainActor in self.beginGeohashSampling(for: ghs) }
|
||||
|
||||
// Also observe bookmark changes to update sampling
|
||||
GeohashBookmarksStore.shared.$bookmarks
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] bookmarks in
|
||||
guard let self = self else { return }
|
||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
Task { @MainActor in
|
||||
self.beginGeohashSampling(for: union)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Kick off initial sampling if we have regional channels or bookmarks
|
||||
do {
|
||||
let regional = LocationChannelManager.shared.availableChannels.map { $0.geohash }
|
||||
let bookmarks = GeohashBookmarksStore.shared.bookmarks
|
||||
let union = Array(Set(regional).union(bookmarks))
|
||||
if !union.isEmpty {
|
||||
Task { @MainActor in self.beginGeohashSampling(for: union) }
|
||||
}
|
||||
}
|
||||
// Refresh channels once when authorized to seed sampling
|
||||
LocationChannelManager.shared.$permissionState
|
||||
@@ -1838,7 +1863,6 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
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 }
|
||||
@@ -1861,6 +1885,30 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
}()
|
||||
Task { @MainActor in
|
||||
self.lastGeoNotificationAt[gh] = now
|
||||
// Pre-populate the target geohash timeline so the triggering message appears when user opens it
|
||||
var arr = self.geoTimelines[gh] ?? []
|
||||
let senderSuffix = String(event.pubkey.suffix(4))
|
||||
let nick = self.geoNicknames[event.pubkey.lowercased()]
|
||||
let senderName = (nick?.isEmpty == false ? nick! : "anon") + "#" + senderSuffix
|
||||
let ts = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let mentions = self.parseMentions(from: content)
|
||||
let msg = BitchatMessage(
|
||||
id: event.id,
|
||||
sender: senderName,
|
||||
content: content,
|
||||
timestamp: ts,
|
||||
isRelay: false,
|
||||
originalSender: nil,
|
||||
isPrivate: false,
|
||||
recipientNickname: nil,
|
||||
senderPeerID: "nostr:\(event.pubkey.prefix(TransportConfig.nostrShortKeyDisplayLength))",
|
||||
mentions: mentions.isEmpty ? nil : mentions
|
||||
)
|
||||
if !arr.contains(where: { $0.id == msg.id }) {
|
||||
arr.append(msg)
|
||||
if arr.count > self.geoTimelineCap { arr = Array(arr.suffix(self.geoTimelineCap)) }
|
||||
self.geoTimelines[gh] = arr
|
||||
}
|
||||
NotificationService.shared.sendGeohashActivityNotification(geohash: gh, bodyPreview: preview)
|
||||
}
|
||||
}
|
||||
@@ -4529,14 +4577,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
|
||||
@@ -4545,16 +4589,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
|
||||
@@ -5740,11 +5782,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
||||
|
||||
guard channelMatches else { return }
|
||||
|
||||
|
||||
// Removed background nudge notification for generic "new chats!"
|
||||
|
||||
// Append via batching buffer (skip empty content)
|
||||
// Append via batching buffer (skip empty content) with simple dedup by ID
|
||||
if !finalMessage.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
enqueuePublic(finalMessage)
|
||||
if !messages.contains(where: { $0.id == finalMessage.id }) {
|
||||
enqueuePublic(finalMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ struct ContentView: View {
|
||||
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@ObservedObject private var locationManager = LocationChannelManager.shared
|
||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||
@State private var messageText = ""
|
||||
@State private var textFieldSelection: NSRange? = nil
|
||||
@FocusState private var isTextFieldFocused: Bool
|
||||
@@ -1118,6 +1119,15 @@ struct ContentView: View {
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Open unread private chat")
|
||||
}
|
||||
// Bookmark toggle for current geohash (not shown for mesh)
|
||||
if case .location(let ch) = locationManager.selectedChannel {
|
||||
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
|
||||
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Toggle bookmark for #\(ch.geohash)")
|
||||
}
|
||||
// Location channels button '#'
|
||||
Button(action: { showLocationChannelsSheet = true }) {
|
||||
let badgeText: String = {
|
||||
|
||||
@@ -8,6 +8,7 @@ import AppKit
|
||||
struct LocationChannelsSheet: View {
|
||||
@Binding var isPresented: Bool
|
||||
@ObservedObject private var manager = LocationChannelManager.shared
|
||||
@ObservedObject private var bookmarks = GeohashBookmarksStore.shared
|
||||
@EnvironmentObject var viewModel: ChatViewModel
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@State private var customGeohash: String = ""
|
||||
@@ -98,7 +99,7 @@ struct LocationChannelsSheet: View {
|
||||
|
||||
private var channelList: some View {
|
||||
List {
|
||||
// Mesh option first
|
||||
// Mesh option first (no bookmark)
|
||||
channelRow(title: meshTitleWithCount(), subtitlePrefix: "#bluetooth • \(bluetoothRangeString())", isSelected: isMeshSelected, titleColor: standardBlue, titleBold: meshCount() > 0) {
|
||||
manager.select(ChannelID.mesh)
|
||||
isPresented = false
|
||||
@@ -112,7 +113,21 @@ struct LocationChannelsSheet: View {
|
||||
let namePart = nameBase.map { formattedNamePrefix(for: channel.level) + $0 }
|
||||
let subtitlePrefix = "#\(channel.geohash) • \(coverage)"
|
||||
let highlight = viewModel.geohashParticipantCount(for: channel.geohash) > 0
|
||||
channelRow(title: geohashTitleWithCount(for: channel), subtitlePrefix: subtitlePrefix, subtitleName: namePart, isSelected: isSelected(channel), titleBold: highlight) {
|
||||
channelRow(
|
||||
title: geohashTitleWithCount(for: channel),
|
||||
subtitlePrefix: subtitlePrefix,
|
||||
subtitleName: namePart,
|
||||
isSelected: isSelected(channel),
|
||||
titleBold: highlight,
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(channel.geohash) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
// Selecting a suggested nearby channel is not a teleport. Persist this.
|
||||
manager.markTeleported(for: channel.geohash, false)
|
||||
manager.select(ChannelID.location(channel))
|
||||
@@ -188,6 +203,48 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Bookmarked geohashes
|
||||
if !bookmarks.bookmarks.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text("bookmarked")
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.listRowSeparator(.hidden)
|
||||
ForEach(bookmarks.bookmarks, id: \.self) { gh in
|
||||
let level = levelForLength(gh.count)
|
||||
let channel = GeohashChannel(level: level, geohash: gh)
|
||||
let coverage = coverageString(forPrecision: gh.count)
|
||||
let subtitle = "#\(gh) • \(coverage)"
|
||||
let name = bookmarks.bookmarkNames[gh]
|
||||
channelRow(
|
||||
title: geohashHashTitleWithCount(gh),
|
||||
subtitlePrefix: subtitle,
|
||||
subtitleName: name.map { formattedNamePrefix(for: level) + $0 },
|
||||
isSelected: isSelected(channel),
|
||||
trailingAccessory: {
|
||||
Button(action: { bookmarks.toggle(gh) }) {
|
||||
Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark")
|
||||
.font(.system(size: 14))
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.leading, 8)
|
||||
}
|
||||
) {
|
||||
// For bookmarked selection, mark teleported based on regional membership
|
||||
let inRegional = manager.availableChannels.contains { $0.geohash == gh }
|
||||
if !inRegional && !manager.availableChannels.isEmpty {
|
||||
manager.markTeleported(for: gh, true)
|
||||
} else {
|
||||
manager.markTeleported(for: gh, false)
|
||||
}
|
||||
manager.select(ChannelID.location(channel))
|
||||
isPresented = false
|
||||
}
|
||||
.onAppear { bookmarks.resolveNameIfNeeded(for: gh) }
|
||||
}
|
||||
}
|
||||
|
||||
// Footer action inside the list
|
||||
if manager.permissionState == LocationChannelManager.PermissionState.authorized {
|
||||
Button(action: {
|
||||
@@ -220,14 +277,24 @@ struct LocationChannelsSheet: View {
|
||||
return false
|
||||
}
|
||||
|
||||
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 {
|
||||
Button(action: action) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
// Render title with smaller font for trailing count in parentheses
|
||||
let parts = splitTitleAndCount(title)
|
||||
HStack(spacing: 4) {
|
||||
Text(parts.base)
|
||||
@ViewBuilder
|
||||
private func channelRow(
|
||||
title: String,
|
||||
subtitlePrefix: String,
|
||||
subtitleName: String? = nil,
|
||||
subtitleNameBold: Bool = false,
|
||||
isSelected: Bool,
|
||||
titleColor: Color? = nil,
|
||||
titleBold: Bool = false,
|
||||
@ViewBuilder trailingAccessory: () -> some View = { EmptyView() },
|
||||
action: @escaping () -> Void
|
||||
) -> some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
VStack(alignment: .leading) {
|
||||
// Render title with smaller font for trailing count in parentheses
|
||||
let parts = splitTitleAndCount(title)
|
||||
HStack(spacing: 4) {
|
||||
Text(parts.base)
|
||||
.font(.system(size: 14, design: .monospaced))
|
||||
.fontWeight(titleBold ? .bold : .regular)
|
||||
.foregroundColor(titleColor ?? Color.primary)
|
||||
@@ -249,6 +316,8 @@ struct LocationChannelsSheet: View {
|
||||
.font(.system(size: 12, design: .monospaced))
|
||||
.fontWeight(subtitleNameBold ? .bold : .regular)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -258,11 +327,11 @@ struct LocationChannelsSheet: View {
|
||||
.font(.system(size: 16, design: .monospaced))
|
||||
.foregroundColor(standardGreen)
|
||||
}
|
||||
trailingAccessory()
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture(perform: action)
|
||||
}
|
||||
|
||||
// Split a title like "#mesh [3 people]" into base and suffix "[3 people]"
|
||||
@@ -291,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 }
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import XCTest
|
||||
@testable import bitchat
|
||||
|
||||
final class GeohashBookmarksStoreTests: XCTestCase {
|
||||
let storeKey = "locationChannel.bookmarks"
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
// Clear persisted state before each test
|
||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
||||
GeohashBookmarksStore.shared._resetForTesting()
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Clean after each test
|
||||
UserDefaults.standard.removeObject(forKey: storeKey)
|
||||
GeohashBookmarksStore.shared._resetForTesting()
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
func testToggleAndNormalize() {
|
||||
let store = GeohashBookmarksStore.shared
|
||||
// Start clean
|
||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||
|
||||
// Add with mixed case and hash prefix
|
||||
store.toggle("#U4PRUY")
|
||||
XCTAssertTrue(store.isBookmarked("u4pruy"))
|
||||
XCTAssertEqual(store.bookmarks.first, "u4pruy")
|
||||
|
||||
// Toggling again removes
|
||||
store.toggle("u4pruy")
|
||||
XCTAssertFalse(store.isBookmarked("u4pruy"))
|
||||
XCTAssertTrue(store.bookmarks.isEmpty)
|
||||
}
|
||||
|
||||
func testPersistenceWritten() throws {
|
||||
let store = GeohashBookmarksStore.shared
|
||||
store.toggle("ezs42")
|
||||
store.toggle("u4pruy")
|
||||
|
||||
// Verify persisted JSON contains both (order not enforced here)
|
||||
guard let data = UserDefaults.standard.data(forKey: storeKey) else {
|
||||
XCTFail("No persisted data found")
|
||||
return
|
||||
}
|
||||
let arr = try JSONDecoder().decode([String].self, from: data)
|
||||
XCTAssertTrue(arr.contains("ezs42"))
|
||||
XCTAssertTrue(arr.contains("u4pruy"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user