mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 19:05:20 +00:00
Feature/signed public identity (#456)
* 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#<myIDprefix>' 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 <jackjackbits@users.noreply.github.com>
This commit is contained in:
@@ -192,7 +192,7 @@ struct BitchatPacket: Codable {
|
|||||||
let recipientID: Data?
|
let recipientID: Data?
|
||||||
let timestamp: UInt64
|
let timestamp: UInt64
|
||||||
let payload: Data
|
let payload: Data
|
||||||
let signature: Data?
|
var signature: Data?
|
||||||
var ttl: UInt8
|
var ttl: UInt8
|
||||||
|
|
||||||
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, 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)
|
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? {
|
static func from(_ data: Data) -> BitchatPacket? {
|
||||||
BinaryProtocol.decode(data)
|
BinaryProtocol.decode(data)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,13 @@ import Foundation
|
|||||||
|
|
||||||
struct AnnouncementPacket {
|
struct AnnouncementPacket {
|
||||||
let nickname: String
|
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 {
|
private enum TLVType: UInt8 {
|
||||||
case nickname = 0x01
|
case nickname = 0x01
|
||||||
case noisePublicKey = 0x02
|
case noisePublicKey = 0x02
|
||||||
|
case signingPublicKey = 0x03
|
||||||
}
|
}
|
||||||
|
|
||||||
func encode() -> Data? {
|
func encode() -> Data? {
|
||||||
@@ -20,11 +22,17 @@ struct AnnouncementPacket {
|
|||||||
data.append(UInt8(nicknameData.count))
|
data.append(UInt8(nicknameData.count))
|
||||||
data.append(nicknameData)
|
data.append(nicknameData)
|
||||||
|
|
||||||
// TLV for public key
|
// TLV for noise public key
|
||||||
guard publicKey.count <= 255 else { return nil }
|
guard noisePublicKey.count <= 255 else { return nil }
|
||||||
data.append(TLVType.noisePublicKey.rawValue)
|
data.append(TLVType.noisePublicKey.rawValue)
|
||||||
data.append(UInt8(publicKey.count))
|
data.append(UInt8(noisePublicKey.count))
|
||||||
data.append(publicKey)
|
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
|
return data
|
||||||
}
|
}
|
||||||
@@ -32,12 +40,12 @@ struct AnnouncementPacket {
|
|||||||
static func decode(from data: Data) -> AnnouncementPacket? {
|
static func decode(from data: Data) -> AnnouncementPacket? {
|
||||||
var offset = 0
|
var offset = 0
|
||||||
var nickname: String?
|
var nickname: String?
|
||||||
var publicKey: Data?
|
var noisePublicKey: Data?
|
||||||
|
var signingPublicKey: Data?
|
||||||
|
|
||||||
while offset + 2 <= data.count {
|
while offset + 2 <= data.count {
|
||||||
guard let type = TLVType(rawValue: data[offset]) else { return nil }
|
let typeRaw = data[offset]
|
||||||
offset += 1
|
offset += 1
|
||||||
|
|
||||||
let length = Int(data[offset])
|
let length = Int(data[offset])
|
||||||
offset += 1
|
offset += 1
|
||||||
|
|
||||||
@@ -45,16 +53,27 @@ struct AnnouncementPacket {
|
|||||||
let value = data[offset..<offset + length]
|
let value = data[offset..<offset + length]
|
||||||
offset += length
|
offset += length
|
||||||
|
|
||||||
switch type {
|
if let type = TLVType(rawValue: typeRaw) {
|
||||||
case .nickname:
|
switch type {
|
||||||
nickname = String(data: value, encoding: .utf8)
|
case .nickname:
|
||||||
case .noisePublicKey:
|
nickname = String(data: value, encoding: .utf8)
|
||||||
publicKey = Data(value)
|
case .noisePublicKey:
|
||||||
|
noisePublicKey = Data(value)
|
||||||
|
case .signingPublicKey:
|
||||||
|
signingPublicKey = Data(value)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Unknown TLV; skip (tolerant decoder for forward compatibility)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
guard let nickname = nickname, let publicKey = publicKey else { return nil }
|
guard let nickname = nickname, let noisePublicKey = noisePublicKey, let signingPublicKey = signingPublicKey else { return nil }
|
||||||
return AnnouncementPacket(nickname: nickname, publicKey: publicKey)
|
return AnnouncementPacket(
|
||||||
|
nickname: nickname,
|
||||||
|
noisePublicKey: noisePublicKey,
|
||||||
|
signingPublicKey: signingPublicKey
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,4 +132,3 @@ struct PrivateMessagePacket {
|
|||||||
return PrivateMessagePacket(messageID: messageID, content: content)
|
return PrivateMessagePacket(messageID: messageID, content: content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ final class BLEService: NSObject {
|
|||||||
var nickname: String
|
var nickname: String
|
||||||
var isConnected: Bool
|
var isConnected: Bool
|
||||||
var noisePublicKey: Data?
|
var noisePublicKey: Data?
|
||||||
|
var signingPublicKey: Data?
|
||||||
|
var isVerifiedNickname: Bool
|
||||||
var lastSeen: Date
|
var lastSeen: Date
|
||||||
}
|
}
|
||||||
private var peers: [String: PeerInfo] = [:]
|
private var peers: [String: PeerInfo] = [:]
|
||||||
@@ -59,6 +61,8 @@ final class BLEService: NSObject {
|
|||||||
// 5. Fragment Reassembly (necessary for messages > MTU)
|
// 5. Fragment Reassembly (necessary for messages > MTU)
|
||||||
private var incomingFragments: [String: [Int: Data]] = [:]
|
private var incomingFragments: [String: [Int: Data]] = [:]
|
||||||
private var fragmentMetadata: [String: (type: UInt8, total: Int, timestamp: Date)] = [:]
|
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
|
// Simple announce throttling
|
||||||
private var lastAnnounceSent = Date.distantPast
|
private var lastAnnounceSent = Date.distantPast
|
||||||
@@ -113,10 +117,20 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
|
func currentPeerSnapshots() -> [TransportPeerSnapshot] {
|
||||||
collectionsQueue.sync {
|
collectionsQueue.sync {
|
||||||
peers.values.map { info in
|
// Compute nickname collision counts for connected peers
|
||||||
TransportPeerSnapshot(
|
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,
|
id: info.id,
|
||||||
nickname: info.nickname,
|
nickname: display,
|
||||||
isConnected: info.isConnected,
|
isConnected: info.isConnected,
|
||||||
noisePublicKey: info.noisePublicKey,
|
noisePublicKey: info.noisePublicKey,
|
||||||
lastSeen: info.lastSeen
|
lastSeen: info.lastSeen
|
||||||
@@ -349,9 +363,22 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
func getPeerNicknames() -> [String: String] {
|
func getPeerNicknames() -> [String: String] {
|
||||||
return collectionsQueue.sync {
|
return collectionsQueue.sync {
|
||||||
Dictionary(uniqueKeysWithValues: peers.compactMap { (id, info) in
|
// Only connected peers
|
||||||
info.isConnected ? (id, info.nickname) : nil
|
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
|
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.
|
// 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 {
|
if derivedFromKey != peerID {
|
||||||
SecureLogger.log("⚠️ Announce sender mismatch: derived \(derivedFromKey.prefix(8))… vs packet \(peerID.prefix(8))…", category: SecureLogger.security, level: .warning)
|
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
|
// Don't add ourselves as a peer
|
||||||
@@ -1066,23 +1091,49 @@ final class BLEService: NSObject {
|
|||||||
isNewPeer = (existingPeer == nil)
|
isNewPeer = (existingPeer == nil)
|
||||||
isReconnectedPeer = wasDisconnected
|
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
|
// Update or create peer info
|
||||||
if let existing = existingPeer, existing.isConnected {
|
if let existing = existingPeer, existing.isConnected {
|
||||||
// Peer already connected, just update lastSeen to keep connection alive
|
// Update lastSeen and identity info
|
||||||
peers[peerID] = PeerInfo(
|
peers[peerID] = PeerInfo(
|
||||||
id: existing.id,
|
id: existing.id,
|
||||||
nickname: announcement.nickname, // Update nickname in case it changed
|
nickname: announcement.nickname,
|
||||||
isConnected: true,
|
isConnected: true,
|
||||||
noisePublicKey: announcement.publicKey,
|
noisePublicKey: announcement.noisePublicKey,
|
||||||
lastSeen: Date() // Update timestamp to prevent timeout
|
signingPublicKey: announcement.signingPublicKey,
|
||||||
|
isVerifiedNickname: true,
|
||||||
|
lastSeen: Date()
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// New peer or reconnecting peer
|
// New peer or reconnecting peer
|
||||||
peers[peerID] = PeerInfo(
|
peers[peerID] = PeerInfo(
|
||||||
id: peerID,
|
id: peerID,
|
||||||
nickname: announcement.nickname,
|
nickname: announcement.nickname,
|
||||||
isConnected: true, // If we received their announce, we're connected
|
isConnected: true,
|
||||||
noisePublicKey: announcement.publicKey,
|
noisePublicKey: announcement.noisePublicKey,
|
||||||
|
signingPublicKey: announcement.signingPublicKey,
|
||||||
|
isVerifiedNickname: true,
|
||||||
lastSeen: Date()
|
lastSeen: Date()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -1130,8 +1181,10 @@ final class BLEService: NSObject {
|
|||||||
// Mention parsing moved to ChatViewModel
|
// Mention parsing moved to ChatViewModel
|
||||||
|
|
||||||
private func handleMessage(_ packet: BitchatPacket, from peerID: String) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1140,7 +1193,14 @@ final class BLEService: NSObject {
|
|||||||
return
|
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)
|
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)
|
let ts = Date(timeIntervalSince1970: Double(packet.timestamp) / 1000)
|
||||||
@@ -1287,9 +1347,14 @@ final class BLEService: NSObject {
|
|||||||
|
|
||||||
// Reduced logging - only log errors, not every announce
|
// 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(
|
let announcement = AnnouncementPacket(
|
||||||
nickname: myNickname,
|
nickname: myNickname,
|
||||||
publicKey: noiseService.getStaticPublicKeyData()
|
noisePublicKey: noisePub,
|
||||||
|
signingPublicKey: signingPub
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let payload = announcement.encode() else {
|
guard let payload = announcement.encode() else {
|
||||||
@@ -1297,19 +1362,29 @@ final class BLEService: NSObject {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create packet with signature using the noise private key
|
||||||
let packet = BitchatPacket(
|
let packet = BitchatPacket(
|
||||||
type: MessageType.announce.rawValue,
|
type: MessageType.announce.rawValue,
|
||||||
ttl: messageTTL,
|
senderID: Data(hexString: myPeerID) ?? Data(),
|
||||||
senderID: myPeerID,
|
recipientID: nil,
|
||||||
payload: payload
|
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
|
// Call directly if on messageQueue, otherwise dispatch
|
||||||
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
if DispatchQueue.getSpecific(key: messageQueueKey) != nil {
|
||||||
broadcastPacket(packet)
|
broadcastPacket(signedPacket)
|
||||||
} else {
|
} else {
|
||||||
messageQueue.async { [weak self] in
|
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
|
// NEW: Publish peer snapshots to subscribers and notify Transport delegates
|
||||||
private func publishFullPeerData() {
|
private func publishFullPeerData() {
|
||||||
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
|
let transportPeers: [TransportPeerSnapshot] = collectionsQueue.sync {
|
||||||
peers.values.map { info in
|
// Compute nickname collision counts for connected peers
|
||||||
TransportPeerSnapshot(
|
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,
|
id: info.id,
|
||||||
nickname: info.nickname,
|
nickname: display,
|
||||||
isConnected: info.isConnected,
|
isConnected: info.isConnected,
|
||||||
noisePublicKey: info.noisePublicKey,
|
noisePublicKey: info.noisePublicKey,
|
||||||
lastSeen: info.lastSeen
|
lastSeen: info.lastSeen
|
||||||
@@ -1463,6 +1547,10 @@ final class BLEService: NSObject {
|
|||||||
fragmentMetadata.removeValue(forKey: fragmentID)
|
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) {
|
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||||
let peripheralID = peripheral.identifier.uuidString
|
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
|
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
|
// Skip if signal too weak - prevents connection attempts at extreme range
|
||||||
guard rssiValue > -90 else {
|
guard rssiValue > -90 else {
|
||||||
// Too far away, don't attempt connection
|
// 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
|
// Check peripheral state - but cancel if stale
|
||||||
if peripheral.state == .connecting || peripheral.state == .connected {
|
if peripheral.state == .connecting || peripheral.state == .connected {
|
||||||
// iOS might have stale state - force disconnect and retry
|
// iOS might have stale state - force disconnect and retry
|
||||||
@@ -1557,17 +1654,19 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
]
|
]
|
||||||
central.connect(peripheral, options: options)
|
central.connect(peripheral, options: options)
|
||||||
|
|
||||||
// Set a timeout for the connection attempt
|
// Set a timeout for the connection attempt (slightly longer for reliability)
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 5.0) { [weak self] in
|
// Use BLE queue to mutate BLE-related state consistently
|
||||||
|
bleQueue.asyncAfter(deadline: .now() + 8.0) { [weak self] in
|
||||||
guard let self = self,
|
guard let self = self,
|
||||||
let state = self.peripherals[peripheralID],
|
let state = self.peripherals[peripheralID],
|
||||||
state.isConnecting && !state.isConnected else { return }
|
state.isConnecting && !state.isConnected else { return }
|
||||||
|
|
||||||
// Connection timed out - cancel it
|
// Connection timed out - cancel it
|
||||||
SecureLogger.log("⏱️ Timeout: \(advertisedName)",
|
SecureLogger.log("⏱️ Timeout: \(advertisedName)",
|
||||||
category: SecureLogger.session, level: .warning)
|
category: SecureLogger.session, level: .debug)
|
||||||
central.cancelPeripheralConnection(peripheral)
|
central.cancelPeripheralConnection(peripheral)
|
||||||
self.peripherals[peripheralID] = nil
|
self.peripherals[peripheralID] = nil
|
||||||
|
self.recentConnectTimeouts[peripheralID] = Date()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1622,7 +1721,7 @@ extension BLEService: CBCentralManagerDelegate {
|
|||||||
if centralManager?.state == .poweredOn {
|
if centralManager?.state == .poweredOn {
|
||||||
// Stop and restart scanning to ensure we get fresh discovery events
|
// Stop and restart scanning to ensure we get fresh discovery events
|
||||||
centralManager?.stopScan()
|
centralManager?.stopScan()
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
bleQueue.asyncAfter(deadline: .now() + 0.1) { [weak self] in
|
||||||
self?.startScanning()
|
self?.startScanning()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,10 +124,22 @@ class CommandProcessor {
|
|||||||
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
meshService?.sendPrivateMessage(personalMessage, to: targetPeerID,
|
||||||
recipientNickname: peerNickname,
|
recipientNickname: peerNickname,
|
||||||
messageID: UUID().uuidString)
|
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 {
|
} 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: [])
|
meshService?.sendMessage(emoteContent, mentions: [])
|
||||||
|
let publicEcho = "\(emoji) \(myNickname) \(action) \(nickname)\(suffix)"
|
||||||
|
chatViewModel?.addPublicSystemMessage(publicEcho)
|
||||||
}
|
}
|
||||||
|
|
||||||
return .handled
|
return .handled
|
||||||
|
|||||||
@@ -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
|
// MARK: - Handshake Management
|
||||||
|
|
||||||
/// Initiate a Noise handshake with a peer
|
/// Initiate a Noise handshake with a peer
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
private var hasNotifiedNetworkAvailable = false
|
private var hasNotifiedNetworkAvailable = false
|
||||||
private var recentlySeenPeers: Set<String> = []
|
private var recentlySeenPeers: Set<String> = []
|
||||||
private var lastNetworkNotificationTime = Date.distantPast
|
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 = "" {
|
@Published var nickname: String = "" {
|
||||||
didSet {
|
didSet {
|
||||||
// Trim whitespace whenever nickname is set
|
// Trim whitespace whenever nickname is set
|
||||||
@@ -896,6 +898,25 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
// MARK: - Bluetooth State Management
|
||||||
|
|
||||||
/// Updates the Bluetooth state and shows appropriate alerts
|
/// Updates the Bluetooth state and shows appropriate alerts
|
||||||
@@ -1776,22 +1797,34 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
||||||
|
|
||||||
if message.sender != "system" {
|
if message.sender != "system" {
|
||||||
// Sender (at the beginning)
|
// Sender (at the beginning) with light-gray suffix styling if present
|
||||||
let sender = AttributedString("<@\(message.sender)> ")
|
let (baseName, suffix) = splitSuffix(from: message.sender)
|
||||||
var senderStyle = AttributeContainer()
|
var senderStyle = AttributeContainer()
|
||||||
|
|
||||||
// Use consistent color for all senders
|
// Use consistent color for all senders
|
||||||
senderStyle.foregroundColor = primaryColor
|
senderStyle.foregroundColor = primaryColor
|
||||||
// Bold the user's own nickname
|
// Bold the user's own nickname
|
||||||
let fontWeight: Font.Weight = message.sender == nickname ? .bold : .medium
|
let fontWeight: Font.Weight = message.sender == nickname ? .bold : .medium
|
||||||
senderStyle.font = .system(size: 14, weight: fontWeight, design: .monospaced)
|
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
|
// Process content with hashtags and mentions
|
||||||
let content = message.content
|
let content = message.content
|
||||||
|
|
||||||
let hashtagPattern = "#([a-zA-Z0-9_]+)"
|
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 hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
|
||||||
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, 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 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)) ?? []
|
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)] = []
|
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"))
|
allMatches.append((match.range(at: 0), "hashtag"))
|
||||||
}
|
}
|
||||||
for match in mentionMatches {
|
for match in mentionMatches {
|
||||||
allMatches.append((match.range(at: 0), "mention"))
|
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.append((match.range, "url"))
|
||||||
}
|
}
|
||||||
allMatches.sort { $0.range.location < $1.range.location }
|
allMatches.sort { $0.range.location < $1.range.location }
|
||||||
@@ -1836,20 +1874,34 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// Add styled match
|
// Add styled match
|
||||||
let matchText = String(content[nsRange])
|
let matchText = String(content[nsRange])
|
||||||
var matchStyle = AttributeContainer()
|
if type == "mention" {
|
||||||
matchStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
|
// Split optional '#abcd' suffix and color suffix light grey
|
||||||
|
let (mBase, mSuffix) = splitSuffix(from: matchText.replacingOccurrences(of: "@", with: ""))
|
||||||
if type == "hashtag" {
|
var mentionStyle = AttributeContainer()
|
||||||
matchStyle.foregroundColor = Color.blue
|
mentionStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
|
||||||
matchStyle.underlineStyle = .single
|
mentionStyle.foregroundColor = Color.orange
|
||||||
} else if type == "mention" {
|
// Emit '@'
|
||||||
matchStyle.foregroundColor = Color.orange
|
result.append(AttributedString("@").mergingAttributes(mentionStyle))
|
||||||
} else if type == "url" {
|
// Base name in orange
|
||||||
matchStyle.foregroundColor = Color.blue
|
result.append(AttributedString(mBase).mergingAttributes(mentionStyle))
|
||||||
matchStyle.underlineStyle = .single
|
// 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
|
lastEnd = nsRange.upperBound
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1894,6 +1946,19 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
return result
|
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 {
|
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
||||||
var result = AttributedString()
|
var result = AttributedString()
|
||||||
|
|
||||||
@@ -2560,6 +2625,9 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
|
|
||||||
// Smart notification logic for "bitchatters nearby"
|
// Smart notification logic for "bitchatters nearby"
|
||||||
if !peers.isEmpty {
|
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)
|
// Only count mesh peers (actually connected via Bluetooth)
|
||||||
let meshPeers = peers.filter { peerID in
|
let meshPeers = peers.filter { peerID in
|
||||||
self.meshService.isPeerConnected(peerID)
|
self.meshService.isPeerConnected(peerID)
|
||||||
@@ -2568,14 +2636,11 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
// Check if we have new mesh peers we haven't seen recently
|
// Check if we have new mesh peers we haven't seen recently
|
||||||
let currentPeerSet = Set(meshPeers)
|
let currentPeerSet = Set(meshPeers)
|
||||||
let newPeers = currentPeerSet.subtracting(self.recentlySeenPeers)
|
let newPeers = currentPeerSet.subtracting(self.recentlySeenPeers)
|
||||||
let timeSinceLastNotification = Date().timeIntervalSince(self.lastNetworkNotificationTime)
|
|
||||||
|
|
||||||
// Send notification if:
|
// Send notification if:
|
||||||
// 1. We have mesh peers (not just Nostr-only)
|
// 1. We have mesh peers (not just Nostr-only)
|
||||||
// 2. There are new peers we haven't seen
|
// 2. There are new peers we haven't seen (rising-edge)
|
||||||
// 3. Either it's been more than 5 minutes since last notification OR we haven't notified yet
|
// 3. We haven't already notified since the last sustained-empty period
|
||||||
if meshPeers.count > 0 && !newPeers.isEmpty &&
|
if meshPeers.count > 0 && !newPeers.isEmpty && !self.hasNotifiedNetworkAvailable {
|
||||||
(timeSinceLastNotification > 300 || !self.hasNotifiedNetworkAvailable) {
|
|
||||||
self.hasNotifiedNetworkAvailable = true
|
self.hasNotifiedNetworkAvailable = true
|
||||||
self.lastNetworkNotificationTime = Date()
|
self.lastNetworkNotificationTime = Date()
|
||||||
self.recentlySeenPeers = currentPeerSet
|
self.recentlySeenPeers = currentPeerSet
|
||||||
@@ -2584,9 +2649,16 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
category: SecureLogger.session, level: .info)
|
category: SecureLogger.session, level: .info)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// No peers - reset tracking
|
// No peers - schedule a graceful reset to avoid refiring on brief drops
|
||||||
self.hasNotifiedNetworkAvailable = false
|
if self.networkResetTimer == nil {
|
||||||
self.recentlySeenPeers.removeAll()
|
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
|
// Register ephemeral sessions for all connected peers
|
||||||
@@ -2685,19 +2757,25 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func parseMentions(from content: String) -> [String] {
|
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 regex = try? NSRegularExpression(pattern: pattern, options: [])
|
||||||
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
let matches = regex?.matches(in: content, options: [], range: NSRange(location: 0, length: content.count)) ?? []
|
||||||
|
|
||||||
var mentions: [String] = []
|
var mentions: [String] = []
|
||||||
let peerNicknames = meshService.getPeerNicknames()
|
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 {
|
for match in matches {
|
||||||
if let range = Range(match.range(at: 1), in: content) {
|
if let range = Range(match.range(at: 1), in: content) {
|
||||||
let mentionedName = String(content[range])
|
let mentionedName = String(content[range])
|
||||||
// Only include if it's a valid nickname
|
// Only include if it's a current valid token (base or suffixed)
|
||||||
if allNicknames.contains(mentionedName) {
|
if validTokens.contains(mentionedName) {
|
||||||
mentions.append(mentionedName)
|
mentions.append(mentionedName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2805,6 +2883,13 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
messages.append(systemMessage)
|
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)
|
// MARK: - Simplified Nostr Integration (Inlined from MessageRouter)
|
||||||
|
|
||||||
// Removed inlined Nostr send helpers in favor of MessageRouter
|
// Removed inlined Nostr send helpers in favor of MessageRouter
|
||||||
@@ -3672,19 +3757,27 @@ class ChatViewModel: ObservableObject, BitchatDelegate {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check for mentions and send notifications
|
/// Check for mentions and send notifications
|
||||||
private func checkForMentions(_ message: BitchatMessage) {
|
|
||||||
let isMentioned = message.mentions?.contains(nickname) ?? false
|
|
||||||
|
|
||||||
// Mention check: \(isMentioned ? "mentioned" : "not mentioned")
|
private func checkForMentions(_ message: BitchatMessage) {
|
||||||
|
// Determine our acceptable mention token. If any connected peer shares our nickname,
|
||||||
if isMentioned && message.sender != nickname {
|
// require the disambiguated form '<nickname>#<peerIDprefix>' to trigger.
|
||||||
SecureLogger.log("🔔 Mention from \(message.sender)",
|
var myTokens: Set<String> = [nickname]
|
||||||
category: SecureLogger.session, level: .info)
|
let meshPeers = meshService.getPeerNicknames()
|
||||||
NotificationService.shared.sendMentionNotification(from: message.sender, message: message.content)
|
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)
|
||||||
|
|
||||||
/// Send haptic feedback for special messages (iOS only)
|
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) {
|
private func sendHapticFeedback(for message: BitchatMessage) {
|
||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
guard UIApplication.shared.applicationState == .active else { return }
|
guard UIApplication.shared.applicationState == .active else { return }
|
||||||
|
|||||||
@@ -719,9 +719,18 @@ struct ContentView: View {
|
|||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Text(peer.displayName)
|
// Render nickname with light-gray '#abcd' suffix if present
|
||||||
.font(.system(size: 14, design: .monospaced))
|
let parts = splitNameSuffix(peer.displayName)
|
||||||
.foregroundColor(peer.isFavorite || peerNicknames[peer.id] != nil ? textColor : secondaryTextColor)
|
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)
|
// Encryption status icon (after peer name)
|
||||||
if let icon = peer.encryptionStatus.icon {
|
if let icon = peer.encryptionStatus.icon {
|
||||||
@@ -845,6 +854,19 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 {
|
private var mainHeaderView: some View {
|
||||||
HStack(spacing: 0) {
|
HStack(spacing: 0) {
|
||||||
@@ -925,7 +947,7 @@ struct ContentView: View {
|
|||||||
.font(.system(size: 12, design: .monospaced))
|
.font(.system(size: 12, design: .monospaced))
|
||||||
.accessibilityHidden(true)
|
.accessibilityHidden(true)
|
||||||
}
|
}
|
||||||
.foregroundColor(isNostrOnly ? Color.purple : (meshPeerCount > 0 ? textColor : Color.red))
|
.foregroundColor(isNostrOnly ? Color.purple : (meshPeerCount > 0 ? Color.blue : Color.secondary))
|
||||||
}
|
}
|
||||||
.onTapGesture {
|
.onTapGesture {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user