Refine location notes UI and align sheet layouts (#660)

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-09-21 06:56:20 -06:00
committed by GitHub
parent c837afb818
commit f8f780d2d6
6 changed files with 544 additions and 167 deletions

View File

@@ -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<String>()
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
}
}

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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)

View File

@@ -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, "")
}

View File

@@ -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)
}
}