Skip to content

Commit 17f5087

Browse files
initial mentions implementation
1 parent a1e6390 commit 17f5087

File tree

15 files changed

+519
-62
lines changed

15 files changed

+519
-62
lines changed
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
//
2+
// Copyright © 2021 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import StreamChat
6+
import SwiftUI
7+
8+
/// Enum describing the attachment picker's state.
9+
public enum AttachmentPickerState {
10+
case files
11+
case photos
12+
case camera
13+
case custom
14+
}
15+
16+
/// Struct representing an asset added to the composer.
17+
public struct AddedAsset: Identifiable {
18+
public let image: UIImage
19+
public let id: String
20+
public let url: URL
21+
public let type: AssetType
22+
public var extraData: [String: Any] = [:]
23+
}
24+
25+
/// Type of asset added to the composer.
26+
public enum AssetType {
27+
case image
28+
case video
29+
}
30+
31+
public struct CustomAttachment: Identifiable, Equatable {
32+
33+
public static func == (lhs: CustomAttachment, rhs: CustomAttachment) -> Bool {
34+
lhs.id == rhs.id
35+
}
36+
37+
public let id: String
38+
public let content: AnyAttachmentPayload
39+
40+
public init(id: String, content: AnyAttachmentPayload) {
41+
self.id = id
42+
self.content = content
43+
}
44+
}

