From 216ab73d20a7b508d7a229abd6a19176caa193da Mon Sep 17 00:00:00 2001 From: krishrathi1 Date: Sun, 2 Aug 2026 23:23:45 +0530 Subject: [PATCH] fix(formatting): resolve overlapping rich-text matches before rendering findAllMatches() only cross-checks each match type against mentions (and bolt11/lnurl against url+lightning); it never checks url against cashu, or any other combination the ad-hoc per-type guards didn't anticipate. An ordinary message containing a URL whose path embeds a "cashuA..."-shaped token (e.g. a mint redemption link) produces two overlapping matches for the same text -- the whole URL, and the nested cashu token inside it. formatContent's single linear rendering pass assumes matches are non-overlapping with strictly increasing ranges. Given the nested pair, it appends the URL's full text, then unconditionally re-appends the nested cashu substring a second time (styled differently), and walks its cursor backward to the cashu match's end -- which is before the URL's actual end. The trailing "remaining text" check then re-appends the tail of the URL a third time, since the cursor never advanced past it. Add a final pass that 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. This fixes the url/cashu case and generally guards any other pair of match types that isn't already handled by an explicit per-type check, without touching the existing exclusion logic. --- .../Services/MessageFormattingEngine.swift | 21 +++++++++++++-- .../MessageFormattingEngineTests.swift | 26 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) 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