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
@@ -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) {
}
}
@@ -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
}
}
}
@@ -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)
}
@@ -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))
}
}
}
}
@@ -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?
}
@@ -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)
}
}
}
@@ -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.")
}
@@ -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 }
}
@@ -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")
}
}
}
@@ -8,7 +8,7 @@
import Foundation
struct SeparatorRoomTimelineItem: TimelineItemProtocol, Identifiable, Equatable {
struct SeparatorRoomTimelineItem: RoomTimelineItemProtocol, Identifiable, Equatable {
let id: String
let text: String
}
@@ -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?
}
@@ -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()
}
}
@@ -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 {
}
@@ -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")
}
}
}