Sources/StreamChatSwiftUI/ChatChannel/Composer/ComposerTextInputView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import UIKit
99
struct ComposerTextInputView: UIViewRepresentable {
1010
@Binding var text: String
1111
@Binding var height: CGFloat
12+
@Binding var selectedRangeLocation: Int
1213

1314
var placeholder: String
1415

@@ -25,6 +26,7 @@ struct ComposerTextInputView: UIViewRepresentable {
2526
func updateUIView(_ uiView: InputTextView, context: Context) {
2627
DispatchQueue.main.async {
2728
if uiView.markedTextRange == nil {
29+
uiView.selectedRange.location = selectedRangeLocation
2830
uiView.text = text
2931
uiView.handleTextChange()
3032
}
@@ -45,6 +47,7 @@ struct ComposerTextInputView: UIViewRepresentable {
4547
}
4648

4749
func textViewDidChange(_ textView: UITextView) {
50+
textInput.selectedRangeLocation = textView.selectedRange.location
4851
textInput.text = textView.text
4952
}
5053

Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerView.swift

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ public struct MessageComposerView<Factory: ViewFactory>: View, KeyboardReadable
1212

1313
// Initial popup size, before the keyboard is shown.
1414
@State private var popupSize: CGFloat = 350
15+
@State private var composerHeight: CGFloat = 0
1516

1617
private var factory: Factory
1718
@Binding var quotedMessage: ChatMessage?
@@ -58,6 +59,7 @@ public struct MessageComposerView<Factory: ViewFactory>: View, KeyboardReadable
5859

5960
factory.makeComposerInputView(
6061
text: $viewModel.text,
62+
selectedRangeLocation: $viewModel.selectedRangeLocation,
6163
addedAssets: viewModel.addedAssets,
6264
addedFileURLs: viewModel.addedFileURLs,
6365
addedCustomAttachments: viewModel.addedCustomAttachments,
@@ -105,6 +107,18 @@ public struct MessageComposerView<Factory: ViewFactory>: View, KeyboardReadable
105107
popupHeight: popupSize
106108
)
107109
}
110+
.background(
111+
GeometryReader { proxy in
112+
let frame = proxy.frame(in: .local)
113+
let height = frame.height
114+
Color.clear.preference(key: HeightPreferenceKey.self, value: height)
115+
}
116+
)
117+
.onPreferenceChange(HeightPreferenceKey.self) { value in
118+
if let value = value, value != composerHeight {
119+
self.composerHeight = value
120+
}
121+
}
108122
.onReceive(keyboardPublisher) { visible in
109123
if visible {
110124
withAnimation(.easeInOut(duration: 0.02)) {
@@ -117,6 +131,15 @@ public struct MessageComposerView<Factory: ViewFactory>: View, KeyboardReadable
117131
self.popupSize = height - bottomSafeArea
118132
}
119133
}
134+
.overlay(
135+
viewModel.typingSuggestion != nil ? CommandsContainerView(
136+
suggestedUsers: viewModel.suggestedUsers,
137+
userSelected: viewModel.mentionedUserSelected
138+
)
139+
.offset(y: -composerHeight)
140+
.animation(nil) : nil,
141+
alignment: .bottom
142+
)
120143
.alert(isPresented: $viewModel.errorShown) {
121144
Alert.defaultErrorAlert
122145
}
@@ -132,6 +155,7 @@ public struct ComposerInputView<Factory: ViewFactory>: View {
132155

133156
var factory: Factory
134157
@Binding var text: String
158+
@Binding var selectedRangeLocation: Int
135159
var addedAssets: [AddedAsset]
136160
var addedFileURLs: [URL]
137161
var addedCustomAttachments: [CustomAttachment]
@@ -195,6 +219,7 @@ public struct ComposerInputView<Factory: ViewFactory>: View {
195219
ComposerTextInputView(
196220
text: $text,
197221
height: $textHeight,
222+
selectedRangeLocation: $selectedRangeLocation,
198223
placeholder: L10n.Composer.Placeholder.message
199224
)
200225
.frame(height: textFieldHeight)

Sources/StreamChatSwiftUI/ChatChannel/Composer/MessageComposerViewModel.swift

Lines changed: 52 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Copyright © 2021 Stream.io Inc. All rights reserved.
33
//
44

5+
import Combine
56
import Photos
67
import StreamChat
78
import SwiftUI
@@ -36,9 +37,15 @@ public class MessageComposerViewModel: ObservableObject {
3637
if text != "" {
3738
pickerTypeState = .collapsed
3839
channelController.sendKeystrokeEvent()
40+
checkTypingSuggestions()
41+
} else {
42+
typingSuggestion = nil
43+
selectedRangeLocation = 0
3944
}
4045
}
4146
}
47+
48+
@Published var selectedRangeLocation: Int = 0
4249

4350
@Published var addedFileURLs = [URL]() {
4451
didSet {
@@ -70,21 +77,29 @@ public class MessageComposerViewModel: ObservableObject {
7077
}
7178
}
7279
}
80+
81+
@Published private(set) var typingSuggestion: TypingSuggestion?
7382

7483
@Published var filePickerShown = false
7584
@Published var cameraPickerShown = false
7685
@Published var errorShown = false
7786
@Published var showReplyInChannel = false
87+
@Published var suggestedUsers = [ChatUser]()
7888

7989
private let channelController: ChatChannelController
8090
private var messageController: ChatMessageController?
8191

92+
private let typingSuggester = TypingSuggester(options: .init(symbol: "@"))
93+
private let mentionsSuggester: MentionsSuggester
94+
private var cancellables = Set<AnyCancellable>()
95+
8296
public init(
8397
channelController: ChatChannelController,
8498
messageController: ChatMessageController?
8599
) {
86100
self.channelController = channelController
87101
self.messageController = messageController
102+
mentionsSuggester = MentionsSuggester(channelController: channelController)
88103
}
89104

90105
public func sendMessage(
@@ -205,7 +220,7 @@ public class MessageComposerViewModel: ObservableObject {
205220
}
206221

207222
func removeAttachment(with id: String) {
208-
if isURL(string: id), let url = URL(string: id) {
223+
if id.isURL, let url = URL(string: id) {
209224
var urls = [URL]()
210225
for added in addedFileURLs {
211226
if url != added {
@@ -287,8 +302,31 @@ public class MessageComposerViewModel: ObservableObject {
287302
}
288303
}
289304

305+
func mentionedUserSelected(_ chatUser: ChatUser) {
306+
guard let typingSuggestion = typingSuggestion else { return }
307+
let mentionText = self.mentionText(for: chatUser)
308+
let newText = (text as NSString).replacingCharacters(
309+
in: typingSuggestion.locationRange,
310+
with: mentionText
311+
)
312+
text = newText
313+
314+
let newCaretLocation =
315+
selectedRangeLocation + (mentionText.count - typingSuggestion.text.count)
316+
selectedRangeLocation = newCaretLocation
317+
self.typingSuggestion = nil
318+
}
319+
290320
// MARK: - private
291321

322+
private func mentionText(for user: ChatUser) -> String {
323+
if let name = user.name, !name.isEmpty {
324+
return name
325+
} else {
326+
return user.id
327+
}
328+
}
329+
292330
private func edit(
293331
message: ChatMessage,
294332
completion: @escaping () -> Void
@@ -325,60 +363,20 @@ public class MessageComposerViewModel: ObservableObject {
325363
}
326364
}
327365

328-
private func isURL(string: String) -> Bool {
329-
let types: NSTextCheckingResult.CheckingType = [.link]
330-
let detector = try? NSDataDetector(types: types.rawValue)
331-
332-
guard (detector != nil && !string.isEmpty) else {
333-
return false
334-
}
366+
private func checkTypingSuggestions() {
367+
typingSuggestion = typingSuggester.typingSuggestion(
368+
in: text,
369+
caretLocation: selectedRangeLocation
370+
)
335371

336-
if detector!.numberOfMatches(
337-
in: string,
338-
options: NSRegularExpression.MatchingOptions(rawValue: 0),
339-
range: NSMakeRange(0, string.count)
340-
) > 0 {
341-
return true
372+
if let typingSuggestion = typingSuggestion {
373+
mentionsSuggester.showMentionSuggestions(for: typingSuggestion.text, mentionRange: typingSuggestion.locationRange)
374+
.sink { [weak self] users in
375+
withAnimation {
376+
self?.suggestedUsers = users
377+
}
378+
}
379+
.store(in: &cancellables)
342380
}
343-
344-
return false
345-
}
346-
}
347-
348-
/// Enum describing the attachment picker's state.
349-
public enum AttachmentPickerState {
350-
case files
351-
case photos
352-
case camera
353-
case custom
354-
}
355-
356-
/// Struct representing an asset added to the composer.
357-
public struct AddedAsset: Identifiable {
358-
public let image: UIImage
359-
public let id: String
360-
public let url: URL
361-
public let type: AssetType
362-
public var extraData: [String: Any] = [:]
363-
}
364-
365-
/// Type of asset added to the composer.
366-
public enum AssetType {
367-
case image
368-
case video
369-
}
370-
371-
public struct CustomAttachment: Identifiable, Equatable {
372-
373-
public static func == (lhs: CustomAttachment, rhs: CustomAttachment) -> Bool {
374-
lhs.id == rhs.id
375-
}
376-
377-
public let id: String
378-
public let content: AnyAttachmentPayload
379-
380-
public init(id: String, content: AnyAttachmentPayload) {
381-
self.id = id
382-
self.content = content
383381
}
384382
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
//
2+
// Copyright © 2021 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import StreamChat
6+
import SwiftUI
7+
8+
struct CommandsContainerView: View {
9+
10+
var suggestedUsers: [ChatUser]
11+
var userSelected: (ChatUser) -> Void
12+
13+
var body: some View {
14+
ZStack {
15+
MentionUsersView(
16+
users: suggestedUsers,
17+
userSelected: userSelected
18+
)
19+
}
20+
}
21+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//
2+
// Copyright © 2021 Stream.io Inc. All rights reserved.
3+
//
4+
5+
import StreamChat
6+
import SwiftUI
7+
8+
struct MentionUsersView: View {
9+
10+
@Injected(\.colors) private var colors
11+
12+
private let itemHeight: CGFloat = 60
13+
14+
var users: [ChatUser]
15+
var userSelected: (ChatUser) -> Void
16+
17+
var body: some View {
18+
ScrollView {
19+
LazyVStack {
20+
ForEach(users) { user in
21+
MentionUserView(
22+
user: user,
23+
userSelected: userSelected
24+
)
25+
}
26+
}
27+
.animation(nil)
28+
}
29+
.frame(height: viewHeight)
30+
.background(Color(colors.background))
31+
.modifier(ShadowViewModifier())
32+
.padding(.all, 8)
33+
.animation(.spring())
34+
}
35+
36+
private var viewHeight: CGFloat {
37+
if users.count > 3 {
38+
return 3 * itemHeight
39+
} else {
40+
return CGFloat(users.count) * itemHeight
41+
}
42+
}
43+
}
44+
45+
struct MentionUserView: View {
46+
@Injected(\.fonts) private var fonts
47+
@Injected(\.colors) private var colors
48+
49+
var user: ChatUser
50+
var userSelected: (ChatUser) -> Void
51+
52+
var body: some View {
53+
HStack {
54+
MessageAvatarView(
55+
author: user,
56+
showOnlineIndicator: true
57+
)
58+
Text(user.name ?? user.id)
59+
.lineLimit(1)
60+
.font(fonts.bodyBold)
61+
Spacer()
62+
Text("@")
63+
.font(fonts.title)
64+
.foregroundColor(colors.tintColor)
65+
}
66+
.standardPadding()
67+
.onTapGesture {
68+
userSelected(user)
69+
}
70+
}
71+
}

0 commit comments

Comments
 (0)