Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 58 additions & 8 deletions bitchat/Models/BitchatMessage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ final class BitchatMessage: Codable {
let originalSender: String?
let isPrivate: Bool
let recipientNickname: String?
let senderPeerID: PeerID?
let senderPeerID: PeerID
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking

Expand Down Expand Up @@ -51,7 +51,7 @@ final class BitchatMessage: Codable {
originalSender: String? = nil,
isPrivate: Bool = false,
recipientNickname: String? = nil,
senderPeerID: PeerID? = nil,
senderPeerID: PeerID = .empty,
mentions: [String]? = nil,
deliveryStatus: DeliveryStatus? = nil
) {
Expand All @@ -67,6 +67,52 @@ final class BitchatMessage: Codable {
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
}

required convenience init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let id = try container.decodeIfPresent(String.self, forKey: .id)
let sender = try container.decode(String.self, forKey: .sender)
let content = try container.decode(String.self, forKey: .content)
let timestamp = try container.decode(Date.self, forKey: .timestamp)
let isRelay = try container.decode(Bool.self, forKey: .isRelay)
let originalSender = try container.decodeIfPresent(String.self, forKey: .originalSender)
let isPrivate = try container.decode(Bool.self, forKey: .isPrivate)
let recipientNickname = try container.decodeIfPresent(String.self, forKey: .recipientNickname)
let senderPeerID = try container.decodeIfPresent(PeerID.self, forKey: .senderPeerID) ?? .empty
let mentions = try container.decodeIfPresent([String].self, forKey: .mentions)
let deliveryStatus = try container.decodeIfPresent(DeliveryStatus.self, forKey: .deliveryStatus)

self.init(
id: id,
sender: sender,
content: content,
timestamp: timestamp,
isRelay: isRelay,
originalSender: originalSender,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions,
deliveryStatus: deliveryStatus
)
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(id, forKey: .id)
try container.encode(sender, forKey: .sender)
try container.encode(content, forKey: .content)
try container.encode(timestamp, forKey: .timestamp)
try container.encode(isRelay, forKey: .isRelay)
try container.encodeIfPresent(originalSender, forKey: .originalSender)
try container.encode(isPrivate, forKey: .isPrivate)
try container.encodeIfPresent(recipientNickname, forKey: .recipientNickname)
if !senderPeerID.isEmpty {
try container.encode(senderPeerID, forKey: .senderPeerID)
}
try container.encodeIfPresent(mentions, forKey: .mentions)
try container.encodeIfPresent(deliveryStatus, forKey: .deliveryStatus)
}
}

// MARK: - Equatable Conformance
Expand Down Expand Up @@ -113,7 +159,7 @@ extension BitchatMessage {
if isPrivate { flags |= 0x02 }
if originalSender != nil { flags |= 0x04 }
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if !senderPeerID.isEmpty { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }

data.append(flags)
Expand Down Expand Up @@ -163,7 +209,7 @@ extension BitchatMessage {
data.append(recipData.prefix(255))
}

if let peerData = senderPeerID?.id.data(using: .utf8) {
if !senderPeerID.isEmpty, let peerData = senderPeerID.id.data(using: .utf8) {
data.append(UInt8(min(peerData.count, 255)))
data.append(peerData.prefix(255))
}
Expand Down Expand Up @@ -276,11 +322,11 @@ extension BitchatMessage {
}
}

var senderPeerID: PeerID?
var senderPeerID: PeerID = .empty
if hasSenderPeerID && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
senderPeerID = PeerID(data: dataCopy[offset..<offset+length])
senderPeerID = PeerID(data: dataCopy[offset..<offset+length]) ?? .empty
offset += length
}
}
Expand Down Expand Up @@ -323,16 +369,20 @@ extension BitchatMessage {
// MARK: - Helpers

extension BitchatMessage {

private static let timestampFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter
}()

var formattedTimestamp: String {
Self.timestampFormatter.string(from: timestamp)
}

var hasSenderPeerID: Bool {
!senderPeerID.isEmpty
}
}

extension Array where Element == BitchatMessage {
Expand Down
3 changes: 3 additions & 0 deletions bitchat/Models/PeerID.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ struct PeerID: Equatable, Hashable {
self.prefix = prefix
self.bare = String(bare)
}

/// Represents the absence of a known peer identifier.
static let empty = PeerID(str: "")
}

