mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 23:25:19 +00:00
* 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>
395 lines
14 KiB
Swift
395 lines
14 KiB
Swift
import Testing
|
|
import Combine
|
|
import Foundation
|
|
@testable import bitchat
|
|
|
|
@MainActor
|
|
struct LocationNotesManagerTests {
|
|
@Test
|
|
func subscribeWithoutRelays_setsNoRelaysState() {
|
|
var subscribeCalled = false
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in [] },
|
|
subscribe: { _, _, _, _, _ in
|
|
subscribeCalled = true
|
|
},
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in try NostrIdentity.generate() },
|
|
now: { Date() }
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
|
|
#expect(subscribeCalled == false)
|
|
#expect(manager.state == .noRelays)
|
|
#expect(manager.initialLoadComplete)
|
|
#expect(manager.errorMessage == String(localized: "location_notes.error.no_relays"))
|
|
}
|
|
|
|
@Test
|
|
func noRelays_resubscribesWhenDirectoryRefreshes() async throws {
|
|
var relays: [String] = []
|
|
var subscribeCount = 0
|
|
let directorySubject = PassthroughSubject<Void, Never>()
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in relays },
|
|
subscribe: { _, _, _, _, _ in
|
|
subscribeCount += 1
|
|
},
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in try NostrIdentity.generate() },
|
|
now: { Date() },
|
|
relayDirectoryUpdates: directorySubject.eraseToAnyPublisher()
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
#expect(manager.state == .noRelays)
|
|
#expect(subscribeCount == 0)
|
|
|
|
// Directory loads later (e.g. remote fetch finished after Tor came up).
|
|
relays = ["wss://relay.one"]
|
|
directorySubject.send(())
|
|
|
|
let deadline = Date().addingTimeInterval(1.0)
|
|
while manager.state == .noRelays && Date() < deadline {
|
|
try await Task.sleep(nanoseconds: 10_000_000)
|
|
}
|
|
|
|
#expect(subscribeCount == 1)
|
|
#expect(manager.state == .loading)
|
|
#expect(manager.errorMessage == nil)
|
|
}
|
|
|
|
@Test
|
|
func sendWithoutRelays_surfacesNoRelaysError() {
|
|
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: "u4pruydq", dependencies: deps)
|
|
manager.send(content: "hello", nickname: "tester")
|
|
|
|
#expect(sendCalled == false)
|
|
#expect(manager.state == .noRelays)
|
|
#expect(manager.errorMessage == String(localized: "location_notes.error.no_relays"))
|
|
}
|
|
|
|
@Test func subscribeUsesGeoRelaysAndAppendsNotes() throws {
|
|
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
|
|
#expect(filter.kinds == [1])
|
|
#expect(!id.isEmpty)
|
|
relaysCaptured = relays
|
|
storedHandler = handler
|
|
storedEOSE = eose
|
|
},
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
|
now: { Date() }
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
#expect(relaysCaptured == ["wss://relay.one"])
|
|
#expect(manager.state == .loading)
|
|
|
|
let identity = try NostrIdentity.generate()
|
|
let event = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: Date(),
|
|
kind: .textNote,
|
|
tags: [["g", "u4pruydq"]],
|
|
content: "hi"
|
|
)
|
|
let signed = try event.sign(with: identity.schnorrSigningKey())
|
|
storedHandler?(signed)
|
|
storedEOSE?()
|
|
|
|
#expect(manager.state == .ready)
|
|
#expect(manager.notes.count == 1)
|
|
#expect(manager.notes.first?.content == "hi")
|
|
}
|
|
|
|
@Test
|
|
func refreshAndCancel_manageSubscriptions() {
|
|
var subscribeIDs: [String] = []
|
|
var unsubscribedIDs: [String] = []
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
|
subscribe: { _, id, _, _, _ in
|
|
subscribeIDs.append(id)
|
|
},
|
|
unsubscribe: { id in
|
|
unsubscribedIDs.append(id)
|
|
},
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in try NostrIdentity.generate() },
|
|
now: { Date() }
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
manager.refresh()
|
|
manager.cancel()
|
|
|
|
#expect(subscribeIDs.count == 2)
|
|
#expect(unsubscribedIDs.count == 2)
|
|
#expect(manager.state == .idle)
|
|
#expect(manager.errorMessage == nil)
|
|
}
|
|
|
|
@Test
|
|
func send_successCreatesLocalEchoAndClearsError() throws {
|
|
var sentEvents: [NostrEvent] = []
|
|
let identity = try NostrIdentity.generate()
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
|
subscribe: { _, _, _, _, _ in },
|
|
unsubscribe: { _ in },
|
|
sendEvent: { event, _ in
|
|
sentEvents.append(event)
|
|
},
|
|
deriveIdentity: { _ in identity },
|
|
now: { Date(timeIntervalSince1970: 123_456) }
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
manager.send(content: " hello note ", nickname: "Builder")
|
|
|
|
#expect(sentEvents.count == 1)
|
|
#expect(manager.state == .ready)
|
|
#expect(manager.errorMessage == nil)
|
|
#expect(manager.notes.first?.content == "hello note")
|
|
#expect(manager.notes.first?.displayName.hasPrefix("Builder#") == true)
|
|
}
|
|
|
|
@Test
|
|
func send_failureFormatsErrorMessageAndClearErrorRemovesIt() {
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
|
subscribe: { _, _, _, _, _ in },
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
|
now: { Date() }
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
manager.send(content: "hello", nickname: "Builder")
|
|
|
|
#expect(manager.errorMessage?.isEmpty == false)
|
|
|
|
manager.clearError()
|
|
|
|
#expect(manager.errorMessage == nil)
|
|
}
|
|
|
|
@Test
|
|
func ingestDropsExpiredNotesAndKeepsUnexpiredOnes() throws {
|
|
var storedHandler: ((NostrEvent) -> Void)?
|
|
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
|
subscribe: { _, _, _, handler, _ in
|
|
storedHandler = handler
|
|
},
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
|
now: { now }
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
let identity = try NostrIdentity.generate()
|
|
|
|
let expired = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: now.addingTimeInterval(-3600),
|
|
kind: .textNote,
|
|
tags: [["g", "u4pruydq"], ["expiration", String(Int(now.timeIntervalSince1970) - 60)]],
|
|
content: "gone"
|
|
)
|
|
storedHandler?(try expired.sign(with: identity.schnorrSigningKey()))
|
|
|
|
let live = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: now.addingTimeInterval(-3600),
|
|
kind: .textNote,
|
|
tags: [["g", "u4pruydq"], ["expiration", String(Int(now.timeIntervalSince1970) + 3600)]],
|
|
content: "still here"
|
|
)
|
|
storedHandler?(try live.sign(with: identity.schnorrSigningKey()))
|
|
|
|
#expect(manager.notes.count == 1)
|
|
#expect(manager.notes.first?.content == "still here")
|
|
#expect(manager.notes.first?.expiresAt == Date(timeIntervalSince1970: TimeInterval(Int(now.timeIntervalSince1970) + 3600)))
|
|
}
|
|
|
|
@Test
|
|
func postDrop_sendsExpiringNoteToGeoRelays() throws {
|
|
var sentEvents: [NostrEvent] = []
|
|
let now = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let identity = try NostrIdentity.generate()
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
|
subscribe: { _, _, _, _, _ in },
|
|
unsubscribe: { _ in },
|
|
sendEvent: { event, _ in sentEvents.append(event) },
|
|
deriveIdentity: { _ in identity },
|
|
now: { now }
|
|
)
|
|
|
|
let posted = LocationNotesManager.postDrop(
|
|
content: " the coffee here is great ",
|
|
nickname: "scout",
|
|
geohash: "u4pruydq",
|
|
dependencies: deps
|
|
)
|
|
|
|
#expect(posted)
|
|
#expect(sentEvents.count == 1)
|
|
let event = try #require(sentEvents.first)
|
|
#expect(event.kind == NostrProtocol.EventKind.textNote.rawValue)
|
|
#expect(event.content == "the coffee here is great")
|
|
#expect(event.tags.contains(["g", "u4pruydq"]))
|
|
let expiration = event.tags.first { $0.first == "expiration" }?.last
|
|
let expected = Int(now.addingTimeInterval(TransportConfig.locationDropExpirySeconds).timeIntervalSince1970)
|
|
#expect(expiration == String(expected))
|
|
}
|
|
|
|
@Test
|
|
func postDrop_failsWithoutRelays() {
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in [] },
|
|
subscribe: { _, _, _, _, _ in },
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
|
now: { Date() }
|
|
)
|
|
|
|
#expect(!LocationNotesManager.postDrop(content: "hi", nickname: "x", geohash: "u4pruydq", dependencies: deps))
|
|
}
|
|
|
|
@Test
|
|
func pruneExpiredNotes_dropsNotesWhoseExpiryPassed() throws {
|
|
var storedHandler: ((NostrEvent) -> Void)?
|
|
var currentNow = Date(timeIntervalSince1970: 1_700_000_000)
|
|
let deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
|
subscribe: { _, _, _, handler, _ in
|
|
storedHandler = handler
|
|
},
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
|
now: { currentNow }
|
|
)
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
let identity = try NostrIdentity.generate()
|
|
let note = NostrEvent(
|
|
pubkey: identity.publicKeyHex,
|
|
createdAt: currentNow,
|
|
kind: .textNote,
|
|
tags: [["g", "u4pruydq"], ["expiration", String(Int(currentNow.timeIntervalSince1970) + 60)]],
|
|
content: "short lived"
|
|
)
|
|
storedHandler?(try note.sign(with: identity.schnorrSigningKey()))
|
|
#expect(manager.notes.count == 1)
|
|
|
|
currentNow = currentNow.addingTimeInterval(120)
|
|
manager.pruneExpiredNotes()
|
|
|
|
#expect(manager.notes.isEmpty)
|
|
}
|
|
|
|
@Test
|
|
func eoseWithoutConnectedRelays_showsConnectingInsteadOfEmpty() {
|
|
var storedEOSE: (() -> Void)?
|
|
var deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
|
subscribe: { _, _, _, _, eose in storedEOSE = eose },
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
|
now: { Date() }
|
|
)
|
|
deps.anyRelayConnected = { _ in false }
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
storedEOSE?()
|
|
|
|
#expect(manager.state == .connecting)
|
|
#expect(manager.initialLoadComplete)
|
|
}
|
|
|
|
@Test
|
|
func eoseWithConnectedRelayAndNoNotes_isReadyEmpty() {
|
|
var storedEOSE: (() -> Void)?
|
|
var deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
|
subscribe: { _, _, _, _, eose in storedEOSE = eose },
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
|
now: { Date() }
|
|
)
|
|
deps.anyRelayConnected = { _ in true }
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
storedEOSE?()
|
|
|
|
#expect(manager.state == .ready)
|
|
}
|
|
|
|
@Test
|
|
func connectingState_retriesOnceARelayComesUp() {
|
|
var storedEOSE: (() -> Void)?
|
|
var subscribeCount = 0
|
|
var relayUp = false
|
|
var deps = LocationNotesDependencies(
|
|
relayLookup: { _, _ in ["wss://relay.one"] },
|
|
subscribe: { _, _, _, _, eose in
|
|
subscribeCount += 1
|
|
storedEOSE = eose
|
|
},
|
|
unsubscribe: { _ in },
|
|
sendEvent: { _, _ in },
|
|
deriveIdentity: { _ in throw TestError.shouldNotDerive },
|
|
now: { Date() }
|
|
)
|
|
deps.anyRelayConnected = { _ in relayUp }
|
|
|
|
let manager = LocationNotesManager(geohash: "u4pruydq", dependencies: deps)
|
|
#expect(subscribeCount == 1)
|
|
storedEOSE?()
|
|
#expect(manager.state == .connecting)
|
|
|
|
// Relay still down: no retry.
|
|
manager.retryIfRelaysAvailable(relays: ["wss://relay.one"])
|
|
#expect(subscribeCount == 1)
|
|
|
|
// Relay up: re-subscribes for a fresh initial fetch.
|
|
relayUp = true
|
|
manager.retryIfRelaysAvailable(relays: ["wss://relay.one"])
|
|
#expect(subscribeCount == 2)
|
|
#expect(manager.state == .loading)
|
|
}
|
|
|
|
private enum TestError: Error {
|
|
case shouldNotDerive
|
|
}
|
|
}
|