Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

# Upcoming

### ✅ Added
- Feature rich markdown rendering with AttributedString [#757](https://github.com/GetStream/stream-chat-swiftui/pull/757)
- Add `Fonts.title2` for supporting markdown headers [#757](https://github.com/GetStream/stream-chat-swiftui/pull/757)
### 🐞 Fixed
- Fix visibility of tabbar when reactions are shown [#750](https://github.com/GetStream/stream-chat-swiftui/pull/750)
- Show all members in direct message channel info view [#760](https://github.com/GetStream/stream-chat-swiftui/pull/760)
Expand Down
106 changes: 46 additions & 60 deletions Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Comment on lines 276 to 281
Copy link
Contributor Author

@laevandus laevandus Feb 17, 2025

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

  • Manual link detection for mentions and plain links was separate from markdown handling
  • Markdown handling came purely from Text's markdown support

After

  • First parse and style markdown (if allowed)
  • Add links for mentions (if allowed)
  • Detect plain links in text (if allowed)
  • Apply styling attributes for links

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).

Expand All @@ -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
Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 messageLinkDisplayResolver. Only way I found it make it working is init attributes without UIColor and then adding Color to the container using the .foregroundColor() functions which takes in a Color instance.

for (value, range) in attributedString.runs[\.link] {
guard value != nil else { continue }
attributedString[range].mergeAttributes(linkAttributeContainer)
}
}

return attributedString
}
}
1 change: 1 addition & 0 deletions Sources/StreamChatSwiftUI/Fonts.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public struct Fonts {
public var headline = Font.headline
public var headlineBold = Font.headline.bold()
public var title = Font.title
public var title2 = Font.title2
public var title3 = Font.title3
public var emoji = Font.system(size: 50)
}
1 change: 1 addition & 0 deletions Sources/StreamChatSwiftUI/Utils.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import StreamChat
public class Utils {
// TODO: Make it public in future versions.
internal var messagePreviewFormatter = MessagePreviewFormatter()
var markdownFormatter = MarkdownFormatter()

public var dateFormatter: DateFormatter
public var videoPreviewLoader: VideoPreviewLoader
Expand Down
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
}
}
91 changes: 91 additions & 0 deletions Sources/StreamChatSwiftUI/Utils/MarkdownFormatter.swift
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
}
}
}
Loading
Loading