Merge 1b9b31a245f9a545ec757f8d8e3c745b4d170a39 into 1f59e814f90c3f489f48d68262cb1bf640bf6181

This commit is contained in:
krish rathi 2026-08-06 08:41:13 +00:00 committed by GitHub
commit 94f5a268e1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 113 additions and 4 deletions

View File

@ -368,8 +368,44 @@ 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 }
// 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). 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.
///
/// Ties break by start position only, not length: whichever match starts
/// first wins outright, so an outer match (e.g. a URL) always keeps
/// priority over a shorter match nested inside it (e.g. an embedded cashu
/// token) rather than the other way around. That's intentional for
/// rendering flip it to prefer the inner match only with a matching
/// change to how callers render the dropped outer span.
static func resolveOverlappingMatches<Match>(
_ 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 {
let matchRange = range(match)
guard matchRange.location >= occupiedUntil else { continue }
resolved.append(match)
occupiedUntil = matchRange.location + matchRange.length
}
return resolved
}
private static func formatPlainContent(_ content: String, baseColor: Color, isSelf: Bool) -> AttributedString {

View File

@ -188,13 +188,23 @@ 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 }
// 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 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
let isMentioned = message.mentions?.contains { $0.normalizedNickname == myNickname } ?? false
for (range, type) in allMatches {
for (range, type) in resolvedMatches {
guard let swiftRange = Range(range, in: content) else { continue }
if lastEnd < swiftRange.lowerBound {

View File

@ -349,6 +349,69 @@ 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)]")
}
// 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