Refactor: BitchatMessage (#610)

* Extract BitchatMessage into a separate file

* Convert `fromBinaryPayload` to `convenience init?`

* Extract message dedup into an extension

* Remove dead `formatMessageContent`

* Minor refactor of timestamp and username formatting

* Remove dead `getSenderColor`
This commit is contained in:
Islam
2025-09-15 15:00:12 +02:00
committed by GitHub
parent 347ce5ece4
commit ea8d51a36b
11 changed files with 392 additions and 449 deletions
+343
View File
@@ -0,0 +1,343 @@
//
// BitchatMessage.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
/// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages
final class BitchatMessage: Codable {
let id: String
let sender: String
let content: String
let timestamp: Date
let isRelay: Bool
let originalSender: String?
let isPrivate: Bool
let recipientNickname: String?
let senderPeerID: String?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"]
}
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
}
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
self.timestamp = timestamp
self.isRelay = isRelay
self.originalSender = originalSender
self.isPrivate = isPrivate
self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
}
}
// MARK: - Equatable Conformance
extension BitchatMessage: Equatable {
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id &&
lhs.sender == rhs.sender &&
lhs.content == rhs.content &&
lhs.timestamp == rhs.timestamp &&
lhs.isRelay == rhs.isRelay &&
lhs.originalSender == rhs.originalSender &&
lhs.isPrivate == rhs.isPrivate &&
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
}
}
// MARK: - Binary encoding
extension BitchatMessage {
func toBinaryPayload() -> Data? {
var data = Data()
// Message format:
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
// - Timestamp: 8 bytes (seconds since epoch)
// - ID length: 1 byte
// - ID: variable
// - Sender length: 1 byte
// - Sender: variable
// - Content length: 2 bytes
// - Content: variable
// Optional fields based on flags:
// - Original sender length + data
// - Recipient nickname length + data
// - Sender peer ID length + data
// - Mentions array
var flags: UInt8 = 0
if isRelay { flags |= 0x01 }
if isPrivate { flags |= 0x02 }
if originalSender != nil { flags |= 0x04 }
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
data.append(flags)
// Timestamp (in milliseconds)
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
// Encode as 8 bytes, big-endian
for i in (0..<8).reversed() {
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
}
// ID
if let idData = id.data(using: .utf8) {
data.append(UInt8(min(idData.count, 255)))
data.append(idData.prefix(255))
} else {
data.append(0)
}
// Sender
if let senderData = sender.data(using: .utf8) {
data.append(UInt8(min(senderData.count, 255)))
data.append(senderData.prefix(255))
} else {
data.append(0)
}
// Content
if let contentData = content.data(using: .utf8) {
let length = UInt16(min(contentData.count, 65535))
// Encode length as 2 bytes, big-endian
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
data.append(contentData.prefix(Int(length)))
} else {
data.append(contentsOf: [0, 0])
}
// Optional fields
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
data.append(UInt8(min(origData.count, 255)))
data.append(origData.prefix(255))
}
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
data.append(UInt8(min(recipData.count, 255)))
data.append(recipData.prefix(255))
}
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
data.append(UInt8(min(peerData.count, 255)))
data.append(peerData.prefix(255))
}
// Mentions array
if let mentions = mentions {
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
for mention in mentions.prefix(255) {
if let mentionData = mention.data(using: .utf8) {
data.append(UInt8(min(mentionData.count, 255)))
data.append(mentionData.prefix(255))
} else {
data.append(0)
}
}
}
return data
}
convenience init?(_ data: Data) {
// Create an immutable copy to prevent threading issues
let dataCopy = Data(data)
guard dataCopy.count >= 13 else {
return nil
}
var offset = 0
// Flags
guard offset < dataCopy.count else {
return nil
}
let flags = dataCopy[offset]; offset += 1
let isRelay = (flags & 0x01) != 0
let isPrivate = (flags & 0x02) != 0
let hasOriginalSender = (flags & 0x04) != 0
let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
// Timestamp
guard offset + 8 <= dataCopy.count else {
return nil
}
let timestampData = dataCopy[offset..<offset+8]
let timestampMillis = timestampData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
}
offset += 8
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
// ID
guard offset < dataCopy.count else {
return nil
}
let idLength = Int(dataCopy[offset]); offset += 1
guard offset + idLength <= dataCopy.count else {
return nil
}
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
offset += idLength
// Sender
guard offset < dataCopy.count else {
return nil
}
let senderLength = Int(dataCopy[offset]); offset += 1
guard offset + senderLength <= dataCopy.count else {
return nil
}
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
offset += senderLength
// Content
guard offset + 2 <= dataCopy.count else {
return nil
}
let contentLengthData = dataCopy[offset..<offset+2]
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
})
offset += 2
guard offset + contentLength <= dataCopy.count else {
return nil
}
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
offset += contentLength
// Optional fields
var originalSender: String?
if hasOriginalSender && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var recipientNickname: String?
if hasRecipientNickname && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var senderPeerID: String?
if hasSenderPeerID && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
// Mentions array
var mentions: [String]?
if hasMentions && offset < dataCopy.count {
let mentionCount = Int(dataCopy[offset]); offset += 1
if mentionCount > 0 {
mentions = []
for _ in 0..<mentionCount {
if offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
mentions?.append(mention)
}
offset += length
}
}
}
}
}
self.init(
id: id,
sender: sender,
content: content,
timestamp: timestamp,
isRelay: isRelay,
originalSender: originalSender,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions
)
}
}
// 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)
}
}
extension Array where Element == BitchatMessage {
/// Filters out empty ones and deduplicate by ID while preserving order (from oldest to newest)
func cleanedAndDeduped() -> [Element] {
let arr = filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false }
guard arr.count > 1 else {
return arr
}
var seen = Set<String>()
var dedup: [BitchatMessage] = []
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if !seen.contains(m.id) {
dedup.append(m)
seen.insert(m.id)
}
}
return dedup
}
}
-233
View File
@@ -328,236 +328,3 @@ struct BinaryProtocol {
}
}
}
// Binary encoding for BitchatMessage
extension BitchatMessage {
func toBinaryPayload() -> Data? {
var data = Data()
// Message format:
// - Flags: 1 byte (bit 0: isRelay, bit 1: isPrivate, bit 2: hasOriginalSender, bit 3: hasRecipientNickname, bit 4: hasSenderPeerID, bit 5: hasMentions)
// - Timestamp: 8 bytes (seconds since epoch)
// - ID length: 1 byte
// - ID: variable
// - Sender length: 1 byte
// - Sender: variable
// - Content length: 2 bytes
// - Content: variable
// Optional fields based on flags:
// - Original sender length + data
// - Recipient nickname length + data
// - Sender peer ID length + data
// - Mentions array
var flags: UInt8 = 0
if isRelay { flags |= 0x01 }
if isPrivate { flags |= 0x02 }
if originalSender != nil { flags |= 0x04 }
if recipientNickname != nil { flags |= 0x08 }
if senderPeerID != nil { flags |= 0x10 }
if mentions != nil && !mentions!.isEmpty { flags |= 0x20 }
data.append(flags)
// Timestamp (in milliseconds)
let timestampMillis = UInt64(timestamp.timeIntervalSince1970 * 1000)
// Encode as 8 bytes, big-endian
for i in (0..<8).reversed() {
data.append(UInt8((timestampMillis >> (i * 8)) & 0xFF))
}
// ID
if let idData = id.data(using: .utf8) {
data.append(UInt8(min(idData.count, 255)))
data.append(idData.prefix(255))
} else {
data.append(0)
}
// Sender
if let senderData = sender.data(using: .utf8) {
data.append(UInt8(min(senderData.count, 255)))
data.append(senderData.prefix(255))
} else {
data.append(0)
}
// Content
if let contentData = content.data(using: .utf8) {
let length = UInt16(min(contentData.count, 65535))
// Encode length as 2 bytes, big-endian
data.append(UInt8((length >> 8) & 0xFF))
data.append(UInt8(length & 0xFF))
data.append(contentData.prefix(Int(length)))
} else {
data.append(contentsOf: [0, 0])
}
// Optional fields
if let originalSender = originalSender, let origData = originalSender.data(using: .utf8) {
data.append(UInt8(min(origData.count, 255)))
data.append(origData.prefix(255))
}
if let recipientNickname = recipientNickname, let recipData = recipientNickname.data(using: .utf8) {
data.append(UInt8(min(recipData.count, 255)))
data.append(recipData.prefix(255))
}
if let senderPeerID = senderPeerID, let peerData = senderPeerID.data(using: .utf8) {
data.append(UInt8(min(peerData.count, 255)))
data.append(peerData.prefix(255))
}
// Mentions array
if let mentions = mentions {
data.append(UInt8(min(mentions.count, 255))) // Number of mentions
for mention in mentions.prefix(255) {
if let mentionData = mention.data(using: .utf8) {
data.append(UInt8(min(mentionData.count, 255)))
data.append(mentionData.prefix(255))
} else {
data.append(0)
}
}
}
return data
}
static func fromBinaryPayload(_ data: Data) -> BitchatMessage? {
// Create an immutable copy to prevent threading issues
let dataCopy = Data(data)
guard dataCopy.count >= 13 else {
return nil
}
var offset = 0
// Flags
guard offset < dataCopy.count else {
return nil
}
let flags = dataCopy[offset]; offset += 1
let isRelay = (flags & 0x01) != 0
let isPrivate = (flags & 0x02) != 0
let hasOriginalSender = (flags & 0x04) != 0
let hasRecipientNickname = (flags & 0x08) != 0
let hasSenderPeerID = (flags & 0x10) != 0
let hasMentions = (flags & 0x20) != 0
// Timestamp
guard offset + 8 <= dataCopy.count else {
return nil
}
let timestampData = dataCopy[offset..<offset+8]
let timestampMillis = timestampData.reduce(0) { result, byte in
(result << 8) | UInt64(byte)
}
offset += 8
let timestamp = Date(timeIntervalSince1970: TimeInterval(timestampMillis) / 1000.0)
// ID
guard offset < dataCopy.count else {
return nil
}
let idLength = Int(dataCopy[offset]); offset += 1
guard offset + idLength <= dataCopy.count else {
return nil
}
let id = String(data: dataCopy[offset..<offset+idLength], encoding: .utf8) ?? UUID().uuidString
offset += idLength
// Sender
guard offset < dataCopy.count else {
return nil
}
let senderLength = Int(dataCopy[offset]); offset += 1
guard offset + senderLength <= dataCopy.count else {
return nil
}
let sender = String(data: dataCopy[offset..<offset+senderLength], encoding: .utf8) ?? "unknown"
offset += senderLength
// Content
guard offset + 2 <= dataCopy.count else {
return nil
}
let contentLengthData = dataCopy[offset..<offset+2]
let contentLength = Int(contentLengthData.reduce(0) { result, byte in
(result << 8) | UInt16(byte)
})
offset += 2
guard offset + contentLength <= dataCopy.count else {
return nil
}
let content = String(data: dataCopy[offset..<offset+contentLength], encoding: .utf8) ?? ""
offset += contentLength
// Optional fields
var originalSender: String?
if hasOriginalSender && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
originalSender = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var recipientNickname: String?
if hasRecipientNickname && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
recipientNickname = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
var senderPeerID: String?
if hasSenderPeerID && offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
senderPeerID = String(data: dataCopy[offset..<offset+length], encoding: .utf8)
offset += length
}
}
// Mentions array
var mentions: [String]?
if hasMentions && offset < dataCopy.count {
let mentionCount = Int(dataCopy[offset]); offset += 1
if mentionCount > 0 {
mentions = []
for _ in 0..<mentionCount {
if offset < dataCopy.count {
let length = Int(dataCopy[offset]); offset += 1
if offset + length <= dataCopy.count {
if let mention = String(data: dataCopy[offset..<offset+length], encoding: .utf8) {
mentions?.append(mention)
}
offset += length
}
}
}
}
}
let message = BitchatMessage(
id: id,
sender: sender,
content: content,
timestamp: timestamp,
isRelay: isRelay,
originalSender: originalSender,
isPrivate: isPrivate,
recipientNickname: recipientNickname,
senderPeerID: senderPeerID,
mentions: mentions
)
return message
}
}
-68
View File
@@ -393,74 +393,6 @@ enum DeliveryStatus: Codable, Equatable {
}
}
// MARK: - Message Model
/// Represents a user-visible message in the BitChat system.
/// Handles both broadcast messages and private encrypted messages,
/// with support for mentions, replies, and delivery tracking.
/// - Note: This is the primary data model for chat messages
final class BitchatMessage: Codable {
let id: String
let sender: String
let content: String
let timestamp: Date
let isRelay: Bool
let originalSender: String?
let isPrivate: Bool
let recipientNickname: String?
let senderPeerID: String?
let mentions: [String]? // Array of mentioned nicknames
var deliveryStatus: DeliveryStatus? // Delivery tracking
// Cached formatted text (not included in Codable)
private var _cachedFormattedText: [String: AttributedString] = [:]
func getCachedFormattedText(isDark: Bool, isSelf: Bool) -> AttributedString? {
return _cachedFormattedText["\(isDark)-\(isSelf)"]
}
func setCachedFormattedText(_ text: AttributedString, isDark: Bool, isSelf: Bool) {
_cachedFormattedText["\(isDark)-\(isSelf)"] = text
}
// Codable implementation
enum CodingKeys: String, CodingKey {
case id, sender, content, timestamp, isRelay, originalSender
case isPrivate, recipientNickname, senderPeerID, mentions, deliveryStatus
}
init(id: String? = nil, sender: String, content: String, timestamp: Date, isRelay: Bool, originalSender: String? = nil, isPrivate: Bool = false, recipientNickname: String? = nil, senderPeerID: String? = nil, mentions: [String]? = nil, deliveryStatus: DeliveryStatus? = nil) {
self.id = id ?? UUID().uuidString
self.sender = sender
self.content = content
self.timestamp = timestamp
self.isRelay = isRelay
self.originalSender = originalSender
self.isPrivate = isPrivate
self.recipientNickname = recipientNickname
self.senderPeerID = senderPeerID
self.mentions = mentions
self.deliveryStatus = deliveryStatus ?? (isPrivate ? .sending : nil)
}
}
// Equatable conformance for BitchatMessage
extension BitchatMessage: Equatable {
static func == (lhs: BitchatMessage, rhs: BitchatMessage) -> Bool {
return lhs.id == rhs.id &&
lhs.sender == rhs.sender &&
lhs.content == rhs.content &&
lhs.timestamp == rhs.timestamp &&
lhs.isRelay == rhs.isRelay &&
lhs.originalSender == rhs.originalSender &&
lhs.isPrivate == rhs.isPrivate &&
lhs.recipientNickname == rhs.recipientNickname &&
lhs.senderPeerID == rhs.senderPeerID &&
lhs.mentions == rhs.mentions &&
lhs.deliveryStatus == rhs.deliveryStatus
}
}
// MARK: - Delegate Protocol
protocol BitchatDelegate: AnyObject {
+25
View File
@@ -0,0 +1,25 @@
//
// String+Nickname.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
extension String {
/// Split a nickname into base and a '#abcd' suffix if present
func splitSuffix() -> (String, String) {
let name = self.replacingOccurrences(of: "@", with: "")
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, "")
}
}
+9 -133
View File
@@ -1522,29 +1522,10 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
geohashPeople = []
teleportedGeo.removeAll()
case .location(let ch):
// Sanitize existing timeline (filter any prior empty-content entries)
var arr = geoTimelines[ch.geohash] ?? []
arr.removeAll { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
// Deduplicate by ID while preserving order (from oldest to newest)
if arr.count > 1 {
var seen = Set<String>()
var dedup: [BitchatMessage] = []
for m in arr.sorted(by: { $0.timestamp < $1.timestamp }) {
if !seen.contains(m.id) {
dedup.append(m)
seen.insert(m.id)
}
}
arr = dedup
}
// Persist the cleaned/sorted timeline for this geohash
geoTimelines[ch.geohash] = arr
messages = arr
// Debug: log if any empty messages are present post-sanitize
let emptyGeo = messages.filter { $0.content.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }.count
if emptyGeo > 0 {
SecureLogger.debug("RenderGuard: geohash \(ch.geohash) timeline has \(emptyGeo) empty messages after sanitize", category: .session)
}
let deduped = geoTimelines[ch.geohash]?.cleanedAndDeduped() ?? []
geoTimelines[ch.geohash] = deduped
messages = deduped
}
// If switching to a location channel, flush any pending geohash-only system messages
if case .location = channel, !pendingGeohashSystemMessages.isEmpty {
@@ -3088,17 +3069,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
// MARK: - Formatting Helpers
func formatTimestamp(_ date: Date) -> String {
let formatter = DateFormatter()
formatter.dateFormat = "HH:mm:ss"
return formatter.string(from: date)
}
// MARK: - Autocomplete
func updateAutocomplete(for text: String, cursorPosition: Int) {
@@ -3160,87 +3130,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
// MARK: - Message Formatting
func getSenderColor(for message: BitchatMessage, colorScheme: ColorScheme) -> Color {
let isDark = colorScheme == .dark
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
return primaryColor
}
func formatMessageContent(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
let isDark = colorScheme == .dark
let contentText = message.content
var processedContent = AttributedString()
// Regular expressions for mentions and hashtags
let mentionPattern = "@([\\p{L}0-9_]+)"
let hashtagPattern = "#([a-zA-Z0-9_]+)"
let mentionRegex = try? NSRegularExpression(pattern: mentionPattern, options: [])
let hashtagRegex = try? NSRegularExpression(pattern: hashtagPattern, options: [])
let nsContent = contentText as NSString
let nsLen = nsContent.length
let mentionMatches = mentionRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
let hashtagMatches = hashtagRegex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
// Combine and sort all matches
var allMatches: [(range: NSRange, type: String)] = []
for match in mentionMatches {
allMatches.append((match.range(at: 0), "mention"))
}
for match in hashtagMatches {
allMatches.append((match.range(at: 0), "hashtag"))
}
allMatches.sort { $0.range.location < $1.range.location }
var lastEndIndex = contentText.startIndex
for (matchRange, matchType) in allMatches {
// Add text before the match
if let range = Range(matchRange, in: contentText) {
if lastEndIndex < range.lowerBound {
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
if !beforeText.isEmpty {
var normalStyle = AttributeContainer()
normalStyle.font = .system(size: 14, design: .monospaced)
normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
}
}
// Add the match with appropriate styling
let matchText = String(contentText[range])
var matchStyle = AttributeContainer()
matchStyle.font = .system(size: 14, weight: .semibold, design: .monospaced)
if matchType == "mention" {
matchStyle.foregroundColor = Color.orange
} else {
// Hashtag
matchStyle.foregroundColor = Color.blue
matchStyle.underlineStyle = .single
}
processedContent.append(AttributedString(matchText).mergingAttributes(matchStyle))
if lastEndIndex < range.upperBound { lastEndIndex = range.upperBound }
}
}
// Add any remaining text
if lastEndIndex < contentText.endIndex {
let remainingText = String(contentText[lastEndIndex...])
var normalStyle = AttributeContainer()
normalStyle.font = .system(size: 14, design: .monospaced)
normalStyle.foregroundColor = isDark ? Color.white : Color.black
processedContent.append(AttributedString(remainingText).mergingAttributes(normalStyle))
}
return processedContent
}
@MainActor
func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
// Determine if this message was sent by self (mesh, geo, or DM)
@@ -3272,7 +3161,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
if message.sender != "system" {
// Sender (at the beginning) with light-gray suffix styling if present
let (baseName, suffix) = splitSuffix(from: message.sender)
let (baseName, suffix) = message.sender.splitSuffix()
var senderStyle = AttributeContainer()
// Use consistent color for all senders
senderStyle.foregroundColor = baseColor
@@ -3423,7 +3312,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
let matchText = String(content[nsRange])
if type == "mention" {
// Split optional '#abcd' suffix and color suffix light grey
let (mBase, mSuffix) = splitSuffix(from: matchText.replacingOccurrences(of: "@", with: ""))
let (mBase, mSuffix) = matchText.splitSuffix()
// Determine if this mention targets me (resolves with optional suffix per active channel)
let mySuffix: String? = {
if case .location(let ch) = activeChannel, let id = try? NostrIdentityBridge.deriveIdentity(forGeohash: ch.geohash) {
@@ -3547,7 +3436,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
// Add timestamp at the end (smaller, light grey)
let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]")
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
timestampStyle.font = .system(size: 10, design: .monospaced)
@@ -3561,7 +3450,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
result.append(content.mergingAttributes(contentStyle))
// Add timestamp at the end for system messages too
let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]")
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
timestampStyle.font = .system(size: 10, design: .monospaced)
@@ -3574,19 +3463,6 @@ final 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()
@@ -3601,7 +3477,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
result.append(content.mergingAttributes(contentStyle))
// Add timestamp at the end for system messages
let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]")
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
timestampStyle.font = .system(size: 10, design: .monospaced)
@@ -3675,7 +3551,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate {
}
// Add timestamp at the end (smaller, light grey)
let timestamp = AttributedString(" [\(formatTimestamp(message.timestamp))]")
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
var timestampStyle = AttributeContainer()
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
timestampStyle.font = .system(size: 10, design: .monospaced)
+1 -1
View File
@@ -215,7 +215,7 @@ final class BLEServiceTests: XCTestCase {
let expectation = XCTestExpectation(description: "Delivery handler called")
service.packetDeliveryHandler = { packet in
if let msg = BitchatMessage.fromBinaryPayload(packet.payload) {
if let msg = BitchatMessage(packet.payload) {
XCTAssertEqual(msg.content, "Test delivery")
expectation.fulfill()
}
@@ -139,7 +139,7 @@ final class PrivateChatE2ETests: XCTestCase {
alice.packetDeliveryHandler = { packet in
// Encrypt outgoing private messages
if packet.type == 0x01,
let message = BitchatMessage.fromBinaryPayload(packet.payload),
let message = BitchatMessage(packet.payload),
message.isPrivate {
do {
let encrypted = try aliceManager.encrypt(packet.payload, for: TestConstants.testPeerID2)
@@ -164,7 +164,7 @@ final class PrivateChatE2ETests: XCTestCase {
if packet.type == 0x02 {
do {
let decrypted = try bobManager.decrypt(packet.payload, from: TestConstants.testPeerID1)
if let message = BitchatMessage.fromBinaryPayload(decrypted) {
if let message = BitchatMessage(decrypted) {
XCTAssertEqual(message.content, TestConstants.testMessage1)
XCTAssertTrue(message.isPrivate)
expectation.fulfill()
@@ -98,7 +98,7 @@ final class PublicChatE2ETests: XCTestCase {
// Set up relay in Bob
bob.packetDeliveryHandler = { packet in
// Bob should relay to Charlie
if let message = BitchatMessage.fromBinaryPayload(packet.payload),
if let message = BitchatMessage(packet.payload),
message.sender == TestConstants.testNickname1 {
// Create relay message
@@ -437,7 +437,7 @@ final class PublicChatE2ETests: XCTestCase {
// Check if should relay
guard packet.ttl > 1 else { return }
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
if let message = BitchatMessage(packet.payload) {
// Don't relay own messages
guard message.senderPeerID != node.peerID else { return }
@@ -531,7 +531,7 @@ final class IntegrationTests: XCTestCase {
// Setup encryption at Alice
nodes["Alice"]!.packetDeliveryHandler = { packet in
if packet.type == 0x01,
let message = BitchatMessage.fromBinaryPayload(packet.payload),
let message = BitchatMessage(packet.payload),
message.isPrivate && packet.recipientID != nil {
// Encrypt private messages
if let encrypted = try? self.noiseManagers["Alice"]!.encrypt(packet.payload, for: TestConstants.testPeerID2) {
@@ -553,7 +553,7 @@ final class IntegrationTests: XCTestCase {
nodes["Bob"]!.packetDeliveryHandler = { packet in
if packet.type == 0x02 {
if let decrypted = try? self.noiseManagers["Bob"]!.decrypt(packet.payload, from: TestConstants.testPeerID1),
let message = BitchatMessage.fromBinaryPayload(decrypted) {
let message = BitchatMessage(decrypted) {
bobDecrypted = message.content == "Secret message"
expectation.fulfill()
}
@@ -626,7 +626,7 @@ final class IntegrationTests: XCTestCase {
node.packetDeliveryHandler = { packet in
guard packet.ttl > 1 else { return }
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
if let message = BitchatMessage(packet.payload) {
guard message.senderPeerID != node.peerID else { return }
let relayMessage = BitchatMessage(
+1 -1
View File
@@ -309,7 +309,7 @@ final class MockBLEService: NSObject {
func simulateIncomingPacket(_ packet: BitchatPacket) {
// Process through the actual handling logic
if let message = BitchatMessage.fromBinaryPayload(packet.payload) {
if let message = BitchatMessage(packet.payload) {
var shouldDeliver = false
seenLock.lock()
if !seenMessageIDs.contains(message.id) {
@@ -197,7 +197,7 @@ final class BinaryProtocolTests: XCTestCase {
return
}
guard let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
guard let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to decode message from binary")
return
}
@@ -219,7 +219,7 @@ final class BinaryProtocolTests: XCTestCase {
)
guard let payload = message.toBinaryPayload(),
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to encode/decode private message")
return
}
@@ -233,7 +233,7 @@ final class BinaryProtocolTests: XCTestCase {
let message = TestHelpers.createTestMessage(mentions: mentions)
guard let payload = message.toBinaryPayload(),
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to encode/decode message with mentions")
return
}
@@ -256,7 +256,7 @@ final class BinaryProtocolTests: XCTestCase {
)
guard let payload = message.toBinaryPayload(),
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to encode/decode relay message")
return
}
@@ -294,7 +294,7 @@ final class BinaryProtocolTests: XCTestCase {
let message = TestHelpers.createTestMessage(content: largeContent)
guard let payload = message.toBinaryPayload(),
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to handle large message")
return
}
@@ -307,7 +307,7 @@ final class BinaryProtocolTests: XCTestCase {
let emptyMessage = TestHelpers.createTestMessage(content: "")
guard let payload = emptyMessage.toBinaryPayload(),
let decodedMessage = BitchatMessage.fromBinaryPayload(payload) else {
let decodedMessage = BitchatMessage(payload) else {
XCTFail("Failed to handle empty message")
return
}