diff --git a/bitchat/Models/BitchatMessage.swift b/bitchat/Models/BitchatMessage.swift index 1ae62040..f52004f2 100644 --- a/bitchat/Models/BitchatMessage.swift +++ b/bitchat/Models/BitchatMessage.swift @@ -21,7 +21,7 @@ final class BitchatMessage: Codable { let originalSender: String? let isPrivate: Bool let recipientNickname: String? - let senderPeerID: String? + let senderPeerID: PeerID? let mentions: [String]? // Array of mentioned nicknames var deliveryStatus: DeliveryStatus? // Delivery tracking @@ -51,7 +51,7 @@ final class BitchatMessage: Codable { self.originalSender = originalSender self.isPrivate = isPrivate self.recipientNickname = recipientNickname - self.senderPeerID = senderPeerID + self.senderPeerID = PeerID(str: senderPeerID) self.mentions = mentions self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil) } @@ -151,7 +151,7 @@ extension BitchatMessage { data.append(recipData.prefix(255)) } - if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) { + if let peerData = senderPeerID?.id.data(using: .utf8) { data.append(UInt8(min(peerData.count, 255))) data.append(peerData.prefix(255)) } diff --git a/bitchat/Models/PeerID.swift b/bitchat/Models/PeerID.swift index 23ddc4f3..6cc13f94 100644 --- a/bitchat/Models/PeerID.swift +++ b/bitchat/Models/PeerID.swift @@ -61,6 +61,12 @@ extension PeerID { } } + /// Convenience init to handle `Optional` + init?(str: (any StringProtocol)?) { + guard let str else { return nil } + self.init(str: str) + } + /// Convenience init to create PeerID by converting Data to String init?(data: Data) { guard let str = String(data: data, encoding: .utf8) else { return nil } @@ -175,3 +181,28 @@ extension PeerID: Comparable { lhs.id < rhs.id } } + +// MARK: - String Interop Helpers + +// MARK: CustomStringConvertible + +extension PeerID: CustomStringConvertible { + /// So it returns the actual `id` like before even inside another String + var description: String { + id + } +} + +// MARK: Custom Equatable w/ String & Optionality + +// PeerID <> String +extension Optional where Wrapped == PeerID { + static func ==(lhs: Optional, rhs: Optional) -> Bool { lhs?.id == rhs } + static func !=(lhs: Optional, rhs: Optional) -> Bool { lhs?.id != rhs } +} + +// String <> PeerID +extension Optional where Wrapped == String { + static func ==(lhs: Optional, rhs: Optional) -> Bool { lhs == rhs?.id } + static func !=(lhs: Optional, rhs: Optional) -> Bool { lhs != rhs?.id } +} diff --git a/bitchat/Services/PrivateChatManager.swift b/bitchat/Services/PrivateChatManager.swift index bc6e9ee6..531b117e 100644 --- a/bitchat/Services/PrivateChatManager.swift +++ b/bitchat/Services/PrivateChatManager.swift @@ -111,13 +111,13 @@ final class PrivateChatManager: ObservableObject { // Route via MessageRouter to avoid handshakeRequired spam when session isn't established if let router = messageRouter { - SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.prefix(8))… via router", category: .session) + SecureLogger.debug("PrivateChatManager: sending READ ack for \(message.id.prefix(8))… to \(senderPeerID.id.prefix(8))… via router", category: .session) Task { @MainActor in - router.sendReadReceipt(receipt, to: senderPeerID) + router.sendReadReceipt(receipt, to: senderPeerID.id) } } else { // Fallback: preserve previous behavior - meshService?.sendReadReceipt(receipt, to: senderPeerID) + meshService?.sendReadReceipt(receipt, to: senderPeerID.id) } } } diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 7ff6e04d..a868995c 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -153,7 +153,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func normalizedSenderKey(for message: BitchatMessage) -> String { - if let spid = message.senderPeerID { + if let spid = message.senderPeerID?.id { if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") { let bare: String = { if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) } @@ -1967,7 +1967,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if let gh = currentGeohash { if var arr = geoTimelines[gh] { arr.removeAll { msg in - if let spid = msg.senderPeerID, spid.hasPrefix("nostr") { + if let spid = msg.senderPeerID?.id, spid.hasPrefix("nostr") { if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex } } return false @@ -1978,7 +1978,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { switch activeChannel { case .location: messages.removeAll { msg in - if let spid = msg.senderPeerID, spid.hasPrefix("nostr") { + if let spid = msg.senderPeerID?.id , spid.hasPrefix("nostr") { if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex } } return false @@ -2676,7 +2676,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { // Store the Nostr pubkey if provided (for messages from unknown senders) if let nostrPubkey = notification.userInfo?["nostrPubkey"] as? String, - let senderPeerID = message.senderPeerID { + let senderPeerID = message.senderPeerID?.id { // Store mapping for read receipts nostrKeyMapping[senderPeerID] = nostrPubkey } @@ -3296,7 +3296,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { // Determine if this message was sent by self (mesh, geo, or DM) let isSelf: Bool = { - if let spid = message.senderPeerID { + if let spid = message.senderPeerID?.id { // In geohash channels, compare against our per-geohash nostr short ID if case .location(let ch) = activeChannel, spid.hasPrefix("nostr:") { if let myGeo = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) { @@ -3331,7 +3331,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { let fontWeight: Font.Weight = isSelf ? .bold : .medium senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: .monospaced) // Make sender clickable: encode senderPeerID into a custom URL - if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") { + if let spid = message.senderPeerID?.id, let url = URL(string: "bitchat://user/\(spid.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid)") { senderStyle.link = url } @@ -3862,7 +3862,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { - if let spid = message.senderPeerID { + if let spid = message.senderPeerID?.id { if spid.hasPrefix("nostr:") || spid.hasPrefix("nostr_") { let bare: String = { if spid.hasPrefix("nostr:") { return String(spid.dropFirst(6)) } @@ -5654,7 +5654,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { /// Check if a message should be blocked based on sender @MainActor private func isMessageBlocked(_ message: BitchatMessage) -> Bool { - if let peerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) { + if let peerID = message.senderPeerID?.id ?? getPeerIDForNickname(message.sender) { // Check mesh/known peers first if isPeerBlocked(peerID) { return true } // Check geohash (Nostr) blocks using mapping to full pubkey @@ -5694,7 +5694,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { originalSender: message.originalSender, isPrivate: message.isPrivate, recipientNickname: message.recipientNickname, - senderPeerID: message.senderPeerID, + senderPeerID: message.senderPeerID?.id, mentions: message.mentions, deliveryStatus: message.deliveryStatus ) @@ -5802,7 +5802,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { @MainActor private func handlePrivateMessage(_ message: BitchatMessage) { SecureLogger.debug("📥 handlePrivateMessage called for message from \(message.sender)", category: .session) - let senderPeerID = message.senderPeerID ?? getPeerIDForNickname(message.sender) + let senderPeerID = message.senderPeerID?.id ?? getPeerIDForNickname(message.sender) guard let peerID = senderPeerID else { SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session) @@ -5912,7 +5912,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { readerNickname: nickname ) - let recipientID = message.senderPeerID ?? peerID + let recipientID = message.senderPeerID?.id ?? peerID Task { @MainActor in var originalTransport: String? = nil @@ -5944,7 +5944,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate { if isMessageBlocked(finalMessage) { return } // Classify origin: geochat if senderPeerID starts with 'nostr:', else mesh (or system) - let isGeo = finalMessage.senderPeerID?.hasPrefix("nostr:") == true + let isGeo = finalMessage.senderPeerID?.isGeoChat == true // Apply per-sender and per-content rate limits (drop if exceeded) if finalMessage.sender != "system" { diff --git a/bitchatTests/EndToEnd/PublicChatE2ETests.swift b/bitchatTests/EndToEnd/PublicChatE2ETests.swift index 1df9ff7a..25e58055 100644 --- a/bitchatTests/EndToEnd/PublicChatE2ETests.swift +++ b/bitchatTests/EndToEnd/PublicChatE2ETests.swift @@ -111,7 +111,7 @@ final class PublicChatE2ETests: XCTestCase { originalSender: message.sender, isPrivate: message.isPrivate, recipientNickname: message.recipientNickname, - senderPeerID: message.senderPeerID, + senderPeerID: message.senderPeerID?.id, mentions: message.mentions ) @@ -451,7 +451,7 @@ final class PublicChatE2ETests: XCTestCase { originalSender: message.isRelay ? message.originalSender : message.sender, isPrivate: message.isPrivate, recipientNickname: message.recipientNickname, - senderPeerID: message.senderPeerID, + senderPeerID: message.senderPeerID?.id, mentions: message.mentions ) diff --git a/bitchatTests/Integration/IntegrationTests.swift b/bitchatTests/Integration/IntegrationTests.swift index 076dd281..50249e37 100644 --- a/bitchatTests/Integration/IntegrationTests.swift +++ b/bitchatTests/Integration/IntegrationTests.swift @@ -638,7 +638,7 @@ final class IntegrationTests: XCTestCase { originalSender: message.isRelay ? message.originalSender : message.sender, isPrivate: message.isPrivate, recipientNickname: message.recipientNickname, - senderPeerID: message.senderPeerID, + senderPeerID: message.senderPeerID?.id, mentions: message.mentions ) diff --git a/bitchatTests/Utils/PeerIDTests.swift b/bitchatTests/Utils/PeerIDTests.swift index bdb7d561..d1b75839 100644 --- a/bitchatTests/Utils/PeerIDTests.swift +++ b/bitchatTests/Utils/PeerIDTests.swift @@ -290,6 +290,48 @@ final class PeerIDTests: XCTestCase { XCTAssertEqual(sorted, [p1, p2]) } + func test_equality() { + let string = "aaa" + let peerID = PeerID(str: string) + let badString = "bbb" + + // PeerID == String + XCTAssertTrue(peerID == string) + XCTAssertTrue(peerID == Optional(string)) + XCTAssertTrue(Optional(peerID) == string) + XCTAssertTrue(Optional(peerID) == Optional(string)) + + // PeerID != String + XCTAssertTrue(peerID != badString) + XCTAssertTrue(peerID != Optional(badString)) + XCTAssertTrue(Optional(peerID) != badString) + XCTAssertTrue(Optional(peerID) != Optional(badString)) + + // String == PeerID + XCTAssertTrue(string == peerID) + XCTAssertTrue(Optional(string) == peerID) + XCTAssertTrue(string == Optional(peerID)) + XCTAssertTrue(Optional(string) == Optional(peerID)) + + // String != PeerID + XCTAssertTrue(badString != peerID) + XCTAssertTrue(Optional(badString) != peerID) + XCTAssertTrue(badString != Optional(peerID)) + XCTAssertTrue(Optional(badString) != Optional(peerID)) + + + // Make sure the regular PeerID <> PeerID is not broken + XCTAssertTrue(peerID == PeerID(str: "aaa")) + XCTAssertTrue(peerID == Optional(PeerID(str: "aaa"))) + XCTAssertTrue(PeerID(str: "aaa") == peerID) + XCTAssertTrue(Optional(PeerID(str: "aaa")) == Optional(peerID)) + + XCTAssertTrue(peerID != PeerID(str: "bbb")) + XCTAssertTrue(peerID != Optional(PeerID(str: "bbb"))) + XCTAssertTrue(PeerID(str: "bbb") != peerID) + XCTAssertTrue(Optional(PeerID(str: "bbb")) != Optional(peerID)) + } + // MARK: - Computed properties func test_isEmpty_true_and_false() {