Loading images and user avatars.

This commit is contained in:
Stefan Ceriu 2022-03-17 18:09:29 +02:00
parent 30378ae6a9
commit 120bcaf012
31 changed files with 542 additions and 207 deletions

View File

@ -27,9 +27,12 @@
18C5745227E1D88600D70937 /* ImageRoomMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C5745127E1D88600D70937 /* ImageRoomMessage.swift */; };
18C5745427E1D88E00D70937 /* TextRoomMessage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C5745327E1D88E00D70937 /* TextRoomMessage.swift */; };
18C5745627E1DCA800D70937 /* RoomMessageFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C5745527E1DCA800D70937 /* RoomMessageFactory.swift */; };
18C5745827E1EB6E00D70937 /* TimelineItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C5745727E1EB6E00D70937 /* TimelineItemFactory.swift */; };
18DF7C2A27E23E3A00291672 /* TimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DF7C2927E23E3A00291672 /* TimelineItemProtocol.swift */; };
18DF7C2C27E23EC000291672 /* TimelineViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DF7C2B27E23EC000291672 /* TimelineViewFactory.swift */; };
18C5745827E1EB6E00D70937 /* RoomTimelineItemFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18C5745727E1EB6E00D70937 /* RoomTimelineItemFactory.swift */; };
18DF7C2A27E23E3A00291672 /* RoomTimelineItemProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DF7C2927E23E3A00291672 /* RoomTimelineItemProtocol.swift */; };
18DF7C2C27E23EC000291672 /* RoomTimelineViewFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DF7C2B27E23EC000291672 /* RoomTimelineViewFactory.swift */; };
18DF7C2F27E264FC00291672 /* MediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DF7C2E27E264FC00291672 /* MediaProvider.swift */; };
18DF7C3127E3608100291672 /* MediaProviderProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DF7C3027E3608100291672 /* MediaProviderProtocol.swift */; };
18DF7C3327E3608800291672 /* MockMediaProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18DF7C3227E3608800291672 /* MockMediaProvider.swift */; };
18F2BADA27D25B4000DD1988 /* RoomTimelineProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F2BA7727D25B4000DD1988 /* RoomTimelineProvider.swift */; };
18F2BADB27D25B4000DD1988 /* AuthenticationCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F2BA7927D25B4000DD1988 /* AuthenticationCoordinator.swift */; };
18F2BADC27D25B4000DD1988 /* UserSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18F2BA7A27D25B4000DD1988 /* UserSession.swift */; };
@ -133,9 +136,12 @@
18C5745127E1D88600D70937 /* ImageRoomMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageRoomMessage.swift; sourceTree = "<group>"; };
18C5745327E1D88E00D70937 /* TextRoomMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomMessage.swift; sourceTree = "<group>"; };
18C5745527E1DCA800D70937 /* RoomMessageFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMessageFactory.swift; sourceTree = "<group>"; };
18C5745727E1EB6E00D70937 /* TimelineItemFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemFactory.swift; sourceTree = "<group>"; };
18DF7C2927E23E3A00291672 /* TimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemProtocol.swift; sourceTree = "<group>"; };
18DF7C2B27E23EC000291672 /* TimelineViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewFactory.swift; sourceTree = "<group>"; };
18C5745727E1EB6E00D70937 /* RoomTimelineItemFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemFactory.swift; sourceTree = "<group>"; };
18DF7C2927E23E3A00291672 /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = "<group>"; };
18DF7C2B27E23EC000291672 /* RoomTimelineViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineViewFactory.swift; sourceTree = "<group>"; };
18DF7C2E27E264FC00291672 /* MediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProvider.swift; sourceTree = "<group>"; };
18DF7C3027E3608100291672 /* MediaProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProviderProtocol.swift; sourceTree = "<group>"; };
18DF7C3227E3608800291672 /* MockMediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaProvider.swift; sourceTree = "<group>"; };
18F2BA7727D25B4000DD1988 /* RoomTimelineProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomTimelineProvider.swift; sourceTree = "<group>"; };
18F2BA7927D25B4000DD1988 /* AuthenticationCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationCoordinator.swift; sourceTree = "<group>"; };
18F2BA7A27D25B4000DD1988 /* UserSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserSession.swift; sourceTree = "<group>"; };
@ -306,9 +312,9 @@
18A318D927DA42C9000867CD /* TimelineItems */ = {
isa = PBXGroup;
children = (
18C5745727E1EB6E00D70937 /* TimelineItemFactory.swift */,
18DF7C2927E23E3A00291672 /* TimelineItemProtocol.swift */,
18DF7C2B27E23EC000291672 /* TimelineViewFactory.swift */,
18DF7C2927E23E3A00291672 /* RoomTimelineItemProtocol.swift */,
18C5745727E1EB6E00D70937 /* RoomTimelineItemFactory.swift */,
18DF7C2B27E23EC000291672 /* RoomTimelineViewFactory.swift */,
18A318DB27DA42C9000867CD /* RoomTimelineViewProvider.swift */,
18F9889727DB7473002F48B4 /* ImageRoomTimelineItem.swift */,
18F9889D27DB752B002F48B4 /* ImageRoomTimelineView.swift */,
@ -349,12 +355,23 @@
path = Messages;
sourceTree = "<group>";
};
18DF7C2D27E264EE00291672 /* Media */ = {
isa = PBXGroup;
children = (
18DF7C3027E3608100291672 /* MediaProviderProtocol.swift */,
18DF7C2E27E264FC00291672 /* MediaProvider.swift */,
18DF7C3227E3608800291672 /* MockMediaProvider.swift */,
);
path = Media;
sourceTree = "<group>";
};
18F2BA7227D25B4000DD1988 /* Services */ = {
isa = PBXGroup;
children = (
18F2BA7827D25B4000DD1988 /* Authentication */,
18C5744727E1D84000D70937 /* Room */,
18F2BA7627D25B4000DD1988 /* Timeline */,
18DF7C2D27E264EE00291672 /* Media */,
);
path = Services;
sourceTree = "<group>";
@ -804,7 +821,7 @@
18F2BB1527D25B4000DD1988 /* LoginScreenViewModelProtocol.swift in Sources */,
18F2BAEB27D25B4000DD1988 /* LabelledActivityIndicatorView.swift in Sources */,
18F2BAE427D25B4000DD1988 /* Presentable.swift in Sources */,
18DF7C2A27E23E3A00291672 /* TimelineItemProtocol.swift in Sources */,
18DF7C2A27E23E3A00291672 /* RoomTimelineItemProtocol.swift in Sources */,
18F2BAF927D25B4000DD1988 /* SplashViewController.swift in Sources */,
18F2BAE327D25B4000DD1988 /* RootRouter.swift in Sources */,
18F2BAE527D25B4000DD1988 /* NavigationModule.swift in Sources */,
@ -834,14 +851,17 @@
18F2BAF527D25B4000DD1988 /* WeakKeyDictionary.swift in Sources */,
18F2BADF27D25B4000DD1988 /* NavigationRouterStore.swift in Sources */,
18F2BAFE27D25B4000DD1988 /* HomeScreenViewModelProtocol.swift in Sources */,
18DF7C3327E3608800291672 /* MockMediaProvider.swift in Sources */,
18F2BAE827D25B4000DD1988 /* RectangleToastView.swift in Sources */,
18F2BB1627D25B4000DD1988 /* LoginScreenModels.swift in Sources */,
18F9889E27DB752B002F48B4 /* ImageRoomTimelineView.swift in Sources */,
18F2BADA27D25B4000DD1988 /* RoomTimelineProvider.swift in Sources */,
18DF7C2F27E264FC00291672 /* MediaProvider.swift in Sources */,
18C5744D27E1D84000D70937 /* RoomProxy.swift in Sources */,
18F9889827DB7473002F48B4 /* ImageRoomTimelineItem.swift in Sources */,
18F2BB0027D25B4000DD1988 /* HomeScreen.swift in Sources */,
18F2BB2827D2647A00DD1988 /* MockRoomTimelineController.swift in Sources */,
18DF7C3127E3608100291672 /* MediaProviderProtocol.swift in Sources */,
18F2BB0127D25B4000DD1988 /* HomeScreenViewModel.swift in Sources */,
18F2BAF027D25B4000DD1988 /* ActivityDismissal.swift in Sources */,
18F2BADD27D25B4000DD1988 /* KeychainController.swift in Sources */,
@ -860,8 +880,8 @@
18C5744E27E1D84000D70937 /* MockRoomProxy.swift in Sources */,
18F2BADC27D25B4000DD1988 /* UserSession.swift in Sources */,
18F2BAEF27D25B4000DD1988 /* ActivityRequest.swift in Sources */,
18DF7C2C27E23EC000291672 /* TimelineViewFactory.swift in Sources */,
18C5745827E1EB6E00D70937 /* TimelineItemFactory.swift in Sources */,
18DF7C2C27E23EC000291672 /* RoomTimelineViewFactory.swift in Sources */,
18C5745827E1EB6E00D70937 /* RoomTimelineItemFactory.swift in Sources */,
18F2BAEE27D25B4000DD1988 /* Activity.swift in Sources */,
18F2BAEC27D25B4000DD1988 /* ToastActivityPresenter.swift in Sources */,
);

View File

@ -6,7 +6,6 @@
//
import UIKit
import Kingfisher
class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
private let window: UIWindow
@ -81,8 +80,8 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
fatalError("User session should be already setup at this point")
}
let parameters = HomeScreenCoordinatorParameters(userSession: userSession)
let coordinator = HomeScreenCoordinator(parameters: parameters, imageCache: ImageCache.default)
let parameters = HomeScreenCoordinatorParameters(userSession: userSession, mediaProvider: userSession.mediaProvider)
let coordinator = HomeScreenCoordinator(parameters: parameters)
coordinator.completion = { [weak self] result in
switch result {
@ -114,7 +113,7 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
return
}
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy)
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy, mediaProvider: userSession.mediaProvider)
let coordinator = RoomScreenCoordinator(parameters: parameters)
self.add(childCoordinator: coordinator)

