diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index d14e89fd..6e38a875 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -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 = ""; }; 047502B82E560F690083520F /* RelayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayController.swift; sourceTree = ""; }; 048A4BE62E5CCCC300162C4A /* TransportConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransportConfig.swift; sourceTree = ""; }; + 048A4C272E5FCD6600162C4A /* GeohashBookmarksStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStore.swift; sourceTree = ""; }; + 048A4C2A2E5FCE0300162C4A /* GeohashBookmarksStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeohashBookmarksStoreTests.swift; sourceTree = ""; }; 049BD38C2E4EC4F0001A566B /* AutocompleteService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AutocompleteService.swift; sourceTree = ""; }; 049BD38D2E4EC4F0001A566B /* CommandProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommandProcessor.swift; sourceTree = ""; }; 049BD38F2E4EC4F0001A566B /* PrivateChatManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrivateChatManager.swift; sourceTree = ""; }; @@ -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 */, 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 new file mode 100644 index 00000000..39e2d7a1 --- /dev/null +++ b/bitchat/Services/GeohashBookmarksStore.swift @@ -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 = [] + #if os(iOS) || os(macOS) + private let geocoder = CLGeocoder() + private var resolving: Set = [] + #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() + 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() + 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 054df669..f074ab64 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -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) + } } } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 71faf085..cc757ad7 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -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 = { diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index cf9f79d6..44a6438e 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -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 } diff --git a/bitchatTests/GeohashBookmarksStoreTests.swift b/bitchatTests/GeohashBookmarksStoreTests.swift new file mode 100644 index 00000000..6b3fdac1 --- /dev/null +++ b/bitchatTests/GeohashBookmarksStoreTests.swift @@ -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")) + } +}