Skip to content

Commit d4261af

Browse files
Highlighting and tapping on user mentions (#473)
1 parent 0970711 commit d4261af

File tree

9 files changed

+184
-1
lines changed

9 files changed

+184
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
44
# Upcoming
55

66
### ✅ Added
7+
- Highlighting and tapping on user mentions
78
- Customization of the channel loading view
89

910
# [4.52.0](https://github.com/GetStream/stream-chat-swiftui/releases/tag/4.52.0)

DemoAppSwiftUI/Info.plist

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,19 @@
1818
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
1919
<key>CFBundleShortVersionString</key>
2020
<string>$(MARKETING_VERSION)</string>
21+
<key>CFBundleURLTypes</key>
22+
<array>
23+
<dict>
24+
<key>CFBundleTypeRole</key>
25+
<string>Viewer</string>
26+
<key>CFBundleURLName</key>
27+
<string>io.getstream.iOS.DemoAppSwiftUI</string>
28+
<key>CFBundleURLSchemes</key>
29+
<array>
30+
<string>getstream</string>
31+
</array>
32+
</dict>
33+
</array>
2134
<key>CFBundleVersion</key>
2235
<string>$(CURRENT_PROJECT_VERSION)</string>
2336
<key>ITSAppUsesNonExemptEncryption</key>

DemoAppSwiftUI/ViewFactoryExamples.swift

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ class DemoAppFactory: ViewFactory {
1111
@Injected(\.chatClient) public var chatClient
1212

1313
private init() {}
14+
15+
private var mentionsHandler = MentionsHandler()
1416

1517
public static let shared = DemoAppFactory()
1618

@@ -70,6 +72,10 @@ class DemoAppFactory: ViewFactory {
7072
)
7173
}
7274

75+
public func makeMessageViewModifier(for messageModifierInfo: MessageModifierInfo) -> some ViewModifier {
76+
ShowProfileModifier(messageModifierInfo: messageModifierInfo, mentionsHandler: mentionsHandler)
77+
}
78+
7379
private func pinChannelAction(
7480
for channel: ChatChannel,
7581
onDismiss: @escaping () -> Void,
@@ -99,6 +105,72 @@ class DemoAppFactory: ViewFactory {
99105
}
100106
}
101107

108+
struct ShowProfileModifier: ViewModifier {
109+
110+
let messageModifierInfo: MessageModifierInfo
111+
112+
@ObservedObject var mentionsHandler: MentionsHandler
113+
114+
func body(content: Content) -> some View {
115+
content
116+
.modifier(
117+
DefaultViewFactory.shared.makeMessageViewModifier(for: messageModifierInfo)
118+
)
119+
.modifier(
120+
ProfileURLModifier(
121+
mentionsHandler: mentionsHandler,
122+
messageModifierInfo: messageModifierInfo
123+
)
124+
)
125+
}
126+
}
127+
128+
class MentionsHandler: ObservableObject {
129+
130+
@Published var selectedUser: ChatUser?
131+
}
132+
133+
struct ProfileURLModifier: ViewModifier {
134+
135+
@ObservedObject var mentionsHandler: MentionsHandler
136+
var messageModifierInfo: MessageModifierInfo
137+
138+
@State var showProfile = false
139+
140+
func body(content: Content) -> some View {
141+
if !messageModifierInfo.message.mentionedUsers.isEmpty {
142+
content
143+
.onOpenURL(perform: { url in
144+
if url.absoluteString.contains("getstream://mention")
145+
&& url.pathComponents.count > 2
146+
&& messageModifierInfo.message.scrollMessageId == url.pathComponents[1]
147+
&& (mentionsHandler.selectedUser?.id != url.pathComponents[2] || !showProfile) {
148+
let userId = url.pathComponents[2]
149+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
150+
if mentionsHandler.selectedUser == nil {
151+
let user = messageModifierInfo.message.mentionedUsers.first(where: { $0.id == userId })
152+
mentionsHandler.selectedUser = user
153+
showProfile = true
154+
}
155+
}
156+
}
157+
})
158+
.sheet(isPresented: $showProfile, onDismiss: {
159+
mentionsHandler.selectedUser = nil
160+
}, content: {
161+
if let user = mentionsHandler.selectedUser {
162+
VStack {
163+
MessageAvatarView(avatarURL: user.imageURL)
164+
Text(user.name ?? user.id)
165+
}
166+
}
167+
})
168+
} else {
169+
content
170+
}
171+
}
172+
}
173+
102174
struct CustomChannelDestination: View {
103175

104176
var channel: ChatChannel

Sources/StreamChatSwiftUI/ChatChannel/MessageList/MessageView.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,8 +299,23 @@ public struct LinkDetectionTextView: View {
299299
string: message.adjustedText,
300300
attributes: attributes
301301
)
302-
302+
let attributedTextString = attributedText.string
303303
var containsLinks = false
304+
305+
message.mentionedUsers.forEach { user in
306+
containsLinks = true
307+
let mention = "@\(user.name ?? user.id)"
308+
attributedTextString
309+
.ranges(of: mention, options: [.caseInsensitive])
310+
.map { NSRange($0, in: attributedTextString) }
311+
.forEach {
312+
let messageId = message.messageId.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed)
313+
if let messageId {
314+
attributedText.addAttribute(.link, value: "getstream://mention/\(messageId)/\(user.id)", range: $0)
315+
}
316+
}
317+
}
318+
304319
let range = NSRange(location: 0, length: message.adjustedText.utf16.count)
305320
linkDetector.links(in: message.adjustedText).forEach { textLink in
306321
let pattern = "\\[([^\\]]+)\\]\\(\(textLink.originalText)\\)"

Sources/StreamChatSwiftUI/Utils/StringExtensions.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,3 +112,17 @@ extension String {
112112

113113
static let unknownMessageId = "unknown"
114114
}
115+
116+
extension String {
117+
func ranges<S: StringProtocol>(of string: S, options: String.CompareOptions = []) -> [Range<String.Index>] {
118+
var result: [Range<Index>] = []
119+
var startIndex = self.startIndex
120+
while startIndex < endIndex, let range = self[startIndex...].range(of: string, options: options) {
121+
result.append(range)
122+
startIndex = range.lowerBound < range.upperBound
123+
? range.upperBound
124+
: index(range.lowerBound, offsetBy: 1, limitedBy: endIndex) ?? endIndex
125+
}
126+
return result
127+
}
128+
}

StreamChatSwiftUITests/Tests/ChatChannel/MessageView_Tests.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,54 @@ class MessageView_Tests: StreamChatTestCase {
3333
// Then
3434
assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
3535
}
36+
37+
func test_messageViewTextMention_snapshot() {
38+
// Given
39+
let textMessage = ChatMessage.mock(
40+
id: .unique,
41+
cid: .unique,
42+
text: "Hi @Martin, how are you?",
43+
author: .mock(id: .unique),
44+
mentionedUsers: [.mock(id: "martin", name: "Martin")]
45+
)
46+
47+
// When
48+
let view = MessageView(
49+
factory: DefaultViewFactory.shared,
50+
message: textMessage,
51+
contentWidth: defaultScreenSize.width,
52+
isFirst: true,
53+
scrolledId: .constant(nil)
54+
)
55+
.frame(width: defaultScreenSize.width, height: 100)
56+
57+
// Then
58+
assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
59+
}
60+
61+
func test_messageViewTextMentionMultiple_snapshot() {
62+
// Given
63+
let textMessage = ChatMessage.mock(
64+
id: .unique,
65+
cid: .unique,
66+
text: "Hi @Martin and @Alexey, how are you? This is @Martin's test!",
67+
author: .mock(id: .unique),
68+
mentionedUsers: [.mock(id: "martin", name: "Martin"), .mock(id: "alexey", name: "Alexey")]
69+
)
70+
71+
// When
72+
let view = MessageView(
73+
factory: DefaultViewFactory.shared,
74+
message: textMessage,
75+
contentWidth: defaultScreenSize.width,
76+
isFirst: true,
77+
scrolledId: .constant(nil)
78+
)
79+
.frame(width: defaultScreenSize.width, height: 100)
80+
81+
// Then
82+
assertSnapshot(matching: view, as: .image(perceptualPrecision: precision))
83+
}
3684

3785
func test_messageViewImage_snapshot() {
3886
// Given
Loading
Loading

StreamChatSwiftUITests/Tests/Utils/StringExtensions_Tests.swift

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,4 +62,24 @@ class String_Extensions_Tests: XCTestCase {
6262
XCTAssert("example".isURL == false)
6363
XCTAssert("invalid_url".isURL == false)
6464
}
65+
66+
func testRangesOfString() {
67+
let mention = "@Martin"
68+
let string = "Hey \(mention), how are you?"
69+
let result = string
70+
.ranges(of: mention, options: [.caseInsensitive])
71+
.map { NSRange($0, in: string) }
72+
.first
73+
XCTAssertEqual(result, NSRange(location: 4, length: 7))
74+
}
75+
76+
func testRangesOfStringNotFound() {
77+
let string = "Hey @Martin, how are you?"
78+
let mention = "@Alexey"
79+
let result = string
80+
.ranges(of: mention, options: [.caseInsensitive])
81+
.map { NSRange($0, in: string) }
82+
.first
83+
XCTAssertEqual(result, nil)
84+
}
6585
}

0 commit comments

Comments
 (0)