Media loading flow changes (#483)

* Use an imageProvider directly from the view in the home screen

* Add support for media request coalescing

* Rename MediaProxy to MediaLoader

* Add new image loading mechanism to the room details screen avatar.

* Use the `SettingsScreen` prefix for all settings screen related components

* Add new image loading mechanism to the room header

* Add new image loading mechanism to the room member details screen

* Introduce a LoadableImage SwiftUI view that will automatically handle image loading

* Adopt the new LoadableImage where possible

* Fix LoadableImage not using/storing loaded images properly

* Simplify media loader enqueueing

* Made LodableImage load content after mediaSource updates. Adopt it on the home and settings screens

* Introduce a LoadableAvatarImage component and reuse it throughout the app

* Small logging tweaks, made some LoadableImage properties private

* Fix redacted skeletons avatar background color

* Fix placeholder avatars changing when backgrounding the app

* PR comments.

- Trim the @ sign off of mxid placeholders.
- Only expose AvatarSize on the avatar image, use CGSize elsewhere.

Co-authored-by: Doug <douglase@element.io>
This commit is contained in:
Stefan Ceriu
2023-01-25 19:45:01 +02:00
committed by GitHub
parent 3a74f13097
commit bd4c0b72cb
58 changed files with 875 additions and 830 deletions
@@ -25,11 +25,7 @@ class AppCoordinator: AppCoordinatorProtocol {
/// Common background task to resume long-running tasks in the background.
/// When this task expiring, we'll try to suspend the state machine by `suspend` event.
private var backgroundTask: BackgroundTaskProtocol?
private var isSuspended = false {
didSet {
MXLog.info("didSet to: \(isSuspended)")
}
}
private var isSuspended = false
private var userSession: UserSessionProtocol! {
didSet {
@@ -36,6 +36,10 @@ class ViewModelContext<ViewState: BindableState, ViewAction>: ObservableObject {
/// Get-able/Observable `Published` property for the `ViewState`
@Published fileprivate(set) var viewState: ViewState
/// An optional image loading service so that views can manage themselves
/// Intentionally non-generic so that it doesn't grow uncontrollably
let imageProvider: ImageProviderProtocol?
/// Set-able/Bindable access to the bindable state.
subscript<T>(dynamicMember keyPath: WritableKeyPath<ViewState.BindStateType, T>) -> T {
@@ -43,9 +47,10 @@ class ViewModelContext<ViewState: BindableState, ViewAction>: ObservableObject {
set { viewState.bindings[keyPath: keyPath] = newValue }
}
init(initialViewState: ViewState) {
init(initialViewState: ViewState, imageProvider: ImageProviderProtocol?) {
self.viewActions = PassthroughSubject()
self.viewState = initialViewState
self.imageProvider = imageProvider
}
/// Send a `ViewAction` to the `ViewModel` for processing.
@@ -78,8 +83,8 @@ class StateStoreViewModel<State: BindableState, ViewAction> {
set { context.viewState = newValue }
}
init(initialViewState: State) {
context = Context(initialViewState: initialViewState)
init(initialViewState: State, imageProvider: ImageProviderProtocol? = nil) {
context = Context(initialViewState: initialViewState, imageProvider: imageProvider)
context.viewActions
.sink { [weak self] action in
guard let self else { return }
@@ -0,0 +1,50 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct LoadableAvatarImage: View {
private let url: URL?
private let name: String?
private let contentID: String?
private let avatarSize: AvatarSize
private let imageProvider: ImageProviderProtocol?
@ScaledMetric private var frameSize: CGFloat
init(url: URL?, name: String?, contentID: String?, avatarSize: AvatarSize, imageProvider: ImageProviderProtocol?) {
self.url = url
self.name = name
self.contentID = contentID
self.avatarSize = avatarSize
self.imageProvider = imageProvider
_frameSize = ScaledMetric(wrappedValue: avatarSize.value)
}
var body: some View {
LoadableImage(url: url,
size: avatarSize.scaledSize,
imageProvider: imageProvider) { image in
image
.scaledToFill()
} placeholder: {
PlaceholderAvatarImage(name: name, contentID: contentID)
}
.frame(width: frameSize, height: frameSize)
.clipShape(Circle())
}
}
@@ -0,0 +1,102 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct LoadableImage<TransformerView: View, PlaceholderView: View>: View {
private let mediaSource: MediaSourceProxy?
private let blurhash: String?
private let size: CGSize?
private let imageProvider: ImageProviderProtocol?
private var transformer: (Image) -> TransformerView
private let placeholder: () -> PlaceholderView
@State private var cachedImage: UIImage?
/// A SwiftUI view that automatically fetches images
/// It will try fetching the image from in-memory cache and if that's not available
/// it will fire a task to load it through the image provider
/// - Parameters:
/// - mediaSource: the source of the image
/// - blurhash: an optional blurhash
/// - transformer: entry point for configuring the resulting image view
/// - placeholder: a view to show while the image or blurhash are not available
init(mediaSource: MediaSourceProxy?,
blurhash: String? = nil,
size: CGSize? = nil,
imageProvider: ImageProviderProtocol?,
transformer: @escaping (Image) -> TransformerView = { $0 },
placeholder: @escaping () -> PlaceholderView) {
self.mediaSource = mediaSource
self.blurhash = blurhash
self.size = size
self.imageProvider = imageProvider
self.transformer = transformer
self.placeholder = placeholder
}
init(url: URL?,
blurhash: String? = nil,
size: CGSize? = nil,
imageProvider: ImageProviderProtocol?,
transformer: @escaping (Image) -> TransformerView = { $0 },
placeholder: @escaping () -> PlaceholderView) {
let mediaSource = url.map(MediaSourceProxy.init)
self.init(mediaSource: mediaSource,
blurhash: blurhash,
size: size,
imageProvider: imageProvider,
transformer: transformer,
placeholder: placeholder)
}
var body: some View {
let _ = Task {
// Future improvement: Does guarding against a nil image prevent the image being updated when the URL changes?
guard image == nil, let mediaSource else { return }
if case let .success(image) = await imageProvider?.loadImageFromSource(mediaSource, size: size) {
self.cachedImage = image
}
}
ZStack {
if let image {
transformer(
Image(uiImage: image)
.resizable()
)
} else if let blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
transformer(
Image(uiImage: image)
.resizable()
)
} else {
placeholder()
}
}
.animation(.elementDefault, value: image)
}
private var image: UIImage? {
cachedImage ?? imageProvider?.imageFromSource(mediaSource, size: size)
}
}
@@ -0,0 +1,76 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct PlaceholderAvatarImage: View {
@Environment(\.redactionReasons) var redactionReasons
private let textForImage: String
private let contentID: String?
var body: some View {
ZStack {
bgColor
// This text's frame doesn't look right when redacted
if redactionReasons != .placeholder {
Text(textForImage)
.padding(4)
.foregroundColor(.white)
.font(.system(size: 200).weight(.semibold))
.minimumScaleFactor(0.001)
}
}
.aspectRatio(1, contentMode: .fill)
}
init(name: String?, contentID: String?) {
let baseName = name ?? contentID?.trimmingCharacters(in: .punctuationCharacters)
textForImage = baseName?.first?.uppercased() ?? ""
self.contentID = contentID
}
private var bgColor: Color {
if redactionReasons == .placeholder {
return .element.systemGray4
}
guard let contentID else {
return .element.accent
}
return .element.avatarBackground(for: contentID)
}
}
struct PlaceholderAvatarImage_Previews: PreviewProvider {
static var previews: some View {
VStack(spacing: 75) {
PlaceholderAvatarImage(name: "Xavier", contentID: "@userid1:matrix.org")
.clipShape(Circle())
.frame(width: 150, height: 100)
PlaceholderAvatarImage(name: "@*~AmazingName~*@", contentID: "@userid2:matrix.org")
.clipShape(Circle())
.frame(width: 150, height: 100)
PlaceholderAvatarImage(name: nil, contentID: "@userid3:matrix.org")
.clipShape(Circle())
.frame(width: 150, height: 100)
}
}
}
@@ -34,7 +34,6 @@ enum HomeScreenViewUserMenuAction {
}
enum HomeScreenViewAction {
case loadRoomData(roomIdentifier: String)
case selectRoom(roomIdentifier: String)
case userMenu(action: HomeScreenViewUserMenuAction)
case verifySession
@@ -59,7 +58,7 @@ enum HomeScreenRoomListMode: CustomStringConvertible {
struct HomeScreenViewState: BindableState {
var userID: String
var userDisplayName: String?
var userAvatar: UIImage?
var userAvatarURL: URL?
var showSessionVerificationBanner = false
@@ -96,7 +95,6 @@ struct HomeScreenViewStateBindings {
struct HomeScreenRoom: Identifiable, Equatable {
private static let placeholderLastMessage = AttributedString("Last message")
private static let placeholderAvatar = UIImage(systemName: "photo")
/// The list item identifier can be a real room identifier, a custom one for invalidated entries
/// or a completely unique one for empty items and skeletons
@@ -115,8 +113,6 @@ struct HomeScreenRoom: Identifiable, Equatable {
var avatarURL: URL?
var avatar: UIImage?
var isPlaceholder = false
static func placeholder() -> HomeScreenRoom {
@@ -126,7 +122,6 @@ struct HomeScreenRoom: Identifiable, Equatable {
hasUnreads: false,
timestamp: "Now",
lastMessage: Self.placeholderLastMessage,
avatar: Self.placeholderAvatar,
isPlaceholder: true)
}
}
@@ -37,7 +37,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
// MARK: - Setup
// swiftlint:disable:next function_body_length cyclomatic_complexity
// swiftlint:disable:next function_body_length
init(userSession: UserSessionProtocol, attributedStringBuilder: AttributedStringBuilderProtocol) {
self.userSession = userSession
self.attributedStringBuilder = attributedStringBuilder
@@ -45,7 +45,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
visibleRoomsSummaryProvider = userSession.clientProxy.visibleRoomsSummaryProvider
allRoomsSummaryProvider = userSession.clientProxy.allRoomsSummaryProvider
super.init(initialViewState: HomeScreenViewState(userID: userSession.userID))
super.init(initialViewState: HomeScreenViewState(userID: userSession.userID),
imageProvider: userSession.mediaProvider)
userSession.callbacks
.receive(on: DispatchQueue.main)
@@ -71,9 +72,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
Task {
if case let .success(url) = await userSession.clientProxy.loadUserAvatarURL() {
if case let .success(avatar) = await userSession.mediaProvider.loadImageFromURL(url, avatarSize: .user(on: .home)) {
state.userAvatar = avatar
}
state.userAvatarURL = url
}
}
@@ -139,13 +138,8 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
// MARK: - Public
// swiftlint:disable:next cyclomatic_complexity
override func process(viewAction: HomeScreenViewAction) async {
switch viewAction {
case .loadRoomData(let roomIdentifier):
if state.roomListMode != .skeletons {
loadDataForRoomIdentifier(roomIdentifier)
}
case .selectRoom(let roomIdentifier):
callback?(.presentRoom(roomIdentifier: roomIdentifier))
case .userMenu(let action):
@@ -178,27 +172,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
}
// MARK: - Private
private func loadDataForRoomIdentifier(_ identifier: String) {
guard let room = roomsForIdentifiers[identifier],
room.avatar == nil,
let avatarURL = room.avatarURL else {
return
}
Task {
if case let .success(image) = await userSession.mediaProvider.loadImageFromURL(avatarURL, avatarSize: .room(on: .home)) {
guard let roomIndex = state.rooms.firstIndex(where: { $0.roomId == identifier }) else {
return
}
var room = state.rooms[roomIndex]
room.avatar = image
state.rooms[roomIndex] = room
}
}
}
/// This method will update all view state rooms by merging the data from both summary providers
/// If a room is empty in the visible room summary provider it will try to get it from the allRooms one
/// This ensures that we show as many room details as possible without loading up timelines
@@ -256,10 +230,6 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
room.hasUnreads = details.unreadNotificationCount > 0
room.lastMessage = details.lastMessage
if let avatarURL = details.avatarURL {
room.avatar = userSession.mediaProvider.imageFromURL(avatarURL, avatarSize: .room(on: .home))
}
if let lastMessageTimestamp = details.lastMessageTimestamp {
room.timestamp = lastMessageTimestamp.formattedMinimal()
}
@@ -102,9 +102,12 @@ struct HomeScreen: View {
}
}
} label: {
userAvatarImageView
.animation(.elementDefault, value: context.viewState.userAvatar)
.transition(.opacity)
LoadableAvatarImage(url: context.viewState.userAvatarURL,
name: context.viewState.userDisplayName,
contentID: context.viewState.userID,
avatarSize: .user(on: .home),
imageProvider: context.imageProvider)
.accessibilityIdentifier("userAvatarImage")
}
.alert(ElementL10n.actionSignOut,
isPresented: $showingLogoutConfirmation) {
@@ -116,26 +119,6 @@ struct HomeScreen: View {
}
.accessibilityLabel(ElementL10n.a11yAllChatsUserAvatarMenu)
}
@ViewBuilder
private var userAvatarImageView: some View {
userAvatarImage
.frame(width: AvatarSize.user(on: .home).value, height: AvatarSize.user(on: .home).value, alignment: .center)
.clipShape(Circle())
.accessibilityIdentifier("userAvatarImage")
}
@ViewBuilder
private var userAvatarImage: some View {
if let avatar = context.viewState.userAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
} else {
PlaceholderAvatarImage(text: context.viewState.userDisplayName ?? context.viewState.userID,
contentId: context.viewState.userID)
}
}
private var sessionVerificationBanner: some View {
VStack(alignment: .leading, spacing: 16) {
@@ -17,8 +17,6 @@
import SwiftUI
struct HomeScreenRoomCell: View {
@ScaledMetric private var avatarSize = AvatarSize.room(on: .home).value
let room: HomeScreenRoom
let context: HomeScreenViewModel.Context
@@ -38,11 +36,6 @@ struct HomeScreenRoomCell: View {
}
.frame(minHeight: 64.0)
.accessibilityElement(children: .combine)
.task {
if let roomId = room.roomId {
context.send(viewAction: .loadRoomData(roomIdentifier: roomId))
}
}
}
.buttonStyle(HomeScreenRoomCellButtonStyle())
.accessibilityIdentifier("roomName:\(room.name)")
@@ -50,19 +43,12 @@ struct HomeScreenRoomCell: View {
@ViewBuilder
var avatar: some View {
if let avatar = room.avatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
.accessibilityHidden(true)
} else {
PlaceholderAvatarImage(text: room.name, contentId: room.roomId)
.clipShape(Circle())
.frame(width: avatarSize, height: avatarSize)
.accessibilityHidden(true)
}
LoadableAvatarImage(url: room.avatarURL,
name: room.name,
contentID: room.roomId,
avatarSize: .room(on: .home),
imageProvider: context.imageProvider)
.accessibilityHidden(true)
}
@ViewBuilder
@@ -32,9 +32,9 @@ struct RoomDetailsViewState: BindableState {
let roomId: String
let isEncrypted: Bool
let isDirect: Bool
var roomTitle = ""
var roomTopic: String?
var roomAvatar: UIImage?
var title = ""
var topic: String?
var avatarURL: URL?
var members: [RoomDetailsMember]
var isLoadingMembers: Bool {
@@ -62,8 +62,6 @@ struct RoomDetailsMember: Identifiable, Equatable {
let id: String
let name: String?
let avatarURL: URL?
// cached
var avatar: UIImage?
init(withProxy proxy: RoomMemberProxy) {
id = proxy.userId
@@ -19,29 +19,25 @@ import SwiftUI
typealias RoomDetailsViewModelType = StateStoreViewModel<RoomDetailsViewState, RoomDetailsViewAction>
class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtocol {
private let roomProxy: RoomProxyProtocol
private let mediaProvider: MediaProviderProtocol
private var members: [RoomMemberProxy] = [] {
didSet {
state.members = members.map { RoomDetailsMember(withProxy: $0) }
}
}
var callback: ((RoomDetailsViewModelAction) -> Void)?
init(roomProxy: RoomProxyProtocol,
mediaProvider: MediaProviderProtocol) {
self.roomProxy = roomProxy
self.mediaProvider = mediaProvider
init(roomProxy: RoomProxyProtocol, mediaProvider: MediaProviderProtocol) {
super.init(initialViewState: .init(roomId: roomProxy.id,
isEncrypted: roomProxy.isEncrypted,
isDirect: roomProxy.isDirect,
roomTitle: roomProxy.displayName ?? roomProxy.name ?? "Unknown Room",
roomTopic: roomProxy.topic,
title: roomProxy.displayName ?? roomProxy.name ?? "Unknown Room",
topic: roomProxy.topic,
avatarURL: roomProxy.avatarURL,
members: [],
bindings: .init()))
bindings: .init()),
imageProvider: mediaProvider)
Task {
switch await roomProxy.members() {
case .success(let members):
@@ -51,14 +47,6 @@ class RoomDetailsViewModel: RoomDetailsViewModelType, RoomDetailsViewModelProtoc
state.bindings.alertInfo = AlertInfo(id: .alert(ElementL10n.unknownError))
}
}
if let avatarURL = roomProxy.avatarURL {
Task {
if case let .success(avatar) = await mediaProvider.loadImageFromURL(avatarURL, avatarSize: .room(on: .details)) {
state.roomAvatar = avatar
}
}
}
}
// MARK: - Public
@@ -17,24 +17,16 @@
import SwiftUI
struct RoomDetailsScreen: View {
// MARK: Private
@Environment(\.colorScheme) private var colorScheme
@ScaledMetric private var avatarSize = AvatarSize.room(on: .details).value
@ScaledMetric private var menuIconSize = 30.0
private let listRowInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)
// MARK: Public
@ObservedObject var context: RoomDetailsViewModel.Context
// MARK: Views
var body: some View {
Form {
headerSection
if let topic = context.viewState.roomTopic {
if let topic = context.viewState.topic {
topicSection(with: topic)
}
@@ -47,11 +39,19 @@ struct RoomDetailsScreen: View {
.alert(item: $context.alertInfo) { $0.alert }
.navigationTitle(ElementL10n.roomDetailsTitle)
}
// MARK: - Private
private var headerSection: some View {
VStack(spacing: 16.0) {
roomAvatarImage
Text(context.viewState.roomTitle)
LoadableAvatarImage(url: context.viewState.avatarURL,
name: context.viewState.title,
contentID: context.viewState.roomId,
avatarSize: .room(on: .details),
imageProvider: context.imageProvider)
.accessibilityIdentifier("roomAvatarImage")
Text(context.viewState.title)
.foregroundColor(.element.primaryContent)
.font(.element.headline)
.multilineTextAlignment(.center)
@@ -128,23 +128,6 @@ struct RoomDetailsScreen: View {
}
}
}
@ViewBuilder private var roomAvatarImage: some View {
if let avatar = context.viewState.roomAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
.accessibilityIdentifier("roomAvatarImage")
} else {
PlaceholderAvatarImage(text: context.viewState.roomTitle,
contentId: context.viewState.roomId)
.clipShape(Circle())
.frame(width: avatarSize, height: avatarSize)
.accessibilityIdentifier("roomAvatarPlaceholderImage")
}
}
}
// MARK: - Previews
@@ -46,5 +46,4 @@ struct RoomMemberDetailsViewStateBindings {
enum RoomMemberDetailsViewAction {
case selectMember(id: String)
case loadMemberData(id: String)
}
@@ -27,7 +27,8 @@ class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDeta
members: [RoomMemberProxy]) {
self.mediaProvider = mediaProvider
super.init(initialViewState: .init(members: members.map { RoomDetailsMember(withProxy: $0) },
bindings: .init()))
bindings: .init()),
imageProvider: mediaProvider)
}
// MARK: - Public
@@ -36,30 +37,6 @@ class RoomMemberDetailsViewModel: RoomMemberDetailsViewModelType, RoomMemberDeta
switch viewAction {
case .selectMember(let id):
MXLog.debug("Member selected: \(id)")
case .loadMemberData(let id):
await loadAvatar(forMember: id)
}
}
private func loadAvatar(forMember memberId: String) async {
guard let member = state.members.first(where: { $0.id == memberId }) else {
return
}
if member.avatar != nil {
// Avatar already loaded.
return
}
guard let avatarURL = member.avatarURL else {
return
}
switch await mediaProvider.loadImageFromURL(avatarURL, avatarSize: .user(on: .roomDetails)) {
case .success(let image):
if let index = state.members.firstIndex(where: { $0.id == memberId }) {
state.members[index].avatar = image
}
case .failure(let error):
MXLog.error("Failed to retrieve room member avatar: \(error)")
}
}
}
@@ -27,19 +27,12 @@ struct RoomMemberDetailsMemberCell: View {
context.send(viewAction: .selectMember(id: member.id))
} label: {
HStack {
if let avatar = member.avatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
.accessibilityHidden(true)
} else {
PlaceholderAvatarImage(text: member.name ?? "", contentId: member.id)
.clipShape(Circle())
.frame(width: avatarSize, height: avatarSize)
.accessibilityHidden(true)
}
LoadableAvatarImage(url: member.avatarURL,
name: member.name ?? "",
contentID: member.id,
avatarSize: .user(on: .roomDetails),
imageProvider: context.imageProvider)
.accessibilityHidden(true)
Text(member.name ?? "")
.font(.element.callout.bold())
@@ -49,9 +42,6 @@ struct RoomMemberDetailsMemberCell: View {
Spacer()
}
.accessibilityElement(children: .combine)
.task {
context.send(viewAction: .loadMemberData(id: member.id))
}
}
}
}
@@ -49,7 +49,7 @@ enum RoomScreenViewAction {
struct RoomScreenViewState: BindableState {
var roomId: String
var roomTitle = ""
var roomAvatar: UIImage?
var roomAvatarURL: URL?
var items: [RoomTimelineViewProvider] = []
var canBackPaginate = true
var isBackPaginating = false
@@ -28,7 +28,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
private let timelineController: RoomTimelineControllerProtocol
private let timelineViewFactory: RoomTimelineViewFactoryProtocol
private let mediaProvider: MediaProviderProtocol
// MARK: - Setup
@@ -39,12 +38,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
roomAvatarUrl: URL? = nil) {
self.timelineController = timelineController
self.timelineViewFactory = timelineViewFactory
self.mediaProvider = mediaProvider
super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomID,
roomTitle: roomName ?? "Unknown room 💥",
roomAvatar: nil,
bindings: .init(composerText: "", composerFocused: false)))
roomAvatarURL: roomAvatarUrl,
bindings: .init(composerText: "", composerFocused: false)),
imageProvider: mediaProvider)
timelineController.callbacks
.receive(on: DispatchQueue.main)
@@ -76,15 +75,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
state.contextMenuBuilder = buildContextMenuForItemId(_:)
buildTimelineViews()
if let roomAvatarUrl {
Task {
if case let .success(avatar) = await mediaProvider.loadImageFromURL(roomAvatarUrl,
avatarSize: .room(on: .timeline)) {
state.roomAvatar = avatar
}
}
}
}
// MARK: - Public
@@ -38,24 +38,12 @@ struct RoomHeaderView: View {
}
@ViewBuilder private var roomAvatar: some View {
ZStack(alignment: .bottomTrailing) {
roomAvatarImage
.clipShape(Circle())
}
.frame(width: AvatarSize.room(on: .timeline).value, height: AvatarSize.room(on: .timeline).value)
}
@ViewBuilder private var roomAvatarImage: some View {
if let avatar = context.viewState.roomAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.accessibilityIdentifier("roomAvatarImage")
} else {
PlaceholderAvatarImage(text: context.viewState.roomTitle,
contentId: context.viewState.roomId)
.accessibilityIdentifier("roomAvatarPlaceholderImage")
}
LoadableAvatarImage(url: context.viewState.roomAvatarURL,
name: context.viewState.roomTitle,
contentID: context.viewState.roomId,
avatarSize: .room(on: .timeline),
imageProvider: context.imageProvider)
.accessibilityIdentifier("roomAvatarImage")
}
}
@@ -18,33 +18,29 @@ import Foundation
import SwiftUI
struct ImageRoomTimelineView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
let timelineItem: ImageRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
if let image = timelineItem.image {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} else {
ZStack {
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView(ElementL10n.loading)
.frame(maxWidth: .infinity)
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
LoadableImage(mediaSource: timelineItem.source,
blurhash: timelineItem.blurhash,
imageProvider: context.imageProvider) {
placeholder
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
}
var placeholder: some View {
ZStack {
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView(ElementL10n.loading)
.frame(maxWidth: .infinity)
}
.animation(.elementDefault, value: timelineItem.image)
}
}
@@ -63,8 +59,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
source: nil,
image: UIImage(systemName: "photo")))
source: nil))
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
text: "Some other image",
@@ -73,8 +68,7 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
source: nil,
image: nil))
source: nil))
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
text: "Blurhashed image",
@@ -84,7 +78,6 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
isEditable: false,
sender: .init(id: "Bob"),
source: nil,
image: nil,
aspectRatio: 0.7,
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
}
@@ -1,55 +0,0 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import SwiftUI
struct PlaceholderAvatarImage: View {
private let textForImage: String
private let contentId: String?
var body: some View {
ZStack {
bgColor
Text(textForImage)
.padding(4)
.foregroundColor(.white)
.font(.system(size: 200).weight(.semibold))
.minimumScaleFactor(0.001)
}
.aspectRatio(1, contentMode: .fill)
}
init(text: String, contentId: String? = nil) {
textForImage = text.first?.uppercased() ?? ""
self.contentId = contentId
}
private var bgColor: Color {
guard let contentId else {
return .element.accent
}
return .element.avatarBackground(for: contentId)
}
}
struct PlaceholderAvatarImage_Previews: PreviewProvider {
static var previews: some View {
PlaceholderAvatarImage(text: "X", contentId: "@userid:matrix.org")
.clipShape(Circle())
.frame(width: 150, height: 100)
}
}
@@ -18,35 +18,31 @@ import Foundation
import SwiftUI
struct StickerRoomTimelineView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
let timelineItem: StickerRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
if let image = timelineItem.image {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
} else {
ZStack {
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView(ElementL10n.loading)
.frame(maxWidth: .infinity)
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
LoadableImage(url: timelineItem.imageURL,
blurhash: timelineItem.blurhash,
imageProvider: context.imageProvider) {
placeholder
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
.animation(.elementDefault, value: timelineItem.image)
.accessibilityLabel(timelineItem.text)
}
private var placeholder: some View {
ZStack {
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView(ElementL10n.loading)
.frame(maxWidth: .infinity)
}
}
}
struct StickerRoomTimelineView_Previews: PreviewProvider {
@@ -64,8 +60,7 @@ struct StickerRoomTimelineView_Previews: PreviewProvider {
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
imageURL: nil,
image: UIImage(systemName: "photo")))
imageURL: URL.picturesDirectory))
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString,
text: "Some other image",
@@ -74,8 +69,7 @@ struct StickerRoomTimelineView_Previews: PreviewProvider {
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
imageURL: nil,
image: nil))
imageURL: URL.picturesDirectory))
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString,
text: "Blurhashed image",
@@ -84,8 +78,7 @@ struct StickerRoomTimelineView_Previews: PreviewProvider {
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
imageURL: nil,
image: nil,
imageURL: URL.picturesDirectory,
aspectRatio: 0.7,
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
}
@@ -18,42 +18,39 @@ import Foundation
import SwiftUI
struct VideoRoomTimelineView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
let timelineItem: VideoRoomTimelineItem
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
if let image = timelineItem.image {
thumbnail(with: image)
} else if let blurhash = timelineItem.blurhash,
// Build a small blurhash image so that it's fast
let image = UIImage(blurHash: blurhash, size: .init(width: 10.0, height: 10.0)) {
thumbnail(with: image)
} else {
ZStack {
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView(ElementL10n.loading)
.frame(maxWidth: .infinity)
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
LoadableImage(mediaSource: timelineItem.thumbnailSource,
blurhash: timelineItem.blurhash,
imageProvider: context.imageProvider) { imageView in
imageView
.overlay { playIcon }
} placeholder: {
placeholder
}
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
}
.animation(.elementDefault, value: timelineItem.image)
}
@ViewBuilder
private func thumbnail(with image: UIImage) -> some View {
var playIcon: some View {
Image(systemName: "play.circle.fill")
.resizable()
.frame(width: 50, height: 50)
.background(.ultraThinMaterial, in: Circle())
.foregroundColor(.white)
}
var placeholder: some View {
ZStack {
Image(uiImage: image)
.resizable()
.aspectRatio(timelineItem.aspectRatio, contentMode: .fit)
Image(systemName: "play.circle.fill")
.resizable()
.frame(width: 50, height: 50)
.background(.ultraThinMaterial, in: Circle())
.foregroundColor(.white)
Rectangle()
.foregroundColor(.element.systemGray6)
.opacity(0.3)
ProgressView(ElementL10n.loading)
.frame(maxWidth: .infinity)
}
}
}
@@ -75,8 +72,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
sender: .init(id: "Bob"),
duration: 21,
source: nil,
thumbnailSource: nil,
image: UIImage(systemName: "photo")))
thumbnailSource: nil))
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
text: "Some other video",
@@ -87,8 +83,7 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
sender: .init(id: "Bob"),
duration: 22,
source: nil,
thumbnailSource: nil,
image: nil))
thumbnailSource: nil))
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
text: "Blurhashed video",
@@ -100,7 +95,6 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
duration: 23,
source: nil,
thumbnailSource: nil,
image: nil,
aspectRatio: 0.7,
blurhash: "L%KUc%kqS$RP?Ks,WEf8OlrqaekW"))
}
@@ -18,29 +18,19 @@ import Foundation
import SwiftUI
struct TimelineSenderAvatarView: View {
let timelineItem: EventBasedTimelineItemProtocol
@EnvironmentObject private var context: RoomScreenViewModel.Context
@ScaledMetric private var avatarSize = AvatarSize.user(on: .timeline).value
let timelineItem: EventBasedTimelineItemProtocol
var body: some View {
ZStack(alignment: .center) {
if let avatar = timelineItem.sender.avatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.overlay(Circle().stroke(Color.element.accent))
} else {
PlaceholderAvatarImage(text: timelineItem.sender.displayName ?? timelineItem.sender.id,
contentId: timelineItem.sender.id)
}
}
.clipShape(Circle())
.frame(width: avatarSize, height: avatarSize)
.overlay(
Circle()
.stroke(Color.element.background, lineWidth: 3)
)
.animation(.elementDefault, value: timelineItem.sender.avatar)
LoadableAvatarImage(url: timelineItem.sender.avatarURL,
name: timelineItem.sender.displayName,
contentID: timelineItem.sender.id,
avatarSize: .user(on: .timeline),
imageProvider: context.imageProvider)
.overlay(
Circle().stroke(Color.element.background, lineWidth: 3)
)
}
}
@@ -16,30 +16,30 @@
import SwiftUI
struct SettingsCoordinatorParameters {
struct SettingsScreenCoordinatorParameters {
let navigationStackCoordinator: NavigationStackCoordinator
let userNotificationController: UserNotificationControllerProtocol
let userSession: UserSessionProtocol
let bugReportService: BugReportServiceProtocol
}
enum SettingsCoordinatorAction {
enum SettingsScreenCoordinatorAction {
case dismiss
case logout
}
final class SettingsCoordinator: CoordinatorProtocol {
private let parameters: SettingsCoordinatorParameters
private var viewModel: SettingsViewModelProtocol
final class SettingsScreenCoordinator: CoordinatorProtocol {
private let parameters: SettingsScreenCoordinatorParameters
private var viewModel: SettingsScreenViewModelProtocol
var callback: ((SettingsCoordinatorAction) -> Void)?
var callback: ((SettingsScreenCoordinatorAction) -> Void)?
// MARK: - Setup
init(parameters: SettingsCoordinatorParameters) {
init(parameters: SettingsScreenCoordinatorParameters) {
self.parameters = parameters
viewModel = SettingsViewModel(withUserSession: parameters.userSession)
viewModel = SettingsScreenViewModel(withUserSession: parameters.userSession)
viewModel.callback = { [weak self] action in
guard let self else { return }
@@ -17,26 +17,26 @@
import Foundation
import UIKit
enum SettingsViewModelAction {
enum SettingsScreenViewModelAction {
case close
case toggleAnalytics
case reportBug
case logout
}
struct SettingsViewState: BindableState {
var bindings: SettingsViewStateBindings
struct SettingsScreenViewState: BindableState {
var bindings: SettingsScreenViewStateBindings
var deviceID: String?
var userID: String
var userAvatar: UIImage?
var userAvatarURL: URL?
var userDisplayName: String?
}
struct SettingsViewStateBindings {
struct SettingsScreenViewStateBindings {
var enableAnalytics = ServiceLocator.shared.settings.enableAnalytics
}
enum SettingsViewAction {
enum SettingsScreenViewAction {
case close
case toggleAnalytics
case reportBug
@@ -16,23 +16,22 @@
import SwiftUI
typealias SettingsViewModelType = StateStoreViewModel<SettingsViewState, SettingsViewAction>
typealias SettingsScreenViewModelType = StateStoreViewModel<SettingsScreenViewState, SettingsScreenViewAction>
class SettingsViewModel: SettingsViewModelType, SettingsViewModelProtocol {
class SettingsScreenViewModel: SettingsScreenViewModelType, SettingsScreenViewModelProtocol {
private let userSession: UserSessionProtocol
var callback: ((SettingsViewModelAction) -> Void)?
var callback: ((SettingsScreenViewModelAction) -> Void)?
init(withUserSession userSession: UserSessionProtocol) {
self.userSession = userSession
let bindings = SettingsViewStateBindings()
super.init(initialViewState: .init(bindings: bindings, deviceID: userSession.deviceId, userID: userSession.userID))
let bindings = SettingsScreenViewStateBindings()
super.init(initialViewState: .init(bindings: bindings, deviceID: userSession.deviceId, userID: userSession.userID),
imageProvider: userSession.mediaProvider)
Task {
if case let .success(userAvatarURL) = await userSession.clientProxy.loadUserAvatarURL() {
if case let .success(avatar) = await userSession.mediaProvider.loadImageFromURL(userAvatarURL, avatarSize: .user(on: .settings)) {
state.userAvatar = avatar
}
state.userAvatarURL = userAvatarURL
}
}
@@ -43,7 +42,7 @@ class SettingsViewModel: SettingsViewModelType, SettingsViewModelProtocol {
}
}
override func process(viewAction: SettingsViewAction) async {
override func process(viewAction: SettingsScreenViewAction) async {
switch viewAction {
case .close:
callback?(.close)
@@ -17,7 +17,7 @@
import Foundation
@MainActor
protocol SettingsViewModelProtocol {
var callback: ((SettingsViewModelAction) -> Void)? { get set }
var context: SettingsViewModelType.Context { get }
protocol SettingsScreenViewModelProtocol {
var callback: ((SettingsScreenViewModelAction) -> Void)? { get set }
var context: SettingsScreenViewModelType.Context { get }
}
@@ -25,7 +25,7 @@ struct SettingsScreen: View {
@ScaledMetric private var menuIconSize = 30.0
private let listRowInsets = EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)
@ObservedObject var context: SettingsViewModel.Context
@ObservedObject var context: SettingsScreenViewModel.Context
var body: some View {
Form {
@@ -69,7 +69,12 @@ struct SettingsScreen: View {
private var userSection: some View {
Section {
HStack(spacing: 13) {
userAvatar
LoadableAvatarImage(url: context.viewState.userAvatarURL,
name: context.viewState.userDisplayName,
contentID: context.viewState.userID,
avatarSize: .user(on: .settings),
imageProvider: context.imageProvider)
VStack(alignment: .leading, spacing: 4) {
Text(context.viewState.userDisplayName ?? "")
.font(.title3)
@@ -80,21 +85,6 @@ struct SettingsScreen: View {
.listRowInsets(listRowInsets)
}
}
@ViewBuilder
private var userAvatar: some View {
if let avatar = context.viewState.userAvatar {
Image(uiImage: avatar)
.resizable()
.scaledToFill()
.frame(width: avatarSize, height: avatarSize)
.clipShape(Circle())
} else {
PlaceholderAvatarImage(text: context.viewState.userDisplayName ?? context.viewState.userID, contentId: context.viewState.userID)
.clipShape(Circle())
.frame(width: avatarSize, height: avatarSize)
}
}
private var analyticsSection: some View {
Section {
@@ -198,11 +188,11 @@ extension TimelineStyle: CustomStringConvertible {
// MARK: - Previews
struct Settings_Previews: PreviewProvider {
struct SettingsScreen_Previews: PreviewProvider {
static var previews: some View {
let userSession = MockUserSession(clientProxy: MockClientProxy(userID: "@userid:example.com"),
mediaProvider: MockMediaProvider())
let viewModel = SettingsViewModel(withUserSession: userSession)
let viewModel = SettingsScreenViewModel(withUserSession: userSession)
NavigationView {
SettingsScreen(context: viewModel.context)
@@ -58,7 +58,7 @@ class UIKitBackgroundTask: BackgroundTaskProtocol {
}
if identifier == .invalid {
MXLog.info("Do not start background task: \(name), as OS declined")
MXLog.error("Do not start background task: \(name), as OS declined")
// call expiration handler immediately
expirationHandler?(self)
return nil
@@ -69,7 +69,7 @@ class UIKitBackgroundTask: BackgroundTaskProtocol {
reuse()
}
MXLog.info("Start background task #\(identifier.rawValue) - \(name)")
MXLog.verbose("Start background task #\(identifier.rawValue) - \(name)")
}
func reuse() {
@@ -92,7 +92,7 @@ class UIKitBackgroundTask: BackgroundTaskProtocol {
private func endTask() {
if identifier != .invalid {
MXLog.info("End background task #\(identifier.rawValue) - \(name) after \(readableElapsedTime)")
MXLog.verbose("End background task #\(identifier.rawValue) - \(name) after \(readableElapsedTime)")
application.endBackgroundTask(identifier)
identifier = .invalid
@@ -41,7 +41,7 @@ class UIKitBackgroundTaskService: BackgroundTaskServiceProtocol {
}
if avoidStartingNewTasks(for: application) {
MXLog.info("Do not start background task: \(name), as not enough time exists")
MXLog.error("Do not start background task: \(name), as not enough time exists")
// call expiration handler immediately
expirationHandler?()
return nil
@@ -82,7 +82,7 @@ class UIKitBackgroundTaskService: BackgroundTaskServiceProtocol {
let appState = application.applicationState
let remainingTime = readableBackgroundTimeRemaining(application.backgroundTimeRemaining)
MXLog.info("Background task \(name) \(created ? "started" : "reused") with app state: \(appState) and estimated background time remaining: \(remainingTime)")
MXLog.verbose("Background task \(name) \(created ? "started" : "reused") with app state: \(appState) and estimated background time remaining: \(remainingTime)")
return result
}
@@ -56,7 +56,7 @@ class ClientProxy: ClientProxyProtocol {
private let client: ClientProtocol
private let backgroundTaskService: BackgroundTaskServiceProtocol
private var sessionVerificationControllerProxy: SessionVerificationControllerProxy?
private let mediaProxy: MediaProxyProtocol
private let mediaLoader: MediaLoaderProtocol
private let clientQueue: DispatchQueue
private var slidingSyncObserverToken: StoppableSpawn?
@@ -86,7 +86,7 @@ class ClientProxy: ClientProxyProtocol {
self.backgroundTaskService = backgroundTaskService
clientQueue = .init(label: "ClientProxyQueue", attributes: .concurrent)
mediaProxy = MediaProxy(client: client, clientQueue: clientQueue)
mediaLoader = MediaLoader(client: client, clientQueue: clientQueue)
client.setDelegate(delegate: WeakClientProxyWrapper(clientProxy: self))
@@ -401,16 +401,16 @@ class ClientProxy: ClientProxyProtocol {
}
}
extension ClientProxy: MediaProxyProtocol {
func mediaSourceForURL(_ url: URL) -> MediaSourceProxy {
mediaProxy.mediaSourceForURL(url)
extension ClientProxy: MediaLoaderProtocol {
func mediaSourceForURL(_ url: URL) async -> MediaSourceProxy {
await mediaLoader.mediaSourceForURL(url)
}
func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data {
try await mediaProxy.loadMediaContentForSource(source)
try await mediaLoader.loadMediaContentForSource(source)
}
func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data {
try await mediaProxy.loadMediaThumbnailForSource(source, width: width, height: height)
try await mediaLoader.loadMediaThumbnailForSource(source, width: width, height: height)
}
}
@@ -58,7 +58,7 @@ enum PushFormat {
// }
}
protocol ClientProxyProtocol: AnyObject, MediaProxyProtocol {
protocol ClientProxyProtocol: AnyObject, MediaLoaderProtocol {
var callbacks: PassthroughSubject<ClientProxyCallback, Never> { get }
var userID: String { get }
@@ -0,0 +1,45 @@
//
// Copyright 2023 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import UIKit
protocol ImageProviderProtocol {
func imageFromSource(_ source: MediaSourceProxy?, size: CGSize?) -> UIImage?
@discardableResult func loadImageFromSource(_ source: MediaSourceProxy, size: CGSize?) async -> Result<UIImage, MediaProviderError>
func imageFromURL(_ url: URL?, size: CGSize?) -> UIImage?
@discardableResult func loadImageFromURL(_ url: URL, size: CGSize?) async -> Result<UIImage, MediaProviderError>
}
extension ImageProviderProtocol {
func imageFromSource(_ source: MediaSourceProxy?) -> UIImage? {
imageFromSource(source, size: nil)
}
@discardableResult func loadImageFromSource(_ source: MediaSourceProxy) async -> Result<UIImage, MediaProviderError> {
await loadImageFromSource(source, size: nil)
}
func imageFromURL(_ url: URL?) -> UIImage? {
imageFromURL(url, size: nil)
}
@discardableResult func loadImageFromURL(_ url: URL) async -> Result<UIImage, MediaProviderError> {
await loadImageFromURL(url, size: nil)
}
}
@@ -0,0 +1,84 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import Foundation
import MatrixRustSDK
import UIKit
private final class MediaRequest {
var continuations: [CheckedContinuation<Data, Error>] = []
}
actor MediaLoader: MediaLoaderProtocol {
private let client: ClientProtocol
private let clientQueue: DispatchQueue
private var ongoingRequests = [MediaSourceProxy: MediaRequest]()
init(client: ClientProtocol,
clientQueue: DispatchQueue = .global()) {
self.client = client
self.clientQueue = clientQueue
}
func mediaSourceForURL(_ url: URL) -> MediaSourceProxy {
.init(url: url)
}
func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data {
try await enqueueLoadMediaRequest(forSource: source) {
try self.client.getMediaContent(source: source.underlyingSource)
}
}
func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data {
try await enqueueLoadMediaRequest(forSource: source) {
try self.client.getMediaThumbnail(source: source.underlyingSource, width: UInt64(width), height: UInt64(height))
}
}
// MARK: - Private
private func enqueueLoadMediaRequest(forSource source: MediaSourceProxy, operation: @escaping () throws -> [UInt8]) async throws -> Data {
if let ongoingRequest = ongoingRequests[source] {
return try await withCheckedThrowingContinuation { continuation in
ongoingRequest.continuations.append(continuation)
}
}
let ongoingRequest = MediaRequest()
ongoingRequests[source] = ongoingRequest
defer {
ongoingRequests[source] = nil
}
do {
let result = try await Task.dispatch(on: clientQueue) {
let bytes = try operation()
return Data(bytes: bytes, count: bytes.count)
}
ongoingRequest.continuations.forEach { $0.resume(returning: result) }
return result
} catch {
ongoingRequest.continuations.forEach { $0.resume(throwing: error) }
throw error
}
}
}
@@ -16,8 +16,8 @@
import Foundation
protocol MediaProxyProtocol {
func mediaSourceForURL(_ url: URL) -> MediaSourceProxy
protocol MediaLoaderProtocol {
func mediaSourceForURL(_ url: URL) async -> MediaSourceProxy
func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data
@@ -18,53 +18,54 @@ import Kingfisher
import UIKit
struct MediaProvider: MediaProviderProtocol {
private let mediaProxy: MediaProxyProtocol
private let mediaLoader: MediaLoaderProtocol
private let imageCache: Kingfisher.ImageCache
private let fileCache: FileCacheProtocol
private let backgroundTaskService: BackgroundTaskServiceProtocol?
init(mediaProxy: MediaProxyProtocol,
init(mediaLoader: MediaLoaderProtocol,
imageCache: Kingfisher.ImageCache,
fileCache: FileCacheProtocol,
backgroundTaskService: BackgroundTaskServiceProtocol?) {
self.mediaProxy = mediaProxy
self.mediaLoader = mediaLoader
self.imageCache = imageCache
self.fileCache = fileCache
self.backgroundTaskService = backgroundTaskService
}
func imageFromSource(_ source: MediaSourceProxy?, avatarSize: AvatarSize?) -> UIImage? {
// MARK: Images
func imageFromSource(_ source: MediaSourceProxy?, size: CGSize?) -> UIImage? {
guard let url = source?.url else {
return nil
}
let cacheKey = cacheKeyForURL(url, avatarSize: avatarSize)
let cacheKey = cacheKeyForURL(url, size: size)
return imageCache.retrieveImageInMemoryCache(forKey: cacheKey, options: nil)
}
func imageFromURL(_ url: URL?, avatarSize: AvatarSize?) -> UIImage? {
func imageFromURL(_ url: URL?, size: CGSize?) -> UIImage? {
guard let url else {
return nil
}
return imageFromSource(.init(url: url), avatarSize: avatarSize)
return imageFromSource(.init(url: url), size: size)
}
func loadImageFromURL(_ url: URL, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError> {
await loadImageFromSource(.init(url: url), avatarSize: avatarSize)
func loadImageFromURL(_ url: URL, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
await loadImageFromSource(.init(url: url), size: size)
}
func loadImageFromSource(_ source: MediaSourceProxy, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError> {
if let image = imageFromSource(source, avatarSize: avatarSize) {
func loadImageFromSource(_ source: MediaSourceProxy, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
if let image = imageFromSource(source, size: size) {
return .success(image)
}
#warning("Media loading should check for existing in flight operations and de-dupe requests.")
let loadImageBgTask = await backgroundTaskService?.startBackgroundTask(withName: "LoadImage: \(source.url.hashValue)")
defer {
loadImageBgTask?.stop()
}
let cacheKey = cacheKeyForURL(source.url, avatarSize: avatarSize)
let cacheKey = cacheKeyForURL(source.url, size: size)
if case let .success(cacheResult) = await imageCache.retrieveImage(forKey: cacheKey),
let image = cacheResult.image {
@@ -73,10 +74,10 @@ struct MediaProvider: MediaProviderProtocol {
do {
let imageData: Data
if let avatarSize {
imageData = try await mediaProxy.loadMediaThumbnailForSource(source, width: UInt(avatarSize.scaledValue), height: UInt(avatarSize.scaledValue))
if let size {
imageData = try await mediaLoader.loadMediaThumbnailForSource(source, width: UInt(size.width), height: UInt(size.height))
} else {
imageData = try await mediaProxy.loadMediaContentForSource(source)
imageData = try await mediaLoader.loadMediaContentForSource(source)
}
guard let image = UIImage(data: imageData) else {
@@ -92,6 +93,8 @@ struct MediaProvider: MediaProviderProtocol {
return .failure(.failedRetrievingImage)
}
}
// MARK: Files
func fileFromSource(_ source: MediaSourceProxy?, fileExtension: String) -> URL? {
guard let source else {
@@ -100,6 +103,18 @@ struct MediaProvider: MediaProviderProtocol {
let cacheKey = fileCacheKeyForURL(source.url)
return fileCache.file(forKey: cacheKey, fileExtension: fileExtension)
}
func fileFromURL(_ url: URL?, fileExtension: String) -> URL? {
guard let url else {
return nil
}
return fileFromSource(MediaSourceProxy(url: url), fileExtension: fileExtension)
}
func loadFileFromURL(_ url: URL, fileExtension: String) async -> Result<URL, MediaProviderError> {
await loadFileFromSource(MediaSourceProxy(url: url), fileExtension: fileExtension)
}
@discardableResult func loadFileFromSource(_ source: MediaSourceProxy, fileExtension: String) async -> Result<URL, MediaProviderError> {
if let url = fileFromSource(source, fileExtension: fileExtension) {
@@ -114,7 +129,7 @@ struct MediaProvider: MediaProviderProtocol {
let cacheKey = fileCacheKeyForURL(source.url)
do {
let data = try await mediaProxy.loadMediaContentForSource(source)
let data = try await mediaLoader.loadMediaContentForSource(source)
let url = try fileCache.store(data, with: fileExtension, forKey: cacheKey)
return .success(url)
@@ -124,23 +139,11 @@ struct MediaProvider: MediaProviderProtocol {
}
}
func fileFromURL(_ url: URL?, fileExtension: String) -> URL? {
guard let url else {
return nil
}
return fileFromSource(MediaSourceProxy(url: url), fileExtension: fileExtension)
}
func loadFileFromURL(_ url: URL, fileExtension: String) async -> Result<URL, MediaProviderError> {
await loadFileFromSource(MediaSourceProxy(url: url), fileExtension: fileExtension)
}
// MARK: - Private
private func cacheKeyForURL(_ url: URL, avatarSize: AvatarSize?) -> String {
if let avatarSize {
return "\(url.absoluteString){\(avatarSize.scaledValue),\(avatarSize.scaledValue)}"
private func cacheKeyForURL(_ url: URL, size: CGSize?) -> String {
if let size {
return "\(url.absoluteString){\(size.width),\(size.height)}"
} else {
return url.absoluteString
}
@@ -23,15 +23,7 @@ enum MediaProviderError: Error {
case invalidImageData
}
protocol MediaProviderProtocol {
func imageFromSource(_ source: MediaSourceProxy?, avatarSize: AvatarSize?) -> UIImage?
@discardableResult func loadImageFromSource(_ source: MediaSourceProxy, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError>
func imageFromURL(_ url: URL?, avatarSize: AvatarSize?) -> UIImage?
@discardableResult func loadImageFromURL(_ url: URL, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError>
protocol MediaProviderProtocol: ImageProviderProtocol {
func fileFromSource(_ source: MediaSourceProxy?, fileExtension: String) -> URL?
@discardableResult func loadFileFromSource(_ source: MediaSourceProxy, fileExtension: String) async -> Result<URL, MediaProviderError>
@@ -40,21 +32,3 @@ protocol MediaProviderProtocol {
@discardableResult func loadFileFromURL(_ url: URL, fileExtension: String) async -> Result<URL, MediaProviderError>
}
extension MediaProviderProtocol {
func imageFromSource(_ source: MediaSourceProxy?) -> UIImage? {
imageFromSource(source, avatarSize: nil)
}
@discardableResult func loadImageFromSource(_ source: MediaSourceProxy) async -> Result<UIImage, MediaProviderError> {
await loadImageFromSource(source, avatarSize: nil)
}
func imageFromURL(_ url: URL?) -> UIImage? {
imageFromURL(url, avatarSize: nil)
}
@discardableResult func loadImageFromURL(_ url: URL) async -> Result<UIImage, MediaProviderError> {
await loadImageFromURL(url, avatarSize: nil)
}
}
@@ -1,49 +0,0 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import Foundation
import MatrixRustSDK
import UIKit
class MediaProxy: MediaProxyProtocol {
private let client: ClientProtocol
private let clientQueue: DispatchQueue
init(client: ClientProtocol,
clientQueue: DispatchQueue = .global()) {
self.client = client
self.clientQueue = clientQueue
}
func mediaSourceForURL(_ url: URL) -> MediaSourceProxy {
.init(url: url)
}
func loadMediaContentForSource(_ source: MediaSourceProxy) async throws -> Data {
try await Task.dispatch(on: clientQueue) {
let bytes = try self.client.getMediaContent(source: source.underlyingSource)
return Data(bytes: bytes, count: bytes.count)
}
}
func loadMediaThumbnailForSource(_ source: MediaSourceProxy, width: UInt, height: UInt) async throws -> Data {
try await Task.dispatch(on: clientQueue) {
let bytes = try self.client.getMediaThumbnail(source: source.underlyingSource, width: UInt64(width), height: UInt64(height))
return Data(bytes: bytes, count: bytes.count)
}
}
}
@@ -18,31 +18,31 @@ import Foundation
import UIKit
struct MockMediaProvider: MediaProviderProtocol {
func imageFromSource(_ source: MediaSourceProxy?, avatarSize: AvatarSize?) -> UIImage? {
func imageFromSource(_ source: MediaSourceProxy?, size: CGSize?) -> UIImage? {
nil
}
func loadImageFromSource(_ source: MediaSourceProxy, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError> {
func loadImageFromSource(_ source: MediaSourceProxy, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
.failure(.failedRetrievingImage)
}
func imageFromURL(_ url: URL?, avatarSize: AvatarSize?) -> UIImage? {
func imageFromURL(_ url: URL?, size: CGSize?) -> UIImage? {
guard url != nil else {
return nil
}
if let avatarSize {
switch avatarSize {
case .room:
#warning("Fix me. this is stupid!")
if let size {
if size == AvatarSize.room(on: .details).scaledSize
|| size == AvatarSize.room(on: .home).scaledSize
|| size == AvatarSize.room(on: .timeline).scaledSize {
return Asset.Images.appLogo.image
default:
return UIImage(systemName: "photo")
}
}
return UIImage(systemName: "photo")
}
func loadImageFromURL(_ url: URL, avatarSize: AvatarSize?) async -> Result<UIImage, MediaProviderError> {
func loadImageFromURL(_ url: URL, size: CGSize?) async -> Result<UIImage, MediaProviderError> {
guard let image = UIImage(systemName: "photo") else {
fatalError()
}
@@ -103,20 +103,8 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
}
if let item = timelineItem as? EventBasedTimelineItemProtocol {
await loadUserAvatarForTimelineItem(item)
await loadUserDisplayNameForTimelineItem(item)
}
switch timelineItem {
case let item as ImageRoomTimelineItem:
await loadThumbnailForImageTimelineItem(item)
case let item as VideoRoomTimelineItem:
await loadThumbnailForVideoTimelineItem(item)
case let item as StickerRoomTimelineItem:
await loadImageForStickerTimelineItem(item)
default:
break
}
}
func processItemDisappearance(_ itemID: String) { }
@@ -339,78 +327,6 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
return .middle
}
private func loadThumbnailForImageTimelineItem(_ timelineItem: ImageRoomTimelineItem) async {
if timelineItem.image != nil {
return
}
guard let source = timelineItem.source else {
return
}
switch await mediaProvider.loadImageFromSource(source) {
case .success(let image):
guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }),
var item = timelineItems[index] as? ImageRoomTimelineItem else {
return
}
item.image = image
timelineItems[index] = item
callbacks.send(.updatedTimelineItem(timelineItem.id))
case .failure:
break
}
}
private func loadThumbnailForVideoTimelineItem(_ timelineItem: VideoRoomTimelineItem) async {
if timelineItem.image != nil {
return
}
guard let source = timelineItem.thumbnailSource else {
return
}
switch await mediaProvider.loadImageFromSource(source) {
case .success(let image):
guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }),
var item = timelineItems[index] as? VideoRoomTimelineItem else {
return
}
item.image = image
timelineItems[index] = item
callbacks.send(.updatedTimelineItem(timelineItem.id))
case .failure:
break
}
}
private func loadImageForStickerTimelineItem(_ timelineItem: StickerRoomTimelineItem) async {
if timelineItem.image != nil {
return
}
guard let url = timelineItem.imageURL else {
return
}
switch await mediaProvider.loadImageFromURL(url) {
case .success(let image):
guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }),
var item = timelineItems[index] as? StickerRoomTimelineItem else {
return
}
item.image = image
timelineItems[index] = item
callbacks.send(.updatedTimelineItem(timelineItem.id))
case .failure:
break
}
}
private func loadVideoForTimelineItem(_ timelineItem: VideoRoomTimelineItem) async {
if timelineItem.cachedVideoURL != nil {
// already cached
@@ -511,29 +427,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
break
}
}
private func loadUserAvatarForTimelineItem(_ timelineItem: EventBasedTimelineItemProtocol) async {
guard timelineItem.shouldShowSenderDetails,
let avatarURL = timelineItem.sender.avatarURL,
timelineItem.sender.avatar == nil else {
return
}
switch await mediaProvider.loadImageFromURL(avatarURL, avatarSize: .user(on: .timeline)) {
case .success(let avatar):
guard let index = timelineItems.firstIndex(where: { $0.id == timelineItem.id }),
var item = timelineItems[index] as? EventBasedTimelineItemProtocol else {
return
}
item.sender.avatar = avatar
timelineItems[index] = item
callbacks.send(.updatedTimelineItem(timelineItem.id))
case .failure:
break
}
}
#warning("This is here because sender profiles aren't working properly. Remove it entirely later")
private func loadUserDisplayNameForTimelineItem(_ timelineItem: EventBasedTimelineItemProtocol) async {
if timelineItem.shouldShowSenderDetails == false || timelineItem.sender.displayName != nil {
@@ -23,5 +23,4 @@ struct TimelineItemSender: Identifiable, Hashable {
// Lazy loaded properties, displayName and avatarURL will be come lets.
var displayName: String?
var avatarURL: URL?
var avatar: UIImage?
}
@@ -27,7 +27,6 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
var sender: TimelineItemSender
let source: MediaSourceProxy?
var image: UIImage?
var cachedFileURL: URL?
var width: CGFloat?
@@ -29,7 +29,6 @@ struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let duration: UInt64
let source: MediaSourceProxy?
let thumbnailSource: MediaSourceProxy?
var image: UIImage?
var cachedVideoURL: URL?
var width: CGFloat?
@@ -26,8 +26,7 @@ struct StickerRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Ha
var sender: TimelineItemSender
let imageURL: URL?
var image: UIImage?
let imageURL: URL
var width: CGFloat?
var height: CGFloat?
@@ -38,64 +38,61 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
// swiftlint:disable:next cyclomatic_complexity
func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy,
groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol? {
var sender = eventItemProxy.sender
if let senderAvatarURL = eventItemProxy.sender.avatarURL,
let image = mediaProvider.imageFromURL(senderAvatarURL, avatarSize: .user(on: .timeline)) {
sender.avatar = image
}
let isOutgoing = eventItemProxy.isOwn
switch eventItemProxy.content.kind() {
case .unableToDecrypt(let encryptedMessage):
return buildEncryptedTimelineItem(eventItemProxy, encryptedMessage, sender, isOutgoing, groupState)
return buildEncryptedTimelineItem(eventItemProxy, encryptedMessage, isOutgoing, groupState)
case .redactedMessage:
return buildRedactedTimelineItem(eventItemProxy, sender, isOutgoing, groupState)
case .sticker(let body, let imageInfo, let url):
return buildStickerTimelineItem(eventItemProxy, body, imageInfo, url, sender, isOutgoing, groupState)
return buildRedactedTimelineItem(eventItemProxy, isOutgoing, groupState)
case .sticker(let body, let imageInfo, let urlString):
guard let url = URL(string: urlString) else {
MXLog.error("Invalid sticker url string: \(urlString)")
return buildUnsupportedTimelineItem(eventItemProxy, "m.sticker", "Invalid Sticker URL", isOutgoing, groupState)
}
return buildStickerTimelineItem(eventItemProxy, body, imageInfo, url, isOutgoing, groupState)
case .failedToParseMessageLike(let eventType, let error):
return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, sender, isOutgoing, groupState)
return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing, groupState)
case .failedToParseState(let eventType, _, let error):
return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, sender, isOutgoing, groupState)
return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing, groupState)
case .message:
guard let messageContent = eventItemProxy.content.asMessage() else { fatalError("Invalid message timeline item: \(eventItemProxy)") }
switch messageContent.msgtype() {
case .text(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildTextTimelineItemFromMessage(eventItemProxy, message, sender, isOutgoing, groupState)
return buildTextTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState)
case .image(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildImageTimelineItemFromMessage(eventItemProxy, message, sender, isOutgoing, groupState)
return buildImageTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState)
case .video(let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildVideoTimelineItemFromMessage(eventItemProxy, message, sender, isOutgoing, groupState)
return buildVideoTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState)
case .file(let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildFileTimelineItemFromMessage(eventItemProxy, message, sender, isOutgoing, groupState)
return buildFileTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState)
case .notice(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildNoticeTimelineItemFromMessage(eventItemProxy, message, sender, isOutgoing, groupState)
return buildNoticeTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState)
case .emote(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildEmoteTimelineItemFromMessage(eventItemProxy, message, sender, isOutgoing, groupState)
return buildEmoteTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState)
case .none:
return buildFallbackTimelineItem(eventItemProxy, sender, isOutgoing, groupState)
return buildFallbackTimelineItem(eventItemProxy, isOutgoing, groupState)
}
case .state(let stateKey, let content):
return buildStateTimelineItemFor(eventItemProxy: eventItemProxy, state: content, stateKey: stateKey, sender: sender, isOutgoing: isOutgoing)
return buildStateTimelineItemFor(eventItemProxy: eventItemProxy, state: content, stateKey: stateKey, isOutgoing: isOutgoing)
case .roomMembership(userId: let userID, change: let change):
return buildStateMembershipChangeTimelineItemFor(eventItemProxy: eventItemProxy, member: userID, membershipChange: change, sender: sender, isOutgoing: isOutgoing)
return buildStateMembershipChangeTimelineItemFor(eventItemProxy: eventItemProxy, member: userID, membershipChange: change, isOutgoing: isOutgoing)
}
}
// MARK: - Message Events
// swiftlint:disable:next function_parameter_count
private func buildUnsupportedTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ eventType: String,
_ error: String,
_ sender: TimelineItemSender,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
UnsupportedRoomTimelineItem(id: eventItemProxy.id,
@@ -106,7 +103,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: sender,
sender: eventItemProxy.sender,
properties: RoomTimelineItemProperties())
}
@@ -114,8 +111,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildStickerTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ body: String,
_ imageInfo: ImageInfo,
_ imageURLString: String,
_ sender: TimelineItemSender,
_ imageURL: URL,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
var aspectRatio: CGFloat?
@@ -125,20 +121,14 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
aspectRatio = width / height
}
var image: UIImage?
if let url = URL(string: imageURLString) {
image = mediaProvider.imageFromURL(url)
}
return StickerRoomTimelineItem(id: eventItemProxy.id,
text: body,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: sender,
imageURL: URL(string: imageURLString),
image: image,
sender: eventItemProxy.sender,
imageURL: imageURL,
width: width,
height: height,
aspectRatio: aspectRatio,
@@ -149,7 +139,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildEncryptedTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ encryptedMessage: EncryptedMessage,
_ sender: TimelineItemSender,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
var encryptionType = EncryptedRoomTimelineItem.EncryptionType.unknown
@@ -169,12 +158,11 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: sender,
sender: eventItemProxy.sender,
properties: RoomTimelineItemProperties())
}
private func buildRedactedTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ sender: TimelineItemSender,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
RedactedRoomTimelineItem(id: eventItemProxy.id,
@@ -183,12 +171,11 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: sender,
sender: eventItemProxy.sender,
properties: RoomTimelineItemProperties())
}
private func buildFallbackTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ sender: TimelineItemSender,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
let attributedText = attributedStringBuilder.fromPlain(eventItemProxy.body)
@@ -201,14 +188,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: sender,
sender: eventItemProxy.sender,
properties: RoomTimelineItemProperties(isEdited: eventItemProxy.content.asMessage()?.isEdited() ?? false,
reactions: aggregateReactions(eventItemProxy.reactions)))
}
private func buildTextTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<TextMessageContent>,
_ sender: TimelineItemSender,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
let attributedText = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
@@ -221,7 +207,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: sender,
sender: eventItemProxy.sender,
properties: RoomTimelineItemProperties(isEdited: message.isEdited,
reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus))
@@ -229,7 +215,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildImageTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<ImageMessageContent>,
_ sender: TimelineItemSender,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
var aspectRatio: CGFloat?
@@ -244,9 +229,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: sender,
sender: eventItemProxy.sender,
source: message.source,
image: mediaProvider.imageFromSource(message.source),
width: message.width,
height: message.height,
aspectRatio: aspectRatio,
@@ -258,7 +242,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildVideoTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<VideoMessageContent>,
_ sender: TimelineItemSender,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
var aspectRatio: CGFloat?
@@ -273,11 +256,10 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: sender,
sender: eventItemProxy.sender,
duration: message.duration,
source: message.source,
thumbnailSource: message.thumbnailSource,
image: mediaProvider.imageFromSource(message.thumbnailSource),
width: message.width,
height: message.height,
aspectRatio: aspectRatio,
@@ -289,7 +271,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildFileTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<FileMessageContent>,
_ sender: TimelineItemSender,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
FileRoomTimelineItem(id: message.id,
@@ -298,7 +279,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: sender,
sender: eventItemProxy.sender,
source: message.source,
thumbnailSource: message.thumbnailSource,
properties: RoomTimelineItemProperties(isEdited: message.isEdited,
@@ -308,7 +289,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildNoticeTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<NoticeMessageContent>,
_ sender: TimelineItemSender,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
let attributedText = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
@@ -321,7 +301,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: sender,
sender: eventItemProxy.sender,
properties: RoomTimelineItemProperties(isEdited: message.isEdited,
reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus))
@@ -329,10 +309,9 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildEmoteTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<EmoteMessageContent>,
_ sender: TimelineItemSender,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
let name = sender.displayName ?? sender.id
let name = eventItemProxy.sender.displayName ?? eventItemProxy.sender.id
var attributedText: AttributedString?
if let htmlBody = message.htmlBody {
@@ -350,7 +329,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: sender,
sender: eventItemProxy.sender,
properties: RoomTimelineItemProperties(isEdited: message.isEdited,
reactions: aggregateReactions(eventItemProxy.reactions),
deliveryStatus: eventItemProxy.deliveryStatus))
@@ -368,28 +347,26 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildStateTimelineItemFor(eventItemProxy: EventTimelineItemProxy,
state: OtherState,
stateKey: String,
sender: TimelineItemSender,
isOutgoing: Bool) -> RoomTimelineItemProtocol? {
guard let text = stateEventStringBuilder.buildString(for: state, stateKey: stateKey, sender: sender, isOutgoing: isOutgoing) else { return nil }
return buildStateTimelineItem(eventItemProxy: eventItemProxy, text: text, sender: sender, isOutgoing: isOutgoing)
guard let text = stateEventStringBuilder.buildString(for: state, stateKey: stateKey, sender: eventItemProxy.sender, isOutgoing: isOutgoing) else { return nil }
return buildStateTimelineItem(eventItemProxy: eventItemProxy, text: text, isOutgoing: isOutgoing)
}
private func buildStateMembershipChangeTimelineItemFor(eventItemProxy: EventTimelineItemProxy,
member: String,
membershipChange: MembershipChange,
sender: TimelineItemSender,
isOutgoing: Bool) -> RoomTimelineItemProtocol? {
guard let text = stateEventStringBuilder.buildString(for: membershipChange, member: member, sender: eventItemProxy.sender, isOutgoing: isOutgoing) else { return nil }
return buildStateTimelineItem(eventItemProxy: eventItemProxy, text: text, sender: sender, isOutgoing: isOutgoing)
return buildStateTimelineItem(eventItemProxy: eventItemProxy, text: text, isOutgoing: isOutgoing)
}
private func buildStateTimelineItem(eventItemProxy: EventTimelineItemProxy, text: String, sender: TimelineItemSender, isOutgoing: Bool) -> RoomTimelineItemProtocol {
private func buildStateTimelineItem(eventItemProxy: EventTimelineItemProxy, text: String, isOutgoing: Bool) -> RoomTimelineItemProtocol {
StateRoomTimelineItem(id: eventItemProxy.id,
text: text,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
groupState: .single,
isOutgoing: isOutgoing,
isEditable: false,
sender: sender)
sender: eventItemProxy.sender)
}
}
@@ -190,12 +190,12 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
let userNotificationController = UserNotificationController(rootCoordinator: settingsNavigationStackCoordinator)
let parameters = SettingsCoordinatorParameters(navigationStackCoordinator: settingsNavigationStackCoordinator,
userNotificationController: userNotificationController,
userSession: userSession,
bugReportService: bugReportService)
let settingsCoordinator = SettingsCoordinator(parameters: parameters)
settingsCoordinator.callback = { [weak self] action in
let parameters = SettingsScreenCoordinatorParameters(navigationStackCoordinator: settingsNavigationStackCoordinator,
userNotificationController: userNotificationController,
userSession: userSession,
bugReportService: bugReportService)
let settingsScreenCoordinator = SettingsScreenCoordinator(parameters: parameters)
settingsScreenCoordinator.callback = { [weak self] action in
guard let self else { return }
switch action {
case .dismiss:
@@ -206,7 +206,7 @@ class UserSessionFlowCoordinator: CoordinatorProtocol {
}
}
settingsNavigationStackCoordinator.setRootCoordinator(settingsCoordinator)
settingsNavigationStackCoordinator.setRootCoordinator(settingsScreenCoordinator)
navigationSplitCoordinator.setSheetCoordinator(userNotificationController) { [weak self] in
self?.stateMachine.processEvent(.dismissedSettingsScreen)
@@ -96,7 +96,7 @@ class UserSessionStore: UserSessionStoreProtocol {
imageCache.memoryStorage.config.keepWhenEnteringBackground = true
return UserSession(clientProxy: clientProxy,
mediaProvider: MediaProvider(mediaProxy: clientProxy,
mediaProvider: MediaProvider(mediaLoader: clientProxy,
imageCache: imageCache,
fileCache: FileCache.default,
backgroundTaskService: backgroundTaskService))
@@ -118,11 +118,11 @@ class MockScreen: Identifiable {
return navigationStackCoordinator
case .settings:
let navigationStackCoordinator = NavigationStackCoordinator()
let coordinator = SettingsCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator,
userNotificationController: MockUserNotificationController(),
userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"),
mediaProvider: MockMediaProvider()),
bugReportService: MockBugReportService()))
let coordinator = SettingsScreenCoordinator(parameters: .init(navigationStackCoordinator: navigationStackCoordinator,
userNotificationController: MockUserNotificationController(),
userSession: MockUserSession(clientProxy: MockClientProxy(userID: "@mock:client.com"),
mediaProvider: MockMediaProvider()),
bugReportService: MockBugReportService()))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .bugReport: