mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
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:
parent
1f59e814f9
commit
216ab73d20
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user