Added room timeline screen with live events listening and back pagination. Rearranged project files.

This commit is contained in:
Stefan Ceriu
2022-03-08 14:24:33 +02:00
parent f403c59b95
commit c22d9689d4
77 changed files with 1269 additions and 491 deletions
@@ -0,0 +1,202 @@
//
// AuthenticationCoordinator.swift
// ElementX
//
// Created by Stefan Ceriu on 11.02.2022.
//
import Foundation
import MatrixRustSDK
enum AuthenticationCoordinatorError: Error {
case failedLoggingIn
case failedRestoringLogin
case failedSettingUpSession
}
protocol AuthenticationCoordinatorDelegate: AnyObject {
func authenticationCoordinatorDidStartLoading(_ authenticationCoordinator: AuthenticationCoordinator)
func authenticationCoordinatorDidSetupUserSession(_ authenticationCoordinator: AuthenticationCoordinator)
func authenticationCoordinatorDidTearDownUserSession(_ authenticationCoordinator: AuthenticationCoordinator)
func authenticationCoordinator(_ authenticationCoordinator: AuthenticationCoordinator,
didFailWithError error: AuthenticationCoordinatorError)
}
class AuthenticationCoordinator: Coordinator {
private let keychainController: KeychainControllerProtocol
private let navigationRouter: NavigationRouter
private(set) var userSession: UserSession?
var childCoordinators: [Coordinator] = []
weak var delegate: AuthenticationCoordinatorDelegate?
init(keychainController: KeychainControllerProtocol,
navigationRouter: NavigationRouter) {
self.keychainController = keychainController
self.navigationRouter = navigationRouter
}
func start() {
let availableRestoreTokens = keychainController.restoreTokens()
guard let usernameTokenTuple = availableRestoreTokens.first else {
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) { [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()
if let userIdentifier = userSession?.userIdentifier {
deleteBaseDirectoryForUsername(userIdentifier)
}
userSession = nil
delegate?.authenticationCoordinatorDidTearDownUserSession(self)
}
// MARK: - Private
private func startNewLoginFlow(_ completion: @escaping (Result<(), AuthenticationCoordinatorError>) -> Void) {
let parameters = LoginScreenCoordinatorParameters()
let coordinator = LoginScreenCoordinator(parameters: parameters)
coordinator.completion = { [weak self, weak coordinator] result in
guard let self = self, let coordinator = coordinator else {
return
}
switch result {
case .login(let result):
self.login(username: result.username, password: result.password) { [weak self] result in
guard let self = self else { return }
switch result {
case .success:
completion(.success(()))
self.remove(childCoordinator: coordinator)
self.navigationRouter.dismissModule()
case .failure(let error):
completion(.failure(error))
}
}
}
}
add(childCoordinator: coordinator)
navigationRouter.present(coordinator)
coordinator.start()
}
private func login(username: String, password: String, completion: @escaping (Result<Void, AuthenticationCoordinatorError>) -> Void) {
delegate?.authenticationCoordinatorDidStartLoading(self)
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, AuthenticationCoordinatorError>) -> Void) {
delegate?.authenticationCoordinatorDidStartLoading(self)
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))
}
}
}
}
private func setupUserSessionForClient(_ client: Client) {
do {
let restoreToken = try client.restoreToken()
let userId = try client.userId()
keychainController.setRestoreToken(restoreToken, forUsername: userId)
} catch {
delegate?.authenticationCoordinator(self, didFailWithError: .failedSettingUpSession)
MXLog.error("Failed setting up user session with error: \(error)")
}
userSession = UserSession(client: client)
}
private func baseDirectoryPathForUsername(_ username: String) -> String {
guard var url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
fatalError("Should always be able to retrieve the caches directory")
}
url = url.appendingPathComponent(username)
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: false, attributes: nil)
return url.path
}
private func deleteBaseDirectoryForUsername(_ username: String) {
guard var url = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
fatalError("Should always be able to retrieve the caches directory")
}
url = url.appendingPathComponent(username)
try? FileManager.default.removeItem(at: url)
}
}
@@ -0,0 +1,57 @@
//
// KeychainController.swift
// ElementX
//
// Created by Stefan Ceriu on 14.02.2022.
//
import Foundation
import KeychainAccess
class KeychainController: KeychainControllerProtocol {
struct Constants {
static let restoreTokenGroupKey = "restoreTokens"
}
private let keychain: Keychain
init(identifier: String) {
keychain = Keychain(service: identifier)
}
func setRestoreToken(_ token: String, forUsername username: String) {
do {
try keychain.set(token, key: username)
} catch {
MXLog.error("Failed storing user restore token")
}
}
func restoreTokenForUsername(_ username: String) -> String? {
do {
return try keychain.get(username)
} catch {
MXLog.error("Failed retrieving user restore token")
return nil
}
}
func restoreTokens() -> [(username: String, token: String)] {
keychain.allKeys().compactMap { username in
guard let token = restoreTokenForUsername(username) else {
return nil
}
return (username, token)
}
}
func removeAllTokens() {
do {
try keychain.removeAll()
} catch {
MXLog.error("Failed removing all tokens")
}
}
}
@@ -0,0 +1,15 @@
//
// KeychainControllerProtocol.swift
// ElementX
//
// Created by Stefan Ceriu on 14.02.2022.
//
import Foundation
protocol KeychainControllerProtocol {
func setRestoreToken(_ token: String, forUsername username: String)
func restoreTokenForUsername(_ username: String) -> String?
func restoreTokens() -> [(username: String, token: String)]
func removeAllTokens()
}
@@ -0,0 +1,119 @@
//
// UserSession.swift
// ElementX
//
// Created by Stefan Ceriu on 14.02.2022.
//
import Foundation
import MatrixRustSDK
import Combine
import UIKit
enum UserSessionCallback {
case updatedRoomsList
}
enum UserSessionError: Error {
case failedRetrievingAvatar
}
private class WeakUserSessionWrapper: ClientDelegate {
private weak var userSession: UserSession?
init(userSession: UserSession) {
self.userSession = userSession
}
func didReceiveSyncUpdate() {
DispatchQueue.main.async {
self.userSession?.didReceiveSyncUpdate()
}
}
}
class UserSession: ClientDelegate {
private let client: Client
private var rooms: [RoomProxy] = [] {
didSet {
self.callbacks.send(.updatedRoomsList)
}
}
deinit {
client.setDelegate(delegate: nil)
}
let callbacks = PassthroughSubject<UserSessionCallback, Never>()
init(client: Client) {
self.client = client
client.setDelegate(delegate: WeakUserSessionWrapper(userSession: self))
client.startSync()
}
var userIdentifier: String {
do {
return try client.userId()
} catch {
MXLog.error("Failed retrieving room info with error: \(error)")
return "Unknown user identifier"
}
}
var userDisplayName: String? {
do {
return try client.displayName()
} catch {
MXLog.error("Failed retrieving room info with error: \(error)")
return nil
}
}
func loadUserAvatar(_ completion: @escaping (Result<UIImage?, Error>) -> 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 ([RoomProxyProtocol]) -> Void) {
fetchRoomList(completion)
}
// MARK: ClientDelegate
func didReceiveSyncUpdate() {
fetchRoomList { [weak self] rooms in
guard let self = self else { return }
if self.rooms != rooms {
self.rooms = rooms
}
}
}
// MARK: Private
func fetchRoomList(_ completion: @escaping ([RoomProxy]) -> Void) {
DispatchQueue.global(qos: .background).async {
let rooms = self.client.conversations().map {
return RoomProxy(room: $0)
}
DispatchQueue.main.async {
completion(rooms)
}
}
}
}
@@ -0,0 +1,44 @@
//
// MockRoomProxy.swift
// ElementX
//
// Created by Stefan Ceriu on 17.02.2022.
//
import Foundation
import UIKit
import Combine
struct MockRoomProxy: RoomProxyProtocol {
let id = 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()
var callbacks = PassthroughSubject<RoomProxyCallback, Never>()
func loadDisplayName(_ completion: @escaping (Result<String, Error>) -> Void) {
completion(.success(displayName))
}
func loadAvatar(_ completion: (Result<UIImage?, Error>) -> Void) {
completion(.success(UIImage(systemName: "wand.and.stars")))
}
func startLiveEventListener() {
}
func paginateBackwards(start: UInt, finish: UInt) {
}
}
+152
View File
@@ -0,0 +1,152 @@
//
// RoomProxy.swift
// ElementX
//
// Created by Stefan Ceriu on 14.02.2022.
//
import Foundation
import UIKit
import Combine
import MatrixRustSDK
enum RoomProxyError: Error {
case failedRetrievingDisplayName
case failedRetrievingAvatar
}
private class WeakRoomProxyWrapper: RoomDelegate {
private weak var roomProxy: RoomProxy?
init(roomProxy: RoomProxy) {
self.roomProxy = roomProxy
}
func didReceiveMessage(message: Message) {
DispatchQueue.main.async {
self.roomProxy?.appendMessage(message)
}
}
func didPaginateBackwards(messages: [Message]) {
DispatchQueue.main.async {
self.roomProxy?.preprendMessages(messages)
}
}
}
class RoomProxy: RoomProxyProtocol, Equatable {
private let room: Room
let callbacks = PassthroughSubject<RoomProxyCallback, Never>()
init(room: Room) {
self.room = room
self.room.setDelegate(delegate: WeakRoomProxyWrapper(roomProxy: self))
}
var id: String {
return room.id()
}
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 topic: String? {
return room.topic()
}
var lastMessage: String? {
didSet {
callbacks.send(.updatedLastMessage)
}
}
var avatarURL: URL? {
guard let urlString = room.avatarUrl() else {
return nil
}
return URL(string: urlString)
}
func loadDisplayName(_ completion: @escaping (Result<String, Error>) -> Void) {
DispatchQueue.global(qos: .background).async {
do {
let displayName = try self.room.displayName()
DispatchQueue.main.async {
completion(.success(displayName))
}
} catch {
DispatchQueue.main.async {
completion(.failure(RoomProxyError.failedRetrievingDisplayName))
}
}
}
}
func loadAvatar(_ completion: @escaping (Result<UIImage?, Error>) -> Void) {
DispatchQueue.global(qos: .background).async {
do {
let avatarData = try self.room.avatar()
DispatchQueue.main.async {
completion(.success(UIImage(data: Data(bytes: avatarData, count: avatarData.count))))
}
} catch {
DispatchQueue.main.async {
completion(.failure(RoomProxyError.failedRetrievingAvatar))
}
}
}
}
func startLiveEventListener() {
room.startLiveEventListener()
}
func paginateBackwards(start: UInt, finish: UInt) {
room.paginateBackwards(from: UInt8(start), to: UInt8(finish))
}
// MARK: - Equatable
static func == (lhs: RoomProxy, rhs: RoomProxy) -> Bool {
lhs.id == rhs.id
}
// MARK: - Private
fileprivate func preprendMessages(_ messages: [Message]) {
if lastMessage == nil {
lastMessage = messages.last?.content()
}
callbacks.send(.prependedMessages(messages))
}
fileprivate func appendMessage(_ message: Message) {
lastMessage = message.content()
callbacks.send(.addedMessage(message))
}
}
@@ -0,0 +1,39 @@
//
// RoomProxyProtocol.swift
// ElementX
//
// Created by Stefan Ceriu on 17.02.2022.
//
import UIKit
import Combine
import MatrixRustSDK
enum RoomProxyCallback {
case prependedMessages([Message])
case addedMessage(Message)
case updatedLastMessage
}
protocol RoomProxyProtocol {
var id: String { get }
var isDirect: Bool { get }
var isPublic: Bool { get }
var isSpace: Bool { get }
var isEncrypted: Bool { get }
var name: String? { get }
var topic: String? { get }
var lastMessage: String? { get }
var avatarURL: URL? { get }
func loadDisplayName(_ completion: @escaping (Result<String, Error>) -> Void)
func loadAvatar(_ completion: @escaping (Result<UIImage?, Error>) -> Void)
func startLiveEventListener()
func paginateBackwards(start: UInt, finish: UInt)
var callbacks: PassthroughSubject<RoomProxyCallback, Never> { get }
}
@@ -0,0 +1,20 @@
//
// MockRoomTimelineController.swift
// ElementX
//
// Created by Stefan Ceriu on 04.03.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import Combine
class MockRoomTimelineController: RoomTimelineControllerProtocol {
let timelineItems: [RoomTimelineItemProtocol] = [TextRoomTimelineItem(id: UUID().uuidString, senderDisplayName: "Anne", text: "You rock!", originServerTs: .now),
TextRoomTimelineItem(id: UUID().uuidString, senderDisplayName: "Bob", text: "You rule!", originServerTs: .now)]
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
func paginateBackwards(_ count: UInt) {
}
}
@@ -0,0 +1,48 @@
//
// RoomTimelineController.swift
// ElementX
//
// Created by Stefan Ceriu on 04.03.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import Combine
import MatrixRustSDK
enum RoomTimelineControllerCallback {
case updatedTimelineItems
}
class RoomTimelineController: RoomTimelineControllerProtocol {
private let timelineProvider: RoomTimelineProvider
private var cancellables = Set<AnyCancellable>()
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
private(set) var timelineItems = [RoomTimelineItemProtocol]()
init(timelineProvider: RoomTimelineProvider) {
self.timelineProvider = timelineProvider
self.timelineProvider.callbacks.sink { [weak self] callback in
guard let self = self else { return }
switch callback {
case .updatedMessages:
self.timelineItems = self.timelineProvider.messages.map { message in
let timestamp = Date(timeIntervalSince1970: TimeInterval(message.originServerTs()))
return TextRoomTimelineItem(id: message.id(),
senderDisplayName: message.sender(),
text: message.content(),
originServerTs: timestamp)
}
self.callbacks.send(.updatedTimelineItems)
}
}.store(in: &cancellables)
}
func paginateBackwards(_ count: UInt) {
timelineProvider.paginateBackwards(count)
}
}
@@ -0,0 +1,17 @@
//
// RoomTimelineControllerProtocol.swift
// ElementX
//
// Created by Stefan Ceriu on 04.03.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import Combine
protocol RoomTimelineControllerProtocol {
var timelineItems: [RoomTimelineItemProtocol] { get }
var callbacks: PassthroughSubject<RoomTimelineControllerCallback, Never> { get }
func paginateBackwards(_ count: UInt)
}
@@ -0,0 +1,16 @@
//
// RoomTimelineItemProtocol.swift
// ElementX
//
// Created by Stefan Ceriu on 04.03.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
protocol RoomTimelineItemProtocol {
var id: String { get }
var senderDisplayName: String { get }
var text: String { get }
var originServerTs: Date { get }
}
@@ -0,0 +1,61 @@
//
// RoomTimeline.swift
// ElementX
//
// Created by Stefan Ceriu on 04.03.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import Combine
import MatrixRustSDK
enum RoomTimelineCallback {
case updatedMessages
}
class RoomTimelineProvider {
private let roomProxy: RoomProxyProtocol
private var cancellables = Set<AnyCancellable>()
private var paginationCounter: UInt = 0
private var isWaitingPaginationResponse = false
let callbacks = PassthroughSubject<RoomTimelineCallback, Never>()
private(set) var messages = [Message]()
init(roomProxy: RoomProxyProtocol) {
self.roomProxy = roomProxy
self.roomProxy.startLiveEventListener()
self.roomProxy.callbacks.sink { [weak self] callback in
guard let self = self else { return }
switch callback {
case .addedMessage(let message):
self.messages.append(message)
case .prependedMessages(let messages):
self.messages.insert(contentsOf: messages.reversed(), at: 0)
self.isWaitingPaginationResponse = false
case .updatedLastMessage:
break
}
self.callbacks.send(.updatedMessages)
}.store(in: &cancellables)
}
func paginateBackwards(_ count: UInt) {
if isWaitingPaginationResponse {
return
}
self.roomProxy.paginateBackwards(start: paginationCounter, finish: count)
isWaitingPaginationResponse = true
// This is not in any way correct but it will do for now.
self.paginationCounter += count
}
}
@@ -0,0 +1,16 @@
//
// TextRoomTimelineItem.swift
// ElementX
//
// Created by Stefan Ceriu on 04.03.2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
struct TextRoomTimelineItem: RoomTimelineItemProtocol {
let id: String
let senderDisplayName: String
let text: String
let originServerTs: Date
}