mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-25 06:05:20 +00:00
Refactor ChatViewModel: extract logic to services
- Remove duplicate Regexes enum, use MessageFormattingEngine.Patterns - Delete unused formatMessage() function (views use formatMessageAsText) - Move message consolidation logic to PrivateChatManager - Add consolidateMessages() and syncReadReceiptsForSentMessages() - Wire PrivateChatManager to UnifiedPeerService for peer lookup Reduces ChatViewModel from 5998 to 5713 lines (-285 lines) All 303 tests pass
This commit is contained in:
@@ -22,6 +22,8 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
weak var meshService: Transport?
|
weak var meshService: Transport?
|
||||||
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
// Route acks/receipts via MessageRouter (chooses mesh or Nostr)
|
||||||
weak var messageRouter: MessageRouter?
|
weak var messageRouter: MessageRouter?
|
||||||
|
// Peer service for looking up peer info during consolidation
|
||||||
|
weak var unifiedPeerService: UnifiedPeerService?
|
||||||
|
|
||||||
init(meshService: Transport? = nil) {
|
init(meshService: Transport? = nil) {
|
||||||
self.meshService = meshService
|
self.meshService = meshService
|
||||||
@@ -30,6 +32,156 @@ final class PrivateChatManager: ObservableObject {
|
|||||||
// Cap for messages stored per private chat
|
// Cap for messages stored per private chat
|
||||||
private let privateChatCap = TransportConfig.privateChatCap
|
private let privateChatCap = TransportConfig.privateChatCap
|
||||||
|
|
||||||
|
// MARK: - Message Consolidation
|
||||||
|
|
||||||
|
/// Consolidates messages from different peer ID representations into a single chat.
|
||||||
|
/// This ensures messages from stable Noise keys and temporary Nostr peer IDs are merged.
|
||||||
|
/// - Parameters:
|
||||||
|
/// - peerID: The target peer ID to consolidate messages into
|
||||||
|
/// - peerNickname: The peer's display name (lowercased for matching)
|
||||||
|
/// - Returns: True if any unread messages were found during consolidation
|
||||||
|
@MainActor
|
||||||
|
func consolidateMessages(for peerID: PeerID, peerNickname: String) -> Bool {
|
||||||
|
guard let meshService = meshService else { return false }
|
||||||
|
var hasUnreadMessages = false
|
||||||
|
|
||||||
|
// 1. Consolidate from stable Noise key (64-char hex)
|
||||||
|
if let peer = unifiedPeerService?.getPeer(by: peerID) {
|
||||||
|
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
||||||
|
|
||||||
|
if noiseKeyHex != peerID, let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
|
||||||
|
if privateChats[peerID] == nil {
|
||||||
|
privateChats[peerID] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
||||||
|
for message in nostrMessages {
|
||||||
|
if !existingMessageIds.contains(message.id) {
|
||||||
|
// Update senderPeerID for correct read receipts
|
||||||
|
let updatedMessage = BitchatMessage(
|
||||||
|
id: message.id,
|
||||||
|
sender: message.sender,
|
||||||
|
content: message.content,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
isRelay: message.isRelay,
|
||||||
|
originalSender: message.originalSender,
|
||||||
|
isPrivate: message.isPrivate,
|
||||||
|
recipientNickname: message.recipientNickname,
|
||||||
|
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID,
|
||||||
|
mentions: message.mentions,
|
||||||
|
deliveryStatus: message.deliveryStatus
|
||||||
|
)
|
||||||
|
privateChats[peerID]?.append(updatedMessage)
|
||||||
|
|
||||||
|
// Check for recent unread messages (< 60s, not sent by us, not already read)
|
||||||
|
if message.senderPeerID != meshService.myPeerID {
|
||||||
|
let messageAge = Date().timeIntervalSince(message.timestamp)
|
||||||
|
if messageAge < 60 && !sentReadReceipts.contains(message.id) {
|
||||||
|
hasUnreadMessages = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
||||||
|
|
||||||
|
if hasUnreadMessages {
|
||||||
|
unreadMessages.insert(peerID)
|
||||||
|
} else if unreadMessages.contains(noiseKeyHex) {
|
||||||
|
unreadMessages.remove(noiseKeyHex)
|
||||||
|
}
|
||||||
|
|
||||||
|
privateChats.removeValue(forKey: noiseKeyHex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Consolidate from temporary Nostr peer IDs (nostr_* prefixed)
|
||||||
|
let normalizedNickname = peerNickname.lowercased()
|
||||||
|
var tempPeerIDsToConsolidate: [PeerID] = []
|
||||||
|
|
||||||
|
for (storedPeerID, messages) in privateChats {
|
||||||
|
if storedPeerID.isGeoDM && storedPeerID != peerID {
|
||||||
|
let nicknamesMatch = messages.allSatisfy { $0.sender.lowercased() == normalizedNickname }
|
||||||
|
if nicknamesMatch && !messages.isEmpty {
|
||||||
|
tempPeerIDsToConsolidate.append(storedPeerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !tempPeerIDsToConsolidate.isEmpty {
|
||||||
|
if privateChats[peerID] == nil {
|
||||||
|
privateChats[peerID] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
||||||
|
var consolidatedCount = 0
|
||||||
|
var hadUnreadTemp = false
|
||||||
|
|
||||||
|
for tempPeerID in tempPeerIDsToConsolidate {
|
||||||
|
if unreadMessages.contains(tempPeerID) {
|
||||||
|
hadUnreadTemp = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if let tempMessages = privateChats[tempPeerID] {
|
||||||
|
for message in tempMessages {
|
||||||
|
if !existingMessageIds.contains(message.id) {
|
||||||
|
let updatedMessage = BitchatMessage(
|
||||||
|
id: message.id,
|
||||||
|
sender: message.sender,
|
||||||
|
content: message.content,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
isRelay: message.isRelay,
|
||||||
|
originalSender: message.originalSender,
|
||||||
|
isPrivate: message.isPrivate,
|
||||||
|
recipientNickname: message.recipientNickname,
|
||||||
|
senderPeerID: peerID,
|
||||||
|
mentions: message.mentions,
|
||||||
|
deliveryStatus: message.deliveryStatus
|
||||||
|
)
|
||||||
|
privateChats[peerID]?.append(updatedMessage)
|
||||||
|
consolidatedCount += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
privateChats.removeValue(forKey: tempPeerID)
|
||||||
|
unreadMessages.remove(tempPeerID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if hadUnreadTemp {
|
||||||
|
unreadMessages.insert(peerID)
|
||||||
|
hasUnreadMessages = true
|
||||||
|
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
|
||||||
|
}
|
||||||
|
|
||||||
|
if consolidatedCount > 0 {
|
||||||
|
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
||||||
|
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hasUnreadMessages
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Syncs the read receipt tracking between manager and view model for sent messages
|
||||||
|
@MainActor
|
||||||
|
func syncReadReceiptsForSentMessages(peerID: PeerID, nickname: String, externalReceipts: inout Set<String>) {
|
||||||
|
guard let messages = privateChats[peerID] else { return }
|
||||||
|
|
||||||
|
for message in messages {
|
||||||
|
if message.sender == nickname {
|
||||||
|
if let status = message.deliveryStatus {
|
||||||
|
switch status {
|
||||||
|
case .read, .delivered:
|
||||||
|
externalReceipts.insert(message.id)
|
||||||
|
sentReadReceipts.insert(message.id)
|
||||||
|
case .failed, .partiallyDelivered, .sending, .sent:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Start a private chat with a peer
|
/// Start a private chat with a peer
|
||||||
func startChat(with peerID: PeerID) {
|
func startChat(with peerID: PeerID) {
|
||||||
selectedPeer = peerID
|
selectedPeer = peerID
|
||||||
|
|||||||
@@ -93,33 +93,8 @@ import UniformTypeIdentifiers
|
|||||||
/// Acts as the primary coordinator between UI components and backend services,
|
/// Acts as the primary coordinator between UI components and backend services,
|
||||||
/// implementing the BitchatDelegate protocol to handle network events.
|
/// implementing the BitchatDelegate protocol to handle network events.
|
||||||
final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
|
final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext {
|
||||||
// Precompiled regexes and detectors reused across formatting
|
// Use MessageFormattingEngine.Patterns for regex matching (shared, precompiled)
|
||||||
private enum Regexes {
|
private typealias Patterns = MessageFormattingEngine.Patterns
|
||||||
static let hashtag: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "#([a-zA-Z0-9_]+)", options: [])
|
|
||||||
}()
|
|
||||||
static let mention: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "@([\\p{L}0-9_]+(?:#[a-fA-F0-9]{4})?)", options: [])
|
|
||||||
}()
|
|
||||||
static let cashu: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
|
||||||
}()
|
|
||||||
static let bolt11: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
|
|
||||||
}()
|
|
||||||
static let lnurl: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
|
|
||||||
}()
|
|
||||||
static let lightningScheme: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
|
|
||||||
}()
|
|
||||||
static let linkDetector: NSDataDetector? = {
|
|
||||||
try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
|
|
||||||
}()
|
|
||||||
static let quickCashuPresence: NSRegularExpression = {
|
|
||||||
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
private typealias GeoOutgoingContext = (channel: GeohashChannel, event: NostrEvent, identity: NostrIdentity, teleported: Bool)
|
private typealias GeoOutgoingContext = (channel: GeohashChannel, event: NostrEvent, identity: NostrIdentity, teleported: Bool)
|
||||||
|
|
||||||
@@ -464,6 +439,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
self.messageRouter = MessageRouter(mesh: meshService, nostr: nostrTransport)
|
||||||
// Route receipts from PrivateChatManager through MessageRouter
|
// Route receipts from PrivateChatManager through MessageRouter
|
||||||
self.privateChatManager.messageRouter = self.messageRouter
|
self.privateChatManager.messageRouter = self.messageRouter
|
||||||
|
// Allow PrivateChatManager to look up peer info for message consolidation
|
||||||
|
self.privateChatManager.unifiedPeerService = self.unifiedPeerService
|
||||||
// Allow UnifiedPeerService to route favorite notifications via mesh/Nostr
|
// Allow UnifiedPeerService to route favorite notifications via mesh/Nostr
|
||||||
self.unifiedPeerService.messageRouter = self.messageRouter
|
self.unifiedPeerService.messageRouter = self.messageRouter
|
||||||
self.autocompleteService = AutocompleteService()
|
self.autocompleteService = AutocompleteService()
|
||||||
@@ -2582,154 +2559,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Consolidate messages from stable Noise key if needed
|
// Consolidate messages from different peer ID representations (stable Noise key, temp Nostr IDs)
|
||||||
// This ensures Nostr messages appear when opening a chat with an ephemeral peer ID
|
_ = privateChatManager.consolidateMessages(for: peerID, peerNickname: peerNickname)
|
||||||
if let peer = unifiedPeerService.getPeer(by: peerID) {
|
|
||||||
let noiseKeyHex = PeerID(hexData: peer.noisePublicKey)
|
|
||||||
|
|
||||||
// If we have messages stored under the stable Noise key hex but not under the ephemeral ID,
|
|
||||||
// or if we need to merge them, do so now
|
|
||||||
if noiseKeyHex != peerID {
|
|
||||||
if let nostrMessages = privateChats[noiseKeyHex], !nostrMessages.isEmpty {
|
|
||||||
// Check if there are ACTUALLY unread messages (not just the unread flag)
|
|
||||||
// Only transfer unread status if there are recent unread messages
|
|
||||||
var hasActualUnreadMessages = false
|
|
||||||
|
|
||||||
// Merge messages from stable key into ephemeral peer ID storage
|
|
||||||
if privateChats[peerID] == nil {
|
|
||||||
privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add any messages that aren't already in the ephemeral storage
|
|
||||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
|
||||||
for message in nostrMessages {
|
|
||||||
if !existingMessageIds.contains(message.id) {
|
|
||||||
// Create updated message with correct senderPeerID
|
|
||||||
// This is crucial for read receipts to work correctly
|
|
||||||
let updatedMessage = BitchatMessage(
|
|
||||||
id: message.id,
|
|
||||||
sender: message.sender,
|
|
||||||
content: message.content,
|
|
||||||
timestamp: message.timestamp,
|
|
||||||
isRelay: message.isRelay,
|
|
||||||
originalSender: message.originalSender,
|
|
||||||
isPrivate: message.isPrivate,
|
|
||||||
recipientNickname: message.recipientNickname,
|
|
||||||
senderPeerID: message.senderPeerID == meshService.myPeerID ? meshService.myPeerID : peerID, // Update peer ID if it's from them
|
|
||||||
mentions: message.mentions,
|
|
||||||
deliveryStatus: message.deliveryStatus
|
|
||||||
)
|
|
||||||
privateChats[peerID]?.append(updatedMessage)
|
|
||||||
|
|
||||||
// Check if this is an actually unread message
|
|
||||||
// Only mark as unread if:
|
|
||||||
// 1. Not a message we sent
|
|
||||||
// 2. Message is recent (< 60s old)
|
|
||||||
// Never mark old messages as unread during consolidation
|
|
||||||
if message.senderPeerID != meshService.myPeerID {
|
|
||||||
let messageAge = Date().timeIntervalSince(message.timestamp)
|
|
||||||
if messageAge < 60 && !sentReadReceipts.contains(message.id) {
|
|
||||||
hasActualUnreadMessages = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort by timestamp
|
|
||||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
||||||
|
|
||||||
// Only transfer unread status if there are actual recent unread messages
|
|
||||||
if hasActualUnreadMessages {
|
|
||||||
unreadPrivateMessages.insert(peerID)
|
|
||||||
} else if unreadPrivateMessages.contains(noiseKeyHex) {
|
|
||||||
// Remove incorrect unread status from stable key
|
|
||||||
unreadPrivateMessages.remove(noiseKeyHex)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up the stable key storage to avoid duplication
|
|
||||||
privateChats.removeValue(forKey: noiseKeyHex)
|
|
||||||
|
|
||||||
// Consolidated Nostr messages from stable key
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also consolidate messages from temporary Nostr peer IDs
|
|
||||||
// These are messages received via Nostr when we didn't know the sender's Noise key
|
|
||||||
// They're stored under "nostr_" + first 16 chars of Nostr pubkey
|
|
||||||
let currentPeerNickname = peerNickname.lowercased()
|
|
||||||
var tempPeerIDsToConsolidate: [PeerID] = []
|
|
||||||
|
|
||||||
// Find all temporary Nostr peer IDs that have messages from the same nickname
|
|
||||||
for (storedPeerID, messages) in privateChats {
|
|
||||||
if storedPeerID.isGeoDM && storedPeerID != peerID {
|
|
||||||
// Check if ALL messages from this temporary peer have the same sender nickname
|
|
||||||
// This is more reliable than just checking the first message
|
|
||||||
let nicknamesMatch = messages.allSatisfy { msg in
|
|
||||||
msg.sender.lowercased() == currentPeerNickname
|
|
||||||
}
|
|
||||||
if nicknamesMatch && !messages.isEmpty {
|
|
||||||
tempPeerIDsToConsolidate.append(storedPeerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Consolidate messages from temporary Nostr peer IDs
|
|
||||||
if !tempPeerIDsToConsolidate.isEmpty {
|
|
||||||
if privateChats[peerID] == nil {
|
|
||||||
privateChats[peerID] = []
|
|
||||||
}
|
|
||||||
|
|
||||||
let existingMessageIds = Set(privateChats[peerID]?.map { $0.id } ?? [])
|
|
||||||
var consolidatedCount = 0
|
|
||||||
|
|
||||||
var hadUnreadTemp = false
|
|
||||||
for tempPeerID in tempPeerIDsToConsolidate {
|
|
||||||
// Check if this temp peer ID had unread messages
|
|
||||||
if unreadPrivateMessages.contains(tempPeerID) {
|
|
||||||
hadUnreadTemp = true
|
|
||||||
}
|
|
||||||
|
|
||||||
if let tempMessages = privateChats[tempPeerID] {
|
|
||||||
for message in tempMessages {
|
|
||||||
if !existingMessageIds.contains(message.id) {
|
|
||||||
// Create a new message with the updated sender peer ID
|
|
||||||
let updatedMessage = BitchatMessage(
|
|
||||||
id: message.id,
|
|
||||||
sender: message.sender,
|
|
||||||
content: message.content,
|
|
||||||
timestamp: message.timestamp,
|
|
||||||
isRelay: message.isRelay,
|
|
||||||
originalSender: message.originalSender,
|
|
||||||
isPrivate: message.isPrivate,
|
|
||||||
recipientNickname: message.recipientNickname,
|
|
||||||
senderPeerID: peerID, // Update to match current peer
|
|
||||||
mentions: message.mentions,
|
|
||||||
deliveryStatus: message.deliveryStatus
|
|
||||||
)
|
|
||||||
privateChats[peerID]?.append(updatedMessage)
|
|
||||||
consolidatedCount += 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Remove the temporary storage
|
|
||||||
privateChats.removeValue(forKey: tempPeerID)
|
|
||||||
unreadPrivateMessages.remove(tempPeerID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If any temp peer ID had unread messages, mark the consolidated peer as unread
|
|
||||||
if hadUnreadTemp {
|
|
||||||
unreadPrivateMessages.insert(peerID)
|
|
||||||
SecureLogger.debug("📬 Transferred unread status from temp peer IDs to \(peerID)", category: .session)
|
|
||||||
}
|
|
||||||
|
|
||||||
if consolidatedCount > 0 {
|
|
||||||
// Sort by timestamp
|
|
||||||
privateChats[peerID]?.sort { $0.timestamp < $1.timestamp }
|
|
||||||
|
|
||||||
SecureLogger.info("📥 Consolidated \(consolidatedCount) Nostr messages from temporary peer IDs to \(peerNickname)", category: .session)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Trigger handshake if needed (mesh peers only). Skip for Nostr geohash conv keys.
|
// Trigger handshake if needed (mesh peers only). Skip for Nostr geohash conv keys.
|
||||||
if !peerID.isGeoDM && !peerID.isGeoChat {
|
if !peerID.isGeoDM && !peerID.isGeoChat {
|
||||||
@@ -2744,26 +2575,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session)
|
SecureLogger.debug("GeoDM: skipping mesh handshake for virtual peerID=\(peerID)", category: .session)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delegate to private chat manager but add already-acked messages first
|
// Sync read receipt tracking to prevent duplicates
|
||||||
// This prevents duplicate read receipts
|
privateChatManager.syncReadReceiptsForSentMessages(peerID: peerID, nickname: nickname, externalReceipts: &sentReadReceipts)
|
||||||
// IMPORTANT: Only add messages WE sent to sentReadReceipts, not messages we received
|
|
||||||
if let messages = privateChats[peerID] {
|
|
||||||
for message in messages {
|
|
||||||
// Only track read receipts for messages WE sent (not received messages)
|
|
||||||
if message.sender == nickname {
|
|
||||||
// Check if message has been read or delivered
|
|
||||||
if let status = message.deliveryStatus {
|
|
||||||
switch status {
|
|
||||||
case .read, .delivered:
|
|
||||||
sentReadReceipts.insert(message.id)
|
|
||||||
privateChatManager.sentReadReceipts.insert(message.id)
|
|
||||||
case .failed, .partiallyDelivered, .sending, .sent:
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
privateChatManager.startChat(with: peerID)
|
privateChatManager.startChat(with: peerID)
|
||||||
|
|
||||||
@@ -3508,7 +3321,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
let nsContent = content as NSString
|
let nsContent = content as NSString
|
||||||
let nsLen = nsContent.length
|
let nsLen = nsContent.length
|
||||||
let containsCashuEarly: Bool = {
|
let containsCashuEarly: Bool = {
|
||||||
let rx = Regexes.quickCashuPresence
|
let rx = Patterns.quickCashuPresence
|
||||||
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
|
return rx.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0
|
||||||
}()
|
}()
|
||||||
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly {
|
||||||
@@ -3519,14 +3332,14 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
: .bitchatSystem(size: 14, design: .monospaced)
|
: .bitchatSystem(size: 14, design: .monospaced)
|
||||||
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
result.append(AttributedString(content).mergingAttributes(plainStyle))
|
||||||
} else {
|
} else {
|
||||||
// Reuse compiled regexes and detector
|
// Reuse compiled regexes and detector from MessageFormattingEngine
|
||||||
let hashtagRegex = Regexes.hashtag
|
let hashtagRegex = Patterns.hashtag
|
||||||
let mentionRegex = Regexes.mention
|
let mentionRegex = Patterns.mention
|
||||||
let cashuRegex = Regexes.cashu
|
let cashuRegex = Patterns.cashu
|
||||||
let bolt11Regex = Regexes.bolt11
|
let bolt11Regex = Patterns.bolt11
|
||||||
let lnurlRegex = Regexes.lnurl
|
let lnurlRegex = Patterns.lnurl
|
||||||
let lightningSchemeRegex = Regexes.lightningScheme
|
let lightningSchemeRegex = Patterns.lightningScheme
|
||||||
let detector = Regexes.linkDetector
|
let detector = Patterns.linkDetector
|
||||||
let hasMentionsHint = content.contains("@")
|
let hasMentionsHint = content.contains("@")
|
||||||
let hasHashtagsHint = content.contains("#")
|
let hasHashtagsHint = content.contains("#")
|
||||||
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http")
|
||||||
@@ -3824,104 +3637,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme) -> AttributedString {
|
|
||||||
var result = AttributedString()
|
|
||||||
|
|
||||||
let isDark = colorScheme == .dark
|
|
||||||
let primaryColor = isDark ? Color.green : Color(red: 0, green: 0.5, blue: 0)
|
|
||||||
|
|
||||||
if message.sender == "system" {
|
|
||||||
let content = AttributedString("* \(message.content) *")
|
|
||||||
var contentStyle = AttributeContainer()
|
|
||||||
contentStyle.foregroundColor = Color.gray
|
|
||||||
contentStyle.font = .bitchatSystem(size: 12, design: .monospaced).italic()
|
|
||||||
result.append(content.mergingAttributes(contentStyle))
|
|
||||||
|
|
||||||
// Add timestamp at the end for system messages
|
|
||||||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
|
||||||
var timestampStyle = AttributeContainer()
|
|
||||||
timestampStyle.foregroundColor = Color.gray.opacity(0.5)
|
|
||||||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
|
||||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
|
||||||
} else {
|
|
||||||
let sender = AttributedString("<@\(message.sender)> ")
|
|
||||||
var senderStyle = AttributeContainer()
|
|
||||||
|
|
||||||
// Use consistent color for all senders
|
|
||||||
senderStyle.foregroundColor = primaryColor
|
|
||||||
// Bold the user's own nickname
|
|
||||||
let fontWeight: Font.Weight = message.sender == nickname ? .bold : .medium
|
|
||||||
senderStyle.font = .bitchatSystem(size: 12, weight: fontWeight, design: .monospaced)
|
|
||||||
result.append(sender.mergingAttributes(senderStyle))
|
|
||||||
|
|
||||||
|
|
||||||
// Process content to highlight mentions
|
|
||||||
let contentText = message.content
|
|
||||||
var processedContent = AttributedString()
|
|
||||||
|
|
||||||
// Regular expression to find @mentions
|
|
||||||
let pattern = "@([\\p{L}0-9_]+)"
|
|
||||||
let regex = try? NSRegularExpression(pattern: pattern, options: [])
|
|
||||||
let nsContent = contentText as NSString
|
|
||||||
let nsLen = nsContent.length
|
|
||||||
let matches = regex?.matches(in: contentText, options: [], range: NSRange(location: 0, length: nsLen)) ?? []
|
|
||||||
|
|
||||||
var lastEndIndex = contentText.startIndex
|
|
||||||
|
|
||||||
for match in matches {
|
|
||||||
// Add text before the mention
|
|
||||||
if let range = Range(match.range(at: 0), in: contentText) {
|
|
||||||
if lastEndIndex < range.lowerBound {
|
|
||||||
let beforeText = String(contentText[lastEndIndex..<range.lowerBound])
|
|
||||||
if !beforeText.isEmpty {
|
|
||||||
var normalStyle = AttributeContainer()
|
|
||||||
normalStyle.font = .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
|
||||||
processedContent.append(AttributedString(beforeText).mergingAttributes(normalStyle))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the mention with highlight
|
|
||||||
let mentionText = String(contentText[range])
|
|
||||||
var mentionStyle = AttributeContainer()
|
|
||||||
mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced)
|
|
||||||
mentionStyle.foregroundColor = Color.orange
|
|
||||||
processedContent.append(AttributedString(mentionText).mergingAttributes(mentionStyle))
|
|
||||||
|
|
||||||
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 = .bitchatSystem(size: 14, design: .monospaced)
|
|
||||||
normalStyle.foregroundColor = isDark ? Color.white : Color.black
|
|
||||||
processedContent.append(AttributedString(remainingText).mergingAttributes(normalStyle))
|
|
||||||
}
|
|
||||||
|
|
||||||
result.append(processedContent)
|
|
||||||
|
|
||||||
if message.isRelay, let originalSender = message.originalSender {
|
|
||||||
let relay = AttributedString(" (via \(originalSender))")
|
|
||||||
var relayStyle = AttributeContainer()
|
|
||||||
relayStyle.foregroundColor = primaryColor.opacity(0.7)
|
|
||||||
relayStyle.font = .bitchatSystem(size: 11, design: .monospaced)
|
|
||||||
result.append(relay.mergingAttributes(relayStyle))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add timestamp at the end (smaller, light grey)
|
|
||||||
let timestamp = AttributedString(" [\(message.formattedTimestamp)]")
|
|
||||||
var timestampStyle = AttributeContainer()
|
|
||||||
timestampStyle.foregroundColor = Color.gray.opacity(0.7)
|
|
||||||
timestampStyle.font = .bitchatSystem(size: 10, design: .monospaced)
|
|
||||||
result.append(timestamp.mergingAttributes(timestampStyle))
|
|
||||||
}
|
|
||||||
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Noise Protocol Support
|
// MARK: - Noise Protocol Support
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@@ -4915,7 +4630,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
|
|||||||
|
|
||||||
private func parseMentions(from content: String) -> [String] {
|
private func parseMentions(from content: String) -> [String] {
|
||||||
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
|
// Allow optional disambiguation suffix '#abcd' for duplicate nicknames
|
||||||
let regex = Regexes.mention
|
let regex = Patterns.mention
|
||||||
let nsContent = content as NSString
|
let nsContent = content as NSString
|
||||||
let nsLen = nsContent.length
|
let nsLen = nsContent.length
|
||||||
let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
let matches = regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen))
|
||||||
|
|||||||
Reference in New Issue
Block a user