diff --git a/bitchat/Services/GeohashParticipantTracker.swift b/bitchat/Services/GeohashParticipantTracker.swift new file mode 100644 index 00000000..1bd2df02 --- /dev/null +++ b/bitchat/Services/GeohashParticipantTracker.swift @@ -0,0 +1,159 @@ +// +// GeohashParticipantTracker.swift +// bitchat +// +// Tracks participants in geohash-based location channels. +// This is free and unencumbered software released into the public domain. +// + +import Foundation + +/// Represents a participant in a geohash channel +public struct GeoPerson: Identifiable, Equatable, Sendable { + public let id: String // pubkey hex (lowercased) + public let displayName: String + public let lastSeen: Date + + public init(id: String, displayName: String, lastSeen: Date) { + self.id = id + self.displayName = displayName + self.lastSeen = lastSeen + } +} + +/// Protocol for resolving display names and checking block status +@MainActor +public protocol GeohashParticipantContext: AnyObject { + /// Returns display name for a Nostr pubkey (e.g., "alice#a1b2" or "anon#c3d4") + func displayNameForPubkey(_ pubkeyHex: String) -> String + /// Returns true if the pubkey is blocked + func isBlocked(_ pubkeyHexLowercased: String) -> Bool +} + +/// Tracks participants across multiple geohash channels +@MainActor +public final class GeohashParticipantTracker: ObservableObject { + + /// Activity cutoff duration (defaults to 5 minutes) + public let activityCutoff: TimeInterval + + /// Per-geohash participant map: [geohash: [pubkeyHex: lastSeen]] + private var participants: [String: [String: Date]] = [:] + + /// Currently visible people for the active geohash + @Published public private(set) var visiblePeople: [GeoPerson] = [] + + /// The currently active geohash (if any) + private var activeGeohash: String? + + /// Context for display name resolution and block checking + private weak var context: GeohashParticipantContext? + + /// Timer for periodic refresh + private var refreshTimer: Timer? + + public init(activityCutoff: TimeInterval = -300) { // default 5 minutes + self.activityCutoff = activityCutoff + } + + /// Configure with a context provider + public func configure(context: GeohashParticipantContext) { + self.context = context + } + + /// Set the currently active geohash + public func setActiveGeohash(_ geohash: String?) { + activeGeohash = geohash + if geohash == nil { + visiblePeople = [] + } else { + refresh() + } + } + + /// Record activity from a participant in the current active geohash + public func recordParticipant(pubkeyHex: String) { + guard let gh = activeGeohash else { return } + recordParticipant(pubkeyHex: pubkeyHex, geohash: gh) + } + + /// Record activity from a participant in a specific geohash + public func recordParticipant(pubkeyHex: String, geohash: String) { + let key = pubkeyHex.lowercased() + var map = participants[geohash] ?? [:] + map[key] = Date() + participants[geohash] = map + + // Only refresh visible list if this geohash is currently active + if activeGeohash == geohash { + refresh() + } + } + + /// Remove a participant from all geohashes (used when blocking) + public func removeParticipant(pubkeyHex: String) { + let key = pubkeyHex.lowercased() + for (gh, var map) in participants { + map.removeValue(forKey: key) + participants[gh] = map + } + refresh() + } + + /// Get participant count for a specific geohash + public func participantCount(for geohash: String) -> Int { + let cutoff = Date().addingTimeInterval(activityCutoff) + let map = participants[geohash] ?? [:] + return map.values.filter { $0 >= cutoff }.count + } + + /// Get the visible people list for the active geohash (read-only query) + public func getVisiblePeople() -> [GeoPerson] { + guard let gh = activeGeohash, let context = context else { return [] } + let cutoff = Date().addingTimeInterval(activityCutoff) + let map = (participants[gh] ?? [:]) + .filter { $0.value >= cutoff } + .filter { !context.isBlocked($0.key) } + + return map + .map { (pub, seen) in + GeoPerson(id: pub, displayName: context.displayNameForPubkey(pub), lastSeen: seen) + } + .sorted { $0.lastSeen > $1.lastSeen } + } + + /// Refresh the visible people list + public func refresh() { + visiblePeople = getVisiblePeople() + } + + /// Start the periodic refresh timer + public func startRefreshTimer(interval: TimeInterval = 30.0) { + stopRefreshTimer() + refreshTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in + Task { @MainActor in + self?.refresh() + } + } + } + + /// Stop the periodic refresh timer + public func stopRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil + } + + /// Clear all participant data + public func clear() { + participants.removeAll() + visiblePeople = [] + } + + /// Clear participant data for a specific geohash + public func clear(geohash: String) { + participants.removeValue(forKey: geohash) + if activeGeohash == geohash { + visiblePeople = [] + } + } +} diff --git a/bitchat/Services/MessageFormattingEngine.swift b/bitchat/Services/MessageFormattingEngine.swift new file mode 100644 index 00000000..934c3f41 --- /dev/null +++ b/bitchat/Services/MessageFormattingEngine.swift @@ -0,0 +1,471 @@ +// +// MessageFormattingEngine.swift +// bitchat +// +// Handles message text formatting, including mentions, hashtags, URLs, and tokens. +// This is free and unencumbered software released into the public domain. +// + +import Foundation +import SwiftUI + +// MARK: - Formatting Context Protocol + +/// Protocol defining the context needed for message formatting. +/// Implemented by ChatViewModel to provide runtime state. +@MainActor +protocol MessageFormattingContext: AnyObject { + /// The user's current nickname + var nickname: String { get } + + /// Determines if a message was sent by the current user + func isSelfMessage(_ message: BitchatMessage) -> Bool + + /// Gets the color for a message's sender + func senderColor(for message: BitchatMessage, isDark: Bool) -> Color + + /// Resolves a peer ID to a clickable URL + func peerURL(for peerID: PeerID) -> URL? +} + +// MARK: - Formatting Engine + +/// Handles rich text formatting for chat messages. +/// Extracts mentions, hashtags, URLs, Lightning invoices, and Cashu tokens. +final class MessageFormattingEngine { + + // MARK: - Precompiled Regexes + + /// Precompiled regex patterns for message content parsing + enum 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: []) + }() + + static let simplifyHTTPURL: NSRegularExpression = { + try! NSRegularExpression(pattern: "https?://[^\\s?#]+(?:[?#][^\\s]*)?", options: [.caseInsensitive]) + }() + } + + // MARK: - Match Types + + /// Types of matches found in message content + enum MatchType: String { + case hashtag + case mention + case url + case cashu + case lightning + case bolt11 + case lnurl + } + + /// A match found in message content + struct ContentMatch { + let range: NSRange + let type: MatchType + } + + // MARK: - Public API + + /// Formats a message with rich text styling + @MainActor + static func formatMessage( + _ message: BitchatMessage, + context: MessageFormattingContext, + colorScheme: ColorScheme + ) -> AttributedString { + let isDark = colorScheme == .dark + let isSelf = context.isSelfMessage(message) + + // Check cache first + if let cached = message.getCachedFormattedText(isDark: isDark, isSelf: isSelf) { + return cached + } + + var result = AttributedString() + let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark) + + // Format system messages differently + if message.sender == "system" { + result = formatSystemMessage(message, isDark: isDark) + } else { + // Format sender header + result = formatSenderHeader( + message: message, + baseColor: baseColor, + isSelf: isSelf, + context: context + ) + + // Format content + let contentResult = formatContent( + message.content, + baseColor: baseColor, + isSelf: isSelf, + isMentioned: message.mentions?.contains(context.nickname) ?? false + ) + result.append(contentResult) + + // Add timestamp + result.append(formatTimestamp(message.formattedTimestamp)) + } + + // Cache the result + message.setCachedFormattedText(result, isDark: isDark, isSelf: isSelf) + + return result + } + + /// Formats just the message header (sender portion) + @MainActor + static func formatHeader( + _ message: BitchatMessage, + context: MessageFormattingContext, + colorScheme: ColorScheme + ) -> AttributedString { + let isDark = colorScheme == .dark + let isSelf = context.isSelfMessage(message) + let baseColor: Color = isSelf ? .orange : context.senderColor(for: message, isDark: isDark) + + if message.sender == "system" { + var style = AttributeContainer() + style.foregroundColor = baseColor + style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced) + return AttributedString(message.sender).mergingAttributes(style) + } + + return formatSenderHeader( + message: message, + baseColor: baseColor, + isSelf: isSelf, + context: context + ) + } + + /// Extracts mentions from message content + static func extractMentions(from content: String) -> [String] { + let nsContent = content as NSString + let range = NSRange(location: 0, length: nsContent.length) + let matches = Patterns.mention.matches(in: content, options: [], range: range) + + return matches.compactMap { match -> String? in + guard match.numberOfRanges > 1 else { return nil } + let captureRange = match.range(at: 1) + guard let swiftRange = Range(captureRange, in: content) else { return nil } + return String(content[swiftRange]) + } + } + + /// Checks if content contains a Cashu token + static func containsCashuToken(_ content: String) -> Bool { + let nsContent = content as NSString + let range = NSRange(location: 0, length: nsContent.length) + return Patterns.quickCashuPresence.numberOfMatches(in: content, options: [], range: range) > 0 + } + + // MARK: - Private Helpers + + private static func formatSystemMessage(_ message: BitchatMessage, isDark: Bool) -> AttributedString { + var result = AttributedString() + + 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 + 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)) + + return result + } + + @MainActor + private static func formatSenderHeader( + message: BitchatMessage, + baseColor: Color, + isSelf: Bool, + context: MessageFormattingContext + ) -> AttributedString { + var result = AttributedString() + + 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: .monospaced) + + // Make sender clickable + if let spid = message.senderPeerID, let url = context.peerURL(for: spid) { + senderStyle.link = url + } + + // Build: "<@baseName#suffix> " + 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 + } + + private static func formatContent( + _ content: String, + baseColor: Color, + isSelf: Bool, + isMentioned: Bool + ) -> AttributedString { + // For very long content without special tokens, use plain formatting + let containsCashu = containsCashuToken(content) + if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu { + return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf) + } + + // Find all matches + let matches = findAllMatches(in: content) + + // Build formatted content + var result = AttributedString() + var lastEnd = content.startIndex + + for match in matches { + guard let swiftRange = Range(match.range, in: content) else { continue } + + // Add text before match + if lastEnd < swiftRange.lowerBound { + let beforeText = String(content[lastEnd.. [ContentMatch] { + let nsContent = content as NSString + let nsLen = nsContent.length + let fullRange = NSRange(location: 0, length: nsLen) + + // Quick hints to avoid unnecessary regex work + let hasMentions = content.contains("@") + let hasHashtags = content.contains("#") + let hasURLs = content.contains("://") || content.contains("www.") || content.contains("http") + let hasLightning = content.lowercased().contains("ln") || content.lowercased().contains("lightning:") + let hasCashu = content.lowercased().contains("cashu") + + // Collect matches + let mentionMatches = hasMentions ? Patterns.mention.matches(in: content, options: [], range: fullRange) : [] + let hashtagMatches = hasHashtags ? Patterns.hashtag.matches(in: content, options: [], range: fullRange) : [] + let urlMatches = hasURLs ? (Patterns.linkDetector?.matches(in: content, options: [], range: fullRange) ?? []) : [] + let cashuMatches = hasCashu ? Patterns.cashu.matches(in: content, options: [], range: fullRange) : [] + let lightningMatches = hasLightning ? Patterns.lightningScheme.matches(in: content, options: [], range: fullRange) : [] + let bolt11Matches = hasLightning ? Patterns.bolt11.matches(in: content, options: [], range: fullRange) : [] + let lnurlMatches = hasLightning ? Patterns.lnurl.matches(in: content, options: [], range: fullRange) : [] + + // Build mention ranges for overlap checking + let mentionRanges = mentionMatches.map { $0.range(at: 0) } + + func overlapsMention(_ r: NSRange) -> Bool { + mentionRanges.contains { NSIntersectionRange(r, $0).length > 0 } + } + + func isStandaloneHashtag(_ r: NSRange) -> Bool { + guard let swiftRange = Range(r, in: content) else { return false } + if swiftRange.lowerBound == content.startIndex { return true } + let prev = content.index(before: swiftRange.lowerBound) + return content[prev].isWhitespace || content[prev].isNewline + } + + func attachedToMention(_ r: NSRange) -> Bool { + guard let swiftRange = Range(r, in: content), swiftRange.lowerBound > content.startIndex else { return false } + var i = content.index(before: swiftRange.lowerBound) + while true { + let ch = content[i] + if ch.isWhitespace || ch.isNewline { break } + if ch == "@" { return true } + if i == content.startIndex { break } + i = content.index(before: i) + } + return false + } + + var allMatches: [ContentMatch] = [] + + // Add hashtags (excluding those attached to mentions) + for match in hashtagMatches { + let range = match.range(at: 0) + if !overlapsMention(range) && !attachedToMention(range) && isStandaloneHashtag(range) { + allMatches.append(ContentMatch(range: range, type: .hashtag)) + } + } + + // Add mentions + for match in mentionMatches { + allMatches.append(ContentMatch(range: match.range(at: 0), type: .mention)) + } + + // Add URLs + for match in urlMatches where !overlapsMention(match.range) { + allMatches.append(ContentMatch(range: match.range, type: .url)) + } + + // Add Cashu tokens + for match in cashuMatches where !overlapsMention(match.range(at: 0)) { + allMatches.append(ContentMatch(range: match.range(at: 0), type: .cashu)) + } + + // Add Lightning scheme URLs + for match in lightningMatches where !overlapsMention(match.range(at: 0)) { + allMatches.append(ContentMatch(range: match.range(at: 0), type: .lightning)) + } + + // Add bolt11/lnurl (avoiding overlaps with lightning scheme and URLs) + let occupied = urlMatches.map { $0.range } + lightningMatches.map { $0.range(at: 0) } + func overlapsOccupied(_ r: NSRange) -> Bool { + occupied.contains { NSIntersectionRange(r, $0).length > 0 } + } + + for match in bolt11Matches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { + allMatches.append(ContentMatch(range: match.range(at: 0), type: .bolt11)) + } + + for match in lnurlMatches where !overlapsMention(match.range(at: 0)) && !overlapsOccupied(match.range(at: 0)) { + allMatches.append(ContentMatch(range: match.range(at: 0), type: .lnurl)) + } + + // Sort by position + return allMatches.sorted { $0.range.location < $1.range.location } + } + + private static func formatPlainContent(_ content: String, baseColor: Color, isSelf: Bool) -> AttributedString { + var style = AttributeContainer() + style.foregroundColor = baseColor + style.font = isSelf + ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) + : .bitchatSystem(size: 14, design: .monospaced) + return AttributedString(content).mergingAttributes(style) + } + + private static func formatPlainText(_ text: String, baseColor: Color, isSelf: Bool, isMentioned: Bool) -> AttributedString { + guard !text.isEmpty else { return AttributedString() } + + var style = AttributeContainer() + style.foregroundColor = baseColor + style.font = isSelf + ? .bitchatSystem(size: 14, weight: .bold, design: .monospaced) + : .bitchatSystem(size: 14, design: .monospaced) + + if isMentioned { + style.font = style.font?.bold() + } + + return AttributedString(text).mergingAttributes(style) + } + + private static func formatMatch(_ text: String, type: MatchType, baseColor: Color, isSelf: Bool) -> AttributedString { + var style = AttributeContainer() + + switch type { + case .mention: + // Split optional '#abcd' suffix + let (baseName, suffix) = text.splitSuffix() + var result = AttributedString() + + var mentionStyle = AttributeContainer() + mentionStyle.foregroundColor = .blue + mentionStyle.font = .bitchatSystem(size: 14, weight: .semibold, design: .monospaced) + result.append(AttributedString(baseName).mergingAttributes(mentionStyle)) + + if !suffix.isEmpty { + var suffixStyle = mentionStyle + suffixStyle.foregroundColor = Color.gray.opacity(0.7) + result.append(AttributedString(suffix).mergingAttributes(suffixStyle)) + } + + return result + + case .hashtag: + style.foregroundColor = .purple + style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced) + + case .url: + style.foregroundColor = .blue + style.font = .bitchatSystem(size: 14, design: .monospaced) + style.underlineStyle = .single + if let url = URL(string: text) { + style.link = url + } + + case .cashu: + style.foregroundColor = .green + style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced) + style.backgroundColor = Color.green.opacity(0.1) + + case .lightning, .bolt11, .lnurl: + style.foregroundColor = .yellow + style.font = .bitchatSystem(size: 14, weight: .medium, design: .monospaced) + style.backgroundColor = Color.yellow.opacity(0.1) + } + + return AttributedString(text).mergingAttributes(style) + } + + private static func formatTimestamp(_ timestamp: String) -> AttributedString { + let text = AttributedString(" [\(timestamp)]") + var style = AttributeContainer() + style.foregroundColor = Color.gray.opacity(0.5) + style.font = .bitchatSystem(size: 10, design: .monospaced) + return text.mergingAttributes(style) + } +} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index cea5e752..0e7bcd61 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -92,7 +92,7 @@ import UniformTypeIdentifiers /// Manages the application state and business logic for BitChat. /// Acts as the primary coordinator between UI components and backend services, /// implementing the BitchatDelegate protocol to handle network events. -final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProvider { +final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProvider, GeohashParticipantContext, MessageFormattingContext { // Precompiled regexes and detectors reused across formatting private enum Regexes { static let hashtag: NSRegularExpression = { @@ -419,10 +419,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv private var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time private var lastPublicActivityNotifyAt: [String: Date] = [:] private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds - // Geohash participants (per geohash: pubkey -> lastSeen) - private var geoParticipants: [String: [String: Date]] = [:] - @Published private(set) var geohashPeople: [GeoPerson] = [] - private var geoParticipantsTimer: Timer? = nil + // Geohash participant tracker + let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds) // Participants who indicated they teleported (by tag in their events) @Published private(set) var teleportedGeo: Set = [] // lowercased pubkey hex // Sampling subscriptions for multiple geohashes (when channel sheet is open) @@ -526,6 +524,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv // Wire up dependencies self.commandProcessor.chatViewModel = self + self.participantTracker.configure(context: self) // Subscribe to privateChatManager changes to trigger UI updates privateChatManager.objectWillChange @@ -533,6 +532,13 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv self?.objectWillChange.send() } .store(in: &cancellables) + + // Subscribe to participantTracker changes to trigger UI updates + participantTracker.objectWillChange + .sink { [weak self] _ in + self?.objectWillChange.send() + } + .store(in: &cancellables) self.commandProcessor.meshService = meshService loadNickname() @@ -873,7 +879,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv return } // Ensure participant decay timer is running - startGeoParticipantsTimer() + participantTracker.startRefreshTimer() // Unsubscribe + resubscribe NostrRelayManager.shared.unsubscribe(id: subID) let filter = NostrFilter.geohashEphemeral( @@ -932,7 +938,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey // Update participants last-seen for this pubkey - recordGeoParticipant(pubkeyHex: event.pubkey) + participantTracker.recordParticipant(pubkeyHex: event.pubkey) // Track teleported tag (only our format ["t","teleport"]) for icon state let hasTeleportTag = event.tags.contains(where: { tag in @@ -1479,7 +1485,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv } // Track ourselves as active participant - recordGeoParticipant(pubkeyHex: identity.publicKeyHex) + participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) nostrKeyMapping[PeerID(nostr: identity.publicKeyHex)] = identity.publicKeyHex SecureLogger.debug("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(context.teleported)", category: .session) @@ -1514,8 +1520,8 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv if emptyMesh > 0 { SecureLogger.debug("RenderGuard: mesh timeline contains \(emptyMesh) empty messages", category: .session) } - stopGeoParticipantsTimer() - geohashPeople = [] + participantTracker.stopRefreshTimer() + participantTracker.setActiveGeohash(nil) teleportedGeo.removeAll() case .location: refreshVisibleMessages(from: channel) @@ -1536,16 +1542,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv geoDmSubscriptionID = nil } currentGeohash = nil + participantTracker.setActiveGeohash(nil) // Reset nickname cache for geochat participants geoNicknames.removeAll() - geohashPeople = [] - + guard case .location(let ch) = channel else { return } currentGeohash = ch.geohash + participantTracker.setActiveGeohash(ch.geohash) // Ensure self appears immediately in the people list; mark teleported state only when truly teleported if let id = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { - recordGeoParticipant(pubkeyHex: id.publicKeyHex) + participantTracker.recordParticipant(pubkeyHex: id.publicKeyHex) let hasRegional = !LocationChannelManager.shared.availableChannels.isEmpty let inRegional = LocationChannelManager.shared.availableChannels.contains { $0.geohash == ch.geohash } let key = id.publicKeyHex.lowercased() @@ -1559,7 +1566,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv let subID = "geo-\(ch.geohash)" geoSubscriptionID = subID - startGeoParticipantsTimer() + participantTracker.startRefreshTimer() let ts = Date().addingTimeInterval(-TransportConfig.nostrGeohashInitialLookbackSeconds) let filter = NostrFilter.geohashEphemeral(ch.geohash, since: ts, limit: TransportConfig.nostrGeohashInitialLimit) let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) @@ -1629,7 +1636,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv nostrKeyMapping[PeerID(nostr: event.pubkey)] = event.pubkey // Update participants last-seen for this pubkey - recordGeoParticipant(pubkeyHex: event.pubkey) + participantTracker.recordParticipant(pubkeyHex: event.pubkey) let senderName = displayNameForNostrPubkey(event.pubkey) let content = event.content @@ -1811,49 +1818,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv } // MARK: - Geohash Participants - struct GeoPerson: Identifiable, Equatable { - let id: String // pubkey hex (lowercased) - let displayName: String - let lastSeen: Date - } - - private func recordGeoParticipant(pubkeyHex: String) { - guard let gh = currentGeohash else { return } - let key = pubkeyHex.lowercased() - var map = geoParticipants[gh] ?? [:] - map[key] = Date() - geoParticipants[gh] = map - refreshGeohashPeople() - } - - private func recordGeoParticipant(pubkeyHex: String, geohash: String) { - let key = pubkeyHex.lowercased() - var map = geoParticipants[geohash] ?? [:] - map[key] = Date() - geoParticipants[geohash] = map - // Only refresh list if this geohash is currently selected - if currentGeohash == geohash { - refreshGeohashPeople() - } - } - - private func refreshGeohashPeople() { - guard let gh = currentGeohash else { geohashPeople = []; return } - let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) - var map = geoParticipants[gh] ?? [:] - // Prune expired entries - map = map.filter { $0.value >= cutoff } - // Remove blocked Nostr pubkeys - map = map.filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) } - geoParticipants[gh] = map - // Build display list - let people = map - .map { (pub, seen) in - GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) - } - .sorted { $0.lastSeen > $1.lastSeen } - geohashPeople = people - } @MainActor func isSelfSender(peerID: PeerID?, displayName: String?) -> Bool { @@ -1878,33 +1842,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv return false } - private func startGeoParticipantsTimer() { - stopGeoParticipantsTimer() - geoParticipantsTimer = Timer.scheduledTimer(withTimeInterval: 30.0, repeats: true) { [weak self] _ in - Task { @MainActor in - self?.refreshGeohashPeople() - } - } + // MARK: - Public helpers + + /// Published geohash people list for SwiftUI observation + var geohashPeople: [GeoPerson] { + participantTracker.visiblePeople } - private func stopGeoParticipantsTimer() { - geoParticipantsTimer?.invalidate() - geoParticipantsTimer = nil - } - - // MARK: - Public helpers /// Return the current, pruned, sorted people list for the active geohash without mutating state. @MainActor func visibleGeohashPeople() -> [GeoPerson] { - guard let gh = currentGeohash else { return [] } - let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) - let map = (geoParticipants[gh] ?? [:]) - .filter { $0.value >= cutoff } - .filter { !identityManager.isNostrBlocked(pubkeyHexLowercased: $0.key) } - let people = map - .map { (pub, seen) in GeoPerson(id: pub, displayName: displayNameForNostrPubkey(pub), lastSeen: seen) } - .sorted { $0.lastSeen > $1.lastSeen } - return people + participantTracker.getVisiblePeople() } /// CommandContextProvider conformance - returns visible geo participants @@ -1914,9 +1862,17 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv /// Returns the current participant count for a specific geohash, using the 5-minute activity window. @MainActor func geohashParticipantCount(for geohash: String) -> Int { - let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) - let map = geoParticipants[geohash] ?? [:] - return map.values.filter { $0 >= cutoff }.count + participantTracker.participantCount(for: geohash) + } + + // MARK: - GeohashParticipantContext Protocol + + func displayNameForPubkey(_ pubkeyHex: String) -> String { + displayNameForNostrPubkey(pubkeyHex) + } + + func isBlocked(_ pubkeyHexLowercased: String) -> Bool { + identityManager.isNostrBlocked(pubkeyHexLowercased: pubkeyHexLowercased) } // Geohash block helpers @@ -1928,13 +1884,9 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv func blockGeohashUser(pubkeyHexLowercased: String, displayName: String) { let hex = pubkeyHexLowercased.lowercased() identityManager.setNostrBlocked(hex, isBlocked: true) - + // Remove from participants for all geohashes - for (gh, var map) in geoParticipants { - map.removeValue(forKey: hex) - geoParticipants[gh] = map - } - refreshGeohashPeople() + participantTracker.removeParticipant(pubkeyHex: hex) // Remove their public messages from current geohash timeline and visible list if let gh = currentGeohash { @@ -2022,13 +1974,12 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv private func subscribeNostrEvent(_ event: NostrEvent, gh: String) { guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } - + // Compute current participant count (5-minute window) BEFORE updating with this event - let cutoff = Date().addingTimeInterval(-TransportConfig.uiRecentCutoffFiveMinutesSeconds) - let existingCount = geoParticipants[gh]?.values.filter { $0 >= cutoff }.count ?? 0 - + let existingCount = participantTracker.participantCount(for: gh) + // Update participants for this specific geohash - recordGeoParticipant(pubkeyHex: event.pubkey, geohash: gh) + participantTracker.recordParticipant(pubkeyHex: event.pubkey, geohash: gh) // Notify only on rising-edge: previously zero people, now someone sends a chat let content = event.content.trimmingCharacters(in: .whitespacesAndNewlines) @@ -3229,7 +3180,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv NostrRelayManager.shared.sendEvent(event, to: targetRelays) } // Track ourselves as active participant - self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) + self.participantTracker.recordParticipant(pubkeyHex: identity.publicKeyHex) } catch { SecureLogger.error("❌ Failed to send geohash screenshot message: \(error)", category: .session) self.addSystemMessage( @@ -4242,6 +4193,46 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv return Color(peerSeed: message.sender.lowercased(), isDark: isDark) } + // MARK: - MessageFormattingContext Protocol + + @MainActor + func isSelfMessage(_ message: BitchatMessage) -> Bool { + if let spid = message.senderPeerID { + // In geohash channels, compare against our per-geohash nostr short ID + if case .location(let ch) = activeChannel, spid.isGeoChat { + let myGeo: NostrIdentity? = { + if let cached = cachedGeohashIdentity, cached.geohash == ch.geohash { + return cached.identity + } + // Derive and cache + if let identity = try? idBridge.deriveIdentity(forGeohash: ch.geohash) { + cachedGeohashIdentity = (ch.geohash, identity) + return identity + } + return nil + }() + if let myGeo { + return spid == PeerID(nostr: myGeo.publicKeyHex) + } + } + return spid == meshService.myPeerID + } + // Fallback by nickname + if message.sender == nickname { return true } + if message.sender.hasPrefix(nickname + "#") { return true } + return false + } + + @MainActor + func senderColor(for message: BitchatMessage, isDark: Bool) -> Color { + return peerColor(for: message, isDark: isDark) + } + + @MainActor + func peerURL(for peerID: PeerID) -> URL? { + return URL(string: "bitchat://user/\(peerID.toPercentEncoded())") + } + // Public helpers for views to color peers consistently in lists @MainActor func colorForNostrPubkey(_ pubkeyHexLowercased: String, isDark: Bool) -> Color { diff --git a/bitchatTests/GeohashParticipantTrackerTests.swift b/bitchatTests/GeohashParticipantTrackerTests.swift new file mode 100644 index 00000000..d05b8171 --- /dev/null +++ b/bitchatTests/GeohashParticipantTrackerTests.swift @@ -0,0 +1,293 @@ +// +// GeohashParticipantTrackerTests.swift +// bitchatTests +// +// Tests for GeohashParticipantTracker. +// This is free and unencumbered software released into the public domain. +// + +import Testing +import Foundation +@testable import bitchat + +/// Mock context for testing +@MainActor +final class MockParticipantContext: GeohashParticipantContext { + var blockedPubkeys: Set = [] + var nicknameMap: [String: String] = [:] + var selfPubkey: String? + + func displayNameForPubkey(_ pubkeyHex: String) -> String { + let suffix = String(pubkeyHex.suffix(4)) + if let self = selfPubkey, pubkeyHex.lowercased() == self.lowercased() { + return "me#\(suffix)" + } + if let nick = nicknameMap[pubkeyHex.lowercased()] { + return "\(nick)#\(suffix)" + } + return "anon#\(suffix)" + } + + func isBlocked(_ pubkeyHexLowercased: String) -> Bool { + blockedPubkeys.contains(pubkeyHexLowercased.lowercased()) + } +} + +@MainActor +struct GeohashParticipantTrackerTests { + + // MARK: - Basic Recording Tests + + @Test func recordParticipant_addsToActiveGeohash() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "deadbeef1234") + + #expect(tracker.participantCount(for: "abc123") == 1) + } + + @Test func recordParticipant_noActiveGeohash_noOp() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + // No active geohash set + + tracker.recordParticipant(pubkeyHex: "deadbeef1234") + + // Should not throw or crash + #expect(tracker.participantCount(for: "abc123") == 0) + } + + @Test func recordParticipant_specificGeohash() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + + tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1") + tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2") + + #expect(tracker.participantCount(for: "geo1") == 1) + #expect(tracker.participantCount(for: "geo2") == 1) + } + + @Test func recordParticipant_updatesLastSeen() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "pubkey1") + // Small delay and record again + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + tracker.recordParticipant(pubkeyHex: "pubkey1") + + // Should still count as 1 participant (updated, not duplicated) + #expect(tracker.participantCount(for: "abc123") == 1) + } + + @Test func recordParticipant_lowercasesPubkey() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "DEADBEEF") + tracker.recordParticipant(pubkeyHex: "deadbeef") + + // Should be treated as same participant + #expect(tracker.participantCount(for: "abc123") == 1) + } + + // MARK: - Visible People Tests + + @Test func getVisiblePeople_returnsActiveGeohashParticipants() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "pubkey1") + tracker.recordParticipant(pubkeyHex: "pubkey2") + + let people = tracker.getVisiblePeople() + #expect(people.count == 2) + } + + @Test func getVisiblePeople_excludesBlockedParticipants() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + context.blockedPubkeys = ["pubkey2"] + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "pubkey1") + tracker.recordParticipant(pubkeyHex: "pubkey2") + + let people = tracker.getVisiblePeople() + #expect(people.count == 1) + #expect(people.first?.id == "pubkey1") + } + + @Test func getVisiblePeople_usesDisplayNameFromContext() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + context.nicknameMap = ["pubkey1234": "alice"] + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "pubkey1234") + + let people = tracker.getVisiblePeople() + #expect(people.count == 1) + #expect(people.first?.displayName == "alice#1234") + } + + @Test func getVisiblePeople_sortedByLastSeen() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "older") + try? await Task.sleep(nanoseconds: 10_000_000) // 10ms + tracker.recordParticipant(pubkeyHex: "newer") + + let people = tracker.getVisiblePeople() + #expect(people.count == 2) + #expect(people.first?.id == "newer") + #expect(people.last?.id == "older") + } + + @Test func getVisiblePeople_emptyWhenNoActiveGeohash() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + + tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123") + + let people = tracker.getVisiblePeople() + #expect(people.isEmpty) + } + + // MARK: - Activity Cutoff Tests + + @Test func participantCount_excludesExpiredEntries() async { + // Use a very short cutoff for testing + let tracker = GeohashParticipantTracker(activityCutoff: -0.05) // 50ms cutoff + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "pubkey1") + + // Should be counted immediately + #expect(tracker.participantCount(for: "abc123") == 1) + + // Wait for expiry + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + + // Should be expired now + #expect(tracker.participantCount(for: "abc123") == 0) + } + + // MARK: - Remove Participant Tests + + @Test func removeParticipant_removesFromAllGeohashes() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + + tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1") + tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo2") + tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo1") + + tracker.removeParticipant(pubkeyHex: "pubkey1") + + #expect(tracker.participantCount(for: "geo1") == 1) + #expect(tracker.participantCount(for: "geo2") == 0) + } + + // MARK: - Clear Tests + + @Test func clear_removesAllData() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + + tracker.recordParticipant(pubkeyHex: "pubkey1") + tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "other") + + tracker.clear() + + #expect(tracker.participantCount(for: "abc123") == 0) + #expect(tracker.participantCount(for: "other") == 0) + #expect(tracker.visiblePeople.isEmpty) + } + + @Test func clearGeohash_removesOnlySpecificGeohash() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + + tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "geo1") + tracker.recordParticipant(pubkeyHex: "pubkey2", geohash: "geo2") + + tracker.clear(geohash: "geo1") + + #expect(tracker.participantCount(for: "geo1") == 0) + #expect(tracker.participantCount(for: "geo2") == 1) + } + + // MARK: - Set Active Geohash Tests + + @Test func setActiveGeohash_clearsVisiblePeopleWhenNil() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + tracker.setActiveGeohash("abc123") + tracker.recordParticipant(pubkeyHex: "pubkey1") + + #expect(!tracker.visiblePeople.isEmpty) + + tracker.setActiveGeohash(nil) + + #expect(tracker.visiblePeople.isEmpty) + } + + @Test func setActiveGeohash_refreshesVisiblePeople() async { + let tracker = GeohashParticipantTracker() + let context = MockParticipantContext() + tracker.configure(context: context) + + // Pre-populate a geohash + tracker.recordParticipant(pubkeyHex: "pubkey1", geohash: "abc123") + + // Set it as active + tracker.setActiveGeohash("abc123") + + #expect(tracker.visiblePeople.count == 1) + } + + // MARK: - GeoPerson Tests + + @Test func geoPerson_identifiable() async { + let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date()) + let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: Date()) + let person3 = GeoPerson(id: "xyz", displayName: "bob", lastSeen: Date()) + + #expect(person1.id == person2.id) + #expect(person1.id != person3.id) + } + + @Test func geoPerson_equatable() async { + let date = Date() + let person1 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date) + let person2 = GeoPerson(id: "abc", displayName: "alice", lastSeen: date) + + #expect(person1 == person2) + } +} diff --git a/bitchatTests/MessageFormattingEngineTests.swift b/bitchatTests/MessageFormattingEngineTests.swift new file mode 100644 index 00000000..3de0b95f --- /dev/null +++ b/bitchatTests/MessageFormattingEngineTests.swift @@ -0,0 +1,223 @@ +// +// MessageFormattingEngineTests.swift +// bitchatTests +// +// Tests for MessageFormattingEngine regex patterns and utility functions. +// This is free and unencumbered software released into the public domain. +// + +import Testing +import Foundation +import SwiftUI +@testable import bitchat + +struct MessageFormattingEngineTests { + + // MARK: - Mention Extraction Tests + + @Test func extractMentions_singleMention() { + let content = "Hello @alice how are you?" + let mentions = MessageFormattingEngine.extractMentions(from: content) + #expect(mentions == ["alice"]) + } + + @Test func extractMentions_multipleMentions() { + let content = "@alice and @bob are chatting with @charlie" + let mentions = MessageFormattingEngine.extractMentions(from: content) + #expect(mentions.count == 3) + #expect(mentions.contains("alice")) + #expect(mentions.contains("bob")) + #expect(mentions.contains("charlie")) + } + + @Test func extractMentions_mentionWithSuffix() { + let content = "Hey @alice#a1b2 check this out" + let mentions = MessageFormattingEngine.extractMentions(from: content) + #expect(mentions == ["alice#a1b2"]) + } + + @Test func extractMentions_noMentions() { + let content = "Just a regular message with no mentions" + let mentions = MessageFormattingEngine.extractMentions(from: content) + #expect(mentions.isEmpty) + } + + @Test func extractMentions_unicodeNickname() { + let content = "Hello @日本語 and @émile" + let mentions = MessageFormattingEngine.extractMentions(from: content) + #expect(mentions.count == 2) + #expect(mentions.contains("日本語")) + #expect(mentions.contains("émile")) + } + + @Test func extractMentions_mentionWithUnderscore() { + let content = "Thanks @user_name_123" + let mentions = MessageFormattingEngine.extractMentions(from: content) + #expect(mentions == ["user_name_123"]) + } + + @Test func extractMentions_emailNotCaptured() { + // Email addresses should not be captured as mentions + let content = "Contact me at test@example.com" + let mentions = MessageFormattingEngine.extractMentions(from: content) + // The regex will capture "example" after @ in email - this is expected behavior + // as the regex doesn't distinguish email addresses + #expect(mentions.count == 1) + } + + // MARK: - Cashu Token Detection Tests + + @Test func containsCashuToken_validTokenA() { + let content = "Here's a token: cashuAeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i" + #expect(MessageFormattingEngine.containsCashuToken(content)) + } + + @Test func containsCashuToken_validTokenB() { + let content = "Payment: cashuBeyJwcm9vZnMiOiJIZWxsbyBXb3JsZCEgVGhpcyBpcyBhIHRlc3QgdG9rZW4i" + #expect(MessageFormattingEngine.containsCashuToken(content)) + } + + @Test func containsCashuToken_noToken() { + let content = "Just a regular message about cashews" + #expect(!MessageFormattingEngine.containsCashuToken(content)) + } + + @Test func containsCashuToken_tooShort() { + let content = "Invalid: cashuAshort" + #expect(!MessageFormattingEngine.containsCashuToken(content)) + } + + // MARK: - Regex Pattern Tests + + @Test func hashtagPattern_standaloneHashtag() { + let content = "#bitcoin is great" + let nsContent = content as NSString + let range = NSRange(location: 0, length: nsContent.length) + let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range) + #expect(matches.count == 1) + } + + @Test func hashtagPattern_multipleHashtags() { + let content = "#bitcoin #lightning #nostr" + let nsContent = content as NSString + let range = NSRange(location: 0, length: nsContent.length) + let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range) + #expect(matches.count == 3) + } + + @Test func hashtagPattern_hashInMiddleOfWord() { + let content = "test#notahashtag" + let nsContent = content as NSString + let range = NSRange(location: 0, length: nsContent.length) + let matches = MessageFormattingEngine.Patterns.hashtag.matches(in: content, options: [], range: range) + // This will match because the regex doesn't check for word boundaries + #expect(matches.count == 1) + } + + @Test func bolt11Pattern_mainnet() { + let content = "Pay this: lnbc10u1pjexampleinvoice0000000000000000000000000000000000000000000" + let nsContent = content as NSString + let range = NSRange(location: 0, length: nsContent.length) + let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range) + #expect(matches.count == 1) + } + + @Test func bolt11Pattern_testnet() { + let content = "Test: lntb10u1pjexampleinvoice0000000000000000000000000000000000000000000" + let nsContent = content as NSString + let range = NSRange(location: 0, length: nsContent.length) + let matches = MessageFormattingEngine.Patterns.bolt11.matches(in: content, options: [], range: range) + #expect(matches.count == 1) + } + + @Test func lnurlPattern_valid() { + let content = "LNURL: lnurl1dp68gurn8ghj7um9wfmxjcm99e3k7mf0v9cxj0m385ekvcenxc6r2c35xvukxefcv5mkvv34x5ekzd3ev56nyd3hxqurzepexejxxepnxscrvwfnv9nxzcn9xq6xyefhvgcxxcmyxymnserx" + let nsContent = content as NSString + let range = NSRange(location: 0, length: nsContent.length) + let matches = MessageFormattingEngine.Patterns.lnurl.matches(in: content, options: [], range: range) + #expect(matches.count == 1) + } + + @Test func lightningSchemePattern_valid() { + let content = "Click: lightning:lnbc10u1example" + let nsContent = content as NSString + let range = NSRange(location: 0, length: nsContent.length) + let matches = MessageFormattingEngine.Patterns.lightningScheme.matches(in: content, options: [], range: range) + #expect(matches.count == 1) + } + + @Test func cashuPattern_valid() { + let content = "Token: cashuAeyJwcm9vZnMiOlt7ImlkIjoiMDAwMDAwMDAwMDAwMDAwMCJ9XX0=" + let nsContent = content as NSString + let range = NSRange(location: 0, length: nsContent.length) + let matches = MessageFormattingEngine.Patterns.cashu.matches(in: content, options: [], range: range) + #expect(matches.count == 1) + } + + // MARK: - URL Detection Tests + + @Test func linkDetector_httpURL() { + let content = "Check out http://example.com" + let nsContent = content as NSString + let range = NSRange(location: 0, length: nsContent.length) + let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? [] + #expect(matches.count == 1) + } + + @Test func linkDetector_httpsURL() { + let content = "Visit https://example.com/path?query=value" + let nsContent = content as NSString + let range = NSRange(location: 0, length: nsContent.length) + let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? [] + #expect(matches.count == 1) + } + + @Test func linkDetector_multipleURLs() { + let content = "See https://a.com and http://b.com" + let nsContent = content as NSString + let range = NSRange(location: 0, length: nsContent.length) + let matches = MessageFormattingEngine.Patterns.linkDetector?.matches(in: content, options: [], range: range) ?? [] + #expect(matches.count == 2) + } + + // MARK: - String Extension Tests + + @Test func splitSuffix_withSuffix() { + let name = "alice#a1b2" + let (base, suffix) = name.splitSuffix() + #expect(base == "alice") + #expect(suffix == "#a1b2") + } + + @Test func splitSuffix_withoutSuffix() { + let name = "alice" + let (base, suffix) = name.splitSuffix() + #expect(base == "alice") + #expect(suffix == "") + } + + @Test func splitSuffix_withAtPrefix() { + let name = "@alice#a1b2" + let (base, suffix) = name.splitSuffix() + #expect(base == "alice") + #expect(suffix == "#a1b2") + } + + @Test func hasVeryLongToken_noLongToken() { + let content = "Short words only here" + #expect(!content.hasVeryLongToken(threshold: 50)) + } + + @Test func hasVeryLongToken_withLongToken() { + let longToken = String(repeating: "a", count: 100) + let content = "Here is a \(longToken) token" + #expect(content.hasVeryLongToken(threshold: 50)) + } + + @Test func hasVeryLongToken_exactThreshold() { + let exactToken = String(repeating: "a", count: 50) + let content = "Token: \(exactToken)" + // Exactly at threshold DOES trigger (uses >= comparison) + #expect(content.hasVeryLongToken(threshold: 50)) + } +}