Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Flipcash/Core/Controllers/ConversationController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)" }
Expand Down
42 changes: 38 additions & 4 deletions Flipcash/Core/Screens/Conversation/ChatItem+Conversation.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<NSString, FormattedTextBox> = {
let cache = NSCache<NSString, FormattedTextBox>()
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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
2 changes: 1 addition & 1 deletion Flipcash/Core/Spotlight/ChatSpotlightItem.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -122,7 +125,8 @@ extension ChatItem {
sender: sender,
isContinuationFromPrevious: false,
isContinuedByNext: false,
isEmojiOnly: isEmojiOnly
isEmojiOnly: isEmojiOnly,
format: textFormat
)))
previous = message
}
Expand Down
172 changes: 172 additions & 0 deletions FlipcashCore/Sources/FlipcashCore/Models/Chat/ChatTextFormat.swift
Original file line number Diff line number Diff line change
@@ -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<Character> = ["*", "_", "~", "`", ">", "-", "[", "\\", "."]

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 // ")"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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..<mention.endIndex, in: text)
let range = NSRange(mention.startIndex..<end, in: text)
guard !links.contains(where: { NSIntersectionRange($0.range, range).length > 0 }) else {
return nil
}
Expand All @@ -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_@])/
}
Loading