diff --git a/bitchat/Services/MessageFormattingEngine.swift b/bitchat/Services/MessageFormattingEngine.swift index 88a32e3c..adb7f2c4 100644 --- a/bitchat/Services/MessageFormattingEngine.swift +++ b/bitchat/Services/MessageFormattingEngine.swift @@ -368,8 +368,44 @@ final class MessageFormattingEngine { allMatches.append(ContentMatch(range: match.range(at: 0), type: .lnurl)) } - // Sort by position - return allMatches.sorted { $0.range.location < $1.range.location } + // The per-type checks above only guard specific known pairs (e.g. + // bolt11/lnurl against url/lightning) -- they don't cover every + // combination, so an ordinary message can still produce overlapping + // matches of two OTHER types the checks never compared (e.g. a URL + // whose path embeds a "cashuA..."-shaped token). Resolve any + // remaining overlap before handing matches to a rendering pass. + return resolveOverlappingMatches(allMatches) { $0.range } + } + + /// Sorts matches by start position and greedily keeps only non-overlapping + /// ones, dropping any later match whose start falls inside an + /// already-kept match's range. Rendering passes that consume the result + /// (here and in `ChatMessageFormatter`, which has its own copy of the + /// match-collection logic above) assume non-overlapping, + /// strictly-increasing ranges; without this resolution a nested match + /// re-renders already-shown text and can walk the render cursor + /// backwards, duplicating a trailing slice of content a second time. + /// + /// Ties break by start position only, not length: whichever match starts + /// first wins outright, so an outer match (e.g. a URL) always keeps + /// priority over a shorter match nested inside it (e.g. an embedded cashu + /// token) rather than the other way around. That's intentional for + /// rendering — flip it to prefer the inner match only with a matching + /// change to how callers render the dropped outer span. + static func resolveOverlappingMatches( + _ matches: [Match], + range: (Match) -> NSRange + ) -> [Match] { + let sorted = matches.sorted { range($0).location < range($1).location } + var resolved: [Match] = [] + var occupiedUntil = 0 + for match in sorted { + let matchRange = range(match) + guard matchRange.location >= occupiedUntil else { continue } + resolved.append(match) + occupiedUntil = matchRange.location + matchRange.length + } + return resolved } private static func formatPlainContent(_ content: String, baseColor: Color, isSelf: Bool) -> AttributedString { diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index 1a00315b..b6653ab6 100644 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -188,13 +188,23 @@ final class ChatMessageFormatter { 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 } + + // The per-type checks above only guard specific known pairs + // (e.g. bolt11/lnurl against url+lightning); they don't + // cover every combination, so an ordinary message can still + // produce overlapping matches of two OTHER types (e.g. a URL + // whose path embeds a "cashuA..."-shaped token). The render + // loop below assumes non-overlapping, strictly-increasing + // ranges; without resolving those first, a nested + // cashu/lightning match renders as an extra spacer character + // injected into already-shown text instead of being skipped. + let resolvedMatches = MessageFormattingEngine.resolveOverlappingMatches(allMatches) { $0.range } var lastEnd = content.startIndex let myNickname = viewModel.nickname.normalizedNickname let isMentioned = message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false - for (range, type) in allMatches { + for (range, type) in resolvedMatches { guard let swiftRange = Range(range, in: content) else { continue } if lastEnd < swiftRange.lowerBound { diff --git a/bitchatTests/MessageFormattingEngineTests.swift b/bitchatTests/MessageFormattingEngineTests.swift index 5de2adea..f32cd69a 100644 --- a/bitchatTests/MessageFormattingEngineTests.swift +++ b/bitchatTests/MessageFormattingEngineTests.swift @@ -349,6 +349,69 @@ struct MessageFormattingEngineTests { #expect(String(formatted.characters) == "<@alice> \(longContent) [\(message.formattedTimestamp)]") } + + @MainActor + @Test func formatMessage_urlContainingCashuTokenIsNotDuplicated() { + // Bug: a URL match and a Cashu match are never cross-checked for + // overlap, only each independently against mentions. A URL whose + // path embeds a "cashuA..."-shaped segment (a perfectly ordinary + // mint redemption link) produces two overlapping matches; the + // single linear rendering pass then re-renders the nested cashu + // substring a second time and, because it walks `lastEnd` backwards, + // re-renders the URL's own trailing segment a third time. + let context = MockMessageFormattingContext(nickname: "carol") + let cashuToken = "cashuA" + String(repeating: "a", count: 44) + let url = "https://mint.example.com/redeem/\(cashuToken)/confirm" + let content = "redeem link \(url) now" + let message = BitchatMessage( + id: "url-cashu", + sender: "alice", + content: content, + timestamp: Date(timeIntervalSince1970: 1_700_001_111), + isRelay: false + ) + + let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light) + + #expect(String(formatted.characters) == "<@alice> \(content) [\(message.formattedTimestamp)]") + } + + // MARK: - resolveOverlappingMatches Tests + // + // Direct coverage for the shared helper `ChatMessageFormatter` also + // calls, since its own match-collection logic (a hand-copy of + // findAllMatches above) isn't independently unit-testable without a + // live ChatViewModel. + + @Test func resolveOverlappingMatches_dropsANestedOverlap() { + let outer = (range: NSRange(location: 0, length: 10), type: "outer") + let nested = (range: NSRange(location: 3, length: 2), type: "nested") + + let resolved = MessageFormattingEngine.resolveOverlappingMatches([outer, nested]) { $0.range } + + #expect(resolved.map(\.type) == ["outer"]) + } + + @Test func resolveOverlappingMatches_keepsNonOverlappingMatchesSortedByStart() { + let first = (range: NSRange(location: 0, length: 3), type: "first") + let second = (range: NSRange(location: 5, length: 3), type: "second") + + // Passed out of order; the helper must sort internally. + let resolved = MessageFormattingEngine.resolveOverlappingMatches([second, first]) { $0.range } + + #expect(resolved.map(\.type) == ["first", "second"]) + } + + @Test func resolveOverlappingMatches_adjacentNonOverlappingMatchesAreBothKept() { + // A match starting exactly where the previous one ends is not an + // overlap (NSRange upper bound is exclusive). + let first = (range: NSRange(location: 0, length: 5), type: "first") + let adjacent = (range: NSRange(location: 5, length: 5), type: "adjacent") + + let resolved = MessageFormattingEngine.resolveOverlappingMatches([first, adjacent]) { $0.range } + + #expect(resolved.map(\.type) == ["first", "adjacent"]) + } } @MainActor