mirror of
https://github.com/vector-im/element-ios.git
synced 2024-09-28 23:32:41 +00:00
#1098 - Working real user suggestion inside the main application.
This commit is contained in:
parent
109a4e7157
commit
9b3e68a3db
12 changed files with 191 additions and 57 deletions
|
@ -225,6 +225,7 @@ internal struct ImageAsset {
|
|||
internal typealias Image = UIImage
|
||||
#endif
|
||||
|
||||
@available(iOS 8.0, tvOS 9.0, watchOS 2.0, macOS 10.7, *)
|
||||
internal var image: Image {
|
||||
let bundle = BundleToken.bundle
|
||||
#if os(iOS) || os(tvOS)
|
||||
|
@ -236,13 +237,25 @@ internal struct ImageAsset {
|
|||
let image = Image(named: name)
|
||||
#endif
|
||||
guard let result = image else {
|
||||
fatalError("Unable to load image named \(name).")
|
||||
fatalError("Unable to load image asset named \(name).")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
#if os(iOS) || os(tvOS)
|
||||
@available(iOS 8.0, tvOS 9.0, *)
|
||||
internal func image(compatibleWith traitCollection: UITraitCollection) -> Image {
|
||||
let bundle = BundleToken.bundle
|
||||
guard let result = Image(named: name, in: bundle, compatibleWith: traitCollection) else {
|
||||
fatalError("Unable to load image asset named \(name).")
|
||||
}
|
||||
return result
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
internal extension ImageAsset.Image {
|
||||
@available(iOS 8.0, tvOS 9.0, watchOS 2.0, *)
|
||||
@available(macOS, deprecated,
|
||||
message: "This initializer is unsafe on macOS, please use the ImageAsset.image property")
|
||||
convenience init!(asset: ImageAsset) {
|
||||
|
|
|
@ -137,7 +137,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
@interface RoomViewController () <UISearchBarDelegate, UIGestureRecognizerDelegate, UIScrollViewAccessibilityDelegate, RoomTitleViewTapGestureDelegate, RoomParticipantsViewControllerDelegate, MXKRoomMemberDetailsViewControllerDelegate, ContactsTableViewControllerDelegate, MXServerNoticesDelegate, RoomContextualMenuViewControllerDelegate,
|
||||
ReactionsMenuViewModelCoordinatorDelegate, EditHistoryCoordinatorBridgePresenterDelegate, MXKDocumentPickerPresenterDelegate, EmojiPickerCoordinatorBridgePresenterDelegate,
|
||||
ReactionHistoryCoordinatorBridgePresenterDelegate, CameraPresenterDelegate, MediaPickerCoordinatorBridgePresenterDelegate,
|
||||
RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate, SpaceDetailPresenterDelegate>
|
||||
RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate, SpaceDetailPresenterDelegate, UserSuggestionCoordinatorBridgeDelegate>
|
||||
{
|
||||
|
||||
// The preview header
|
||||
|
@ -1044,6 +1044,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
|
||||
_userSuggestionCoordinator = [[UserSuggestionCoordinatorBridge alloc] initWithMediaManager:self.roomDataSource.mxSession.mediaManager
|
||||
room:dataSource.room];
|
||||
_userSuggestionCoordinator.delegate = self;
|
||||
}
|
||||
|
||||
- (void)onRoomDataSourceReady
|
||||
|
@ -4226,9 +4227,9 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
[self cancelEventSelection];
|
||||
}
|
||||
|
||||
- (void)roomInputToolbarViewDidRequestUserSuggestions:(MXKRoomInputToolbarView *)toolbarView
|
||||
- (void)roomInputToolbarViewDidChangeTextMessage:(MXKRoomInputToolbarView *)toolbarView
|
||||
{
|
||||
|
||||
[self.userSuggestionCoordinator processTextMessage:toolbarView.textMessage];
|
||||
}
|
||||
|
||||
#pragma mark - MXKRoomMemberDetailsViewControllerDelegate
|
||||
|
@ -6547,4 +6548,22 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
|
|||
[[LegacyAppDelegate theDelegate] openSpaceWithId:spaceId];
|
||||
}
|
||||
|
||||
#pragma mark - UserSuggestionCoordinatorBridgeDelegate
|
||||
|
||||
- (void)userSuggestionCoordinatorBridge:(UserSuggestionCoordinatorBridge *)coordinator
|
||||
didRequestMentionForMember:(MXRoomMember *)member
|
||||
textTrigger:(NSString *)textTrigger
|
||||
{
|
||||
if (textTrigger.length) {
|
||||
NSString *textMessage = [self.inputToolbarView textMessage];
|
||||
textMessage = [textMessage stringByReplacingOccurrencesOfString:textTrigger
|
||||
withString:@""
|
||||
options:NSBackwardsSearch | NSAnchoredSearch
|
||||
range:NSMakeRange(0, textMessage.length)];
|
||||
[self.inputToolbarView setTextMessage:textMessage];
|
||||
}
|
||||
|
||||
[self mention:member];
|
||||
}
|
||||
|
||||
@end
|
||||
|
|
|
@ -325,10 +325,6 @@ const CGFloat kComposerContainerTrailingPadding = 12;
|
|||
NSString *newText = [textView.text stringByReplacingCharactersInRange:range withString:text];
|
||||
[self updateUIWithTextMessage:newText animated:YES];
|
||||
|
||||
if ([text isEqualToString:@"@"]) {
|
||||
[self.delegate roomInputToolbarViewDidRequestUserSuggestions:self];
|
||||
}
|
||||
|
||||
return YES;
|
||||
}
|
||||
|
||||
|
@ -344,6 +340,8 @@ const CGFloat kComposerContainerTrailingPadding = 12;
|
|||
{
|
||||
[self.delegate roomInputToolbarView:self isTyping:(self.textMessage.length > 0 ? YES : NO)];
|
||||
}
|
||||
|
||||
[self.delegate roomInputToolbarViewDidChangeTextMessage:self];
|
||||
}
|
||||
|
||||
- (void)textViewDidChangeHeight:(GrowingTextView *)textView height:(CGFloat)height
|
||||
|
|
|
@ -20,6 +20,13 @@ import Foundation
|
|||
import UIKit
|
||||
import SwiftUI
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
protocol UserSuggestionCoordinatorDelegate: AnyObject {
|
||||
func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator,
|
||||
didRequestMentionForMember member: MXRoomMember,
|
||||
textTrigger: String?)
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
final class UserSuggestionCoordinator: Coordinator {
|
||||
|
||||
|
@ -30,7 +37,7 @@ final class UserSuggestionCoordinator: Coordinator {
|
|||
private let parameters: UserSuggestionCoordinatorParameters
|
||||
private let userSuggestionHostingController: UIViewController
|
||||
|
||||
private var userSuggestionService: UserSuggestionServiceProtocol
|
||||
private var userSuggestionService: UserSuggestionService
|
||||
private var userSuggestionViewModel: UserSuggestionViewModelProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
@ -39,6 +46,8 @@ final class UserSuggestionCoordinator: Coordinator {
|
|||
var childCoordinators: [Coordinator] = []
|
||||
var completion: (() -> Void)?
|
||||
|
||||
weak var delegate: UserSuggestionCoordinatorDelegate?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
|
@ -47,15 +56,33 @@ final class UserSuggestionCoordinator: Coordinator {
|
|||
|
||||
userSuggestionService = UserSuggestionService(room: parameters.room)
|
||||
userSuggestionViewModel = UserSuggestionViewModel.makeUserSuggestionViewModel(userSuggestionService: userSuggestionService)
|
||||
|
||||
|
||||
let view = UserSuggestionList(viewModel: userSuggestionViewModel.context)
|
||||
.addDependency(AvatarService.instantiate(mediaManager: parameters.mediaManager))
|
||||
|
||||
userSuggestionHostingController = UIHostingController(rootView: view)
|
||||
|
||||
userSuggestionViewModel.completion = { [weak self] result in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
switch result {
|
||||
case .selectedItemWithIdentifier(let identifier):
|
||||
guard let member = self.userSuggestionService.roomMemberForIdentifier(identifier) else {
|
||||
return
|
||||
}
|
||||
|
||||
self.delegate?.userSuggestionCoordinator(self,
|
||||
didRequestMentionForMember: member,
|
||||
textTrigger: self.userSuggestionService.currentTextTrigger)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func processPartialUserName(_ userName: String) {
|
||||
userSuggestionService.processPartialUserName(userName)
|
||||
func processTextMessage(_ textMessage: String) {
|
||||
userSuggestionService.processTextMessage(textMessage)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
|
|
@ -16,6 +16,13 @@
|
|||
|
||||
import Foundation
|
||||
|
||||
@objc
|
||||
protocol UserSuggestionCoordinatorBridgeDelegate: AnyObject {
|
||||
func userSuggestionCoordinatorBridge(_ coordinator: UserSuggestionCoordinatorBridge,
|
||||
didRequestMentionForMember member: MXRoomMember,
|
||||
textTrigger: String?)
|
||||
}
|
||||
|
||||
@objcMembers
|
||||
final class UserSuggestionCoordinatorBridge: NSObject {
|
||||
|
||||
|
@ -25,16 +32,25 @@ final class UserSuggestionCoordinatorBridge: NSObject {
|
|||
return _userSuggestionCoordinator as! UserSuggestionCoordinator
|
||||
}
|
||||
|
||||
weak var delegate: UserSuggestionCoordinatorBridgeDelegate?
|
||||
|
||||
init(mediaManager: MXMediaManager, room: MXRoom) {
|
||||
let parameters = UserSuggestionCoordinatorParameters(mediaManager: mediaManager, room: room)
|
||||
if #available(iOS 14.0, *) {
|
||||
self._userSuggestionCoordinator = UserSuggestionCoordinator(parameters: parameters)
|
||||
let userSuggestionCoordinator = UserSuggestionCoordinator(parameters: parameters)
|
||||
self._userSuggestionCoordinator = userSuggestionCoordinator
|
||||
}
|
||||
|
||||
super.init()
|
||||
|
||||
if #available(iOS 14.0, *) {
|
||||
userSuggestionCoordinator.delegate = self
|
||||
}
|
||||
}
|
||||
|
||||
func processPartialUserName(_ userName: String) {
|
||||
func processTextMessage(_ textMessage: String) {
|
||||
if #available(iOS 14.0, *) {
|
||||
return self.userSuggestionCoordinator.processPartialUserName(userName)
|
||||
return self.userSuggestionCoordinator.processTextMessage(textMessage)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -46,3 +62,14 @@ final class UserSuggestionCoordinatorBridge: NSObject {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
extension UserSuggestionCoordinatorBridge: UserSuggestionCoordinatorDelegate {
|
||||
func userSuggestionCoordinator(_ coordinator: UserSuggestionCoordinator,
|
||||
didRequestMentionForMember member: MXRoomMember,
|
||||
textTrigger: String?) {
|
||||
delegate?.userSuggestionCoordinatorBridge(self,
|
||||
didRequestMentionForMember: member,
|
||||
textTrigger: textTrigger)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -20,5 +20,5 @@ import Foundation
|
|||
|
||||
@available(iOS 14, *)
|
||||
enum UserSuggestionViewModelResult {
|
||||
case selectedItem(UserSuggestionItemProtocol)
|
||||
case selectedItemWithIdentifier(String)
|
||||
}
|
||||
|
|
|
@ -19,6 +19,13 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
struct UserSuggestionServiceItem: UserSuggestionItemProtocol {
|
||||
let userId: String
|
||||
let displayName: String?
|
||||
let avatarUrl: String?
|
||||
}
|
||||
|
||||
@available(iOS 14.0, *)
|
||||
class UserSuggestionService: UserSuggestionServiceProtocol {
|
||||
|
||||
|
@ -29,10 +36,12 @@ class UserSuggestionService: UserSuggestionServiceProtocol {
|
|||
private let room: MXRoom
|
||||
|
||||
private var suggestionItems: [UserSuggestionItemProtocol] = []
|
||||
private var roomJoinedMembers: [MXRoomMember] = []
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var items: CurrentValueSubject<[UserSuggestionItemProtocol], Never>
|
||||
var currentTextTrigger: String?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
|
@ -40,26 +49,64 @@ class UserSuggestionService: UserSuggestionServiceProtocol {
|
|||
self.room = room
|
||||
self.items = CurrentValueSubject([])
|
||||
|
||||
generateUsersWithCount(10)
|
||||
items.send(suggestionItems)
|
||||
self.room.members { [weak self] members in
|
||||
guard let self = self, let joinedMembers = members?.joinedMembers else {
|
||||
return
|
||||
}
|
||||
|
||||
self.roomJoinedMembers = joinedMembers
|
||||
|
||||
self.suggestionItems = joinedMembers.map { member in
|
||||
UserSuggestionServiceItem(userId: member.userId, displayName: member.displayname, avatarUrl: member.avatarUrl)
|
||||
}
|
||||
} lazyLoadedMembers: { [weak self] lazyMembers in
|
||||
guard let self = self, let joinedMembers = lazyMembers?.joinedMembers else {
|
||||
return
|
||||
}
|
||||
|
||||
self.roomJoinedMembers = joinedMembers
|
||||
|
||||
self.suggestionItems = joinedMembers.map { member in
|
||||
UserSuggestionServiceItem(userId: member.userId, displayName: member.displayname, avatarUrl: member.avatarUrl)
|
||||
}
|
||||
} failure: { error in
|
||||
MXLog.error("[UserSuggestionService] Failed loading room with error: \(String(describing: error))")
|
||||
}
|
||||
}
|
||||
|
||||
func processPartialUserName(_ userName: String) {
|
||||
guard userName.count > 0 else {
|
||||
items.send(suggestionItems)
|
||||
func roomMemberForIdentifier(_ identifier: String) -> MXRoomMember? {
|
||||
return roomJoinedMembers.filter { $0.userId == identifier }.first
|
||||
}
|
||||
|
||||
// MARK: - UserSuggestionServiceProtocol
|
||||
|
||||
func processTextMessage(_ textMessage: String) {
|
||||
items.send([])
|
||||
currentTextTrigger = nil
|
||||
|
||||
guard textMessage.count > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let components = textMessage.components(separatedBy: .whitespaces)
|
||||
|
||||
guard let lastComponent = components.last else {
|
||||
return
|
||||
}
|
||||
|
||||
guard lastComponent.hasPrefix("@") else {
|
||||
return
|
||||
}
|
||||
|
||||
currentTextTrigger = lastComponent
|
||||
|
||||
var partialName = lastComponent
|
||||
partialName.removeFirst()
|
||||
|
||||
items.send(suggestionItems.filter({ userSuggestion in
|
||||
return (userSuggestion.displayName?.lowercased().range(of: userName.lowercased()) != .none)
|
||||
let containedInUsername = userSuggestion.userId.lowercased().range(of: partialName.lowercased()) != .none
|
||||
let containedInDisplayName = (userSuggestion.displayName ?? "").lowercased().range(of: partialName.lowercased()) != .none
|
||||
return (containedInUsername || containedInDisplayName)
|
||||
}))
|
||||
}
|
||||
|
||||
private func generateUsersWithCount(_ count: UInt) {
|
||||
suggestionItems.removeAll()
|
||||
for _ in 0..<count {
|
||||
let identifier = "@" + UUID().uuidString
|
||||
suggestionItems.append(MockUserSuggestionServiceItem(userId: identifier, displayName: identifier, avatarUrl: "mxc://matrix.org/VyNYAgahaiAzUoOeZETtQ"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -36,8 +36,8 @@ enum MockUserSuggestionScreenState: MockScreenState, CaseIterable {
|
|||
|
||||
let listViewModel = UserSuggestionViewModel.makeUserSuggestionViewModel(userSuggestionService: service)
|
||||
|
||||
let viewModel = UserSuggestionListWithInputViewModel(listViewModel: listViewModel) { partialUserName in
|
||||
service.processPartialUserName(partialUserName)
|
||||
let viewModel = UserSuggestionListWithInputViewModel(listViewModel: listViewModel) { textMessage in
|
||||
service.processTextMessage(textMessage)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -38,14 +38,29 @@ class MockUserSuggestionService: UserSuggestionServiceProtocol {
|
|||
items.send(suggestionItems)
|
||||
}
|
||||
|
||||
func processPartialUserName(_ userName: String) {
|
||||
guard userName.count > 0 else {
|
||||
items.send(suggestionItems)
|
||||
func processTextMessage(_ textMessage: String) {
|
||||
|
||||
items.send([])
|
||||
|
||||
guard textMessage.count > 0 else {
|
||||
return
|
||||
}
|
||||
|
||||
let components = textMessage.components(separatedBy: .whitespaces)
|
||||
|
||||
guard let lastComponent = components.last else {
|
||||
return
|
||||
}
|
||||
|
||||
guard lastComponent.hasPrefix("@") else {
|
||||
return
|
||||
}
|
||||
|
||||
var partialName = lastComponent
|
||||
partialName.removeFirst()
|
||||
|
||||
items.send(suggestionItems.filter({ userSuggestion in
|
||||
return (userSuggestion.displayName?.lowercased().range(of: userName.lowercased()) != .none)
|
||||
return (userSuggestion.displayName?.lowercased().range(of: partialName.lowercased()) != .none)
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
|
@ -31,7 +31,7 @@ protocol UserSuggestionServiceProtocol {
|
|||
|
||||
var items: CurrentValueSubject<[UserSuggestionItemProtocol], Never> { get }
|
||||
|
||||
func processPartialUserName(_ userName: String)
|
||||
func processTextMessage(_ textMessage: String)
|
||||
}
|
||||
|
||||
// MARK: Avatarable
|
||||
|
|
|
@ -36,28 +36,20 @@ struct UserSuggestionList: View {
|
|||
|
||||
var body: some View {
|
||||
BackgroundView {
|
||||
ScrollViewReader { scrollViewReader in
|
||||
List(viewModel.viewState.items) { item in
|
||||
List(viewModel.viewState.items) { item in
|
||||
Button {
|
||||
viewModel.send(viewAction: .selectedItem(item))
|
||||
} label: {
|
||||
UserSuggestionListItem(
|
||||
avatar: item.avatar,
|
||||
displayName: item.displayName,
|
||||
userId: item.id
|
||||
)
|
||||
.padding([.top, .bottom], 4.0)
|
||||
.onTapGesture {
|
||||
viewModel.send(viewAction: .selectedItem(item))
|
||||
}
|
||||
}
|
||||
.environment(\.defaultMinListRowHeight, rowHeight)
|
||||
.frame(height: min(maxHeight, rowHeight * CGFloat(viewModel.viewState.items.count)))
|
||||
.onAppear(perform: {
|
||||
guard let lastItemId = viewModel.viewState.items.last?.id else {
|
||||
return
|
||||
}
|
||||
|
||||
scrollViewReader.scrollTo(lastItemId)
|
||||
})
|
||||
}
|
||||
.environment(\.defaultMinListRowHeight, rowHeight)
|
||||
.frame(height: min(maxHeight, rowHeight * CGFloat(viewModel.viewState.items.count)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,10 +42,6 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo
|
|||
return UserSuggestionViewModel(userSuggestionService: userSuggestionService)
|
||||
}
|
||||
|
||||
deinit {
|
||||
print("well shit")
|
||||
}
|
||||
|
||||
private init(userSuggestionService: UserSuggestionServiceProtocol) {
|
||||
self.userSuggestionService = userSuggestionService
|
||||
super.init(initialViewState: Self.defaultState(userSuggestionService: userSuggestionService))
|
||||
|
@ -71,8 +67,8 @@ class UserSuggestionViewModel: UserSuggestionViewModelType, UserSuggestionViewMo
|
|||
|
||||
override func process(viewAction: UserSuggestionViewAction) {
|
||||
switch viewAction {
|
||||
case .selectedItem(_):
|
||||
break
|
||||
case .selectedItem(let item):
|
||||
completion?(.selectedItemWithIdentifier(item.id))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue