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/Services/GeohashBookmarksStore.swift b/bitchat/Services/GeohashBookmarksStore.swift new file mode 100644 index 00000000..2c2c735f --- /dev/null +++ b/bitchat/Services/GeohashBookmarksStore.swift @@ -0,0 +1,94 @@ +import Foundation +import Combine + +/// 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] = [] + + private let storeKey = "locationChannel.bookmarks" + private var membership: Set = [] + + 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() + } + + 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) + 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 + } + } + + private func persist() { + if let data = try? JSONEncoder().encode(bookmarks) { + UserDefaults.standard.set(data, forKey: storeKey) + } + } + + // 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) } + } + + #if DEBUG + /// Testing-only reset helper + func _resetForTesting() { + bookmarks.removeAll() + membership.removeAll() + persist() + } + #endif +} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index bc670afb..8bcfc470 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 diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 83b51bff..95a82169 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 @@ -1112,6 +1113,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..c30e2244 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, + leadingAccessory: { + Button(action: { bookmarks.toggle(channel.geohash) }) { + Image(systemName: bookmarks.isBookmarked(channel.geohash) ? "bookmark.fill" : "bookmark") + .font(.system(size: 14)) + } + .buttonStyle(.plain) + .padding(.trailing, 6) + } + ) { // 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,45 @@ 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)" + channelRow( + title: geohashTitleWithCount(for: channel), + subtitlePrefix: subtitle, + isSelected: isSelected(channel), + leadingAccessory: { + Button(action: { bookmarks.toggle(gh) }) { + Image(systemName: bookmarks.isBookmarked(gh) ? "bookmark.fill" : "bookmark") + .font(.system(size: 14)) + } + .buttonStyle(.plain) + .padding(.trailing, 6) + } + ) { + // 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 + } + } + } + // Footer action inside the list if manager.permissionState == LocationChannelManager.PermissionState.authorized { Button(action: { @@ -220,14 +274,25 @@ 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 leadingAccessory: () -> 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) + HStack(spacing: 4) { + Text(parts.base) .font(.system(size: 14, design: .monospaced)) .fontWeight(titleBold ? .bold : .regular) .foregroundColor(titleColor ?? Color.primary) @@ -260,9 +325,8 @@ struct LocationChannelsSheet: View { } } .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]" 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")) + } +}