mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 11:58:32 +00:00
Media gallery - part 1(#3588)
* Introduce a `MediaEventsTimelineFlowCoordinator` * Update SDK API and architecture * Add a feature flag, add translations * Move the media events timeline presentation under the room flow coordinator state machine * Rename `TimelineViewState.timelineViewState` of type `TimelineState` to `timelineState` * Enabled SwiftLint's `trailing_closure` rule and fix the warnings.
This commit is contained in:
@@ -34,7 +34,7 @@ struct BlockedUsersScreen: View {
|
||||
ForEach(context.viewState.blockedUsers, id: \.self) { user in
|
||||
ListRow(label: .avatar(title: user.displayName ?? user.userID, icon: avatar(for: user)),
|
||||
details: .isWaiting(context.viewState.processingUserID == user.userID),
|
||||
kind: .button(action: { context.send(viewAction: .unblockUser(user)) }))
|
||||
kind: .button { context.send(viewAction: .unblockUser(user)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,14 +34,14 @@ class PollFormScreenViewModel: PollFormScreenViewModelType, PollFormScreenViewMo
|
||||
title: L10n.screenEditPollDeleteConfirmationTitle,
|
||||
message: L10n.screenEditPollDeleteConfirmation,
|
||||
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
|
||||
secondaryButton: .init(title: L10n.actionOk, action: { self.actionsSubject.send(.delete) }))
|
||||
secondaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.delete) })
|
||||
case .cancel:
|
||||
if state.formContentHasChanged {
|
||||
state.bindings.alertInfo = .init(id: .init(),
|
||||
title: L10n.screenCreatePollCancelConfirmationTitleIos,
|
||||
message: L10n.screenCreatePollCancelConfirmationContentIos,
|
||||
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
|
||||
secondaryButton: .init(title: L10n.actionOk, action: { self.actionsSubject.send(.cancel) }))
|
||||
secondaryButton: .init(title: L10n.actionOk) { self.actionsSubject.send(.cancel) })
|
||||
} else {
|
||||
actionsSubject.send(.cancel)
|
||||
}
|
||||
|
||||
+2
-2
@@ -43,9 +43,9 @@ class DeactivateAccountScreenViewModel: DeactivateAccountScreenViewModelType, De
|
||||
state.bindings.alertInfo = .init(id: .confirmation,
|
||||
title: L10n.screenDeactivateAccountTitle,
|
||||
message: L10n.screenDeactivateAccountConfirmationDialogContent,
|
||||
primaryButton: .init(title: L10n.actionDeactivate, action: {
|
||||
primaryButton: .init(title: L10n.actionDeactivate) {
|
||||
Task { await self.deactivateAccount() }
|
||||
}),
|
||||
},
|
||||
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -38,10 +38,10 @@ class EncryptionResetScreenViewModel: EncryptionResetScreenViewModelType, Encryp
|
||||
state.bindings.alertInfo = .init(id: UUID(),
|
||||
title: L10n.screenResetEncryptionConfirmationAlertTitle,
|
||||
message: L10n.screenResetEncryptionConfirmationAlertSubtitle,
|
||||
primaryButton: .init(title: L10n.screenResetEncryptionConfirmationAlertAction, role: .destructive, action: { [weak self] in
|
||||
primaryButton: .init(title: L10n.screenResetEncryptionConfirmationAlertAction, role: .destructive) { [weak self] in
|
||||
guard let self else { return }
|
||||
Task { await self.startResetFlow() }
|
||||
}))
|
||||
})
|
||||
case .cancel:
|
||||
actionsSubject.send(.cancel)
|
||||
}
|
||||
|
||||
@@ -326,11 +326,10 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||
self.state.bindings.alertInfo = AlertInfo(id: UUID(),
|
||||
title: L10n.bannerMigrateToNativeSlidingSyncForceLogoutTitle,
|
||||
primaryButton: .init(title: L10n.bannerMigrateToNativeSlidingSyncAction,
|
||||
action: { [weak self] in
|
||||
self?.appSettings.slidingSyncDiscovery = .native
|
||||
self?.actionsSubject.send(.logoutWithoutConfirmation)
|
||||
}))
|
||||
primaryButton: .init(title: L10n.bannerMigrateToNativeSlidingSyncAction) { [weak self] in
|
||||
self?.appSettings.slidingSyncDiscovery = .native
|
||||
self?.actionsSubject.send(.logoutWithoutConfirmation)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -433,7 +432,7 @@ class HomeScreenViewModel: HomeScreenViewModelType, HomeScreenViewModelProtocol
|
||||
title: title,
|
||||
message: message,
|
||||
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
|
||||
secondaryButton: .init(title: L10n.actionDecline, role: .destructive, action: { Task { await self.declineInvite(roomID: room.id) } }))
|
||||
secondaryButton: .init(title: L10n.actionDecline, role: .destructive) { Task { await self.declineInvite(roomID: room.id) } })
|
||||
}
|
||||
|
||||
private func declineInvite(roomID: String) async {
|
||||
|
||||
@@ -63,7 +63,7 @@ struct HomeScreenEmptyStateLayout: Layout {
|
||||
}
|
||||
|
||||
func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
|
||||
let mainView = subviews.first(where: { $0.priority > 0 })
|
||||
let mainView = subviews.first { $0.priority > 0 }
|
||||
let topViews = subviews.filter { $0 != mainView }
|
||||
|
||||
var y: CGFloat = bounds.minY
|
||||
|
||||
@@ -50,7 +50,7 @@ struct InviteUsersScreenSelectedItem_Previews: PreviewProvider, TestablePreview
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 28) {
|
||||
ForEach(people, id: \.userID) { user in
|
||||
InviteUsersScreenSelectedItem(user: user, mediaProvider: MediaProviderMock(configuration: .init()), dismissAction: { })
|
||||
InviteUsersScreenSelectedItem(user: user, mediaProvider: MediaProviderMock(configuration: .init())) { }
|
||||
.frame(width: 72)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,7 +221,7 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
|
||||
title: L10n.screenInvitesDeclineChatTitle,
|
||||
message: L10n.screenInvitesDeclineChatMessage(roomName),
|
||||
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
|
||||
secondaryButton: .init(title: L10n.actionDecline, role: .destructive, action: { Task { await self.declineInvite() } }))
|
||||
secondaryButton: .init(title: L10n.actionDecline, role: .destructive) { Task { await self.declineInvite() } })
|
||||
}
|
||||
|
||||
private func showCancelKnockConfirmationAlert() {
|
||||
@@ -229,7 +229,7 @@ class JoinRoomScreenViewModel: JoinRoomScreenViewModelType, JoinRoomScreenViewMo
|
||||
title: L10n.screenJoinRoomCancelKnockAlertTitle,
|
||||
message: L10n.screenJoinRoomCancelKnockAlertDescription,
|
||||
primaryButton: .init(title: L10n.actionNo, role: .cancel, action: nil),
|
||||
secondaryButton: .init(title: L10n.screenJoinRoomCancelKnockAlertConfirmation, role: .destructive, action: { Task { await self.cancelKnock() } }))
|
||||
secondaryButton: .init(title: L10n.screenJoinRoomCancelKnockAlertConfirmation, role: .destructive) { Task { await self.cancelKnock() } })
|
||||
}
|
||||
|
||||
private func declineInvite() async {
|
||||
|
||||
@@ -261,7 +261,7 @@ struct JoinRoomScreen_Previews: PreviewProvider, TestablePreview {
|
||||
topic: "“Science and technology were the only keys to opening the door to the future, and people approached science with the faith and sincerity of elementary school students.”",
|
||||
avatarURL: .mockMXCAvatar,
|
||||
memberCount: UInt(100),
|
||||
isHistoryWorldReadable: false,
|
||||
isHistoryWorldReadable: nil,
|
||||
isJoined: membership.isJoined,
|
||||
isInvited: membership.isInvited,
|
||||
isPublic: membership.isPublic,
|
||||
|
||||
@@ -177,19 +177,19 @@ struct KnockRequestCell_Previews: PreviewProvider, TestablePreview {
|
||||
static let aliceWithNoName = KnockRequestCellInfo(id: "@alice:matrix.org", displayName: nil, avatarURL: nil, timestamp: "20 Nov 2024", reason: nil)
|
||||
|
||||
static var previews: some View {
|
||||
KnockRequestCell(cellInfo: aliceWithLongReason, onAccept: { _ in }, onDecline: { _ in }, onDeclineAndBan: { _ in })
|
||||
KnockRequestCell(cellInfo: aliceWithLongReason) { _ in } onDecline: { _ in } onDeclineAndBan: { _ in }
|
||||
.previewDisplayName("Long reason")
|
||||
KnockRequestCell(cellInfo: aliceWithShortReason, onAccept: { _ in }, onDecline: { _ in }, onDeclineAndBan: { _ in })
|
||||
KnockRequestCell(cellInfo: aliceWithShortReason) { _ in } onDecline: { _ in } onDeclineAndBan: { _ in }
|
||||
.previewDisplayName("Short reason")
|
||||
KnockRequestCell(cellInfo: aliceWithNoReason, onAccept: { _ in }, onDecline: { _ in }, onDeclineAndBan: { _ in })
|
||||
KnockRequestCell(cellInfo: aliceWithNoReason) { _ in } onDecline: { _ in } onDeclineAndBan: { _ in }
|
||||
.previewDisplayName("No reason")
|
||||
KnockRequestCell(cellInfo: aliceWithNoName, onAccept: { _ in }, onDecline: { _ in }, onDeclineAndBan: { _ in })
|
||||
KnockRequestCell(cellInfo: aliceWithNoName) { _ in } onDecline: { _ in } onDeclineAndBan: { _ in }
|
||||
.previewDisplayName("No name")
|
||||
KnockRequestCell(cellInfo: aliceWithShortReason, onAccept: nil, onDecline: { _ in }, onDeclineAndBan: { _ in })
|
||||
.previewDisplayName("No Accept")
|
||||
KnockRequestCell(cellInfo: aliceWithShortReason, onAccept: nil, onDecline: nil, onDeclineAndBan: { _ in })
|
||||
.previewDisplayName("No Accept and Decline")
|
||||
KnockRequestCell(cellInfo: aliceWithShortReason, onAccept: { _ in }, onDecline: { _ in }, onDeclineAndBan: nil)
|
||||
.previewDisplayName("No Ban")
|
||||
// KnockRequestCell(cellInfo: aliceWithShortReason, onAccept: nil) onDecline: { _ in } onDeclineAndBan: { _ in }
|
||||
// .previewDisplayName("No Accept")
|
||||
// KnockRequestCell(cellInfo: aliceWithShortReason) onDeclineAndBan: { _ in }
|
||||
// .previewDisplayName("No Accept and Decline")
|
||||
// KnockRequestCell(cellInfo: aliceWithShortReason) { _ in } onDecline: { _ in })
|
||||
// .previewDisplayName("No Ban")
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct MediaEventsTimelineScreenCoordinatorParameters {
|
||||
let roomProxy: JoinedRoomProxyProtocol
|
||||
let mediaTimelineController: RoomTimelineControllerProtocol
|
||||
let filesTimelineController: RoomTimelineControllerProtocol
|
||||
let mediaProvider: MediaProviderProtocol
|
||||
let mediaPlayerProvider: MediaPlayerProviderProtocol
|
||||
let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
|
||||
let appMediator: AppMediatorProtocol
|
||||
let emojiProvider: EmojiProviderProtocol
|
||||
}
|
||||
|
||||
enum MediaEventsTimelineScreenCoordinatorAction { }
|
||||
|
||||
final class MediaEventsTimelineScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: MediaEventsTimelineScreenCoordinatorParameters
|
||||
private let viewModel: MediaEventsTimelineScreenViewModelProtocol
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
private let actionsSubject: PassthroughSubject<MediaEventsTimelineScreenCoordinatorAction, Never> = .init()
|
||||
var actions: AnyPublisher<MediaEventsTimelineScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: MediaEventsTimelineScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
let mediaTimelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
|
||||
timelineController: parameters.mediaTimelineController,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
mediaPlayerProvider: parameters.mediaPlayerProvider,
|
||||
voiceMessageMediaManager: parameters.voiceMessageMediaManager,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
appMediator: parameters.appMediator,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
emojiProvider: parameters.emojiProvider)
|
||||
|
||||
let filesTimelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
|
||||
timelineController: parameters.filesTimelineController,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
mediaPlayerProvider: parameters.mediaPlayerProvider,
|
||||
voiceMessageMediaManager: parameters.voiceMessageMediaManager,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController,
|
||||
appMediator: parameters.appMediator,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
emojiProvider: parameters.emojiProvider)
|
||||
|
||||
viewModel = MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: mediaTimelineViewModel,
|
||||
filesTimelineViewModel: filesTimelineViewModel,
|
||||
mediaProvider: parameters.mediaProvider)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(MediaEventsTimelineScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum MediaEventsTimelineScreenViewModelAction { }
|
||||
|
||||
enum MediaEventsTimelineScreenMode {
|
||||
case media
|
||||
case files
|
||||
}
|
||||
|
||||
struct MediaEventsTimelineScreenViewState: BindableState {
|
||||
var isBackPaginating = false
|
||||
var items = [RoomTimelineItemViewState]()
|
||||
|
||||
var bindings: MediaEventsTimelineScreenViewStateBindings
|
||||
}
|
||||
|
||||
struct MediaEventsTimelineScreenViewStateBindings {
|
||||
var screenMode: MediaEventsTimelineScreenMode
|
||||
}
|
||||
|
||||
enum MediaEventsTimelineScreenViewAction {
|
||||
case changedScreenMode
|
||||
case oldestItemDidAppear
|
||||
case oldestItemDidDisappear
|
||||
}
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias MediaEventsTimelineScreenViewModelType = StateStoreViewModel<MediaEventsTimelineScreenViewState, MediaEventsTimelineScreenViewAction>
|
||||
|
||||
class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType, MediaEventsTimelineScreenViewModelProtocol {
|
||||
private let mediaTimelineViewModel: TimelineViewModelProtocol
|
||||
private let filesTimelineViewModel: TimelineViewModelProtocol
|
||||
|
||||
private var isOldestItemVisible = false
|
||||
|
||||
private var activeTimelineViewModel: TimelineViewModelProtocol {
|
||||
switch state.bindings.screenMode {
|
||||
case .media:
|
||||
mediaTimelineViewModel
|
||||
case .files:
|
||||
filesTimelineViewModel
|
||||
}
|
||||
}
|
||||
|
||||
private let actionsSubject: PassthroughSubject<MediaEventsTimelineScreenViewModelAction, Never> = .init()
|
||||
var actionsPublisher: AnyPublisher<MediaEventsTimelineScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(mediaTimelineViewModel: TimelineViewModelProtocol,
|
||||
filesTimelineViewModel: TimelineViewModelProtocol,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
screenMode: MediaEventsTimelineScreenMode = .media) {
|
||||
self.mediaTimelineViewModel = mediaTimelineViewModel
|
||||
self.filesTimelineViewModel = filesTimelineViewModel
|
||||
|
||||
super.init(initialViewState: .init(bindings: .init(screenMode: screenMode)), mediaProvider: mediaProvider)
|
||||
|
||||
mediaTimelineViewModel.context.$viewState.sink { [weak self] timelineViewState in
|
||||
guard let self, state.bindings.screenMode == .media else {
|
||||
return
|
||||
}
|
||||
|
||||
updateWithTimelineViewState(timelineViewState)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
filesTimelineViewModel.context.$viewState.sink { [weak self] timelineViewState in
|
||||
guard let self, state.bindings.screenMode == .files else {
|
||||
return
|
||||
}
|
||||
|
||||
updateWithTimelineViewState(timelineViewState)
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
updateWithTimelineViewState(activeTimelineViewModel.context.viewState)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: MediaEventsTimelineScreenViewAction) {
|
||||
MXLog.info("View model: received view action: \(viewAction)")
|
||||
|
||||
switch viewAction {
|
||||
case .changedScreenMode:
|
||||
updateWithTimelineViewState(activeTimelineViewModel.context.viewState)
|
||||
case .oldestItemDidAppear:
|
||||
isOldestItemVisible = true
|
||||
backPaginateIfNecessary(paginationStatus: activeTimelineViewModel.context.viewState.timelineState.paginationState.backward)
|
||||
case .oldestItemDidDisappear:
|
||||
isOldestItemVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func updateWithTimelineViewState(_ timelineViewState: TimelineViewState) {
|
||||
state.items = timelineViewState.timelineState.itemViewStates.filter { itemViewState in
|
||||
switch itemViewState.type {
|
||||
case .image, .video:
|
||||
state.bindings.screenMode == .media
|
||||
case .audio, .file:
|
||||
state.bindings.screenMode == .files
|
||||
default:
|
||||
false
|
||||
}
|
||||
}.reversed()
|
||||
|
||||
state.isBackPaginating = (timelineViewState.timelineState.paginationState.backward == .paginating)
|
||||
backPaginateIfNecessary(paginationStatus: timelineViewState.timelineState.paginationState.backward)
|
||||
}
|
||||
|
||||
private func backPaginateIfNecessary(paginationStatus: PaginationStatus) {
|
||||
if paginationStatus == .idle, isOldestItemVisible {
|
||||
activeTimelineViewModel.context.send(viewAction: .paginateBackwards)
|
||||
}
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
protocol MediaEventsTimelineScreenViewModelProtocol {
|
||||
var actionsPublisher: AnyPublisher<MediaEventsTimelineScreenViewModelAction, Never> { get }
|
||||
var context: MediaEventsTimelineScreenViewModelType.Context { get }
|
||||
}
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// Copyright 2022-2024 New Vector Ltd.
|
||||
//
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
// Please see LICENSE in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct MediaEventsTimelineScreen: View {
|
||||
@ObservedObject var context: MediaEventsTimelineScreenViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
content
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.background(.compound.bgCanvasDefault)
|
||||
// Doesn't play well with the transformed scrollView
|
||||
.toolbarBackground(.visible, for: .navigationBar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .principal) {
|
||||
Picker("", selection: $context.screenMode) {
|
||||
Text(L10n.screenMediaBrowserListModeMedia)
|
||||
.padding()
|
||||
.tag(MediaEventsTimelineScreenMode.media)
|
||||
Text(L10n.screenMediaBrowserListModeFiles)
|
||||
.padding()
|
||||
.tag(MediaEventsTimelineScreenMode.files)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
ScrollView {
|
||||
Group {
|
||||
let columns = [GridItem(.adaptive(minimum: 80, maximum: 150), spacing: 1)]
|
||||
LazyVGrid(columns: columns, alignment: .center, spacing: 1) {
|
||||
ForEach(context.viewState.items) { item in
|
||||
Color.clear // Let the image aspect fill in place
|
||||
.aspectRatio(1, contentMode: .fill)
|
||||
.overlay {
|
||||
viewForTimelineItem(item)
|
||||
}
|
||||
.clipped()
|
||||
.scaleEffect(.init(width: 1, height: -1))
|
||||
}
|
||||
}
|
||||
|
||||
// Needs to be wrapped in a LazyStack otherwise appearance calls don't trigger
|
||||
LazyVStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.frame(height: 44)
|
||||
.foregroundStyle(.compound.bgCanvasDefault)
|
||||
.overlay {
|
||||
if context.viewState.isBackPaginating {
|
||||
ProgressView()
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
context.send(viewAction: .oldestItemDidAppear)
|
||||
}
|
||||
.onDisappear {
|
||||
context.send(viewAction: .oldestItemDidDisappear)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.scaleEffect(.init(width: 1, height: -1))
|
||||
.onChange(of: context.screenMode) { _, _ in
|
||||
context.send(viewAction: .changedScreenMode)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder func viewForTimelineItem(_ item: RoomTimelineItemViewState) -> some View {
|
||||
switch item.type {
|
||||
case .image(let timelineItem):
|
||||
#warning("Make this work for gifs")
|
||||
LoadableImage(mediaSource: timelineItem.content.thumbnailInfo?.source ?? timelineItem.content.imageInfo.source,
|
||||
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID.id),
|
||||
blurhash: timelineItem.content.blurhash,
|
||||
size: timelineItem.content.thumbnailInfo?.size ?? timelineItem.content.imageInfo.size,
|
||||
mediaProvider: context.mediaProvider) {
|
||||
placeholder
|
||||
}
|
||||
.mediaItemAspectRatio(imageInfo: timelineItem.content.thumbnailInfo ?? timelineItem.content.imageInfo)
|
||||
case .video(let timelineItem):
|
||||
if let thumbnailSource = timelineItem.content.thumbnailInfo?.source {
|
||||
LoadableImage(mediaSource: thumbnailSource,
|
||||
mediaType: .timelineItem(uniqueID: timelineItem.id.uniqueID.id),
|
||||
blurhash: timelineItem.content.blurhash,
|
||||
size: timelineItem.content.thumbnailInfo?.size,
|
||||
mediaProvider: context.mediaProvider) { imageView in
|
||||
imageView
|
||||
.overlay { playIcon }
|
||||
} placeholder: {
|
||||
placeholder
|
||||
}
|
||||
.mediaItemAspectRatio(imageInfo: timelineItem.content.thumbnailInfo)
|
||||
} else {
|
||||
playIcon
|
||||
}
|
||||
case .separator(let timelineItem):
|
||||
Text(timelineItem.text)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var playIcon: some View {
|
||||
Image(systemName: "play.circle.fill")
|
||||
.resizable()
|
||||
.frame(width: 50, height: 50)
|
||||
.background(.ultraThinMaterial, in: Circle())
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
|
||||
private var placeholder: some View {
|
||||
Rectangle()
|
||||
.foregroundColor(.compound._bgBubbleIncoming)
|
||||
.opacity(0.3)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// Constrains the max height of a media item in the timeline, whilst preserving its aspect ratio.
|
||||
@ViewBuilder
|
||||
func mediaItemAspectRatio(imageInfo: ImageInfoProxy?) -> some View {
|
||||
aspectRatio(imageInfo?.aspectRatio, contentMode: .fill)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct MediaEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let timelineViewModel: TimelineViewModel = {
|
||||
let timelineController = MockRoomTimelineController(timelineKind: .media)
|
||||
return TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")),
|
||||
timelineController: timelineController,
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
mediaPlayerProvider: MediaPlayerProviderMock(),
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
userIndicatorController: UserIndicatorControllerMock(),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analyticsService: ServiceLocator.shared.analytics,
|
||||
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings))
|
||||
}()
|
||||
|
||||
static let mediaViewModel = MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: timelineViewModel,
|
||||
filesTimelineViewModel: timelineViewModel,
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
screenMode: .media)
|
||||
|
||||
static let filesViewModel = MediaEventsTimelineScreenViewModel(mediaTimelineViewModel: timelineViewModel,
|
||||
filesTimelineViewModel: timelineViewModel,
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
screenMode: .files)
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
MediaEventsTimelineScreen(context: mediaViewModel.context)
|
||||
.previewDisplayName("Media")
|
||||
}
|
||||
|
||||
NavigationStack {
|
||||
MediaEventsTimelineScreen(context: filesViewModel.context)
|
||||
.previewDisplayName("Files")
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -69,7 +69,7 @@ struct PinnedEventsTimelineScreen: View {
|
||||
TimelineView()
|
||||
.id(timelineContext.viewState.roomID)
|
||||
.environmentObject(timelineContext)
|
||||
.environment(\.focussedEventID, timelineContext.viewState.timelineViewState.focussedEvent?.eventID)
|
||||
.environment(\.focussedEventID, timelineContext.viewState.timelineState.focussedEvent?.eventID)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-2
@@ -60,8 +60,7 @@ struct RoomChangeRolesScreenSelectedItem_Previews: PreviewProvider, TestablePrev
|
||||
HStack(spacing: 12) {
|
||||
ForEach(members, id: \.id) { member in
|
||||
RoomChangeRolesScreenSelectedItem(member: member,
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
dismissAction: { })
|
||||
mediaProvider: MediaProviderMock(configuration: .init())) { }
|
||||
.frame(width: 72)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ enum RoomDetailsScreenCoordinatorAction {
|
||||
case presentRolesAndPermissionsScreen
|
||||
case presentCall
|
||||
case presentPinnedEventsTimeline
|
||||
case presentMediaEventsTimeline
|
||||
case presentKnockingRequestsListScreen
|
||||
}
|
||||
|
||||
@@ -80,6 +81,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.send(.presentCall)
|
||||
case .displayPinnedEventsTimeline:
|
||||
actionsSubject.send(.presentPinnedEventsTimeline)
|
||||
case .displayMediaEventsTimeline:
|
||||
actionsSubject.send(.presentMediaEventsTimeline)
|
||||
case .displayKnockingRequests:
|
||||
actionsSubject.send(.presentKnockingRequestsListScreen)
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ enum RoomDetailsScreenViewModelAction {
|
||||
case requestRolesAndPermissionsPresentation
|
||||
case startCall
|
||||
case displayPinnedEventsTimeline
|
||||
case displayMediaEventsTimeline
|
||||
case displayKnockingRequests
|
||||
}
|
||||
|
||||
@@ -48,13 +49,15 @@ struct RoomDetailsScreenViewState: BindableState {
|
||||
var notificationSettingsState: RoomDetailsNotificationSettingsState = .loading
|
||||
var canJoinCall = false
|
||||
var pinnedEventsActionState = RoomDetailsScreenPinnedEventsActionState.loading
|
||||
|
||||
var knockingEnabled = false
|
||||
var isKnockableRoom = false
|
||||
|
||||
var canSeeKnockingRequests: Bool {
|
||||
knockingEnabled && dmRecipient == nil && isKnockableRoom && (canInviteUsers || canKickUsers || canBanUsers)
|
||||
}
|
||||
|
||||
var mediaBrowserEnabled = false
|
||||
|
||||
var canEdit: Bool {
|
||||
!isDirect && (canEditRoomName || canEditRoomTopic || canEditRoomAvatar)
|
||||
}
|
||||
@@ -197,6 +200,7 @@ enum RoomDetailsScreenViewAction {
|
||||
case processTapRolesAndPermissions
|
||||
case processTapCall
|
||||
case processTapPinnedEvents
|
||||
case processTapMediaEvents
|
||||
case processTapRequestsToJoin
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,10 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
||||
.weakAssign(to: \.state.knockingEnabled, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
appSettings.$mediaBrowserEnabled
|
||||
.weakAssign(to: \.state.mediaBrowserEnabled, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
appMediator.networkMonitor.reachabilityPublisher
|
||||
.filter { $0 == .reachable }
|
||||
.receive(on: DispatchQueue.main)
|
||||
@@ -164,6 +168,8 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
||||
case .processTapPinnedEvents:
|
||||
analyticsService.trackInteraction(name: .PinnedMessageRoomInfoButton)
|
||||
actionsSubject.send(.displayPinnedEventsTimeline)
|
||||
case .processTapMediaEvents:
|
||||
actionsSubject.send(.displayMediaEventsTimeline)
|
||||
case .processTapRequestsToJoin:
|
||||
actionsSubject.send(.displayKnockingRequests)
|
||||
}
|
||||
@@ -207,8 +213,8 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self, ownUserID = roomProxy.ownUserID] members in
|
||||
guard let self else { return }
|
||||
let accountOwner = members.first(where: { $0.userID == ownUserID })
|
||||
let dmRecipient = members.first(where: { $0.userID != ownUserID })
|
||||
let accountOwner = members.first { $0.userID == ownUserID }
|
||||
let dmRecipient = members.first { $0.userID != ownUserID }
|
||||
self.dmRecipient = dmRecipient
|
||||
self.state.dmRecipient = dmRecipient.map(RoomMemberDetails.init(withProxy:))
|
||||
self.state.accountOwner = accountOwner.map(RoomMemberDetails.init(withProxy:))
|
||||
|
||||
@@ -164,9 +164,9 @@ struct RoomDetailsScreen: View {
|
||||
ListRow(label: .default(title: L10n.screenRoomDetailsPinnedEventsRowTitle,
|
||||
icon: \.pin),
|
||||
details: context.viewState.pinnedEventsActionState.isLoading ? .isWaiting(true) : .title(context.viewState.pinnedEventsActionState.count),
|
||||
kind: context.viewState.pinnedEventsActionState.isLoading ? .label : .navigationLink(action: {
|
||||
kind: context.viewState.pinnedEventsActionState.isLoading ? .label : .navigationLink {
|
||||
context.send(viewAction: .processTapPinnedEvents)
|
||||
}))
|
||||
})
|
||||
.disabled(context.viewState.pinnedEventsActionState.isLoading)
|
||||
|
||||
if context.viewState.canSeeKnockingRequests {
|
||||
@@ -184,6 +184,13 @@ struct RoomDetailsScreen: View {
|
||||
context.send(viewAction: .processTapPolls)
|
||||
})
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.pollsHistory)
|
||||
|
||||
if context.viewState.mediaBrowserEnabled {
|
||||
ListRow(label: .default(title: L10n.screenMediaBrowserTitle, icon: \.image),
|
||||
kind: .navigationLink {
|
||||
context.send(viewAction: .processTapMediaEvents)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,8 +46,7 @@ struct RoomMemberDetailsScreen: View {
|
||||
AvatarHeaderView(user: UserProfileProxy(userID: context.viewState.userID),
|
||||
isVerified: context.viewState.showVerifiedBadge,
|
||||
avatarSize: .user(on: .memberDetails),
|
||||
mediaProvider: context.mediaProvider,
|
||||
footer: { })
|
||||
mediaProvider: context.mediaProvider) { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-12
@@ -586,14 +586,14 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
private func makeCreateWithTextAlertInfo(urlBinding: Binding<String>, textBinding: Binding<String>) -> AlertInfo<UUID> {
|
||||
AlertInfo(id: UUID(),
|
||||
title: L10n.richTextEditorCreateLink,
|
||||
primaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionCancel, action: {
|
||||
primaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionCancel) {
|
||||
self.restoreComposerSelectedRange()
|
||||
}),
|
||||
secondaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionSave, action: {
|
||||
},
|
||||
secondaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionSave) {
|
||||
self.restoreComposerSelectedRange()
|
||||
self.createLinkWithText()
|
||||
|
||||
}),
|
||||
},
|
||||
textFields: [AlertInfo<UUID>.AlertTextField(placeholder: L10n.commonText,
|
||||
text: textBinding,
|
||||
autoCapitalization: .never,
|
||||
@@ -607,14 +607,14 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
private func makeSetUrlAlertInfo(urlBinding: Binding<String>, isEdit: Bool) -> AlertInfo<UUID> {
|
||||
AlertInfo(id: UUID(),
|
||||
title: isEdit ? L10n.richTextEditorEditLink : L10n.richTextEditorCreateLink,
|
||||
primaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionCancel, action: {
|
||||
primaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionCancel) {
|
||||
self.restoreComposerSelectedRange()
|
||||
}),
|
||||
secondaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionSave, action: {
|
||||
},
|
||||
secondaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionSave) {
|
||||
self.restoreComposerSelectedRange()
|
||||
self.setLink()
|
||||
|
||||
}),
|
||||
},
|
||||
textFields: [AlertInfo<UUID>.AlertTextField(placeholder: L10n.richTextEditorUrlPlaceholder,
|
||||
text: urlBinding,
|
||||
autoCapitalization: .never,
|
||||
@@ -624,16 +624,16 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
private func makeEditChoiceAlertInfo(urlBinding: Binding<String>) -> AlertInfo<UUID> {
|
||||
AlertInfo(id: UUID(),
|
||||
title: L10n.richTextEditorEditLink,
|
||||
primaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionRemove, role: .destructive, action: {
|
||||
primaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionRemove, role: .destructive) {
|
||||
self.restoreComposerSelectedRange()
|
||||
self.removeLinks()
|
||||
}),
|
||||
verticalButtons: [AlertInfo<UUID>.AlertButton(title: L10n.actionEdit, action: {
|
||||
},
|
||||
verticalButtons: [AlertInfo<UUID>.AlertButton(title: L10n.actionEdit) {
|
||||
self.state.bindings.alertInfo = nil
|
||||
DispatchQueue.main.async {
|
||||
self.state.bindings.alertInfo = self.makeSetUrlAlertInfo(urlBinding: urlBinding, isEdit: true)
|
||||
}
|
||||
})])
|
||||
}])
|
||||
}
|
||||
|
||||
private func restoreComposerSelectedRange() {
|
||||
|
||||
@@ -102,9 +102,9 @@ private struct SingleKnockRequestBannerContent: View {
|
||||
Button(L10n.screenRoomSingleKnockRequestViewButtonTitle, action: onViewAll)
|
||||
.buttonStyle(.compound(.secondary, size: .medium))
|
||||
if let onAccept {
|
||||
Button(L10n.screenRoomSingleKnockRequestAcceptButtonTitle, action: {
|
||||
Button(L10n.screenRoomSingleKnockRequestAcceptButtonTitle) {
|
||||
onAccept(request.userID)
|
||||
})
|
||||
}
|
||||
.buttonStyle(.compound(.primary, size: .medium))
|
||||
}
|
||||
}
|
||||
@@ -166,9 +166,9 @@ private struct KnockRequestsBannerDismissButton: View {
|
||||
CompoundIcon(\.close, size: .medium, relativeTo: .compound.bodySMSemibold)
|
||||
.foregroundColor(.compound.iconTertiary)
|
||||
}
|
||||
.alignmentGuide(.top, computeValue: { _ in
|
||||
.alignmentGuide(.top) { _ in
|
||||
3
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,15 +188,16 @@ struct KnockRequestsBannerView_Previews: PreviewProvider, TestablePreview {
|
||||
]
|
||||
|
||||
static var previews: some View {
|
||||
KnockRequestsBannerView(requests: singleRequest, onDismiss: { }, onAccept: { _ in }, onViewAll: { })
|
||||
KnockRequestsBannerView(requests: singleRequest) { } onAccept: { _ in } onViewAll: { }
|
||||
.previewDisplayName("Single Request")
|
||||
// swiftlint:disable:next trailing_closure
|
||||
KnockRequestsBannerView(requests: singleRequest, onDismiss: { }, onAccept: nil, onViewAll: { })
|
||||
.previewDisplayName("Single Request, no accept action")
|
||||
KnockRequestsBannerView(requests: singleRequestWithReason, onDismiss: { }, onAccept: { _ in }, onViewAll: { })
|
||||
KnockRequestsBannerView(requests: singleRequestWithReason) { } onAccept: { _ in } onViewAll: { }
|
||||
.previewDisplayName("Single Request with reason")
|
||||
KnockRequestsBannerView(requests: singleRequestNoDisplayName, onDismiss: { }, onAccept: { _ in }, onViewAll: { })
|
||||
KnockRequestsBannerView(requests: singleRequestNoDisplayName) { } onAccept: { _ in } onViewAll: { }
|
||||
.previewDisplayName("Single Request, No Display Name")
|
||||
KnockRequestsBannerView(requests: multipleRequests, onDismiss: { }, onAccept: { _ in }, onViewAll: { })
|
||||
KnockRequestsBannerView(requests: multipleRequests) { } onAccept: { _ in } onViewAll: { }
|
||||
.previewDisplayName("Multiple Requests")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ struct RoomScreen: View {
|
||||
TimelineView()
|
||||
.id(timelineContext.viewState.roomID)
|
||||
.environmentObject(timelineContext)
|
||||
.environment(\.focussedEventID, timelineContext.viewState.timelineViewState.focussedEvent?.eventID)
|
||||
.environment(\.focussedEventID, timelineContext.viewState.timelineState.focussedEvent?.eventID)
|
||||
.overlay(alignment: .bottomTrailing) {
|
||||
scrollToBottomButton
|
||||
}
|
||||
@@ -183,7 +183,7 @@ struct RoomScreen: View {
|
||||
}
|
||||
|
||||
private var isAtBottomAndLive: Bool {
|
||||
timelineContext.isScrolledToBottom && timelineContext.viewState.timelineViewState.isLive
|
||||
timelineContext.isScrolledToBottom && timelineContext.viewState.timelineState.isLive
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
+1
@@ -51,6 +51,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
|
||||
var elementCallBaseURLOverride: URL? { get set }
|
||||
var knockingEnabled: Bool { get set }
|
||||
var createMediaCaptionsEnabled: Bool { get set }
|
||||
var mediaBrowserEnabled: Bool { get set }
|
||||
}
|
||||
|
||||
extension AppSettings: DeveloperOptionsProtocol { }
|
||||
|
||||
+5
-1
@@ -49,7 +49,7 @@ struct DeveloperOptionsScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Timeline") {
|
||||
Section("Room") {
|
||||
Toggle(isOn: $context.hideTimelineMedia) {
|
||||
Text("Hide image & video previews")
|
||||
}
|
||||
@@ -57,6 +57,10 @@ struct DeveloperOptionsScreen: View {
|
||||
Toggle(isOn: $context.createMediaCaptionsEnabled) {
|
||||
Text("Allow creation of media captions")
|
||||
}
|
||||
|
||||
Toggle(isOn: $context.mediaBrowserEnabled) {
|
||||
Text("Enable the media browser")
|
||||
}
|
||||
}
|
||||
|
||||
Section("Join rules") {
|
||||
|
||||
+2
-2
@@ -123,7 +123,7 @@ class NotificationSettingsEditScreenViewModel: NotificationSettingsEditScreenVie
|
||||
guard !Task.isCancelled else { return }
|
||||
|
||||
let filteredRoomsSummary = roomSummaryProvider.roomListPublisher.value.filter { summary in
|
||||
roomsWithUserDefinedRules.contains(where: { summary.id == $0 })
|
||||
roomsWithUserDefinedRules.contains { summary.id == $0 }
|
||||
}
|
||||
|
||||
var roomsWithUserDefinedMode: [NotificationSettingsEditScreenRoom] = []
|
||||
@@ -142,7 +142,7 @@ class NotificationSettingsEditScreenViewModel: NotificationSettingsEditScreenVie
|
||||
}
|
||||
|
||||
// Sort the room list
|
||||
roomsWithUserDefinedMode.sort(by: { $0.name.localizedCompare($1.name) == .orderedAscending })
|
||||
roomsWithUserDefinedMode.sort { $0.name.localizedCompare($1.name) == .orderedAscending }
|
||||
|
||||
state.roomsWithUserDefinedMode = roomsWithUserDefinedMode
|
||||
}
|
||||
|
||||
@@ -391,7 +391,6 @@ class TimelineInteractionHandler {
|
||||
|
||||
// MARK: Audio Playback
|
||||
|
||||
// swiftlint:disable:next cyclomatic_complexity
|
||||
func playPauseAudio(for itemID: TimelineItemIdentifier) async {
|
||||
MXLog.info("Toggle play/pause audio for itemID \(itemID)")
|
||||
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else {
|
||||
|
||||
@@ -92,7 +92,7 @@ struct TimelineViewState: BindableState {
|
||||
var showLoading = false
|
||||
var showReadReceipts = false
|
||||
var isEncryptedOneToOneRoom = false
|
||||
var timelineViewState: TimelineState // check the doc before changing this
|
||||
var timelineState: TimelineState // check the doc before changing this
|
||||
|
||||
var ownUserID: String
|
||||
var canCurrentUserRedactOthers = false
|
||||
|
||||
@@ -78,7 +78,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
super.init(initialViewState: TimelineViewState(isPinnedEventsTimeline: timelineController.timelineKind == .pinned,
|
||||
roomID: roomProxy.id,
|
||||
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
|
||||
timelineViewState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
|
||||
timelineState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
|
||||
ownUserID: roomProxy.ownUserID,
|
||||
isViewSourceEnabled: appSettings.viewSourceEnabled,
|
||||
isCreateMediaCaptionsEnabled: appSettings.createMediaCaptionsEnabled,
|
||||
@@ -107,6 +107,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
return self.timelineInteractionHandler.audioPlayerState(for: itemID)
|
||||
}
|
||||
|
||||
state.timelineState.paginationState = timelineController.paginationState
|
||||
buildTimelineViews(timelineItems: timelineController.timelineItems)
|
||||
|
||||
updateMembers(roomProxy.membersPublisher.value)
|
||||
@@ -174,7 +175,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
case .scrolledToFocussedItem:
|
||||
didScrollToFocussedItem()
|
||||
case .hasSwitchedTimeline:
|
||||
Task { state.timelineViewState.isSwitchingTimelines = false }
|
||||
Task { state.timelineState.isSwitchingTimelines = false }
|
||||
case let .hasScrolled(direction):
|
||||
actionsSubject.send(.hasScrolled(direction: direction))
|
||||
case .setOpenURLAction(let action):
|
||||
@@ -215,8 +216,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
}
|
||||
|
||||
func focusOnEvent(eventID: String) async {
|
||||
if state.timelineViewState.hasLoadedItem(with: eventID) {
|
||||
state.timelineViewState.focussedEvent = .init(eventID: eventID, appearance: .animated)
|
||||
if state.timelineState.hasLoadedItem(with: eventID) {
|
||||
state.timelineState.focussedEvent = .init(eventID: eventID, appearance: .animated)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -225,7 +226,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
|
||||
switch await timelineController.focusOnEvent(eventID, timelineSize: Constants.detachedTimelineSize) {
|
||||
case .success:
|
||||
state.timelineViewState.focussedEvent = .init(eventID: eventID, appearance: .immediate)
|
||||
state.timelineState.focussedEvent = .init(eventID: eventID, appearance: .immediate)
|
||||
case .failure(let error):
|
||||
MXLog.error("Failed to focus on event \(eventID)")
|
||||
|
||||
@@ -244,9 +245,9 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
}
|
||||
|
||||
private func didScrollToFocussedItem() {
|
||||
if var focussedEvent = state.timelineViewState.focussedEvent {
|
||||
if var focussedEvent = state.timelineState.focussedEvent {
|
||||
focussedEvent.appearance = .hasAppeared
|
||||
state.timelineViewState.focussedEvent = focussedEvent
|
||||
state.timelineState.focussedEvent = focussedEvent
|
||||
hideFocusLoadingIndicator()
|
||||
}
|
||||
}
|
||||
@@ -362,16 +363,16 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
case .updatedTimelineItems(let updatedItems, let isSwitchingTimelines):
|
||||
buildTimelineViews(timelineItems: updatedItems, isSwitchingTimelines: isSwitchingTimelines)
|
||||
case .paginationState(let paginationState):
|
||||
if state.timelineViewState.paginationState != paginationState {
|
||||
state.timelineViewState.paginationState = paginationState
|
||||
if state.timelineState.paginationState != paginationState {
|
||||
state.timelineState.paginationState = paginationState
|
||||
}
|
||||
case .isLive(let isLive):
|
||||
if state.timelineViewState.isLive != isLive {
|
||||
state.timelineViewState.isLive = isLive
|
||||
if state.timelineState.isLive != isLive {
|
||||
state.timelineState.isLive = isLive
|
||||
|
||||
// Remove the event highlight *only* when transitioning from non-live to live.
|
||||
if isLive, state.timelineViewState.focussedEvent != nil {
|
||||
state.timelineViewState.focussedEvent = nil
|
||||
if isLive, state.timelineState.focussedEvent != nil {
|
||||
state.timelineState.focussedEvent = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -516,7 +517,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
break
|
||||
}
|
||||
|
||||
if state.timelineViewState.paginationState.forward == .timelineEndReached {
|
||||
if state.timelineState.paginationState.forward == .timelineEndReached {
|
||||
focusLive()
|
||||
}
|
||||
|
||||
@@ -525,8 +526,8 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
}
|
||||
|
||||
private func scrollToBottom() {
|
||||
if state.timelineViewState.isLive {
|
||||
state.timelineViewState.scrollToBottomPublisher.send(())
|
||||
if state.timelineState.isLive {
|
||||
state.timelineState.scrollToBottomPublisher.send(())
|
||||
} else {
|
||||
focusLive()
|
||||
}
|
||||
@@ -703,14 +704,14 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
}
|
||||
|
||||
if isSwitchingTimelines {
|
||||
state.timelineViewState.isSwitchingTimelines = true
|
||||
state.timelineState.isSwitchingTimelines = true
|
||||
}
|
||||
|
||||
state.timelineViewState.itemsDictionary = timelineItemsDictionary
|
||||
state.timelineState.itemsDictionary = timelineItemsDictionary
|
||||
}
|
||||
|
||||
private func updateViewState(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewState {
|
||||
if let timelineItemViewState = state.timelineViewState.itemsDictionary[item.id.uniqueID] {
|
||||
if let timelineItemViewState = state.timelineState.itemsDictionary[item.id.uniqueID] {
|
||||
timelineItemViewState.groupStyle = groupStyle
|
||||
timelineItemViewState.type = .init(item: item)
|
||||
return timelineItemViewState
|
||||
@@ -744,7 +745,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
userIndicatorController.alertInfo = .init(id: .init(),
|
||||
title: L10n.screenRoomInviteAgainAlertTitle,
|
||||
message: L10n.screenRoomInviteAgainAlertMessage,
|
||||
primaryButton: .init(title: L10n.actionInvite, action: { [weak self] in self?.inviteOtherDMUserBack() }),
|
||||
primaryButton: .init(title: L10n.actionInvite) { [weak self] in self?.inviteOtherDMUserBack() },
|
||||
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
|
||||
}
|
||||
|
||||
@@ -830,14 +831,14 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
state.bindings.alertInfo = .init(id: type,
|
||||
title: L10n.dialogPermissionMicrophoneTitleIos(InfoPlistReader.main.bundleDisplayName),
|
||||
message: L10n.dialogPermissionMicrophoneDescriptionIos,
|
||||
primaryButton: .init(title: L10n.commonSettings, action: { [weak self] in self?.appMediator.openAppSettings() }),
|
||||
primaryButton: .init(title: L10n.commonSettings) { [weak self] in self?.appMediator.openAppSettings() },
|
||||
secondaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil))
|
||||
case .pollEndConfirmation(let pollStartID):
|
||||
state.bindings.alertInfo = .init(id: type,
|
||||
title: L10n.actionEndPoll,
|
||||
message: L10n.commonPollEndConfirmation,
|
||||
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
|
||||
secondaryButton: .init(title: L10n.actionOk, action: { self.timelineInteractionHandler.endPoll(pollStartID: pollStartID) }))
|
||||
secondaryButton: .init(title: L10n.actionOk) { self.timelineInteractionHandler.endPoll(pollStartID: pollStartID) })
|
||||
case .sendingFailed:
|
||||
state.bindings.alertInfo = .init(id: type,
|
||||
title: L10n.commonSendingFailed,
|
||||
|
||||
@@ -400,7 +400,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview
|
||||
static var mockTimeline: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
ForEach(viewModel.state.timelineViewState.itemViewStates) { viewState in
|
||||
ForEach(viewModel.state.timelineState.itemViewStates) { viewState in
|
||||
RoomTimelineItemView(viewState: viewState)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ struct TimelineItemStyler_Previews: PreviewProvider, TestablePreview {
|
||||
}()
|
||||
|
||||
static let sendingLast: TextRoomTimelineItem = {
|
||||
let id = viewModel.state.timelineViewState.uniqueIDs.last ?? .init(id: UUID().uuidString)
|
||||
let id = viewModel.state.timelineState.uniqueIDs.last ?? .init(id: UUID().uuidString)
|
||||
var result = TextRoomTimelineItem(id: .event(uniqueID: id, eventOrTransactionID: .eventId(eventId: UUID().uuidString)),
|
||||
timestamp: .mock,
|
||||
isOutgoing: true,
|
||||
@@ -99,7 +99,7 @@ struct TimelineItemStyler_Previews: PreviewProvider, TestablePreview {
|
||||
}()
|
||||
|
||||
static let sentLast: TextRoomTimelineItem = {
|
||||
let id = viewModel.state.timelineViewState.uniqueIDs.last ?? .init(id: UUID().uuidString)
|
||||
let id = viewModel.state.timelineState.uniqueIDs.last ?? .init(id: UUID().uuidString)
|
||||
let result = TextRoomTimelineItem(id: .event(uniqueID: id, eventOrTransactionID: .eventId(eventId: UUID().uuidString)),
|
||||
timestamp: .mock,
|
||||
isOutgoing: true,
|
||||
|
||||
@@ -14,7 +14,7 @@ struct TimelineItemStatusView: View {
|
||||
@EnvironmentObject private var context: TimelineViewModel.Context
|
||||
|
||||
private var isLastOutgoingMessage: Bool {
|
||||
timelineItem.isOutgoing && context.viewState.timelineViewState.uniqueIDs.last == timelineItem.id.uniqueID
|
||||
timelineItem.isOutgoing && context.viewState.timelineState.uniqueIDs.last == timelineItem.id.uniqueID
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
|
||||
@@ -16,7 +16,7 @@ struct TimelineView: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> TimelineTableViewController {
|
||||
let tableViewController = TimelineTableViewController(coordinator: context.coordinator,
|
||||
isScrolledToBottom: $viewModelContext.isScrolledToBottom,
|
||||
scrollToBottomPublisher: viewModelContext.viewState.timelineViewState.scrollToBottomPublisher)
|
||||
scrollToBottomPublisher: viewModelContext.viewState.timelineState.scrollToBottomPublisher)
|
||||
// Needs to be dispatched on main asynchronously otherwise we get a runtime warning
|
||||
DispatchQueue.main.async {
|
||||
viewModelContext.send(viewAction: .setOpenURLAction(openURL))
|
||||
@@ -44,21 +44,21 @@ struct TimelineView: UIViewControllerRepresentable {
|
||||
|
||||
/// Updates the specified table view's properties from the current view state.
|
||||
func update(tableViewController: TimelineTableViewController) {
|
||||
if tableViewController.isSwitchingTimelines != context.viewState.timelineViewState.isSwitchingTimelines {
|
||||
if tableViewController.isSwitchingTimelines != context.viewState.timelineState.isSwitchingTimelines {
|
||||
// Must come before timelineItemsDictionary in order to disable animations.
|
||||
tableViewController.isSwitchingTimelines = context.viewState.timelineViewState.isSwitchingTimelines
|
||||
tableViewController.isSwitchingTimelines = context.viewState.timelineState.isSwitchingTimelines
|
||||
}
|
||||
if tableViewController.timelineItemsDictionary != context.viewState.timelineViewState.itemsDictionary {
|
||||
tableViewController.timelineItemsDictionary = context.viewState.timelineViewState.itemsDictionary
|
||||
if tableViewController.timelineItemsDictionary != context.viewState.timelineState.itemsDictionary {
|
||||
tableViewController.timelineItemsDictionary = context.viewState.timelineState.itemsDictionary
|
||||
}
|
||||
if tableViewController.paginationState != context.viewState.timelineViewState.paginationState {
|
||||
tableViewController.paginationState = context.viewState.timelineViewState.paginationState
|
||||
if tableViewController.paginationState != context.viewState.timelineState.paginationState {
|
||||
tableViewController.paginationState = context.viewState.timelineState.paginationState
|
||||
}
|
||||
if tableViewController.isLive != context.viewState.timelineViewState.isLive {
|
||||
tableViewController.isLive = context.viewState.timelineViewState.isLive
|
||||
if tableViewController.isLive != context.viewState.timelineState.isLive {
|
||||
tableViewController.isLive = context.viewState.timelineState.isLive
|
||||
}
|
||||
if tableViewController.focussedEvent != context.viewState.timelineViewState.focussedEvent {
|
||||
tableViewController.focussedEvent = context.viewState.timelineViewState.focussedEvent
|
||||
if tableViewController.focussedEvent != context.viewState.timelineState.focussedEvent {
|
||||
tableViewController.focussedEvent = context.viewState.timelineState.focussedEvent
|
||||
}
|
||||
if tableViewController.hideTimelineMedia != context.viewState.hideTimelineMedia {
|
||||
tableViewController.hideTimelineMedia = context.viewState.hideTimelineMedia
|
||||
|
||||
@@ -43,8 +43,7 @@ struct UserProfileScreen: View {
|
||||
AvatarHeaderView(user: UserProfileProxy(userID: context.viewState.userID),
|
||||
isVerified: context.viewState.showVerifiedBadge,
|
||||
avatarSize: .user(on: .memberDetails),
|
||||
mediaProvider: context.mediaProvider,
|
||||
footer: { })
|
||||
mediaProvider: context.mediaProvider) { }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user