Skip to content

Commit

Permalink
Add support for user mentions in channels with 100+ members (#3043)
Browse files Browse the repository at this point in the history
* Fix MembersViewController not showing user ids as fallback

* Increase member page size in MembersViewController (DemoApp)

* Debounce user mention suggestions

* Add support for user mentions in channels with 100+ members

* When the debouncer has a 0 value, execute the action immediately

* Fix ComposerVC_Tests

* Add test coverage to data source factory method

* Update CHANGELOG.md

* Add mentionAllAppUsers config

* Make the debouncer faster for user mentions

* Make sure the previous result does not impact the new search if it is not related

* Disable BG Mapping for Member List Controller

* When the mention already exists do not show results

* Add test coverage

* Fix UI Tests

* Fix mentionAllAppUsers when tapping mention with ID

* Add protection againsts delayed user mention requests
  • Loading branch information
nuno-vieira authored Feb 27, 2024
1 parent 7e9b17b commit e25100b
Show file tree
Hide file tree
Showing 14 changed files with 288 additions and 46 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
- Fix showing empty search results with background mapping enabled [#3042](https://github.com/GetStream/stream-chat-swift/pull/3042)

## StreamChatUI
### ✅ Added
- Add support for user mentions in channels with 100+ members [#3043](https://github.com/GetStream/stream-chat-swift/pull/3043)
### 🐞 Fixed
- Fix composer link preview overridden by previous enrichment [#3025](https://github.com/GetStream/stream-chat-swift/pull/3025)
- Fix merged avatars changing sub-image locations when opening channel list [#3013](https://github.com/GetStream/stream-chat-swift/pull/3013)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,7 @@ class AppConfigViewController: UITableViewController {
case channelListSearchStrategy
case isUnreadMessageSeparatorEnabled
case isJumpToUnreadEnabled
case mentionAllAppUsers
}

enum ChatClientConfigOption: String, CaseIterable {
Expand Down Expand Up @@ -438,6 +439,10 @@ class AppConfigViewController: UITableViewController {
cell.accessoryView = makeSwitchButton(Components.default.isJumpToUnreadEnabled) { newValue in
Components.default.isJumpToUnreadEnabled = newValue
}
case .mentionAllAppUsers:
cell.accessoryView = makeSwitchButton(Components.default.mentionAllAppUsers) { newValue in
Components.default.mentionAllAppUsers = newValue
}
}
}

Expand Down
2 changes: 1 addition & 1 deletion DemoApp/Screens/MembersViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ class MembersViewController: UITableViewController, ChatChannelMemberListControl
if let imageURL = member.imageURL {
Nuke.loadImage(with: imageURL, into: cell.avatarView)
}
cell.nameLabel.text = member.name
cell.nameLabel.text = member.name ?? member.id
cell.removeButton.isHidden = true
return cell
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ final class DemoChatChannelListRouter: ChatChannelListRouter {
guard let cid = channelController.channel?.cid else { return }
let client = channelController.client
self.rootViewController.present(MembersViewController(
membersController: client.memberListController(query: .init(cid: cid))
membersController: client.memberListController(query: .init(cid: cid, pageSize: 105))
), animated: true)
}),
.init(title: "Show Banned Members", handler: { [unowned self] _ in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ public class ChatChannelMemberListController: DataController, DelegateCallable,

private func createMemberListObserver() -> ListDatabaseObserverWrapper<ChatChannelMember, MemberDTO> {
let observer = environment.memberListObserverBuilder(
StreamRuntimeCheck._isBackgroundMappingEnabled,
false,
client.databaseContainer,
MemberDTO.members(matching: query),
{ try $0.asModel() },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public class ChatUserSearchController: DataController, DelegateCallable, DataSto
public let client: ChatClient

/// Copy of last search query made, used for getting next page.
private(set) var query: UserListQuery?
public private(set) var query: UserListQuery?

/// The users matching the last query of this controller.
private var _users: [ChatUser] = []
Expand Down Expand Up @@ -111,6 +111,11 @@ public class ChatUserSearchController: DataController, DelegateCallable, DataSto

fetch(updatedQuery, completion: completion)
}

/// Clears the current search results.
public func clearResults() {
_users = []
}
}

private extension ChatUserSearchController {
Expand Down
6 changes: 6 additions & 0 deletions Sources/StreamChat/Utils/Debouncer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ public struct Debouncer {
public mutating func execute(block: @escaping () -> Void) {
/// Cancels the current job if there is one.
job?.cancel()

if interval == .zero {
block()
return
}

/// Creates a new job with the given block and assigns it to the newJob constant.
let newJob = DispatchWorkItem { block() }
/// Schedules the new job to be executed after a certain time interval on the provided queue.
Expand Down
158 changes: 132 additions & 26 deletions Sources/StreamChatUI/Composer/ComposerVC.swift
Original file line number Diff line number Diff line change
Expand Up @@ -287,10 +287,15 @@ open class ComposerVC: _ViewController,
}
}

/// The component responsible for tracking cooldown timing when slow mode is enabled.
open var cooldownTracker: CooldownTracker = CooldownTracker(timer: ScheduledStreamTimer(interval: 1))

/// The debouncer to control requests when enriching urls.
public var enrichUrlDebouncer = Debouncer(0.4, queue: .main)

/// The debouncer to control user searching requests when mentioning users.
public var userMentionsDebouncer = Debouncer(0.25, queue: .main)

lazy var linkDetector = TextLinkDetector()

/// A symbol that is used to recognise when the user is mentioning a user.
Expand Down Expand Up @@ -335,6 +340,9 @@ open class ComposerVC: _ViewController,
/// A controller to search users and that is used to populate the mention suggestions.
open var userSearchController: ChatUserSearchController!

/// A controller to search members in a channel and that is used to populate the mention suggestions.
open var memberListController: ChatChannelMemberListController?

/// A controller that manages the channel that the composer is creating content for.
open var channelController: ChatChannelController?

Expand Down Expand Up @@ -598,7 +606,9 @@ open class ComposerVC: _ViewController,
}

if isMentionsEnabled, let (typingMention, mentionRange) = typingMention(in: composerView.inputMessageView.textView) {
showMentionSuggestions(for: typingMention, mentionRange: mentionRange)
userMentionsDebouncer.execute { [weak self] in
self?.showMentionSuggestions(for: typingMention, mentionRange: mentionRange)
}
return
}

Expand Down Expand Up @@ -875,7 +885,7 @@ open class ComposerVC: _ViewController,
showSuggestions()
}

/// Returns the query to be used for searching users for the given typing mention.
/// Returns the query to be used for searching users across the whole app.
///
/// This function is called in `showMentionSuggestions` to retrieve the query
/// that will be used to search the users. You should override this if you want to change the
Expand All @@ -893,51 +903,65 @@ open class ComposerVC: _ViewController,
)
}

/// Returns the query to be used for searching members inside a channel.
///
/// This function is called in `showMentionSuggestions` to retrieve the query
/// that will be used to search for members. You should override this if you want to change the
/// member searching logic.
///
/// - Parameter typingMention: The potential user mention the current user is typing.
/// - Returns: `ChannelMemberListQuery` instance that will be used for searching members in a channel.
open func queryForChannelMentionSuggestionsSearch(typingMention term: String) -> ChannelMemberListQuery? {
guard let cid = channelController?.cid else {
return nil
}
return ChannelMemberListQuery(
cid: cid,
filter: .autocomplete(.name, text: term),
sort: [.init(key: .name, isAscending: true)]
)
}

/// Returns the member list controller to be used for searching members inside a channel.
///
/// - Parameter term: The potential user mention the current user is typing.
/// - Returns: `ChatChannelMemberListController` instance that will be used for searching members in a channel.
open func makeMemberListControllerForMemberSuggestions(typingMention term: String) -> ChatChannelMemberListController? {
guard let query = queryForChannelMentionSuggestionsSearch(typingMention: term) else { return nil }
return userSearchController.client.memberListController(query: query)
}

/// Shows the mention suggestions for the potential mention the current user is typing.
/// - Parameters:
/// - typingMention: The potential user mention the current user is typing.
/// - mentionRange: The position where the current user is typing a mention to it can be replaced with the suggestion.
open func showMentionSuggestions(for typingMention: String, mentionRange: NSRange) {
guard let channel = channelController?.channel else {
guard !content.text.isEmpty else {
// Because we do not have cancellation, when a mention request is finished it can happen
// that we already published the message, so we don't need to show the suggestions anymore.
return
}
guard let currentUserId = channelController?.client.currentUserId else {
guard let dataSource = makeMentionSuggestionsDataSource(for: typingMention) else {
return
}

var usersCache: [ChatUser] = []

if mentionAllAppUsers {
userSearchController.search(
query: queryForMentionSuggestionsSearch(typingMention: typingMention)
)
} else {
usersCache = searchUsers(
channel.lastActiveMembers,
by: typingMention,
excludingId: currentUserId
)
}

let dataSource = ChatMessageComposerSuggestionsMentionDataSource(
collectionView: suggestionsVC.collectionView,
searchController: userSearchController,
usersCache: usersCache
)
suggestionsVC.dataSource = dataSource
suggestionsVC.didSelectItemAt = { [weak self] userIndex in
guard let self = self else { return }
guard dataSource.usersCache.count >= userIndex else {
guard dataSource.users.count >= userIndex else {
return
}
guard let user = dataSource.usersCache[safe: userIndex] else {
guard let user = dataSource.users[safe: userIndex] else {
indexNotFoundAssertion()
return
}

let textView = self.composerView.inputMessageView.textView
let text = textView.text as NSString
let mentionText = self.mentionText(for: user)
guard mentionRange.length <= mentionText.count else {
return self.dismissSuggestions()
}

let newText = text.replacingCharacters(in: mentionRange, with: mentionText)
// Add additional spacing to help continue writing the message
self.content.text = newText + " "
Expand All @@ -953,6 +977,88 @@ open class ComposerVC: _ViewController,
showSuggestions()
}

/// Creates a `ChatMessageComposerSuggestionsMentionDataSource` with data from local cache, user search or channel members.
/// The source of the data will depend on `mentionAllAppUsers` flag and the amount of members in the channel.
/// - Parameter typingMention: The potential user mention the current user is typing.
/// - Returns: A `ChatMessageComposerSuggestionsMentionDataSource` instance.
public func makeMentionSuggestionsDataSource(for typingMention: String) -> ChatMessageComposerSuggestionsMentionDataSource? {
guard let channel = channelController?.channel else {
return nil
}

guard let currentUserId = channelController?.client.currentUserId else {
return nil
}

let trimmedTypingMention = typingMention.trimmingCharacters(in: .whitespacesAndNewlines)
let mentionedUsersNames = content.mentionedUsers.map(\.name)
let mentionedUsersIds = content.mentionedUsers.map(\.id)
let mentionIsAlreadyPresent = mentionedUsersNames.contains(trimmedTypingMention) || mentionedUsersIds.contains(trimmedTypingMention)
let shouldShowEmptyMentions = typingMention.isEmpty || mentionIsAlreadyPresent

// Because we re-create the ChatMessageComposerSuggestionsMentionDataSource always from scratch
// We lose the results of the previous search query, so we need to provide it manually.
let initialUsers: (String, [ChatUser]) -> [ChatUser] = { previousQuery, previousResult in
if typingMention.isEmpty {
return []
}
if typingMention.hasPrefix(previousQuery) || previousQuery.hasPrefix(typingMention) {
return previousResult
}
return []
}

if mentionAllAppUsers {
var previousResult = userSearchController.userArray
let previousQuery = (userSearchController?.query?.filter?.value as? String) ?? ""
if shouldShowEmptyMentions {
userSearchController.clearResults()
previousResult = []
} else {
userSearchController.search(
query: queryForMentionSuggestionsSearch(typingMention: typingMention)
)
}
return ChatMessageComposerSuggestionsMentionDataSource(
collectionView: suggestionsVC.collectionView,
searchController: userSearchController,
memberListController: nil,
initialUsers: initialUsers(previousQuery, previousResult)
)
}

let memberCount = channel.memberCount
if memberCount > channel.lastActiveMembers.count {
var previousResult = Array(memberListController?.members ?? [])
let previousQuery = (memberListController?.query.filter?.value as? String) ?? ""
memberListController = makeMemberListControllerForMemberSuggestions(typingMention: typingMention)
if shouldShowEmptyMentions {
memberListController = nil
previousResult = []
} else {
memberListController?.synchronize()
}
return ChatMessageComposerSuggestionsMentionDataSource(
collectionView: suggestionsVC.collectionView,
searchController: userSearchController,
memberListController: memberListController,
initialUsers: initialUsers(previousQuery, previousResult)
)
}

let usersCache = searchUsers(
channel.lastActiveMembers,
by: typingMention,
excludingId: currentUserId
)
return ChatMessageComposerSuggestionsMentionDataSource(
collectionView: suggestionsVC.collectionView,
searchController: userSearchController,
memberListController: nil,
initialUsers: usersCache
)
}

/// Provides the mention text for composer text field, when the user selects a mention suggestion.
open func mentionText(for user: ChatUser) -> String {
if let name = user.name, !name.isEmpty {
Expand Down
Loading

0 comments on commit e25100b

Please sign in to comment.