Nearby notes: tap-to-reveal (consent-gate the location subscription) + subscription hygiene (#1422)

* Nearby notes: tap-to-reveal before any relay REQ, plus shared subscription pool

The nearby-notes counter used to open a live building-precision (precision-8)
geohash REQ to the closest geo relays whenever the mesh public timeline was
visible — a passive location side-channel with no opt-in. Now nothing
subscribes until one explicit act reveals the counter for the session: tapping
the new "check for notes left here" line on the empty mesh timeline (static,
no network), opening the notices sheet's geo tab, or a successful /drop. The
app-info setting stays the default-ON kill switch and still gates /drop.

Subscription hygiene alongside:
- LocationNotesManager.deinit now unsubscribes its live REQ (hopping to the
  main actor like the timer teardown) instead of only invalidating timers.
- New refcounted LocationNotesPool dedupes the counter's and the notices
  sheet's identical 9-cell kind-1 REQs into one shared manager per geohash;
  both callers release-and-reacquire instead of retargeting in place, and the
  sheet releases its ref on dismissal.

The reveal affordance is localized across all 29 catalog locales, and new
NearbyNotesCounterTests cover the no-REQ-before-reveal contract, the 9-cell
filter, NIP-40 expiry handling, single unsubscribe on deactivate, and pool
refcounting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Review fixes: permission-gate the hint, exclude building cell from pre-reveal sampling, explicit-act reveal only

Fixes the verified review findings on the tap-to-reveal PR:

- Permission dead-end: the "check for notes left here" hint now renders
  only when location permission is already authorized (it never prompts).
  Previously a location-denied install could tap it, flip the sticky
  revealed flag, and get nothing for the rest of the session because
  retarget() guards on authorization. Predicate lives on
  NearbyNotesCounter.offersRevealHint(permissionState:) and is reactive
  to the published permission state.

- Building-cell sampling: background geohash sampling subscribed
  geo-sample-<gh> for every regional level including the precision-8
  building cell, pre-reveal — contradicting the claim that nothing
  building-precision hits a relay before the explicit act.
  GeoChannelCoordinator now excludes the building level until
  NearbyNotesCounter.revealed (injectable publisher for tests); coarser
  levels keep the nearby-conversation hint and participant counts
  working, and bookmarks stay exempt (bookmarking is explicit).

- Implicit reveal: opening the notices sheet no longer reveals — the
  sheet auto-lands on the geo tab whenever a location channel is
  selected, so browsing a remote geohash and opening notices revealed
  the LOCAL building subscription. reveal() now fires only on the
  person actively picking the geo segment, and only when the sheet has
  a geo scope (the empty-mesh hint tap and /drop stay as before).

- Subscription hygiene: switching the sheet geo → mesh releases the
  pooled notes manager (the REQ was left streaming behind the mesh
  board); switching back re-acquires from the pool. The dismissal
  release stays balanced — liveGeoManager is nil after the tab-switch
  release.

- VoiceOver: the hint button exposes the plain localized action text
  instead of the decorated "* 📍 … *" label.

- Removed LocationNotesManager.setGeohash: zero callers, and calling it
  on a pooled instance would corrupt the pool's keying and refcounts.

New tests: hint permission gate, explicit-geo-tab reveal contract,
building-cell sampling exclusion before/after reveal, pool
release/re-acquire round trip. Full suite (1467) green; iOS simulator
build green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Deflake peer-snapshot binding waits: longTimeout for the multi-hop pipeline

CI failed once on initialization_bindsPeerSnapshotsIntoAllPeers: the
snapshot -> allPeers binding crosses the mock transport's unstructured
Task, UnifiedPeerService.updatePeers, a receive(on: main), and another
Task { @MainActor } in bindPeerService — all contending with every
parallel worker. On the failing runner the whole suite took 10.1s
(usually ~4.4s locally), so the positive 5s defaultTimeout wait lost
the race. That's exactly the case TestConstants.longTimeout documents;
passing runs return as soon as the condition holds and never pay it.

Not caused by the tap-to-reveal changes: nothing on that pipeline was
touched, and 20 full parallel-suite loops each on the branch and on
origin/main reproduce zero failures locally. The two sibling waits on
the same pipeline in this file get the same timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jack
2026-07-09 16:47:08 +02:00
committed by GitHub
co-authored by jack Claude Opus 4.8
parent b7c6f42b3a
commit 304460ee83
13 changed files with 771 additions and 67 deletions
@@ -24,6 +24,7 @@ struct MeshEmptyStateView: View {
@EnvironmentObject private var peerListModel: PeerListModel
@ObservedObject private var activityTracker = GeohashChatActivityTracker.shared
@ObservedObject private var sightingsTracker = MeshSightingsTracker.shared
@ObservedObject private var nearbyNotes = NearbyNotesCounter.shared
@ThemedPalette private var palette
@@ -38,6 +39,7 @@ struct MeshEmptyStateView: View {
static let meshIntro = String(localized: "content.empty.mesh_intro", comment: "First line of the empty mesh timeline explaining what the mesh channel is")
static let switchHint = String(localized: "content.empty.switch_hint", comment: "Empty timeline hint pointing at the channel switcher and the help screen")
static let sightingsOne = String(localized: "content.empty.sightings_one", comment: "Empty mesh timeline stat when exactly one device came within range today")
static let checkNotes = String(localized: "content.empty.check_notes", comment: "Empty mesh timeline action that starts looking for notes left at this place; before tapping, no lookup runs")
static func sightingsMany(_ count: Int) -> String {
String(
@@ -80,6 +82,9 @@ struct MeshEmptyStateView: View {
if let conversation = nearbyConversation {
conversationHint(conversation)
}
if showsCheckNotesHint {
checkNotesHint
}
} else {
// The radar + tally already say "scanning, nobody yet", so
// the narration stays to two lines with the live hint after
@@ -89,6 +94,9 @@ struct MeshEmptyStateView: View {
if let conversation = nearbyConversation {
conversationHint(conversation)
}
if showsCheckNotesHint {
checkNotesHint
}
// The radar centers in whatever space is left below the
// text the flexible spacers split it evenly.
@@ -127,6 +135,31 @@ private extension MeshEmptyStateView {
activityTracker.mostActiveConversation(among: locationChannelsModel.availableChannels)
}
/// Tap-to-reveal: the nearby-notes counter never subscribes on its own
/// looking at the mesh timeline must not open a building-precision relay
/// REQ (a passive location side-channel). This static line is the one
/// explicit act that unlocks it; nothing touches the network until the
/// tap. It only renders when location permission is already granted
/// (the tap never prompts, so without permission it would dead-end
/// silently). Once revealed it yields to today's live strip and count,
/// and the app-info setting stays the kill switch.
var showsCheckNotesHint: Bool {
nearbyNotes.offersRevealHint(permissionState: locationChannelsModel.permissionState)
}
var checkNotesHint: some View {
Button {
NearbyNotesCounter.shared.reveal()
} label: {
actionLine("📍 \(Strings.checkNotes)")
.contentShape(Rectangle())
}
.buttonStyle(.plain)
// The visual label carries decorative asterisks and an emoji; expose
// just the localized action text to assistive tech.
.accessibilityLabel(Strings.checkNotes)
}
var sightingsText: String {
sightingsTracker.todayCount == 1
? Strings.sightingsOne
+3 -1
View File
@@ -334,8 +334,10 @@ private extension MessageListView {
.buttonStyle(.plain)
}
/// The nearby-notes counter runs whenever the mesh public timeline is
/// The nearby-notes counter is held whenever the mesh public timeline is
/// showing the strip needs a live count before it can decide to exist.
/// Holding is not subscribing: nothing hits the relays until an explicit
/// act reveals the counter (tap-to-reveal).
func updateNotesCounterHold() {
let shouldHold = privatePeer == nil && locationChannelsModel.selectedChannel.isMesh
guard shouldHold != holdsNotesCounter else { return }
+37 -11
View File
@@ -40,8 +40,10 @@ struct NoticesView: View {
/// Injected notes manager for tests; live use derives one per geohash.
private let notesManager: LocationNotesManager?
/// Live manager owned by the sheet so the composer can post pure Nostr
/// Pooled manager held by the sheet so the composer can post pure Nostr
/// notes ( expiry has no mesh-board copy) and the list can render them.
/// Acquired from `LocationNotesPool` (shared with the nearby-notes
/// counter, one REQ per geohash) and released on dismissal.
@State private var liveGeoManager: LocationNotesManager?
init(
@@ -61,19 +63,33 @@ struct NoticesView: View {
notesManager ?? liveGeoManager
}
/// Creates (or retargets/revives) the sheet-owned notes manager for the
/// The one explicit act inside the sheet that unlocks the passive
/// nearby-notes counter: the person actively picking the geo segment
/// while the sheet has a geo scope. Landing on the geo tab via the
/// sheet's initial selection (auto-derived from the current channel
/// e.g. browsing a remote geohash) is not an act toward the LOCAL
/// building cell and must not reveal it.
static func revealsNearbyNotes(onSwitchingTo tab: Tab, geoGeohash: String?) -> Bool {
tab == .geo && geoGeohash != nil
}
/// Acquires (or retargets/revives) the pooled notes manager for the
/// current geo scope.
private func ensureGeoNotesManager() {
guard notesManager == nil, tab == .geo, let geohash = geoGeohash else { return }
guard tab == .geo else { return }
guard notesManager == nil, let geohash = geoGeohash else { return }
if let manager = liveGeoManager {
if manager.geohash != geohash.lowercased() {
manager.setGeohash(geohash)
// Pooled managers are shared; never retarget one in place
// release the old cell and acquire the new one.
LocationNotesPool.shared.release(manager)
liveGeoManager = LocationNotesPool.shared.acquire(geohash)
} else if manager.state == .idle {
// Cancelled on a tab switch; returning re-subscribes.
// Revived after a cancel elsewhere; returning re-subscribes.
manager.refresh()
}
} else {
liveGeoManager = LocationNotesManager(geohash: geohash)
liveGeoManager = LocationNotesPool.shared.acquire(geohash)
}
}
@@ -180,9 +196,19 @@ struct NoticesView: View {
.onChange(of: tab) { newTab in
if newTab == .geo {
beginGeoLocationIfNeeded()
if Self.revealsNearbyNotes(onSwitchingTo: newTab, geoGeohash: geoGeohash) {
NearbyNotesCounter.shared.reveal()
}
ensureGeoNotesManager()
} else {
locationChannelsModel.endLiveRefresh()
// Leaving the geo tab must take its REQ down with it, not
// leave the geohash subscription streaming behind the mesh
// board. The pool makes the return trip cheap (re-acquire
// revives the manager), and the dismissal release below is
// safe: `liveGeoManager` is nil until re-acquired.
LocationNotesPool.shared.release(liveGeoManager)
liveGeoManager = nil
}
// Each tab keeps its natural default: geo notes stay until
// deleted (), mesh board posts fade within a week.
@@ -196,7 +222,11 @@ struct NoticesView: View {
.onChange(of: geoGeohash) { _ in
ensureGeoNotesManager()
}
.onDisappear { locationChannelsModel.endLiveRefresh() }
.onDisappear {
locationChannelsModel.endLiveRefresh()
LocationNotesPool.shared.release(liveGeoManager)
liveGeoManager = nil
}
}
/// The geo tab tracks the device's building geohash while on mesh; keep
@@ -417,10 +447,6 @@ private struct GeoNoticesList: View {
board: board,
notesManager: notesManager
)
.onChange(of: geohash) { newValue in
notesManager.setGeohash(newValue)
}
.onDisappear { notesManager.cancel() }
}
}