mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-22 07:16:03 +00:00
Merge 1b9b31a245f9a545ec757f8d8e3c745b4d170a39 into 1f59e814f90c3f489f48d68262cb1bf640bf6181
This commit is contained in:
commit
94f5a268e1
@ -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 {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user