From 7ba358ce468f3133ba50da6dc25ecf6ab8505aca Mon Sep 17 00:00:00 2001 From: krishrathi1 Date: Sun, 2 Aug 2026 23:33:06 +0530 Subject: [PATCH] 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. --- bitchat/ViewModels/ChatMessageFormatter.swift | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index 1a00315b..b08c8c7c 100644 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -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 {