View File

@ -16,10 +16,10 @@
import SwiftUI
import Combine
import Kingfisher
struct HomeScreenCoordinatorParameters {
let userSession: UserSession
let mediaProvider: MediaProviderProtocol
}
enum HomeScreenCoordinatorResult {
@ -47,11 +47,11 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
// MARK: - Setup
init(parameters: HomeScreenCoordinatorParameters, imageCache: Kingfisher.ImageCache) {
init(parameters: HomeScreenCoordinatorParameters) {
self.parameters = parameters
let userDisplayName = self.parameters.userSession.userDisplayName ?? self.parameters.userSession.userIdentifier
viewModel = HomeScreenViewModel(userDisplayName: userDisplayName, imageCache: imageCache)
viewModel = HomeScreenViewModel(userDisplayName: userDisplayName, mediaProvider: self.parameters.mediaProvider)
let view = HomeScreen(context: viewModel.context)
hostingController = UIHostingController(rootView: view)
@ -63,7 +63,7 @@ final class HomeScreenCoordinator: Coordinator, Presentable {
case .logout:
self.completion?(.logout)
case .loadUserAvatar:
self.parameters.userSession.loadUserAvatar({ result in
self.parameters.mediaProvider.loadCurrentUserAvatar({ result in
switch result {
case .success(let avatar):
self.viewModel.updateWithUserAvatar(avatar)

View File

@ -16,7 +16,6 @@
import SwiftUI
import Combine
import Kingfisher
@available(iOS 14, *)
typealias HomeScreenViewModelType = StateStoreViewModel<HomeScreenViewState,
@ -25,21 +24,21 @@ typealias HomeScreenViewModelType = StateStoreViewModel<HomeScreenViewState,
@available(iOS 14, *)
class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol {
private let mediaProvider: MediaProviderProtocol
private var roomUpdateListeners = Set<AnyCancellable>()
private var roomList: [RoomProxyProtocol]? {
didSet {
self.state.isLoadingRooms = (roomList?.count ?? 0 == 0)
}
}
private let imageCache: ImageCache
var completion: ((HomeScreenViewModelResult) -> Void)?
// MARK: - Setup
init(userDisplayName: String, imageCache: Kingfisher.ImageCache) {
self.imageCache = imageCache
init(userDisplayName: String, mediaProvider: MediaProviderProtocol) {
self.mediaProvider = mediaProvider
super.init(initialViewState: HomeScreenViewState(userDisplayName: userDisplayName, isLoadingRooms: true))
}
@ -94,37 +93,14 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
private func loadAvatarForRoomWithIdentifier(_ roomIdentifier: String) {
guard let room = roomList?.filter({ $0.id == roomIdentifier }).first,
let cacheKey = room.avatarURL?.path else {
let avatarURLString = room.avatarURL else {
return
}
if imageCache.isCached(forKey: cacheKey) {
imageCache.retrieveImage(forKey: cacheKey) { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let value):
self.updateAvatar(value.image, forRoomWithIdentifier: roomIdentifier)
case .failure(let error):
MXLog.error("Failed retrieving avatar from cache with error: \(error)")
}
}
return
}
room.loadAvatar { [weak self] result in
mediaProvider.loadImageFromURL(avatarURLString) { [weak self] result in
guard let self = self else { return }
switch result {
case .success(let avatar):
guard let avatar = avatar else {
return
}
self.imageCache.store(avatar, forKey: cacheKey)
self.updateAvatar(avatar, forRoomWithIdentifier: roomIdentifier)
default:
break
if case let .success(image) = result {
self.updateAvatar(image, forRoomWithIdentifier: roomIdentifier)
}
}
}

View File

@ -15,7 +15,6 @@
//
import SwiftUI
import Kingfisher
struct HomeScreen: View {
@ -154,7 +153,8 @@ struct RoomCell: View {
struct HomeScreen_Previews: PreviewProvider {
static var previews: some View {
let viewModel = HomeScreenViewModel(userDisplayName: "Johnny Appleseed", imageCache: ImageCache.default)
let viewModel = HomeScreenViewModel(userDisplayName: "Johnny Appleseed",
mediaProvider: MockMediaProvider())
let rooms = [MockRoomProxy(displayName: "Alfa"),
MockRoomProxy(displayName: "Beta"),

View File

@ -18,6 +18,7 @@ import SwiftUI
struct RoomScreenCoordinatorParameters {
let roomProxy: RoomProxyProtocol
let mediaProvider: MediaProviderProtocol
}
final class RoomScreenCoordinator: Coordinator, Presentable {
@ -43,10 +44,12 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
let timelineProvider = RoomTimelineProvider(roomProxy: parameters.roomProxy)
let timelineController = RoomTimelineController(timelineProvider: timelineProvider,
timelineItemFactory: TimelineItemFactory(),
timelineViewFactory: TimelineViewFactory())
timelineItemFactory: RoomTimelineItemFactory(mediaProvider: parameters.mediaProvider),
mediaProvider: parameters.mediaProvider)
let viewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy, timelineController: timelineController)
let viewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
timelineController: timelineController,
timelineViewFactory: RoomTimelineViewFactory())
let view = RoomScreen(context: viewModel.context)
roomScreenViewModel = viewModel
roomScreenHostingController = UIHostingController(rootView: view)

View File

@ -28,6 +28,6 @@ enum RoomScreenViewAction {
struct RoomScreenViewState: BindableState {
var roomTitle: String = ""
var timelineItems: [RoomTimelineViewProvider] = []
var items: [RoomTimelineViewProvider] = []
var isBackPaginating = false
}

View File

@ -29,24 +29,35 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private let roomProxy: RoomProxyProtocol
private let timelineController: RoomTimelineControllerProtocol
private let timelineViewFactory: RoomTimelineViewFactory
// MARK: - Setup
init(roomProxy: RoomProxyProtocol, timelineController: RoomTimelineControllerProtocol) {
init(roomProxy: RoomProxyProtocol,
timelineController: RoomTimelineControllerProtocol,
timelineViewFactory: RoomTimelineViewFactory) {
self.roomProxy = roomProxy
self.timelineController = timelineController
self.timelineViewFactory = timelineViewFactory
super.init(initialViewState: RoomScreenViewState())
state.roomTitle = roomProxy.name ?? ""
state.timelineItems = timelineController.timelineItems
buildTimelineViews()
timelineController.callbacks.sink { [weak self] callback in
guard let self = self else { return }
switch callback {
case .updatedTimelineItems:
self.state.timelineItems = timelineController.timelineItems
self.buildTimelineViews()
case .updatedTimelineItem(let itemId):
guard let timelineItem = self.timelineController.timelineItems.first(where: { $0.id == itemId }),
let viewIndex = self.state.items.firstIndex(where: { $0.id == itemId }) else {
return
}
self.state.items[viewIndex] = timelineViewFactory.buildTimelineViewFor(timelineItem)
}
}.store(in: &cancellables)
}
@ -60,10 +71,18 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
timelineController.paginateBackwards(Constants.backPaginationPageSize) { [weak self] _ in
self?.state.isBackPaginating = false
}
case .itemAppeared:
break
case .itemDisappeared:
break
case .itemAppeared(let id):
timelineController.processItemAppearance(id)
case .itemDisappeared(let id):
timelineController.processItemDisappearance(id)
}
}
// MARK: - Private
private func buildTimelineViews() {
state.items = timelineController.timelineItems.map { item in
timelineViewFactory.buildTimelineViewFor(item)
}
}
}

View File

@ -40,12 +40,9 @@ struct RoomScreen: View {
}
// No idea why previews don't work otherwise
ForEach(isPreview ? context.viewState.timelineItems : timelineItems) { timelineItem in
ForEach(isPreview ? context.viewState.items : timelineItems) { timelineItem in
timelineItem
.listRowSeparator(.hidden)
.task {
}
.onAppear {
context.send(viewAction: .itemAppeared(id: timelineItem.id))
}
@ -75,7 +72,7 @@ struct RoomScreen: View {
attemptBackPagination()
})
.onChange(of: context.viewState.timelineItems) { _ in
.onChange(of: context.viewState.items) { _ in
// Don't update the list while moving
if tableViewObserver.isDecelerating || tableViewObserver.isTracking {
hasPendingChanges = true
@ -83,7 +80,7 @@ struct RoomScreen: View {
}
tableViewObserver.saveCurrentOffset()
timelineItems = context.viewState.timelineItems
timelineItems = context.viewState.items
}
.onReceive(tableViewObserver.scrollViewDidRest, perform: {
if hasPendingChanges == false {
@ -91,7 +88,7 @@ struct RoomScreen: View {
}
tableViewObserver.saveCurrentOffset()
timelineItems = context.viewState.timelineItems
timelineItems = context.viewState.items
hasPendingChanges = false
})
.onChange(of: timelineItems, perform: { _ in
@ -244,7 +241,8 @@ private class TableViewObserver: NSObject, UITableViewDelegate {
struct RoomScreen_Previews: PreviewProvider {
static var previews: some View {
let viewModel = RoomScreenViewModel(roomProxy: MockRoomProxy(displayName: "Test"),
timelineController: MockRoomTimelineController())
timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory())
RoomScreen(context: viewModel.context)
}

View File

@ -9,15 +9,12 @@ import Foundation
import MatrixRustSDK
import Combine
import UIKit
import Kingfisher
enum UserSessionCallback {
case updatedRoomsList
}
enum UserSessionError: Error {
case failedRetrievingAvatar
}
private class WeakUserSessionWrapper: ClientDelegate {
private weak var userSession: UserSession?
@ -41,6 +38,8 @@ class UserSession: ClientDelegate {
}
}
let mediaProvider: MediaProviderProtocol
deinit {
client.setDelegate(delegate: nil)
}
@ -49,6 +48,7 @@ class UserSession: ClientDelegate {
init(client: Client) {
self.client = client
self.mediaProvider = MediaProvider(client: client, imageCache: ImageCache.default)
client.setDelegate(delegate: WeakUserSessionWrapper(userSession: self))
client.startSync()
@ -72,22 +72,6 @@ class UserSession: ClientDelegate {
}
}
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)
}

View File

@ -0,0 +1,75 @@
//
// MediaProvider.swift
// ElementX
//
// Created by Stefan Ceriu on 16/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import UIKit
import MatrixRustSDK
import Kingfisher
struct MediaProvider: MediaProviderProtocol {
private let client: Client
private let imageCache: Kingfisher.ImageCache
init(client: Client, imageCache: Kingfisher.ImageCache) {
self.client = client
self.imageCache = imageCache
}
func loadCurrentUserAvatar(_ completion: @escaping (Result<UIImage?, MediaProviderError>) -> Void) {
DispatchQueue.global(qos: .background).async {
do {
let imageData = try self.client.avatar()
DispatchQueue.main.async {
completion(.success(UIImage(data: Data(bytes: imageData, count: imageData.count))))
}
} catch {
MXLog.error("Failed retrieving image with error: \(error)")
DispatchQueue.main.async {
completion(.failure(.failedRetrievingImage))
}
}
}
}
func hasImageCachedForURL(_ url: String) -> Bool {
self.imageCache.imageCachedType(forKey: url) == .memory
}
func loadImageFromURL(_ url: String, _ completion: @escaping (Result<UIImage, MediaProviderError>) -> Void) {
self.imageCache.retrieveImage(forKey: url) { result in
if case let .success(cacheResult) = result,
let image = cacheResult.image {
completion(.success(image))
}
}
DispatchQueue.global(qos: .background).async {
do {
let imageData = try self.client.loadImage(url: url)
guard let image = UIImage(data: Data(bytes: imageData, count: imageData.count)) else {
MXLog.error("Invalid image data")
DispatchQueue.main.async {
completion(.failure(.invalidImageData))
}
return
}
self.imageCache.store(image, forKey: url)
DispatchQueue.main.async {
completion(.success(image))
}
} catch {
MXLog.error("Failed retrieving image with error: \(error)")
DispatchQueue.main.async {
completion(.failure(.failedRetrievingImage))
}
}
}
}
}

View File

@ -0,0 +1,21 @@
//
// MediaProviderProtocol.swift
// ElementX
//
// Created by Stefan Ceriu on 17/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import UIKit
enum MediaProviderError: Error {
case failedRetrievingImage
case invalidImageData
}
protocol MediaProviderProtocol {
func loadCurrentUserAvatar(_ completion: @escaping (Result<UIImage?, MediaProviderError>) -> Void)
func hasImageCachedForURL(_ url: String) -> Bool
func loadImageFromURL(_ url: String, _ completion: @escaping (Result<UIImage, MediaProviderError>) -> Void)
}

View File

@ -0,0 +1,25 @@
//
// MockMediaProvider.swift
// ElementX
//
// Created by Stefan Ceriu on 17/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import UIKit
struct MockMediaProvider: MediaProviderProtocol {
func loadCurrentUserAvatar(_ completion: @escaping (Result<UIImage?, MediaProviderError>) -> Void) {
}
func hasImageCachedForURL(_ url: String) -> Bool {
true
}
func loadImageFromURL(_ url: String, _ completion: @escaping (Result<UIImage, MediaProviderError>) -> Void) {
}
}

View File

@ -31,4 +31,8 @@ struct ImageRoomMessage: RoomMessageProtocol {
var originServerTs: Date {
Date(timeIntervalSince1970: TimeInterval(message.baseMessage().originServerTs()))
}
var url: String? {
message.url()
}
}

View File

@ -8,7 +8,6 @@
import Foundation
import UIKit
import Combine
import MatrixRustSDK
struct MockRoomProxy: RoomProxyProtocol {
let id = UUID().uuidString
@ -18,7 +17,7 @@ struct MockRoomProxy: RoomProxyProtocol {
let topic: String? = nil
let lastMessage: String? = "Last message"
let avatarURL: URL? = nil
let avatarURL: String? = nil
let isDirect = Bool.random()
let isSpace = Bool.random()
@ -27,19 +26,19 @@ struct MockRoomProxy: RoomProxyProtocol {
var callbacks = PassthroughSubject<RoomProxyCallback, Never>()
func loadDisplayName(_ completion: @escaping (Result<String, Error>) -> Void) {
func loadDisplayName(_ completion: @escaping (Result<String, RoomProxyError>) -> Void) {
completion(.success(displayName))
}
func loadAvatar(_ completion: (Result<UIImage?, Error>) -> Void) {
completion(.success(UIImage(systemName: "wand.and.stars")))
}
func startLiveEventListener() {
}
func paginateBackwards(count: UInt, callback: ((Result<[RoomMessageProtocol], Error>) -> Void)?) {
func paginateBackwards(count: UInt, callback: ((Result<[RoomMessageProtocol], RoomProxyError>) -> Void)?) {
}
func avatarURLForUserId(_ userId: String, completion: @escaping (Result<String?, RoomProxyError>) -> Void) {
}
}

View File

@ -11,12 +11,6 @@ import Combine
import MatrixRustSDK
enum RoomProxyError: Error {
case failedRetrievingDisplayName
case failedRetrievingAvatar
case backwardStreamNotAvailable
}
private class WeakRoomProxyWrapper: RoomDelegate {
private weak var roomProxy: RoomProxy?
@ -92,21 +86,17 @@ class RoomProxy: RoomProxyProtocol, Equatable {
}
}
var avatarURL: URL? {
guard let urlString = room.avatarUrl() else {
return nil
}
return URL(string: urlString)
var avatarURL: String? {
room.avatarUrl()
}
func loadDisplayName(_ completion: @escaping (Result<String, Error>) -> Void) {
func avatarURLForUserId(_ userId: String, completion: @escaping (Result<String?, RoomProxyError>) -> Void) {
DispatchQueue.global(qos: .background).async {
do {
let displayName = try self.room.displayName()
let avatarURL = try self.room.memberAvatarUrl(userId: userId)
DispatchQueue.main.async {
completion(.success(displayName))
completion(.success(avatarURL))
}
} catch {
DispatchQueue.main.async {
@ -116,28 +106,28 @@ class RoomProxy: RoomProxyProtocol, Equatable {
}
}
func loadAvatar(_ completion: @escaping (Result<UIImage?, Error>) -> Void) {
func loadDisplayName(_ completion: @escaping (Result<String, RoomProxyError>) -> Void) {
DispatchQueue.global(qos: .background).async {
do {
let avatarData = try self.room.avatar()
let displayName = try self.room.displayName()
DispatchQueue.main.async {
completion(.success(UIImage(data: Data(bytes: avatarData, count: avatarData.count))))
completion(.success(displayName))
}
} catch {
DispatchQueue.main.async {
completion(.failure(RoomProxyError.failedRetrievingAvatar))
completion(.failure(.failedRetrievingDisplayName))
}
}
}
}
func paginateBackwards(count: UInt, callback: ((Result<[RoomMessageProtocol], Error>) -> Void)?) {
func paginateBackwards(count: UInt, callback: ((Result<[RoomMessageProtocol], RoomProxyError>) -> Void)?) {
MXLog.debug("Started backpaginating")
processingQueue.async {
guard let backwardStream = self.backwardStream else {
DispatchQueue.main.async {
callback?(.failure(RoomProxyError.backwardStreamNotAvailable))
callback?(.failure(.backwardStreamNotAvailable))
}
return
}

View File

@ -7,7 +7,12 @@
import UIKit
import Combine
import MatrixRustSDK
enum RoomProxyError: Error {
case failedRetrievingDisplayName
case failedRetrievingAvatar
case backwardStreamNotAvailable
}
enum RoomProxyCallback {
case addedMessage(RoomMessageProtocol)
@ -26,12 +31,13 @@ protocol RoomProxyProtocol {
var topic: String? { get }
var lastMessage: String? { get }
var avatarURL: URL? { get }
var avatarURL: String? { get }
func loadDisplayName(_ completion: @escaping (Result<String, Error>) -> Void)
func loadAvatar(_ completion: @escaping (Result<UIImage?, Error>) -> Void)
func avatarURLForUserId(_ userId: String, completion: @escaping (Result<String?, RoomProxyError>) -> Void)
func paginateBackwards(count: UInt, callback: ((Result<[RoomMessageProtocol], Error>) -> Void)?)
func loadDisplayName(_ completion: @escaping (Result<String, RoomProxyError>) -> Void)
func paginateBackwards(count: UInt, callback: ((Result<[RoomMessageProtocol], RoomProxyError>) -> Void)?)
var callbacks: PassthroughSubject<RoomProxyCallback, Never> { get }
}

View File

@ -13,13 +13,21 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
var timelineItems: [RoomTimelineViewProvider] = [RoomTimelineViewProvider.separator(.init(id: UUID().uuidString, text: "Yesterday")),
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Alice", text: "You rock!", timestamp: "10:10 AM", shouldShowSenderDetails: true)),
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Alice", text: "You also rule!", timestamp: "10:11 AM", shouldShowSenderDetails: false)),
RoomTimelineViewProvider.separator(.init(id: UUID().uuidString, text: "Today")),
RoomTimelineViewProvider.text(.init(id: UUID().uuidString, senderDisplayName: "Bob", text: "You too!", timestamp: "5 PM", shouldShowSenderDetails: true))]
var timelineItems: [RoomTimelineItemProtocol] = [SeparatorRoomTimelineItem(id: UUID().uuidString, text: "Yesterday"),
TextRoomTimelineItem(id: UUID().uuidString, text: "You rock!", timestamp: "10:10 AM", shouldShowSenderDetails: true, sender: "Alice"),
TextRoomTimelineItem(id: UUID().uuidString, text: "You also rule!", timestamp: "10:11 AM", shouldShowSenderDetails: false, sender: "Alice"),
SeparatorRoomTimelineItem(id: UUID().uuidString, text: "Today"),
TextRoomTimelineItem(id: UUID().uuidString, text: "You too!", timestamp: "5 PM", shouldShowSenderDetails: true, sender: "Bob")]
func paginateBackwards(_ count: UInt, callback: ((Result<Void, RoomTimelineControllerError>) -> Void)) {
callbacks.send(.updatedTimelineItems)
}
func processItemAppearance(_ itemId: String) {
}
func processItemDisappearance(_ itemId: String) {
}
}

View File

@ -8,31 +8,31 @@
import Foundation
import Combine
import MatrixRustSDK
class RoomTimelineController: RoomTimelineControllerProtocol {
private let timelineProvider: RoomTimelineProvider
private let timelineItemFactory: TimelineItemFactory
private let timelineViewFactory: TimelineViewFactory
private let timelineItemFactory: RoomTimelineItemFactory
private let mediaProvider: MediaProviderProtocol
private var cancellables = Set<AnyCancellable>()
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
private(set) var timelineItems = [RoomTimelineViewProvider]()
private(set) var timelineItems = [RoomTimelineItemProtocol]()
init(timelineProvider: RoomTimelineProvider,
timelineItemFactory: TimelineItemFactory,
timelineViewFactory: TimelineViewFactory) {
timelineItemFactory: RoomTimelineItemFactory,
mediaProvider: MediaProviderProtocol) {
self.timelineProvider = timelineProvider
self.timelineItemFactory = timelineItemFactory
self.timelineViewFactory = timelineViewFactory
self.mediaProvider = mediaProvider
self.timelineProvider.callbacks.sink { [weak self] callback in
guard let self = self else { return }
switch callback {
case .addedMessage:
self.rebuildTimeline()
self.updateTimelineItems()
}
}.store(in: &cancellables)
}
@ -42,17 +42,58 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
switch result {
case .success:
callback(.success(()))
self?.rebuildTimeline()
self?.updateTimelineItems()
case .failure:
callback(.failure(.generic))
}
}
}
func processItemAppearance(_ itemId: String) {
guard let timelineItem = self.timelineItems.filter({ $0.id == itemId}).first else {
return
}
loadAvatarIfNeededForTimelineItem(timelineItem)
switch timelineItem {
case var item as ImageRoomTimelineItem:
if item.image != nil {
return
}
guard let url = item.url else {
return
}
mediaProvider.loadImageFromURL(url) { [weak self] result in
guard let self = self else {
return
}
if case let .success(image) = result {
guard let index = self.timelineItems.firstIndex(where: { $0.id == itemId }) else {
return
}
item.image = image
self.timelineItems[index] = item
self.callbacks.send(.updatedTimelineItem(itemId))
}
}
default:
break
}
}
func processItemDisappearance(_ itemId: String) {
}
// MARK: - Private
private func rebuildTimeline() {
var newTimelineItems = [RoomTimelineViewProvider]()
private func updateTimelineItems() {
var newTimelineItems = [RoomTimelineItemProtocol]()
var previousMessage: RoomMessageProtocol?
for message in self.timelineProvider.messages {
@ -60,17 +101,14 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
let shouldAddSectionHeader = !areMessagesFromTheSameDay
if shouldAddSectionHeader {
let item = SeparatorRoomTimelineItem(id: message.originServerTs.ISO8601Format(),
text: message.originServerTs.formatted(date: .long, time: .omitted))
newTimelineItems.append(RoomTimelineViewProvider.separator(item))
newTimelineItems.append(SeparatorRoomTimelineItem(id: message.originServerTs.ISO8601Format(),
text: message.originServerTs.formatted(date: .long, time: .omitted)))
}
let areMessagesFromTheSameSender = (previousMessage?.sender == message.sender)
let shouldShowSenderDetails = !areMessagesFromTheSameSender || !areMessagesFromTheSameDay
let timelineItem = timelineItemFactory.buildTimelineItemFor(message, showSenderDetails: shouldShowSenderDetails)
newTimelineItems.append(timelineViewFactory.buildTimelineViewFor(timelineItem))
newTimelineItems.append(timelineItemFactory.buildTimelineItemFor(message, showSenderDetails: shouldShowSenderDetails))
previousMessage = message
}
@ -87,4 +125,43 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
return Calendar.current.isDate(lhs.originServerTs, inSameDayAs: rhs.originServerTs)
}
private func loadAvatarIfNeededForTimelineItem(_ timelineItem: RoomTimelineItemProtocol) {
switch timelineItem {
case var item as BaseRoomTimelineItemProtocol:
if item.shouldShowSenderDetails == false {
break
}
timelineProvider.avatarURLForUserId(item.sender) { [weak self] result in
guard let self = self else {
return
}
switch result {
case .success(let userAvatarURL):
guard let avatarURL = userAvatarURL else {
return
}
self.mediaProvider.loadImageFromURL(avatarURL) { result in
if case let .success(image) = result {
guard let index = self.timelineItems.firstIndex(where: { $0.id == timelineItem.id }) else {
return
}
item.senderAvatar = image
self.timelineItems[index] = item
self.callbacks.send(.updatedTimelineItem(timelineItem.id))
}
}
case .failure:
MXLog.error("Failed retrieving user avatar")
}
}
default:
break
}
}
}

View File

@ -11,6 +11,7 @@ import Combine
enum RoomTimelineControllerCallback {
case updatedTimelineItems
case updatedTimelineItem(_ itemId: String)
}
enum RoomTimelineControllerError: Error {
@ -18,8 +19,12 @@ enum RoomTimelineControllerError: Error {
}
protocol RoomTimelineControllerProtocol {
var timelineItems: [RoomTimelineViewProvider] { get }
var timelineItems: [RoomTimelineItemProtocol] { get }
var callbacks: PassthroughSubject<RoomTimelineControllerCallback, Never> { get }
func paginateBackwards(_ count: UInt, callback: @escaping ((Result<Void, RoomTimelineControllerError>) -> Void))
func processItemAppearance(_ itemId: String)
func processItemDisappearance(_ itemId: String)
}

View File

@ -8,7 +8,6 @@
import Foundation
import Combine
import MatrixRustSDK
enum RoomTimelineCallback {
case addedMessage
@ -52,4 +51,16 @@ class RoomTimelineProvider {
}
}
}
// This is probably not the right place for this method. We need a RoomMemberProvider or something
func avatarURLForUserId(_ userId: String, completion: @escaping (Result<String?, RoomTimelineError>) -> Void) {
self.roomProxy.avatarURLForUserId(userId) { result in
switch result {
case .success(let avatarURL):
completion(.success(avatarURL))
case .failure:
completion(.failure(.generic))
}
}
}
}

View File

@ -7,11 +7,17 @@
//
import Foundation
import UIKit
struct ImageRoomTimelineItem: TimelineItemProtocol, Identifiable, Equatable {
struct ImageRoomTimelineItem: BaseRoomTimelineItemProtocol, Identifiable, Equatable {
let id: String
let senderDisplayName: String
let text: String
let timestamp: String
let shouldShowSenderDetails: Bool
let sender: String
var senderAvatar: UIImage?
let url: String?
var image: UIImage?
}

View File

@ -11,16 +11,50 @@ import SwiftUI
struct ImageRoomTimelineView: View {
let timelineItem: ImageRoomTimelineItem
var loadedImage: UIImage?
var body: some View {
if let loadedImage = loadedImage {
Image(uiImage: loadedImage)
} else {
if let image = timelineItem.image {
VStack {
Image(systemName: "photo")
ProgressView()
HStack {
Text(timelineItem.text)
Spacer()
}
Image(uiImage: image)
.resizable()
.scaledToFit()
}
} else {
VStack(alignment: .center) {
HStack {
Text(timelineItem.text)
Spacer()
}
ProgressView("Loading")
}
}
}
}
struct ImageRoomTimelineView_Previews: PreviewProvider {
static var previews: some View {
VStack {
let timelineItem = ImageRoomTimelineItem(id: UUID().uuidString,
text: "Some image",
timestamp: "Now",
shouldShowSenderDetails: false,
sender: "Bob",
url: nil,
image: UIImage(systemName: "photo"))
ImageRoomTimelineView(timelineItem: timelineItem)
let timelineItem = ImageRoomTimelineItem(id: UUID().uuidString,
text: "Some other image",
timestamp: "Now",
shouldShowSenderDetails: false,
sender: "Bob",
url: nil,
image: nil)
ImageRoomTimelineView(timelineItem: timelineItem)
}
}
}

View File

@ -7,22 +7,43 @@
//
import Foundation
import UIKit
struct TimelineItemFactory {
func buildTimelineItemFor(_ roomMessage: RoomMessageProtocol, showSenderDetails: Bool) -> TimelineItemProtocol {
struct RoomTimelineItemFactory {
private let mediaProvider: MediaProviderProtocol
init(mediaProvider: MediaProviderProtocol) {
self.mediaProvider = mediaProvider
}
func buildTimelineItemFor(_ roomMessage: RoomMessageProtocol, showSenderDetails: Bool) -> RoomTimelineItemProtocol {
switch roomMessage {
case let message as TextRoomMessage:
return TextRoomTimelineItem(id: message.id,
senderDisplayName: message.sender,
text: message.content,
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails)
shouldShowSenderDetails: showSenderDetails,
sender: message.sender)
case let message as ImageRoomMessage:
var image: UIImage?
if let url = message.url {
if mediaProvider.hasImageCachedForURL(url) {
mediaProvider.loadImageFromURL(url, { result in
if case let .success(cachedImage) = result {
image = cachedImage
}
})
}
}
return ImageRoomTimelineItem(id: message.id,
senderDisplayName: message.sender,
text: message.content,
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails)
shouldShowSenderDetails: showSenderDetails,
sender: message.sender,
url: message.url,
image: image)
default:
fatalError("Unknown room message.")
}

View File

@ -0,0 +1,23 @@
//
// RoomTimelineItemProtocol.swift
// ElementX
//
// Created by Stefan Ceriu on 16/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import UIKit
protocol RoomTimelineItemProtocol {
var id: String { get }
}
protocol BaseRoomTimelineItemProtocol: RoomTimelineItemProtocol {
var text: String { get }
var timestamp: String { get }
var shouldShowSenderDetails: Bool { get }
var sender: String { get }
var senderAvatar: UIImage? { get set }
}

View File

@ -0,0 +1,24 @@
//
// TimelineViewFactory.swift
// ElementX
//
// Created by Stefan Ceriu on 16/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
struct RoomTimelineViewFactory {
func buildTimelineViewFor(_ timelineItem: RoomTimelineItemProtocol) -> RoomTimelineViewProvider {
switch timelineItem {
case let item as TextRoomTimelineItem:
return .text(item)
case let item as ImageRoomTimelineItem:
return .image(item)
case let item as SeparatorRoomTimelineItem:
return .separator(item)
default:
fatalError("Unknown timeline item")
}
}
}

View File

@ -8,7 +8,7 @@
import Foundation
struct SeparatorRoomTimelineItem: TimelineItemProtocol, Identifiable, Equatable {
struct SeparatorRoomTimelineItem: RoomTimelineItemProtocol, Identifiable, Equatable {
let id: String
let text: String
}

View File

@ -7,11 +7,14 @@
//
import Foundation
import UIKit
struct TextRoomTimelineItem: TimelineItemProtocol, Identifiable, Equatable {
struct TextRoomTimelineItem: BaseRoomTimelineItemProtocol, Identifiable, Equatable {
let id: String
let senderDisplayName: String
let text: String
let timestamp: String
let shouldShowSenderDetails: Bool
let sender: String
var senderAvatar: UIImage?
}

View File

@ -16,7 +16,8 @@ struct TextRoomTimelineView: View {
VStack(alignment: .leading) {
if timelineItem.shouldShowSenderDetails {
HStack {
Text(timelineItem.senderDisplayName)
avatar
Text(timelineItem.sender)
.font(.footnote)
.bold()
Spacer()
@ -24,10 +25,48 @@ struct TextRoomTimelineView: View {
.font(.footnote)
}
Divider()
Spacer()
}
Text(timelineItem.text)
}
.id(timelineItem.id)
}
@ViewBuilder var avatar: some View {
ZStack(alignment: .center) {
Circle()
.fill(Color(.sRGB, red: 0.05, green: 0.74, blue: 0.55, opacity: 1.0))
if let avatar = timelineItem.senderAvatar {
Image(uiImage: avatar)
.resizable()
.clipShape(Circle())
} else {
Text(timelineItem.sender.prefix(2).suffix(1))
.foregroundColor(.white)
.font(.title)
.bold()
}
}
.frame(width: 44.0, height: 44.0)
}
}
struct TextRoomTimelineView_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 20.0) {
let timelineItem = TextRoomTimelineItem(id: UUID().uuidString,
text: "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic.",
timestamp: "Now",
shouldShowSenderDetails: true,
sender: "Bob")
TextRoomTimelineView(timelineItem: timelineItem)
let timelineItem = TextRoomTimelineItem(id: UUID().uuidString,
text: "Some other text",
timestamp: "Later",
shouldShowSenderDetails: true,
sender: "Anne")
TextRoomTimelineView(timelineItem: timelineItem)
}
.padding()
}
}

View File

@ -1,13 +0,0 @@
//
// TimelineItemProtocol.swift
// ElementX
//
// Created by Stefan Ceriu on 16/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
protocol TimelineItemProtocol {
}

View File

@ -1,22 +0,0 @@
//
// TimelineViewFactory.swift
// ElementX
//
// Created by Stefan Ceriu on 16/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
struct TimelineViewFactory {
func buildTimelineViewFor(_ timelineItem: TimelineItemProtocol) -> RoomTimelineViewProvider {
switch timelineItem {
case let textItem as TextRoomTimelineItem:
return .text(textItem)
case let imageItem as ImageRoomTimelineItem:
return .image(imageItem)
default:
fatalError("Unknown timeline item")
}
}
}