From 1c33a92765670c4c09ccb4fac1308e3d5a4ee863 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Tue, 19 Aug 2025 23:37:15 +0200 Subject: [PATCH] Feature/signed public identity (#456) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Require signed announces; add Ed25519 key/signature/timestamp TLVs and verify - Protocol: AnnouncementPacket now requires ed25519PublicKey (0x03), announceSignature (0x04), and announceTimestamp (0x05). Encoder emits, decoder requires; unknown TLVs still tolerated. - NoiseEncryptionService: add canonical announce sign/verify helpers using context 'bitchat-announce-v1'. - BLEService: sign Announce, include TLVs; on receive verify (±5 min skew) and ignore unverified; store ed25519 key and isVerifiedNickname in PeerInfo. - Preserve 8-byte on-wire IDs to keep BLE headers small; no per-message bloat. * Fix Data.append usage for timestamp bytes in Packets and NoiseEncryptionService (use append(contentsOf:) on UnsafeRawBufferPointer) * BLEService: fix non-optional announce fields and unwrap signature result; enforce required verification path * Enforce verified-only public messages; append peerID suffix on nickname collisions in handleMessage * Suffix duplicate nicknames with peerID prefix in snapshots and getPeerNicknames; ensures lists and messages disambiguate 'jack' vs 'jack' * Include self nickname in collision detection: suffix remote 'jack' when it matches our nickname in lists and public chat * UI labels: remove space before '#abcd' suffix in names (public chat, peer lists, snapshots) * Style '#abcd' suffix light gray: - Public chat: color suffix in sender via AttributedString segments - People list: split name and color suffix segment; add helper splitNameSuffix() * Debounce 'bitchatters nearby' notification: add 60s grace before reset when mesh goes empty; cancel reset when peers return * Lighten '#abcd' suffix: use Color.secondary.opacity(0.6) in public chat sender and People list * Toolbar peers indicator: use blue when Bluetooth peers present (was default text color) * Toolbar peers indicator: use grey when zero peers (was red) * Toolbar peers indicator: use system grey (Color.secondary) when zero peers, not green * Fix crash: mutate peripherals dict on BLE queue (not main). Move scan restart to BLE queue as well. * Reduce connect timeout churn: back off peripherals that time out (15s), increase timeout to 8s, skip non-connectable adverts, and demote timeout log to debug; clean backoff map periodically * Mentions: support '@name#abcd' disambiguation; filter hashtag/URL styling inside mentions; deliver notifications only to the specified suffixed target when collisions exist * Fix string interpolation in mention log (escape sequence) * Mentions: parse '@name#abcd' in sender/receiver; render mention suffix grey (not orange/blue/underlined); ensure receiver notifications trigger for suffixed tokens * Mentions: include own suffixed token 'name#' in valid tokens so '@name#abcd' to self is recognized and notified * Public actions: show own system message by processing own public messages (remove early-return). Private /hug: add local system message for sender's chat. * Grammar: change local '/hug' message to past tense ('you hugged/slapped'). Notifications: only fire 'bitchatters nearby' on rising-edge; remove 5-min re-fire and increase empty reset grace to 10m. * Public /hug echo: add local system message so sender sees action immediately (no reliance on echo) * Fix visibility: expose addPublicSystemMessage on ChatViewModel and use it from CommandProcessor * use noise pubkey wip * ttl=0 for signatures * verification works --------- Co-authored-by: jack --- bitchat/Protocols/BitchatProtocol.swift | 19 +- bitchat/Protocols/Packets.swift | 50 +++-- bitchat/Services/BLEService.swift | 169 ++++++++++++---- bitchat/Services/CommandProcessor.swift | 14 +- bitchat/Services/NoiseEncryptionService.swift | 38 ++++ bitchat/ViewModels/ChatViewModel.swift | 185 +++++++++++++----- bitchat/Views/ContentView.swift | 30 ++- 7 files changed, 402 insertions(+), 103 deletions(-) diff --git a/bitchat/Protocols/BitchatProtocol.swift b/bitchat/Protocols/BitchatProtocol.swift index cf832b36..4f138c21 100644 --- a/bitchat/Protocols/BitchatProtocol.swift +++ b/bitchat/Protocols/BitchatProtocol.swift @@ -192,7 +192,7 @@ struct BitchatPacket: Codable { let recipientID: Data? let timestamp: UInt64 let payload: Data - let signature: Data? + var signature: Data? var ttl: UInt8 init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8) { @@ -236,6 +236,23 @@ struct BitchatPacket: Codable { BinaryProtocol.encode(self) } + /// Create binary representation for signing (without signature and TTL fields) + /// TTL is excluded because it changes during packet relay operations + func toBinaryDataForSigning() -> Data? { + // Create a copy without signature and with fixed TTL for signing + // TTL must be excluded because it changes during relay + let unsignedPacket = BitchatPacket( + type: type, + senderID: senderID, + recipientID: recipientID, + timestamp: timestamp, + payload: payload, + signature: nil, // Remove signature for signing + ttl: 0 // Use fixed TTL=0 for signing to ensure relay compatibility + ) + return BinaryProtocol.encode(unsignedPacket) + } + static func from(_ data: Data) -> BitchatPacket? { BinaryProtocol.decode(data) } diff --git a/bitchat/Protocols/Packets.swift b/bitchat/Protocols/Packets.swift index 519c20c0..b735f486 100644 --- a/bitchat/Protocols/Packets.swift +++ b/bitchat/Protocols/Packets.swift @@ -4,11 +4,13 @@ import Foundation struct AnnouncementPacket { let nickname: String - let publicKey: Data + let noisePublicKey: Data // Noise static public key (Curve25519.KeyAgreement) + let signingPublicKey: Data // Ed25519 public key for signing private enum TLVType: UInt8 { case nickname = 0x01 case noisePublicKey = 0x02 + case signingPublicKey = 0x03 } func encode() -> Data? { @@ -20,11 +22,17 @@ struct AnnouncementPacket { data.append(UInt8(nicknameData.count)) data.append(nicknameData) - // TLV for public key - guard publicKey.count <= 255 else { return nil } + // TLV for noise public key + guard noisePublicKey.count <= 255 else { return nil } data.append(TLVType.noisePublicKey.rawValue) - data.append(UInt8(publicKey.count)) - data.append(publicKey) + data.append(UInt8(noisePublicKey.count)) + data.append(noisePublicKey) + + // TLV for signing public key + guard signingPublicKey.count <= 255 else { return nil } + data.append(TLVType.signingPublicKey.rawValue) + data.append(UInt8(signingPublicKey.count)) + data.append(signingPublicKey) return data } @@ -32,12 +40,12 @@ struct AnnouncementPacket { static func decode(from data: Data) -> AnnouncementPacket? { var offset = 0 var nickname: String? - var publicKey: Data? + var noisePublicKey: Data? + var signingPublicKey: Data? while offset + 2 <= data.count { - guard let type = TLVType(rawValue: data[offset]) else { return nil } + let typeRaw = data[offset] offset += 1 - let length = Int(data[offset]) offset += 1 @@ -45,16 +53,27 @@ struct AnnouncementPacket { let value = data[offset.. MTU) private var incomingFragments: [String: [Int: Data]] = [:] private var fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)] = [:] + // Backoff for peripherals that recently timed out connecting + private var recentConnectTimeouts: [String: Date] = [:] // Peripheral UUID -> last timeout // Simple announce throttling private var lastAnnounceSent = Date.distantPast @@ -113,10 +117,20 @@ final class BLEService: NSObject { func currentPeerSnapshots() -> [TransportPeerSnapshot] { collectionsQueue.sync { - peers.values.map { info in - TransportPeerSnapshot( + // Compute nickname collision counts for connected peers + let connected = peers.values.filter { $0.isConnected } + var counts: [String: Int] = [:] + for p in connected { counts[p.nickname, default: 0] += 1 } + // Include our own nickname in collision counts so remote matching ours gets suffixed + counts[myNickname, default: 0] += 1 + return peers.values.map { info in + var display = info.nickname + if info.isConnected, (counts[info.nickname] ?? 0) > 1 { + display += "#" + String(info.id.prefix(4)) + } + return TransportPeerSnapshot( id: info.id, - nickname: info.nickname, + nickname: display, isConnected: info.isConnected, noisePublicKey: info.noisePublicKey, lastSeen: info.lastSeen @@ -349,9 +363,22 @@ final class BLEService: NSObject { func getPeerNicknames() -> [String: String] { return collectionsQueue.sync { - Dictionary(uniqueKeysWithValues: peers.compactMap { (id, info) in - info.isConnected ? (id, info.nickname) : nil - }) + // Only connected peers + let connected = peers.filter { $0.value.isConnected } + // Count collisions by nickname (include our own nickname) + var counts: [String: Int] = [:] + for (_, info) in connected { counts[info.nickname, default: 0] += 1 } + counts[myNickname, default: 0] += 1 + // Build map with suffix for collisions + var result: [String: String] = [:] + for (id, info) in connected { + var name = info.nickname + if (counts[info.nickname] ?? 0) > 1 { + name += "#" + String(id.prefix(4)) + } + result[id] = name + } + return result } } @@ -1028,14 +1055,12 @@ final class BLEService: NSObject { return } - // Verify that the sender's derived ID from the announced public key matches the packet senderID + // Verify that the sender's derived ID from the announced noise public key matches the packet senderID // This helps detect relayed or spoofed announces. Only warn in release; assert in debug. - let derivedFromKey = PeerIDUtils.derivePeerID(fromPublicKey: announcement.publicKey) + let derivedFromKey = PeerIDUtils.derivePeerID(fromPublicKey: announcement.noisePublicKey) if derivedFromKey != peerID { SecureLogger.log("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))…", category: SecureLogger.security, level: .warning) - #if DEBUG - assertionFailure("Announce senderID does not match key-derived ID") - #endif + } // Don't add ourselves as a peer @@ -1066,23 +1091,49 @@ final class BLEService: NSObject { isNewPeer = (existingPeer == nil) isReconnectedPeer = wasDisconnected + // Verify packet signature using the announced signing public key + var verified = false + if packet.signature != nil { + // Verify that the packet was signed by the signing private key corresponding to the announced signing public key + verified = noiseService.verifyPacketSignature(packet, publicKey: announcement.signingPublicKey) + if !verified { + SecureLogger.log("⚠️ Signature verification for announce failed \(peerID.prefix(8))", category: SecureLogger.security, level: .warning) + } + } + + // If existing peer has a different noise public key, do not consider this verified + if let existing = existingPeer, let existingKey = existing.noisePublicKey, existingKey != announcement.noisePublicKey { + SecureLogger.log("⚠️ Announce key mismatch for \(peerID.prefix(8))… — keeping unverified", category: SecureLogger.security, level: .warning) + verified = false + } + + // Require verified announce; ignore otherwise (no backward compatibility) + if !verified { + SecureLogger.log("❌ Ignoring unverified announce from \(peerID.prefix(8))…", category: SecureLogger.security, level: .warning) + return + } + // Update or create peer info if let existing = existingPeer, existing.isConnected { - // Peer already connected, just update lastSeen to keep connection alive + // Update lastSeen and identity info peers[peerID] = PeerInfo( id: existing.id, - nickname: announcement.nickname, // Update nickname in case it changed + nickname: announcement.nickname, isConnected: true, - noisePublicKey: announcement.publicKey, - lastSeen: Date() // Update timestamp to prevent timeout + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + isVerifiedNickname: true, + lastSeen: Date() ) } else { // New peer or reconnecting peer peers[peerID] = PeerInfo( id: peerID, nickname: announcement.nickname, - isConnected: true, // If we received their announce, we're connected - noisePublicKey: announcement.publicKey, + isConnected: true, + noisePublicKey: announcement.noisePublicKey, + signingPublicKey: announcement.signingPublicKey, + isVerifiedNickname: true, lastSeen: Date() ) } @@ -1130,17 +1181,26 @@ final class BLEService: NSObject { // Mention parsing moved to ChatViewModel private func handleMessage(_ packet: BitchatPacket, from peerID: String) { - // Don't process our own messages - if peerID == myPeerID { + + // Enforce: only accept public messages from verified peers we know + guard let info = peers[peerID], info.isVerifiedNickname else { + SecureLogger.log("🚫 Dropping public message from unverified or unknown peer \(peerID.prefix(8))…", category: SecureLogger.security, level: .warning) return } - + guard let content = String(data: packet.payload, encoding: .utf8) else { SecureLogger.log("❌ Failed to decode message payload as UTF-8", category: SecureLogger.session, level: .error) return } - let senderNickname = peers[peerID]?.nickname ?? "Unknown" + // Resolve display nickname; if collisions exist, append short peerID suffix + var senderNickname = info.nickname + // Treat a collision if another connected peer shares the nickname OR our own nickname matches + let hasCollision = peers.values.contains { $0.isConnected && $0.nickname == info.nickname && $0.id != peerID } || (myNickname == info.nickname) + if hasCollision { + senderNickname += "#" + String(peerID.prefix(4)) + } + SecureLogger.log("💬 [\(senderNickname)] TTL:\(packet.ttl): \(String(content.prefix(50)))\(content.count > 50 ? "..." : "")", category: SecureLogger.session, level: .debug) let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000) @@ -1287,9 +1347,14 @@ final class BLEService: NSObject { // Reduced logging - only log errors, not every announce + // Create announce payload with both noise and signing public keys + let noisePub = noiseService.getStaticPublicKeyData() // For noise handshakes and peer identification + let signingPub = noiseService.getSigningPublicKeyData() // For signature verification + let announcement = AnnouncementPacket( nickname: myNickname, - publicKey: noiseService.getStaticPublicKeyData() + noisePublicKey: noisePub, + signingPublicKey: signingPub ) guard let payload = announcement.encode() else { @@ -1297,19 +1362,29 @@ final class BLEService: NSObject { return } + // Create packet with signature using the noise private key let packet = BitchatPacket( type: MessageType.announce.rawValue, - ttl: messageTTL, - senderID: myPeerID, - payload: payload + senderID: Data(hexString: myPeerID) ?? Data(), + recipientID: nil, + timestamp: UInt64(Date().timeIntervalSince1970 * 1000), + payload: payload, + signature: nil, // Will be set by signPacket below + ttl: messageTTL ) + // Sign the packet using the noise private key + guard let signedPacket = noiseService.signPacket(packet) else { + SecureLogger.log("❌ Failed to sign announce packet", category: SecureLogger.security, level: .error) + return + } + // Call directly if on messageQueue, otherwise dispatch if DispatchQueue.getSpecific(key: messageQueueKey) != nil { - broadcastPacket(packet) + broadcastPacket(signedPacket) } else { messageQueue.async { [weak self] in - self?.broadcastPacket(packet) + self?.broadcastPacket(signedPacket) } } } @@ -1356,10 +1431,19 @@ final class BLEService: NSObject { // NEW: Publish peer snapshots to subscribers and notify Transport delegates private func publishFullPeerData() { let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync { - peers.values.map { info in - TransportPeerSnapshot( + // Compute nickname collision counts for connected peers + let connected = peers.values.filter { $0.isConnected } + var counts: [String: Int] = [:] + for p in connected { counts[p.nickname, default: 0] += 1 } + counts[myNickname, default: 0] += 1 + return peers.values.map { info in + var display = info.nickname + if info.isConnected, (counts[info.nickname] ?? 0) > 1 { + display += "#" + String(info.id.prefix(4)) + } + return TransportPeerSnapshot( id: info.id, - nickname: info.nickname, + nickname: display, isConnected: info.isConnected, noisePublicKey: info.noisePublicKey, lastSeen: info.lastSeen @@ -1463,6 +1547,10 @@ final class BLEService: NSObject { fragmentMetadata.removeValue(forKey: fragmentID) } } + + // Clean old connection timeout backoff entries (> 2 minutes) + let timeoutCutoff = now.addingTimeInterval(-120) + recentConnectTimeouts = recentConnectTimeouts.filter { $0.value >= timeoutCutoff } } } @@ -1499,9 +1587,13 @@ extension BLEService: CBCentralManagerDelegate { func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) { let peripheralID = peripheral.identifier.uuidString - let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? "Unknown" + let advertisedName = advertisementData[CBAdvertisementDataLocalNameKey] as? String ?? (peripheralID.prefix(6) + "…") + let isConnectable = (advertisementData[CBAdvertisementDataIsConnectable] as? NSNumber)?.boolValue ?? true let rssiValue = RSSI.intValue + // Skip if peripheral is not connectable (per advertisement data) + guard isConnectable else { return } + // Skip if signal too weak - prevents connection attempts at extreme range guard rssiValue > -90 else { // Too far away, don't attempt connection @@ -1523,6 +1615,11 @@ extension BLEService: CBCentralManagerDelegate { } } + // Backoff if this peripheral recently timed out connection within the last 15 seconds + if let lastTimeout = recentConnectTimeouts[peripheralID], Date().timeIntervalSince(lastTimeout) < 15 { + return + } + // Check peripheral state - but cancel if stale if peripheral.state == .connecting || peripheral.state == .connected { // iOS might have stale state - force disconnect and retry @@ -1557,17 +1654,19 @@ extension BLEService: CBCentralManagerDelegate { ] central.connect(peripheral, options: options) - // Set a timeout for the connection attempt - DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in + // Set a timeout for the connection attempt (slightly longer for reliability) + // Use BLE queue to mutate BLE-related state consistently + bleQueue.asyncAfter(deadline: .now() + 8.0) { [weak self] in guard let self = self, let state = self.peripherals[peripheralID], state.isConnecting && !state.isConnected else { return } // Connection timed out - cancel it SecureLogger.log("⏱️ Timeout: \(advertisedName)", - category: SecureLogger.session, level: .warning) + category: SecureLogger.session, level: .debug) central.cancelPeripheralConnection(peripheral) self.peripherals[peripheralID] = nil + self.recentConnectTimeouts[peripheralID] = Date() } } @@ -1622,7 +1721,7 @@ extension BLEService: CBCentralManagerDelegate { if centralManager?.state == .poweredOn { // Stop and restart scanning to ensure we get fresh discovery events centralManager?.stopScan() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in + bleQueue.asyncAfter(deadline: .now() + 0.1) { [weak self] in self?.startScanning() } } diff --git a/bitchat/Services/CommandProcessor.swift b/bitchat/Services/CommandProcessor.swift index 0beb91d5..40a3c3dc 100644 --- a/bitchat/Services/CommandProcessor.swift +++ b/bitchat/Services/CommandProcessor.swift @@ -124,10 +124,22 @@ class CommandProcessor { meshService?.sendPrivateMessage(personalMessage, to: targetPeerID, recipientNickname: peerNickname, messageID: UUID().uuidString) + // Also add a local system message so the sender sees a natural-language confirmation + let pastAction: String = { + switch action { + case "hugs": return "hugged" + case "slaps": return "slapped" + default: return action.hasSuffix("e") ? action + "d" : action + "ed" + } + }() + let localText = "\(emoji) you \(pastAction) \(nickname)\(suffix)" + chatViewModel?.addLocalPrivateSystemMessage(localText, to: targetPeerID) } } else { - // In public chat + // In public chat: send to mesh and also add a local system echo so sender sees it immediately meshService?.sendMessage(emoteContent, mentions: []) + let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)" + chatViewModel?.addPublicSystemMessage(publicEcho) } return .handled diff --git a/bitchat/Services/NoiseEncryptionService.swift b/bitchat/Services/NoiseEncryptionService.swift index 196d7bb7..eaf4f176 100644 --- a/bitchat/Services/NoiseEncryptionService.swift +++ b/bitchat/Services/NoiseEncryptionService.swift @@ -297,6 +297,44 @@ class NoiseEncryptionService { } } + // MARK: - Packet Signing/Verification + + /// Sign a BitchatPacket using the noise private key + func signPacket(_ packet: BitchatPacket) -> BitchatPacket? { + // Create canonical packet bytes for signing + guard let packetData = packet.toBinaryDataForSigning() else { + return nil + } + + // Sign with the noise private key (converted to Ed25519 for signing) + guard let signature = signData(packetData) else { + return nil + } + + // Return new packet with signature + var signedPacket = packet + signedPacket.signature = signature + return signedPacket + } + + /// Verify a BitchatPacket signature using the provided public key + func verifyPacketSignature(_ packet: BitchatPacket, publicKey: Data) -> Bool { + guard let signature = packet.signature else { + return false + } + + // Create canonical packet bytes for verification (without signature) + + guard let packetData = packet.toBinaryDataForSigning() else { + return false + } + + // For noise public keys, we need to derive the Ed25519 key for verification + // This assumes the noise key can be used for Ed25519 signing + return verifySignature(signature, for: packetData, publicKey: publicKey) + } + + // MARK: - Handshake Management /// Initiate a Noise handshake with a peer diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 9c794112..fe23a387 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -99,6 +99,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { private var hasNotifiedNetworkAvailable = false private var recentlySeenPeers: Set = [] private var lastNetworkNotificationTime = Date.distantPast + private var networkResetTimer: Timer? = nil + private let networkResetGraceSeconds: TimeInterval = 600 // 10 minutes; avoid refiring on short drops/reconnects @Published var nickname: String = "" { didSet { // Trim whitespace whenever nickname is set @@ -895,6 +897,25 @@ class ChatViewModel: ObservableObject, BitchatDelegate { addSystemMessage("Cannot send message to \(recipientNickname ?? "user") - peer is not reachable via mesh or Nostr.") } } + + /// Add a local system message to a private chat (no network send) + @MainActor + func addLocalPrivateSystemMessage(_ content: String, to peerID: String) { + let systemMessage = BitchatMessage( + sender: "system", + content: content, + timestamp: Date(), + isRelay: false, + originalSender: nil, + isPrivate: true, + recipientNickname: meshService.peerNickname(peerID: peerID), + senderPeerID: meshService.myPeerID + ) + if privateChats[peerID] == nil { privateChats[peerID] = [] } + privateChats[peerID]?.append(systemMessage) + trimPrivateChatMessagesIfNeeded(for: peerID) + objectWillChange.send() + } // MARK: - Bluetooth State Management @@ -1776,22 +1797,34 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0) if message.sender != "system" { - // Sender (at the beginning) - let sender = AttributedString("<@\(message.sender)> ") + // Sender (at the beginning) with light-gray suffix styling if present + let (baseName, suffix) = splitSuffix(from: message.sender) var senderStyle = AttributeContainer() - // Use consistent color for all senders senderStyle.foregroundColor = primaryColor // Bold the user's own nickname let fontWeight: Font.Weight = message.sender == nickname ? .bold : .medium senderStyle.font = .system(size: 14, weight: fontWeight, design: .monospaced) - result.append(sender.mergingAttributes(senderStyle)) + + // Prefix "<@" + result.append(AttributedString("<@").mergingAttributes(senderStyle)) + // Base name + result.append(AttributedString(baseName).mergingAttributes(senderStyle)) + // Optional suffix (light gray) + if !suffix.isEmpty { + var suffixStyle = senderStyle + suffixStyle.foregroundColor = Color.secondary.opacity(0.6) + result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) + } + // Suffix "> " + result.append(AttributedString("> ").mergingAttributes(senderStyle)) // Process content with hashtags and mentions let content = message.content let hashtagPattern = "#([a-zA-Z0-9_]+)" - let mentionPattern = "@([\\p{L}0-9_]+)" + // Allow optional '#abcd' suffix in mentions + let mentionPattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)" let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: []) let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: []) @@ -1803,15 +1836,20 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let mentionMatches = mentionRegex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] let urlMatches = detector?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] - // Combine and sort matches + // Combine and sort matches, excluding hashtags/URLs overlapping mentions + let mentionRanges = mentionMatches.map { $0.range(at: 0) } + func overlapsMention(_ r: NSRange) -> Bool { + for mr in mentionRanges { if NSIntersectionRange(r, mr).length > 0 { return true } } + return false + } var allMatches: [(range: NSRange, type: String)] = [] - for match in hashtagMatches { + for match in hashtagMatches where !overlapsMention(match.range(at: 0)) { allMatches.append((match.range(at: 0), "hashtag")) } for match in mentionMatches { allMatches.append((match.range(at: 0), "mention")) } - for match in urlMatches { + for match in urlMatches where !overlapsMention(match.range) { allMatches.append((match.range, "url")) } allMatches.sort { $0.range.location < $1.range.location } @@ -1836,20 +1874,34 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Add styled match let matchText = String(content[nsRange]) - var matchStyle = AttributeContainer() - matchStyle.font = .system(size: 14, weight: .semibold, design: .monospaced) - - if type == "hashtag" { - matchStyle.foregroundColor = Color.blue - matchStyle.underlineStyle = .single - } else if type == "mention" { - matchStyle.foregroundColor = Color.orange - } else if type == "url" { - matchStyle.foregroundColor = Color.blue - matchStyle.underlineStyle = .single + if type == "mention" { + // Split optional '#abcd' suffix and color suffix light grey + let (mBase, mSuffix) = splitSuffix(from: matchText.replacingOccurrences(of: "@", with: "")) + var mentionStyle = AttributeContainer() + mentionStyle.font = .system(size: 14, weight: .semibold, design: .monospaced) + mentionStyle.foregroundColor = Color.orange + // Emit '@' + result.append(AttributedString("@").mergingAttributes(mentionStyle)) + // Base name in orange + result.append(AttributedString(mBase).mergingAttributes(mentionStyle)) + // Suffix in light grey + if !mSuffix.isEmpty { + var grey = mentionStyle + grey.foregroundColor = Color.secondary.opacity(0.6) + result.append(AttributedString(mSuffix).mergingAttributes(grey)) + } + } else { + var matchStyle = AttributeContainer() + matchStyle.font = .system(size: 14, weight: .semibold, design: .monospaced) + if type == "hashtag" { + matchStyle.foregroundColor = Color.blue + matchStyle.underlineStyle = .single + } else if type == "url" { + matchStyle.foregroundColor = Color.blue + matchStyle.underlineStyle = .single + } + result.append(AttributedString(matchText).mergingAttributes(matchStyle)) } - - result.append(AttributedString(matchText).mergingAttributes(matchStyle)) lastEnd = nsRange.upperBound } } @@ -1893,6 +1945,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate { return result } + + // Split a nickname into base and a '#abcd' suffix if present + private func splitSuffix(from name: String) -> (String, String) { + guard name.count >= 5 else { return (name, "") } + let suffix = String(name.suffix(5)) + if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in + ("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c)) + }) { + let base = String(name.dropLast(5)) + return (base, suffix) + } + return (name, "") + } func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString { var result = AttributedString() @@ -2560,6 +2625,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Smart notification logic for "bitchatters nearby" if !peers.isEmpty { + // Cancel any pending reset if peers are back + self.networkResetTimer?.invalidate() + self.networkResetTimer = nil // Only count mesh peers (actually connected via Bluetooth) let meshPeers = peers.filter { peerID in self.meshService.isPeerConnected(peerID) @@ -2568,14 +2636,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Check if we have new mesh peers we haven't seen recently let currentPeerSet = Set(meshPeers) let newPeers = currentPeerSet.subtracting(self.recentlySeenPeers) - let timeSinceLastNotification = Date().timeIntervalSince(self.lastNetworkNotificationTime) - // Send notification if: // 1. We have mesh peers (not just Nostr-only) - // 2. There are new peers we haven't seen - // 3. Either it's been more than 5 minutes since last notification OR we haven't notified yet - if meshPeers.count > 0 && !newPeers.isEmpty && - (timeSinceLastNotification > 300 || !self.hasNotifiedNetworkAvailable) { + // 2. There are new peers we haven't seen (rising-edge) + // 3. We haven't already notified since the last sustained-empty period + if meshPeers.count > 0 && !newPeers.isEmpty && !self.hasNotifiedNetworkAvailable { self.hasNotifiedNetworkAvailable = true self.lastNetworkNotificationTime = Date() self.recentlySeenPeers = currentPeerSet @@ -2584,9 +2649,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate { category: SecureLogger.session, level: .info) } } else { - // No peers - reset tracking - self.hasNotifiedNetworkAvailable = false - self.recentlySeenPeers.removeAll() + // No peers - schedule a graceful reset to avoid refiring on brief drops + if self.networkResetTimer == nil { + self.networkResetTimer = Timer.scheduledTimer(withTimeInterval: self.networkResetGraceSeconds, repeats: false) { [weak self] _ in + guard let self = self else { return } + self.hasNotifiedNetworkAvailable = false + self.recentlySeenPeers.removeAll() + self.networkResetTimer = nil + SecureLogger.log("⏳ Mesh empty for \(Int(self.networkResetGraceSeconds))s — reset network notification state", category: SecureLogger.session, level: .debug) + } + } } // Register ephemeral sessions for all connected peers @@ -2685,19 +2757,25 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } private func parseMentions(from content: String) -> [String] { - let pattern = "@([\\p{L}0-9_]+)" + // Allow optional disambiguation suffix '#abcd' for duplicate nicknames + let pattern = "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)" let regex = try? NSRegularExpression(pattern: pattern, options: []) let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? [] var mentions: [String] = [] let peerNicknames = meshService.getPeerNicknames() - let allNicknames = Set(peerNicknames.values).union([nickname]) // Include self + // Compose the valid mention tokens based on current peers (already suffixed where needed) + var validTokens = Set(peerNicknames.values) + // Always allow mentioning self by base nickname and suffixed disambiguator + validTokens.insert(nickname) + let selfSuffixToken = nickname + "#" + String(meshService.myPeerID.prefix(4)) + validTokens.insert(selfSuffixToken) for match in matches { if let range = Range(match.range(at: 1), in: content) { let mentionedName = String(content[range]) - // Only include if it's a valid nickname - if allNicknames.contains(mentionedName) { + // Only include if it's a current valid token (base or suffixed) + if validTokens.contains(mentionedName) { mentions.append(mentionedName) } } @@ -2804,6 +2882,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate { ) messages.append(systemMessage) } + + /// Public helper to add a system message to the public chat timeline + @MainActor + func addPublicSystemMessage(_ content: String) { + addSystemMessage(content) + objectWillChange.send() + } // MARK: - Simplified Nostr Integration (Inlined from MessageRouter) @@ -3672,19 +3757,27 @@ class ChatViewModel: ObservableObject, BitchatDelegate { } /// Check for mentions and send notifications - private func checkForMentions(_ message: BitchatMessage) { - let isMentioned = message.mentions?.contains(nickname) ?? false - - // Mention check: \(isMentioned ? "mentioned" : "not mentioned") - - if isMentioned && message.sender != nickname { - SecureLogger.log("🔔 Mention from \(message.sender)", - category: SecureLogger.session, level: .info) - NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) - } - } - /// Send haptic feedback for special messages (iOS only) +private func checkForMentions(_ message: BitchatMessage) { + // Determine our acceptable mention token. If any connected peer shares our nickname, + // require the disambiguated form '#' to trigger. + var myTokens: Set = [nickname] + let meshPeers = meshService.getPeerNicknames() + let collisions = meshPeers.values.filter { $0.hasPrefix(nickname + "#") } + if !collisions.isEmpty { + let suffix = "#" + String(meshService.myPeerID.prefix(4)) + myTokens = [nickname + suffix] + } + let isMentioned = (message.mentions?.contains { myTokens.contains($0) } ?? false) + + if isMentioned && message.sender != nickname { + SecureLogger.log("🔔 Mention from \(message.sender)", + category: SecureLogger.session, level: .info) + NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content) + } +} + +/// Send haptic feedback for special messages (iOS only) private func sendHapticFeedback(for message: BitchatMessage) { #if os(iOS) guard UIApplication.shared.applicationState == .active else { return } diff --git a/bitchat/Views/ContentView.swift b/bitchat/Views/ContentView.swift index 9154dea2..045f3bdf 100644 --- a/bitchat/Views/ContentView.swift +++ b/bitchat/Views/ContentView.swift @@ -719,9 +719,18 @@ struct ContentView: View { Spacer() } } else { - Text(peer.displayName) - .font(.system(size: 14, design: .monospaced)) - .foregroundColor(peer.isFavorite || peerNicknames[peer.id] != nil ? textColor : secondaryTextColor) + // Render nickname with light-gray '#abcd' suffix if present + let parts = splitNameSuffix(peer.displayName) + HStack(spacing: 0) { + Text(parts.base) + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(peer.isFavorite || peerNicknames[peer.id] != nil ? textColor : secondaryTextColor) + if !parts.suffix.isEmpty { + Text(parts.suffix) + .font(.system(size: 14, design: .monospaced)) + .foregroundColor(Color.secondary.opacity(0.6)) + } + } // Encryption status icon (after peer name) if let icon = peer.encryptionStatus.icon { @@ -844,6 +853,19 @@ struct ContentView: View { .foregroundColor(textColor) } } + + // Split a name into base and a '#abcd' suffix if present + private func splitNameSuffix(_ name: String) -> (base: String, suffix: String) { + guard name.count >= 5 else { return (name, "") } + let suffix = String(name.suffix(5)) + if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in + ("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c)) + }) { + let base = String(name.dropLast(5)) + return (base, suffix) + } + return (name, "") + } private var mainHeaderView: some View { @@ -925,7 +947,7 @@ struct ContentView: View { .font(.system(size: 12, design: .monospaced)) .accessibilityHidden(true) } - .foregroundColor(isNostrOnly ? Color.purple : (meshPeerCount > 0 ? textColor : Color.red)) + .foregroundColor(isNostrOnly ? Color.purple : (meshPeerCount > 0 ? Color.blue : Color.secondary)) } .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) {