mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-11-01 07:53:26 +00:00
Stop rebuilding the geochat roster on every inbound event (#1789)
* Stop rebuilding the geochat roster on every inbound event * Keep geochat names and counts current after the roster change * Rebuild mesh rows from every peer snapshot * Mark teleport only for activity that counts as presence Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
5fa0d3e733
commit
14c6b0b47d
@ -30,17 +30,22 @@ final class LocationPresenceStore: ObservableObject {
|
||||
currentGeohash = normalized
|
||||
}
|
||||
|
||||
func setNickname(_ nickname: String, for pubkeyHex: String) {
|
||||
/// Returns whether the stored nickname changed. Chat events repeat the
|
||||
/// sender's nickname every time; rewriting an unchanged value would
|
||||
/// republish the whole table.
|
||||
@discardableResult
|
||||
func setNickname(_ nickname: String, for pubkeyHex: String) -> Bool {
|
||||
guard geoNicknameCapacity > 0 else {
|
||||
clearGeoNicknames()
|
||||
return
|
||||
return false
|
||||
}
|
||||
|
||||
let nickname = nickname.normalizedNickname
|
||||
let key = pubkeyHex.lowercased()
|
||||
if geoNicknames[key] != nil {
|
||||
if let existing = geoNicknames[key] {
|
||||
guard existing != nickname else { return false }
|
||||
geoNicknames[key] = nickname
|
||||
return
|
||||
return true
|
||||
}
|
||||
|
||||
while geoNicknameOrder.count >= geoNicknameCapacity, let oldest = geoNicknameOrder.first {
|
||||
@ -50,6 +55,7 @@ final class LocationPresenceStore: ObservableObject {
|
||||
|
||||
geoNicknames[key] = nickname
|
||||
geoNicknameOrder.append(key)
|
||||
return true
|
||||
}
|
||||
|
||||
func replaceGeoNicknames(_ nicknames: [String: String]) {
|
||||
@ -83,12 +89,6 @@ final class LocationPresenceStore: ObservableObject {
|
||||
geoNicknameOrder.removeAll()
|
||||
}
|
||||
|
||||
func retainGeoNicknames(keeping pubkeys: Set<String>) {
|
||||
let allowed = Set(pubkeys.map { $0.lowercased() })
|
||||
geoNicknameOrder = geoNicknameOrder.filter { allowed.contains($0) }
|
||||
geoNicknames = geoNicknames.filter { allowed.contains($0.key) }
|
||||
}
|
||||
|
||||
func markTeleported(_ pubkeyHex: String) {
|
||||
guard teleportedGeoCapacity > 0 else {
|
||||
clearTeleportedGeo()
|
||||
@ -109,6 +109,7 @@ final class LocationPresenceStore: ObservableObject {
|
||||
|
||||
func clearTeleported(_ pubkeyHex: String) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
guard teleportedGeo.contains(key) else { return }
|
||||
teleportedGeo.remove(key)
|
||||
teleportedGeoOrder.removeAll { $0 == key }
|
||||
}
|
||||
@ -132,8 +133,11 @@ final class LocationPresenceStore: ObservableObject {
|
||||
teleportedGeo = Set(ordered)
|
||||
}
|
||||
|
||||
/// Prunes only when someone actually left: every assignment republishes
|
||||
/// `teleportedGeo`, and its subscribers rebuild the people list.
|
||||
func retainTeleportedGeo(keeping pubkeys: Set<String>) {
|
||||
let allowed = Set(pubkeys.map { $0.lowercased() })
|
||||
guard !teleportedGeo.isSubset(of: allowed) else { return }
|
||||
teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) }
|
||||
teleportedGeo = teleportedGeo.intersection(allowed)
|
||||
}
|
||||
|
||||
@ -55,7 +55,6 @@ struct RecentChatRow: Identifiable, Equatable {
|
||||
|
||||
@MainActor
|
||||
final class PeerListModel: ObservableObject {
|
||||
@Published private(set) var allPeers: [BitchatPeer] = []
|
||||
@Published private(set) var meshRows: [MeshPeerRow] = []
|
||||
@Published private(set) var geohashPeople: [GeohashPersonRow] = []
|
||||
@Published private(set) var groupRows: [GroupChatRow] = []
|
||||
@ -69,7 +68,11 @@ final class PeerListModel: ObservableObject {
|
||||
private let locationChannelsModel: LocationChannelsModel
|
||||
private let peerIdentityStore: PeerIdentityStore
|
||||
private let locationPresenceStore: LocationPresenceStore
|
||||
/// The mesh roster `refresh()` builds rows from. Not published: no view
|
||||
/// reads it, and publishing it would re-render on every mesh snapshot.
|
||||
private var allPeers: [BitchatPeer] = []
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var refreshScheduled = false
|
||||
|
||||
init(
|
||||
chatViewModel: ChatViewModel,
|
||||
@ -128,32 +131,37 @@ final class PeerListModel: ObservableObject {
|
||||
}
|
||||
|
||||
private func bind() {
|
||||
// No equality guard: `BitchatPeer.==` compares only peer IDs, so a
|
||||
// rename or a connection, favorite or petname change looks unchanged,
|
||||
// and blocking someone republishes identical peers. The refresh is
|
||||
// coalesced, and rows are compared in full before they're written.
|
||||
chatViewModel.$allPeers
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] peers in
|
||||
self?.allPeers = peers
|
||||
self?.refresh()
|
||||
guard let self else { return }
|
||||
self.allPeers = peers
|
||||
self.setNeedsRefresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.$nickname
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
self?.setNeedsRefresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationPresenceStore.$teleportedGeo
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
self?.setNeedsRefresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
conversations.$unreadConversations
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
self?.setNeedsRefresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@ -164,67 +172,89 @@ final class PeerListModel: ObservableObject {
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] change in
|
||||
guard case .direct = Self.changedConversationID(change) else { return }
|
||||
self?.refresh()
|
||||
self?.setNeedsRefresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.groupStore.$groups
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
self?.setNeedsRefresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$encryptionStatuses
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
self?.setNeedsRefresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
peerIdentityStore.$verifiedFingerprints
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
self?.setNeedsRefresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: Notification.Name("peerStatusUpdated"))
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
self?.setNeedsRefresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
chatViewModel.participantTracker.$visiblePeople
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
self?.setNeedsRefresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$selectedChannel
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
self?.setNeedsRefresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$teleported
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
self?.setNeedsRefresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
locationChannelsModel.$availableChannels
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] _ in
|
||||
self?.refresh()
|
||||
self?.setNeedsRefresh()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
/// Rebuilds the rows now. The geohash roster republishes only when
|
||||
/// membership or names change, so its recency order goes stale while the
|
||||
/// same people stay active; the people sheet calls this as it opens.
|
||||
func refreshNow() {
|
||||
refresh()
|
||||
}
|
||||
|
||||
/// Coalesces bursts of triggers into one rebuild per main-queue turn: a
|
||||
/// participant event republishes the roster and the teleport set, and a
|
||||
/// mesh snapshot touches peers, unread and encryption state at once.
|
||||
private func setNeedsRefresh() {
|
||||
guard !refreshScheduled else { return }
|
||||
refreshScheduled = true
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
MainActor.assumeIsolated {
|
||||
guard let self else { return }
|
||||
self.refreshScheduled = false
|
||||
self.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh() {
|
||||
let myPeerID = chatViewModel.meshService.myPeerID
|
||||
let meshRows = allPeers.map { peer in
|
||||
@ -266,13 +296,15 @@ final class PeerListModel: ObservableObject {
|
||||
let groupRows = buildGroupRows()
|
||||
let recentChatRows = buildRecentChatRows(meshRows: meshRows, geohashPeople: geohashPeople)
|
||||
|
||||
self.meshRows = meshRows
|
||||
reachableMeshPeerCount = meshCounts.reachable
|
||||
connectedMeshPeerCount = meshCounts.connected
|
||||
self.geohashPeople = geohashPeople
|
||||
visibleGeohashPeerCount = geohashPeople.count
|
||||
self.groupRows = groupRows
|
||||
self.recentChatRows = recentChatRows
|
||||
// Assign only what changed: every @Published write re-renders every
|
||||
// view observing this model (root, header, the open sheet).
|
||||
if self.meshRows != meshRows { self.meshRows = meshRows }
|
||||
if reachableMeshPeerCount != meshCounts.reachable { reachableMeshPeerCount = meshCounts.reachable }
|
||||
if connectedMeshPeerCount != meshCounts.connected { connectedMeshPeerCount = meshCounts.connected }
|
||||
if self.geohashPeople != geohashPeople { self.geohashPeople = geohashPeople }
|
||||
if visibleGeohashPeerCount != geohashPeople.count { visibleGeohashPeerCount = geohashPeople.count }
|
||||
if self.groupRows != groupRows { self.groupRows = groupRows }
|
||||
if self.recentChatRows != recentChatRows { self.recentChatRows = recentChatRows }
|
||||
}
|
||||
|
||||
/// Direct conversations with people absent from the rosters above:
|
||||
|
||||
@ -48,6 +48,9 @@ protocol CommandContextProvider: AnyObject {
|
||||
func getPeerIDForNickname(_ nickname: String) -> PeerID?
|
||||
func getVisibleGeoParticipants() -> [CommandGeoParticipant]
|
||||
func nostrPubkeyForDisplayName(_ displayName: String) -> String?
|
||||
/// A /block or /unblock by name changed which geohash participants are
|
||||
/// hidden; the roster re-filters instead of waiting for its timer.
|
||||
func geohashBlockListDidChange()
|
||||
|
||||
// MARK: - Chat Actions
|
||||
func startPrivateChat(with peerID: PeerID)
|
||||
@ -368,6 +371,7 @@ final class CommandProcessor {
|
||||
return .success(message: String(format: String(localized: "command.block.already", defaultValue: "%@ is already blocked", comment: "Reply when /block targets an already-blocked nickname"), locale: .current, nickname))
|
||||
}
|
||||
identityManager.setNostrBlocked(pub, isBlocked: true)
|
||||
contextProvider?.geohashBlockListDidChange()
|
||||
return .success(message: String(format: String(localized: "command.block.done_geo", defaultValue: "blocked %@ in geohash chats", comment: "Confirmation after blocking a geohash participant"), locale: .current, nickname))
|
||||
}
|
||||
|
||||
@ -396,6 +400,7 @@ final class CommandProcessor {
|
||||
return .success(message: String(format: String(localized: "command.unblock.not_blocked", defaultValue: "%@ is not blocked", comment: "Reply when /unblock targets a nickname that isn't blocked"), locale: .current, nickname))
|
||||
}
|
||||
identityManager.setNostrBlocked(pub, isBlocked: false)
|
||||
contextProvider?.geohashBlockListDidChange()
|
||||
return .success(message: String(format: String(localized: "command.unblock.done_geo", defaultValue: "unblocked %@ in geohash chats", comment: "Confirmation after unblocking a geohash participant"), locale: .current, nickname))
|
||||
}
|
||||
return .error(message: String(format: String(localized: "command.unblock.failed", defaultValue: "cannot unblock %@: not found", comment: "Error when /unblock can't resolve the target"), locale: .current, nickname))
|
||||
|
||||
@ -77,34 +77,61 @@ final class GeohashParticipantTracker: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Record activity from a participant in the current active geohash
|
||||
func recordParticipant(pubkeyHex: String) {
|
||||
guard let gh = activeGeohash else { return }
|
||||
recordParticipant(pubkeyHex: pubkeyHex, geohash: gh)
|
||||
/// Record activity from a participant in the current active geohash.
|
||||
/// Returns `false`, recording nothing, when no geohash is active.
|
||||
@discardableResult
|
||||
func recordParticipant(pubkeyHex: String, seenAt: Date = Date()) -> Bool {
|
||||
guard let gh = activeGeohash else { return false }
|
||||
return recordParticipant(pubkeyHex: pubkeyHex, geohash: gh, seenAt: seenAt)
|
||||
}
|
||||
|
||||
/// Record activity from a participant in a specific geohash
|
||||
func recordParticipant(pubkeyHex: String, geohash: String) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
var map = participants[geohash] ?? [:]
|
||||
map[key] = Date()
|
||||
participants[geohash] = map
|
||||
|
||||
// Always notify observers that state has changed so counts in UI update
|
||||
objectWillChange.send()
|
||||
/// Record activity from a participant in a specific geohash.
|
||||
///
|
||||
/// `seenAt` is when the activity happened, clamped to now: arrival time
|
||||
/// for live events, the event's own timestamp for history relays replay
|
||||
/// on subscribe. Replayed history older than the activity window must
|
||||
/// not make its authors count as present. Returns whether the activity
|
||||
/// counted, i.e. fell inside the window.
|
||||
///
|
||||
/// Runs for every inbound geohash event, so the common case — someone
|
||||
/// already present sending a heartbeat or message — only moves their
|
||||
/// timestamp. The roster is republished only when someone becomes
|
||||
/// visible; departures are picked up by the refresh timer.
|
||||
@discardableResult
|
||||
func recordParticipant(pubkeyHex: String, geohash: String, seenAt: Date = Date()) -> Bool {
|
||||
let now = Date()
|
||||
let cutoff = now.addingTimeInterval(activityCutoff)
|
||||
let stamp = min(seenAt, now)
|
||||
guard stamp >= cutoff else { return false }
|
||||
|
||||
// Only refresh visible list if this geohash is currently active
|
||||
let key = pubkeyHex.lowercased()
|
||||
let previous = participants[geohash]?[key]
|
||||
if let previous, previous >= stamp { return true }
|
||||
participants[geohash, default: [:]][key] = stamp
|
||||
|
||||
let wasActive = previous.map { $0 >= cutoff } ?? false
|
||||
guard !wasActive else { return true }
|
||||
// Someone newly counts as present here: participant counts for
|
||||
// this geohash changed (sampled channels included).
|
||||
objectWillChange.send()
|
||||
if activeGeohash == geohash {
|
||||
refresh()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// A participant's display name may have changed (a nickname was learned
|
||||
/// or edited). Republishes the roster if they're currently visible.
|
||||
func noteDisplayNameChanged(pubkeyHex: String) {
|
||||
guard visibleMemberIDs.contains(pubkeyHex.lowercased()) else { return }
|
||||
refresh()
|
||||
}
|
||||
|
||||
/// Remove a participant from all geohashes (used when blocking)
|
||||
func removeParticipant(pubkeyHex: String) {
|
||||
let key = pubkeyHex.lowercased()
|
||||
for (gh, var map) in participants {
|
||||
map.removeValue(forKey: key)
|
||||
participants[gh] = map
|
||||
for geohash in participants.keys {
|
||||
participants[geohash]?.removeValue(forKey: key)
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
@ -113,7 +140,22 @@ final class GeohashParticipantTracker: ObservableObject {
|
||||
func participantCount(for geohash: String) -> Int {
|
||||
let cutoff = Date().addingTimeInterval(activityCutoff)
|
||||
let map = participants[geohash] ?? [:]
|
||||
return map.values.filter { $0 >= cutoff }.count
|
||||
return map.values.reduce(0) { $1 >= cutoff ? $0 + 1 : $0 }
|
||||
}
|
||||
|
||||
/// Drops entries that aged out of the activity window, in every geohash
|
||||
/// (sampled ones included) so a long session doesn't keep every pubkey
|
||||
/// it ever saw.
|
||||
func pruneExpired(now: Date = Date()) {
|
||||
let cutoff = now.addingTimeInterval(activityCutoff)
|
||||
for (geohash, map) in participants {
|
||||
let active = map.filter { $0.value >= cutoff }
|
||||
if active.isEmpty {
|
||||
participants.removeValue(forKey: geohash)
|
||||
} else if active.count != map.count {
|
||||
participants[geohash] = active
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the visible people list for the active geohash (read-only query)
|
||||
@ -131,9 +173,18 @@ final class GeohashParticipantTracker: ObservableObject {
|
||||
.sorted { $0.lastSeen > $1.lastSeen }
|
||||
}
|
||||
|
||||
/// Refresh the visible people list
|
||||
/// Refresh the visible people list. Publishes only when what the roster
|
||||
/// shows changed — who is present or how they're named — so subscribers
|
||||
/// (the people sheet, the bootstrapper's prune) don't rebuild on
|
||||
/// activity that only moved timestamps.
|
||||
func refresh() {
|
||||
publishVisiblePeople(getVisiblePeople())
|
||||
let people = getVisiblePeople()
|
||||
guard Self.rosterKey(people) != Self.rosterKey(visiblePeople) else { return }
|
||||
publishVisiblePeople(people)
|
||||
}
|
||||
|
||||
private static func rosterKey(_ people: [GeoPerson]) -> [String: String] {
|
||||
Dictionary(people.map { ($0.id, $0.displayName) }, uniquingKeysWith: { first, _ in first })
|
||||
}
|
||||
|
||||
private func publishVisiblePeople(_ people: [GeoPerson]) {
|
||||
@ -145,11 +196,12 @@ final class GeohashParticipantTracker: ObservableObject {
|
||||
visiblePeople = people
|
||||
}
|
||||
|
||||
/// Start the periodic refresh timer
|
||||
/// Start the periodic refresh timer: expires people who went quiet.
|
||||
func startRefreshTimer(interval: TimeInterval = 30.0) {
|
||||
stopRefreshTimer()
|
||||
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.pruneExpired()
|
||||
self?.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@ -1685,6 +1685,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, SynchronousMessage
|
||||
_ = clearGeoSamplingSubs()
|
||||
cachedGeohashIdentity = nil
|
||||
nostrKeyMapping.removeAll()
|
||||
// Every pubkey seen in any geohash this session, with when.
|
||||
participantTracker.clear()
|
||||
|
||||
// Clear read receipt tracking
|
||||
sentReadReceipts.removeAll()
|
||||
|
||||
@ -157,13 +157,19 @@ private extension ChatViewModelBootstrapper {
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
|
||||
// Teleport markers describe people present now, so they follow the
|
||||
// roster (pruned against it as it is when this runs, not the
|
||||
// delivered snapshot). Nicknames don't: they name the authors of
|
||||
// messages on screen, including replayed history from people no
|
||||
// longer present, so they stay for the channel (cleared on switch,
|
||||
// bounded by the store's capacity).
|
||||
viewModel.participantTracker.$visiblePeople
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak viewModel] people in
|
||||
Task { @MainActor [weak viewModel] in
|
||||
let visible = Set(people.map { $0.id })
|
||||
viewModel?.locationPresenceStore.retainTeleportedGeo(keeping: visible)
|
||||
viewModel?.locationPresenceStore.retainGeoNicknames(keeping: visible)
|
||||
.sink { [weak viewModel] _ in
|
||||
MainActor.assumeIsolated {
|
||||
guard let viewModel else { return }
|
||||
let visible = Set(viewModel.participantTracker.visiblePeople.map(\.id))
|
||||
viewModel.locationPresenceStore.retainTeleportedGeo(keeping: visible)
|
||||
}
|
||||
}
|
||||
.store(in: &viewModel.cancellables)
|
||||
|
||||
@ -80,6 +80,11 @@ extension ChatViewModel {
|
||||
nostrCoordinator.nostrPubkeyForDisplayName(name)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func geohashBlockListDidChange() {
|
||||
participantTracker.refresh()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func startGeohashDM(withPubkeyHex hex: String) {
|
||||
nostrCoordinator.startGeohashDM(withPubkeyHex: hex)
|
||||
|
||||
@ -19,7 +19,7 @@ protocol GeoPresenceContext: AnyObject {
|
||||
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
||||
func parseMentions(from content: String) -> [String]
|
||||
|
||||
func recordGeoParticipant(pubkeyHex: String, geohash: String)
|
||||
func recordGeoParticipant(pubkeyHex: String, geohash: String, seenAt: Date)
|
||||
func geoParticipantCount(for geohash: String) -> Int
|
||||
func markGeoTeleported(_ pubkeyHexLowercased: String)
|
||||
|
||||
@ -43,8 +43,8 @@ extension ChatViewModel: GeoPresenceContext {
|
||||
locationPresenceStore.teleportedGeo.count
|
||||
}
|
||||
|
||||
func recordGeoParticipant(pubkeyHex: String, geohash: String) {
|
||||
participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: geohash)
|
||||
func recordGeoParticipant(pubkeyHex: String, geohash: String, seenAt: Date) {
|
||||
participantTracker.recordParticipant(pubkeyHex: pubkeyHex, geohash: geohash, seenAt: seenAt)
|
||||
}
|
||||
|
||||
func geoParticipantCount(for geohash: String) -> Int {
|
||||
@ -107,8 +107,21 @@ final class GeoPresenceTracker {
|
||||
// actor) by NostrRelayManager before delivery.
|
||||
guard shouldProcessGeoSamplingEvent(event.id) else { return }
|
||||
|
||||
let existingCount = context.geoParticipantCount(for: gh)
|
||||
context.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh)
|
||||
// The open channel is usually one of the sampled cells, and its own
|
||||
// subscription already records its senders; recording this copy too
|
||||
// ran the participant bookkeeping twice for every event there.
|
||||
let isOpenChannel: Bool = {
|
||||
if case .location(let channel) = context.activeChannel {
|
||||
return channel.geohash == gh
|
||||
}
|
||||
return false
|
||||
}()
|
||||
let existingCount = isOpenChannel ? nil : context.geoParticipantCount(for: gh)
|
||||
if !isOpenChannel {
|
||||
// Arrival time: sampling replays at most a few minutes of history,
|
||||
// and live senders with skewed clocks must still count.
|
||||
context.recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh, seenAt: Date())
|
||||
}
|
||||
|
||||
guard let content = event.content.trimmedOrNilIfEmpty else { return }
|
||||
if context.isNostrBlocked(pubkeyHexLowercased: event.pubkey.lowercased()) { return }
|
||||
|
||||
@ -47,7 +47,8 @@ protocol GeohashSubscriptionContext: AnyObject {
|
||||
func startGeoParticipantRefreshTimer()
|
||||
func stopGeoParticipantRefreshTimer()
|
||||
func setActiveParticipantGeohash(_ geohash: String?)
|
||||
func recordGeoParticipant(pubkeyHex: String)
|
||||
@discardableResult
|
||||
func recordGeoParticipant(pubkeyHex: String, seenAt: Date) -> Bool
|
||||
func markGeoTeleported(_ pubkeyHexLowercased: String)
|
||||
func clearGeoTeleported(_ pubkeyHexLowercased: String)
|
||||
func clearTeleportedGeo()
|
||||
@ -118,6 +119,12 @@ final class GeohashSubscriptionManager {
|
||||
/// Geohashes already told "sent via mesh gateway" this session, so the
|
||||
/// notice appears once per channel instead of once per message.
|
||||
private var gatewayNoticeGeohashes = Set<String>()
|
||||
/// The geochat subscription whose stored history relays may still be
|
||||
/// replaying; cleared when its EOSE callback runs. Best effort: the relay
|
||||
/// manager also runs that callback on its fallback timeout (e.g. while
|
||||
/// Tor is still bootstrapping), and history arriving after it counts as
|
||||
/// live activity. See `NostrInboundPipeline`'s `isHistoryReplay`.
|
||||
private var replayingGeoSubscriptionID: String?
|
||||
|
||||
init(context: any GeohashSubscriptionContext, inbound: NostrInboundPipeline, presence: GeoPresenceTracker) {
|
||||
self.context = context
|
||||
@ -145,14 +152,20 @@ final class GeohashSubscriptionManager {
|
||||
toGeohash: channel.geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||
replayingGeoSubscriptionID = subID
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays, handler: { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.inbound.subscribeNostrEvent(event)
|
||||
guard let self else { return }
|
||||
self.inbound.subscribeNostrEvent(event, isHistoryReplay: self.replayingGeoSubscriptionID == subID)
|
||||
// Gateway downlink: rebroadcast relay events for the viewed
|
||||
// channel onto the mesh (no-op unless gateway mode is on).
|
||||
GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash)
|
||||
}
|
||||
}
|
||||
}, onEOSE: { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.endHistoryReplay(for: subID)
|
||||
}
|
||||
})
|
||||
|
||||
if let dmSub = context.geoDmSubscriptionID {
|
||||
NostrRelayManager.shared.unsubscribe(id: dmSub)
|
||||
@ -219,7 +232,7 @@ final class GeohashSubscriptionManager {
|
||||
context.setActiveParticipantGeohash(channel.geohash)
|
||||
|
||||
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
||||
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex, seenAt: Date())
|
||||
let key = identity.publicKeyHex.lowercased()
|
||||
if context.isTeleported && context.isGeohashOutsideRegionalChannels(channel.geohash) {
|
||||
context.markGeoTeleported(key)
|
||||
@ -238,14 +251,20 @@ final class GeohashSubscriptionManager {
|
||||
let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds)
|
||||
let filter = NostrFilter.geohashEphemeral(channel.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit)
|
||||
let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in
|
||||
replayingGeoSubscriptionID = subID
|
||||
NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays, handler: { [weak self] event in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.inbound.handleNostrEvent(event)
|
||||
guard let self else { return }
|
||||
self.inbound.handleNostrEvent(event, isHistoryReplay: self.replayingGeoSubscriptionID == subID)
|
||||
// Gateway downlink: rebroadcast relay events for the viewed
|
||||
// channel onto the mesh (no-op unless gateway mode is on).
|
||||
GatewayService.shared.rebroadcastRelayEvent(event, geohash: channel.geohash)
|
||||
}
|
||||
}
|
||||
}, onEOSE: { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.endHistoryReplay(for: subID)
|
||||
}
|
||||
})
|
||||
|
||||
subscribeToGeoChat(channel)
|
||||
}
|
||||
@ -306,7 +325,7 @@ final class GeohashSubscriptionManager {
|
||||
)
|
||||
}
|
||||
|
||||
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex)
|
||||
context.recordGeoParticipant(pubkeyHex: identity.publicKeyHex, seenAt: Date())
|
||||
context.registerNostrKeyMapping(identity.publicKeyHex, for: PeerID(nostr: identity.publicKeyHex))
|
||||
SecureLogger.debug(
|
||||
"GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(geoContext.teleported)",
|
||||
@ -325,6 +344,12 @@ final class GeohashSubscriptionManager {
|
||||
context.recordProcessedNostrEvent(event.id)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func endHistoryReplay(for subscriptionID: String) {
|
||||
guard replayingGeoSubscriptionID == subscriptionID else { return }
|
||||
replayingGeoSubscriptionID = nil
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func beginGeohashSampling(for geohashes: [String]) {
|
||||
guard let context else { return }
|
||||
|
||||
@ -29,7 +29,10 @@ protocol NostrInboundPipelineContext: AnyObject {
|
||||
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String)
|
||||
/// Records the Nostr pubkey behind a (possibly virtual) peer ID.
|
||||
func registerNostrKeyMapping(_ pubkey: String, for peerID: PeerID)
|
||||
func recordGeoParticipant(pubkeyHex: String)
|
||||
/// `seenAt` is the event's own timestamp, so replayed history doesn't
|
||||
/// count its authors as present. Returns whether the activity counted.
|
||||
@discardableResult
|
||||
func recordGeoParticipant(pubkeyHex: String, seenAt: Date) -> Bool
|
||||
|
||||
// MARK: Inbound public messages
|
||||
/// `powBits` is the validated NIP-13 difficulty of the source event
|
||||
@ -70,11 +73,14 @@ extension ChatViewModel: NostrInboundPipelineContext {
|
||||
}
|
||||
|
||||
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) {
|
||||
locationPresenceStore.setNickname(nickname, for: pubkeyHex)
|
||||
if locationPresenceStore.setNickname(nickname, for: pubkeyHex) {
|
||||
participantTracker.noteDisplayNameChanged(pubkeyHex: pubkeyHex)
|
||||
}
|
||||
}
|
||||
|
||||
func recordGeoParticipant(pubkeyHex: String) {
|
||||
participantTracker.recordParticipant(pubkeyHex: pubkeyHex)
|
||||
@discardableResult
|
||||
func recordGeoParticipant(pubkeyHex: String, seenAt: Date) -> Bool {
|
||||
participantTracker.recordParticipant(pubkeyHex: pubkeyHex, seenAt: seenAt)
|
||||
}
|
||||
}
|
||||
|
||||
@ -115,8 +121,12 @@ final class NostrInboundPipeline {
|
||||
self.presence = presence
|
||||
}
|
||||
|
||||
/// `isHistoryReplay` marks events relays return from storage before EOSE.
|
||||
/// Those count as activity at the time they happened, so an hour of
|
||||
/// replayed history doesn't make its authors look present; live events
|
||||
/// count when they arrive, which keeps senders with skewed clocks visible.
|
||||
@MainActor
|
||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||
func subscribeNostrEvent(_ event: NostrEvent, isHistoryReplay: Bool = false) {
|
||||
guard let context else { return }
|
||||
// Cheap rejects (kind, dedup lookup) — duplicates dominate real
|
||||
// traffic. The signature was already verified (exactly once, off the
|
||||
@ -146,13 +156,21 @@ final class NostrInboundPipeline {
|
||||
|
||||
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey))
|
||||
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey))
|
||||
context.recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||
let countsAsPresence = context.recordGeoParticipant(
|
||||
pubkeyHex: event.pubkey,
|
||||
seenAt: isHistoryReplay ? Date(timeIntervalSince1970: TimeInterval(event.created_at)) : Date()
|
||||
)
|
||||
|
||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||
return
|
||||
}
|
||||
|
||||
if GeoPresenceTracker.hasTeleportTag(event) {
|
||||
// Teleport markers describe people present now and are pruned only
|
||||
// when the roster changes, so a tag on history too old to count must
|
||||
// not mark its absent author: the marker could still be there when
|
||||
// they came back. A teleported sender tags every message, so their
|
||||
// recent ones still mark them.
|
||||
if countsAsPresence, GeoPresenceTracker.hasTeleportTag(event) {
|
||||
let key = event.pubkey.lowercased()
|
||||
let isSelf: Bool = {
|
||||
if let gh = context.currentGeohash,
|
||||
@ -193,8 +211,9 @@ final class NostrInboundPipeline {
|
||||
}
|
||||
}
|
||||
|
||||
/// See `subscribeNostrEvent(_:isHistoryReplay:)` for `isHistoryReplay`.
|
||||
@MainActor
|
||||
func handleNostrEvent(_ event: NostrEvent) {
|
||||
func handleNostrEvent(_ event: NostrEvent, isHistoryReplay: Bool = false) {
|
||||
guard let context else { return }
|
||||
// Cheap rejects (kind, dedup lookup) — the signature was already
|
||||
// verified (exactly once, off the main actor) by NostrRelayManager.
|
||||
@ -231,25 +250,27 @@ final class NostrInboundPipeline {
|
||||
return false
|
||||
}()
|
||||
|
||||
if hasTeleportTag, !isSelf {
|
||||
presence.scheduleMarkPeerTeleported(event.pubkey.lowercased(), logged: true)
|
||||
}
|
||||
|
||||
context.recordGeoParticipant(pubkeyHex: event.pubkey)
|
||||
|
||||
if isSelf {
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
if Date().timeIntervalSince(eventTime) < 15 {
|
||||
return
|
||||
}
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
let seenAt = isHistoryReplay ? eventTime : Date()
|
||||
if isSelf, Date().timeIntervalSince(eventTime) < 15 {
|
||||
context.recordGeoParticipant(pubkeyHex: event.pubkey, seenAt: seenAt)
|
||||
return
|
||||
}
|
||||
|
||||
// Learn the nickname before recording the sender, so a newcomer
|
||||
// enters the roster under their name rather than anon#xxxx.
|
||||
if let nickTag = event.tags.first(where: { $0.first == "n" }), nickTag.count >= 2 {
|
||||
context.setGeoNickname(nickTag[1].trimmed, forPubkey: event.pubkey)
|
||||
}
|
||||
|
||||
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr_: event.pubkey))
|
||||
context.registerNostrKeyMapping(event.pubkey, for: PeerID(nostr: event.pubkey))
|
||||
let countsAsPresence = context.recordGeoParticipant(pubkeyHex: event.pubkey, seenAt: seenAt)
|
||||
|
||||
// Only activity that counts marks teleport; see subscribeNostrEvent.
|
||||
if hasTeleportTag, !isSelf, countsAsPresence {
|
||||
presence.scheduleMarkPeerTeleported(event.pubkey.lowercased(), logged: true)
|
||||
}
|
||||
|
||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||
return
|
||||
|
||||
@ -149,7 +149,14 @@ struct GeohashPeopleList: View, Equatable {
|
||||
}
|
||||
// Seed and update order outside result builder
|
||||
.onAppear {
|
||||
orderedIDs = PeopleListOrdering.stableOrder(previous: [], current: currentIDs)
|
||||
// The roster republishes only when membership or names
|
||||
// change, so its recency order can be stale. Rebuild once so
|
||||
// the sheet opens with the most recently active people first.
|
||||
peerListModel.refreshNow()
|
||||
orderedIDs = PeopleListOrdering.stableOrder(
|
||||
previous: [],
|
||||
current: peerListModel.geohashPeople.map(\.id)
|
||||
)
|
||||
}
|
||||
.onChange(of: currentIDs) { ids in
|
||||
let newOrder = PeopleListOrdering.stableOrder(previous: orderedIDs, current: ids)
|
||||
|
||||
@ -239,7 +239,17 @@ struct LocationChannelsSheet: View {
|
||||
SheetCloseButton { isPresented = false }
|
||||
}
|
||||
|
||||
/// Participant counts mean "active in the last five minutes": they change
|
||||
/// as people arrive and as quiet ones age out, neither of which publishes
|
||||
/// anything this sheet observes, so re-read them every few seconds while
|
||||
/// the sheet is open.
|
||||
private var channelList: some View {
|
||||
TimelineView(.periodic(from: .now, by: 5)) { _ in
|
||||
channelListContent
|
||||
}
|
||||
}
|
||||
|
||||
private var channelListContent: some View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
channelRow(title: Strings.meshTitle(peerListModel.reachableMeshPeerCount), subtitlePrefix: Strings.subtitlePrefix(geohash: "bluetooth", coverage: bluetoothRangeString()), isSelected: isMeshSelected, titleColor: standardBlue, titleBold: peerListModel.reachableMeshPeerCount > 0) {
|
||||
|
||||
@ -182,9 +182,6 @@ struct AppArchitectureTests {
|
||||
|
||||
#expect(store.geoNicknames == ["bbbbbb": "bob", "cccccc": "carol"])
|
||||
|
||||
store.retainGeoNicknames(keeping: Set(["CCCCCC"]))
|
||||
#expect(store.geoNicknames == ["cccccc": "carol"])
|
||||
|
||||
store.setCurrentGeohash("u4pruz")
|
||||
#expect(store.geoNicknames.isEmpty)
|
||||
}
|
||||
|
||||
@ -152,7 +152,10 @@ private final class MockChatNostrContext: ChatNostrContext {
|
||||
private(set) var refreshTimerStopCount = 0
|
||||
private(set) var activeParticipantGeohashes: [String?] = []
|
||||
private(set) var recordedParticipants: [String] = []
|
||||
private(set) var recordedParticipantTimes: [Date] = []
|
||||
private(set) var recordedSampledParticipants: [(pubkeyHex: String, geohash: String)] = []
|
||||
/// "nickname:<pubkey>" / "record:<pubkey>" in call order.
|
||||
private(set) var presenceCallLog: [String] = []
|
||||
private(set) var clearTeleportedGeoCount = 0
|
||||
private(set) var clearGeoNicknamesCount = 0
|
||||
var visiblePeople: [GeoPerson] = []
|
||||
@ -160,9 +163,15 @@ private final class MockChatNostrContext: ChatNostrContext {
|
||||
func startGeoParticipantRefreshTimer() { refreshTimerStartCount += 1 }
|
||||
func stopGeoParticipantRefreshTimer() { refreshTimerStopCount += 1 }
|
||||
func setActiveParticipantGeohash(_ geohash: String?) { activeParticipantGeohashes.append(geohash) }
|
||||
func recordGeoParticipant(pubkeyHex: String) { recordedParticipants.append(pubkeyHex) }
|
||||
func recordGeoParticipant(pubkeyHex: String, seenAt: Date) -> Bool {
|
||||
recordedParticipants.append(pubkeyHex)
|
||||
recordedParticipantTimes.append(seenAt)
|
||||
presenceCallLog.append("record:\(pubkeyHex)")
|
||||
// Like the tracker: activity older than the window doesn't count.
|
||||
return seenAt >= Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds)
|
||||
}
|
||||
|
||||
func recordGeoParticipant(pubkeyHex: String, geohash: String) {
|
||||
func recordGeoParticipant(pubkeyHex: String, geohash: String, seenAt: Date) {
|
||||
recordedSampledParticipants.append((pubkeyHex, geohash))
|
||||
}
|
||||
|
||||
@ -170,7 +179,10 @@ private final class MockChatNostrContext: ChatNostrContext {
|
||||
recordedSampledParticipants.filter { $0.geohash == geohash }.count
|
||||
}
|
||||
|
||||
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { geoNicknames[pubkeyHex.lowercased()] = nickname }
|
||||
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) {
|
||||
geoNicknames[pubkeyHex.lowercased()] = nickname
|
||||
presenceCallLog.append("nickname:\(pubkeyHex)")
|
||||
}
|
||||
func markGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.insert(pubkeyHexLowercased) }
|
||||
func clearGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.remove(pubkeyHexLowercased) }
|
||||
|
||||
@ -239,6 +251,18 @@ private func drainMainQueue() async {
|
||||
}
|
||||
}
|
||||
|
||||
/// A signed geohash message carrying the teleport tag, sent at `sentAt`
|
||||
/// (the `NostrProtocol` builder always stamps now).
|
||||
private func teleportedGeohashEvent(from sender: NostrIdentity, sentAt: Date) throws -> NostrEvent {
|
||||
try NostrEvent(
|
||||
pubkey: sender.publicKeyHex,
|
||||
createdAt: sentAt,
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", "u4pruyd"], ["t", "teleport"]],
|
||||
content: "from earlier"
|
||||
).sign(with: sender.schnorrSigningKey())
|
||||
}
|
||||
|
||||
// MARK: - Coordinator Tests Against Mock Context
|
||||
|
||||
/// Exercises `ChatNostrCoordinator` against `MockChatNostrContext` with no
|
||||
@ -334,6 +358,104 @@ struct ChatNostrCoordinatorContextTests {
|
||||
#expect(context.handledPublicMessages.isEmpty)
|
||||
}
|
||||
|
||||
/// A newcomer's nickname is known before they're recorded, so they enter
|
||||
/// the roster under their name. Live events count as activity when they
|
||||
/// arrive (keeping senders with skewed clocks visible).
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_learnsTheNicknameBeforeRecordingTheSender() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
|
||||
let sender = try NostrIdentity.generate()
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: "hello",
|
||||
geohash: "u4pruyd",
|
||||
senderIdentity: sender,
|
||||
nickname: "alice"
|
||||
)
|
||||
|
||||
let beforeArrival = Date()
|
||||
coordinator.inbound.handleNostrEvent(event)
|
||||
await drainMainQueue()
|
||||
|
||||
#expect(context.presenceCallLog == ["nickname:\(event.pubkey)", "record:\(event.pubkey)"])
|
||||
#expect(context.recordedParticipantTimes.first.map { $0 >= beforeArrival } == true)
|
||||
}
|
||||
|
||||
/// Stored history relays replay before EOSE counts as activity at the
|
||||
/// time it happened, so joining doesn't mark an hour of authors present.
|
||||
@Test @MainActor
|
||||
func handleNostrEvent_stampsReplayedHistoryWithTheEventTime() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
|
||||
let sender = try NostrIdentity.generate()
|
||||
let event = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: "from earlier",
|
||||
geohash: "u4pruyd",
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
coordinator.inbound.handleNostrEvent(event, isHistoryReplay: true)
|
||||
await drainMainQueue()
|
||||
|
||||
#expect(context.recordedParticipantTimes == [Date(timeIntervalSince1970: TimeInterval(event.created_at))])
|
||||
}
|
||||
|
||||
/// A teleport tag on replayed history too old to count as presence must
|
||||
/// not mark its author, on either inbound path: markers are pruned only
|
||||
/// as the roster changes, so one set for someone absent would still be
|
||||
/// there when they came back. Recent history still marks.
|
||||
@Test @MainActor
|
||||
func replayedTeleportTag_marksOnlyAuthorsWhoseActivityStillCounts() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
|
||||
let longGone = try NostrIdentity.generate()
|
||||
let alsoLongGone = try NostrIdentity.generate()
|
||||
let stillHere = try NostrIdentity.generate()
|
||||
// Ten minutes old: past the five-minute activity window.
|
||||
let expired = try teleportedGeohashEvent(from: longGone, sentAt: Date().addingTimeInterval(-600))
|
||||
let expiredOnResubscribe = try teleportedGeohashEvent(from: alsoLongGone, sentAt: Date().addingTimeInterval(-600))
|
||||
let recent = try teleportedGeohashEvent(from: stillHere, sentAt: Date().addingTimeInterval(-60))
|
||||
|
||||
coordinator.inbound.handleNostrEvent(expired, isHistoryReplay: true)
|
||||
coordinator.inbound.subscribeNostrEvent(expiredOnResubscribe, isHistoryReplay: true)
|
||||
coordinator.inbound.handleNostrEvent(recent, isHistoryReplay: true)
|
||||
await drainMainQueue()
|
||||
|
||||
#expect(context.teleportedKeys == [stillHere.publicKeyHex.lowercased()])
|
||||
}
|
||||
|
||||
/// The open channel is usually also a sampled cell. Its own subscription
|
||||
/// records senders, so the sampled copy must not record them again
|
||||
/// (that ran the roster bookkeeping twice per event); other sampled
|
||||
/// cells still count.
|
||||
@Test @MainActor
|
||||
func presenceSampling_skipsTheOpenChannelButCountsOtherCells() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
let coordinator = ChatNostrCoordinator(context: context)
|
||||
context.activeChannel = .location(GeohashChannel(level: .neighborhood, geohash: "u4pruy"))
|
||||
|
||||
let sender = try NostrIdentity.generate()
|
||||
let inOpenChannel = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: "here",
|
||||
geohash: "u4pruy",
|
||||
senderIdentity: sender
|
||||
)
|
||||
let elsewhere = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: "there",
|
||||
geohash: "u4pruz",
|
||||
senderIdentity: sender
|
||||
)
|
||||
|
||||
coordinator.presence.subscribeNostrEvent(inOpenChannel, gh: "u4pruy")
|
||||
coordinator.presence.subscribeNostrEvent(elsewhere, gh: "u4pruz")
|
||||
await drainMainQueue()
|
||||
|
||||
#expect(context.recordedSampledParticipants.map(\.geohash) == ["u4pruz"])
|
||||
}
|
||||
|
||||
@Test @MainActor
|
||||
func handleGiftWrap_routesEmbeddedPrivateMessageAndDeduplicates() async throws {
|
||||
let context = MockChatNostrContext()
|
||||
|
||||
102
bitchatTests/ChatViewModelBootstrapperTests.swift
Normal file
102
bitchatTests/ChatViewModelBootstrapperTests.swift
Normal file
@ -0,0 +1,102 @@
|
||||
//
|
||||
// ChatViewModelBootstrapperTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
struct ChatViewModelBootstrapperTests {
|
||||
|
||||
/// Nicknames name the authors of messages on screen, including replayed
|
||||
/// history from people no longer present. Roster publishes prune presence
|
||||
/// state (teleport markers) but must keep those names resolvable for
|
||||
/// /msg, /block, autocomplete and DM headers.
|
||||
@Test func rosterPublishesKeepNicknamesOfAuthorsWhoAreNotPresent() async {
|
||||
let viewModel = makeViewModel()
|
||||
// The initial (mesh) channel arrives asynchronously and resets the
|
||||
// tracker; let startup settle first.
|
||||
await drainMainQueue()
|
||||
|
||||
let geohash = "u4pruyd"
|
||||
viewModel.currentGeohash = geohash
|
||||
viewModel.participantTracker.setActiveGeohash(geohash)
|
||||
let author = String(repeating: "a", count: 64)
|
||||
// A replayed message from ten minutes ago: named, but not present.
|
||||
viewModel.setGeoNickname("troll", forPubkey: author)
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: author, seenAt: Date().addingTimeInterval(-600))
|
||||
|
||||
// Someone else arrives, so the roster publishes and the prune runs.
|
||||
viewModel.participantTracker.recordParticipant(pubkeyHex: String(repeating: "b", count: 64))
|
||||
await drainMainQueue()
|
||||
|
||||
#expect(viewModel.geoNicknames[author] == "troll")
|
||||
}
|
||||
|
||||
/// Teleport markers follow the roster, which prunes them only as it
|
||||
/// changes. A teleport tag replayed from before the activity window must
|
||||
/// not mark its absent author: the marker would wait for them, and when
|
||||
/// they came back, no longer teleported, they'd still be badged.
|
||||
@Test func expiredReplayedTeleportTagDoesNotFollowItsAuthorBackIn() async throws {
|
||||
let viewModel = makeViewModel()
|
||||
await drainMainQueue()
|
||||
|
||||
let geohash = "u4pruyd"
|
||||
viewModel.currentGeohash = geohash
|
||||
viewModel.participantTracker.setActiveGeohash(geohash)
|
||||
let sender = try NostrIdentity.generate()
|
||||
// Replayed on join: a message they sent teleported, ten minutes ago.
|
||||
let replayed = try NostrEvent(
|
||||
pubkey: sender.publicKeyHex,
|
||||
createdAt: Date().addingTimeInterval(-600),
|
||||
kind: .ephemeralEvent,
|
||||
tags: [["g", geohash], ["t", "teleport"]],
|
||||
content: "from earlier"
|
||||
).sign(with: sender.schnorrSigningKey())
|
||||
viewModel.nostrCoordinator.inbound.handleNostrEvent(replayed, isHistoryReplay: true)
|
||||
await drainMainQueue()
|
||||
|
||||
// They come back, live and local.
|
||||
let live = try NostrProtocol.createEphemeralGeohashEvent(
|
||||
content: "back",
|
||||
geohash: geohash,
|
||||
senderIdentity: sender
|
||||
)
|
||||
viewModel.nostrCoordinator.inbound.handleNostrEvent(live)
|
||||
await drainMainQueue()
|
||||
|
||||
let key = sender.publicKeyHex.lowercased()
|
||||
#expect(viewModel.participantTracker.visiblePeople.map(\.id) == [key])
|
||||
#expect(!viewModel.teleportedGeo.contains(key))
|
||||
}
|
||||
|
||||
/// A private location manager: the shared one is switched by other
|
||||
/// suites running in parallel, and a switch clears nicknames and
|
||||
/// teleport markers.
|
||||
private func makeViewModel() -> ChatViewModel {
|
||||
let keychain = MockKeychain()
|
||||
let locationSuite = "ChatViewModelBootstrapperTests.\(UUID().uuidString)"
|
||||
let locationStorage = UserDefaults(suiteName: locationSuite) ?? .standard
|
||||
locationStorage.removePersistentDomain(forName: locationSuite)
|
||||
return ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()),
|
||||
identityManager: MockIdentityManager(keychain),
|
||||
transport: MockTransport(),
|
||||
locationManager: LocationChannelManager(storage: locationStorage)
|
||||
)
|
||||
}
|
||||
|
||||
private func drainMainQueue() async {
|
||||
for _ in 0..<4 {
|
||||
await withCheckedContinuation { continuation in
|
||||
DispatchQueue.main.async { continuation.resume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -292,6 +292,8 @@ struct CommandProcessorTests {
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
#expect(identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
|
||||
// The roster re-filters now rather than on its next timer tick.
|
||||
#expect(context.geohashBlockListChangeCount == 1)
|
||||
|
||||
let unblockResult = await withSelectedChannel(.mesh, context: context) {
|
||||
processor.process("/unblock @carol")
|
||||
@ -303,6 +305,7 @@ struct CommandProcessorTests {
|
||||
Issue.record("Expected success result")
|
||||
}
|
||||
#expect(!identityManager.isNostrBlocked(pubkeyHexLowercased: String(repeating: "d", count: 64)))
|
||||
#expect(context.geohashBlockListChangeCount == 2)
|
||||
}
|
||||
|
||||
/// /fav must go through toggleFavorite (which persists by the real noise
|
||||
@ -608,6 +611,7 @@ private final class MockCommandContextProvider: CommandContextProvider {
|
||||
private(set) var commandOutputDestinations: [CommandOutputDestination] = []
|
||||
private(set) var toggledFavorites: [PeerID] = []
|
||||
private(set) var favoriteNotifications: [(peerID: PeerID, isFavorite: Bool)] = []
|
||||
private(set) var geohashBlockListChangeCount = 0
|
||||
|
||||
init(nickname: String = "tester", idBridge: NostrIdentityBridge = NostrIdentityBridge(keychain: MockKeychain())) {
|
||||
self.nickname = nickname
|
||||
@ -626,6 +630,10 @@ private final class MockCommandContextProvider: CommandContextProvider {
|
||||
displayNameToNostrPubkey[displayName]
|
||||
}
|
||||
|
||||
func geohashBlockListDidChange() {
|
||||
geohashBlockListChangeCount += 1
|
||||
}
|
||||
|
||||
func startPrivateChat(with peerID: PeerID) {
|
||||
startedPrivateChats.append(peerID)
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import bitchat
|
||||
@ -320,6 +321,141 @@ struct GeohashParticipantTrackerTests {
|
||||
#expect(tracker.visibleMembershipRevision != afterRemoval)
|
||||
}
|
||||
|
||||
// MARK: - Activity Semantics
|
||||
|
||||
@Test func recordParticipant_ignoresActivityOlderThanTheWindow() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
|
||||
var changes = 0
|
||||
let cancellable = tracker.objectWillChange.sink { _ in changes += 1 }
|
||||
// Replayed history from ten minutes ago doesn't make its author present
|
||||
// (nor announce a count change or rebuild the roster).
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1", seenAt: Date().addingTimeInterval(-600))
|
||||
|
||||
#expect(tracker.participantCount(for: "abc123") == 0)
|
||||
#expect(tracker.visiblePeople.isEmpty)
|
||||
#expect(changes == 0)
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
/// The inbound pipeline marks a teleport tag only when the activity
|
||||
/// carrying it counted. Activity that makes no one present must report
|
||||
/// false, or its tag leaves a marker the roster never prunes; activity
|
||||
/// from someone already present must report true, or their live teleport
|
||||
/// tags would stop marking them.
|
||||
@Test func recordParticipant_reportsWhetherTheActivityCounted() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
let now = Date()
|
||||
|
||||
// No channel open: nothing is recorded.
|
||||
#expect(!tracker.recordParticipant(pubkeyHex: "pubkey1"))
|
||||
|
||||
tracker.setActiveGeohash("abc123")
|
||||
#expect(!tracker.recordParticipant(pubkeyHex: "pubkey1", seenAt: now.addingTimeInterval(-600)))
|
||||
// Arriving, then a heartbeat from someone already present.
|
||||
#expect(tracker.recordParticipant(pubkeyHex: "pubkey1", seenAt: now.addingTimeInterval(-60)))
|
||||
#expect(tracker.recordParticipant(pubkeyHex: "pubkey1"))
|
||||
// A late copy of earlier activity that is still inside the window.
|
||||
#expect(tracker.recordParticipant(pubkeyHex: "pubkey1", seenAt: now.addingTimeInterval(-200)))
|
||||
#expect(tracker.participantCount(for: "abc123") == 1)
|
||||
}
|
||||
|
||||
@Test func recordParticipant_neverMovesActivityBackwards() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
let now = Date()
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "older", seenAt: now.addingTimeInterval(-120))
|
||||
tracker.recordParticipant(pubkeyHex: "newer", seenAt: now.addingTimeInterval(-60))
|
||||
// A late copy of an earlier event from "newer" must not demote them.
|
||||
tracker.recordParticipant(pubkeyHex: "newer", seenAt: now.addingTimeInterval(-200))
|
||||
|
||||
#expect(tracker.getVisiblePeople().map(\.id) == ["newer", "older"])
|
||||
}
|
||||
|
||||
@Test func recordParticipant_clampsFutureTimestampsToNow() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
|
||||
tracker.recordParticipant(pubkeyHex: "skewed", seenAt: Date().addingTimeInterval(3600))
|
||||
|
||||
#expect(tracker.getVisiblePeople().first.map { $0.lastSeen <= Date() } == true)
|
||||
}
|
||||
|
||||
@Test func repeatActivity_doesNotRepublishTheRoster() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey2")
|
||||
|
||||
var publishes = 0
|
||||
let cancellable = tracker.$visiblePeople.dropFirst().sink { _ in publishes += 1 }
|
||||
// Heartbeats and messages from people already present.
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey2")
|
||||
tracker.refresh()
|
||||
|
||||
#expect(publishes == 0)
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
@Test func displayNameChange_republishesTheRoster() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
tracker.setActiveGeohash("abc123")
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1")
|
||||
#expect(tracker.visiblePeople.first?.displayName.hasPrefix("anon#") == true)
|
||||
|
||||
context.nicknameMap["pubkey1"] = "alice"
|
||||
tracker.noteDisplayNameChanged(pubkeyHex: "pubkey1")
|
||||
|
||||
#expect(tracker.visiblePeople.first?.displayName.hasPrefix("alice#") == true)
|
||||
}
|
||||
|
||||
@Test func pruneExpired_dropsEntriesThatAgedOut() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
let now = Date()
|
||||
tracker.recordParticipant(pubkeyHex: "quiet", geohash: "geo1", seenAt: now.addingTimeInterval(-200))
|
||||
tracker.recordParticipant(pubkeyHex: "chatty", geohash: "geo1", seenAt: now)
|
||||
tracker.recordParticipant(pubkeyHex: "gone", geohash: "geo2", seenAt: now.addingTimeInterval(-200))
|
||||
|
||||
// Two minutes on, "quiet" and "gone" are past the five-minute window.
|
||||
tracker.pruneExpired(now: now.addingTimeInterval(120))
|
||||
|
||||
#expect(tracker.participantCount(for: "geo1") == 1)
|
||||
#expect(tracker.participantCount(for: "geo2") == 0)
|
||||
}
|
||||
|
||||
@Test func objectWillChange_firesOnlyWhenSomeoneNewlyCounts() async {
|
||||
let tracker = GeohashParticipantTracker()
|
||||
let context = MockParticipantContext()
|
||||
tracker.configure(context: context)
|
||||
|
||||
var changes = 0
|
||||
let cancellable = tracker.objectWillChange.sink { _ in changes += 1 }
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
|
||||
let afterJoin = changes
|
||||
tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1")
|
||||
|
||||
#expect(afterJoin >= 1)
|
||||
#expect(changes == afterJoin)
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
// MARK: - GeoPerson Tests
|
||||
|
||||
@Test func geoPerson_identifiable() async {
|
||||
|
||||
44
bitchatTests/LocationPresenceStoreTests.swift
Normal file
44
bitchatTests/LocationPresenceStoreTests.swift
Normal file
@ -0,0 +1,44 @@
|
||||
//
|
||||
// LocationPresenceStoreTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
struct LocationPresenceStoreTests {
|
||||
|
||||
@Test func setNickname_reportsWhetherTheNameChanged() {
|
||||
let store = LocationPresenceStore()
|
||||
|
||||
#expect(store.setNickname("alice", for: "AA") == true)
|
||||
// Chat events repeat the sender's nickname every time.
|
||||
#expect(store.setNickname("alice", for: "aa") == false)
|
||||
#expect(store.setNickname("alicia", for: "aa") == true)
|
||||
#expect(store.geoNicknames["aa"] == "alicia")
|
||||
}
|
||||
|
||||
/// Every write republishes, and the people list rebuilds on these
|
||||
/// publishers, so pruning that removes nothing must not write.
|
||||
@Test func pruningThatRemovesNothingDoesNotRepublish() {
|
||||
let store = LocationPresenceStore()
|
||||
store.markTeleported("aa")
|
||||
store.setNickname("alice", for: "aa")
|
||||
|
||||
var changes = 0
|
||||
let cancellable = store.objectWillChange.sink { _ in changes += 1 }
|
||||
store.retainTeleportedGeo(keeping: ["aa", "bb"])
|
||||
store.clearTeleported("cc")
|
||||
#expect(changes == 0)
|
||||
|
||||
store.retainTeleportedGeo(keeping: ["bb"])
|
||||
#expect(changes == 1)
|
||||
#expect(store.teleportedGeo.isEmpty)
|
||||
_ = cancellable
|
||||
}
|
||||
}
|
||||
113
bitchatTests/PeerListModelTests.swift
Normal file
113
bitchatTests/PeerListModelTests.swift
Normal file
@ -0,0 +1,113 @@
|
||||
//
|
||||
// PeerListModelTests.swift
|
||||
// bitchatTests
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
// For more information, see <https://unlicense.org>
|
||||
//
|
||||
|
||||
import BitFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import bitchat
|
||||
|
||||
@MainActor
|
||||
struct PeerListModelTests {
|
||||
|
||||
/// Every @Published write re-renders every view observing the model
|
||||
/// (root view, header, open people sheet), so a refresh trigger that
|
||||
/// leaves the rows as they were must not write anything.
|
||||
@Test func refreshThatChangesNothingDoesNotRepublish() async {
|
||||
let viewModel = makeViewModel()
|
||||
let model = PeerListModel(
|
||||
chatViewModel: viewModel,
|
||||
conversations: viewModel.conversations,
|
||||
locationChannelsModel: LocationChannelsModel(manager: makeLocationManager())
|
||||
)
|
||||
await drainMainQueue()
|
||||
|
||||
var changes = 0
|
||||
let cancellable = model.objectWillChange.sink { _ in changes += 1 }
|
||||
let roster = viewModel.allPeers
|
||||
for _ in 0..<5 {
|
||||
NotificationCenter.default.post(name: Notification.Name("peerStatusUpdated"), object: nil)
|
||||
// Every mesh snapshot republishes the roster, usually unchanged.
|
||||
viewModel.allPeers = roster
|
||||
}
|
||||
await drainMainQueue()
|
||||
|
||||
#expect(changes == 0)
|
||||
_ = cancellable
|
||||
}
|
||||
|
||||
/// `BitchatPeer.==` compares only peer IDs, so a roster in which someone
|
||||
/// changed name, connection or favorite compares equal to the previous
|
||||
/// one. Their row must still be rebuilt from it.
|
||||
@Test func rosterChangeWithTheSameIDsUpdatesTheRow() async throws {
|
||||
let transport = MockTransport()
|
||||
let viewModel = makeViewModel(transport: transport)
|
||||
let model = PeerListModel(
|
||||
chatViewModel: viewModel,
|
||||
conversations: viewModel.conversations,
|
||||
locationChannelsModel: LocationChannelsModel(manager: makeLocationManager())
|
||||
)
|
||||
let peerID = PeerID(str: "00000000000000c7")
|
||||
let noiseKey = Data(repeating: 0xC7, count: 32)
|
||||
func snapshots(nickname: String) -> [TransportPeerSnapshot] {
|
||||
[TransportPeerSnapshot(
|
||||
peerID: peerID,
|
||||
nickname: nickname,
|
||||
isConnected: true,
|
||||
noisePublicKey: noiseKey,
|
||||
lastSeen: Date()
|
||||
)]
|
||||
}
|
||||
|
||||
// Through the transport rather than assigning `viewModel.allPeers`:
|
||||
// anything else that rebuilds the roster (a favorites change from a
|
||||
// parallel test) then yields these same peers.
|
||||
transport.updatePeerSnapshots(snapshots(nickname: "alice"))
|
||||
let listed = await TestHelpers.waitUntil({
|
||||
model.meshRows.contains { $0.peerID == peerID && $0.displayName == "alice" }
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
try #require(listed)
|
||||
|
||||
// Same peer, same place in the roster, new name.
|
||||
transport.updatePeerSnapshots(snapshots(nickname: "alicia"))
|
||||
let renamed = await TestHelpers.waitUntil({
|
||||
model.meshRows.contains { $0.peerID == peerID && $0.displayName == "alicia" }
|
||||
}, timeout: TestConstants.settleTimeout)
|
||||
#expect(renamed)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func makeViewModel(transport: MockTransport = MockTransport()) -> ChatViewModel {
|
||||
let keychain = MockKeychain()
|
||||
return ChatViewModel(
|
||||
keychain: keychain,
|
||||
idBridge: NostrIdentityBridge(keychain: MockKeychainHelper()),
|
||||
identityManager: MockIdentityManager(keychain),
|
||||
transport: transport,
|
||||
locationManager: makeLocationManager()
|
||||
)
|
||||
}
|
||||
|
||||
private func makeLocationManager() -> LocationChannelManager {
|
||||
let suiteName = "PeerListModelTests.\(UUID().uuidString)"
|
||||
let storage = UserDefaults(suiteName: suiteName) ?? .standard
|
||||
storage.removePersistentDomain(forName: suiteName)
|
||||
return LocationChannelManager(storage: storage)
|
||||
}
|
||||
|
||||
/// The model's triggers hop to the main queue and then coalesce onto one
|
||||
/// more hop, so let a few main-queue turns run.
|
||||
private func drainMainQueue() async {
|
||||
for _ in 0..<4 {
|
||||
await withCheckedContinuation { continuation in
|
||||
DispatchQueue.main.async { continuation.resume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -712,8 +712,11 @@ private final class PerfNostrContext: ChatNostrContext {
|
||||
func stopGeoParticipantRefreshTimer() {}
|
||||
func setActiveParticipantGeohash(_ geohash: String?) {}
|
||||
private(set) var participantRecords = 0
|
||||
func recordGeoParticipant(pubkeyHex: String) { participantRecords += 1 }
|
||||
func recordGeoParticipant(pubkeyHex: String, geohash: String) { participantRecords += 1 }
|
||||
func recordGeoParticipant(pubkeyHex: String, seenAt: Date) -> Bool {
|
||||
participantRecords += 1
|
||||
return true
|
||||
}
|
||||
func recordGeoParticipant(pubkeyHex: String, geohash: String, seenAt: Date) { participantRecords += 1 }
|
||||
func geoParticipantCount(for geohash: String) -> Int { 0 }
|
||||
func setGeoNickname(_ nickname: String, forPubkey pubkeyHex: String) { geoNicknames[pubkeyHex.lowercased()] = nickname }
|
||||
func markGeoTeleported(_ pubkeyHexLowercased: String) { teleportedKeys.insert(pubkeyHexLowercased) }
|
||||
|
||||
@ -271,6 +271,7 @@ private final class DiagnosticsMockContext: CommandContextProvider {
|
||||
|
||||
func getVisibleGeoParticipants() -> [CommandGeoParticipant] { [] }
|
||||
func nostrPubkeyForDisplayName(_ displayName: String) -> String? { nil }
|
||||
func geohashBlockListDidChange() {}
|
||||
func startPrivateChat(with peerID: PeerID) {}
|
||||
func sendPrivateMessage(_ content: String, to peerID: PeerID) {}
|
||||
func clearCurrentPublicTimeline() {}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user