import BitFoundation import Foundation import SwiftUI @MainActor final class ChatMessageFormatter { typealias Patterns = MessageFormattingEngine.Patterns private unowned let viewModel: ChatViewModel private let meshPalette = MinimalDistancePalette(config: .mesh) private let nostrPalette = MinimalDistancePalette(config: .nostr) init(viewModel: ChatViewModel) { self.viewModel = viewModel } func formatMessageAsText(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString { let design = theme.bodyFontDesign let isSelf: Bool = { if let spid = message.senderPeerID { if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat { let myGeo: NostrIdentity? = { if let cached = viewModel.cachedGeohashIdentity, cached.geohash == channel.geohash { return cached.identity } if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { viewModel.cachedGeohashIdentity = (channel.geohash, identity) return identity } return nil }() if let myGeo { return spid == PeerID(nostr: myGeo.publicKeyHex) } } return spid == viewModel.meshService.myPeerID } if message.sender == viewModel.nickname { return true } if message.sender.hasPrefix(viewModel.nickname + "#") { return true } return false }() let isDark = colorScheme == .dark if let cachedText = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant) { return cachedText } var result = AttributedString() let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) if message.sender != "system" { let (baseName, suffix) = message.sender.splitSuffix() var senderStyle = AttributeContainer() senderStyle.foregroundColor = baseColor let fontWeight: Font.Weight = isSelf ? .bold : .medium senderStyle.font = .bitchatSystem(size: 14, weight: fontWeight, design: design) if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.toPercentEncoded())") { senderStyle.link = url } result.append(AttributedString("<@").mergingAttributes(senderStyle)) result.append(AttributedString(baseName).mergingAttributes(senderStyle)) if !suffix.isEmpty { var suffixStyle = senderStyle suffixStyle.foregroundColor = baseColor.opacity(0.6) result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) } result.append(AttributedString("> ").mergingAttributes(senderStyle)) let content = message.content let nsContent = content as NSString let nsLen = nsContent.length if content.isOversizedForRichFormatting() { var plainStyle = AttributeContainer() plainStyle.foregroundColor = baseColor plainStyle.font = isSelf ? .bitchatSystem(size: 14, weight: .bold, design: design) : .bitchatSystem(size: 14, design: design) result.append(AttributedString(content).mergingAttributes(plainStyle)) } else { let hashtagRegex = Patterns.hashtag let mentionRegex = Patterns.mention let cashuRegex = Patterns.cashu let bolt11Regex = Patterns.bolt11 let lnurlRegex = Patterns.lnurl let lightningSchemeRegex = Patterns.lightningScheme let detector = Patterns.linkDetector let hasMentionsHint = content.contains("@") let hasHashtagsHint = content.contains("#") let hasURLHint = content.contains("://") || content.contains("www.") || content.contains("http") let hasLightningHint = content.lowercased().contains("ln") || content.lowercased().contains("lightning:") let hasCashuHint = content.lowercased().contains("cashu") let hashtagMatches = hasHashtagsHint ? hashtagRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] let mentionMatches = hasMentionsHint ? mentionRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] let urlMatches = hasURLHint ? (detector?.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) ?? []) : [] let cashuMatches = hasCashuHint ? cashuRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] let lightningMatches = hasLightningHint ? lightningSchemeRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] let bolt11Matches = hasLightningHint ? bolt11Regex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] let lnurlMatches = hasLightningHint ? lnurlRegex.matches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) : [] let mentionRanges = mentionMatches.map { $0.range(at: 0) } func overlapsMention(_ range: NSRange) -> Bool { for mentionRange in mentionRanges where NSIntersectionRange(range, mentionRange).length > 0 { return true } return false } func attachedToMention(_ range: NSRange) -> Bool { if let swiftRange = Range(range, in: content), swiftRange.lowerBound > content.startIndex { var index = content.index(before: swiftRange.lowerBound) while true { let character = content[index] if character.isWhitespace || character.isNewline { break } if character == "@" { return true } if index == content.startIndex { break } index = content.index(before: index) } } return false } func isStandaloneHashtag(_ range: NSRange) -> Bool { guard let swiftRange = Range(range, in: content) else { return false } if swiftRange.lowerBound == content.startIndex { return true } let previous = content.index(before: swiftRange.lowerBound) return content[previous].isWhitespace || content[previous].isNewline } var allMatches: [(range: NSRange, type: String)] = [] for match in hashtagMatches where !overlapsMention(match.range(at: 0)) && !attachedToMention(match.range(at: 0)) && isStandaloneHashtag(match.range(at: 0)) { allMatches.append((match.range(at: 0), "hashtag")) } for match in mentionMatches { allMatches.append((match.range(at: 0), "mention")) } for match in urlMatches where !overlapsMention(match.range) { allMatches.append((match.range, "url")) } for match in cashuMatches where !overlapsMention(match.range(at: 0)) { allMatches.append((match.range(at: 0), "cashu")) } for match in lightningMatches where !overlapsMention(match.range(at: 0)) { allMatches.append((match.range(at: 0), "lightning")) } let occupied = urlMatches.map(\.range) + lightningMatches.map { $0.range(at: 0) } func overlapsOccupied(_ range: NSRange) -> Bool { for occupiedRange in occupied where NSIntersectionRange(range, occupiedRange).length > 0 { return true } return false } for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { allMatches.append((match.range(at: 0), "bolt11")) } for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { allMatches.append((match.range(at: 0), "lnurl")) } allMatches.sort { $0.range.location < $1.range.location } var lastEnd = content.startIndex let isMentioned = message.mentions?.contains(viewModel.nickname) ?? false for (range, type) in allMatches { guard let swiftRange = Range(range, in: content) else { continue } if lastEnd < swiftRange.lowerBound { let beforeText = String(content[lastEnd.. content.startIndex { var index = content.index(before: swiftRange.lowerBound) while true { let character = content[index] if character.isWhitespace || character.isNewline { break } if character == "@" { return true } if index == content.startIndex { break } index = content.index(before: index) } } return false }() let standalone: Bool = { if swiftRange.lowerBound == content.startIndex { return true } let previous = content.index(before: swiftRange.lowerBound) return content[previous].isWhitespace || content[previous].isNewline }() var tagStyle = AttributeContainer() tagStyle.font = isSelf ? .bitchatSystem(size: 14, weight: .bold, design: design) : .bitchatSystem(size: 14, design: design) tagStyle.foregroundColor = baseColor if isGeohash && !attachedToMentionToken && standalone, let url = URL(string: "bitchat://geohash/\(token)") { tagStyle.link = url tagStyle.underlineStyle = .single } result.append(AttributedString(matchText).mergingAttributes(tagStyle)) } else if type == "cashu" || type == "lightning" || type == "bolt11" || type == "lnurl" { var spacer = AttributeContainer() spacer.foregroundColor = baseColor spacer.font = isSelf ? .bitchatSystem(size: 14, weight: .bold, design: design) : .bitchatSystem(size: 14, design: design) result.append(AttributedString(" ").mergingAttributes(spacer)) } else { var matchStyle = AttributeContainer() matchStyle.font = .bitchatSystem( size: 14, weight: isSelf ? .bold : .semibold, design: design ) if type == "url" { matchStyle.foregroundColor = isSelf ? .orange : .blue matchStyle.underlineStyle = .single if let url = URL(string: matchText) { matchStyle.link = url } } result.append(AttributedString(matchText).mergingAttributes(matchStyle)) } if lastEnd < swiftRange.upperBound { lastEnd = swiftRange.upperBound } } if lastEnd < content.endIndex { let remainingText = String(content[lastEnd...]) var remainingStyle = AttributeContainer() remainingStyle.foregroundColor = baseColor remainingStyle.font = isSelf ? .bitchatSystem(size: 14, weight: .bold, design: design) : .bitchatSystem(size: 14, design: design) if isMentioned { remainingStyle.font = remainingStyle.font?.bold() } result.append(AttributedString(remainingText).mergingAttributes(remainingStyle)) } } let timestamp = AttributedString(" [\(message.formattedTimestamp)]") var timestampStyle = AttributeContainer() timestampStyle.foregroundColor = Color.gray.opacity(0.7) timestampStyle.font = .bitchatSystem(size: 10, design: design) result.append(timestamp.mergingAttributes(timestampStyle)) } else { var contentStyle = AttributeContainer() contentStyle.foregroundColor = Color.gray let content = AttributedString("* \(message.content) *") contentStyle.font = .bitchatSystem(size: 12, design: design).italic() result.append(content.mergingAttributes(contentStyle)) let timestamp = AttributedString(" [\(message.formattedTimestamp)]") var timestampStyle = AttributeContainer() timestampStyle.foregroundColor = Color.gray.opacity(0.5) timestampStyle.font = .bitchatSystem(size: 10, design: design) result.append(timestamp.mergingAttributes(timestampStyle)) } message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf, variant: theme.formatCacheVariant) return result } func formatMessageHeader(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme = .matrix) -> AttributedString { let design = theme.bodyFontDesign let isSelf: Bool = { if let spid = message.senderPeerID { if case .location(let channel) = viewModel.activeChannel, spid.id.hasPrefix("nostr:"), let myGeo = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { return spid == PeerID(nostr: myGeo.publicKeyHex) } return spid == viewModel.meshService.myPeerID } if message.sender == viewModel.nickname { return true } if message.sender.hasPrefix(viewModel.nickname + "#") { return true } return false }() let isDark = colorScheme == .dark let baseColor: Color = isSelf ? .orange : peerColor(for: message, isDark: isDark) if message.sender == "system" { var style = AttributeContainer() style.foregroundColor = baseColor style.font = .bitchatSystem(size: 14, weight: .medium, design: design) return AttributedString(message.sender).mergingAttributes(style) } var result = AttributedString() let (baseName, suffix) = message.sender.splitSuffix() var senderStyle = AttributeContainer() senderStyle.foregroundColor = baseColor senderStyle.font = .bitchatSystem(size: 14, weight: isSelf ? .bold : .medium, design: design) if let spid = message.senderPeerID, let url = URL(string: "bitchat://user/\(spid.id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? spid.id)") { senderStyle.link = url } result.append(AttributedString("<@").mergingAttributes(senderStyle)) result.append(AttributedString(baseName).mergingAttributes(senderStyle)) if !suffix.isEmpty { var suffixStyle = senderStyle suffixStyle.foregroundColor = baseColor.opacity(0.6) result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) } result.append(AttributedString("> ").mergingAttributes(senderStyle)) return result } func isSelfMessage(_ message: BitchatMessage) -> Bool { if let spid = message.senderPeerID { if case .location(let channel) = viewModel.activeChannel, spid.isGeoChat { let myGeo: NostrIdentity? = { if let cached = viewModel.cachedGeohashIdentity, cached.geohash == channel.geohash { return cached.identity } if let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { viewModel.cachedGeohashIdentity = (channel.geohash, identity) return identity } return nil }() if let myGeo { return spid == PeerID(nostr: myGeo.publicKeyHex) } } return spid == viewModel.meshService.myPeerID } if message.sender == viewModel.nickname { return true } if message.sender.hasPrefix(viewModel.nickname + "#") { return true } return false } func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { peerColor(for: message, isDark: isDark) } func peerURL(for peerID: PeerID) -> URL? { URL(string: "bitchat://user/\(peerID.toPercentEncoded())") } func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color { getNostrPaletteColor(for: pubkeyHexLowercased.lowercased(), isDark: isDark) } func colorForMeshPeer(id peerID: PeerID, isDark: Bool) -> Color { getPeerPaletteColor(for: peerID, isDark: isDark) } } private extension ChatMessageFormatter { func peerColor(for message: BitchatMessage, isDark: Bool) -> Color { if let spid = message.senderPeerID { if spid.isGeoChat || spid.isGeoDM { let full = viewModel.nostrKeyMapping[spid]?.lowercased() ?? spid.bare.lowercased() return getNostrPaletteColor(for: full, isDark: isDark) } else if spid.id.count == 16 { return getPeerPaletteColor(for: spid, isDark: isDark) } else { return getPeerPaletteColor(for: PeerID(str: spid.id.lowercased()), isDark: isDark) } } return Color(peerSeed: message.sender.lowercased(), isDark: isDark) } func meshSeed(for peerID: PeerID) -> String { if let full = viewModel.cachedStablePeerID(for: peerID)?.id.lowercased() { return "noise:" + full } return peerID.id.lowercased() } func getPeerPaletteColor(for peerID: PeerID, isDark: Bool) -> Color { if peerID == viewModel.meshService.myPeerID { return .orange } meshPalette.ensurePalette(for: currentMeshPaletteSeeds()) if let color = meshPalette.color(for: peerID.id, isDark: isDark) { return color } return Color(peerSeed: meshSeed(for: peerID), isDark: isDark) } func currentMeshPaletteSeeds() -> [String: String] { let myID = viewModel.meshService.myPeerID var seeds: [String: String] = [:] for peer in viewModel.allPeers where peer.peerID != myID { seeds[peer.peerID.id] = meshSeed(for: peer.peerID) } return seeds } func getNostrPaletteColor(for pubkeyHexLowercased: String, isDark: Bool) -> Color { let myHex = currentGeohashIdentityHex() if let myHex, pubkeyHexLowercased == myHex { return .orange } nostrPalette.ensurePalette(for: currentNostrPaletteSeeds(excluding: myHex)) if let color = nostrPalette.color(for: pubkeyHexLowercased, isDark: isDark) { return color } return Color(peerSeed: "nostr:" + pubkeyHexLowercased, isDark: isDark) } func currentNostrPaletteSeeds(excluding myHex: String?) -> [String: String] { var seeds: [String: String] = [:] let excluded = myHex ?? "" for person in viewModel.visibleGeohashPeople() where person.id != excluded { seeds[person.id] = "nostr:" + person.id } return seeds } func currentGeohashIdentityHex() -> String? { if case .location(let channel) = viewModel.activeChannel, let identity = try? viewModel.idBridge.deriveIdentity(forGeohash: channel.geohash) { return identity.publicKeyHex.lowercased() } return nil } }