mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-26 20:25:22 +00:00
The realistic compromise for many of the people this app is built for is not interception but a phone taken, and often unlocked under coercion. Content encryption is in good shape; these are the gaps around it. Hide notification previews, by default. Notification content is rendered by the system on the lock screen, so it was readable without unlocking: DM alerts carried the sender's nickname and the full message body, and geohash alerts put the geohash in the title. Alerts now state that a DM, mention, or location-channel activity arrived and withhold the rest until the app is opened. userInfo still carries the routing peer ID and deep link, neither of which the system displays, so taps land where they did. A settings toggle restores full previews for anyone who wants them. Default-on is the deliberate part: a phone face-up on a table should not narrate conversations, and someone who wants previews can say so. Cover the window on willResignActive, so the snapshot iOS stores for the app switcher shows a placeholder rather than an open conversation. Opaque rather than blurred, because blurred large text stays partly legible and the snapshot goes to disk. Added synchronously from a UIKit notification with queue: nil, since the capture follows shortly after and an OperationQueue hop or a SwiftUI state change can lose that race. Panic wipe already deleted snapshots already on disk; this stops new ones from being worth deleting. Bound media by age as well as size. The 100 MB quota only ever considered incoming files, so outgoing media had no lifetime at all and a received photo could outlive its conversation indefinitely. A launch-time sweep now deletes managed media older than seven days, incoming and outgoing, with the same exemptions quota eviction honors: in-flight live captures and files reserved by a delivery or deletion in progress. Make /clear tell the truth on the mesh timeline. It recorded an echo watermark and left the gossip archive on disk for up to 6 hours, so someone who cleared before a police stop had deleted nothing. Clearing now erases the archive too. The watermark still matters: it suppresses pre-clear messages this device hears again from peers. The cost is that the device stops serving recent public backlog until it hears fresh traffic, which is a fair reading of what clearing a timeline means. Documented in PRIVACY_POLICY.md and the privacy assessment, including a new section on what is deliberately NOT addressed: there is still no duress mechanism of any kind (no decoy passphrase, no wipe-on-failed-auth, no app lock), macOS gets no file-protection classes, and media is not sealed at the app layer. The duress question is a product decision as much as an engineering one, since in some jurisdictions destroying data on demand is itself an offence and hiding may protect someone better than destroying, so it is called out rather than guessed at. Three findings from the audit that prompted this work turned out to be already fixed on main and are not included: keychain accessibility is AfterFirstUnlockThisDeviceOnly with a retrying migration, the panic media wipe uses a two-location durable marker transaction, and panic already discards staged share-extension content. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
562 lines
22 KiB
Swift
562 lines
22 KiB
Swift
//
|
|
// ChatPublicConversationCoordinatorContextTests.swift
|
|
// bitchatTests
|
|
//
|
|
// Exercises `ChatPublicConversationCoordinator` against a mock
|
|
// `ChatPublicConversationContext` — proving the coordinator works without a
|
|
// `ChatViewModel`, following the `ChatDeliveryCoordinatorContextTests` /
|
|
// `ChatPrivateConversationCoordinatorContextTests` exemplars.
|
|
//
|
|
// Scope note: haptics (UIApplication) and the geohash branch of
|
|
// `sendPublicRaw` (NostrRelayManager.shared, GeoRelayDirectory.shared) are
|
|
// intentionally not exercised here. `checkForMentions` posts through the
|
|
// injected context (`notifyMention(from:message:)`) and is covered, as are
|
|
// the mesh branch of `sendPublicRaw` and all timeline/store/blocking flows.
|
|
//
|
|
|
|
import Testing
|
|
import Foundation
|
|
import BitFoundation
|
|
@testable import bitchat
|
|
|
|
// MARK: - Mock Context
|
|
|
|
/// Lightweight stand-in for `ChatPublicConversationContext` proving that
|
|
/// `ChatPublicConversationCoordinator` is testable without a `ChatViewModel`.
|
|
@MainActor
|
|
private final class MockChatPublicConversationContext: ChatPublicConversationContext {
|
|
// Channel state
|
|
var activeChannel: ChannelID = .mesh
|
|
var currentGeohash: String?
|
|
var nickname = "me"
|
|
var myPeerID = PeerID(str: "0011223344556677")
|
|
private(set) var isBatchingPublic = false
|
|
private(set) var notifyUIChangedCount = 0
|
|
|
|
func setPublicBatching(_ isBatching: Bool) {
|
|
isBatchingPublic = isBatching
|
|
}
|
|
|
|
func notifyUIChanged() {
|
|
notifyUIChangedCount += 1
|
|
}
|
|
|
|
// Public conversation store (single-writer intents)
|
|
var conversations: [ConversationID: [BitchatMessage]] = [:]
|
|
private(set) var queuedGeohashSystemMessages: [String] = []
|
|
|
|
func publicMessages(in conversationID: ConversationID) -> [BitchatMessage] {
|
|
conversations[conversationID] ?? []
|
|
}
|
|
|
|
@discardableResult
|
|
func appendPublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) -> Bool {
|
|
guard conversations[conversationID]?.contains(where: { $0.id == message.id }) != true else {
|
|
return false
|
|
}
|
|
conversations[conversationID, default: []].append(message)
|
|
return true
|
|
}
|
|
|
|
func publicConversationContainsMessage(withID messageID: String, in conversationID: ConversationID) -> Bool {
|
|
conversations[conversationID]?.contains(where: { $0.id == messageID }) == true
|
|
}
|
|
|
|
@discardableResult
|
|
func removePublicMessage(withID messageID: String) -> BitchatMessage? {
|
|
for (conversationID, timeline) in conversations {
|
|
guard let index = timeline.firstIndex(where: { $0.id == messageID }) else { continue }
|
|
var updated = timeline
|
|
let removed = updated.remove(at: index)
|
|
conversations[conversationID] = updated
|
|
return removed
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func removePublicMessages(fromGeohash geohash: String, where predicate: (BitchatMessage) -> Bool) {
|
|
conversations[.geohash(geohash.lowercased())]?.removeAll(where: predicate)
|
|
}
|
|
|
|
private(set) var clearedConversations: [ConversationID] = []
|
|
private(set) var archivePurgeCount = 0
|
|
|
|
func clearPublicConversation(_ conversationID: ConversationID) {
|
|
clearedConversations.append(conversationID)
|
|
conversations[conversationID] = []
|
|
}
|
|
|
|
func purgeArchivedPublicMessages() {
|
|
archivePurgeCount += 1
|
|
}
|
|
|
|
func queueGeohashSystemMessage(_ content: String) {
|
|
queuedGeohashSystemMessages.append(content)
|
|
}
|
|
|
|
// Private chats
|
|
var privateChats: [PeerID: [BitchatMessage]] = [:]
|
|
var unreadPrivateMessages: Set<PeerID> = []
|
|
private(set) var removedPrivateChats: [PeerID] = []
|
|
private(set) var cleanedUpFileMessageIDs: [String] = []
|
|
|
|
func removePrivateChat(_ peerID: PeerID) {
|
|
removedPrivateChats.append(peerID)
|
|
privateChats.removeValue(forKey: peerID)
|
|
unreadPrivateMessages.remove(peerID)
|
|
}
|
|
|
|
@discardableResult
|
|
func removePrivateMessage(withID messageID: String) -> BitchatMessage? {
|
|
var removed: BitchatMessage?
|
|
for (peerID, chat) in privateChats {
|
|
guard let message = chat.first(where: { $0.id == messageID }) else { continue }
|
|
removed = removed ?? message
|
|
let remaining = chat.filter { $0.id != messageID }
|
|
if remaining.isEmpty {
|
|
privateChats.removeValue(forKey: peerID)
|
|
} else {
|
|
privateChats[peerID] = remaining
|
|
}
|
|
}
|
|
return removed
|
|
}
|
|
|
|
func cleanupLocalFile(forMessage message: BitchatMessage) {
|
|
cleanedUpFileMessageIDs.append(message.id)
|
|
}
|
|
|
|
// Geohash participants & presence
|
|
var geoNicknames: [String: String] = [:]
|
|
var isTeleported = false
|
|
var nostrKeyMapping: [PeerID: String] = [:]
|
|
var geoPeople: [GeoPerson] = []
|
|
var geoParticipantCounts: [String: Int] = [:]
|
|
private(set) var removedGeoParticipants: [String] = []
|
|
|
|
func removeNostrKeyMappings(matchingPubkeyHexLowercased hex: String) {
|
|
for (key, value) in nostrKeyMapping where value.lowercased() == hex {
|
|
nostrKeyMapping.removeValue(forKey: key)
|
|
}
|
|
}
|
|
|
|
func visibleGeoPeople() -> [GeoPerson] {
|
|
geoPeople
|
|
}
|
|
|
|
func geoParticipantCount(for geohash: String) -> Int {
|
|
geoParticipantCounts[geohash] ?? 0
|
|
}
|
|
|
|
func removeGeoParticipant(pubkeyHex: String) {
|
|
removedGeoParticipants.append(pubkeyHex)
|
|
}
|
|
|
|
// Nostr identity & blocking
|
|
var blockedNostrPubkeys: Set<String> = []
|
|
|
|
func deriveNostrIdentity(forGeohash geohash: String) throws -> NostrIdentity {
|
|
Self.dummyIdentity
|
|
}
|
|
|
|
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
|
|
blockedNostrPubkeys.contains(pubkeyHexLowercased)
|
|
}
|
|
|
|
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
|
|
if isBlocked {
|
|
blockedNostrPubkeys.insert(pubkeyHexLowercased)
|
|
} else {
|
|
blockedNostrPubkeys.remove(pubkeyHexLowercased)
|
|
}
|
|
}
|
|
|
|
// Mesh transport
|
|
var meshNicknames: [PeerID: String] = [:]
|
|
private(set) var sentMeshMessages: [(content: String, messageID: String)] = []
|
|
|
|
func meshPeerNicknames() -> [PeerID: String] {
|
|
meshNicknames
|
|
}
|
|
|
|
func sendMeshMessage(_ content: String, mentions: [String], messageID: String, timestamp: Date) {
|
|
sentMeshMessages.append((content, messageID))
|
|
}
|
|
|
|
// Inbound public message processing
|
|
var blockedMessageIDs: Set<String> = []
|
|
var rateLimitAllowed = true
|
|
private(set) var rateLimitChecks: [(senderKey: String, contentKey: String, powBits: Int)] = []
|
|
private(set) var enqueuedMessages: [(messageID: String, conversationID: ConversationID)] = []
|
|
var enqueuedMessageIDs: [String] { enqueuedMessages.map(\.messageID) }
|
|
var stablePeerIDs: [PeerID: PeerID] = [:]
|
|
|
|
func processActionMessage(_ message: BitchatMessage) -> BitchatMessage {
|
|
message
|
|
}
|
|
|
|
func isMessageBlocked(_ message: BitchatMessage) -> Bool {
|
|
blockedMessageIDs.contains(message.id)
|
|
}
|
|
|
|
func allowPublicMessage(senderKey: String, contentKey: String, powBits: Int) -> Bool {
|
|
rateLimitChecks.append((senderKey, contentKey, powBits))
|
|
return rateLimitAllowed
|
|
}
|
|
|
|
func enqueuePublicMessage(_ message: BitchatMessage, to conversationID: ConversationID) {
|
|
enqueuedMessages.append((message.id, conversationID))
|
|
}
|
|
|
|
func cachedStablePeerID(for shortPeerID: PeerID) -> PeerID? {
|
|
stablePeerIDs[shortPeerID]
|
|
}
|
|
|
|
// Content dedup & formatting
|
|
var contentTimestamps: [String: Date] = [:]
|
|
private(set) var recordedContentKeys: [(key: String, timestamp: Date)] = []
|
|
private(set) var prewarmedMessageIDs: [String] = []
|
|
|
|
func normalizedContentKey(_ content: String) -> String {
|
|
content.lowercased()
|
|
}
|
|
|
|
func contentTimestamp(forKey key: String) -> Date? {
|
|
contentTimestamps[key]
|
|
}
|
|
|
|
func recordContentKey(_ key: String, timestamp: Date) {
|
|
recordedContentKeys.append((key, timestamp))
|
|
}
|
|
|
|
func prewarmMessageFormatting(_ message: BitchatMessage) {
|
|
prewarmedMessageIDs.append(message.id)
|
|
}
|
|
|
|
// Notifications
|
|
private(set) var mentionNotifications: [(sender: String, message: String)] = []
|
|
|
|
func notifyMention(from sender: String, message: String) {
|
|
mentionNotifications.append((sender, message))
|
|
}
|
|
|
|
static let dummyIdentity = NostrIdentity(
|
|
privateKey: Data(repeating: 0x11, count: 32),
|
|
publicKey: Data(repeating: 0x22, count: 32),
|
|
npub: "npub1mock",
|
|
createdAt: Date(timeIntervalSince1970: 0)
|
|
)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
@MainActor
|
|
private func makePublicMessage(
|
|
id: String = UUID().uuidString,
|
|
sender: String = "alice",
|
|
content: String = "hello world",
|
|
senderPeerID: PeerID? = PeerID(str: "aabbccddeeff0011")
|
|
) -> BitchatMessage {
|
|
BitchatMessage(
|
|
id: id,
|
|
sender: sender,
|
|
content: content,
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
isPrivate: false,
|
|
senderPeerID: senderPeerID
|
|
)
|
|
}
|
|
|
|
// MARK: - Coordinator Tests Against Mock Context
|
|
|
|
/// Exercises `ChatPublicConversationCoordinator` against
|
|
/// `MockChatPublicConversationContext` — no `ChatViewModel` involved.
|
|
struct ChatPublicConversationCoordinatorContextTests {
|
|
|
|
@Test @MainActor
|
|
func handlePublicMessage_meshMessage_enqueuesForBatchedStoreCommit() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
let message = makePublicMessage(id: "mesh-msg-1", content: "Hello Mesh")
|
|
|
|
coordinator.handlePublicMessage(message)
|
|
|
|
// Visible-channel arrival: buffered for the batched pipeline flush
|
|
// (which commits to the store), not appended directly.
|
|
#expect(context.rateLimitChecks.count == 1)
|
|
#expect(context.rateLimitChecks.first?.senderKey == "mesh:aabbccddeeff0011")
|
|
#expect(context.rateLimitChecks.first?.contentKey == "hello mesh")
|
|
#expect(context.enqueuedMessages.map(\.messageID) == ["mesh-msg-1"])
|
|
#expect(context.enqueuedMessages.first?.conversationID == .mesh)
|
|
#expect(context.publicMessages(in: .mesh).isEmpty)
|
|
|
|
// Already committed to the store: not re-enqueued.
|
|
context.appendPublicMessage(message, to: .mesh)
|
|
coordinator.handlePublicMessage(message)
|
|
#expect(context.enqueuedMessageIDs == ["mesh-msg-1"])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handlePublicMessage_blockedOrRateLimited_dropsMessage() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
|
|
// Blocked sender: dropped before rate limiting and storage.
|
|
context.blockedMessageIDs = ["blocked-msg"]
|
|
coordinator.handlePublicMessage(makePublicMessage(id: "blocked-msg"))
|
|
#expect(context.rateLimitChecks.isEmpty)
|
|
#expect(context.publicMessages(in: .mesh).isEmpty)
|
|
#expect(context.enqueuedMessageIDs.isEmpty)
|
|
|
|
// Rate limited: consulted, then dropped before storage.
|
|
context.rateLimitAllowed = false
|
|
coordinator.handlePublicMessage(makePublicMessage(id: "limited-msg"))
|
|
#expect(context.rateLimitChecks.count == 1)
|
|
#expect(context.publicMessages(in: .mesh).isEmpty)
|
|
#expect(context.enqueuedMessageIDs.isEmpty)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func handlePublicMessage_geoMessage_respectsActiveChannel() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
let geohash = "u4pruy"
|
|
context.currentGeohash = geohash
|
|
let senderHex = String(repeating: "ab", count: 32)
|
|
let geoMessage = makePublicMessage(
|
|
id: "geo-msg-1",
|
|
content: "geo hello",
|
|
senderPeerID: PeerID(nostr: senderHex)
|
|
)
|
|
|
|
// On mesh channel: a background-channel arrival lands in the geohash
|
|
// conversation immediately, with no pipeline batching.
|
|
context.activeChannel = .mesh
|
|
coordinator.handlePublicMessage(geoMessage)
|
|
#expect(context.publicMessages(in: .geohash(geohash)).map(\.id) == ["geo-msg-1"])
|
|
#expect(context.publicMessages(in: .mesh).isEmpty)
|
|
#expect(context.enqueuedMessageIDs.isEmpty)
|
|
|
|
// On the matching location channel: enqueued for the batched flush.
|
|
context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash))
|
|
let second = makePublicMessage(
|
|
id: "geo-msg-2",
|
|
content: "geo again",
|
|
senderPeerID: PeerID(nostr: senderHex)
|
|
)
|
|
coordinator.handlePublicMessage(second)
|
|
#expect(context.enqueuedMessages.map(\.messageID) == ["geo-msg-2"])
|
|
#expect(context.enqueuedMessages.first?.conversationID == .geohash(geohash))
|
|
}
|
|
|
|
/// `/clear` on the mesh timeline used to only record a watermark, leaving
|
|
/// the archive on disk for up to its freshness window — so someone who
|
|
/// cleared before a police stop had deleted nothing. It must now erase the
|
|
/// archive behind the timeline.
|
|
@Test @MainActor
|
|
func clearCurrentPublicTimeline_onMesh_erasesTheArchive() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
context.activeChannel = .mesh
|
|
|
|
coordinator.clearCurrentPublicTimeline()
|
|
|
|
#expect(context.clearedConversations == [.mesh])
|
|
#expect(context.archivePurgeCount == 1)
|
|
}
|
|
|
|
/// Geohash timelines are Nostr-backed and carry no mesh gossip archive, so
|
|
/// clearing one must not reach for it.
|
|
@Test @MainActor
|
|
func clearCurrentPublicTimeline_onGeohash_leavesTheArchiveAlone() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy"))
|
|
|
|
coordinator.clearCurrentPublicTimeline()
|
|
|
|
#expect(context.archivePurgeCount == 0)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func blockGeohashUser_purgesMessagesMappingsAndPrivateChats() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
let geohash = "u4pruy"
|
|
let hex = String(repeating: "cd", count: 32)
|
|
let senderPeerID = PeerID(nostr: hex)
|
|
let convKey = PeerID(nostr_: hex)
|
|
let geoMessage = makePublicMessage(id: "geo-bad-1", sender: "rude", senderPeerID: senderPeerID)
|
|
|
|
context.currentGeohash = geohash
|
|
context.activeChannel = .location(GeohashChannel(level: .city, geohash: geohash))
|
|
context.conversations[.geohash(geohash)] = [geoMessage]
|
|
context.nostrKeyMapping = [senderPeerID: hex, convKey: hex]
|
|
context.privateChats[convKey] = [geoMessage]
|
|
context.unreadPrivateMessages = [convKey]
|
|
|
|
coordinator.blockGeohashUser(pubkeyHexLowercased: hex, displayName: "rude#abcd")
|
|
|
|
#expect(context.blockedNostrPubkeys.contains(hex))
|
|
#expect(context.removedGeoParticipants == [hex])
|
|
#expect(context.privateChats[convKey] == nil)
|
|
#expect(context.unreadPrivateMessages.isEmpty)
|
|
#expect(context.nostrKeyMapping.isEmpty)
|
|
// The blocked user's message is purged from the geohash conversation
|
|
// (the visible timeline is the same conversation now); a system
|
|
// notice was appended to the active conversation.
|
|
#expect(!context.publicMessages(in: .geohash(geohash)).contains(where: { $0.id == "geo-bad-1" }))
|
|
#expect(context.publicMessages(in: .geohash(geohash)).last?.sender == "system")
|
|
|
|
coordinator.unblockGeohashUser(pubkeyHexLowercased: hex, displayName: "rude#abcd")
|
|
#expect(!context.blockedNostrPubkeys.contains(hex))
|
|
}
|
|
|
|
@Test @MainActor
|
|
func removeMessage_removesEverywhereAndCleansUpFile() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
let peerID = PeerID(str: "0102030405060708")
|
|
let message = makePublicMessage(id: "doomed-msg")
|
|
context.conversations[.mesh] = [message]
|
|
context.privateChats[peerID] = [message]
|
|
|
|
coordinator.removeMessage(withID: "doomed-msg", cleanupFile: true)
|
|
|
|
#expect(context.publicMessages(in: .mesh).isEmpty)
|
|
#expect(context.privateChats[peerID] == nil)
|
|
#expect(context.cleanedUpFileMessageIDs == ["doomed-msg"])
|
|
#expect(context.notifyUIChangedCount == 1)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func addPublicSystemMessage_appendsToActiveConversationAndRecordsContentKey() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
|
|
coordinator.addPublicSystemMessage("Tor Ready")
|
|
|
|
#expect(context.publicMessages(in: .mesh).count == 1)
|
|
#expect(context.publicMessages(in: .mesh).first?.sender == "system")
|
|
#expect(context.recordedContentKeys.map(\.key) == ["tor ready"])
|
|
|
|
// On mesh, geohash-only system messages are queued for the next geo visit.
|
|
coordinator.addGeohashOnlySystemMessage("geo notice")
|
|
#expect(context.queuedGeohashSystemMessages == ["geo notice"])
|
|
}
|
|
|
|
@Test @MainActor
|
|
func sendPublicRaw_onMeshChannel_sendsViaMeshTransport() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
|
|
coordinator.sendPublicRaw("raw mesh payload")
|
|
|
|
#expect(context.sentMeshMessages.count == 1)
|
|
#expect(context.sentMeshMessages.first?.content == "raw mesh payload")
|
|
}
|
|
|
|
@Test @MainActor
|
|
func pipelineDelegate_readsAndWritesContextState() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
let pipeline = PublicMessagePipeline()
|
|
let message = makePublicMessage(id: "pipeline-msg")
|
|
context.contentTimestamps["key-1"] = Date(timeIntervalSince1970: 42)
|
|
|
|
#expect(coordinator.pipeline(pipeline, normalizeContent: "HeLLo") == "hello")
|
|
#expect(coordinator.pipeline(pipeline, contentTimestampForKey: "key-1") == Date(timeIntervalSince1970: 42))
|
|
|
|
// Commit lands in the store via the append intent; a duplicate ID
|
|
// reports `false` (the store's dedup contract).
|
|
#expect(coordinator.pipeline(pipeline, commit: message, to: .mesh))
|
|
#expect(context.publicMessages(in: .mesh).map(\.id) == ["pipeline-msg"])
|
|
#expect(!coordinator.pipeline(pipeline, commit: message, to: .mesh))
|
|
|
|
coordinator.pipeline(pipeline, recordContentKey: "key-2", timestamp: Date(timeIntervalSince1970: 7))
|
|
#expect(context.recordedContentKeys.map(\.key) == ["key-2"])
|
|
|
|
coordinator.pipelinePrewarmMessage(pipeline, message: message)
|
|
#expect(context.prewarmedMessageIDs == ["pipeline-msg"])
|
|
|
|
coordinator.pipelineSetBatchingState(pipeline, isBatching: true)
|
|
#expect(context.isBatchingPublic)
|
|
}
|
|
|
|
@Test @MainActor
|
|
func currentPublicSenderAndDisplayName_deriveGeoSuffixedIdentity() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
let identityHex = MockChatPublicConversationContext.dummyIdentity.publicKeyHex
|
|
let suffix = String(identityHex.suffix(4))
|
|
|
|
// Mesh: plain nickname and mesh peer ID.
|
|
let meshSender = coordinator.currentPublicSender()
|
|
#expect(meshSender.name == "me")
|
|
#expect(meshSender.peerID == context.myPeerID)
|
|
|
|
// Location channel: suffixed nickname and nostr peer ID.
|
|
context.activeChannel = .location(GeohashChannel(level: .city, geohash: "u4pruy"))
|
|
let geoSender = coordinator.currentPublicSender()
|
|
#expect(geoSender.name == "me#\(suffix)")
|
|
#expect(geoSender.peerID == PeerID(nostr: identityHex))
|
|
|
|
// Display names: own geo identity, known nickname, and anon fallback.
|
|
context.currentGeohash = "u4pruy"
|
|
#expect(coordinator.displayNameForNostrPubkey(identityHex) == "me#\(suffix)")
|
|
let otherHex = String(repeating: "ef", count: 32)
|
|
context.geoNicknames[otherHex] = "bob"
|
|
#expect(coordinator.displayNameForNostrPubkey(otherHex) == "bob#" + otherHex.suffix(4))
|
|
let unknownHex = String(repeating: "12", count: 32)
|
|
#expect(coordinator.displayNameForNostrPubkey(unknownHex) == "anon#" + unknownHex.suffix(4))
|
|
}
|
|
@Test @MainActor
|
|
func checkForMentions_postsMentionNotificationOnlyForOthersMentioningMe() async {
|
|
let context = MockChatPublicConversationContext()
|
|
let coordinator = ChatPublicConversationCoordinator(context: context)
|
|
|
|
// A mention of my nickname from someone else notifies.
|
|
coordinator.checkForMentions(
|
|
BitchatMessage(
|
|
id: "mention-1",
|
|
sender: "alice",
|
|
content: "hey @me",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
mentions: ["me"]
|
|
)
|
|
)
|
|
#expect(context.mentionNotifications.count == 1)
|
|
#expect(context.mentionNotifications.first?.sender == "alice")
|
|
#expect(context.mentionNotifications.first?.message == "hey @me")
|
|
|
|
// Mentioning someone else does not notify.
|
|
coordinator.checkForMentions(
|
|
BitchatMessage(
|
|
id: "mention-2",
|
|
sender: "alice",
|
|
content: "hey @bob",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
mentions: ["bob"]
|
|
)
|
|
)
|
|
#expect(context.mentionNotifications.count == 1)
|
|
|
|
// My own message mentioning myself does not notify.
|
|
coordinator.checkForMentions(
|
|
BitchatMessage(
|
|
id: "mention-3",
|
|
sender: "me",
|
|
content: "talking about @me",
|
|
timestamp: Date(),
|
|
isRelay: false,
|
|
mentions: ["me"]
|
|
)
|
|
)
|
|
#expect(context.mentionNotifications.count == 1)
|
|
}
|
|
|
|
}
|