mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-27 11:25:20 +00:00
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:
co-authored by
jack
Claude Opus 4.8
parent
b7c6f42b3a
commit
304460ee83
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user