From 41b7f5171e8936508f467153d0cc27796739f78a Mon Sep 17 00:00:00 2001 From: jack <212554440+jackjackbits@users.noreply.github.com> Date: Tue, 2 Jun 2026 22:19:09 +0200 Subject: [PATCH] Fix Cashu long-message guard bypass --- .../Services/MessageFormattingEngine.swift | 5 ++-- bitchat/ViewModels/ChatMessageFormatter.swift | 7 +---- .../Views/Components/TextMessageView.swift | 4 +-- bitchat/Views/MessageTextHelpers.swift | 13 ++++++++++ bitchatTests/ChatViewModelTests.swift | 19 ++++++++++++++ .../MessageFormattingEngineTests.swift | 26 +++++++++++++++++++ 6 files changed, 63 insertions(+), 11 deletions(-) diff --git a/bitchat/Services/MessageFormattingEngine.swift b/bitchat/Services/MessageFormattingEngine.swift index 71abcbe4..2ffc8dc4 100644 --- a/bitchat/Services/MessageFormattingEngine.swift +++ b/bitchat/Services/MessageFormattingEngine.swift @@ -255,9 +255,8 @@ final class MessageFormattingEngine { isSelf: Bool, isMentioned: Bool ) -> AttributedString { - // For very long content without special tokens, use plain formatting - let containsCashu = containsCashuToken(content) - if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashu { + // For very long content, use plain formatting to avoid expensive regex and detector work. + if content.isOversizedForRichFormatting() { return formatPlainContent(content, baseColor: baseColor, isSelf: isSelf) } diff --git a/bitchat/ViewModels/ChatMessageFormatter.swift b/bitchat/ViewModels/ChatMessageFormatter.swift index 21a63028..0b49a3bc 100644 --- a/bitchat/ViewModels/ChatMessageFormatter.swift +++ b/bitchat/ViewModels/ChatMessageFormatter.swift @@ -70,12 +70,7 @@ final class ChatMessageFormatter { let content = message.content let nsContent = content as NSString let nsLen = nsContent.length - let containsCashuEarly: Bool = { - let regex = Patterns.quickCashuPresence - return regex.numberOfMatches(in: content, options: [], range: NSRange(location: 0, length: nsLen)) > 0 - }() - - if (content.count > 4000 || content.hasVeryLongToken(threshold: 1024)) && !containsCashuEarly { + if content.isOversizedForRichFormatting() { var plainStyle = AttributeContainer() plainStyle.foregroundColor = baseColor plainStyle.font = isSelf diff --git a/bitchat/Views/Components/TextMessageView.swift b/bitchat/Views/Components/TextMessageView.swift index 6b1be23a..0b3ffa70 100644 --- a/bitchat/Views/Components/TextMessageView.swift +++ b/bitchat/Views/Components/TextMessageView.swift @@ -22,7 +22,7 @@ struct TextMessageView: View { let cashuLinks = message.content.extractCashuLinks() let lightningLinks = message.content.extractLightningLinks() HStack(alignment: .top, spacing: 0) { - let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty + let isLong = message.content.isLongForDisplay() let isExpanded = expandedMessageIDs.contains(message.id) Text(conversationUIModel.formatMessage(message, colorScheme: colorScheme)) .fixedSize(horizontal: false, vertical: true) @@ -38,7 +38,7 @@ struct TextMessageView: View { } // Expand/Collapse for very long messages - if (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuLinks.isEmpty { + if message.content.isLongForDisplay() { let isExpanded = expandedMessageIDs.contains(message.id) let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more") Button(labelKey) { diff --git a/bitchat/Views/MessageTextHelpers.swift b/bitchat/Views/MessageTextHelpers.swift index bd653ec9..94e0dfdf 100644 --- a/bitchat/Views/MessageTextHelpers.swift +++ b/bitchat/Views/MessageTextHelpers.swift @@ -21,6 +21,19 @@ extension String { return current >= threshold } + // Detect if message content should be collapsed to avoid expensive unbounded layout. + func isLongForDisplay( + lengthThreshold: Int = TransportConfig.uiLongMessageLengthThreshold, + tokenThreshold: Int = TransportConfig.uiVeryLongTokenThreshold + ) -> Bool { + count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold) + } + + // Detect if message content should use plain formatting to avoid expensive regex parsing. + func isOversizedForRichFormatting(lengthThreshold: Int = 4000, tokenThreshold: Int = 1024) -> Bool { + count > lengthThreshold || hasVeryLongToken(threshold: tokenThreshold) + } + // Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths. func extractCashuLinks(max: Int = 3) -> [String] { let regex = MessageFormattingEngine.Patterns.cashu diff --git a/bitchatTests/ChatViewModelTests.swift b/bitchatTests/ChatViewModelTests.swift index 304b96d5..3be0cda0 100644 --- a/bitchatTests/ChatViewModelTests.swift +++ b/bitchatTests/ChatViewModelTests.swift @@ -606,6 +606,25 @@ struct ChatViewModelFormattingTests { #expect(String(header.characters) == "<@Alice#a1b2> ") } + + @Test @MainActor + func formatMessageAsText_longCashuMessageUsesPlainFastPath() async { + let (viewModel, _) = makeTestableViewModel() + let cashu = "cashuA" + String(repeating: "a", count: 40) + let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100) + let message = BitchatMessage( + id: "fmt-long-cashu", + sender: "Alice#a1b2", + content: longContent, + timestamp: Date(timeIntervalSince1970: 1_700_010_123), + isRelay: false, + senderPeerID: PeerID(str: "00000000000000b3") + ) + + let formatted = viewModel.formatMessageAsText(message, colorScheme: .light) + + #expect(String(formatted.characters) == "<@Alice#a1b2> \(longContent) [\(message.formattedTimestamp)]") + } } // MARK: - Verification Tests diff --git a/bitchatTests/MessageFormattingEngineTests.swift b/bitchatTests/MessageFormattingEngineTests.swift index 602ad38d..5de2adea 100644 --- a/bitchatTests/MessageFormattingEngineTests.swift +++ b/bitchatTests/MessageFormattingEngineTests.swift @@ -323,6 +323,32 @@ struct MessageFormattingEngineTests { // Exactly at threshold DOES trigger (uses >= comparison) #expect(content.hasVeryLongToken(threshold: 50)) } + + @Test func isLongForDisplay_doesNotIgnoreCashuLinks() { + let cashu = "cashuA" + String(repeating: "a", count: 40) + let content = String(repeating: "a", count: TransportConfig.uiLongMessageLengthThreshold + 1) + " " + cashu + + #expect(content.extractCashuLinks().count == 1) + #expect(content.isLongForDisplay()) + } + + @MainActor + @Test func formatMessage_longCashuMessageFallsBackToPlainContentPath() { + let context = MockMessageFormattingContext(nickname: "carol") + let cashu = "cashuA" + String(repeating: "a", count: 40) + let longContent = "hi @bob " + cashu + " " + String(repeating: "x", count: 4_100) + let message = BitchatMessage( + id: "long-cashu", + sender: "alice", + content: longContent, + timestamp: Date(timeIntervalSince1970: 1_700_000_999), + isRelay: false + ) + + let formatted = MessageFormattingEngine.formatMessage(message, context: context, colorScheme: .light) + + #expect(String(formatted.characters) == "<@alice> \(longContent) [\(message.formattedTimestamp)]") + } } @MainActor