Location Notes (kind 1) at Building Precision + Live Updates (#598)

* Location notes: Matrix loader with 1–3s minimum display; allow multiple notes; wire notes UI from mesh toolbar

* Location notes: prevent duplicate loading/subscriptions (StateObject manager, geohash captured at open, guard subscribe)

* Location notes: fix duplicate  state redeclaration in ContentView

* Location notes: render note bodies with monospaced font in notes list

* Location notes: add close (x) button in sheet header (upper-right) using @Environment(\.dismiss)

* Location notes: acquire geohash on open (force refresh) and show Matrix loader until block-level geohash resolves; add LocationNotesSheet wrapper

* Location notes: inline sheet wrapper to avoid missing file in target; force location refresh and show Matrix loader until block geohash resolves

* Location notes: remove Matrix animation; use simple spinner when acquiring location; simplify notes view layout

* Location notes: remove per-note relative timestamp from sheet (no seconds display)

* Location notes: fix intermittent first load by ensuring resubscribe after geohash change, removing over-eager subscribe guard, and widening note fetch window (no since filter)

* Location notes: add background counter service and show count next to notes icon on mesh; auto-subscribe to current block geohash

* Location notes: move notes icon to the right of #mesh badge in toolbar

* Location notes: restore timestamps in notes list; remove loading state from manager and sheet (no spinner/animation)

* Location notes: drop 'teleport' tag from kind-1 events; simplify note builder API and usage

* Location notes: show timestamp as relative within 7 days, else absolute date (MMM d or MMM d, y); keep monospaced styling

* Location notes: append 'ago' to relative timestamps (within 7 days)

* Switch location notes and UI helpers to building-level geohash (precision 8): add GeohashChannelLevel.building, map length 8, use building for notes selection and counter, add building name mapping

* Location notes: change notes toolbar icon to SF Symbol 'long.text.page.and.pencil'

* Revert notes geohash selection back to block-level (precision 7); keep building level available but unused; update counter and sheet accordingly

* Location notes: show block name (from reverse geocode) in header instead of 'street-level notes'

* Nostr: add EOSE handling with callback support for subscriptions; wire to LocationNotesManager/Counter (initialLoadComplete). Remove 'building' level from channel enum and geocoder mapping; geohash list shows block/neighborhood/city/province/region only

* Fix Swift 6 isolation: hop to @MainActor inside Timer callback for EOSE tracker mutations

* Notes counter: show 0 by default; subscribe at building-level (precision 8) for notes and counter; keep building hidden in channel list

* Notes header: show building name when available (fallback to block name)

* Notes: subscribe counter to building + parent block (merge results); hide notes icon unless location is authorized; replace 10s timer with live location updates while sheet open

* Notes counter: subscribe only to building geohash (precision 8) so count reflects current 8-cell; auto-begin live location refresh on mesh (and permission) to update as you walk

* Notes header: show count in parentheses; icon shows blue if any notes, grey if none; only start live location updates while sheet is open; reduce nickname width; set live distance filter to 10m

* Notes header: show count as '(N note/notes)' with non-bold, secondary styling next to title

* Notes header: move count before title as '<N> note(s) @ #gh'; remove parentheses; keep count non-bold

* Notes header: bold count text; ensure sheet updates when building geohash changes by calling manager.setGeohash on geohash change

* Notes sheet: add light haptic feedback when building geohash changes (iOS only)

* Notes icon: force a one-shot location refresh before (re)subscribing the counter so icon turns blue immediately on current 8-cell

* Notes subscribe: fallback to default relays when GeoRelayDirectory has no entries (pass nil relayUrls) so counter/icon turn blue and sheet loads even before georelay fetch

* Notes icon: turn blue immediately when sheet shows notes by passing count up from sheet (closure) and OR-ing with counter; reset on sheet close

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
This commit is contained in:
jack
2025-09-13 14:55:04 +02:00
committed by GitHub
co-authored by jack
parent 920dc31795
commit 2870acdcc7
13 changed files with 649 additions and 33 deletions
+47 -4
View File
@@ -45,6 +45,14 @@ final class NostrRelayManager: ObservableObject {
// Coalesce duplicate subscribe requests for the same id within a short window
private var subscribeCoalesce: [String: Date] = [:]
private var cancellables = Set<AnyCancellable>()
// Track EOSE per subscription to signal when initial stored events are done
private struct EOSETracker {
var pendingRelays: Set<String>
var callback: () -> Void
var timer: Timer?
}
private var eoseTrackers: [String: EOSETracker] = [:]
// Message queue for reliability
// Pending sends held only for relays that are not yet connected.
@@ -201,7 +209,8 @@ final class NostrRelayManager: ObservableObject {
filter: NostrFilter,
id: String = UUID().uuidString,
relayUrls: [String]? = nil,
handler: @escaping (NostrEvent) -> Void
handler: @escaping (NostrEvent) -> Void,
onEOSE: (() -> Void)? = nil
) {
// Coalesce rapid duplicate subscribe requests only if a handler already exists
let now = Date()
@@ -250,6 +259,22 @@ final class NostrRelayManager: ObservableObject {
map[id] = messageString
self.pendingSubscriptions[url] = map
}
// Initialize EOSE tracking if requested
if let onEOSE = onEOSE {
var tracker = EOSETracker(pendingRelays: Set(urls), callback: onEOSE, timer: nil)
// Fallback timeout to avoid hanging if a relay never sends EOSE
tracker.timer = Timer.scheduledTimer(withTimeInterval: 2.0, repeats: false) { [weak self] _ in
Task { @MainActor in
guard let self = self else { return }
if let t = self.eoseTrackers[id] {
t.timer?.invalidate()
self.eoseTrackers.removeValue(forKey: id)
onEOSE()
}
}
}
eoseTrackers[id] = tracker
}
SecureLogger.debug("📋 Queued subscription id=\(id) for \(urls.count) relay(s)", category: .session)
// Ensure we actually have sockets opening to these relays so queued REQs can flush
ensureConnections(to: urls)
@@ -418,9 +443,17 @@ final class NostrRelayManager: ObservableObject {
} else {
SecureLogger.warning("⚠️ No handler for subscription \(subId)", category: .session)
}
case .eose:
// No-op for now
break
case .eose(let subId):
if var tracker = eoseTrackers[subId] {
tracker.pendingRelays.remove(relayUrl)
if tracker.pendingRelays.isEmpty {
tracker.timer?.invalidate()
eoseTrackers.removeValue(forKey: subId)
tracker.callback()
} else {
eoseTrackers[subId] = tracker
}
}
case .ok(let eventId, let success, let reason):
if success {
_ = Self.pendingGiftWrapIDs.remove(eventId)
@@ -755,6 +788,16 @@ struct NostrFilter: Encodable {
filter.limit = limit
return filter
}
// For location notes: persistent text notes (kind 1) tagged with geohash
static func geohashNotes(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
var filter = NostrFilter()
filter.kinds = [1]
filter.since = since?.timeIntervalSince1970.toInt()
filter.tagFilters = ["g": [geohash]]
filter.limit = limit
return filter
}
}
// Dynamic coding key for tag filters