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 {