Introduced MemberDetailsProvider, caching user avatar urls and images. Various other tweaks and improvements.

This commit is contained in:
Stefan Ceriu
2022-03-18 13:40:25 +02:00
parent 120bcaf012
commit 2eae0f0573
22 changed files with 346 additions and 203 deletions
+12 -1
View File
@@ -113,7 +113,18 @@ class AppCoordinator: AuthenticationCoordinatorDelegate, Coordinator {
return
}
let parameters = RoomScreenCoordinatorParameters(roomProxy: roomProxy, mediaProvider: userSession.mediaProvider)
let memberDetailsProvider = MemberDetailsProvider(roomProxy: roomProxy)
let timelineItemFactory = RoomTimelineItemFactory(mediaProvider: userSession.mediaProvider,
memberDetailsProvider: memberDetailsProvider)
let timelineController = RoomTimelineController(timelineProvider: RoomTimelineProvider(roomProxy: roomProxy),
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider,
memberDetailsProvider: memberDetailsProvider)
let parameters = RoomScreenCoordinatorParameters(timelineController: timelineController,
roomName: roomProxy.name)
let coordinator = RoomScreenCoordinator(parameters: parameters)
self.add(childCoordinator: coordinator)
@@ -17,8 +17,8 @@
import SwiftUI
struct RoomScreenCoordinatorParameters {
let roomProxy: RoomProxyProtocol
let mediaProvider: MediaProviderProtocol
let timelineController: RoomTimelineControllerProtocol
let roomName: String?
}
final class RoomScreenCoordinator: Coordinator, Presentable {
@@ -42,14 +42,10 @@ final class RoomScreenCoordinator: Coordinator, Presentable {
init(parameters: RoomScreenCoordinatorParameters) {
self.parameters = parameters
let timelineProvider = RoomTimelineProvider(roomProxy: parameters.roomProxy)
let timelineController = RoomTimelineController(timelineProvider: timelineProvider,
timelineItemFactory: RoomTimelineItemFactory(mediaProvider: parameters.mediaProvider),
mediaProvider: parameters.mediaProvider)
let viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
timelineViewFactory: RoomTimelineViewFactory(),
roomName: parameters.roomName)
let viewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
timelineController: timelineController,
timelineViewFactory: RoomTimelineViewFactory())
let view = RoomScreen(context: viewModel.context)
roomScreenViewModel = viewModel
roomScreenHostingController = UIHostingController(rootView: view)
@@ -18,8 +18,8 @@ import SwiftUI
@available(iOS 14, *)
typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState,
Never,
RoomScreenViewAction>
Never,
RoomScreenViewAction>
@available(iOS 14, *)
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
@@ -27,22 +27,19 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
static let backPaginationPageSize: UInt = 30
}
private let roomProxy: RoomProxyProtocol
private let timelineController: RoomTimelineControllerProtocol
private let timelineViewFactory: RoomTimelineViewFactory
// MARK: - Setup
init(roomProxy: RoomProxyProtocol,
timelineController: RoomTimelineControllerProtocol,
timelineViewFactory: RoomTimelineViewFactory) {
self.roomProxy = roomProxy
init(timelineController: RoomTimelineControllerProtocol,
timelineViewFactory: RoomTimelineViewFactory,
roomName: String?) {
self.timelineController = timelineController
self.timelineViewFactory = timelineViewFactory
super.init(initialViewState: RoomScreenViewState())
super.init(initialViewState: RoomScreenViewState(roomTitle: roomName ?? "💥"))
state.roomTitle = roomProxy.name ?? ""
buildTimelineViews()
timelineController.callbacks.sink { [weak self] callback in
@@ -240,9 +240,9 @@ private class TableViewObserver: NSObject, UITableViewDelegate {
struct RoomScreen_Previews: PreviewProvider {
static var previews: some View {
let viewModel = RoomScreenViewModel(roomProxy: MockRoomProxy(displayName: "Test"),
timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory())
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
roomName: "Preview room")
RoomScreen(context: viewModel.context)
}
@@ -13,16 +13,18 @@ import Kingfisher
struct MediaProvider: MediaProviderProtocol {
private let client: Client
private let imageCache: Kingfisher.ImageCache
private let processingQueue: DispatchQueue
init(client: Client, imageCache: Kingfisher.ImageCache) {
self.client = client
self.imageCache = imageCache
self.processingQueue = DispatchQueue(label: "MediaProviderProcessingQueue")
}
func loadCurrentUserAvatar(_ completion: @escaping (Result<UIImage?, MediaProviderError>) -> Void) {
DispatchQueue.global(qos: .background).async {
processingQueue.async {
do {
let imageData = try self.client.avatar()
let imageData = try client.avatar()
DispatchQueue.main.async {
completion(.success(UIImage(data: Data(bytes: imageData, count: imageData.count))))
}
@@ -35,21 +37,25 @@ struct MediaProvider: MediaProviderProtocol {
}
}
func hasImageCachedForURL(_ url: String) -> Bool {
self.imageCache.imageCachedType(forKey: url) == .memory
func imageForURL(_ url: String?) -> UIImage? {
guard let url = url else {
return nil
}
return imageCache.retrieveImageInMemoryCache(forKey: url, options: nil)
}
func loadImageFromURL(_ url: String, _ completion: @escaping (Result<UIImage, MediaProviderError>) -> Void) {
self.imageCache.retrieveImage(forKey: url) { result in
imageCache.retrieveImage(forKey: url) { result in
if case let .success(cacheResult) = result,
let image = cacheResult.image {
completion(.success(image))
}
}
DispatchQueue.global(qos: .background).async {
processingQueue.async {
do {
let imageData = try self.client.loadImage(url: url)
let imageData = try client.loadImage(url: url)
guard let image = UIImage(data: Data(bytes: imageData, count: imageData.count)) else {
MXLog.error("Invalid image data")
@@ -59,7 +65,7 @@ struct MediaProvider: MediaProviderProtocol {
return
}
self.imageCache.store(image, forKey: url)
imageCache.store(image, forKey: url)
DispatchQueue.main.async {
completion(.success(image))
@@ -16,6 +16,8 @@ enum MediaProviderError: Error {
protocol MediaProviderProtocol {
func loadCurrentUserAvatar(_ completion: @escaping (Result<UIImage?, MediaProviderError>) -> Void)
func hasImageCachedForURL(_ url: String) -> Bool
func imageForURL(_ url: String?) -> UIImage?
func loadImageFromURL(_ url: String, _ completion: @escaping (Result<UIImage, MediaProviderError>) -> Void)
}
@@ -15,8 +15,8 @@ struct MockMediaProvider: MediaProviderProtocol {
}
func hasImageCachedForURL(_ url: String) -> Bool {
true
func imageForURL(_ url: String?) -> UIImage? {
return nil
}
func loadImageFromURL(_ url: String, _ completion: @escaping (Result<UIImage, MediaProviderError>) -> Void) {
@@ -0,0 +1,58 @@
//
// MemberDetailsProvider.swift
// ElementX
//
// Created by Stefan Ceriu on 18/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
enum MemberDetailsProviderError: Error {
case invalidRoomProxy
case failedRetrievingUserAvatarURL
}
class MemberDetailsProvider {
private let roomProxy: RoomProxyProtocol?
private let processingQueue = DispatchQueue(label: "MemberDetailsProviderProcessingQueue")
private var memberAvatars = [String: String]()
init(roomProxy: RoomProxyProtocol) {
self.roomProxy = roomProxy
}
func avatarURLForUserId(_ userId: String) -> String? {
self.memberAvatars[userId]
}
func avatarURLForUserId(_ userId: String, completion: @escaping (Result<String?, MemberDetailsProviderError>) -> Void) {
guard let roomProxy = roomProxy else {
return
}
if let avatarURL = avatarURLForUserId(userId) {
completion(.success(avatarURL))
}
processingQueue.async {
roomProxy.avatarURLForUserId(userId, completion: { [weak self] result in
guard let self = self else {
return
}
switch result {
case .success(let avatarURL):
DispatchQueue.main.async {
self.memberAvatars[userId] = avatarURL
completion(.success(avatarURL))
}
case .failure:
DispatchQueue.main.async {
completion(.failure(.failedRetrievingUserAvatarURL))
}
}
})
}
}
}
@@ -91,7 +91,7 @@ class RoomProxy: RoomProxyProtocol, Equatable {
}
func avatarURLForUserId(_ userId: String, completion: @escaping (Result<String?, RoomProxyError>) -> Void) {
DispatchQueue.global(qos: .background).async {
processingQueue.async {
do {
let avatarURL = try self.room.memberAvatarUrl(userId: userId)
@@ -107,7 +107,7 @@ class RoomProxy: RoomProxyProtocol, Equatable {
}
func loadDisplayName(_ completion: @escaping (Result<String, RoomProxyError>) -> Void) {
DispatchQueue.global(qos: .background).async {
processingQueue.async {
do {
let displayName = try self.room.displayName()
@@ -123,7 +123,6 @@ class RoomProxy: RoomProxyProtocol, Equatable {
}
func paginateBackwards(count: UInt, callback: ((Result<[RoomMessageProtocol], RoomProxyError>) -> Void)?) {
MXLog.debug("Started backpaginating")
processingQueue.async {
guard let backwardStream = self.backwardStream else {
DispatchQueue.main.async {
@@ -136,8 +135,6 @@ class RoomProxy: RoomProxyProtocol, Equatable {
self.messageFactory.buildRoomMessageFrom(message)
}
MXLog.debug("Finished backpaginating")
DispatchQueue.main.async {
callback?(.success(messages))
if self.lastMessage == nil {
@@ -13,6 +13,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
private let timelineProvider: RoomTimelineProvider
private let timelineItemFactory: RoomTimelineItemFactory
private let mediaProvider: MediaProviderProtocol
private let memberDetailsProvider: MemberDetailsProvider
private var cancellables = Set<AnyCancellable>()
@@ -22,10 +23,12 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
init(timelineProvider: RoomTimelineProvider,
timelineItemFactory: RoomTimelineItemFactory,
mediaProvider: MediaProviderProtocol) {
mediaProvider: MediaProviderProtocol,
memberDetailsProvider: MemberDetailsProvider) {
self.timelineProvider = timelineProvider
self.timelineItemFactory = timelineItemFactory
self.mediaProvider = mediaProvider
self.memberDetailsProvider = memberDetailsProvider
self.timelineProvider.callbacks.sink { [weak self] callback in
guard let self = self else { return }
@@ -54,33 +57,13 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
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 let item = timelineItem as? EventBasedTimelineItemProtocol {
loadAvatarForTimelineItem(item)
}
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))
}
}
switch timelineItem {
case let item as ImageRoomTimelineItem:
loadImageForTimelineItem(item)
default:
break
}
@@ -126,42 +109,56 @@ 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
private func loadImageForTimelineItem(_ timelineItem: ImageRoomTimelineItem) {
var item = timelineItem
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
}
timelineProvider.avatarURLForUserId(item.sender) { [weak self] result in
guard let self = self else {
if case let .success(image) = result {
guard let index = self.timelineItems.firstIndex(where: { $0.id == timelineItem.id }) 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))
item.image = image
self.timelineItems[index] = item
self.callbacks.send(.updatedTimelineItem(timelineItem.id))
}
}
}
private func loadAvatarForTimelineItem(_ timelineItem: EventBasedTimelineItemProtocol) {
var item = timelineItem
if item.shouldShowSenderDetails == false {
return
}
memberDetailsProvider.avatarURLForUserId(timelineItem.sender) { result in
if case let .success(avatarURL) = result,
let avatarURL = avatarURL {
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
}
}
}
@@ -0,0 +1,13 @@
//
// DecorationTimelineItemProtocol.swift
// ElementX
//
// Created by Stefan Ceriu on 18/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
protocol DecorationTimelineItemProtocol: RoomTimelineItemProtocol {
}
@@ -0,0 +1,19 @@
//
// EventBasedTimelineItemProtocol.swift
// ElementX
//
// Created by Stefan Ceriu on 18/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import UIKit
protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol {
var text: String { get }
var timestamp: String { get }
var shouldShowSenderDetails: Bool { get }
var sender: String { get }
var senderAvatar: UIImage? { get set }
}
@@ -9,7 +9,7 @@
import Foundation
import UIKit
struct ImageRoomTimelineItem: BaseRoomTimelineItemProtocol, Identifiable, Equatable {
struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable {
let id: String
let text: String
let timestamp: String
@@ -8,7 +8,7 @@
import Foundation
struct SeparatorRoomTimelineItem: RoomTimelineItemProtocol, Identifiable, Equatable {
struct SeparatorRoomTimelineItem: DecorationTimelineItemProtocol, Identifiable, Equatable {
let id: String
let text: String
}
@@ -9,7 +9,7 @@
import Foundation
import UIKit
struct TextRoomTimelineItem: BaseRoomTimelineItemProtocol, Identifiable, Equatable {
struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Equatable {
let id: String
let text: String
let timestamp: String
@@ -11,39 +11,36 @@ import UIKit
struct RoomTimelineItemFactory {
private let mediaProvider: MediaProviderProtocol
private let memberDetailsProvider: MemberDetailsProvider
init(mediaProvider: MediaProviderProtocol) {
init(mediaProvider: MediaProviderProtocol,
memberDetailsProvider: MemberDetailsProvider) {
self.mediaProvider = mediaProvider
self.memberDetailsProvider = memberDetailsProvider
}
func buildTimelineItemFor(_ roomMessage: RoomMessageProtocol, showSenderDetails: Bool) -> RoomTimelineItemProtocol {
let avatarURL = memberDetailsProvider.avatarURLForUserId(roomMessage.sender)
let avatarImage = mediaProvider.imageForURL(avatarURL)
switch roomMessage {
case let message as TextRoomMessage:
return TextRoomTimelineItem(id: message.id,
text: message.content,
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails,
sender: message.sender)
sender: message.sender,
senderAvatar: avatarImage)
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,
text: message.content,
timestamp: message.originServerTs.formatted(date: .omitted, time: .shortened),
shouldShowSenderDetails: showSenderDetails,
sender: message.sender,
senderAvatar: avatarImage,
url: message.url,
image: image)
image: mediaProvider.imageForURL(message.url))
default:
fatalError("Unknown room message.")
}
@@ -12,12 +12,3 @@ 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 }
}
@@ -0,0 +1,44 @@
//
// EventBasedTimelineView.swift
// ElementX
//
// Created by Stefan Ceriu on 18/03/2022.
// Copyright © 2022 Element. All rights reserved.
//
import Foundation
import SwiftUI
struct EventBasedTimelineView: View {
let timelineItem: EventBasedTimelineItemProtocol
var body: some View {
if timelineItem.shouldShowSenderDetails {
HStack {
avatar
Text(timelineItem.sender)
.font(.footnote)
.bold()
Spacer()
Text(timelineItem.timestamp)
.font(.footnote)
}
Divider()
}
}
@ViewBuilder private var avatar: some View {
ZStack(alignment: .center) {
if let avatar = timelineItem.senderAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.overlay(Circle().stroke(Color(.sRGB, red: 0.05, green: 0.74, blue: 0.55, opacity: 1.0)))
} else {
PlaceholderAvatarImage(firstCharacter: String(timelineItem.sender.prefix(2).suffix(1)).uppercased())
}
}
.clipShape(Circle())
.frame(width: 44.0, height: 44.0)
}
}
@@ -14,11 +14,9 @@ struct ImageRoomTimelineView: View {
var body: some View {
if let image = timelineItem.image {
VStack {
HStack {
Text(timelineItem.text)
Spacer()
}
VStack(alignment: .leading) {
EventBasedTimelineView(timelineItem: timelineItem)
Text(timelineItem.text)
Image(uiImage: image)
.resizable()
.scaledToFit()
@@ -14,40 +14,13 @@ struct TextRoomTimelineView: View {
var body: some View {
VStack(alignment: .leading) {
if timelineItem.shouldShowSenderDetails {
HStack {
avatar
Text(timelineItem.sender)
.font(.footnote)
.bold()
Spacer()
Text(timelineItem.timestamp)
.font(.footnote)
}
Divider()
}
EventBasedTimelineView(timelineItem: timelineItem)
Text(timelineItem.text)
.fixedSize(horizontal: false, vertical: true)
}
.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 {