- 
                Notifications
    You must be signed in to change notification settings 
- Fork 112
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 18 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 { | ||
| 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,87 @@ | ||
| // | ||
| // 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() | ||
|  | ||
| @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).