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.
This commit is contained in:
krishrathi1 2026-08-02 23:23:45 +05:30
parent 1f59e814f9
commit 216ab73d20
2 changed files with 45 additions and 2 deletions

View File

@ -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 {

View File

@ -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