mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 20:25:22 +00:00
Merge branch 'main' into source-routing-packet-format
This commit is contained in:
@@ -63,6 +63,10 @@ struct BitchatApp: App {
|
||||
|
||||
// Initialize network activation policy; will start Tor/Nostr only when allowed
|
||||
NetworkActivationService.shared.start()
|
||||
|
||||
// Start presence service (will wait for Tor readiness)
|
||||
GeohashPresenceService.shared.start()
|
||||
|
||||
// Check for shared content
|
||||
checkForSharedContent()
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ struct NostrProtocol {
|
||||
case seal = 13 // NIP-17 sealed event
|
||||
case giftWrap = 1059 // NIP-59 gift wrap
|
||||
case ephemeralEvent = 20000
|
||||
case geohashPresence = 20001
|
||||
}
|
||||
|
||||
/// Create a NIP-17 private message
|
||||
@@ -125,6 +126,24 @@ struct NostrProtocol {
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a geohash presence heartbeat (kind 20001)
|
||||
/// Must contain empty content and NO nickname tag
|
||||
static func createGeohashPresenceEvent(
|
||||
geohash: String,
|
||||
senderIdentity: NostrIdentity
|
||||
) throws -> NostrEvent {
|
||||
let tags = [["g", geohash]]
|
||||
let event = NostrEvent(
|
||||
pubkey: senderIdentity.publicKeyHex,
|
||||
createdAt: Date(),
|
||||
kind: .geohashPresence,
|
||||
tags: tags,
|
||||
content: ""
|
||||
)
|
||||
let schnorrKey = try senderIdentity.schnorrSigningKey()
|
||||
return try event.sign(with: schnorrKey)
|
||||
}
|
||||
|
||||
/// Create a persistent location note (kind 1: text note) tagged to a street-level geohash.
|
||||
static func createGeohashTextNote(
|
||||
content: String,
|
||||
|
||||
@@ -887,10 +887,10 @@ struct NostrFilter: Encodable {
|
||||
return filter
|
||||
}
|
||||
|
||||
// For location channels: geohash-scoped ephemeral events (kind 20000)
|
||||
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 200) -> NostrFilter {
|
||||
// For location channels: geohash-scoped ephemeral events (kind 20000) and presence (kind 20001)
|
||||
static func geohashEphemeral(_ geohash: String, since: Date? = nil, limit: Int = 1000) -> NostrFilter {
|
||||
var filter = NostrFilter()
|
||||
filter.kinds = [20000]
|
||||
filter.kinds = [20000, 20001]
|
||||
filter.since = since?.timeIntervalSince1970.toInt()
|
||||
filter.tagFilters = ["g": [geohash]]
|
||||
filter.limit = limit
|
||||
|
||||
@@ -83,6 +83,9 @@ public final class GeohashParticipantTracker: ObservableObject {
|
||||
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 {
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
//
|
||||
// GeohashPresenceService.swift
|
||||
// bitchat
|
||||
//
|
||||
// Manages the broadcasting of ephemeral presence heartbeats (Kind 20001)
|
||||
// to geohash location channels.
|
||||
//
|
||||
// This is free and unencumbered software released into the public domain.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import BitLogger
|
||||
import Tor
|
||||
|
||||
/// Service that coordinates the broadcasting of presence heartbeats.
|
||||
///
|
||||
/// Behavior:
|
||||
/// - Monitors location changes via LocationStateManager
|
||||
/// - Broadcasts Kind 20001 events to low-precision geohash channels
|
||||
/// - Uses randomized timing (40-80s loop) and decorrelated bursts
|
||||
/// - Respects privacy by NOT broadcasting to Neighborhood/Block/Building levels
|
||||
@MainActor
|
||||
final class GeohashPresenceService: ObservableObject {
|
||||
static let shared = GeohashPresenceService()
|
||||
|
||||
private var subscriptions = Set<AnyCancellable>()
|
||||
private var heartbeatTimer: Timer?
|
||||
private let idBridge = NostrIdentityBridge()
|
||||
|
||||
// MARK: - Constants
|
||||
|
||||
// Loop interval range in seconds
|
||||
private let loopMinInterval: TimeInterval = 40.0
|
||||
private let loopMaxInterval: TimeInterval = 80.0
|
||||
|
||||
// Per-broadcast decorrelation delay range in seconds
|
||||
private let burstMinDelay: TimeInterval = 2.0
|
||||
private let burstMaxDelay: TimeInterval = 5.0
|
||||
|
||||
// Privacy: Only broadcast to these levels
|
||||
private let allowedPrecisions: Set<Int> = [
|
||||
GeohashChannelLevel.region.precision, // 2
|
||||
GeohashChannelLevel.province.precision, // 4
|
||||
GeohashChannelLevel.city.precision // 5
|
||||
]
|
||||
|
||||
private init() {
|
||||
setupObservers()
|
||||
}
|
||||
|
||||
/// Start the service (safe to call multiple times)
|
||||
func start() {
|
||||
SecureLogger.info("Presence: service starting...", category: .session)
|
||||
scheduleNextHeartbeat()
|
||||
}
|
||||
|
||||
private func setupObservers() {
|
||||
// Monitor location channel changes
|
||||
LocationStateManager.shared.$availableChannels
|
||||
.dropFirst()
|
||||
.sink { [weak self] _ in
|
||||
self?.handleLocationChange()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
|
||||
// Monitor Tor readiness to kick off heartbeat if it was stalled
|
||||
NotificationCenter.default.publisher(for: .TorDidBecomeReady)
|
||||
.sink { [weak self] _ in
|
||||
self?.handleConnectivityChange()
|
||||
}
|
||||
.store(in: &subscriptions)
|
||||
}
|
||||
|
||||
private func handleLocationChange() {
|
||||
// When location changes, we trigger an immediate (but slightly delayed) heartbeat
|
||||
// to announce presence in the new zone, then reset the loop.
|
||||
SecureLogger.debug("Presence: location changed, scheduling update", category: .session)
|
||||
heartbeatTimer?.invalidate()
|
||||
|
||||
// Small delay to allow location state to settle
|
||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleConnectivityChange() {
|
||||
SecureLogger.debug("Presence: connectivity restored, triggering heartbeat", category: .session)
|
||||
// If we were waiting for network, do it now
|
||||
if heartbeatTimer == nil || !heartbeatTimer!.isValid {
|
||||
scheduleNextHeartbeat()
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleNextHeartbeat() {
|
||||
heartbeatTimer?.invalidate()
|
||||
let interval = TimeInterval.random(in: loopMinInterval...loopMaxInterval)
|
||||
heartbeatTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.performHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func performHeartbeat() {
|
||||
// Always schedule next loop first ensures continuity even if this one fails/skips
|
||||
defer { scheduleNextHeartbeat() }
|
||||
|
||||
// 1. Check preconditions
|
||||
guard TorManager.shared.isReady else {
|
||||
SecureLogger.debug("Presence: skipping heartbeat (Tor not ready)", category: .session)
|
||||
return
|
||||
}
|
||||
|
||||
// App must be active (or at least we shouldn't broadcast if in background, usually)
|
||||
if !TorManager.shared.isForeground() {
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Get channels
|
||||
let channels = LocationStateManager.shared.availableChannels
|
||||
guard !channels.isEmpty else { return }
|
||||
|
||||
// 3. Filter and broadcast
|
||||
// We use Task + sleep for decorrelation to allow the main runloop to proceed
|
||||
for channel in channels {
|
||||
// Check privacy restriction
|
||||
if !self.allowedPrecisions.contains(channel.geohash.count) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Launch independent task for each channel's delay
|
||||
Task { @MainActor in
|
||||
// Random delay for decorrelation
|
||||
let delay = TimeInterval.random(in: self.burstMinDelay...self.burstMaxDelay)
|
||||
let nanoseconds = UInt64(delay * 1_000_000_000)
|
||||
try? await Task.sleep(nanoseconds: nanoseconds)
|
||||
|
||||
self.broadcastPresence(for: channel.geohash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func broadcastPresence(for geohash: String) {
|
||||
do {
|
||||
guard let identity = try? idBridge.deriveIdentity(forGeohash: geohash) else {
|
||||
return
|
||||
}
|
||||
|
||||
let event = try NostrProtocol.createGeohashPresenceEvent(
|
||||
geohash: geohash,
|
||||
senderIdentity: identity
|
||||
)
|
||||
|
||||
// Send via RelayManager
|
||||
let targetRelays = GeoRelayDirectory.shared.closestRelays(
|
||||
toGeohash: geohash,
|
||||
count: TransportConfig.nostrGeoRelayCount
|
||||
)
|
||||
|
||||
if !targetRelays.isEmpty {
|
||||
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
||||
SecureLogger.debug("Presence: sent heartbeat for \(geohash) (pub=\(identity.publicKeyHex.prefix(6))...)", category: .session)
|
||||
}
|
||||
} catch {
|
||||
SecureLogger.error("Presence: failed to create event for \(geohash): \(error)", category: .session)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,8 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func subscribeNostrEvent(_ event: NostrEvent) {
|
||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue,
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue),
|
||||
!deduplicationService.hasProcessedNostrEvent(event.id)
|
||||
else {
|
||||
return
|
||||
@@ -86,6 +87,11 @@ extension ChatViewModel {
|
||||
// Update participants last-seen for this pubkey
|
||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||
|
||||
// If presence heartbeat (Kind 20001), stop here - no content to display
|
||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||
return
|
||||
}
|
||||
|
||||
// Track teleported tag (only our format ["t","teleport"]) for icon state
|
||||
let hasTeleportTag = event.tags.contains(where: { tag in
|
||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||
@@ -239,8 +245,9 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func handleNostrEvent(_ event: NostrEvent) {
|
||||
// Only handle ephemeral kind 20000 with matching tag
|
||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||
// Only handle ephemeral kind 20000 or presence kind 20001 with matching tag
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||
|
||||
// Deduplicate
|
||||
if deduplicationService.hasProcessedNostrEvent(event.id) { return }
|
||||
@@ -250,6 +257,11 @@ extension ChatViewModel {
|
||||
let tagSummary = event.tags.map { "[" + $0.joined(separator: ",") + "]" }.joined(separator: ",")
|
||||
SecureLogger.debug("GeoTeleport: recv pub=\(event.pubkey.prefix(8))… tags=\(tagSummary)", category: .session)
|
||||
|
||||
// If this pubkey is blocked, skip mapping, participants, and timeline
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
// Track teleport tag for participants – only our format ["t", "teleport"]
|
||||
let hasTeleportTag: Bool = event.tags.contains { tag in
|
||||
tag.count >= 2 && tag[0].lowercased() == "t" && tag[1].lowercased() == "teleport"
|
||||
@@ -273,6 +285,9 @@ extension ChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
// Update participants last-seen for this pubkey
|
||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||
|
||||
// Skip only very recent self-echo from relay; include older self events for hydration
|
||||
if isSelf {
|
||||
let eventTime = Date(timeIntervalSince1970: TimeInterval(event.created_at))
|
||||
@@ -287,17 +302,14 @@ extension ChatViewModel {
|
||||
geoNicknames[event.pubkey.lowercased()] = nick
|
||||
}
|
||||
|
||||
// If this pubkey is blocked, skip mapping, participants, and timeline
|
||||
if identityManager.isNostrBlocked(pubkeyHexLowercased: event.pubkey) {
|
||||
return
|
||||
}
|
||||
|
||||
// Store mapping for geohash DM initiation
|
||||
nostrKeyMapping[PeerID(nostr_: event.pubkey)] = event.pubkey
|
||||
nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey
|
||||
|
||||
// Update participants last-seen for this pubkey
|
||||
participantTracker.recordParticipant(pubkeyHex: event.pubkey)
|
||||
// If presence heartbeat (Kind 20001), stop here - no content to display
|
||||
if event.kind == NostrProtocol.EventKind.geohashPresence.rawValue {
|
||||
return
|
||||
}
|
||||
|
||||
let senderName = displayNameForNostrPubkey(event.pubkey)
|
||||
let content = event.content
|
||||
@@ -464,7 +476,8 @@ extension ChatViewModel {
|
||||
}
|
||||
|
||||
func subscribeNostrEvent(_ event: NostrEvent, gh: String) {
|
||||
guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return }
|
||||
guard (event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue ||
|
||||
event.kind == NostrProtocol.EventKind.geohashPresence.rawValue) else { return }
|
||||
|
||||
// Compute current participant count (5-minute window) BEFORE updating with this event
|
||||
let existingCount = participantTracker.participantCount(for: gh)
|
||||
|
||||
@@ -40,10 +40,31 @@ struct LocationChannelsSheet: View {
|
||||
}
|
||||
|
||||
static func levelTitle(for level: GeohashChannelLevel, count: Int) -> String {
|
||||
// High-precision uncertainty: if count is 0 for high-precision levels,
|
||||
// show "?" because presence broadcasting is disabled for privacy.
|
||||
let isHighPrecision = (level == .neighborhood || level == .block || level == .building)
|
||||
if isHighPrecision && count == 0 {
|
||||
return String(
|
||||
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
|
||||
locale: .current,
|
||||
level.displayName
|
||||
)
|
||||
}
|
||||
return rowTitle(label: level.displayName, count: count)
|
||||
}
|
||||
|
||||
static func bookmarkTitle(geohash: String, count: Int) -> String {
|
||||
// Check precision for bookmarks too
|
||||
let len = geohash.count
|
||||
// Neighborhood=6, Block=7, Building=8+
|
||||
let isHighPrecision = (len >= 6)
|
||||
if isHighPrecision && count == 0 {
|
||||
return String(
|
||||
format: String(localized: "location_channels.row_title_unknown", defaultValue: "%@ [? people]"),
|
||||
locale: .current,
|
||||
"#\(geohash)"
|
||||
)
|
||||
}
|
||||
return rowTitle(label: "#\(geohash)", count: count)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user