// MARK: - Convenience Inits
Expand Down
5 changes: 3 additions & 2 deletions bitchat/Services/PrivateChatManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -95,10 +95,11 @@ final class PrivateChatManager: ObservableObject {
// MARK: - Private Methods

private func sendReadReceipt(for message: BitchatMessage) {
guard !sentReadReceipts.contains(message.id),
let senderPeerID = message.senderPeerID else {
guard !sentReadReceipts.contains(message.id), message.hasSenderPeerID else {
return
}

let senderPeerID = message.senderPeerID

sentReadReceipts.insert(message.id)

Expand Down
81 changes: 51 additions & 30 deletions bitchat/ViewModels/ChatViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {

@MainActor
private func normalizedSenderKey(for message: BitchatMessage) -> String {
if let spid = message.senderPeerID {
if message.hasSenderPeerID {
let spid = message.senderPeerID
if spid.isGeoChat || spid.isGeoDM {
let full = (nostrKeyMapping[spid] ?? spid.bare).lowercased()
return "nostr:" + full
Expand Down Expand Up @@ -1998,8 +1999,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if let gh = currentGeohash {
if var arr = geoTimelines[gh] {
arr.removeAll { msg in
if let spid = msg.senderPeerID, spid.isGeoDM || spid.isGeoChat {
if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex }
if msg.hasSenderPeerID {
let spid = msg.senderPeerID
if spid.isGeoDM || spid.isGeoChat {
if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex }
}
}
return false
}
Expand All @@ -2009,8 +2013,11 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
switch activeChannel {
case .location:
messages.removeAll { msg in
if let spid = msg.senderPeerID , spid.isGeoDM || spid.isGeoChat {
if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex }
if msg.hasSenderPeerID {
let spid = msg.senderPeerID
if spid.isGeoDM || spid.isGeoChat {
if let full = nostrKeyMapping[spid]?.lowercased() { return full == hex }
}
}
return false
}
Expand Down Expand Up @@ -3058,9 +3065,9 @@ 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 {
message.hasSenderPeerID {
// Store mapping for read receipts
nostrKeyMapping[senderPeerID] = nostrPubkey
nostrKeyMapping[message.senderPeerID] = nostrPubkey
}

// Process the Nostr message through the same flow as Bluetooth messages
Expand Down Expand Up @@ -3199,7 +3206,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: nil,
senderPeerID: .empty,
mentions: nil
)

Expand Down Expand Up @@ -3437,7 +3444,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
for message in messagesToAck {
// Only send read ACKs for messages from the peer (not our own)
// Check both the ephemeral peer ID and stable Noise key as sender
if (message.senderPeerID == peerID || message.senderPeerID == noiseKeyHex) && !message.isRelay {
let matchesNoiseKey = noiseKeyHex.map { message.senderPeerID == $0 } ?? false
if (message.senderPeerID == peerID || matchesNoiseKey) && !message.isRelay {
// Skip if we already sent an ACK for this message
if !sentReadReceipts.contains(message.id) {
// Use stable Noise key hex if available; else fall back to peerID
Expand Down Expand Up @@ -3709,7 +3717,8 @@ 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 message.hasSenderPeerID {
let spid = message.senderPeerID
// In geohash channels, compare against our per-geohash nostr short ID
if case .location(let ch) = activeChannel, spid.isGeoChat {
let myGeo: NostrIdentity? = {
Expand Down Expand Up @@ -3755,7 +3764,8 @@ 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.toPercentEncoded())") {
if message.hasSenderPeerID,
let url = URL(string: "bitchat://user/\(message.senderPeerID.toPercentEncoded())") {
senderStyle.link = url
}

Expand Down Expand Up @@ -4053,7 +4063,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
@MainActor
func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
let isSelf: Bool = {
if let spid = message.senderPeerID {
if message.hasSenderPeerID {
let spid = message.senderPeerID
if case .location(let ch) = activeChannel, spid.id.hasPrefix("nostr:") {
if let myGeo = try? idBridge.deriveIdentity(forGeohash: ch.geohash) {
return spid == "nostr:\(myGeo.publicKeyHex.prefix(TransportConfig.nostrShortKeyDisplayLength))"
Expand Down Expand Up @@ -4081,8 +4092,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
var senderStyle = AttributeContainer()
senderStyle.foregroundColor = baseColor
senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: .monospaced)
if let spid = message.senderPeerID,
let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") {
if message.hasSenderPeerID,
let url = URL(string: "bitchat://user/\(message.senderPeerID.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? message.senderPeerID.id)") {
senderStyle.link = url
}

Expand Down Expand Up @@ -4333,7 +4344,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {

@MainActor
private func peerColor(for message: BitchatMessage, isDark: Bool) -> Color {
if let spid = message.senderPeerID {
if message.hasSenderPeerID {
let spid = message.senderPeerID
if spid.isGeoChat || spid.isGeoDM {
let full = nostrKeyMapping[spid]?.lowercased() ?? spid.bare.lowercased()
return getNostrPaletteColor(for: full, isDark: isDark)
Expand Down Expand Up @@ -5977,16 +5989,20 @@ 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) {
// Check mesh/known peers first
if isPeerBlocked(peerID) { return true }
// Check geohash (Nostr) blocks using mapping to full pubkey
if peerID.isGeoChat || peerID.isGeoDM {
if let full = nostrKeyMapping[peerID]?.lowercased() {
if identityManager.isNostrBlocked(pubkeyHexLowercased: full) { return true }
}
var peerID = message.senderPeerID
if peerID.isEmpty, let resolved = getPeerIDForNickname(message.sender) {
peerID = resolved
}

guard !peerID.isEmpty else { return false }

// Check mesh/known peers first
if isPeerBlocked(peerID) { return true }
// Check geohash (Nostr) blocks using mapping to full pubkey
if peerID.isGeoChat || peerID.isGeoDM {
if let full = nostrKeyMapping[peerID]?.lowercased() {
if identityManager.isNostrBlocked(pubkeyHexLowercased: full) { return true }
}
return false
}
return false
}
Expand Down Expand Up @@ -6125,12 +6141,17 @@ 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)

guard let peerID = senderPeerID else {
var resolvedSender = message.senderPeerID
if resolvedSender.isEmpty, let fallback = getPeerIDForNickname(message.sender) {
resolvedSender = fallback
}

guard !resolvedSender.isEmpty else {
SecureLogger.warning("⚠️ Could not get peer ID for sender \(message.sender)", category: .session)
return
return
}

let peerID = resolvedSender

// Check if this is a favorite/unfavorite notification
if message.content.hasPrefix("[FAVORITED]") || message.content.hasPrefix("[UNFAVORITED]") {
Expand Down Expand Up @@ -6235,7 +6256,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
readerNickname: nickname
)

let recipientID = message.senderPeerID ?? peerID
let recipientID = message.hasSenderPeerID ? message.senderPeerID : peerID

Task { @MainActor in
var originalTransport: String? = nil
Expand Down Expand Up @@ -6267,7 +6288,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?.isGeoChat == true
let isGeo = finalMessage.senderPeerID.isGeoChat

// Apply per-sender and per-content rate limits (drop if exceeded)
if finalMessage.sender != "system" {
Expand Down
2 changes: 1 addition & 1 deletion bitchat/_PreviewHelpers/BitchatMessage+Preview.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ extension BitchatMessage {
originalSender: nil,
isPrivate: false,
recipientNickname: "Jane Doe",
senderPeerID: nil,
senderPeerID: .empty,
mentions: nil,
deliveryStatus: .sent
)
Expand Down
2 changes: 1 addition & 1 deletion bitchatTests/EndToEnd/PublicChatE2ETests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@ struct PublicChatE2ETests {

if let message = BitchatMessage(packet.payload) {
// Don't relay own messages
guard message.senderPeerID?.id != node.peerID else { return }
guard message.senderPeerID != node.peerID else { return }

// Create relay message
let relayMessage = BitchatMessage(
Expand Down
2 changes: 1 addition & 1 deletion bitchatTests/Mocks/MockBLEService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -301,7 +301,7 @@ final class MockBLEService: NSObject {
let nextTTL = packet.ttl > 0 ? packet.ttl - 1 : 0
for neighbor in neighbors() {
// Avoid immediate echo loopback to sender if known
if let sender = message.senderPeerID, sender == neighbor.peerID { continue }
if message.hasSenderPeerID && message.senderPeerID == neighbor.peerID { continue }
var relay = packet
relay.ttl = nextTTL
neighbor.simulateIncomingPacket(relay)
Expand Down
Loading