mirror of
https://github.com/jackjackbits/bitchat.git
synced 2026-01-10 13:08:13 -05:00
Refine location notes UI and align sheet layouts (#660)
Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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, "")
|
||||
}
|
||||
|
||||
146
bitchatTests/LocationNotesManagerTests.swift
Normal file
146
bitchatTests/LocationNotesManagerTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user