Skip to content

Commit f08b5fc

Browse files
authored
Styled markdown with AttributedString (#757)
1 parent b287254 commit f08b5fc

File tree

92 files changed

+544
-195
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

92 files changed

+544
-195
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
33

44
# Upcoming
55

6+
### ✅ Added
7+
- Feature rich markdown rendering with AttributedString [#757](https://github.com/GetStream/stream-chat-swiftui/pull/757)
8+
- Add `Fonts.title2` for supporting markdown headers [#757](https://github.com/GetStream/stream-chat-swiftui/pull/757)
69
### 🔄 Changed
710
- Uploading a HEIC photo from the library is now converted to JPEG for better compatibility [#767](https://github.com/GetStream/stream-chat-swiftui/pull/767)
811

Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift

Lines changed: 46 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ struct StreamTextView: View {
250250

251251
@available(iOS 15, *)
252252
public struct LinkDetectionTextView: View {
253+
@Environment(\.layoutDirection) var layoutDirection
253254

254255
@Injected(\.colors) var colors
255256
@Injected(\.fonts) var fonts
@@ -271,16 +272,10 @@ public struct LinkDetectionTextView: View {
271272
self.message = message
272273
}
273274

274-
private var markdownEnabled: Bool {
275-
utils.messageListConfig.markdownSupportEnabled
276-
}
277-
278275
public var body: some View {
279276
Group {
280277
if let displayedText {
281278
Text(displayedText)
282-
} else if markdownEnabled {
283-
Text(text)
284279
} else {
285280
Text(message.adjustedText)
286281
}
@@ -289,72 +284,63 @@ public struct LinkDetectionTextView: View {
289284
.font(fonts.body)
290285
.tint(tintColor)
291286
.onAppear {
292-
detectLinks(for: message)
287+
displayedText = attributedString(for: message)
293288
}
294289
.onChange(of: message, perform: { updated in
295-
detectLinks(for: updated)
290+
displayedText = attributedString(for: updated)
296291
})
297292
}
298293

299-
func detectLinks(for message: ChatMessage) {
300-
guard utils.messageListConfig.localLinkDetectionEnabled else { return }
301-
var attributes: [NSAttributedString.Key: Any] = [
302-
.foregroundColor: textColor(for: message),
303-
.font: fonts.body
304-
]
294+
private func attributedString(for message: ChatMessage) -> AttributedString {
295+
let text = message.adjustedText
305296

306-
let additional = utils.messageListConfig.messageDisplayOptions.messageLinkDisplayResolver(message)
307-
for (key, value) in additional {
308-
if key == .foregroundColor, let value = value as? UIColor {
309-
tintColor = Color(value)
310-
} else {
311-
attributes[key] = value
312-
}
297+
// Markdown
298+
let attributes = AttributeContainer()
299+
.foregroundColor(textColor(for: message))
300+
.font(fonts.body)
301+
var attributedString: AttributedString
302+
if utils.messageListConfig.markdownSupportEnabled {
303+
attributedString = utils.markdownFormatter.format(
304+
text,
305+
attributes: attributes,
306+
layoutDirection: layoutDirection
307+
)
308+
} else {
309+
attributedString = AttributedString(message.adjustedText, attributes: attributes)
313310
}
314-
315-
let attributedText = NSMutableAttributedString(
316-
string: message.adjustedText,
317-
attributes: attributes
318-
)
319-
let attributedTextString = attributedText.string
320-
var containsLinks = false
321-
322-
message.mentionedUsers.forEach { user in
323-
containsLinks = true
324-
let mention = "@\(user.name ?? user.id)"
325-
attributedTextString
326-
.ranges(of: mention, options: [.caseInsensitive])
327-
.map { NSRange($0, in: attributedTextString) }
328-
.forEach {
329-
let messageId = message.messageId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)
330-
if let messageId {
331-
attributedText.addAttribute(.link, value: "getstream://mention/\(messageId)/\(user.id)", range: $0)
311+
// Links and mentions
312+
if utils.messageListConfig.localLinkDetectionEnabled {
313+
for user in message.mentionedUsers {
314+
let mention = "@\(user.name ?? user.id)"
315+
let ranges = attributedString.ranges(of: mention, options: [.caseInsensitive])
316+
for range in ranges {
317+
if let messageId = message.messageId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
318+
let url = URL(string: "getstream://mention/\(messageId)/\(user.id)") {
319+
attributedString[range].link = url
332320
}
333321
}
334-
}
335-
336-
let range = NSRange(location: 0, length: message.adjustedText.utf16.count)
337-
linkDetector.links(in: message.adjustedText).forEach { textLink in
338-
let escapedOriginalText = NSRegularExpression.escapedPattern(for: textLink.originalText)
339-
let pattern = "\\[([^\\]]+)\\]\\(\(escapedOriginalText)\\)"
340-
if let regex = try? NSRegularExpression(pattern: pattern) {
341-
containsLinks = (regex.firstMatch(
342-
in: message.adjustedText,
343-
options: [],
344-
range: range
345-
) == nil) || !markdownEnabled
346-
} else {
347-
containsLinks = true
348322
}
349-
350-
if !message.adjustedText.contains("](\(textLink.originalText))") {
351-
containsLinks = true
323+
for link in linkDetector.links(in: String(attributedString.characters)) {
324+
if let attributedStringRange = Range(link.range, in: attributedString) {
325+
attributedString[attributedStringRange].link = link.url
326+
}
352327
}
353-
attributedText.addAttribute(.link, value: textLink.url, range: textLink.range)
354328
}
355-
356-
if containsLinks {
357-
displayedText = AttributedString(attributedText)
329+
// Finally change attributes for links (markdown links, text links, mentions)
330+
var linkAttributes = utils.messageListConfig.messageDisplayOptions.messageLinkDisplayResolver(message)
331+
if !linkAttributes.isEmpty {
332+
var linkAttributeContainer = AttributeContainer()
333+
if let uiColor = linkAttributes[.foregroundColor] as? UIColor {
334+
linkAttributeContainer = linkAttributeContainer.foregroundColor(Color(uiColor: uiColor))
335+
linkAttributes.removeValue(forKey: .foregroundColor)
336+
}
337+
linkAttributeContainer.merge(AttributeContainer(linkAttributes))
338+
for (value, range) in attributedString.runs[\.link] {
339+
guard value != nil else { continue }
340+
attributedString[range].mergeAttributes(linkAttributeContainer)
341+
}
358342
}
343+
344+
return attributedString
359345
}
360346
}

Sources/StreamChatSwiftUI/Fonts.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public struct Fonts {
2121
public var headline = Font.headline
2222
public var headlineBold = Font.headline.bold()
2323
public var title = Font.title
24+
public var title2 = Font.title2
2425
public var title3 = Font.title3
2526
public var emoji = Font.system(size: 50)
2627
}

Sources/StreamChatSwiftUI/Utils.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import StreamChat
1010
public class Utils {
1111
// TODO: Make it public in future versions.
1212
internal var messagePreviewFormatter = MessagePreviewFormatter()
13+
var markdownFormatter = MarkdownFormatter()
1314

1415
public var dateFormatter: DateFormatter
1516
public var videoPreviewLoader: VideoPreviewLoader
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
7+
@available(iOS 15, *)
8+
extension AttributedStringProtocol {
9+
func ranges<T>(
10+
of stringToFind: T,
11+
options: String.CompareOptions = [],
12+
locale: Locale? = nil
13+
) -> [Range<AttributedString.Index>] where T: StringProtocol {
14+
guard !characters.isEmpty else { return [] }
15+
var ranges = [Range<AttributedString.Index>]()
16+
var source: AttributedSubstring = self[startIndex...]
17+
while let range = source.range(of: stringToFind, options: options, locale: locale) {
18+
ranges.append(range)
19+
if range.upperBound < endIndex {
20+
source = self[range.upperBound...]
21+
} else {
22+
break
23+
}
24+
}
25+
return ranges
26+
}
27+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
//
2+
// Copyright © 2025 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import Foundation
6+
import StreamChat
7+
import SwiftUI
8+
9+
/// Converts markdown string to AttributedString with styling attributes.
10+
final class MarkdownFormatter {
11+
@Injected(\.colors) private var colors
12+
@Injected(\.fonts) private var fonts
13+
14+
private let markdownParser = MarkdownParser()
15+
16+
@available(iOS 15, *)
17+
func format(
18+
_ string: String,
19+
attributes: AttributeContainer,
20+
layoutDirection: LayoutDirection
21+
) -> AttributedString {
22+
do {
23+
return try markdownParser.style(
24+
markdown: string,
25+
options: MarkdownParser.ParsingOptions(layoutDirectionLeftToRight: layoutDirection == .leftToRight),
26+
attributes: attributes,
27+
inlinePresentationIntentAttributes: inlinePresentationIntentAttributes(for:),
28+
presentationIntentAttributes: presentationIntentAttributes(for:in:)
29+
)
30+
} catch {
31+
log.debug("Failed to parse markdown with error \(error.localizedDescription)")
32+
return AttributedString(string, attributes: attributes)
33+
}
34+
}
35+
36+
// MARK: - Styling Attributes
37+
38+
@available(iOS 15, *)
39+
private func inlinePresentationIntentAttributes(
40+
for inlinePresentationIntent: InlinePresentationIntent
41+
) -> AttributeContainer? {
42+
nil // use default attributes
43+
}
44+
45+
@available(iOS 15, *)
46+
private func presentationIntentAttributes(
47+
for presentationKind: PresentationIntent.Kind,
48+
in presentationIntent: PresentationIntent
49+
) -> AttributeContainer? {
50+
switch presentationKind {
51+
case .blockQuote:
52+
return AttributeContainer()
53+
.foregroundColor(Color(colors.subtitleText))
54+
case .codeBlock:
55+
return AttributeContainer()
56+
.font(fonts.body.monospaced())
57+
case let .header(level):
58+
let font: Font = {
59+
switch level {
60+
case 1:
61+
return fonts.title
62+
case 2:
63+
return fonts.title2
64+
case 3:
65+
return fonts.title3
66+
case 4:
67+
return fonts.headline
68+
case 5:
69+
return fonts.subheadline
70+
default:
71+
return fonts.footnote
72+
}
73+
}()
74+
let foregroundColor: Color? = level >= 6 ? Color(colors.subtitleText) : nil
75+
if let foregroundColor {
76+
return AttributeContainer()
77+
.font(font)
78+
.foregroundColor(foregroundColor)
79+
} else {
80+
return AttributeContainer()
81+
.font(font)
82+
}
83+
default:
84+
return nil
85+
}
86+
}
87+
}

0 commit comments

Comments
 (0)