diff --git a/bitchat/App/LocationPresenceStore.swift b/bitchat/App/LocationPresenceStore.swift index b67731ba..26ec4131 100644 --- a/bitchat/App/LocationPresenceStore.swift +++ b/bitchat/App/LocationPresenceStore.swift @@ -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) { - 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) { let allowed = Set(pubkeys.map { $0.lowercased() }) + guard !teleportedGeo.isSubset(of: allowed) else { return } teleportedGeoOrder = teleportedGeoOrder.filter { allowed.contains($0) } teleportedGeo = teleportedGeo.intersection(allowed) } diff --git a/bitchat/App/PeerListModel.swift b/bitchat/App/PeerListModel.swift index fd41ce32..67235689 100644 --- a/bitchat/App/PeerListModel.swift +++ b/bitchat/App/PeerListModel.swift @@ -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() + 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: diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index c37aa3dc..7b8d1bf2 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -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)) diff --git a/bitchat/Services/GeohashParticipantTracker.swift b/bitchat/Services/GeohashParticipantTracker.swift index 75bceb8b..f5f7e39e 100644 --- a/bitchat/Services/GeohashParticipantTracker.swift +++ b/bitchat/Services/GeohashParticipantTracker.swift @@ -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() } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 9e010dbd..29c3bc0f 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -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() diff --git a/bitchat/ViewModels/ChatViewModelBootstrapper.swift b/bitchat/ViewModels/ChatViewModelBootstrapper.swift index 3ccb7d12..14de9c9b 100644 --- a/bitchat/ViewModels/ChatViewModelBootstrapper.swift +++ b/bitchat/ViewModels/ChatViewModelBootstrapper.swift @@ -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) diff --git a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift index 8205e13b..7b5a3562 100644 --- a/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift +++ b/bitchat/ViewModels/Extensions/ChatViewModel+Nostr.swift @@ -80,6 +80,11 @@ extension ChatViewModel { nostrCoordinator.nostrPubkeyForDisplayName(name) } + @MainActor + func geohashBlockListDidChange() { + participantTracker.refresh() + } + @MainActor func startGeohashDM(withPubkeyHex hex: String) { nostrCoordinator.startGeohashDM(withPubkeyHex: hex) diff --git a/bitchat/ViewModels/GeoPresenceTracker.swift b/bitchat/ViewModels/GeoPresenceTracker.swift index c2f89b79..bbcf0969 100644 --- a/bitchat/ViewModels/GeoPresenceTracker.swift +++ b/bitchat/ViewModels/GeoPresenceTracker.swift @@ -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 } diff --git a/bitchat/ViewModels/GeohashSubscriptionManager.swift b/bitchat/ViewModels/GeohashSubscriptionManager.swift index ce4c9448..930cc549 100644 --- a/bitchat/ViewModels/GeohashSubscriptionManager.swift +++ b/bitchat/ViewModels/GeohashSubscriptionManager.swift @@ -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() + /// 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 } diff --git a/bitchat/ViewModels/NostrInboundPipeline.swift b/bitchat/ViewModels/NostrInboundPipeline.swift index a6592802..535f1ae5 100644 --- a/bitchat/ViewModels/NostrInboundPipeline.swift +++ b/bitchat/ViewModels/NostrInboundPipeline.swift @@ -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 diff --git a/bitchat/Views/GeohashPeopleList.swift b/bitchat/Views/GeohashPeopleList.swift index d93731d1..bfcc5b60 100644 --- a/bitchat/Views/GeohashPeopleList.swift +++ b/bitchat/Views/GeohashPeopleList.swift @@ -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) diff --git a/bitchat/Views/LocationChannelsSheet.swift b/bitchat/Views/LocationChannelsSheet.swift index dcc2bc84..b90d6261 100644 --- a/bitchat/Views/LocationChannelsSheet.swift +++ b/bitchat/Views/LocationChannelsSheet.swift @@ -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) { diff --git a/bitchatTests/AppArchitectureTests.swift b/bitchatTests/AppArchitectureTests.swift index 747e737b..574f66cc 100644 --- a/bitchatTests/AppArchitectureTests.swift +++ b/bitchatTests/AppArchitectureTests.swift @@ -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) } diff --git a/bitchatTests/ChatNostrCoordinatorContextTests.swift b/bitchatTests/ChatNostrCoordinatorContextTests.swift index 74b0d0e2..ffe22d9f 100644 --- a/bitchatTests/ChatNostrCoordinatorContextTests.swift +++ b/bitchatTests/ChatNostrCoordinatorContextTests.swift @@ -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:" / "record:" 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() diff --git a/bitchatTests/ChatViewModelBootstrapperTests.swift b/bitchatTests/ChatViewModelBootstrapperTests.swift new file mode 100644 index 00000000..2db81e24 --- /dev/null +++ b/bitchatTests/ChatViewModelBootstrapperTests.swift @@ -0,0 +1,102 @@ +// +// ChatViewModelBootstrapperTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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() } + } + } + } +} diff --git a/bitchatTests/CommandProcessorTests.swift b/bitchatTests/CommandProcessorTests.swift index 232aa265..183dcccb 100644 --- a/bitchatTests/CommandProcessorTests.swift +++ b/bitchatTests/CommandProcessorTests.swift @@ -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) } diff --git a/bitchatTests/GeohashParticipantTrackerTests.swift b/bitchatTests/GeohashParticipantTrackerTests.swift index 0c76a30c..f04e6aec 100644 --- a/bitchatTests/GeohashParticipantTrackerTests.swift +++ b/bitchatTests/GeohashParticipantTrackerTests.swift @@ -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 { diff --git a/bitchatTests/LocationPresenceStoreTests.swift b/bitchatTests/LocationPresenceStoreTests.swift new file mode 100644 index 00000000..42d731b7 --- /dev/null +++ b/bitchatTests/LocationPresenceStoreTests.swift @@ -0,0 +1,44 @@ +// +// LocationPresenceStoreTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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 + } +} diff --git a/bitchatTests/PeerListModelTests.swift b/bitchatTests/PeerListModelTests.swift new file mode 100644 index 00000000..d7b32017 --- /dev/null +++ b/bitchatTests/PeerListModelTests.swift @@ -0,0 +1,113 @@ +// +// PeerListModelTests.swift +// bitchatTests +// +// This is free and unencumbered software released into the public domain. +// For more information, see +// + +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() } + } + } + } +} diff --git a/bitchatTests/Performance/PerformanceBaselineTests.swift b/bitchatTests/Performance/PerformanceBaselineTests.swift index 453a4a9a..763f172a 100644 --- a/bitchatTests/Performance/PerformanceBaselineTests.swift +++ b/bitchatTests/Performance/PerformanceBaselineTests.swift @@ -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) } diff --git a/bitchatTests/Services/MeshDiagnosticsTests.swift b/bitchatTests/Services/MeshDiagnosticsTests.swift index b565840d..455efbe9 100644 --- a/bitchatTests/Services/MeshDiagnosticsTests.swift +++ b/bitchatTests/Services/MeshDiagnosticsTests.swift @@ -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() {}