mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 21:45:20 +00:00
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>
528 lines
21 KiB
Swift
528 lines
21 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] = []
|
|
|
|
func clearPublicConversation(_ conversationID: ConversationID) {
|
|
clearedConversations.append(conversationID)
|
|
conversations[conversationID] = []
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
@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)
|
|
}
|
|
|
|
}
|