fix(formatting): mirror the overlap resolution in ChatMessageFormatter

ChatMessageFormatter.formatMessageAsText -- the production chat-row
rendering path (TextMessageView -> ChatViewModel.formatMessageAsText) --
has its own hand-copied matcher with the same gap as
MessageFormattingEngine: url and cashu (and lightning/bolt11/lnurl) are
never cross-checked against each other, only against mentions.

Here the corruption shows differently: this formatter renders cashu and
lightning-family matches as a single spacer character rather than their
matched text, so a URL whose path embeds a "cashuA..."-shaped token gets
an extra, spurious space injected right after the fully-rendered URL
instead of duplicated text.

Same fix as MessageFormattingEngine: sort by start position, then greedily
keep only non-overlapping matches.

I could not add an equivalent regression test here: unlike
MessageFormattingEngine (formatted through a small, mockable
MessageFormattingContext protocol), ChatMessageFormatter has no existing
test target and is constructed against a live ChatViewModel with a large
dependency graph (mesh service, identity bridge, active channel, etc.). I
don't have a Swift toolchain available to build a new test harness for it
with confidence, so I've left that to whoever reviews this with the
ChatViewModel construction context in hand -- happy to add one if pointed
at an existing lightweight construction path I've missed.
This commit is contained in:
krishrathi1 2026-08-02 23:33:06 +05:30
parent 216ab73d20
commit 7ba358ce46

View File

@ -190,11 +190,29 @@ final class ChatMessageFormatter {
}
allMatches.sort { $0.range.location < $1.range.location }
// 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 (e.g. a URL
// whose path embeds a "cashuA..."-shaped token). The render
// loop below assumes non-overlapping, strictly-increasing
// ranges; without this pass a nested cashu/lightning match
// renders as an extra spacer character injected into
// already-shown text instead of being skipped.
var resolvedMatches: [(range: NSRange, type: String)] = []
var occupiedUntil = 0
for match in allMatches {
guard match.range.location >= occupiedUntil else { continue }
resolvedMatches.append(match)
occupiedUntil = match.range.location + match.range.length
}
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 {