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
+31 -1
View File
@@ -27,7 +27,6 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
init() {
splashViewController = SplashViewController()
mainNavigationController = UINavigationController(rootViewController: splashViewController)
mainNavigationController.navigationBar.isHidden = true
window = UIWindow(frame: UIScreen.main.bounds)
window.rootViewController = mainNavigationController
@@ -89,6 +88,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
switch result {
case .logout:
self?.authenticationCoordinator.logout()
case .selectRoom(let roomIdentifier):
self?.presentRoomWithIdentifier(roomIdentifier)
}
}
@@ -96,6 +97,35 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
navigationRouter.setRootModule(coordinator)
}
private func presentRoomWithIdentifier(_ roomIdentifier: String) {
guard let userSession = authenticationCoordinator.userSession else {
fatalError("User session should be already setup at this point")
}
showLoadingIndicator()
userSession.getRoomList { [weak self] rooms in
guard let self = self else { return }
self.hideLoadingIndicator()
guard let roomProxy = rooms.filter({ $0.id == roomIdentifier}).first else {
MXLog.error("Invalid room identifier: \(roomIdentifier)")
return
}
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy)
let coordinator = RoomScreenCoordinator(parameters: parameters)
coordinator.completion = { [weak self] result in
}
self.add(childCoordinator: coordinator)
self.navigationRouter.push(coordinator)
}
}
private func showLoadingIndicator() {
let presenter = FullscreenLoadingActivityPresenter(label: "Loading",
on: mainNavigationController)
@@ -1,109 +0,0 @@
//
// RoomModel.swift
// ElementX
//
// Created by Stefan Ceriu on 14.02.2022.
//
import Foundation
import UIKit
import MatrixRustSDK
enum RoomModelError: Error {
case failedRetrievingDisplayName
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 loadDisplayName(_ completion: @escaping (Result<String, Error>) -> Void) {
DispatchQueue.global(qos: .background).async {
do {
let displayName = try room.displayName()
DispatchQueue.main.async {
completion(.success(displayName))
}
} catch {
DispatchQueue.main.async {
completion(.failure(RoomModelError.failedRetrievingDisplayName))
}
}
}
}
func loadAvatar(_ completion: @escaping (Result<UIImage?, Error>) -> 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))
}
}
}
}
}
@@ -24,6 +24,7 @@ struct HomeScreenCoordinatorParameters {
enum HomeScreenCoordinatorResult {
case logout
case selectRoom(roomIdentifier: String)
}
final class HomeScreenCoordinator: Coordinator, Presentable {
@@ -70,12 +71,14 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
break
}
})
case .selectRoom(let roomIdentifier):
self.completion?(.selectRoom(roomIdentifier: roomIdentifier))
}
}
parameters.userSession.callbacks.sink { [weak self] result in
switch result {
case .updatedData:
case .updatedRoomsList:
self?.updateRoomsList()
}
}.store(in: &cancellables)
@@ -20,12 +20,14 @@ import UIKit
enum HomeScreenViewModelResult {
case logout
case loadUserAvatar
case selectRoom(roomIdentifier: String)
}
enum HomeScreenViewAction {
case logout
case loadUserAvatar
case loadRoomData(roomId: String)
case loadRoomData(roomIdentifier: String)
case selectRoom(roomIdentifier: String)
}
struct HomeScreenViewState: BindableState {
@@ -65,3 +67,11 @@ struct HomeScreenRoom: Identifiable {
let isDirect: Bool
let isEncrypted: Bool
}
extension MutableCollection where Element == HomeScreenRoom {
mutating func updateEach(_ update: (inout Element) -> Void) {
for index in indices {
update(&self[index])
}
}
}
@@ -15,6 +15,7 @@
//
import SwiftUI
import Combine
import Kingfisher
@available(iOS 14, *)
@@ -24,19 +25,15 @@ typealias HomeScreenViewModelType = StateStoreViewModel<HomeScreenViewState,
@available(iOS 14, *)
class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol {
// MARK: - Properties
// MARK: Private
private var roomList: [RoomModelProtocol]? {
private var roomUpdateListeners = Set<AnyCancellable>()
private var roomList: [RoomProxyProtocol]? {
didSet {
self.state.isLoadingRooms = (roomList == nil)
}
}
private let imageCache: ImageCache
// MARK: Public
var completion: ((HomeScreenViewModelResult) -> Void)?
// MARK: - Setup
@@ -52,23 +49,38 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
switch viewAction {
case .logout:
self.completion?(.logout)
case .loadRoomData(let roomId):
self.loadAvatarForRoomWithIdentifier(roomId)
self.loadRoomDisplayNameForRoomWithIdentifier(roomId)
case .loadRoomData(let roomIdentifier):
self.loadAvatarForRoomWithIdentifier(roomIdentifier)
self.loadRoomDisplayNameForRoomWithIdentifier(roomIdentifier)
case .loadUserAvatar:
self.completion?(.loadUserAvatar)
case .selectRoom(let roomIdentifier):
self.completion?(.selectRoom(roomIdentifier: roomIdentifier))
}
}
func updateWithRoomList(_ roomList: [RoomModelProtocol]) {
func updateWithRoomList(_ roomList: [RoomProxyProtocol]) {
roomUpdateListeners.removeAll()
self.roomList = roomList
state.rooms = roomList.map { roomModel in
HomeScreenRoom(id: roomModel.identifier,
displayName: roomModel.name,
topic: roomModel.topic,
lastMessage: roomModel.lastMessage,
isDirect: roomModel.isDirect,
isEncrypted: roomModel.isEncrypted)
roomList.forEach({ roomProxy in
roomProxy.paginateBackwards(start: 0, finish: 1)
roomProxy.startLiveEventListener()
roomProxy.callbacks.sink { [weak self] callback in
switch callback {
case .updatedLastMessage:
self?.updateRoomForProxy(roomProxy)
default:
break
}
}
.store(in: &roomUpdateListeners)
})
state.rooms = roomList.map { roomProxy in
roomFromProxy(roomProxy)
}
}
@@ -79,7 +91,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
// MARK: - Private
private func loadAvatarForRoomWithIdentifier(_ roomIdentifier: String) {
guard let room = roomList?.filter({ $0.identifier == roomIdentifier }).first,
guard let room = roomList?.filter({ $0.id == roomIdentifier }).first,
let cacheKey = room.avatarURL?.path else {
return
}
@@ -116,7 +128,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
}
private func loadRoomDisplayNameForRoomWithIdentifier(_ roomIdentifier: String) {
guard let room = roomList?.filter({ $0.identifier == roomIdentifier }).first else {
guard let room = roomList?.filter({ $0.id == roomIdentifier }).first else {
return
}
@@ -147,4 +159,23 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
self.state.rooms[index].avatar = avatar
}
private func updateRoomForProxy(_ roomProxy: RoomProxyProtocol) {
state.rooms.updateEach { room in
if room.id != roomProxy.id {
return
}
room = roomFromProxy(roomProxy)
}
}
private func roomFromProxy(_ roomProxy: RoomProxyProtocol) -> HomeScreenRoom {
HomeScreenRoom(id: roomProxy.id,
displayName: roomProxy.name,
topic: roomProxy.topic,
lastMessage: roomProxy.lastMessage,
isDirect: roomProxy.isDirect,
isEncrypted: roomProxy.isEncrypted)
}
}
@@ -22,7 +22,7 @@ protocol HomeScreenViewModelProtocol {
var context: HomeScreenViewModelType.Context { get }
func updateWithRoomList(_ roomList: [RoomModelProtocol])
func updateWithRoomList(_ roomList: [RoomProxyProtocol])
func updateWithUserAvatar(_ avatar: UIImage?)
}
@@ -106,40 +106,44 @@ struct RoomCell: View {
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)
.mask(Circle())
} else {
let _ = context.send(viewAction: .loadRoomData(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)
Button {
context.send(viewAction: .selectRoom(roomIdentifier: room.id))
} label: {
HStack(spacing: 16.0) {
if let avatar = room.avatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: 40, height: 40)
.mask(Circle())
} else {
let _ = context.send(viewAction: .loadRoomData(roomIdentifier: room.id))
Image(systemName: "person.3")
.frame(width: 40, height: 40)
}
if let lastMessage = room.lastMessage {
Text(lastMessage)
.font(.footnote)
.fontWeight(.medium)
.lineLimit(1)
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)
}
.frame(minHeight: 60.0)
}
private func roomName(_ room: HomeScreenRoom) -> String {
@@ -153,9 +157,9 @@ struct HomeScreen_Previews: PreviewProvider {
static var previews: some View {
let viewModel = HomeScreenViewModel(userDisplayName: "Johnny Appleseed", imageCache: ImageCache.default)
let rooms = [MockRoomModel(displayName: "Alfa"),
MockRoomModel(displayName: "Beta"),
MockRoomModel(displayName: "Omega")]
let rooms = [MockRoomProxy(displayName: "Alfa"),
MockRoomProxy(displayName: "Beta"),
MockRoomProxy(displayName: "Omega")]
viewModel.updateWithRoomList(rooms)
@@ -0,0 +1,67 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct RoomScreenCoordinatorParameters {
let roomProxy: RoomProxyProtocol
}
final class RoomScreenCoordinator: Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let parameters: RoomScreenCoordinatorParameters
private let roomScreenHostingController: UIViewController
private var roomScreenViewModel: RoomScreenViewModelProtocol
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((RoomScreenViewModelResult) -> Void)?
// MARK: - Setup
@available(iOS 14.0, *)
init(parameters: RoomScreenCoordinatorParameters) {
self.parameters = parameters
let timelineProvider = RoomTimelineProvider(roomProxy: parameters.roomProxy)
let timelineController = RoomTimelineController(timelineProvider: timelineProvider)
let viewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy, timelineController: timelineController)
let view = RoomScreen(context: viewModel.context)
roomScreenViewModel = viewModel
roomScreenHostingController = UIHostingController(rootView: view)
}
// MARK: - Public
func start() {
MXLog.debug("[RoomScreenCoordinator] did start.")
roomScreenViewModel.completion = { [weak self] result in
MXLog.debug("[RoomScreenCoordinator] RoomScreenViewModel did complete with result: \(result).")
guard let self = self else { return }
self.completion?(result)
}
}
func toPresentable() -> UIViewController {
return self.roomScreenHostingController
}
}
@@ -0,0 +1,50 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
enum RoomScreenViewModelResult {
}
enum RoomScreenViewAction {
case loadPreviousPage
}
private var dateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short
dateFormatter.timeStyle = .short
return dateFormatter
}()
struct RoomScreenMessage: Identifiable, Equatable {
let id: String
let sender: String
let text: String
let originServerTs: Date
var timestamp: String {
dateFormatter.string(from: originServerTs)
}
}
struct RoomScreenViewState: BindableState {
var roomTitle: String?
var isLoading: Bool = false
var messages: [RoomScreenMessage] = []
}
@@ -0,0 +1,74 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
@available(iOS 14, *)
typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState,
Never,
RoomScreenViewAction>
@available(iOS 14, *)
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
private struct Constants {
static let backPaginationPageSize: UInt = 20
}
private let roomProxy: RoomProxyProtocol
private let timelineController: RoomTimelineControllerProtocol
var completion: ((RoomScreenViewModelResult) -> Void)?
// MARK: - Setup
init(roomProxy: RoomProxyProtocol, timelineController: RoomTimelineControllerProtocol) {
self.roomProxy = roomProxy
self.timelineController = timelineController
super.init(initialViewState: RoomScreenViewState())
state.messages = buildRoomScreenMessages(timelineController.timelineItems)
timelineController.callbacks.sink { [weak self] callback in
guard let self = self else { return }
switch callback {
case .updatedTimelineItems:
self.state.messages = self.buildRoomScreenMessages(timelineController.timelineItems)
}
}.store(in: &cancellables)
timelineController.paginateBackwards(Constants.backPaginationPageSize)
}
// MARK: - Public
override func process(viewAction: RoomScreenViewAction) {
switch viewAction {
case .loadPreviousPage:
timelineController.paginateBackwards(Constants.backPaginationPageSize)
}
}
// MARK: - Private
private func buildRoomScreenMessages(_ timelineItems: [RoomTimelineItemProtocol]) -> [RoomScreenMessage] {
timelineItems.map { RoomScreenMessage(id: $0.id,
sender: $0.senderDisplayName,
text: $0.text,
originServerTs: $0.originServerTs) }
}
}
@@ -0,0 +1,24 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
protocol RoomScreenViewModelProtocol {
var completion: ((RoomScreenViewModelResult) -> Void)? { get set }
@available(iOS 14, *)
var context: RoomScreenViewModelType.Context { get }
}
@@ -0,0 +1,17 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
@@ -0,0 +1,21 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import XCTest
class RoomScreenViewModelTests: XCTestCase {
}
@@ -0,0 +1,85 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct RoomScreen: View {
let timelineBottomDividerIdentifier = "TimelineBottomDividerIdentifier"
@ObservedObject var context: RoomScreenViewModel.Context
@State var backPaginationMessageIdentifier: String?
var body: some View {
ScrollViewReader { reader in
List {
if backPaginationMessageIdentifier != nil {
HStack {
Spacer()
ProgressView()
Spacer()
}
} else {
Rectangle()
.onAppear {
let _ = MXLog.debug("Request load previous page")
backPaginationMessageIdentifier = context.viewState.messages.first?.id
context.send(viewAction: .loadPreviousPage)
}
.frame(height: 0.0)
}
ForEach(context.viewState.messages) { message in
VStack(alignment: .leading) {
HStack {
Text(message.sender)
Spacer()
Text(message.timestamp)
}
.font(.footnote)
Text(message.text)
}
.listRowSeparator(.hidden)
}
Divider()
.id(timelineBottomDividerIdentifier)
.listRowSeparator(.hidden)
}
.listStyle(.plain)
.onAppear {
reader.scrollTo(timelineBottomDividerIdentifier, anchor: .bottom)
}
.onChange(of: context.viewState.messages) { _ in
if backPaginationMessageIdentifier != nil {
self.backPaginationMessageIdentifier = nil
return
}
reader.scrollTo(timelineBottomDividerIdentifier, anchor: .bottom)
}
}
}
}
// MARK: - Previews
struct RoomScreen_Previews: PreviewProvider {
static var previews: some View {
let viewModel = RoomScreenViewModel(roomProxy: MockRoomProxy(displayName: "Test"),
timelineController: MockRoomTimelineController())
RoomScreen(context: viewModel.context)
}
}
@@ -11,7 +11,7 @@ import Combine
import UIKit
enum UserSessionCallback {
case updatedData
case updatedRoomsList
}
enum UserSessionError: Error {
@@ -19,20 +19,27 @@ enum UserSessionError: Error {
}
private class WeakUserSessionWrapper: ClientDelegate {
weak var userSession: UserSession?
private weak var userSession: UserSession?
init(userSession: UserSession) {
self.userSession = userSession
}
func didReceiveSyncUpdate() {
userSession?.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)
@@ -81,12 +88,27 @@ class UserSession: ClientDelegate {
}
}
func getRoomList(_ completion: @escaping ([RoomModel]) -> Void) {
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 conversations = self.client.conversations()
let rooms = conversations.map {
return RoomModel(room: $0)
let rooms = self.client.conversations().map {
return RoomProxy(room: $0)
}
DispatchQueue.main.async {
@@ -94,12 +116,4 @@ class UserSession: ClientDelegate {
}
}
}
// MARK: ClientDelegate
func didReceiveSyncUpdate() {
DispatchQueue.main.async {
self.callbacks.send(.updatedData)
}
}
}
@@ -1,5 +1,5 @@
//
// MockRoomModel.swift
// MockRoomProxy.swift
// ElementX
//
// Created by Stefan Ceriu on 17.02.2022.
@@ -7,9 +7,10 @@
import Foundation
import UIKit
import Combine
struct MockRoomModel: RoomModelProtocol {
let identifier = UUID().uuidString
struct MockRoomProxy: RoomProxyProtocol {
let id = UUID().uuidString
let name: String? = nil
let displayName: String
@@ -23,6 +24,8 @@ struct MockRoomModel: RoomModelProtocol {
let isPublic = Bool.random()
let isEncrypted = Bool.random()
var callbacks = PassthroughSubject<RoomProxyCallback, Never>()
func loadDisplayName(_ completion: @escaping (Result<String, Error>) -> Void) {
completion(.success(displayName))
}
@@ -30,4 +33,12 @@ struct MockRoomModel: RoomModelProtocol {
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))
}
}
@@ -1,14 +1,22 @@
//
// RoomModelProtocol.swift
// RoomProxyProtocol.swift
// ElementX
//
// Created by Stefan Ceriu on 17.02.2022.
//
import UIKit
import Combine
import MatrixRustSDK
protocol RoomModelProtocol {
var identifier: String { get }
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 }
@@ -23,4 +31,9 @@ protocol RoomModelProtocol {
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
}