mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:05:20 +00:00
* Add capability bits to announce TLV Announces now carry an optional capabilities TLV (0x05): a little-endian bitfield with named bits for upcoming features (prekeys, wifiBulk, gateway, groups, board, vouch, meshDiagnostics). Old clients skip the unknown TLV; peers without it decode as nil so features can distinguish "legacy peer" from "advertises nothing". PeerCapabilities lives in BitFoundation with a minimal-length encoding that preserves unknown bits for forward compatibility. Peer capabilities are stored in the BLE peer registry on verified announce and exposed via BLEService.peerCapabilities(_:). The local advertisement set is empty until each feature ships its bit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Prekey bundles: forward-secret async first contact for courier mail Courier envelopes were sealed with one-way Noise X to the recipient's long-lived static key, so a later compromise of that key exposed every envelope captured in transit. This adds one-time prekey bundles: - PrekeyBundle (MessageType 0x24): 8 one-time Curve25519 public prekeys bound to the owner's Noise static key by an Ed25519 signature over "bitchat-prekey-bundle-v1" canonical bytes; gossiped mesh-wide on its own 60s sync round (SyncTypeFlags bit 9, 200-peer cap, 24h freshness) and verified against the announce-bound signing key before caching. - Sealed envelope v2: Noise X where the responder static is the one-time prekey, prologue "bitchat-prekey-v1" || prekeyID. Sender identity rides encrypted inside and is authenticated exactly like v1 (blocked-sender check included). CourierEnvelope gains an optional prekeyID TLV that v1 decoders skip as unknown. - Local prekeys live in the Keychain; consumed privates survive a 48h grace window for spray-and-wait redeliveries, then are deleted (the forward-secrecy clock starts at deletion). The batch tops back up and re-gossips when unconsumed count drops below 3, and everything is wiped in panic mode. - Routing: courier sealing picks a cached verified bundle when one exists (one prekey per message, reused across deposit retries), with the advertised .prekeys capability as a veto for on-mesh peers, and falls back to static sealing otherwise. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Prekeys: authenticate bundle packets, fix consume-republish, deflake CI Fixes the prekey-bundle PR review + CI failure: - CI root cause: the receive queue (mesh.message) is concurrent, so a gossiped prekey bundle can be processed before the announce that binds its owner's signing key. The old handler dropped such bundles outright, so under CI parallel load the bundle was permanently lost and the cache/gossip tests flaked (verifiedBundleEntersGossipStore, prekeySealedMailTravelsViaCourierAndOpens). Bundles that arrive before their binding are now retained per-owner (bounded) and re-attempted when the verified announce lands, atomically to avoid a check-then-act race. - Authenticate the OUTER prekey-bundle packet (Codex P2 / review MEDIUM): require senderID == PeerID(bundle.noiseStaticPublicKey) and verify the packet's Ed25519 signature (covers senderID + timestamp) against the owner's bound signing key, in addition to the inner bundle signature. Stops replay under a fresh timestamp / fake senderID. - Key the gossip prekey-bundle store/dedup by the bundle's authenticated identity (noiseStaticPublicKey), not the unauthenticated packet senderID, so one valid bundle sprayed under many fabricated sender IDs can't multiply entries and exhaust the 200-owner cap. - Bump published-bundle generatedAt strictly on consume (Codex P1): consuming a prekey shrinks the published bundle, so it now republishes with a strictly newer generatedAt and re-gossips, so peers replace the cached copy and stop assigning the consumed ID before its 48h grace. - Guard the panic/clear detached Application Support tree-deletes behind TestEnvironment.isRunningTests: the SPM test process shares that tree, so the wipe could land mid-test and flake file-dependent tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Update sync tests for prekeyBundle as bit 9 / default sync round Prekeys makes bit 9 (prekeyBundle) a known SyncTypeFlags bit and enables a prekey sync round by default. That broke tests authored by other PRs that assumed bit 9 was phantom or that only their own sync round fires: - SyncTypeFlags(Board)Tests: move the "unknown bits" probes to bits 10+ (0xFE -> 0xFC / 0xFD), since bit 9 is now assigned. - GossipSync(Board)Tests + GossipSyncManagerTests: disable the prekey sync round in configs that run maintenance (as they already do for message/ fragment/fileTransfer), so they isolate the behavior under test. Full app suite (1301 tests) green locally via SPM. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: jack <jackjackbits@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
564 lines
24 KiB
Swift
564 lines
24 KiB
Swift
import BitFoundation
|
|
import BitLogger
|
|
import CoreBluetooth
|
|
import Foundation
|
|
import SwiftUI
|
|
#if os(iOS)
|
|
import UIKit
|
|
#endif
|
|
|
|
/// The narrow surface `ChatPublicConversationCoordinator` needs from its owner.
|
|
///
|
|
/// Follows the `ChatDeliveryContext` exemplar: the coordinator depends on the
|
|
/// minimal context it actually uses instead of holding an `unowned` back-ref
|
|
/// to the whole `ChatViewModel`. This keeps the coordinator independently
|
|
/// testable (see `ChatPublicConversationCoordinatorContextTests`) and makes
|
|
/// its true dependencies explicit. The surface is intentionally large — it
|
|
/// documents the coordinator's real coupling to the public timeline, the
|
|
/// conversation stores, geohash participants, and the inbound public message
|
|
/// pipeline.
|
|
@MainActor
|
|
protocol ChatPublicConversationContext: AnyObject {
|
|
// MARK: Channel state
|
|
var activeChannel: ChannelID { get }
|
|
var currentGeohash: String? { get }
|
|
var nickname: String { get }
|
|
var myPeerID: PeerID { get }
|
|
/// Publishes the public-timeline batching state (UI animation suppression).
|
|
/// (Single mutation path for the owner's `isBatchingPublic`; this
|
|
/// coordinator never reads it.)
|
|
func setPublicBatching(_ isBatching: Bool)
|
|
/// Signals that message state changed so observers refresh (e.g. `objectWillChange.send()`).
|
|
func notifyUIChanged()
|
|
|
|
// MARK: Public conversation store (single-writer intents)
|
|
/// Appends a public message in timestamp order. Returns `false` when a
|
|
/// message with the same ID is already in that conversation.
|
|
@discardableResult
|
|
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool
|
|
/// Appends a geohash message if absent. Returns `true` when stored.
|
|
@discardableResult
|
|
func appendGeohashMessageIfAbsent(_ message: BitchatMessage, toGeohash geohash: String) -> Bool
|
|
func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool
|
|
/// Removes a message by ID from whichever public conversation contains it.
|
|
@discardableResult
|
|
func removePublicMessage(withID messageID: String) -> BitchatMessage?
|
|
/// Removes every matching message from a geohash conversation (block purge).
|
|
func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool)
|
|
/// Empties a public conversation's timeline (`/clear`).
|
|
func clearPublicConversation(_ conversationID: ConversationID)
|
|
/// Queues a system message for the next geohash channel visit.
|
|
func queueGeohashSystemMessage(_ content: String)
|
|
|
|
// MARK: Private chats (block cleanup & message removal)
|
|
/// Removes the peer's chat entirely, including unread state
|
|
/// (single-writer store intent; no-op for unknown peers).
|
|
func removePrivateChat(_ peerID: PeerID)
|
|
/// Removes a message by ID from every private chat containing it,
|
|
/// dropping chats that become empty. Returns the removed message.
|
|
@discardableResult
|
|
func removePrivateMessage(withID messageID: String) -> BitchatMessage?
|
|
func cleanupLocalFile(forMessage message: BitchatMessage)
|
|
|
|
// MARK: Geohash participants & presence
|
|
var geoNicknames: [String: String] { get }
|
|
var isTeleported: Bool { get }
|
|
var nostrKeyMapping: [PeerID: String] { get }
|
|
/// Drops every key mapping that resolves to the given (lowercased) Nostr pubkey.
|
|
func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String)
|
|
func visibleGeoPeople() -> [GeoPerson]
|
|
func geoParticipantCount(for geohash: String) -> Int
|
|
func removeGeoParticipant(pubkeyHex: String)
|
|
|
|
// MARK: Nostr identity & blocking (shared with the other contexts)
|
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity
|
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool
|
|
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool)
|
|
|
|
// MARK: Mesh transport
|
|
func meshPeerNicknames() -> [PeerID: String]
|
|
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date)
|
|
|
|
// MARK: Inbound public message processing
|
|
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage
|
|
func isMessageBlocked(_ message: BitchatMessage) -> Bool
|
|
/// `powBits` is the validated NIP-13 difficulty of the source Nostr event
|
|
/// (0 for mesh messages); sufficient PoW relaxes the per-sender bucket.
|
|
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool
|
|
/// Buffers a visible-channel message for the batched (~80 ms) pipeline
|
|
/// flush, which commits it to `conversationID` in the store.
|
|
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID)
|
|
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID?
|
|
|
|
// MARK: Content dedup & formatting
|
|
func normalizedContentKey(_ content: String) -> String
|
|
func contentTimestamp(forKey key: String) -> Date?
|
|
func recordContentKey(_ key: String, timestamp: Date)
|
|
/// Pre-renders the message so the formatting cache is warm before display.
|
|
func prewarmMessageFormatting(_ message: BitchatMessage)
|
|
|
|
// MARK: Notifications
|
|
/// Posts the you-were-mentioned local notification.
|
|
func notifyMention(from sender: String, message: String)
|
|
}
|
|
|
|
extension ChatViewModel: ChatPublicConversationContext {
|
|
// `unreadPrivateMessages`, `nostrKeyMapping`,
|
|
// `nickname`, `activeChannel`, `currentGeohash`, `geoNicknames`,
|
|
// `myPeerID`, `isTeleported`, `notifyUIChanged()`,
|
|
// `geoParticipantCount(for:)`, `isNostrBlocked(pubkeyHexLowercased:)`,
|
|
// `deriveNostrIdentity(forGeohash:)`, the public conversation store
|
|
// intents (`appendPublicMessage(_:to:)`,
|
|
// `appendGeohashMessageIfAbsent(_:toGeohash:)`,
|
|
// `publicConversationContainsMessage(withID:in:)`,
|
|
// `removePublicMessage(withID:)`,
|
|
// `removePublicMessages(fromGeohash:where:)`,
|
|
// `clearPublicConversation(_:)`, and `queueGeohashSystemMessage(_:)`)
|
|
// are shared requirements with `ChatDeliveryContext` /
|
|
// `ChatPrivateConversationContext` / `ChatNostrContext` or satisfied by
|
|
// existing `ChatViewModel` members. The members below flatten nested
|
|
// service accesses into intent-named calls.
|
|
|
|
func visibleGeoPeople() -> [GeoPerson] {
|
|
participantTracker.getVisiblePeople()
|
|
}
|
|
|
|
func removeGeoParticipant(pubkeyHex: String) {
|
|
participantTracker.removeParticipant(pubkeyHex: pubkeyHex)
|
|
}
|
|
|
|
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
|
identityManager.setNostrBlocked(pubkeyHexLowercased, isBlocked: isBlocked)
|
|
}
|
|
|
|
func meshPeerNicknames() -> [PeerID: String] {
|
|
meshService.getPeerNicknames()
|
|
}
|
|
|
|
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
|
meshService.sendMessage(content, mentions: mentions, messageID: messageID, timestamp: timestamp)
|
|
}
|
|
|
|
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
|
|
publicRateLimiter.allow(senderKey: senderKey, contentKey: contentKey, powBits: powBits)
|
|
}
|
|
|
|
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
|
|
publicMessagePipeline.enqueue(message, to: conversationID)
|
|
}
|
|
|
|
func normalizedContentKey(_ content: String) -> String {
|
|
deduplicationService.normalizedContentKey(content)
|
|
}
|
|
|
|
func contentTimestamp(forKey key: String) -> Date? {
|
|
deduplicationService.contentTimestamp(forKey: key)
|
|
}
|
|
|
|
func recordContentKey(_ key: String, timestamp: Date) {
|
|
deduplicationService.recordContentKey(key, timestamp: timestamp)
|
|
}
|
|
|
|
func prewarmMessageFormatting(_ message: BitchatMessage) {
|
|
_ = formatMessageAsText(message, colorScheme: currentColorScheme)
|
|
}
|
|
|
|
func notifyMention(from sender: String, message: String) {
|
|
NotificationService.shared.sendMentionNotification(from: sender, message: message)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class ChatPublicConversationCoordinator: PublicMessagePipelineDelegate {
|
|
private unowned let context: any ChatPublicConversationContext
|
|
|
|
init(context: any ChatPublicConversationContext) {
|
|
self.context = context
|
|
}
|
|
|
|
func visibleGeohashPeople() -> [GeoPerson] {
|
|
context.visibleGeoPeople()
|
|
}
|
|
|
|
func getVisibleGeoParticipants() -> [CommandGeoParticipant] {
|
|
visibleGeohashPeople().map { CommandGeoParticipant(id: $0.id, displayName: $0.displayName) }
|
|
}
|
|
|
|
func geohashParticipantCount(for geohash: String) -> Int {
|
|
context.geoParticipantCount(for: geohash)
|
|
}
|
|
|
|
func displayNameForPubkey(_ pubkeyHex: String) -> String {
|
|
displayNameForNostrPubkey(pubkeyHex)
|
|
}
|
|
|
|
func isBlocked(_ pubkeyHexLowercased: String) -> Bool {
|
|
context.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
|
|
}
|
|
|
|
func isGeohashUserBlocked(pubkeyHexLowercased: String) -> Bool {
|
|
context.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased)
|
|
}
|
|
|
|
func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
|
let hex = pubkeyHexLowercased.lowercased()
|
|
context.setNostrBlocked(hex, isBlocked: true)
|
|
context.removeGeoParticipant(pubkeyHex: hex)
|
|
|
|
if let gh = context.currentGeohash {
|
|
let predicate: (BitchatMessage) -> Bool = { [unowned context] message in
|
|
guard let senderPeerID = message.senderPeerID,
|
|
senderPeerID.isGeoDM || senderPeerID.isGeoChat else {
|
|
return false
|
|
}
|
|
if let full = context.nostrKeyMapping[senderPeerID]?.lowercased() {
|
|
return full == hex
|
|
}
|
|
return false
|
|
}
|
|
context.removePublicMessages(fromGeohash: gh, where: predicate)
|
|
}
|
|
|
|
// The store intent no-ops when no such chat exists.
|
|
context.removePrivateChat(PeerID(nostr_: hex))
|
|
|
|
context.removeNostrKeyMappings(matchingPubkeyHexLowercased: hex)
|
|
|
|
addSystemMessage(
|
|
String(
|
|
format: String(
|
|
localized: "system.geohash.blocked",
|
|
comment: "System message shown when a user is blocked in geohash chats"
|
|
),
|
|
locale: .current,
|
|
displayName
|
|
)
|
|
)
|
|
}
|
|
|
|
func unblockGeohashUser(pubkeyHexLowercased: String, displayName: String) {
|
|
context.setNostrBlocked(pubkeyHexLowercased, isBlocked: false)
|
|
addSystemMessage(
|
|
String(
|
|
format: String(
|
|
localized: "system.geohash.unblocked",
|
|
comment: "System message shown when a user is unblocked in geohash chats"
|
|
),
|
|
locale: .current,
|
|
displayName
|
|
)
|
|
)
|
|
}
|
|
|
|
func displayNameForNostrPubkey(_ pubkeyHex: String) -> String {
|
|
let suffix = String(pubkeyHex.suffix(4))
|
|
if let geohash = context.currentGeohash,
|
|
let myGeoIdentity = try? context.deriveNostrIdentity(forGeohash: geohash),
|
|
myGeoIdentity.publicKeyHex.lowercased() == pubkeyHex.lowercased() {
|
|
return context.nickname + "#" + suffix
|
|
}
|
|
if let nick = context.geoNicknames[pubkeyHex.lowercased()], !nick.isEmpty {
|
|
return nick + "#" + suffix
|
|
}
|
|
return "anon#\(suffix)"
|
|
}
|
|
|
|
func currentPublicSender() -> (name: String, peerID: PeerID) {
|
|
var displaySender = context.nickname
|
|
var senderPeerID = context.myPeerID
|
|
if case .location(let channel) = context.activeChannel,
|
|
let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
|
let suffix = String(identity.publicKeyHex.suffix(4))
|
|
displaySender = context.nickname + "#" + suffix
|
|
senderPeerID = PeerID(nostr: identity.publicKeyHex)
|
|
}
|
|
return (displaySender, senderPeerID)
|
|
}
|
|
|
|
func removeMessage(withID messageID: String, cleanupFile: Bool = false) {
|
|
var removedMessage = context.removePublicMessage(withID: messageID)
|
|
|
|
if let removedPrivateMessage = context.removePrivateMessage(withID: messageID) {
|
|
removedMessage = removedMessage ?? removedPrivateMessage
|
|
}
|
|
|
|
if cleanupFile, let removedMessage {
|
|
context.cleanupLocalFile(forMessage: removedMessage)
|
|
}
|
|
|
|
context.notifyUIChanged()
|
|
}
|
|
|
|
func clearCurrentPublicTimeline() {
|
|
context.clearPublicConversation(ConversationID(channelID: context.activeChannel))
|
|
|
|
// The SPM test process shares the real Application Support tree, so this
|
|
// detached deletion can land mid-test under parallel scheduling and flake
|
|
// a file-dependent test. Tests never need the on-disk media cleared.
|
|
guard !TestEnvironment.isRunningTests else { return }
|
|
|
|
Task.detached(priority: .utility) {
|
|
// Skipped under tests: the test process shares the user's real
|
|
// ~/Library/Application Support/files tree, and this detached
|
|
// wipe fires at a nondeterministic time — racing tests that
|
|
// write media there (see the same guard in panicClearAllData).
|
|
guard !TestEnvironment.isRunningTests else { return }
|
|
do {
|
|
let base = try FileManager.default.url(
|
|
for: .applicationSupportDirectory,
|
|
in: .userDomainMask,
|
|
appropriateFor: nil,
|
|
create: true
|
|
)
|
|
let filesDir = base.appendingPathComponent("files", isDirectory: true)
|
|
let outgoingDirs = [
|
|
filesDir.appendingPathComponent("voicenotes/outgoing", isDirectory: true),
|
|
filesDir.appendingPathComponent("images/outgoing", isDirectory: true),
|
|
filesDir.appendingPathComponent("files/outgoing", isDirectory: true)
|
|
]
|
|
|
|
for dir in outgoingDirs {
|
|
if FileManager.default.fileExists(atPath: dir.path) {
|
|
try? FileManager.default.removeItem(at: dir)
|
|
try? FileManager.default.createDirectory(
|
|
at: dir,
|
|
withIntermediateDirectories: true,
|
|
attributes: nil
|
|
)
|
|
}
|
|
}
|
|
} catch {
|
|
SecureLogger.error("Failed to clear media files: \(error)", category: .session)
|
|
}
|
|
}
|
|
}
|
|
|
|
func addSystemMessage(_ content: String, timestamp: Date = Date()) {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: content,
|
|
timestamp: timestamp,
|
|
isRelay: false
|
|
)
|
|
context.appendPublicMessage(systemMessage, to: ConversationID(channelID: context.activeChannel))
|
|
}
|
|
|
|
func addMeshOnlySystemMessage(_ content: String) {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: content,
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
context.appendPublicMessage(systemMessage, to: .mesh)
|
|
}
|
|
|
|
func addPublicSystemMessage(_ content: String) {
|
|
let systemMessage = BitchatMessage(
|
|
sender: "system",
|
|
content: content,
|
|
timestamp: Date(),
|
|
isRelay: false
|
|
)
|
|
context.appendPublicMessage(systemMessage, to: ConversationID(channelID: context.activeChannel))
|
|
let contentKey = context.normalizedContentKey(systemMessage.content)
|
|
context.recordContentKey(contentKey, timestamp: systemMessage.timestamp)
|
|
}
|
|
|
|
func addGeohashOnlySystemMessage(_ content: String) {
|
|
if case .location = context.activeChannel {
|
|
addPublicSystemMessage(content)
|
|
} else {
|
|
context.queueGeohashSystemMessage(content)
|
|
}
|
|
}
|
|
|
|
func sendPublicRaw(_ content: String) {
|
|
if case .location(let channel) = context.activeChannel {
|
|
Task { @MainActor [weak context] in
|
|
guard let context else { return }
|
|
do {
|
|
let identity = try context.deriveNostrIdentity(forGeohash: channel.geohash)
|
|
let event = try await NostrProtocol.createMinedEphemeralGeohashEvent(
|
|
content: content,
|
|
geohash: channel.geohash,
|
|
senderIdentity: identity,
|
|
nickname: context.nickname,
|
|
teleported: context.isTeleported
|
|
)
|
|
let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: channel.geohash, count: 5)
|
|
if targetRelays.isEmpty {
|
|
NostrRelayManager.shared.sendEvent(event)
|
|
} else {
|
|
NostrRelayManager.shared.sendEvent(event, to: targetRelays)
|
|
}
|
|
} catch {
|
|
SecureLogger.error("❌ Failed to send geohash raw message: \(error)", category: .session)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
|
|
context.sendMeshMessage(
|
|
content,
|
|
mentions: [],
|
|
messageID: UUID().uuidString,
|
|
timestamp: Date()
|
|
)
|
|
}
|
|
|
|
/// - Parameter powBits: validated NIP-13 difficulty of the source Nostr
|
|
/// event (0 for mesh messages). Sufficient PoW relaxes the per-sender
|
|
/// rate limit; low/no-PoW events keep the strict limits so old clients
|
|
/// still get through at normal rates.
|
|
func handlePublicMessage(_ message: BitchatMessage, powBits: Int = 0) {
|
|
let finalMessage = context.processActionMessage(message)
|
|
if context.isMessageBlocked(finalMessage) { return }
|
|
|
|
let isGeo = finalMessage.senderPeerID?.isGeoChat == true
|
|
let isSystem = finalMessage.sender == "system"
|
|
let shouldRateLimit = !isSystem || finalMessage.senderPeerID != nil
|
|
if shouldRateLimit {
|
|
let senderKey = normalizedSenderKey(for: finalMessage)
|
|
let contentKey = context.normalizedContentKey(finalMessage.content)
|
|
if !context.allowPublicMessage(senderKey: senderKey, contentKey: contentKey, powBits: powBits) {
|
|
return
|
|
}
|
|
}
|
|
|
|
if !isSystem && finalMessage.content.count > 16000 { return }
|
|
// Empty content never rendered before (the old visible-array enqueue
|
|
// filtered it); with the store as the sole timeline it is dropped
|
|
// outright instead of lingering invisibly in a backing buffer.
|
|
guard !finalMessage.content.trimmed.isEmpty else { return }
|
|
|
|
// Resolve the destination conversation. System messages surface on
|
|
// the active channel (matching their old visible-only routing); geo
|
|
// messages require a current geohash, mesh messages always land in
|
|
// the mesh conversation.
|
|
let destination: ConversationID?
|
|
if isSystem {
|
|
destination = ConversationID(channelID: context.activeChannel)
|
|
} else if isGeo {
|
|
destination = context.currentGeohash.map { .geohash($0.lowercased()) }
|
|
} else {
|
|
destination = .mesh
|
|
}
|
|
guard let destination else { return }
|
|
|
|
let channelMatches: Bool = {
|
|
switch context.activeChannel {
|
|
case .mesh: return !isGeo || isSystem
|
|
case .location: return isGeo || isSystem
|
|
}
|
|
}()
|
|
|
|
if channelMatches {
|
|
// Visible-channel arrivals are batched: the pipeline's ~80 ms
|
|
// flush commits them to the store (which dedups by ID), keeping
|
|
// the deliberate UI flush cadence.
|
|
guard !context.publicConversationContainsMessage(withID: finalMessage.id, in: destination) else { return }
|
|
context.enqueuePublicMessage(finalMessage, to: destination)
|
|
} else {
|
|
// Background-channel arrivals have no rendering observers to
|
|
// batch for; they land in the store immediately.
|
|
context.appendPublicMessage(finalMessage, to: destination)
|
|
}
|
|
}
|
|
|
|
func checkForMentions(_ message: BitchatMessage) {
|
|
var myTokens: Set<String> = [context.nickname]
|
|
let meshPeers = context.meshPeerNicknames()
|
|
let collisions = meshPeers.values.filter { $0.hasPrefix(context.nickname + "#") }
|
|
if !collisions.isEmpty {
|
|
let suffix = "#" + String(context.myPeerID.id.prefix(4))
|
|
myTokens = [context.nickname + suffix]
|
|
}
|
|
let isMentioned = message.mentions?.contains(where: myTokens.contains) ?? false
|
|
|
|
if isMentioned && message.sender != context.nickname {
|
|
SecureLogger.info("🔔 Mention from \(message.sender)", category: .session)
|
|
context.notifyMention(from: message.sender, message: message.content)
|
|
}
|
|
}
|
|
|
|
func sendHapticFeedback(for message: BitchatMessage) {
|
|
#if os(iOS)
|
|
guard UIApplication.shared.applicationState == .active else { return }
|
|
|
|
var tokens: [String] = [context.nickname]
|
|
switch context.activeChannel {
|
|
case .location(let channel):
|
|
if let identity = try? context.deriveNostrIdentity(forGeohash: channel.geohash) {
|
|
tokens.append(context.nickname + "#" + String(identity.publicKeyHex.suffix(4)))
|
|
}
|
|
case .mesh:
|
|
break
|
|
}
|
|
|
|
let hugsMe = tokens.contains { message.content.contains("hugs \($0)") } || message.content.contains("hugs you")
|
|
let slapsMe = tokens.contains { message.content.contains("slaps \($0) around") } || message.content.contains("slaps you around")
|
|
let isHugForMe = message.content.contains("🫂") && hugsMe
|
|
let isSlapForMe = message.content.contains("🐟") && slapsMe
|
|
|
|
if isHugForMe && message.sender != context.nickname {
|
|
let impactFeedback = UIImpactFeedbackGenerator(style: .medium)
|
|
impactFeedback.prepare()
|
|
|
|
for i in 0..<8 {
|
|
DispatchQueue.main.asyncAfter(
|
|
deadline: .now() + Double(i) * TransportConfig.uiBatchDispatchStaggerSeconds
|
|
) {
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
}
|
|
} else if isSlapForMe && message.sender != context.nickname {
|
|
let impactFeedback = UIImpactFeedbackGenerator(style: .heavy)
|
|
impactFeedback.prepare()
|
|
impactFeedback.impactOccurred()
|
|
}
|
|
#endif
|
|
}
|
|
|
|
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
|
|
context.normalizedContentKey(content)
|
|
}
|
|
|
|
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? {
|
|
context.contentTimestamp(forKey: key)
|
|
}
|
|
|
|
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
|
|
context.recordContentKey(key, timestamp: timestamp)
|
|
}
|
|
|
|
func pipeline(_ pipeline: PublicMessagePipeline, commit message: BitchatMessage, to conversationID: ConversationID) -> Bool {
|
|
context.appendPublicMessage(message, to: conversationID)
|
|
}
|
|
|
|
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {
|
|
context.prewarmMessageFormatting(message)
|
|
}
|
|
|
|
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {
|
|
context.setPublicBatching(isBatching)
|
|
}
|
|
}
|
|
|
|
private extension ChatPublicConversationCoordinator {
|
|
func normalizedSenderKey(for message: BitchatMessage) -> String {
|
|
if let senderPeerID = message.senderPeerID {
|
|
if senderPeerID.isGeoChat || senderPeerID.isGeoDM {
|
|
let full = (context.nostrKeyMapping[senderPeerID] ?? senderPeerID.bare).lowercased()
|
|
return "nostr:" + full
|
|
} else if senderPeerID.id.count == 16,
|
|
let full = context.cachedStablePeerID(for: senderPeerID)?.id.lowercased() {
|
|
return "noise:" + full
|
|
} else {
|
|
return "mesh:" + senderPeerID.id.lowercased()
|
|
}
|
|
}
|
|
return "name:" + message.sender.lowercased()
|
|
}
|
|
}
|