mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 03:48:27 +00:00
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:
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -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 }
|
||||
|
||||
+6
-6
@@ -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
|
||||
+8
-9
@@ -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)
|
||||
+3
-3
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user