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
+46 -6
View File
@@ -17,6 +17,12 @@ final class NearbyNotesCounter: ObservableObject {
static let shared = NearbyNotesCounter()
@Published private(set) var noteCount = 0
/// Whether an explicit notes act (the empty-timeline "check for notes"
/// tap, opening the notices sheet's geo tab, or a successful /drop) has
/// unlocked the counter this session. Until then nothing subscribes:
/// merely looking at the mesh timeline must not open a building-precision
/// relay REQ that leaks location passively.
@Published private(set) var revealed = false
private var manager: LocationNotesManager?
private var managerCancellable: AnyCancellable?
@@ -24,9 +30,38 @@ final class NearbyNotesCounter: ObservableObject {
private var settingCancellable: AnyCancellable?
private var activeHolders = 0
private let locationManager: LocationChannelManager
private let managerFactory: @MainActor (String) -> LocationNotesManager
private let releaseManager: @MainActor (LocationNotesManager?) -> Void
init(locationManager: LocationChannelManager = .shared) {
init(
locationManager: LocationChannelManager = .shared,
managerFactory: @escaping @MainActor (String) -> LocationNotesManager = { LocationNotesPool.shared.acquire($0) },
releaseManager: @escaping @MainActor (LocationNotesManager?) -> Void = { LocationNotesPool.shared.release($0) }
) {
self.locationManager = locationManager
self.managerFactory = managerFactory
self.releaseManager = releaseManager
}
/// Whether the empty-timeline "check for notes" hint should render.
/// The permission gate matters: `retarget()` never subscribes without
/// location authorization, so offering the hint to an unauthorized
/// install would be a silent dead-end tap, `revealed` flips, the hint
/// vanishes, and nothing else happens for the session. The hint never
/// prompts; it simply stays hidden until permission exists. The caller
/// passes its own observed permission state so the hint re-renders when
/// authorization changes.
func offersRevealHint(permissionState: LocationChannelManager.PermissionState) -> Bool {
!revealed && LocationNotesSettings.enabled && permissionState == .authorized
}
/// Marks the one explicit act that lets the counter subscribe. Sticky for
/// the rest of the session (the singleton's lifetime); `deactivate()`
/// deliberately does not reset it.
func reveal() {
guard !revealed else { return }
revealed = true
retarget()
}
/// Begins (or keeps) the notes subscription for the current building
@@ -53,31 +88,36 @@ final class NearbyNotesCounter: ObservableObject {
channelsCancellable = nil
settingCancellable = nil
managerCancellable = nil
manager?.cancel()
releaseManager(manager)
manager = nil
noteCount = 0
}
private func retarget() {
guard activeHolders > 0,
revealed,
LocationNotesSettings.enabled,
locationManager.permissionState == .authorized,
let geohash = locationManager.availableChannels
.first(where: { $0.level == .building })?.geohash
else {
managerCancellable = nil
manager?.cancel()
releaseManager(manager)
manager = nil
noteCount = 0
return
}
if let manager {
manager.setGeohash(geohash)
return
guard manager.geohash != geohash.lowercased() else { return }
// Pooled managers are shared; never retarget one in place
// release the old cell and acquire the new one.
managerCancellable = nil
releaseManager(manager)
self.manager = nil
}
let fresh = LocationNotesManager(geohash: geohash)
let fresh = managerFactory(geohash)
manager = fresh
managerCancellable = fresh.$notes
.receive(on: DispatchQueue.main)