From 590a5e49cc27886c3d9719e2803f4d01119b3b91 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 9 Oct 2026 22:59:57 -0400 Subject: [PATCH] feat(chat): render formatted message text Parse each text message with the shared TextFormat parser and draw the display text with its spans: bold, italic, strike, code, quote bars and list indents. Masked links and mentions are remapped to display offsets; quote and list messages go to the text view path. Reply quotes, chat list previews, push bodies and Spotlight use the display text; Copy keeps the raw text. A masked link's target never becomes a link card. Mention detection allows one '_' before the '@' and trims a closing '_' that it opened, so _@jeff_ is an italic mention (spec rule 6). --- .../Controllers/ConversationController.swift | 4 +- .../Conversation/ChatItem+Conversation.swift | 42 ++++- .../Conversation/ConversationScreen.swift | 2 +- .../Core/Spotlight/ChatSpotlightItem.swift | 2 +- .../Models/Chat/ChatMessage.swift | 8 + .../Models/Chat/ChatPreviewMapping.swift | 8 +- .../Models/Chat/ChatTextFormat.swift | 172 ++++++++++++++++++ .../Models/Chat/LinkDetector.swift | 6 +- .../Models/Chat/MentionDetector.swift | 20 +- .../Push/NotificationPayload.swift | 2 +- .../ChatTextFormatterTests.swift | 64 +++++++ .../MentionDetectorTests.swift | 21 +++ .../Chat/ChatTextFormatRenderingTests.swift | 80 ++++++++ .../FlipcashUI/Chat/ChatBubbleView.swift | 3 + .../Chat/ChatItem+Differentiable.swift | 2 +- .../FlipcashUI/Chat/ChatTextStyling.swift | 112 ++++++++++++ .../FlipcashUI/Chat/ChatViewController.swift | 2 +- .../FlipcashUI/Chat/LinkableBubbleView.swift | 35 ++++ 18 files changed, 567 insertions(+), 18 deletions(-) create mode 100644 FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatTextFormat.swift create mode 100644 FlipcashCore/Tests/FlipcashCoreTests/ChatTextFormatterTests.swift create mode 100644 FlipcashTests/Chat/ChatTextFormatRenderingTests.swift create mode 100644 FlipcashUI/Sources/FlipcashUI/Chat/ChatTextStyling.swift diff --git a/Flipcash/Core/Controllers/ConversationController.swift b/Flipcash/Core/Controllers/ConversationController.swift index 3b1d20eb1..efd457cab 100644 --- a/Flipcash/Core/Controllers/ConversationController.swift +++ b/Flipcash/Core/Controllers/ConversationController.swift @@ -1613,7 +1613,9 @@ final class ConversationController { let senderName = groupSenderName(for: message, in: conversation, isFromSelf: isFromSelf, knownAuthors: knownAuthors) switch message.content { - case .text(let text): + case .text(let raw): + // The words without their markers: a preview line draws no styles. + let text = ChatTextFormatter.displayText(of: raw) // A bare prefix says less than no line at all, so an empty body previews as nothing. guard !text.isEmpty else { return nil } if isFromSelf { return "You: \(text)" } diff --git a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift index a79b0af88..8c1e2b6ec 100644 --- a/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift +++ b/Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift @@ -52,7 +52,34 @@ nonisolated private func detectedLink(in text: String, card: ([DetectedLink]) -> linkPreviewCache.setObject(DetectedLinkBox(links, mentions), forKey: key) } guard !links.isEmpty || !mentions.isEmpty else { return nil } - return LinkPreview(links: links, card: card(links), mentions: mentions) + // The address inside `[text](address)` is a masked link's target, never a card: the words + // around it are the sender's label, and the card cut would split the markup. + let body = text as NSString + let cardable = links.filter { !ChatTextFormatter.isMaskTarget($0.range, in: body) } + return LinkPreview(links: links, card: card(cardable), mentions: mentions) +} + +nonisolated(unsafe) private let formattedTextCache: NSCache = { + let cache = NSCache() + cache.countLimit = 512 + return cache +}() + +private final class FormattedTextBox { + nonisolated let value: FormattedChatText + nonisolated init(_ value: FormattedChatText) { self.value = value } +} + +/// The parser's result for a whole message, memoized by text beside the link cache: a typing tick +/// remaps the transcript, and each message should cross the bridge once. A row cut out of a split +/// message is not cached, since its preview is a slice of its message's. +nonisolated private func formattedText(_ raw: String, preview: LinkPreview?, cacheable: Bool) -> FormattedChatText { + guard cacheable else { return ChatTextFormatter.format(raw, preview: preview) } + let key = raw as NSString + if let cached = formattedTextCache.object(forKey: key) { return cached.value } + let result = ChatTextFormatter.format(raw, preview: preview) + formattedTextCache.setObject(FormattedTextBox(result), forKey: key) + return result } extension ChatItem { @@ -347,10 +374,16 @@ extension ChatItem { let part = row.part.map { ChatMessagePart(messageID: message.stableID, kind: $0, messageText: row.messageText ?? "") } + // The words go through the shared parser: the row draws the display text and its + // styles, and Copy keeps the raw text. A card row's text is its link, not markup. + let formatted: FormattedChatText? = { + guard row.part != .card, case .text(let whole) = message.content else { return nil } + return formattedText(row.text ?? whole, preview: row.preview, cacheable: row.text == nil) + }() items.append(.message(ChatMessage( id: part?.rowID ?? message.stableID, serverID: serverID, - content: row.text.map(ChatMessage.Content.text) ?? content, + content: formatted.map { .text($0.display) } ?? row.text.map(ChatMessage.Content.text) ?? content, sender: isFromSelf ? .me : .other, isContinuationFromPrevious: isFirst ? groupedAbove : true, isContinuedByNext: isLast ? groupedBelow : true, @@ -359,7 +392,8 @@ extension ChatItem { joinsBubbleBelow: isLast ? joinsBubbleBelow : true, isEmojiOnly: part == nil && isEmojiOnlyBody(message), receipt: isLast ? receipt : nil, - linkPreview: row.preview, + linkPreview: formatted?.preview ?? row.preview, + format: formatted?.format, isEdited: isLast && message.lastEditedTs != nil && !message.isDeleted, actions: orderedActions(capabilities(message)), quote: isFirst ? quote : nil, @@ -415,7 +449,7 @@ extension ChatItem { return ChatQuote( stableID: original.stableID, authorName: authorName, - snippet: ChatQuote.snippet(forText: text), + snippet: ChatQuote.snippet(forText: ChatTextFormatter.displayText(of: text)), kind: .text, authorID: original.senderID ) diff --git a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift index 71f34203c..019b2f2be 100644 --- a/Flipcash/Core/Screens/Conversation/ConversationScreen.swift +++ b/Flipcash/Core/Screens/Conversation/ConversationScreen.swift @@ -1036,7 +1036,7 @@ struct ConversationScreen: View { ) -> (snippet: String, kind: ChatQuote.Kind) { switch message.content { case .text(let text): - (ChatQuote.snippet(forText: text), .text) + (ChatQuote.snippet(forText: ChatTextFormatter.displayText(of: text)), .text) case .cash(let fiat): ( fiat.nativeAmount.formatted(), diff --git a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift index 1167e363e..13bfe18d8 100644 --- a/Flipcash/Core/Spotlight/ChatSpotlightItem.swift +++ b/Flipcash/Core/Spotlight/ChatSpotlightItem.swift @@ -57,7 +57,7 @@ nonisolated struct ChatSpotlightItem { /// Mirrors the recipient picker's last-message subtitle. private static func preview(of message: ConversationMessage?) -> String? { switch message?.content { - case .text(let text): text + case .text(let text): ChatTextFormatter.displayText(of: text) case .cash(let amount): "Cash · \(amount.nativeAmount.formatted())" case .widget(.shareProfile): "Shared a profile" case .media(_, let caption): ChatMediaStrings.listPreview(caption: caption) diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift index 101c28c22..d87899258 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatMessage.swift @@ -107,6 +107,10 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { /// The web link this text row contains, or nil when it carries none — marks the row to render as /// tappable text. Derived from the text at map time (not stored/sent) — cash rows never carry one. public let linkPreview: LinkPreview? + /// The styles over a text row, and the markup as typed, when the text held any. `content` and + /// `linkPreview` are then in terms of the display text, with the markers taken out; Copy and + /// Edit read the raw text from here. Nil for a plain message. + public let format: ChatTextFormat? /// Whether to draw the muted "Edited" marker after the body. public let isEdited: Bool /// What the context menu offers for this row, already ordered. Empty means no menu. @@ -219,6 +223,7 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { isEmojiOnly: Bool = false, receipt: ChatReceipt? = nil, linkPreview: LinkPreview? = nil, + format: ChatTextFormat? = nil, isEdited: Bool = false, actions: [MessageCapability] = [], quote: ChatQuote? = nil, @@ -241,6 +246,7 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { self.isEmojiOnly = isEmojiOnly self.receipt = receipt self.linkPreview = linkPreview + self.format = format self.isEdited = isEdited self.actions = actions self.quote = quote @@ -266,6 +272,7 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { isEmojiOnly: Bool = false, receipt: ChatReceipt? = nil, linkPreview: LinkPreview? = nil, + format: ChatTextFormat? = nil, isEdited: Bool = false, actions: [MessageCapability] = [], quote: ChatQuote? = nil, @@ -289,6 +296,7 @@ public struct ChatMessage: Hashable, Sendable, Codable, Identifiable { isEmojiOnly: isEmojiOnly, receipt: receipt, linkPreview: linkPreview, + format: format, isEdited: isEdited, actions: actions, quote: quote, diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift index c8180173d..7ae0c5280 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatPreviewMapping.swift @@ -64,9 +64,12 @@ extension ChatItem { let sender: ChatMessage.Sender = message.senderID == selfUserID ? .me : .other let content: ChatMessage.Content + var textFormat: ChatTextFormat? switch message.content { case .text(let text): - content = .text(text) + let formatted = ChatTextFormatter.format(detecting: text) + content = .text(formatted.display) + textFormat = formatted.format case .cash(let fiat): // The flag loads from the FlipcashUI bundle, so it resolves inside an extension. The // token name + coin icon come from `mintBranding`; an unresolved mint shows no token @@ -122,7 +125,8 @@ extension ChatItem { sender: sender, isContinuationFromPrevious: false, isContinuedByNext: false, - isEmojiOnly: isEmojiOnly + isEmojiOnly: isEmojiOnly, + format: textFormat ))) previous = message } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatTextFormat.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatTextFormat.swift new file mode 100644 index 000000000..2445e1878 --- /dev/null +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatTextFormat.swift @@ -0,0 +1,172 @@ +// +// ChatTextFormat.swift +// FlipcashCore +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Foundation + +/// One style over a row's display text. Offsets are UTF-16, as `NSRange`. +public struct ChatTextSpan: Hashable, Sendable, Codable { + + public enum Style: String, Hashable, Sendable, Codable { + case bold, italic, strike, code, codeBlock, quote, bullet, numbered + } + + public let style: Style + public let location: Int + public let length: Int + + public var range: NSRange { NSRange(location: location, length: length) } + + public init(style: Style, range: NSRange) { + self.style = style + self.location = range.location + self.length = range.length + } + + init(_ span: TextFormat.Span) { + let style: Style = switch span { + case .bold: .bold + case .italic: .italic + case .strike: .strike + case .code: .code + case .codeBlock: .codeBlock + case .quote: .quote + case .bullet: .bullet + case .numbered: .numbered + } + self.init(style: style, range: span.range) + } +} + +/// What the parser made of a text row, kept beside the row so a copy can still hand back the raw +/// markup the sender typed. +public struct ChatTextFormat: Hashable, Sendable, Codable { + + /// The text as sent. `.text` on the row holds the display text instead. + public let raw: String + public let spans: [ChatTextSpan] + + public init(raw: String, spans: [ChatTextSpan]) { + self.raw = raw + self.spans = spans + } + + /// Whether anything needs the text view: a block style draws a bar or an indent, which the + /// plain label cannot. + public var needsTextView: Bool { + spans.contains { span in + switch span.style { + case .quote, .bullet, .numbered: true + case .bold, .italic, .strike, .code, .codeBlock: false + } + } + } +} + +/// A text row after parsing: the words to draw, their styles, and the link preview moved onto them. +public struct FormattedChatText: Hashable, Sendable { + public let display: String + public let format: ChatTextFormat? + public let preview: LinkPreview? +} + +/// Runs the shared parser over a text row and moves the row's link preview onto the display text. +public enum ChatTextFormatter { + + /// `preview` is what detection found in `raw`. Its links and mentions go to the parser as + /// protected ranges, and come back at their display offsets. A masked link comes back as a link + /// over its text, pointing at its target. + /// + /// If the parser's ranges do not line up with the preview's, which means the two disagree about + /// what a masked link is, the row is left as typed rather than guessed at. + public static func format(_ raw: String, preview: LinkPreview?) -> FormattedChatText { + let links = preview?.links ?? [] + let mentions = preview?.mentions ?? [] + let parsed = TextFormat.parse( + raw, + ranges: links.map { TextFormat.ProtectedRange(range: $0.range, kind: .link) } + + mentions.map { TextFormat.ProtectedRange(range: $0.range, kind: .mention) } + ) + guard parsed.display != raw || !parsed.spans.isEmpty else { + return FormattedChatText(display: raw, format: nil, preview: preview) + } + + // The links the parser kept as themselves: every detected link except the target of a + // masked link, which it consumed. They keep their order, so they pair off one to one. + let rawText = raw as NSString + let bareLinks = links.filter { !isMaskTarget($0.range, in: rawText) } + let bareRanges = parsed.displayRanges.filter { $0.kind == .link && $0.target == nil } + let mentionRanges = parsed.displayRanges.filter { $0.kind == .mention } + let maskedRanges = parsed.displayRanges.filter { $0.kind == .link && $0.target != nil } + guard bareLinks.count == bareRanges.count, mentions.count == mentionRanges.count else { + return FormattedChatText(display: raw, format: nil, preview: preview) + } + + let display = parsed.display as NSString + var movedLinks: [DetectedLink] = zip(bareLinks, bareRanges).map { detected, shown in + DetectedLink(range: shown.range, url: detected.url) + } + // A masked link whose address would not parse has no target: its text must not be opened + // as an address, so it is drawn as plain text. + movedLinks += maskedRanges.compactMap { shown in + shown.target.map { + DetectedLink(range: shown.range, url: $0, maskedLabel: display.substring(with: shown.range)) + } + } + movedLinks.sort { $0.location < $1.location } + let movedMentions = zip(mentions, mentionRanges).map { detected, shown in + DetectedMention(range: shown.range, username: detected.username) + } + + var card = preview?.card + if let current = card, let index = bareLinks.firstIndex(where: { $0.range == current.range }) { + card = current.relocated(to: bareRanges[index].range) + } + + let moved: LinkPreview? = movedLinks.isEmpty && movedMentions.isEmpty && card == nil + ? nil + : LinkPreview(links: movedLinks, card: card, mentions: movedMentions) + let spans = parsed.spans.map(ChatTextSpan.init) + return FormattedChatText( + display: parsed.display, + format: ChatTextFormat(raw: raw, spans: spans), + preview: moved + ) + } + + /// `raw` with its markers taken out and nothing else: the words for a reply quote, a chat list + /// preview, a notification or a Spotlight entry, none of which draw styles. Detects links and + /// mentions itself, since a marker inside one is not a marker. + public static func displayText(of raw: String) -> String { + format(detecting: raw).display + } + + /// ``format(_:preview:)`` for a caller that has not run detection: finds the links and mentions + /// in `raw` first. + public static func format(detecting raw: String) -> FormattedChatText { + guard raw.contains(where: markerCharacters.contains) else { + return FormattedChatText(display: raw, format: nil, preview: nil) + } + let links = detector.webLinks(in: raw) + let mentions = MentionDetector.mentions(in: raw, excluding: links) + let preview = links.isEmpty && mentions.isEmpty ? nil : LinkPreview(links: links, mentions: mentions) + return format(raw, preview: preview) + } + + /// Every character the parser reads as markup, `.` for `1. `. A text with none is its own display. + private static let markerCharacters: Set = ["*", "_", "~", "`", ">", "-", "[", "\\", "."] + + private nonisolated(unsafe) static let detector = LinkDetector() + + /// Whether the link at `range` is the address inside `[text](address)`: the construct the + /// parser consumes. Such a link is never a card, since the words around it are the sender's + /// label and not a message about the link. + public static func isMaskTarget(_ range: NSRange, in text: NSString) -> Bool { + guard range.location >= 2, NSMaxRange(range) < text.length else { return false } + return text.substring(with: NSRange(location: range.location - 2, length: 2)) == "](" + && text.character(at: NSMaxRange(range)) == 0x29 // ")" + } +} diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkDetector.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkDetector.swift index 729e19032..2183b5822 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkDetector.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/LinkDetector.swift @@ -15,13 +15,17 @@ public struct DetectedLink: Hashable, Sendable, Codable { public let location: Int public let length: Int public let url: URL + /// The text a masked link `[text](url)` shows in place of its address, or nil when the link's + /// text is its address. The leaving-Flipcash warning quotes it beside the host. + public let maskedLabel: String? public var range: NSRange { NSRange(location: location, length: length) } - public init(range: NSRange, url: URL) { + public init(range: NSRange, url: URL, maskedLabel: String? = nil) { self.location = range.location self.length = range.length self.url = url + self.maskedLabel = maskedLabel } } diff --git a/FlipcashCore/Sources/FlipcashCore/Models/Chat/MentionDetector.swift b/FlipcashCore/Sources/FlipcashCore/Models/Chat/MentionDetector.swift index 50d073030..fad2714d4 100644 --- a/FlipcashCore/Sources/FlipcashCore/Models/Chat/MentionDetector.swift +++ b/FlipcashCore/Sources/FlipcashCore/Models/Chat/MentionDetector.swift @@ -31,17 +31,27 @@ public enum MentionDetector { /// Every `@handle` in `text` that does not overlap one of `links`, in the order they appear. /// /// The character before the `@` must not be a handle character, `.` or `@`, so an email - /// address or `@@name` is not a mention. The handle ends at the first character a handle + /// address or `@@name` is not a mention. One `_` may sit between that character and the `@`, so + /// `_@jeff_` is an italic mention; see the trim below. The handle ends at the first character a handle /// cannot hold, and a handle running straight into more handle characters or another `@` /// is dropped rather than cut short. public static func mentions(in text: String, excluding links: [DetectedLink]) -> [DetectedMention] { guard text.contains("@") else { return [] } return text.matches(of: pattern).compactMap { match -> DetectedMention? in - let (_, mention, handle) = match.output - guard let username = Username(String(handle).lowercased()) else { return nil } + let (_, opener, mention, handle) = match.output + var name = String(handle) + var end = mention.endIndex + // `_@jeff_`: the `_` before the `@` opened an italic, so the handle's last `_` closes it + // and is not the handle's. Only while two handle characters remain, and only when an + // opening `_` was there: `_hey @jeff_` keeps the handle it wrote. + if !opener.isEmpty, name.hasSuffix("_"), name.count > 2 { + name.removeLast() + end = text.index(before: end) + } + guard let username = Username(name.lowercased()) else { return nil } - let range = NSRange(mention.startIndex.. 0 }) else { return nil } @@ -51,5 +61,5 @@ public enum MentionDetector { // Swift Regex has no lookbehind, so the preceding character is matched (and excluded from // the mention's range) instead. Case folds after the match; `Username` stores lowercase. - private nonisolated(unsafe) static let pattern = /(?:^|[^A-Za-z0-9_.@])(@([A-Za-z0-9_]{2,15}))(?![A-Za-z0-9_@])/ + private nonisolated(unsafe) static let pattern = /(?:^|[^A-Za-z0-9_.@])(_?)(@([A-Za-z0-9_]{2,15}))(?![A-Za-z0-9_@])/ } diff --git a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift index 17d2ae10a..25023b02d 100644 --- a/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift +++ b/FlipcashCore/Sources/FlipcashCore/Push/NotificationPayload.swift @@ -93,7 +93,7 @@ public enum NotificationPayload { } switch message.content { case .text(let text): - return text + return ChatTextFormatter.displayText(of: text) case .cash, .deleted, .encrypted, .widget, .media: return nil } diff --git a/FlipcashCore/Tests/FlipcashCoreTests/ChatTextFormatterTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/ChatTextFormatterTests.swift new file mode 100644 index 000000000..1e83a1739 --- /dev/null +++ b/FlipcashCore/Tests/FlipcashCoreTests/ChatTextFormatterTests.swift @@ -0,0 +1,64 @@ +import Testing +import Foundation +@testable import FlipcashCore + +@Suite("ChatTextFormatter display text and link remapping") +struct ChatTextFormatterTests { + + @Test("Plain text is its own display and carries no format") + func plainText() { + let result = ChatTextFormatter.format(detecting: "hello there") + #expect(result.display == "hello there") + #expect(result.format == nil) + } + + @Test("Bold markers come out of the display and become a span") + func bold() { + let result = ChatTextFormatter.format(detecting: "a *b* c") + #expect(result.display == "a b c") + #expect(result.format?.raw == "a *b* c") + #expect(result.format?.spans == [ChatTextSpan(style: .bold, range: NSRange(location: 2, length: 1))]) + } + + @Test("displayText drops markers for one-line surfaces") + func displayText() { + #expect(ChatTextFormatter.displayText(of: "_hi_ ~there~") == "hi there") + } + + @Test("A bare link keeps its URL and moves to its display offsets") + func bareLinkMoves() throws { + let result = ChatTextFormatter.format(detecting: "*hey* https://example.com") + let link = try #require(result.preview?.links.first) + #expect(result.display == "hey https://example.com") + #expect(link.range == NSRange(location: 4, length: 19)) + #expect(link.url.absoluteString == "https://example.com") + #expect(link.maskedLabel == nil) + } + + @Test("A masked link becomes a link over its text, pointing at its target") + func maskedLink() throws { + let result = ChatTextFormatter.format(detecting: "[my site](https://example.com)") + let link = try #require(result.preview?.links.first) + #expect(result.display == "my site") + #expect(link.range == NSRange(location: 0, length: 7)) + #expect(link.url.absoluteString == "https://example.com") + #expect(link.maskedLabel == "my site") + #expect(result.preview?.links.count == 1) + } + + @Test("A mention moves to its display offsets") + func mentionMoves() throws { + let result = ChatTextFormatter.format(detecting: "*hi* @jeff") + let mention = try #require(result.preview?.mentions.first) + #expect(result.display == "hi @jeff") + #expect(mention.range == NSRange(location: 3, length: 5)) + #expect(mention.username.value == "jeff") + } + + @Test("The address inside a masked link is recognised as a mask target") + func maskTarget() { + let text = "[a](https://example.com)" as NSString + #expect(ChatTextFormatter.isMaskTarget(NSRange(location: 4, length: 19), in: text)) + #expect(!ChatTextFormatter.isMaskTarget(NSRange(location: 0, length: 3), in: text)) + } +} diff --git a/FlipcashCore/Tests/FlipcashCoreTests/MentionDetectorTests.swift b/FlipcashCore/Tests/FlipcashCoreTests/MentionDetectorTests.swift index 30e5849e0..0b8c1357d 100644 --- a/FlipcashCore/Tests/FlipcashCoreTests/MentionDetectorTests.swift +++ b/FlipcashCore/Tests/FlipcashCoreTests/MentionDetectorTests.swift @@ -88,4 +88,25 @@ struct MentionDetectorTests { #expect(handles("no handles here") == []) #expect(handles("just an @ sign") == []) } + + // MARK: - Underscore before the @ (spec rule 6) + + @Test("An underscore before the @ is allowed, and a trailing one it opened is trimmed", + arguments: [ + ("_@jeff_", ["jeff"]), + ("_@jeff__", ["jeff_"]), + ("_@jeff_x", ["jeff_x"]), + ("jeff_@gmail.com", []), + ("__@jeff__", []), + ("_hey @jeff_", ["jeff_"]), + ]) + func underscoreBeforeAt(text: String, expected: [String]) { + #expect(handles(text) == expected) + } + + @Test("The trimmed mention's range stops before the closing underscore") + func trimmedRange() { + let mentions = MentionDetector.mentions(in: "_@jeff_", excluding: []) + #expect(mentions.first?.range == NSRange(location: 1, length: 5)) + } } diff --git a/FlipcashTests/Chat/ChatTextFormatRenderingTests.swift b/FlipcashTests/Chat/ChatTextFormatRenderingTests.swift new file mode 100644 index 000000000..00880530c --- /dev/null +++ b/FlipcashTests/Chat/ChatTextFormatRenderingTests.swift @@ -0,0 +1,80 @@ +// +// ChatTextFormatRenderingTests.swift +// FlipcashTests +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +import Testing +import UIKit +import FlipcashCore +@testable import FlipcashUI + +@MainActor +@Suite("Formatted text rendering") +struct ChatTextFormatRenderingTests { + + private func message(_ raw: String) -> ChatMessage { + let formatted = ChatTextFormatter.format(detecting: raw) + return ChatMessage( + id: "1", + text: formatted.display, + sender: .me, + linkPreview: formatted.preview, + format: formatted.format + ) + } + + private func font(_ text: NSAttributedString, at index: Int) -> UIFont? { + text.attribute(.font, at: index, effectiveRange: nil) as? UIFont + } + + @Test("The display text drops the markers and bold changes only the marked run") + func boldRun() throws { + let text = try #require(ChatBubbleView.displayText(for: message("a *b* c"))) + #expect(text.string == "a b c") + #expect(font(text, at: 0) != font(text, at: 2)) + #expect(font(text, at: 0) == font(text, at: 4)) + } + + @Test("Strike and code draw their attributes over the span") + func strikeAndCode() throws { + let strike = try #require(ChatBubbleView.displayText(for: message("~gone~"))) + #expect(strike.attribute(.strikethroughStyle, at: 0, effectiveRange: nil) != nil) + + let code = try #require(ChatBubbleView.displayText(for: message("`x`"))) + #expect(code.attribute(.backgroundColor, at: 0, effectiveRange: nil) != nil) + } + + @Test("A quote marks its line for the bar and indents it") + func quote() throws { + let text = try #require(ChatBubbleView.displayText(for: message("> hello"))) + #expect(text.string == "hello") + #expect(text.attribute(ChatTextStyling.quoteBar, at: 0, effectiveRange: nil) != nil) + let style = text.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle + #expect(style?.firstLineHeadIndent == ChatTextStyling.quoteIndent) + } + + @Test("A list item hangs its wrapped lines past the marker") + func listHang() throws { + let text = try #require(ChatBubbleView.displayText(for: message("- milk"))) + let style = text.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle + #expect(text.string == "- milk") + #expect(style?.headIndent == ChatTextStyling.bulletIndent) + #expect(style?.firstLineHeadIndent == 0) + } + + @Test("A message with a quote or list goes to the text view, plain styles stay on the label") + func routing() { + #expect(message("> hi").format?.needsTextView == true) + #expect(message("- hi").format?.needsTextView == true) + #expect(message("*hi*").format?.needsTextView == false) + } + + @Test("A masked link is linked over its text in the text view's string") + func maskedLinkAttribute() throws { + let text = try #require(LinkableBubbleView.linkedText(for: message("[docs](https://example.com)"))) + #expect(text.string == "docs") + #expect(text.attribute(.link, at: 0, effectiveRange: nil) as? URL == URL(string: "https://example.com")) + } +} diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift index 265dfc244..2c067efee 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatBubbleView.swift @@ -283,6 +283,9 @@ public final class ChatBubbleView: UIView { string: body, attributes: [.font: bodyFont, .foregroundColor: bodyColor] ) + if let spans = message.format?.spans, !isPlaceholder, !message.rendersAsLargeEmoji { + ChatTextStyling.apply(spans, to: result, size: 16, weight: .medium) + } if Self.showsEditedMarker(for: message), !message.rendersAsLargeEmoji { result.append(EditedMarker.reservation) diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift index 944f0ca4f..a0d386a9d 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatItem+Differentiable.swift @@ -36,7 +36,7 @@ extension ChatItem { case .message(let message): switch message.content { case .text: - message.linkPreview != nil ? ChatLinkMessageCell.reuseIdentifier : ChatMessageCell.reuseIdentifier + message.linkPreview != nil || message.format?.needsTextView == true ? ChatLinkMessageCell.reuseIdentifier : ChatMessageCell.reuseIdentifier case .deleted, .unavailable: // Deliberately the same cell class as plain text: a message becoming a tombstone // then diffs as an in-place reconfigure rather than a delete-and-insert. diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatTextStyling.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatTextStyling.swift new file mode 100644 index 000000000..906faeb7f --- /dev/null +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatTextStyling.swift @@ -0,0 +1,112 @@ +// +// ChatTextStyling.swift +// FlipcashUI +// +// Copyright © 2026 Code Inc. All rights reserved. +// + +#if canImport(UIKit) +import UIKit +import FlipcashCore + +/// Draws the parser's spans onto a bubble's text. The text already has the bubble's base font and +/// colour; this adds weight, slant, strike, monospace, and the indents that block styles take. +enum ChatTextStyling { + + /// Set over each quoted line; the text view draws one bar down each run of them. + static let quoteBar = NSAttributedString.Key("flipcash.quoteBar") + + static let codeBackground = UIColor.white.withAlphaComponent(0.12) + static let quoteBarWidth: CGFloat = 3 + static let quoteIndent: CGFloat = 12 + static let bulletIndent: CGFloat = 12 + static let numberedIndent: CGFloat = 20 + + static func apply(_ spans: [ChatTextSpan], to text: NSMutableAttributedString, size: CGFloat, weight: UIFont.Weight) { + let length = text.length + guard length > 0, !spans.isEmpty else { return } + let bounds = NSRange(location: 0, length: length) + func clamped(_ span: ChatTextSpan) -> NSRange? { + let range = NSIntersectionRange(span.range, bounds) + return range.length > 0 ? range : nil + } + + // Weight and slant combine, so they are worked out per character and applied as runs. + var bold = [Bool](repeating: false, count: length) + var italic = bold + for span in spans { + guard let range = clamped(span) else { continue } + switch span.style { + case .bold: for index in range.location.. 0 else { continue } + var location = span.location + while location < NSMaxRange(span.range) { + let paragraph = string.paragraphRange(for: NSRange(location: location, length: 0)) + var entry = lines[paragraph.location] ?? (paragraph, Line()) + switch span.style { + case .quote: entry.line.quoted = true + case .bullet: entry.line.hang = bulletIndent + case .numbered: entry.line.hang = numberedIndent + case .bold, .italic, .strike, .code, .codeBlock: break + } + lines[paragraph.location] = entry + location = NSMaxRange(paragraph) + } + } + for (range, line) in lines.values { + let style = NSMutableParagraphStyle() + let lead = line.quoted ? quoteIndent : 0 + style.firstLineHeadIndent = lead + style.headIndent = lead + line.hang + text.addAttribute(.paragraphStyle, value: style, range: range) + } + } +} +#endif diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift index 1aa6523e7..a7da34f9a 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/ChatViewController.swift @@ -1389,7 +1389,7 @@ extension ChatViewController { guard !message.actions.isEmpty else { return nil } // A split row copies the whole message, not the piece of it the row happens to draw. - let body: String? = if case .text(let text) = message.content { message.part?.messageText ?? text } else { nil } + let body: String? = if case .text(let text) = message.content { message.part?.messageText ?? message.format?.raw ?? text } else { nil } let rowID = message.messageID let handler = onMessageAction diff --git a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift index c0960c196..549916af9 100644 --- a/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift +++ b/FlipcashUI/Sources/FlipcashUI/Chat/LinkableBubbleView.swift @@ -581,11 +581,14 @@ enum MentionPill { /// ChatLayout's own `MessageTextView` recipe. final class LinkTextView: UITextView { private let pills = CAShapeLayer() + private let quoteBars = CAShapeLayer() override init(frame: CGRect, textContainer: NSTextContainer?) { super.init(frame: frame, textContainer: textContainer) pills.fillColor = MentionPill.fill.cgColor layer.insertSublayer(pills, at: 0) + quoteBars.fillColor = UIColor.white.withAlphaComponent(0.5).cgColor + layer.insertSublayer(quoteBars, at: 0) } @available(*, unavailable) @@ -598,6 +601,38 @@ final class LinkTextView: UITextView { override func layoutSubviews() { super.layoutSubviews() drawPills() + drawQuoteBars() + } + + /// One bar down the left of each run of quoted lines, in the gutter the paragraph style leaves. + private func drawQuoteBars() { + let path = UIBezierPath() + for rect in quoteBarRects { + path.append(UIBezierPath(roundedRect: rect, cornerRadius: ChatTextStyling.quoteBarWidth / 2)) + } + quoteBars.path = path.cgPath + quoteBars.frame = bounds + } + + /// The bar for each run of quoted lines, in the text view's coordinates. Lines of one quote + /// are separate spans with their line breaks between them, so a line that starts where the last + /// one ended continues the bar. + var quoteBarRects: [CGRect] { + guard let text = attributedText, text.length > 0 else { return [] } + var lines: [CGRect] = [] + text.enumerateAttribute(ChatTextStyling.quoteBar, in: NSRange(location: 0, length: text.length)) { value, range, _ in + guard value != nil, let rects = lineRects(for: range), !rects.isEmpty else { return } + lines.append(rects.reduce(CGRect.null) { $0.union($1) }) + } + var bars: [CGRect] = [] + for line in lines { + if let last = bars.last, line.minY - last.maxY < 4 { + bars[bars.count - 1] = CGRect(x: 0, y: last.minY, width: ChatTextStyling.quoteBarWidth, height: line.maxY - last.minY) + } else { + bars.append(CGRect(x: 0, y: line.minY, width: ChatTextStyling.quoteBarWidth, height: line.height)) + } + } + return bars } /// One pill per line a mention covers, behind the text, so a wrapped handle gets one on each line.