diff --git a/ElementX/Sources/AppCoordinator.swift b/ElementX/Sources/AppCoordinator.swift index e59f8768f..e858e4e6c 100644 --- a/ElementX/Sources/AppCoordinator.swift +++ b/ElementX/Sources/AppCoordinator.swift @@ -23,7 +23,7 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { init() { splashViewController = SplashViewController() mainNavigationController = UINavigationController(rootViewController: splashViewController) - mainNavigationController.setNavigationBarHidden(true, animated: false) + mainNavigationController.navigationBar.isHidden = true window = UIWindow(frame: UIScreen.main.bounds) window.rootViewController = mainNavigationController @@ -46,6 +46,10 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { // MARK: - AuthenticationCoordinatorDelegate + func authenticationCoordinatorDidStartLoading(_ authenticationCoordinator: AuthenticationCoordinator) { + + } + func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator, didFailWithError error: AuthenticationCoordinatorError) { } @@ -55,7 +59,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { } func authenticationCoordinatorDidTearDownUserSession(_ authenticationCoordinator: AuthenticationCoordinator) { - + mainNavigationController.setViewControllers([splashViewController], animated: false) + authenticationCoordinator.start() } // MARK: - Private @@ -68,6 +73,13 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator { let parameters = HomeScreenCoordinatorParameters(userSession: userSession) let coordinator = HomeScreenCoordinator(parameters: parameters) + coordinator.completion = { [weak self] result in + switch result { + case .logout: + self?.authenticationCoordinator.logout() + } + } + add(childCoordinator: coordinator) navigationRouter.setRootModule(coordinator) } diff --git a/ElementX/Sources/Modules/Authentication/AuthenticationCoordinator.swift b/ElementX/Sources/Modules/Authentication/AuthenticationCoordinator.swift index d50ae82a9..f3aa03723 100644 --- a/ElementX/Sources/Modules/Authentication/AuthenticationCoordinator.swift +++ b/ElementX/Sources/Modules/Authentication/AuthenticationCoordinator.swift @@ -15,6 +15,9 @@ enum AuthenticationCoordinatorError: Error { } protocol AuthenticationCoordinatorDelegate: AnyObject { + + func authenticationCoordinatorDidStartLoading(_ authenticationCoordinator: AuthenticationCoordinator) + func authenticationCoordinatorDidSetupUserSession(_ authenticationCoordinator: AuthenticationCoordinator) func authenticationCoordinatorDidTearDownUserSession(_ authenticationCoordinator: AuthenticationCoordinator) @@ -40,19 +43,46 @@ class AuthenticationCoordinator: Coordinator { } func start() { + + delegate?.authenticationCoordinatorDidStartLoading(self) + let availableRestoreTokens = keychainController.restoreTokens() guard let usernameTokenTuple = availableRestoreTokens.first else { - startNewLoginFlow() + startNewLoginFlow { result in + switch result { + case .success: + self.delegate?.authenticationCoordinatorDidSetupUserSession(self) + case .failure(let error): + self.delegate?.authenticationCoordinator(self, didFailWithError: error) + MXLog.error("Failed logging in user with error: \(error)") + } + } return } - restorePreviousLogin(usernameTokenTuple) + restorePreviousLogin(usernameTokenTuple) { [weak self] result in + guard let self = self else { return } + + switch result { + case .success: + self.delegate?.authenticationCoordinatorDidSetupUserSession(self) + case .failure(let error): + self.delegate?.authenticationCoordinator(self, didFailWithError: error) + MXLog.error("Failed restoring login with error: \(error)") + } + } + } + + func logout() { + keychainController.removeAllTokens() + userSession = nil + delegate?.authenticationCoordinatorDidTearDownUserSession(self) } // MARK: - Private - private func startNewLoginFlow() { + private func startNewLoginFlow(_ completion: @escaping (Result<(), AuthenticationCoordinatorError>) -> Void) { let parameters = LoginScreenCoordinatorParameters() let coordinator = LoginScreenCoordinator(parameters: parameters) @@ -63,16 +93,18 @@ class AuthenticationCoordinator: Coordinator { switch result { case .login(let result): - do { - self.setupUserSessionForClient(try loginNewClient(basePath: self.baseDirectoryPathForUsername(result.username), - username: result.username, - password: result.password)) + self.login(username: result.username, password: result.password) { [weak self] result in + guard let self = self else { return } + + switch result { + case .success: + completion(.success(())) + case .failure(let error): + completion(.failure(error)) + } self.remove(childCoordinator: coordinator) self.navigationRouter.dismissModule() - } catch { - self.delegate?.authenticationCoordinator(self, didFailWithError: .failedLoggingIn) - MXLog.error("Failed logging in user with error: \(error)") } } } @@ -83,13 +115,42 @@ class AuthenticationCoordinator: Coordinator { coordinator.start() } - private func restorePreviousLogin(_ usernameTokenTuple: (username: String, token: String)) { - do { - setupUserSessionForClient(try loginWithToken(basePath: baseDirectoryPathForUsername(usernameTokenTuple.username), - restoreToken: usernameTokenTuple.token)) - } catch { - delegate?.authenticationCoordinator(self, didFailWithError: .failedRestoringLogin) - MXLog.error("Failed restoring login with error: \(error)") + private func login(username: String, password: String, completion: @escaping (Result) -> Void) { + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self = self else { return } + + do { + self.setupUserSessionForClient(try loginNewClient(basePath: self.baseDirectoryPathForUsername(username), + username: username, + password: password)) + + DispatchQueue.main.async { + completion(.success(())) + } + } catch { + DispatchQueue.main.async { + completion(.failure(.failedLoggingIn)) + } + } + } + } + + private func restorePreviousLogin(_ usernameTokenTuple: (username: String, token: String), completion: @escaping (Result) -> Void) { + DispatchQueue.global(qos: .background).async { [weak self] in + guard let self = self else { return } + + do { + self.setupUserSessionForClient(try loginWithToken(basePath: self.baseDirectoryPathForUsername(usernameTokenTuple.username), + restoreToken: usernameTokenTuple.token)) + + DispatchQueue.main.async { + completion(.success(())) + } + } catch { + DispatchQueue.main.async { + completion(.failure(.failedRestoringLogin)) + } + } } } @@ -106,7 +167,6 @@ class AuthenticationCoordinator: Coordinator { } userSession = UserSession(client: client) - delegate?.authenticationCoordinatorDidSetupUserSession(self) } private func baseDirectoryPathForUsername(_ username: String) -> String { diff --git a/ElementX/Sources/Modules/Authentication/LoginScreen/View/LoginScreen.swift b/ElementX/Sources/Modules/Authentication/LoginScreen/View/LoginScreen.swift index 3ead27259..3250d9856 100644 --- a/ElementX/Sources/Modules/Authentication/LoginScreen/View/LoginScreen.swift +++ b/ElementX/Sources/Modules/Authentication/LoginScreen/View/LoginScreen.swift @@ -38,6 +38,7 @@ struct LoginScreen: View { .padding(.horizontal, 8.0) .navigationTitle("Login") .navigationBarTitleDisplayMode(.inline) + .navigationViewStyle(StackNavigationViewStyle()) } } } diff --git a/ElementX/Sources/Modules/Authentication/UserSession.swift b/ElementX/Sources/Modules/Authentication/UserSession.swift index 9619201e1..0a742e86a 100644 --- a/ElementX/Sources/Modules/Authentication/UserSession.swift +++ b/ElementX/Sources/Modules/Authentication/UserSession.swift @@ -7,33 +7,41 @@ import Foundation import MatrixRustSDK +import Combine +import UIKit -class UserSession { +enum UserSessionCallback { + case updatedData +} + +enum UserSessionError: Error { + case failedRetrievingAvatar +} + +class UserSession: ClientDelegate { private let client: Client + let callbacks = PassthroughSubject() + init(client: Client) { self.client = client if !client.hasFirstSynced() { - MXLog.info("Started initial sync") - client.startSync() - MXLog.info("Finished intial sync") + client.startSync(delegate: self) } } - func roomList() -> [RoomModel] { - client.conversations().compactMap { room in - do { - return RoomModel(displayName: try room.displayName()) - } catch { - MXLog.error("Failed retrieving room info with error: \(error)") - return nil - } + var userIdentifier: String { + do { + return try client.userId() + } catch { + MXLog.error("Failed retrieving room info with error: \(error)") + return "Unknown user identifier" } } - var displayName: String? { + var userDisplayName: String? { do { return try client.displayName() } catch { @@ -41,4 +49,42 @@ class UserSession { return nil } } + + func getUserAvatar(_ completion: @escaping (Result) -> Void) { + DispatchQueue.global(qos: .background).async { + do { + let avatarData = try self.client.avatar() + DispatchQueue.main.async { + completion(.success(UIImage(data: Data(bytes: avatarData, count: avatarData.count)))) + } + } catch { + MXLog.error("Failed retrieving room name with error: \(error)") + DispatchQueue.main.async { + completion(.failure(UserSessionError.failedRetrievingAvatar)) + } + } + } + } + + func getRoomList(_ completion: @escaping ([RoomModel]) -> Void) { + DispatchQueue.global(qos: .background).async { + let conversations = self.client.conversations() + + let rooms = conversations.map { + return RoomModel(room: $0) + } + + DispatchQueue.main.async { + completion(rooms) + } + } + } + + // MARK: ClientDelegate + + func didReceiveSyncUpdate() { + DispatchQueue.main.async { + self.callbacks.send(.updatedData) + } + } } diff --git a/ElementX/Sources/Modules/HomeScreen/HomeScreenCoordinator.swift b/ElementX/Sources/Modules/HomeScreen/HomeScreenCoordinator.swift index 6bfec026c..e714b9eec 100644 --- a/ElementX/Sources/Modules/HomeScreen/HomeScreenCoordinator.swift +++ b/ElementX/Sources/Modules/HomeScreen/HomeScreenCoordinator.swift @@ -15,11 +15,16 @@ // import SwiftUI +import Combine struct HomeScreenCoordinatorParameters { let userSession: UserSession } +enum HomeScreenCoordinatorResult { + case logout +} + final class HomeScreenCoordinator: Coordinator, Presentable { // MARK: - Properties @@ -27,30 +32,52 @@ final class HomeScreenCoordinator: Coordinator, Presentable { // MARK: Private private let parameters: HomeScreenCoordinatorParameters - private let homeScreenHostingController: UIViewController - private var homeScreenViewModel: HomeScreenViewModelProtocol + private let hostingController: UIViewController + private var viewModel: HomeScreenViewModelProtocol + + private var cancellables = Set() // MARK: Public // Must be used only internally var childCoordinators: [Coordinator] = [] - var completion: ((HomeScreenViewModelResult) -> Void)? + var completion: ((HomeScreenCoordinatorResult) -> Void)? // MARK: - Setup - @available(iOS 14.0, *) init(parameters: HomeScreenCoordinatorParameters) { self.parameters = parameters - let viewModel = HomeScreenViewModel(username: self.parameters.userSession.displayName ?? "💥") - let view = HomeScreen(context: viewModel.context) - homeScreenViewModel = viewModel - homeScreenHostingController = UIHostingController(rootView: view) + let userDisplayName = self.parameters.userSession.userDisplayName ?? self.parameters.userSession.userIdentifier + viewModel = HomeScreenViewModel(userDisplayName: userDisplayName) - homeScreenViewModel.completion = { [weak self] result in + let view = HomeScreen(context: viewModel.context) + hostingController = UIHostingController(rootView: view) + + viewModel.completion = { [weak self] result in guard let self = self else { return } - self.completion?(result) + + switch result { + case .logout: + self.completion?(.logout) + case .loadUserAvatar: + self.parameters.userSession.getUserAvatar({ result in + switch result { + case .success(let avatar): + self.viewModel.updateWithUserAvatar(avatar) + default: + break + } + }) + } } + + parameters.userSession.callbacks.sink { [weak self] result in + switch result { + case .updatedData: + self?.updateRoomsList() + } + }.store(in: &cancellables) } // MARK: - Public @@ -59,6 +86,14 @@ final class HomeScreenCoordinator: Coordinator, Presentable { } func toPresentable() -> UIViewController { - return self.homeScreenHostingController + return self.hostingController + } + + // MARK: - Private + + func updateRoomsList() { + parameters.userSession.getRoomList { [weak self] rooms in + self?.viewModel.updateWithRoomList(rooms) + } } } diff --git a/ElementX/Sources/Modules/HomeScreen/HomeScreenModels.swift b/ElementX/Sources/Modules/HomeScreen/HomeScreenModels.swift index 0522ada4d..a483d96d5 100644 --- a/ElementX/Sources/Modules/HomeScreen/HomeScreenModels.swift +++ b/ElementX/Sources/Modules/HomeScreen/HomeScreenModels.swift @@ -15,17 +15,43 @@ // import Foundation +import UIKit enum HomeScreenViewModelResult { case logout -} - -// MARK: View - -struct HomeScreenViewState: BindableState { - let username: String + case loadUserAvatar } enum HomeScreenViewAction { case logout + case loadUserAvatar + case loadRoomAvatar(roomId: String) +} + +struct HomeScreenViewState: BindableState { + let userDisplayName: String + var userAvatar: UIImage? + + var rooms: [HomeScreenRoom] = [] + + var directRooms: [HomeScreenRoom] { + rooms.filter { $0.isDirect } + } + + var nondirectRooms: [HomeScreenRoom] { + rooms.filter { !$0.isDirect } + } +} + +struct HomeScreenRoom: Identifiable { + let id: String + let displayName: String + + let topic: String? + let lastMessage: String? + + var avatar: UIImage? + + let isDirect: Bool + let isEncrypted: Bool } diff --git a/ElementX/Sources/Modules/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Modules/HomeScreen/HomeScreenViewModel.swift index fda288ab6..56c0858bd 100644 --- a/ElementX/Sources/Modules/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Modules/HomeScreen/HomeScreenViewModel.swift @@ -18,28 +18,70 @@ import SwiftUI @available(iOS 14, *) typealias HomeScreenViewModelType = StateStoreViewModel + Never, + HomeScreenViewAction> @available(iOS 14, *) class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol { - + // MARK: - Properties - + // MARK: Private + + private var roomList: [RoomModelProtocol]? // MARK: Public var completion: ((HomeScreenViewModelResult) -> Void)? - + // MARK: - Setup - - init(username: String) { - super.init(initialViewState: HomeScreenViewState(username: username)) + + init(userDisplayName: String) { + super.init(initialViewState: HomeScreenViewState(userDisplayName: userDisplayName)) } - + // MARK: - Public - + override func process(viewAction: HomeScreenViewAction) { - + switch viewAction { + case .logout: + self.completion?(.logout) + case .loadRoomAvatar(let roomId): + guard let room = roomList?.filter({ $0.identifier == roomId }).first else { + break + } + + room.getAvatar { [weak self] result in + guard let self = self else { return } + + switch result { + case .success(let image): + guard let index = self.state.rooms.firstIndex(where: { $0.id == roomId }) else { + return + } + + self.state.rooms[index].avatar = image + default: + break + } + } + case .loadUserAvatar: + self.completion?(.loadUserAvatar) + } + } + + func updateWithRoomList(_ roomList: [RoomModelProtocol]) { + self.roomList = roomList + state.rooms = roomList.map { roomModel in + HomeScreenRoom(id: roomModel.identifier, + displayName: roomModel.displayName, + topic: roomModel.topic, + lastMessage: roomModel.lastMessage, + isDirect: roomModel.isDirect, + isEncrypted: roomModel.isEncrypted) + } + } + + func updateWithUserAvatar(_ avatar: UIImage?) { + self.state.userAvatar = avatar } } diff --git a/ElementX/Sources/Modules/HomeScreen/HomeScreenViewModelProtocol.swift b/ElementX/Sources/Modules/HomeScreen/HomeScreenViewModelProtocol.swift index 45d1bd509..ee80a0e67 100644 --- a/ElementX/Sources/Modules/HomeScreen/HomeScreenViewModelProtocol.swift +++ b/ElementX/Sources/Modules/HomeScreen/HomeScreenViewModelProtocol.swift @@ -15,10 +15,14 @@ // import Foundation +import UIKit protocol HomeScreenViewModelProtocol { - var completion: ((HomeScreenViewModelResult) -> Void)? { get set } - @available(iOS 14, *) + var context: HomeScreenViewModelType.Context { get } + + func updateWithRoomList(_ roomList: [RoomModelProtocol]) + + func updateWithUserAvatar(_ avatar: UIImage?) } diff --git a/ElementX/Sources/Modules/HomeScreen/View/HomeScreen.swift b/ElementX/Sources/Modules/HomeScreen/View/HomeScreen.swift index 87fae221c..c2108bf5a 100644 --- a/ElementX/Sources/Modules/HomeScreen/View/HomeScreen.swift +++ b/ElementX/Sources/Modules/HomeScreen/View/HomeScreen.swift @@ -16,7 +16,6 @@ import SwiftUI -@available(iOS 14.0, *) struct HomeScreen: View { @ObservedObject var context: HomeScreenViewModel.Context @@ -24,18 +23,110 @@ struct HomeScreen: View { // MARK: Views var body: some View { - VStack { - Text("Hello, \(context.viewState.username)!") + NavigationView { + VStack(spacing: 16.0) { + HStack { + if let avatar = context.viewState.userAvatar { + Image(uiImage: avatar) + .resizable() + .scaledToFill() + .frame(width: 40, height: 40, alignment: .center) + } else { + let _ = context.send(viewAction: .loadUserAvatar) + } + Text("Hello, \(context.viewState.userDisplayName)!") + .font(.subheadline) + .fontWeight(.bold) + } + .padding(.vertical, 32.0) + + List { + Section("People") { + ForEach(context.viewState.directRooms) { room in + RoomCell(room: room, context: context) + } + } + + Section("Rooms") { + ForEach(context.viewState.nondirectRooms) { room in + RoomCell(room: room, context: context) + } + } + } + .headerProminence(.increased) + .listStyle(.plain) + } + .navigationBarTitleDisplayMode(.inline) + .navigationViewStyle(StackNavigationViewStyle()) + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Logout") { + context.send(viewAction: .logout) + } + } + } } } } +struct RoomCell: View { + + let room: HomeScreenRoom + let context: HomeScreenViewModel.Context + + var body: some View { + HStack(spacing: 16.0) { + if let avatar = room.avatar { + Image(uiImage: avatar) + .resizable() + .scaledToFill() + .frame(width: 40, height: 40) + } else { + let _ = context.send(viewAction: .loadRoomAvatar(roomId: room.id)) + Image(systemName: "person.3") + .frame(width: 40, height: 40) + } + + VStack(alignment: .leading, spacing: 4.0) { + Text(roomName(room)) + .font(.headline) + .fontWeight(.regular) + + if let roomTopic = room.topic, roomTopic.count > 0 { + Text(roomTopic) + .font(.footnote) + .fontWeight(.bold) + .lineLimit(1) + } + + if let lastMessage = room.lastMessage { + Text(lastMessage) + .font(.footnote) + .fontWeight(.medium) + .lineLimit(1) + } + } + } + .frame(minHeight: 60.0) + } + + private func roomName(_ room: HomeScreenRoom) -> String { + room.displayName + (room.isEncrypted ? "🛡": "") + } +} + // MARK: - Previews -@available(iOS 14.0, *) struct HomeScreen_Previews: PreviewProvider { static var previews: some View { - let viewModel = HomeScreenViewModel(username: "Johnny Appleseed") - HomeScreen(context: viewModel.context) + let viewModel = HomeScreenViewModel(userDisplayName: "Johnny Appleseed") + + let rooms = [MockRoomModel(displayName: "Alfa"), + MockRoomModel(displayName: "Beta"), + MockRoomModel(displayName: "Omega")] + + viewModel.updateWithRoomList(rooms) + + return HomeScreen(context: viewModel.context) } } diff --git a/ElementX/Sources/Modules/Models/MockRoomModel.swift b/ElementX/Sources/Modules/Models/MockRoomModel.swift new file mode 100644 index 000000000..4c1de709e --- /dev/null +++ b/ElementX/Sources/Modules/Models/MockRoomModel.swift @@ -0,0 +1,29 @@ +// +// MockRoomModel.swift +// ElementX +// +// Created by Stefan Ceriu on 17.02.2022. +// + +import Foundation +import UIKit + +struct MockRoomModel: RoomModelProtocol { + let identifier = UUID().uuidString + let name: String? = nil + let displayName: String + + let topic: String? = nil + let lastMessage: String? = "Last message" + + let avatarURL: URL? = nil + + let isDirect = Bool.random() + let isSpace = Bool.random() + let isPublic = Bool.random() + let isEncrypted = Bool.random() + + func getAvatar(_ completion: (Result) -> Void) { + completion(.success(UIImage(systemName: "wand.and.stars"))) + } +} diff --git a/ElementX/Sources/Modules/Models/RoomModel.swift b/ElementX/Sources/Modules/Models/RoomModel.swift index f90f79bba..dc61fa86e 100644 --- a/ElementX/Sources/Modules/Models/RoomModel.swift +++ b/ElementX/Sources/Modules/Models/RoomModel.swift @@ -7,7 +7,86 @@ import Foundation import UIKit +import MatrixRustSDK -struct RoomModel { - let displayName: String +enum RoomModelError: Error { + case failedRetrievingAvatar +} + +struct RoomModel: RoomModelProtocol { + + private let room: Room + + init(room: Room) { + self.room = room + } + + var identifier: String { + return room.identifier() + } + + var isDirect: Bool { + return room.isDirect() + } + + var isPublic: Bool { + return room.isPublic() + } + + var isSpace: Bool { + return room.isSpace() + } + + var isEncrypted: Bool { + return room.isEncrypted() + } + + var name: String? { + return room.name() + } + + var displayName: String { + do { + return try room.displayName() + } catch { + MXLog.error("Failed retrieving room name with error: \(error)") + return "Error" + } + } + + var topic: String? { + return room.topic() + } + + var lastMessage: String? { + guard let lastMessage = try? room.messages().last else { + return "Last message unknown" + } + + return "\(lastMessage.sender()): \(lastMessage.content())" + } + + var avatarURL: URL? { + guard let urlString = room.avatarUrl() else { + return nil + } + + return URL(string: urlString) + } + + func getAvatar(_ completion: @escaping (Result) -> Void) { + DispatchQueue.global(qos: .background).async { + do { + let avatarData = try room.avatar() + + DispatchQueue.main.async { + completion(.success(UIImage(data: Data(bytes: avatarData, count: avatarData.count)))) + } + } catch { + DispatchQueue.main.async { + completion(.failure(RoomModelError.failedRetrievingAvatar)) + } + } + } + } } diff --git a/ElementX/Sources/Modules/Models/RoomModelProtocol.swift b/ElementX/Sources/Modules/Models/RoomModelProtocol.swift new file mode 100644 index 000000000..4bb5b8318 --- /dev/null +++ b/ElementX/Sources/Modules/Models/RoomModelProtocol.swift @@ -0,0 +1,26 @@ +// +// RoomModelProtocol.swift +// ElementX +// +// Created by Stefan Ceriu on 17.02.2022. +// + +import UIKit + +protocol RoomModelProtocol { + var identifier: String { get } + var isDirect: Bool { get } + var isPublic: Bool { get } + var isSpace: Bool { get } + var isEncrypted: Bool { get } + + var displayName: String { get } + var name: String? { get } + + var topic: String? { get } + var lastMessage: String? { get } + + var avatarURL: URL? { get } + + func getAvatar(_ completion: @escaping (Result) -> Void) +}