diff --git a/bitchat/Services/LocationNotesCounter.swift b/bitchat/Services/LocationNotesCounter.swift index 2e377c4a..b82d75fa 100644 --- a/bitchat/Services/LocationNotesCounter.swift +++ b/bitchat/Services/LocationNotesCounter.swift @@ -1,5 +1,34 @@ +import BitLogger import Foundation +struct LocationNotesCounterDependencies { + typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String] + typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void + typealias Unsubscribe = @MainActor (_ id: String) -> Void + + var relayLookup: RelayLookup + var subscribe: Subscribe + var unsubscribe: Unsubscribe + + static let live = LocationNotesCounterDependencies( + relayLookup: { geohash, count in + GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count) + }, + subscribe: { filter, id, relays, handler, onEOSE in + NostrRelayManager.shared.subscribe( + filter: filter, + id: id, + relayUrls: relays, + handler: handler, + onEOSE: onEOSE + ) + }, + unsubscribe: { id in + NostrRelayManager.shared.unsubscribe(id: id) + } + ) +} + /// Lightweight background counter for location notes (kind 1) at building-level geohash (8 chars). @MainActor final class LocationNotesCounter: ObservableObject { @@ -8,29 +37,45 @@ final class LocationNotesCounter: ObservableObject { @Published private(set) var geohash: String? = nil @Published private(set) var count: Int? = 0 @Published private(set) var initialLoadComplete: Bool = false + @Published private(set) var relayAvailable: Bool = true private var subscriptionID: String? = nil private var noteIDs = Set() + private let dependencies: LocationNotesCounterDependencies - private init() {} + private init(dependencies: LocationNotesCounterDependencies = .live) { + self.dependencies = dependencies + } + + init(testDependencies: LocationNotesCounterDependencies) { + self.dependencies = testDependencies + } func subscribe(geohash gh: String) { let norm = gh.lowercased() if geohash == norm, subscriptionID != nil { return } // Unsubscribe previous without clearing count to avoid flicker - if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) } + if let sub = subscriptionID { dependencies.unsubscribe(sub) } subscriptionID = nil geohash = norm noteIDs.removeAll() initialLoadComplete = false + relayAvailable = true // Subscribe only to the building geohash (precision 8) let subID = "locnotes-count-\(norm)-\(UUID().uuidString.prefix(6))" + let relays = dependencies.relayLookup(norm, TransportConfig.nostrGeoRelayCount) + guard !relays.isEmpty else { + relayAvailable = false + initialLoadComplete = true + count = 0 + SecureLogger.warning("LocationNotesCounter: no geo relays for geohash=\(norm)", category: .session) + return + } + subscriptionID = subID let filter = NostrFilter.geohashNotes(norm, since: nil, limit: 500) - let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: norm, count: TransportConfig.nostrGeoRelayCount) - let relayUrls: [String]? = relays.isEmpty ? nil : relays - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in + dependencies.subscribe(filter, subID, relays, { [weak self] event in guard let self = self else { return } guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } guard event.tags.contains(where: { $0.count >= 2 && $0[0].lowercased() == "g" && $0[1].lowercased() == norm }) else { return } @@ -38,16 +83,17 @@ final class LocationNotesCounter: ObservableObject { self.noteIDs.insert(event.id) self.count = self.noteIDs.count } - }, onEOSE: { [weak self] in + }, { [weak self] in self?.initialLoadComplete = true }) } func cancel() { - if let sub = subscriptionID { NostrRelayManager.shared.unsubscribe(id: sub) } + if let sub = subscriptionID { dependencies.unsubscribe(sub) } subscriptionID = nil geohash = nil count = 0 noteIDs.removeAll() + relayAvailable = true } } diff --git a/bitchat/Services/LocationNotesManager.swift b/bitchat/Services/LocationNotesManager.swift index e9f666fa..afcf052e 100644 --- a/bitchat/Services/LocationNotesManager.swift +++ b/bitchat/Services/LocationNotesManager.swift @@ -1,10 +1,57 @@ import BitLogger import Foundation -/// Persistent location notes (Nostr kind 1) scoped to a street-level geohash (precision 7). +/// Dependencies for location notes, allowing tests to stub relay/identity behavior. +struct LocationNotesDependencies { + typealias RelayLookup = @MainActor (_ geohash: String, _ count: Int) -> [String] + typealias Subscribe = @MainActor (_ filter: NostrFilter, _ id: String, _ relays: [String], _ handler: @escaping (NostrEvent) -> Void, _ onEOSE: (() -> Void)?) -> Void + typealias Unsubscribe = @MainActor (_ id: String) -> Void + typealias SendEvent = @MainActor (_ event: NostrEvent, _ relayUrls: [String]) -> Void + + var relayLookup: RelayLookup + var subscribe: Subscribe + var unsubscribe: Unsubscribe + var sendEvent: SendEvent + var deriveIdentity: (_ geohash: String) throws -> NostrIdentity + var now: () -> Date + + static let live = LocationNotesDependencies( + relayLookup: { geohash, count in + GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: count) + }, + subscribe: { filter, id, relays, handler, onEOSE in + NostrRelayManager.shared.subscribe( + filter: filter, + id: id, + relayUrls: relays, + handler: handler, + onEOSE: onEOSE + ) + }, + unsubscribe: { id in + NostrRelayManager.shared.unsubscribe(id: id) + }, + sendEvent: { event, relays in + NostrRelayManager.shared.sendEvent(event, to: relays) + }, + deriveIdentity: { geohash in + try NostrIdentityBridge.deriveIdentity(forGeohash: geohash) + }, + now: { Date() } + ) +} + +/// Persistent location notes (Nostr kind 1) scoped to a building-level geohash (precision 8). /// Subscribes to and publishes notes for a given geohash and provides a send API. @MainActor final class LocationNotesManager: ObservableObject { + enum State: Equatable { + case idle + case loading + case ready + case noRelays + } + struct Note: Identifiable, Equatable { let id: String let pubkey: String @@ -24,10 +71,14 @@ final class LocationNotesManager: ObservableObject { @Published private(set) var notes: [Note] = [] // reverse-chron sorted @Published private(set) var geohash: String @Published private(set) var initialLoadComplete: Bool = false + @Published private(set) var state: State = .loading + @Published private(set) var errorMessage: String? private var subscriptionID: String? + private let dependencies: LocationNotesDependencies - init(geohash: String) { + init(geohash: String, dependencies: LocationNotesDependencies = .live) { self.geohash = geohash.lowercased() + self.dependencies = dependencies subscribe() } @@ -35,7 +86,7 @@ final class LocationNotesManager: ObservableObject { let norm = newGeohash.lowercased() guard norm != geohash else { return } if let sub = subscriptionID { - NostrRelayManager.shared.unsubscribe(id: sub) + dependencies.unsubscribe(sub) subscriptionID = nil } geohash = norm @@ -43,15 +94,43 @@ final class LocationNotesManager: ObservableObject { subscribe() } + func refresh() { + if let sub = subscriptionID { + dependencies.unsubscribe(sub) + subscriptionID = nil + } + notes.removeAll() + subscribe() + } + + func clearError() { + errorMessage = nil + } + private func subscribe() { + state = .loading + errorMessage = nil + if let sub = subscriptionID { + dependencies.unsubscribe(sub) + subscriptionID = nil + } let subID = "locnotes-\(geohash)-\(UUID().uuidString.prefix(8))" + let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) + guard !relays.isEmpty else { + subscriptionID = nil + initialLoadComplete = true + state = .noRelays + errorMessage = "No geo relays available near this location. Try again soon." + SecureLogger.warning("LocationNotesManager: no geo relays for geohash=\(geohash)", category: .session) + return + } + subscriptionID = subID + initialLoadComplete = false // For persistent notes, allow relays to return recent history without an aggressive time cutoff let filter = NostrFilter.geohashNotes(geohash, since: nil, limit: 200) - let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) - let relayUrls: [String]? = relays.isEmpty ? nil : relays - initialLoadComplete = false - NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: relayUrls, handler: { [weak self] event in + + dependencies.subscribe(filter, subID, relays, { [weak self] event in guard let self = self else { return } guard event.kind == NostrProtocol.EventKind.textNote.rawValue else { return } // Ensure matching tag @@ -62,8 +141,13 @@ final class LocationNotesManager: ObservableObject { let note = Note(id: event.id, pubkey: event.pubkey, content: event.content, createdAt: ts, nickname: nick) self.notes.append(note) self.notes.sort { $0.createdAt > $1.createdAt } - }, onEOSE: { [weak self] in - self?.initialLoadComplete = true + self.state = .ready + }, { [weak self] in + guard let self = self else { return } + self.initialLoadComplete = true + if self.state != .noRelays { + self.state = .ready + } }) } @@ -71,29 +155,46 @@ final class LocationNotesManager: ObservableObject { func send(content: String, nickname: String) { let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } + let relays = dependencies.relayLookup(geohash, TransportConfig.nostrGeoRelayCount) + guard !relays.isEmpty else { + state = .noRelays + errorMessage = "No geo relays available near this location. Try again soon." + SecureLogger.warning("LocationNotesManager: send blocked, no geo relays for geohash=\(geohash)", category: .session) + return + } do { - let id = try NostrIdentityBridge.deriveIdentity(forGeohash: geohash) + let id = try dependencies.deriveIdentity(geohash) let event = try NostrProtocol.createGeohashTextNote( content: trimmed, geohash: geohash, senderIdentity: id, nickname: nickname ) - let relays = GeoRelayDirectory.shared.closestRelays(toGeohash: geohash, count: TransportConfig.nostrGeoRelayCount) - NostrRelayManager.shared.sendEvent(event, to: relays) + dependencies.sendEvent(event, relays) // Optimistic local-echo - let echo = Note(id: event.id, pubkey: id.publicKeyHex, content: trimmed, createdAt: Date(), nickname: nickname) + let echo = Note( + id: event.id, + pubkey: id.publicKeyHex, + content: trimmed, + createdAt: dependencies.now(), + nickname: nickname + ) self.notes.insert(echo, at: 0) + self.state = .ready + self.errorMessage = nil } catch { SecureLogger.error("LocationNotesManager: failed to send note: \(error)", category: .session) + errorMessage = "Failed to send note. \(error.localizedDescription)" } } /// Explicitly cancel subscription and release resources. func cancel() { if let sub = subscriptionID { - NostrRelayManager.shared.unsubscribe(id: sub) + dependencies.unsubscribe(sub) subscriptionID = nil } + state = .idle + errorMessage = nil } } diff --git a/bitchat/Views/AppInfoView.swift b/bitchat/Views/AppInfoView.swift index c8a4e779..006656f6 100644 --- a/bitchat/Views/AppInfoView.swift +++ b/bitchat/Views/AppInfoView.swift @@ -116,6 +116,19 @@ struct AppInfoView: View { .frame(maxWidth: .infinity) .padding(.vertical) + // How to Use + VStack(alignment: .leading, spacing: 16) { + SectionHeader(Strings.HowToUse.title) + + VStack(alignment: .leading, spacing: 8) { + ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in + Text(instruction) + } + } + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(textColor) + } + // Features VStack(alignment: .leading, spacing: 16) { SectionHeader(Strings.Features.title) @@ -162,19 +175,6 @@ struct AppInfoView: View { description: Strings.Privacy.panic.2) } - // How to Use - VStack(alignment: .leading, spacing: 16) { - SectionHeader(Strings.HowToUse.title) - - VStack(alignment: .leading, spacing: 8) { - ForEach(Strings.HowToUse.instructions, id: \.self) { instruction in - Text(instruction) - } - } - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(textColor) - } - // Warning VStack(alignment: .leading, spacing: 6) { SectionHeader(Strings.Warning.title) diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index 52d6b702..1d21279a 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -20,8 +20,12 @@ struct LocationChannelsSheet: View { var body: some View { NavigationView { VStack(alignment: .leading, spacing: 12) { - Text("#location channels") - .font(.system(size: 18, design: .monospaced)) + HStack(spacing: 12) { + Text("#location channels") + .font(.system(size: 18, design: .monospaced)) + Spacer() + closeButton + } Text("chat with people near you using geohash channels. only a coarse geohash is shared, never exact gps. your IP address is hidden by routing all traffic over tor.") .font(.system(size: 12, design: .monospaced)) .foregroundColor(.secondary) @@ -60,29 +64,9 @@ struct LocationChannelsSheet: View { .background(backgroundColor) #if os(iOS) .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { isPresented = false }) { - Image(systemName: "xmark") - .font(.system(size: 13, weight: .semibold, design: .monospaced)) - .frame(width: 32, height: 32) - } - .buttonStyle(.plain) - .accessibilityLabel("Close") - } - } + .navigationBarHidden(true) #else - .toolbar { - ToolbarItem(placement: .automatic) { - Button(action: { isPresented = false }) { - Image(systemName: "xmark") - .font(.system(size: 13, weight: .semibold, design: .monospaced)) - .frame(width: 20, height: 20) - } - .buttonStyle(.plain) - .accessibilityLabel("Close") - } - } + .navigationTitle("") #endif } #if os(iOS) @@ -112,6 +96,16 @@ struct LocationChannelsSheet: View { .onChange(of: manager.availableChannels) { _ in } } + private var closeButton: some View { + Button(action: { isPresented = false }) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .frame(width: 32, height: 32) + } + .buttonStyle(.plain) + .accessibilityLabel("Close") + } + private var channelList: some View { List { // Mesh option first (no bookmark) @@ -119,6 +113,7 @@ struct LocationChannelsSheet: View { manager.select(ChannelID.mesh) isPresented = false } + .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) // Nearby options if !manager.availableChannels.isEmpty { @@ -148,6 +143,7 @@ struct LocationChannelsSheet: View { manager.select(ChannelID.location(channel)) isPresented = false } + .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) } } else { HStack { @@ -155,6 +151,7 @@ struct LocationChannelsSheet: View { Text("finding nearby channels…") .font(.system(size: 12, design: .monospaced)) } + .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) } // Custom geohash teleport @@ -204,7 +201,6 @@ struct LocationChannelsSheet: View { } .buttonStyle(.plain) .font(.system(size: 14, design: .monospaced)) - .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color.secondary.opacity(0.12)) .cornerRadius(6) @@ -217,6 +213,7 @@ struct LocationChannelsSheet: View { .foregroundColor(.red) } } + .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) // Bookmarked geohashes if !bookmarks.bookmarks.isEmpty { @@ -263,11 +260,13 @@ struct LocationChannelsSheet: View { .cornerRadius(8) } .listRowSeparator(.hidden) + .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 6, trailing: 0)) } // Footer action inside the list if manager.permissionState == LocationChannelManager.PermissionState.authorized { torToggleSection + .listRowInsets(EdgeInsets(top: 6, leading: 0, bottom: 4, trailing: 0)) Button(action: { openSystemLocationSettings() }) { @@ -282,6 +281,7 @@ struct LocationChannelsSheet: View { .buttonStyle(.plain) .listRowSeparator(.hidden) .listRowBackground(Color.clear) + .listRowInsets(EdgeInsets(top: 4, leading: 0, bottom: 10, trailing: 0)) } } .listStyle(.plain) diff --git a/bitchat/Views/LocationNotesView.swift b/bitchat/Views/LocationNotesView.swift index cb4f9cc7..5313615c 100644 --- a/bitchat/Views/LocationNotesView.swift +++ b/bitchat/Views/LocationNotesView.swift @@ -18,30 +18,23 @@ struct LocationNotesView: View { _manager = StateObject(wrappedValue: LocationNotesManager(geohash: gh)) } - private var backgroundColor: Color { - colorScheme == .dark ? Color.black : Color.white - } - private var textColor: Color { - colorScheme == .dark ? Color.green : Color(red: 0, green: 0.5, blue: 0) - } - private var secondaryTextColor: Color { - colorScheme == .dark ? Color.green.opacity(0.8) : Color(red: 0, green: 0.5, blue: 0).opacity(0.8) - } - // Slightly darker green for hash suffix emphasis - private var darkerTextColor: Color { - colorScheme == .dark ? Color.green : Color(red: 0, green: 0.4, blue: 0) - } + private var backgroundColor: Color { colorScheme == .dark ? .black : .white } + private var accentGreen: Color { colorScheme == .dark ? .green : Color(red: 0, green: 0.5, blue: 0) } var body: some View { +#if os(macOS) VStack(spacing: 0) { - header - Divider() - list - Divider() - input + ScrollView { + VStack(spacing: 0) { + headerSection + notesContent + } + } + .background(backgroundColor) + inputSection } + .frame(minWidth: 420, idealWidth: 440, minHeight: 620, idealHeight: 680) .background(backgroundColor) - .foregroundColor(textColor) .onDisappear { manager.cancel() } .onChange(of: geohash) { newValue in manager.setGeohash(newValue) @@ -50,100 +43,202 @@ struct LocationNotesView: View { .onChange(of: manager.notes.count) { newValue in onNotesCountChanged?(newValue) } +#else + NavigationView { + VStack(spacing: 0) { + headerSection + ScrollView { + notesContent + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + inputSection + } + .background(backgroundColor) + #if os(iOS) + .navigationBarTitleDisplayMode(.inline) + .navigationBarHidden(true) + #else + .navigationTitle("") + #endif + } + #if os(iOS) + .presentationDetents([.large]) + #endif + .background(backgroundColor) + .onDisappear { manager.cancel() } + .onChange(of: geohash) { newValue in + manager.setGeohash(newValue) + } + .onAppear { onNotesCountChanged?(manager.notes.count) } + .onChange(of: manager.notes.count) { newValue in + onNotesCountChanged?(newValue) + } +#endif } - private var header: some View { - HStack { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { - let c = manager.notes.count - Text("\(c) \(c == 1 ? "note" : "notes") ") - .font(.system(size: 16, weight: .bold, design: .monospaced)) - Text("@ ") - .font(.system(size: 16, weight: .bold, design: .monospaced)) - Text("#\(geohash)") - .font(.system(size: 16, weight: .bold, design: .monospaced)) - .foregroundColor(textColor) - } - if let buildingName = locationManager.locationNames[.building], !buildingName.isEmpty { - Text(buildingName) - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(secondaryTextColor) - } else if let blockName = locationManager.locationNames[.block], !blockName.isEmpty { - Text(blockName) - .font(.system(size: 12, design: .monospaced)) - .foregroundColor(secondaryTextColor) - } - } - Spacer() - Button(action: { dismiss() }) { - Image(systemName: "xmark") - .font(.system(size: 13, weight: .semibold, design: .monospaced)) - .foregroundColor(textColor) - .frame(width: 32, height: 32) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Close") + private var closeButton: some View { + Button(action: { dismiss() }) { + Image(systemName: "xmark") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + .frame(width: 32, height: 32) } - .frame(height: 44) - .padding(.horizontal, 12) - .background(backgroundColor.opacity(0.95)) + .buttonStyle(.plain) + .accessibilityLabel("Close") } - private var list: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 8) { - ForEach(manager.notes) { note in - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - // Show @name without the #abcd suffix; timestamp in brackets - HStack(spacing: 0) { - Text("@") - .font(.system(size: 12, weight: .semibold, design: .monospaced)) - .foregroundColor(textColor) - let parts = splitSuffix(from: note.displayName) - Text(parts.0) - .font(.system(size: 12, weight: .semibold, design: .monospaced)) - .foregroundColor(textColor) - } - let ts = timestampText(for: note.createdAt) - Text(ts.isEmpty ? "" : "[\(ts)]") - .font(.system(size: 11, design: .monospaced)) - .foregroundColor(secondaryTextColor.opacity(0.8)) - } - Text(note.content) - .font(.system(size: 14, design: .monospaced)) - .fixedSize(horizontal: false, vertical: true) - } - .padding(.horizontal, 12) - } + private var headerSection: some View { + let count = manager.notes.count + return VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 12) { + Text("#\(geohash) • \(count) \(count == 1 ? "note" : "notes")") + .font(.system(size: 18, design: .monospaced)) + Spacer() + closeButton + } + if let building = locationManager.locationNames[.building], !building.isEmpty { + Text(building) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(accentGreen) + } else if let block = locationManager.locationNames[.block], !block.isEmpty { + Text(block) + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(accentGreen) + } + Text("add short permanent notes to this location for other visitors to find.") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + if manager.state == .loading && !manager.initialLoadComplete { + Text("loading recent notes…") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + } else if manager.state == .noRelays { + Text("geo relays unavailable; notes paused") + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) } - .padding(.vertical, 8) } + .padding(.horizontal, 16) + .padding(.top, 16) + .padding(.bottom, 12) .background(backgroundColor) } - private var input: some View { - HStack(alignment: .center, spacing: 8) { + private var notesContent: some View { + LazyVStack(alignment: .leading, spacing: 12) { + if manager.state == .noRelays { + noRelaysRow + } else if manager.state == .loading && !manager.initialLoadComplete { + loadingRow + } else if manager.notes.isEmpty { + emptyRow + } else { + ForEach(manager.notes) { note in + noteRow(note) + } + } + + if let error = manager.errorMessage, manager.state != .noRelays { + errorRow(message: error) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + + private func noteRow(_ note: LocationNotesManager.Note) -> some View { + let baseName = note.displayName.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? note.displayName + let ts = timestampText(for: note.createdAt) + return VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text("@\(baseName)") + .font(.system(size: 12, weight: .semibold, design: .monospaced)) + if !ts.isEmpty { + Text(ts) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(.secondary) + } + Spacer() + } + Text(note.content) + .font(.system(size: 14, design: .monospaced)) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.vertical, 4) + } + + private var noRelaysRow: some View { + VStack(alignment: .leading, spacing: 4) { + Text("no geo relays nearby") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + Text("notes rely on geo relays. check connection and try again.") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + Button("retry") { manager.refresh() } + .font(.system(size: 12, design: .monospaced)) + .buttonStyle(.plain) + } + .padding(.vertical, 6) + } + + private var loadingRow: some View { + HStack(spacing: 10) { + ProgressView() + Text("loading notes…") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + Spacer() + } + .padding(.vertical, 8) + } + + private var emptyRow: some View { + VStack(alignment: .leading, spacing: 4) { + Text("no notes yet") + .font(.system(size: 13, weight: .semibold, design: .monospaced)) + Text("be the first to add one for this spot.") + .font(.system(size: 12, design: .monospaced)) + .foregroundColor(.secondary) + } + .padding(.vertical, 6) + } + + private func errorRow(message: String) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 12, design: .monospaced)) + Text(message) + .font(.system(size: 12, design: .monospaced)) + Spacer() + } + Button("dismiss") { manager.clearError() } + .font(.system(size: 12, design: .monospaced)) + .buttonStyle(.plain) + } + .padding(.vertical, 6) + } + + private var inputSection: some View { + HStack(alignment: .top, spacing: 10) { TextField("add a note for this place", text: $draft, axis: .vertical) .textFieldStyle(.plain) .font(.system(size: 14, design: .monospaced)) .lineLimit(3, reservesSpace: true) - .padding(.horizontal, 12) - + .padding(.vertical, 6) Button(action: send) { Image(systemName: "arrow.up.circle.fill") .font(.system(size: 20)) - .foregroundColor(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? Color.gray : textColor) + .foregroundColor(sendButtonEnabled ? accentGreen : .secondary) } + .padding(.top, 2) .buttonStyle(.plain) - .disabled(draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .padding(.trailing, 12) + .disabled(!sendButtonEnabled) } - .frame(minHeight: 44) - .padding(.vertical, 8) - .background(backgroundColor.opacity(0.95)) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .background(backgroundColor) + .overlay(Divider(), alignment: .top) } private func send() { @@ -153,15 +248,17 @@ struct LocationNotesView: View { draft = "" } + private var sendButtonEnabled: Bool { + !draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && manager.state != .noRelays + } + // MARK: - Timestamp Formatting private func timestampText(for date: Date) -> String { let now = Date() if let days = Calendar.current.dateComponents([.day], from: date, to: now).day, days < 7 { - // Relative (minute/hour/day), no seconds let rel = Self.relativeFormatter.string(from: date, to: now) ?? "" return rel.isEmpty ? "" : "\(rel) ago" } else { - // Absolute date (MMM d or MMM d, yyyy if different year) let sameYear = Calendar.current.isDate(date, equalTo: now, toGranularity: .year) let fmt = sameYear ? Self.absDateFormatter : Self.absDateYearFormatter return fmt.string(from: date) @@ -189,16 +286,3 @@ struct LocationNotesView: View { return f }() } - -// Helper to split a trailing #abcd suffix -private func splitSuffix(from name: String) -> (String, String) { - guard name.count >= 5 else { return (name, "") } - let suffix = String(name.suffix(5)) - if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in - ("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c)) - }) { - let base = String(name.dropLast(5)) - return (base, suffix) - } - return (name, "") -} diff --git a/bitchatTests/LocationNotesManagerTests.swift b/bitchatTests/LocationNotesManagerTests.swift new file mode 100644 index 00000000..1672d6f7 --- /dev/null +++ b/bitchatTests/LocationNotesManagerTests.swift @@ -0,0 +1,146 @@ +import XCTest +@testable import bitchat + +@MainActor +final class LocationNotesManagerTests: XCTestCase { + func testSubscribeWithoutRelaysSetsNoRelaysState() { + var subscribeCalled = false + let deps = LocationNotesDependencies( + relayLookup: { _, _ in [] }, + subscribe: { _, _, _, _, _ in + subscribeCalled = true + }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in fatalError("should not derive identity") }, + now: { Date() } + ) + + let manager = LocationNotesManager(geohash: "abcd1234", dependencies: deps) + + XCTAssertFalse(subscribeCalled) + XCTAssertEqual(manager.state, .noRelays) + XCTAssertTrue(manager.initialLoadComplete) + XCTAssertEqual(manager.errorMessage, "No geo relays available near this location. Try again soon.") + } + + func testSendWhenNoRelaysSurfacesError() { + var sendCalled = false + let deps = LocationNotesDependencies( + relayLookup: { _, _ in [] }, + subscribe: { _, _, _, _, _ in }, + unsubscribe: { _ in }, + sendEvent: { _, _ in sendCalled = true }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { Date() } + ) + + let manager = LocationNotesManager(geohash: "zzzzzzzz", dependencies: deps) + manager.send(content: "hello", nickname: "tester") + + XCTAssertFalse(sendCalled) + XCTAssertEqual(manager.state, .noRelays) + XCTAssertEqual(manager.errorMessage, "No geo relays available near this location. Try again soon.") + } + + func testSubscribeUsesGeoRelaysAndAppendsNotes() { + var relaysCaptured: [String] = [] + var storedHandler: ((NostrEvent) -> Void)? + var storedEOSE: (() -> Void)? + let deps = LocationNotesDependencies( + relayLookup: { _, _ in ["wss://relay.one"] }, + subscribe: { filter, id, relays, handler, eose in + XCTAssertEqual(filter.kinds, [1]) + XCTAssertFalse(id.isEmpty) + relaysCaptured = relays + storedHandler = handler + storedEOSE = eose + }, + unsubscribe: { _ in }, + sendEvent: { _, _ in }, + deriveIdentity: { _ in throw TestError.shouldNotDerive }, + now: { Date() } + ) + + let manager = LocationNotesManager(geohash: "abcd1234", dependencies: deps) + XCTAssertEqual(relaysCaptured, ["wss://relay.one"]) + XCTAssertEqual(manager.state, .loading) + + var event = NostrEvent( + pubkey: "pub", + createdAt: Date(), + kind: .textNote, + tags: [["g", "abcd1234"]], + content: "hi" + ) + event.id = "event1" + storedHandler?(event) + storedEOSE?() + + XCTAssertEqual(manager.state, .ready) + XCTAssertEqual(manager.notes.count, 1) + XCTAssertEqual(manager.notes.first?.content, "hi") + } + + private enum TestError: Error { + case shouldNotDerive + } +} + +@MainActor +final class LocationNotesCounterTests: XCTestCase { + func testSubscribeWithoutRelaysMarksUnavailable() { + var subscribeCalled = false + let deps = LocationNotesCounterDependencies( + relayLookup: { _, _ in [] }, + subscribe: { _, _, _, _, _ in subscribeCalled = true }, + unsubscribe: { _ in } + ) + + let counter = LocationNotesCounter(testDependencies: deps) + counter.subscribe(geohash: "abcdefgh") + + XCTAssertFalse(subscribeCalled) + XCTAssertFalse(counter.relayAvailable) + XCTAssertTrue(counter.initialLoadComplete) + XCTAssertEqual(counter.count, 0) + } + + func testSubscribeCountsUniqueNotes() { + var storedHandler: ((NostrEvent) -> Void)? + var storedEOSE: (() -> Void)? + let deps = LocationNotesCounterDependencies( + relayLookup: { _, _ in ["wss://relay.geo"] }, + subscribe: { filter, id, relays, handler, eose in + XCTAssertEqual(relays, ["wss://relay.geo"]) + XCTAssertEqual(filter.kinds, [1]) + XCTAssertFalse(id.isEmpty) + storedHandler = handler + storedEOSE = eose + }, + unsubscribe: { _ in } + ) + + let counter = LocationNotesCounter(testDependencies: deps) + counter.subscribe(geohash: "abcdefgh") + + var first = NostrEvent( + pubkey: "pub", + createdAt: Date(), + kind: .textNote, + tags: [["g", "abcdefgh"]], + content: "a" + ) + first.id = "eventA" + storedHandler?(first) + + let duplicate = first + storedHandler?(duplicate) + + storedEOSE?() + + XCTAssertTrue(counter.relayAvailable) + XCTAssertEqual(counter.count, 1) + XCTAssertTrue(counter.initialLoadComplete) + } +}