-
Notifications
You must be signed in to change notification settings - Fork 111
Styled markdown with AttributedString #757
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 11 commits
dbd7532
aa7893d
6b8b6a2
7bf75fa
75b7bd9
f9532dc
e64defb
3b91172
933a028
c9ca1df
d8f91f5
087d4ed
9b3ae7a
05ad2ab
0659ae6
fadae80
a28f8f0
ac6330d
aabcb2c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -250,6 +250,7 @@ struct StreamTextView: View { | |
|
|
||
| @available(iOS 15, *) | ||
| public struct LinkDetectionTextView: View { | ||
| @Environment(\.layoutDirection) var layoutDirection | ||
|
|
||
| @Injected(\.colors) var colors | ||
| @Injected(\.fonts) var fonts | ||
|
|
@@ -271,16 +272,10 @@ public struct LinkDetectionTextView: View { | |
| self.message = message | ||
| } | ||
|
|
||
| private var markdownEnabled: Bool { | ||
| utils.messageListConfig.markdownSupportEnabled | ||
| } | ||
|
|
||
| public var body: some View { | ||
| Group { | ||
| if let displayedText { | ||
| Text(displayedText) | ||
| } else if markdownEnabled { | ||
| Text(text) | ||
| } else { | ||
| Text(message.adjustedText) | ||
| } | ||
|
|
@@ -289,72 +284,63 @@ public struct LinkDetectionTextView: View { | |
| .font(fonts.body) | ||
| .tint(tintColor) | ||
| .onAppear { | ||
| detectLinks(for: message) | ||
| displayedText = attributedString(for: message) | ||
| } | ||
| .onChange(of: message, perform: { updated in | ||
| detectLinks(for: updated) | ||
| displayedText = attributedString(for: updated) | ||
| }) | ||
| } | ||
|
|
||
| func detectLinks(for message: ChatMessage) { | ||
| guard utils.messageListConfig.localLinkDetectionEnabled else { return } | ||
| var attributes: [NSAttributedString.Key: Any] = [ | ||
| .foregroundColor: textColor(for: message), | ||
| .font: fonts.body | ||
| ] | ||
| private func attributedString(for message: ChatMessage) -> AttributedString { | ||
| let text = message.adjustedText | ||
|
|
||
| let additional = utils.messageListConfig.messageDisplayOptions.messageLinkDisplayResolver(message) | ||
| for (key, value) in additional { | ||
| if key == .foregroundColor, let value = value as? UIColor { | ||
| tintColor = Color(value) | ||
| } else { | ||
| attributes[key] = value | ||
| } | ||
| // Markdown | ||
| let attributes = AttributeContainer() | ||
| .foregroundColor(textColor(for: message)) | ||
| .font(fonts.body) | ||
| var attributedString: AttributedString | ||
| if utils.messageListConfig.markdownSupportEnabled, utils.markdownFormatter.containsMarkdown(text) { | ||
| attributedString = utils.markdownFormatter.format( | ||
| text, | ||
| attributes: attributes, | ||
| layoutDirection: layoutDirection | ||
| ) | ||
| } else { | ||
| attributedString = AttributedString(message.adjustedText, attributes: attributes) | ||
| } | ||
|
|
||
| let attributedText = NSMutableAttributedString( | ||
| string: message.adjustedText, | ||
| attributes: attributes | ||
| ) | ||
| let attributedTextString = attributedText.string | ||
| var containsLinks = false | ||
|
|
||
| message.mentionedUsers.forEach { user in | ||
| containsLinks = true | ||
| let mention = "@\(user.name ?? user.id)" | ||
| attributedTextString | ||
| .ranges(of: mention, options: [.caseInsensitive]) | ||
| .map { NSRange($0, in: attributedTextString) } | ||
| .forEach { | ||
| let messageId = message.messageId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) | ||
| if let messageId { | ||
| attributedText.addAttribute(.link, value: "getstream://mention/\(messageId)/\(user.id)", range: $0) | ||
| // Links and mentions | ||
| if utils.messageListConfig.localLinkDetectionEnabled { | ||
| for user in message.mentionedUsers { | ||
| let mention = "@\(user.name ?? user.id)" | ||
| let ranges = attributedString.ranges(of: mention, options: [.caseInsensitive]) | ||
| for range in ranges { | ||
| if let messageId = message.messageId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), | ||
| let url = URL(string: "getstream://mention/\(messageId)/\(user.id)") { | ||
| attributedString[range].link = url | ||
| } | ||
| } | ||
| } | ||
|
|
||
| let range = NSRange(location: 0, length: message.adjustedText.utf16.count) | ||
| linkDetector.links(in: message.adjustedText).forEach { textLink in | ||
| let escapedOriginalText = NSRegularExpression.escapedPattern(for: textLink.originalText) | ||
| let pattern = "\\[([^\\]]+)\\]\\(\(escapedOriginalText)\\)" | ||
| if let regex = try? NSRegularExpression(pattern: pattern) { | ||
| containsLinks = (regex.firstMatch( | ||
| in: message.adjustedText, | ||
| options: [], | ||
| range: range | ||
| ) == nil) || !markdownEnabled | ||
| } else { | ||
| containsLinks = true | ||
| } | ||
|
|
||
| if !message.adjustedText.contains("](\(textLink.originalText))") { | ||
| containsLinks = true | ||
| for link in linkDetector.links(in: String(attributedString.characters)) { | ||
| if let attributedStringRange = Range(link.range, in: attributedString) { | ||
| attributedString[attributedStringRange].link = link.url | ||
| } | ||
| } | ||
| attributedText.addAttribute(.link, value: textLink.url, range: textLink.range) | ||
| } | ||
|
|
||
| if containsLinks { | ||
| displayedText = AttributedString(attributedText) | ||
| // Finally change attributes for links (markdown links, text links, mentions) | ||
| var linkAttributes = utils.messageListConfig.messageDisplayOptions.messageLinkDisplayResolver(message) | ||
| if !linkAttributes.isEmpty { | ||
| var linkAttributeContainer = AttributeContainer() | ||
| if let uiColor = linkAttributes[.foregroundColor] as? UIColor { | ||
| linkAttributeContainer = linkAttributeContainer.foregroundColor(Color(uiColor: uiColor)) | ||
| linkAttributes.removeValue(forKey: .foregroundColor) | ||
| } | ||
| linkAttributeContainer.merge(AttributeContainer(linkAttributes)) | ||
|
Comment on lines
+332
to
+337
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Slightly messy because I need to make sure SwiftUIAttributes.ForegroundColor is used if UIColor is passed in from |
||
| for (value, range) in attributedString.runs[\.link] { | ||
| guard value != nil else { continue } | ||
| attributedString[range].mergeAttributes(linkAttributeContainer) | ||
| } | ||
| } | ||
|
|
||
| return attributedString | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| // | ||
| // Copyright © 2025 Stream.io Inc. All rights reserved. | ||
| // | ||
|
|
||
| import Foundation | ||
|
|
||
| @available(iOS 15, *) | ||
| extension AttributedStringProtocol { | ||
| func ranges<T>( | ||
| of stringToFind: T, | ||
| options: String.CompareOptions = [], | ||
| locale: Locale? = nil | ||
| ) -> [Range<AttributedString.Index>] where T: StringProtocol { | ||
| guard !characters.isEmpty else { return [] } | ||
| var ranges = [Range<AttributedString.Index>]() | ||
| var source: AttributedSubstring = self[startIndex...] | ||
| while let range = source.range(of: stringToFind, options: options, locale: locale) { | ||
| ranges.append(range) | ||
| if range.upperBound < endIndex { | ||
| source = self[range.upperBound...] | ||
| } else { | ||
| break | ||
| } | ||
| } | ||
| return ranges | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| // | ||
| // Copyright © 2025 Stream.io Inc. All rights reserved. | ||
| // | ||
|
|
||
| import Foundation | ||
| import StreamChat | ||
| import SwiftUI | ||
|
|
||
| /// Converts markdown string to AttributedString with styling attributes. | ||
| final class MarkdownFormatter { | ||
| @Injected(\.colors) private var colors | ||
| @Injected(\.fonts) private var fonts | ||
|
|
||
| private let markdownParser = MarkdownParser() | ||
|
|
||
| func containsMarkdown(_ string: String) -> Bool { | ||
| markdownParser.containsMarkdown(string) | ||
| } | ||
|
|
||
| @available(iOS 15, *) | ||
| func format( | ||
| _ string: String, | ||
| attributes: AttributeContainer, | ||
| layoutDirection: LayoutDirection | ||
| ) -> AttributedString { | ||
| do { | ||
| return try markdownParser.style( | ||
| markdown: string, | ||
| options: MarkdownParser.ParsingOptions(layoutDirectionLeftToRight: layoutDirection == .leftToRight), | ||
| attributes: attributes, | ||
| inlinePresentationIntentAttributes: inlinePresentationIntentAttributes(for:), | ||
| presentationIntentAttributes: presentationIntentAttributes(for:in:) | ||
| ) | ||
| } catch { | ||
| log.debug("Failed to parse markdown with error \(error.localizedDescription)") | ||
| return AttributedString(string, attributes: attributes) | ||
| } | ||
| } | ||
|
|
||
| // MARK: - Styling Attributes | ||
|
|
||
| @available(iOS 15, *) | ||
| private func inlinePresentationIntentAttributes( | ||
| for inlinePresentationIntent: InlinePresentationIntent | ||
| ) -> AttributeContainer? { | ||
| nil // use default attributes | ||
| } | ||
|
|
||
| @available(iOS 15, *) | ||
| private func presentationIntentAttributes( | ||
| for presentationKind: PresentationIntent.Kind, | ||
| in presentationIntent: PresentationIntent | ||
| ) -> AttributeContainer? { | ||
| switch presentationKind { | ||
| case .blockQuote: | ||
| return AttributeContainer() | ||
| .foregroundColor(Color(colors.subtitleText)) | ||
| case .codeBlock: | ||
| return AttributeContainer() | ||
| .font(fonts.body.monospaced()) | ||
| case let .header(level): | ||
| let font: Font = { | ||
| switch level { | ||
| case 1: | ||
| return fonts.title | ||
| case 2: | ||
| return fonts.title2 | ||
| case 3: | ||
| return fonts.title3 | ||
| case 4: | ||
| return fonts.headline | ||
| case 5: | ||
| return fonts.subheadline | ||
| default: | ||
| return fonts.footnote | ||
| } | ||
| }() | ||
| let foregroundColor: Color? = level >= 6 ? Color(colors.subtitleText) : nil | ||
| if let foregroundColor { | ||
| return AttributeContainer() | ||
| .font(font) | ||
| .foregroundColor(foregroundColor) | ||
| } else { | ||
| return AttributeContainer() | ||
| .font(font) | ||
| } | ||
| default: | ||
| return nil | ||
| } | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This file's diff is pretty unreadable.
Before
After
Some things could be improved further: since now we use AttributedString always, then it could be created in init (onAppear feels unnecessary, but removing it does break tint color). Needs some investigation (probably better to be a separate PR).