diff --git a/bitchat/Services/MessageFormattingEngine.swift b/bitchat/Services/MessageFormattingEngine.swift index 88a32e3c..024bff5d 100644 --- a/bitchat/Services/MessageFormattingEngine.swift +++ b/bitchat/Services/MessageFormattingEngine.swift @@ -368,8 +368,25 @@ 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 } + // 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 + // 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] = [] + var occupiedUntil = 0 + for match in sorted { + guard match.range.location >= occupiedUntil else { continue } + resolved.append(match) + occupiedUntil = match.range.location + match.range.length + } + return resolved } private static func formatPlainContent(_ content: String, baseColor: Color, isSelf: Bool) -> AttributedString { diff --git a/bitchatTests/MessageFormattingEngineTests.swift b/bitchatTests/MessageFormattingEngineTests.swift index 5de2adea..0484c5e0 100644 --- a/bitchatTests/MessageFormattingEngineTests.swift +++ b/bitchatTests/MessageFormattingEngineTests.swift @@ -349,6 +349,32 @@ 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)]") + } } @MainActor