From c3fbb2230e7540a84417bda96ea3c5ba1935b37b Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Thu, 17 Mar 2022 18:09:29 +0200 Subject: [PATCH] Loading images and user avatars. --- ElementX.xcodeproj/project.pbxproj | 44 +++++-- ElementX/Sources/AppCoordinator.swift | 7 +- .../HomeScreen/HomeScreenCoordinator.swift | 8 +- .../HomeScreen/HomeScreenViewModel.swift | 40 ++----- .../Screens/HomeScreen/View/HomeScreen.swift | 4 +- .../RoomScreen/RoomScreenCoordinator.swift | 9 +- .../Screens/RoomScreen/RoomScreenModels.swift | 2 +- .../RoomScreen/RoomScreenViewModel.swift | 33 ++++-- .../Screens/RoomScreen/View/RoomScreen.swift | 14 +-- .../Services/Authentication/UserSession.swift | 24 +--- .../Services/Media/MediaProvider.swift | 75 ++++++++++++ .../Media/MediaProviderProtocol.swift | 21 ++++ .../Services/Media/MockMediaProvider.swift | 25 ++++ .../Room/Messages/ImageRoomMessage.swift | 4 + .../Sources/Services/Room/MockRoomProxy.swift | 15 ++- .../Sources/Services/Room/RoomProxy.swift | 34 ++---- .../Services/Room/RoomProxyProtocol.swift | 16 ++- .../Timeline/MockRoomTimelineController.swift | 18 ++- .../Timeline/RoomTimelineController.swift | 111 +++++++++++++++--- .../RoomTimelineControllerProtocol.swift | 9 +- .../Timeline/RoomTimelineProvider.swift | 13 +- .../TimelineItems/ImageRoomTimelineItem.swift | 10 +- .../TimelineItems/ImageRoomTimelineView.swift | 46 +++++++- ...ry.swift => RoomTimelineItemFactory.swift} | 33 +++++- .../RoomTimelineItemProtocol.swift | 23 ++++ .../RoomTimelineViewFactory.swift | 24 ++++ .../SeparatorRoomTimelineItem.swift | 2 +- .../TimelineItems/TextRoomTimelineItem.swift | 7 +- .../TimelineItems/TextRoomTimelineView.swift | 43 ++++++- .../TimelineItems/TimelineItemProtocol.swift | 13 -- .../TimelineItems/TimelineViewFactory.swift | 22 ---- 31 files changed, 542 insertions(+), 207 deletions(-) create mode 100644 ElementX/Sources/Services/Media/MediaProvider.swift create mode 100644 ElementX/Sources/Services/Media/MediaProviderProtocol.swift create mode 100644 ElementX/Sources/Services/Media/MockMediaProvider.swift rename ElementX/Sources/Services/Timeline/TimelineItems/{TimelineItemFactory.swift => RoomTimelineItemFactory.swift} (51%) create mode 100644 ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemProtocol.swift create mode 100644 ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactory.swift delete mode 100644 ElementX/Sources/Services/Timeline/TimelineItems/TimelineItemProtocol.swift delete mode 100644 ElementX/Sources/Services/Timeline/TimelineItems/TimelineViewFactory.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index e7c34d7aa..0d4461dc1 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -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 = ""; }; 18C5745327E1D88E00D70937 /* TextRoomMessage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextRoomMessage.swift; sourceTree = ""; }; 18C5745527E1DCA800D70937 /* RoomMessageFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomMessageFactory.swift; sourceTree = ""; }; - 18C5745727E1EB6E00D70937 /* TimelineItemFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemFactory.swift; sourceTree = ""; }; - 18DF7C2927E23E3A00291672 /* TimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineItemProtocol.swift; sourceTree = ""; }; - 18DF7C2B27E23EC000291672 /* TimelineViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineViewFactory.swift; sourceTree = ""; }; + 18C5745727E1EB6E00D70937 /* RoomTimelineItemFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemFactory.swift; sourceTree = ""; }; + 18DF7C2927E23E3A00291672 /* RoomTimelineItemProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineItemProtocol.swift; sourceTree = ""; }; + 18DF7C2B27E23EC000291672 /* RoomTimelineViewFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineViewFactory.swift; sourceTree = ""; }; + 18DF7C2E27E264FC00291672 /* MediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProvider.swift; sourceTree = ""; }; + 18DF7C3027E3608100291672 /* MediaProviderProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaProviderProtocol.swift; sourceTree = ""; }; + 18DF7C3227E3608800291672 /* MockMediaProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockMediaProvider.swift; sourceTree = ""; }; 18F2BA7727D25B4000DD1988 /* RoomTimelineProvider.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RoomTimelineProvider.swift; sourceTree = ""; }; 18F2BA7927D25B4000DD1988 /* AuthenticationCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AuthenticationCoordinator.swift; sourceTree = ""; }; 18F2BA7A27D25B4000DD1988 /* UserSession.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UserSession.swift; sourceTree = ""; }; @@ -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 = ""; }; + 18DF7C2D27E264EE00291672 /* Media */ = { + isa = PBXGroup; + children = ( + 18DF7C3027E3608100291672 /* MediaProviderProtocol.swift */, + 18DF7C2E27E264FC00291672 /* MediaProvider.swift */, + 18DF7C3227E3608800291672 /* MockMediaProvider.swift */, + ); + path = Media; + sourceTree = ""; + }; 18F2BA7227D25B4000DD1988 /* Services */ = { isa = PBXGroup; children = ( 18F2BA7827D25B4000DD1988 /* Authentication */, 18C5744727E1D84000D70937 /* Room */, 18F2BA7627D25B4000DD1988 /* Timeline */, + 18DF7C2D27E264EE00291672 /* Media */, ); path = Services; sourceTree = ""; @@ -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 */, ); diff --git a/ElementX/Sources/AppCoordinator.swift b/ElementX/Sources/AppCoordinator.swift index f51603bc1..4d9357c1e 100644 --- a/ElementX/Sources/AppCoordinator.swift +++ b/ElementX/Sources/AppCoordinator.swift @@ -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) diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift index 20d98e347..e11f38f34 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenCoordinator.swift @@ -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) diff --git a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift index 5dfd9f1ed..e9b7b441e 100644 --- a/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift +++ b/ElementX/Sources/Screens/HomeScreen/HomeScreenViewModel.swift @@ -16,7 +16,6 @@ import SwiftUI import Combine -import Kingfisher @available(iOS 14, *) typealias HomeScreenViewModelType = StateStoreViewModel() 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) } } } diff --git a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift index f67d7a722..3f3a68bcf 100644 --- a/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift +++ b/ElementX/Sources/Screens/HomeScreen/View/HomeScreen.swift @@ -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"), diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index a7f3ef8e8..cdb14256d 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -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) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift index ac4217f23..bbaea01d8 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenModels.swift @@ -28,6 +28,6 @@ enum RoomScreenViewAction { struct RoomScreenViewState: BindableState { var roomTitle: String = "" - var timelineItems: [RoomTimelineViewProvider] = [] + var items: [RoomTimelineViewProvider] = [] var isBackPaginating = false } diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index de5ebaa84..e2a168146 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -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) } } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index b35927fd9..dc26cb76f 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -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) } diff --git a/ElementX/Sources/Services/Authentication/UserSession.swift b/ElementX/Sources/Services/Authentication/UserSession.swift index 402dcc294..fac40baf6 100644 --- a/ElementX/Sources/Services/Authentication/UserSession.swift +++ b/ElementX/Sources/Services/Authentication/UserSession.swift @@ -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) -> 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) } diff --git a/ElementX/Sources/Services/Media/MediaProvider.swift b/ElementX/Sources/Services/Media/MediaProvider.swift new file mode 100644 index 000000000..7297a1b2a --- /dev/null +++ b/ElementX/Sources/Services/Media/MediaProvider.swift @@ -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) -> 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) -> 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)) + } + } + } + } +} diff --git a/ElementX/Sources/Services/Media/MediaProviderProtocol.swift b/ElementX/Sources/Services/Media/MediaProviderProtocol.swift new file mode 100644 index 000000000..44e4fbe7f --- /dev/null +++ b/ElementX/Sources/Services/Media/MediaProviderProtocol.swift @@ -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) -> Void) + func hasImageCachedForURL(_ url: String) -> Bool + func loadImageFromURL(_ url: String, _ completion: @escaping (Result) -> Void) +} diff --git a/ElementX/Sources/Services/Media/MockMediaProvider.swift b/ElementX/Sources/Services/Media/MockMediaProvider.swift new file mode 100644 index 000000000..208283287 --- /dev/null +++ b/ElementX/Sources/Services/Media/MockMediaProvider.swift @@ -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) -> Void) { + + } + + func hasImageCachedForURL(_ url: String) -> Bool { + true + } + + func loadImageFromURL(_ url: String, _ completion: @escaping (Result) -> Void) { + + } +} diff --git a/ElementX/Sources/Services/Room/Messages/ImageRoomMessage.swift b/ElementX/Sources/Services/Room/Messages/ImageRoomMessage.swift index c7e2f3d6d..67c218136 100644 --- a/ElementX/Sources/Services/Room/Messages/ImageRoomMessage.swift +++ b/ElementX/Sources/Services/Room/Messages/ImageRoomMessage.swift @@ -31,4 +31,8 @@ struct ImageRoomMessage: RoomMessageProtocol { var originServerTs: Date { Date(timeIntervalSince1970: TimeInterval(message.baseMessage().originServerTs())) } + + var url: String? { + message.url() + } } diff --git a/ElementX/Sources/Services/Room/MockRoomProxy.swift b/ElementX/Sources/Services/Room/MockRoomProxy.swift index 933469ebe..836f5c1c0 100644 --- a/ElementX/Sources/Services/Room/MockRoomProxy.swift +++ b/ElementX/Sources/Services/Room/MockRoomProxy.swift @@ -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() - func loadDisplayName(_ completion: @escaping (Result) -> Void) { + func loadDisplayName(_ completion: @escaping (Result) -> Void) { completion(.success(displayName)) } - func loadAvatar(_ completion: (Result) -> 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) -> Void) { } } diff --git a/ElementX/Sources/Services/Room/RoomProxy.swift b/ElementX/Sources/Services/Room/RoomProxy.swift index 068902a66..d286444ad 100644 --- a/ElementX/Sources/Services/Room/RoomProxy.swift +++ b/ElementX/Sources/Services/Room/RoomProxy.swift @@ -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) -> Void) { + func avatarURLForUserId(_ userId: String, completion: @escaping (Result) -> 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) -> Void) { + func loadDisplayName(_ completion: @escaping (Result) -> 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 } diff --git a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift index 18becfd48..27227beef 100644 --- a/ElementX/Sources/Services/Room/RoomProxyProtocol.swift +++ b/ElementX/Sources/Services/Room/RoomProxyProtocol.swift @@ -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) -> Void) - func loadAvatar(_ completion: @escaping (Result) -> Void) + func avatarURLForUserId(_ userId: String, completion: @escaping (Result) -> Void) - func paginateBackwards(count: UInt, callback: ((Result<[RoomMessageProtocol], Error>) -> Void)?) + func loadDisplayName(_ completion: @escaping (Result) -> Void) + + func paginateBackwards(count: UInt, callback: ((Result<[RoomMessageProtocol], RoomProxyError>) -> Void)?) var callbacks: PassthroughSubject { get } } diff --git a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift index 436b414a2..317372d7d 100644 --- a/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/MockRoomTimelineController.swift @@ -13,13 +13,21 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol { let callbacks = PassthroughSubject() - 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)) { callbacks.send(.updatedTimelineItems) } + + func processItemAppearance(_ itemId: String) { + + } + + func processItemDisappearance(_ itemId: String) { + + } } diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/RoomTimelineController.swift index 1eaa655f9..b41120a8c 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineController.swift @@ -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() let callbacks = PassthroughSubject() - 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 + } + } } diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift index 028ea1b0e..d813eaf9a 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineControllerProtocol.swift @@ -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 { get } - + func paginateBackwards(_ count: UInt, callback: @escaping ((Result) -> Void)) + + func processItemAppearance(_ itemId: String) + + func processItemDisappearance(_ itemId: String) } diff --git a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift index 6f16911b1..23b2f984d 100644 --- a/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift +++ b/ElementX/Sources/Services/Timeline/RoomTimelineProvider.swift @@ -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) -> Void) { + self.roomProxy.avatarURLForUserId(userId) { result in + switch result { + case .success(let avatarURL): + completion(.success(avatarURL)) + case .failure: + completion(.failure(.generic)) + } + } + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/ImageRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/ImageRoomTimelineItem.swift index 09e2308e9..46e8ca44f 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/ImageRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/ImageRoomTimelineItem.swift @@ -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? } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/ImageRoomTimelineView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/ImageRoomTimelineView.swift index 8c207dc8f..2f0ca15b4 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/ImageRoomTimelineView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/ImageRoomTimelineView.swift @@ -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) + } + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/TimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift similarity index 51% rename from ElementX/Sources/Services/Timeline/TimelineItems/TimelineItemFactory.swift rename to ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 3b095b39a..d011ec3f8 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/TimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -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.") } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemProtocol.swift new file mode 100644 index 000000000..1d663d9ed --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemProtocol.swift @@ -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 } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactory.swift new file mode 100644 index 000000000..3029650bc --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactory.swift @@ -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") + } + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/SeparatorRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/SeparatorRoomTimelineItem.swift index 75fa313fe..26debc00f 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/SeparatorRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/SeparatorRoomTimelineItem.swift @@ -8,7 +8,7 @@ import Foundation -struct SeparatorRoomTimelineItem: TimelineItemProtocol, Identifiable, Equatable { +struct SeparatorRoomTimelineItem: RoomTimelineItemProtocol, Identifiable, Equatable { let id: String let text: String } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/TextRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/TextRoomTimelineItem.swift index 3b1f3abd5..052930ca5 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/TextRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/TextRoomTimelineItem.swift @@ -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? } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/TextRoomTimelineView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/TextRoomTimelineView.swift index ef3695f97..d32a34ec7 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/TextRoomTimelineView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/TextRoomTimelineView.swift @@ -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() + } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/TimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/TimelineItemProtocol.swift deleted file mode 100644 index eae97e5ad..000000000 --- a/ElementX/Sources/Services/Timeline/TimelineItems/TimelineItemProtocol.swift +++ /dev/null @@ -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 { - -} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/TimelineViewFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/TimelineViewFactory.swift deleted file mode 100644 index 276d53b82..000000000 --- a/ElementX/Sources/Services/Timeline/TimelineItems/TimelineViewFactory.swift +++ /dev/null @@ -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") - } - } -}