Files
bitchat/bitchat/Services/GeohashParticipantTracker.swift
T
ef848857b7 Remove dead code found by full Periphery audit; add scan config + advisory CI (#1410)
Periphery 3.7.4 audit of both schemes (macOS + iOS, intersected so
platform-specific code is never touched), with test targets indexed and
the share extension built. 277 dead declarations removed or demoted:
dead forwarding wrappers (ChatViewModel+Nostr/+PrivateChat), removed-
feature remnants (autocomplete command suggestions, back-swipe tuning,
MediaSendError, GeohashParticipantTracker), unused Tor dormancy
bindings, assign-only properties, unused parameters (renamed to _), and
redundant public accessibility. 13 orphaned localization keys deleted
across all 29 locales (old pre-#1392 location-notes UI, app_info
warnings).

Two real tests were flagged as unused because they never ran: Swift
Testing methods missing @Test (NostrProtocolTests.
testAckRoundTripNIP44V2_Delivered, NotificationStreamAssemblerTests.
testAssemblesCompressedLargeFrame). Re-armed both; they pass.

Deliberately kept, now recorded in .periphery.baseline.json: iOS-only
code invisible to the CI macOS scan, C FFI signatures, keep-alive
NWPathMonitor reference, InboundEventKey.eventID (dedup semantics),
wifiBulk capability bit (reserved for Wi-Fi bulk work, used by
BitFoundation package tests), and the String secureClear cluster
(exercised by package tests).

New: .periphery.yml config and an advisory Dead Code CI job (mirrors
the SwiftLint precedent from #1361) that fails on findings not in the
committed baseline.

Verified: full macOS app suite, BitFoundation (119) and BitLogger (13)
package tests green; periphery scan --strict exits clean.

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 11:24:19 +02:00

163 lines
5.0 KiB
Swift

//
// GeohashParticipantTracker.swift
// bitchat
//
// Tracks participants in geohash-based location channels.
// This is free and unencumbered software released into the public domain.
//
import Foundation
/// Represents a participant in a geohash channel
struct GeoPerson: Identifiable, Equatable, Sendable {
let id: String // pubkey hex (lowercased)
let displayName: String
let lastSeen: Date
init(id: String, displayName: String, lastSeen: Date) {
self.id = id
self.displayName = displayName
self.lastSeen = lastSeen
}
}
/// Protocol for resolving display names and checking block status
@MainActor
protocol GeohashParticipantContext: AnyObject {
/// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4")
func displayNameForPubkey(_ pubkeyHex: String) -> String
/// Returns true if the pubkey is blocked
func isBlocked(_ pubkeyHexLowercased: String) -> Bool
}
/// Tracks participants across multiple geohash channels
@MainActor
final class GeohashParticipantTracker: ObservableObject {
/// Activity cutoff duration (defaults to 5 minutes)
let activityCutoff: TimeInterval
/// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]]
private var participants: [String: [String: Date]] = [:]
/// Currently visible people for the active geohash
@Published private(set) var visiblePeople: [GeoPerson] = []
/// The currently active geohash (if any)
private var activeGeohash: String?
/// Context for display name resolution and block checking
private weak var context: GeohashParticipantContext?
/// Timer for periodic refresh
private var refreshTimer: Timer?
init(activityCutoff: TimeInterval = -300) { // default 5 minutes
self.activityCutoff = activityCutoff
}
/// Configure with a context provider
func configure(context: GeohashParticipantContext) {
self.context = context
}
/// Set the currently active geohash
func setActiveGeohash(_ geohash: String?) {
activeGeohash = geohash
if geohash == nil {
visiblePeople = []
} else {
refresh()
}
}
/// 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 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()
// Only refresh visible list if this geohash is currently active
if activeGeohash == geohash {
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
}
refresh()
}
/// Get participant count for a specific geohash
func participantCount(for geohash: String) -> Int {
let cutoff = Date().addingTimeInterval(activityCutoff)
let map = participants[geohash] ?? [:]
return map.values.filter { $0 >= cutoff }.count
}
/// Get the visible people list for the active geohash (read-only query)
func getVisiblePeople() -> [GeoPerson] {
guard let gh = activeGeohash, let context = context else { return [] }
let cutoff = Date().addingTimeInterval(activityCutoff)
let map = (participants[gh] ?? [:])
.filter { $0.value >= cutoff }
.filter { !context.isBlocked($0.key) }
return map
.map { (pub, seen) in
GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen)
}
.sorted { $0.lastSeen > $1.lastSeen }
}
/// Refresh the visible people list
func refresh() {
visiblePeople = getVisiblePeople()
}
/// Start the periodic refresh timer
func startRefreshTimer(interval: TimeInterval = 30.0) {
stopRefreshTimer()
refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
Task { @MainActor in
self?.refresh()
}
}
}
/// Stop the periodic refresh timer
func stopRefreshTimer() {
refreshTimer?.invalidate()
refreshTimer = nil
}
/// Clear all participant data
func clear() {
participants.removeAll()
visiblePeople = []
}
/// Clear participant data for a specific geohash
func clear(geohash: String) {
participants.removeValue(forKey: geohash)
if activeGeohash == geohash {
visiblePeople = []
}
}
}