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:
jack 2026-10-23 20:23:39 +02:00 committed by GitHub
parent 5fa0d3e733
commit 14c6b0b47d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 808 additions and 100 deletions

View File

@ -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)
}

View File

@ -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:

View File

@ -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))

View File

@ -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()
}
}

View File

@ -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()

View File

@ -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)

View File

@ -80,6 +80,11 @@ extension ChatViewModel {
nostrCoordinator.nostrPubkeyForDisplayName(name)
}
@MainActor
func geohashBlockListDidChange() {
participantTracker.refresh()
}
@MainActor
func startGeohashDM(withPubkeyHex hex: String) {
nostrCoordinator.startGeohashDM(withPubkeyHex: hex)

View File

@ -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 }

View File

@ -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 }

View File

@ -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

View File

@ -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)

View File

@ -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) {

View File

@ -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)
}

View File

@ -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()

View 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() }
}
}
}
}

View File

@ -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)
}

View File

@ -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 {

View 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
}
}

View 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() }
}
}
}
}

View File

@ -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) }

View File

@ -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() {}