Merge pull request #898 from permissionlesstech/refactor/chatviewmodel-extraction

Refactor ChatViewModel: extract logic to services
This commit is contained in:
jack
2025-11-25 10:28:51 -10:00
committed by GitHub
2 changed files with 186 additions and 316 deletions
+154
View File
@@ -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,158 @@ 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)
/// - persistedReadReceipts: The persisted read receipts set from ChatViewModel (UserDefaults-backed)
/// - Returns: True if any unread messages were found during consolidation
@MainActor
func consolidateMessages(for peerID: PeerID, peerNickname: String, persistedReadReceipts: Set<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)
// Use persistedReadReceipts to correctly identify already-read messages after app restart
if message.senderPeerID != meshService.myPeerID {
let messageAge = Date().timeIntervalSince(message.timestamp)
if messageAge < 60 && !persistedReadReceipts.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
+19 -303
View File
@@ -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,9 @@ 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 // Pass persisted sentReadReceipts to correctly identify already-read messages after app restart
if let peer = unifiedPeerService.getPeer(by: peerID) { _ = privateChatManager.consolidateMessages(for: peerID, peerNickname: peerNickname, persistedReadReceipts: sentReadReceipts)
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 +2576,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 +3322,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 +3333,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 +3638,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 +4631,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))