diff --git a/bitchat/Services/MessageFormattingEngine.swift b/bitchat/Services/MessageFormattingEngine.swift index 024bff5d..1f5c656e 100644 --- a/bitchat/Services/MessageFormattingEngine.swift +++ b/bitchat/Services/MessageFormattingEngine.swift @@ -368,23 +368,35 @@ final class MessageFormattingEngine { allMatches.append(ContentMatch(range: match.range(at: 0), type: .lnurl)) } - // Sort by position, then drop any match that overlaps one already - // kept. The per-type checks above only guard specific known pairs - // (e.g. bolt11/lnurl against url/lightning) -- they don't cover every + // 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). formatContent's - // single linear pass assumes non-overlapping, strictly-increasing - // ranges; without this pass a nested match re-renders already-shown - // text and can walk `lastEnd` backwards, duplicating a trailing - // slice of content a second time. - let sorted = allMatches.sorted { $0.range.location < $1.range.location } - var resolved: [ContentMatch] = [] + // 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. + 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 { - guard match.range.location >= occupiedUntil else { continue } + let matchRange = range(match) + guard matchRange.location >= occupiedUntil else { continue } resolved.append(match) - occupiedUntil = match.range.location + match.range.length + occupiedUntil = matchRange.location + matchRange.length } return resolved } diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index b08c8c7c..b6653ab6 100644 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -188,25 +188,17 @@ 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 } - // Drop any match that overlaps one already kept. The - // per-type checks above only guard specific known pairs + // 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 this pass a nested cashu/lightning match - // renders as an extra spacer character injected into - // already-shown text instead of being skipped. - var resolvedMatches: [(range: NSRange, type: String)] = [] - var occupiedUntil = 0 - for match in allMatches { - guard match.range.location >= occupiedUntil else { continue } - resolvedMatches.append(match) - occupiedUntil = match.range.location + match.range.length - } + // 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 diff --git a/bitchatTests/MessageFormattingEngineTests.swift b/bitchatTests/MessageFormattingEngineTests.swift index 0484c5e0..f32cd69a 100644 --- a/bitchatTests/MessageFormattingEngineTests.swift +++ b/bitchatTests/MessageFormattingEngineTests.swift @@ -375,6 +375,43 @@ struct MessageFormattingEngineTests { #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