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:
Stefan Ceriu
2024-12-06 16:58:14 +02:00
committed by GitHub
parent a9e4837b62
commit caaa89af62
83 changed files with 1247 additions and 279 deletions
@@ -1048,7 +1048,7 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
.actionsPublisher
.filter(\.isSyncUpdate)
.collect(.byTimeOrCount(DispatchQueue.main, .seconds(10), 10))
.sink(receiveValue: { [weak self] _ in
.sink { [weak self] _ in
guard let self else { return }
MXLog.info("Background app refresh finished")
backgroundRefreshSyncObserver?.cancel()
@@ -1059,6 +1059,6 @@ class AppCoordinator: AppCoordinatorProtocol, AuthenticationFlowCoordinatorDeleg
MXLog.info("Marking Background app refresh task as complete.")
task.setTaskCompleted(success: true)
}
})
}
}
}
@@ -48,6 +48,7 @@ final class AppSettings {
case enableOnlySignedDeviceIsolationMode
case knockingEnabled
case createMediaCaptionsEnabled
case mediaBrowserEnabled
}
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
@@ -288,6 +289,9 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.createMediaCaptionsEnabled, defaultValue: false, storageType: .userDefaults(store))
var createMediaCaptionsEnabled
@UserPreference(key: UserDefaultsKeys.mediaBrowserEnabled, defaultValue: false, storageType: .userDefaults(store))
var mediaBrowserEnabled
#endif
// MARK: - Shared
@@ -42,11 +42,11 @@ struct Application: App {
openURLInSystemBrowser($0)
}
}
.onContinueUserActivity("INStartVideoCallIntent", perform: { userActivity in
.onContinueUserActivity("INStartVideoCallIntent") { userActivity in
// `INStartVideoCallIntent` is to be replaced with `INStartCallIntent`
// but calls from Recents still send it ¯\_(ツ)_/¯
appCoordinator.handleUserActivity(userActivity)
})
}
.task {
appCoordinator.start()
}
@@ -0,0 +1,97 @@
//
// Copyright 2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import Foundation
enum MediaEventsTimelineFlowCoordinatorAction {
case finished
}
class MediaEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
private let navigationStackCoordinator: NavigationStackCoordinator
private let userSession: UserSessionProtocol
private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol
private let roomProxy: JoinedRoomProxyProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appMediator: AppMediatorProtocol
private let emojiProvider: EmojiProviderProtocol
private let actionsSubject: PassthroughSubject<MediaEventsTimelineFlowCoordinatorAction, Never> = .init()
var actionsPublisher: AnyPublisher<MediaEventsTimelineFlowCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private var cancellables = Set<AnyCancellable>()
init(navigationStackCoordinator: NavigationStackCoordinator,
userSession: UserSessionProtocol,
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol,
roomProxy: JoinedRoomProxyProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
appMediator: AppMediatorProtocol,
emojiProvider: EmojiProviderProtocol) {
self.navigationStackCoordinator = navigationStackCoordinator
self.userSession = userSession
self.roomTimelineControllerFactory = roomTimelineControllerFactory
self.roomProxy = roomProxy
self.userIndicatorController = userIndicatorController
self.appMediator = appMediator
self.emojiProvider = emojiProvider
}
func start() {
Task { await presentMediaEventsTimeline() }
}
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
fatalError()
}
func clearRoute(animated: Bool) {
fatalError()
}
// MARK: - Private
private func presentMediaEventsTimeline() async {
let timelineItemFactory = RoomTimelineItemFactory(userID: userSession.clientProxy.userID,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userSession.clientProxy.userID))
guard case let .success(mediaTimelineController) = await roomTimelineControllerFactory.buildMessageFilteredRoomTimelineController(allowedMessageTypes: [.image, .video],
roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider) else {
MXLog.error("Failed presenting media timeline")
return
}
guard case let .success(filesTimelineController) = await roomTimelineControllerFactory.buildMessageFilteredRoomTimelineController(allowedMessageTypes: [.file, .audio],
roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider) else {
MXLog.error("Failed presenting media timeline")
return
}
let parameters = MediaEventsTimelineScreenCoordinatorParameters(roomProxy: roomProxy,
mediaTimelineController: mediaTimelineController,
filesTimelineController: filesTimelineController,
mediaProvider: userSession.mediaProvider,
mediaPlayerProvider: MediaPlayerProvider(),
voiceMessageMediaManager: userSession.voiceMessageMediaManager,
appMediator: appMediator,
emojiProvider: emojiProvider)
let coordinator = MediaEventsTimelineScreenCoordinator(parameters: parameters)
navigationStackCoordinator.push(coordinator) { [weak self] in
self?.actionsSubject.send(.finished)
}
}
}
@@ -65,9 +65,9 @@ class PinnedEventsTimelineFlowCoordinator: FlowCoordinatorProtocol {
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID))
guard let timelineController = await roomTimelineControllerFactory.buildRoomPinnedTimelineController(roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider) else {
guard let timelineController = await roomTimelineControllerFactory.buildPinnedEventsRoomTimelineController(roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
mediaProvider: userSession.mediaProvider) else {
fatalError("This can never fail because we allow this view to be presented only when the timeline is fully loaded and not nil")
}
@@ -50,11 +50,6 @@ struct FocusEvent: Hashable {
let shouldSetPin: Bool
}
private enum PinnedEventsTimelineSource: Hashable {
case room
case details(isRoot: Bool)
}
private enum PresentationAction: Hashable {
case eventFocus(FocusEvent)
case share(ShareExtensionPayload)
@@ -102,6 +97,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
// periphery:ignore - used to avoid deallocation
private var pinnedEventsTimelineFlowCoordinator: PinnedEventsTimelineFlowCoordinator?
// periphery:ignore - used to avoid deallocation
private var mediaEventsTimelineFlowCoordinator: MediaEventsTimelineFlowCoordinator?
// periphery:ignore - used to avoid deallocation
private var childRoomFlowCoordinator: RoomFlowCoordinator?
private let stateMachine: StateMachine<State, Event> = .init(state: .initial)
@@ -149,7 +146,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
fatalError("This flow coordinator expect a route")
}
// swiftlint:disable:next cyclomatic_complexity
func handleAppRoute(_ appRoute: AppRoute, animated: Bool) {
guard stateMachine.state != .complete else {
fatalError("This flow coordinator is `finished` ☠️")
@@ -369,16 +365,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
return .room
case (.room, .presentPinnedEventsTimeline):
return .pinnedEventsTimeline(previousState: .room)
case (.roomDetails(let isRoot), .presentPinnedEventsTimeline):
return .pinnedEventsTimeline(previousState: .details(isRoot: isRoot))
return .pinnedEventsTimeline(previousState: fromState)
case (.roomDetails, .presentPinnedEventsTimeline):
return .pinnedEventsTimeline(previousState: fromState)
case (.pinnedEventsTimeline(let previousState), .dismissPinnedEventsTimeline):
switch previousState {
case .room:
return .room
case .details(let isRoot):
return .roomDetails(isRoot: isRoot)
}
return previousState
case (.roomDetails, .presentPollsHistory):
return .pollsHistory
@@ -400,8 +391,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case (.resolveSendFailure, .dismissResolveSendFailure):
return .room
// Child flow
case (_, .startChildFlow(let roomID, _, _)):
return .presentingChild(childRoomID: roomID, previousState: fromState)
case (.presentingChild(_, let previousState), .dismissChildFlow):
@@ -411,6 +400,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
return .knockRequestsList(previousState: fromState)
case (.knockRequestsList(let previousState), .dismissKnockRequestsListScreen):
return previousState
case (.roomDetails, .presentMediaEventsTimeline):
return .mediaEventsTimeline(previousState: fromState)
case (.mediaEventsTimeline(let previousState), .dismissMediaEventsTimeline):
return previousState
default:
return nil
@@ -527,7 +521,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
break
case (.room, .presentPinnedEventsTimeline, .pinnedEventsTimeline):
presentPinnedEventsTimeline()
startPinnedEventsTimelineFlow()
case (.pinnedEventsTimeline, .dismissPinnedEventsTimeline, .room):
break
@@ -537,7 +531,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
break
case (.roomDetails, .presentPinnedEventsTimeline, .pinnedEventsTimeline):
presentPinnedEventsTimeline()
startPinnedEventsTimelineFlow()
case (.pinnedEventsTimeline, .dismissPinnedEventsTimeline, .roomDetails):
break
@@ -573,6 +567,11 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case (.knockRequestsList, .dismissKnockRequestsListScreen, .room):
break
case (.roomDetails, .presentMediaEventsTimeline, .mediaEventsTimeline):
Task { await self.startMediaEventsTimelineFlow() }
case (.mediaEventsTimeline, .dismissMediaEventsTimeline, .roomDetails):
break
// Child flow
case (_, .startChildFlow(let roomID, let via, let entryPoint), .presentingChild):
Task { await self.startChildFlow(for: roomID, via: via, entryPoint: entryPoint) }
@@ -848,6 +847,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.presentPinnedEventsTimeline)
case .presentKnockingRequestsListScreen:
stateMachine.tryEvent(.presentKnockRequestsListScreen)
case .presentMediaEventsTimeline:
stateMachine.tryEvent(.presentMediaEventsTimeline)
}
}
.store(in: &cancellables)
@@ -1432,44 +1433,6 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
coordinator.start()
}
private func presentPinnedEventsTimeline() {
let stackCoordinator = NavigationStackCoordinator()
let coordinator = PinnedEventsTimelineFlowCoordinator(navigationStackCoordinator: stackCoordinator,
userSession: userSession,
roomTimelineControllerFactory: roomTimelineControllerFactory,
roomProxy: roomProxy,
userIndicatorController: userIndicatorController,
appMediator: appMediator,
emojiProvider: emojiProvider)
coordinator.actionsPublisher.sink { [weak self] action in
guard let self else {
return
}
switch action {
case .finished:
navigationStackCoordinator.setSheetCoordinator(nil)
case .displayUser(let userID):
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID))
case .forwardedMessageToRoom(let roomID):
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room))
case .displayRoomScreenWithFocussedPin(let eventID):
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: true))))
}
}
.store(in: &cancellables)
pinnedEventsTimelineFlowCoordinator = coordinator
navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissPinnedEventsTimeline)
}
coordinator.start()
}
private func presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy) {
let coordinator = ResolveVerifiedUserSendFailureScreenCoordinator(parameters: .init(failure: failure,
sendHandle: sendHandle,
@@ -1490,7 +1453,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
// MARK: - Child Flow
// MARK: - Other flows
private func startChildFlow(for roomID: String, via: [String], entryPoint: RoomFlowCoordinatorEntryPoint) async {
let coordinator = await RoomFlowCoordinator(roomID: roomID,
@@ -1528,6 +1491,71 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
coordinator.handleAppRoute(.share(payload), animated: true)
}
}
private func startPinnedEventsTimelineFlow() {
let stackCoordinator = NavigationStackCoordinator()
let flowCoordinator = PinnedEventsTimelineFlowCoordinator(navigationStackCoordinator: stackCoordinator,
userSession: userSession,
roomTimelineControllerFactory: roomTimelineControllerFactory,
roomProxy: roomProxy,
userIndicatorController: userIndicatorController,
appMediator: appMediator,
emojiProvider: emojiProvider)
flowCoordinator.actionsPublisher.sink { [weak self] action in
guard let self else {
return
}
switch action {
case .finished:
navigationStackCoordinator.setSheetCoordinator(nil)
case .displayUser(let userID):
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.presentRoomMemberDetails(userID: userID))
case .forwardedMessageToRoom(let roomID):
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room))
case .displayRoomScreenWithFocussedPin(let eventID):
navigationStackCoordinator.setSheetCoordinator(nil)
stateMachine.tryEvent(.presentRoom(presentationAction: .eventFocus(.init(eventID: eventID, shouldSetPin: true))))
}
}
.store(in: &cancellables)
pinnedEventsTimelineFlowCoordinator = flowCoordinator
navigationStackCoordinator.setSheetCoordinator(stackCoordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissPinnedEventsTimeline)
}
flowCoordinator.start()
}
private func startMediaEventsTimelineFlow() async {
let flowCoordinator = MediaEventsTimelineFlowCoordinator(navigationStackCoordinator: navigationStackCoordinator,
userSession: userSession,
roomTimelineControllerFactory: roomTimelineControllerFactory,
roomProxy: roomProxy,
userIndicatorController: userIndicatorController,
appMediator: appMediator,
emojiProvider: emojiProvider)
flowCoordinator.actionsPublisher.sink { [weak self] action in
guard let self else { return }
switch action {
case .finished:
stateMachine.tryEvent(.dismissMediaEventsTimeline)
}
}
.store(in: &cancellables)
mediaEventsTimelineFlowCoordinator = flowCoordinator
flowCoordinator.start()
}
}
private extension RoomFlowCoordinator {
@@ -1565,9 +1593,10 @@ private extension RoomFlowCoordinator {
case pollsHistory
case pollsHistoryForm
case rolesAndPermissions
case pinnedEventsTimeline(previousState: PinnedEventsTimelineSource)
case pinnedEventsTimeline(previousState: State)
case resolveSendFailure
case knockRequestsList(previousState: State)
case mediaEventsTimeline(previousState: State)
/// A child flow is in progress.
case presentingChild(childRoomID: String, previousState: State)
@@ -1643,12 +1672,14 @@ private extension RoomFlowCoordinator {
case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
case dismissResolveSendFailure
// Child room flow events
case startChildFlow(roomID: String, via: [String], entryPoint: RoomFlowCoordinatorEntryPoint)
case dismissChildFlow
case presentKnockRequestsListScreen
case dismissKnockRequestsListScreen
case presentMediaEventsTimeline
case dismissMediaEventsTimeline
}
}
@@ -6147,6 +6147,76 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol {
return timelineFocusedOnEventEventIDNumberOfEventsReturnValue
}
}
//MARK: - messageFilteredTimeline
var messageFilteredTimelineAllowedMessageTypesUnderlyingCallsCount = 0
var messageFilteredTimelineAllowedMessageTypesCallsCount: Int {
get {
if Thread.isMainThread {
return messageFilteredTimelineAllowedMessageTypesUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = messageFilteredTimelineAllowedMessageTypesUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
messageFilteredTimelineAllowedMessageTypesUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
messageFilteredTimelineAllowedMessageTypesUnderlyingCallsCount = newValue
}
}
}
}
var messageFilteredTimelineAllowedMessageTypesCalled: Bool {
return messageFilteredTimelineAllowedMessageTypesCallsCount > 0
}
var messageFilteredTimelineAllowedMessageTypesReceivedAllowedMessageTypes: [RoomMessageEventMessageType]?
var messageFilteredTimelineAllowedMessageTypesReceivedInvocations: [[RoomMessageEventMessageType]] = []
var messageFilteredTimelineAllowedMessageTypesUnderlyingReturnValue: Result<TimelineProxyProtocol, RoomProxyError>!
var messageFilteredTimelineAllowedMessageTypesReturnValue: Result<TimelineProxyProtocol, RoomProxyError>! {
get {
if Thread.isMainThread {
return messageFilteredTimelineAllowedMessageTypesUnderlyingReturnValue
} else {
var returnValue: Result<TimelineProxyProtocol, RoomProxyError>? = nil
DispatchQueue.main.sync {
returnValue = messageFilteredTimelineAllowedMessageTypesUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
messageFilteredTimelineAllowedMessageTypesUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
messageFilteredTimelineAllowedMessageTypesUnderlyingReturnValue = newValue
}
}
}
}
var messageFilteredTimelineAllowedMessageTypesClosure: (([RoomMessageEventMessageType]) async -> Result<TimelineProxyProtocol, RoomProxyError>)?
func messageFilteredTimeline(allowedMessageTypes: [RoomMessageEventMessageType]) async -> Result<TimelineProxyProtocol, RoomProxyError> {
messageFilteredTimelineAllowedMessageTypesCallsCount += 1
messageFilteredTimelineAllowedMessageTypesReceivedAllowedMessageTypes = allowedMessageTypes
DispatchQueue.main.async {
self.messageFilteredTimelineAllowedMessageTypesReceivedInvocations.append(allowedMessageTypes)
}
if let messageFilteredTimelineAllowedMessageTypesClosure = messageFilteredTimelineAllowedMessageTypesClosure {
return await messageFilteredTimelineAllowedMessageTypesClosure(allowedMessageTypes)
} else {
return messageFilteredTimelineAllowedMessageTypesReturnValue
}
}
//MARK: - redact
var redactUnderlyingCallsCount = 0
@@ -12602,17 +12672,17 @@ class RoomTimelineControllerFactoryMock: RoomTimelineControllerFactoryProtocol {
return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryMediaProviderReturnValue
}
}
//MARK: - buildRoomPinnedTimelineController
//MARK: - buildPinnedEventsRoomTimelineController
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int {
var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0
var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int {
get {
if Thread.isMainThread {
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
return buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
returnValue = buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
}
return returnValue!
@@ -12620,29 +12690,29 @@ class RoomTimelineControllerFactoryMock: RoomTimelineControllerFactoryProtocol {
}
set {
if Thread.isMainThread {
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
}
}
}
}
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCalled: Bool {
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0
var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCalled: Bool {
return buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0
}
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedArguments: (roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)?
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = []
var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedArguments: (roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)?
var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = []
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: RoomTimelineControllerProtocol?
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReturnValue: RoomTimelineControllerProtocol? {
var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: RoomTimelineControllerProtocol?
var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReturnValue: RoomTimelineControllerProtocol? {
get {
if Thread.isMainThread {
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
return buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
} else {
var returnValue: RoomTimelineControllerProtocol?? = nil
DispatchQueue.main.sync {
returnValue = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
returnValue = buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
}
return returnValue!
@@ -12650,26 +12720,96 @@ class RoomTimelineControllerFactoryMock: RoomTimelineControllerFactoryProtocol {
}
set {
if Thread.isMainThread {
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
}
}
}
}
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure: ((JoinedRoomProxyProtocol, RoomTimelineItemFactoryProtocol, MediaProviderProtocol) async -> RoomTimelineControllerProtocol?)?
var buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure: ((JoinedRoomProxyProtocol, RoomTimelineItemFactoryProtocol, MediaProviderProtocol) async -> RoomTimelineControllerProtocol?)?
func buildRoomPinnedTimelineController(roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol? {
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)
func buildPinnedEventsRoomTimelineController(roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol? {
buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1
buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)
DispatchQueue.main.async {
self.buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations.append((roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider))
self.buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations.append((roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider))
}
if let buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure {
return await buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure(roomProxy, timelineItemFactory, mediaProvider)
if let buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure = buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure {
return await buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderClosure(roomProxy, timelineItemFactory, mediaProvider)
} else {
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReturnValue
return buildPinnedEventsRoomTimelineControllerRoomProxyTimelineItemFactoryMediaProviderReturnValue
}
}
//MARK: - buildMessageFilteredRoomTimelineController
var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = 0
var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderCallsCount: Int {
get {
if Thread.isMainThread {
return buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingCallsCount = newValue
}
}
}
}
var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderCalled: Bool {
return buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderCallsCount > 0
}
var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderReceivedArguments: (allowedMessageTypes: [RoomMessageEventMessageType], roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)?
var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations: [(allowedMessageTypes: [RoomMessageEventMessageType], roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol)] = []
var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue: Result<RoomTimelineControllerProtocol, RoomTimelineFactoryControllerError>!
var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderReturnValue: Result<RoomTimelineControllerProtocol, RoomTimelineFactoryControllerError>! {
get {
if Thread.isMainThread {
return buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
} else {
var returnValue: Result<RoomTimelineControllerProtocol, RoomTimelineFactoryControllerError>? = nil
DispatchQueue.main.sync {
returnValue = buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderUnderlyingReturnValue = newValue
}
}
}
}
var buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderClosure: (([RoomMessageEventMessageType], JoinedRoomProxyProtocol, RoomTimelineItemFactoryProtocol, MediaProviderProtocol) async -> Result<RoomTimelineControllerProtocol, RoomTimelineFactoryControllerError>)?
func buildMessageFilteredRoomTimelineController(allowedMessageTypes: [RoomMessageEventMessageType], roomProxy: JoinedRoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol, mediaProvider: MediaProviderProtocol) async -> Result<RoomTimelineControllerProtocol, RoomTimelineFactoryControllerError> {
buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderCallsCount += 1
buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderReceivedArguments = (allowedMessageTypes: allowedMessageTypes, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider)
DispatchQueue.main.async {
self.buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderReceivedInvocations.append((allowedMessageTypes: allowedMessageTypes, roomProxy: roomProxy, timelineItemFactory: timelineItemFactory, mediaProvider: mediaProvider))
}
if let buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderClosure = buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderClosure {
return await buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderClosure(allowedMessageTypes, roomProxy, timelineItemFactory, mediaProvider)
} else {
return buildMessageFilteredRoomTimelineControllerAllowedMessageTypesRoomProxyTimelineItemFactoryMediaProviderReturnValue
}
}
}
@@ -8248,6 +8248,71 @@ open class MediaSourceSDKMock: MatrixRustSDK.MediaSource {
{
}
//MARK: - toJson
var toJsonUnderlyingCallsCount = 0
open var toJsonCallsCount: Int {
get {
if Thread.isMainThread {
return toJsonUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = toJsonUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
toJsonUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
toJsonUnderlyingCallsCount = newValue
}
}
}
}
open var toJsonCalled: Bool {
return toJsonCallsCount > 0
}
var toJsonUnderlyingReturnValue: String!
open var toJsonReturnValue: String! {
get {
if Thread.isMainThread {
return toJsonUnderlyingReturnValue
} else {
var returnValue: String? = nil
DispatchQueue.main.sync {
returnValue = toJsonUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
toJsonUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
toJsonUnderlyingReturnValue = newValue
}
}
}
}
open var toJsonClosure: (() -> String)?
open override func toJson() -> String {
toJsonCallsCount += 1
if let toJsonClosure = toJsonClosure {
return toJsonClosure()
} else {
return toJsonReturnValue
}
}
//MARK: - url
var urlUnderlyingCallsCount = 0
@@ -12813,6 +12878,81 @@ open class RoomSDKMock: MatrixRustSDK.Room {
}
}
//MARK: - messageFilteredTimeline
open var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesThrowableError: Error?
var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingCallsCount = 0
open var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesCallsCount: Int {
get {
if Thread.isMainThread {
return messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingCallsCount = newValue
}
}
}
}
open var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesCalled: Bool {
return messageFilteredTimelineInternalIdPrefixAllowedMessageTypesCallsCount > 0
}
open var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesReceivedArguments: (internalIdPrefix: String?, allowedMessageTypes: [RoomMessageEventMessageType])?
open var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesReceivedInvocations: [(internalIdPrefix: String?, allowedMessageTypes: [RoomMessageEventMessageType])] = []
var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingReturnValue: Timeline!
open var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesReturnValue: Timeline! {
get {
if Thread.isMainThread {
return messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingReturnValue
} else {
var returnValue: Timeline? = nil
DispatchQueue.main.sync {
returnValue = messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
messageFilteredTimelineInternalIdPrefixAllowedMessageTypesUnderlyingReturnValue = newValue
}
}
}
}
open var messageFilteredTimelineInternalIdPrefixAllowedMessageTypesClosure: ((String?, [RoomMessageEventMessageType]) async throws -> Timeline)?
open override func messageFilteredTimeline(internalIdPrefix: String?, allowedMessageTypes: [RoomMessageEventMessageType]) async throws -> Timeline {
if let error = messageFilteredTimelineInternalIdPrefixAllowedMessageTypesThrowableError {
throw error
}
messageFilteredTimelineInternalIdPrefixAllowedMessageTypesCallsCount += 1
messageFilteredTimelineInternalIdPrefixAllowedMessageTypesReceivedArguments = (internalIdPrefix: internalIdPrefix, allowedMessageTypes: allowedMessageTypes)
DispatchQueue.main.async {
self.messageFilteredTimelineInternalIdPrefixAllowedMessageTypesReceivedInvocations.append((internalIdPrefix: internalIdPrefix, allowedMessageTypes: allowedMessageTypes))
}
if let messageFilteredTimelineInternalIdPrefixAllowedMessageTypesClosure = messageFilteredTimelineInternalIdPrefixAllowedMessageTypesClosure {
return try await messageFilteredTimelineInternalIdPrefixAllowedMessageTypesClosure(internalIdPrefix, allowedMessageTypes)
} else {
return messageFilteredTimelineInternalIdPrefixAllowedMessageTypesReturnValue
}
}
//MARK: - ownUserId
var ownUserIdUnderlyingCallsCount = 0
@@ -10,6 +10,7 @@ import SwiftUI
extension MediaProviderMock {
struct Configuration { }
// swiftlint:disable:next cyclomatic_complexity
convenience init(configuration: Configuration) {
self.init()
@@ -106,12 +106,12 @@ struct VoiceMessageButton_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack(spacing: 8) {
HStack(spacing: 8) {
VoiceMessageButton(state: .paused, size: .small, action: { })
VoiceMessageButton(state: .paused, size: .medium, action: { })
VoiceMessageButton(state: .paused, size: .small) { }
VoiceMessageButton(state: .paused, size: .medium) { }
}
HStack(spacing: 8) {
VoiceMessageButton(state: .playing, size: .small, action: { })
VoiceMessageButton(state: .playing, size: .medium, action: { })
VoiceMessageButton(state: .playing, size: .small) { }
VoiceMessageButton(state: .playing, size: .medium) { }
}
}
.padding()
@@ -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)
}
@@ -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))
}
@@ -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")
}
}
@@ -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))
}
}
@@ -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
}
@@ -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)
}
}
}
@@ -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 }
}
@@ -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")
}
}
}
@@ -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)
}
}
@@ -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) { }
}
}
@@ -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
@@ -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 { }
@@ -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") {
@@ -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) { }
}
}
@@ -506,7 +506,7 @@ class ClientProxy: ClientProxyProtocol {
}
if !roomSummaryProvider.statePublisher.value.isLoaded {
_ = await roomSummaryProvider.statePublisher.values.first(where: { $0.isLoaded })
_ = await roomSummaryProvider.statePublisher.values.first { $0.isLoaded }
}
if shouldAwait {
@@ -298,11 +298,11 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
.infoPublisher
.compactMap { ($0.hasRoomCall, $0.activeRoomCallParticipants) }
.removeDuplicates { $0 == $1 }
.drop(while: { hasRoomCall, _ in
.drop { hasRoomCall, _ in
// Filter all updates before hasRoomCall becomes `true`. Then we can correctly
// detect its change to `false` to stop ringing when the caller hangs up.
!hasRoomCall
})
}
.sink { [weak self] hasOngoingCall, activeRoomCallParticipants in
guard let self else { return }
@@ -120,7 +120,7 @@ final class NotificationSettingsProxy: NotificationSettingsProxyProtocol {
// as in this case no API call is made by the RustSDK and the push rules are therefore not updated.
_ = await callbacks
.timeout(.seconds(2.0), scheduler: DispatchQueue.main, options: nil, customError: nil)
.values.first(where: { $0 == .settingsDidChange })
.values.first { $0 == .settingsDidChange }
}
@MainActor
@@ -166,6 +166,19 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
}
}
func messageFilteredTimeline(allowedMessageTypes: [RoomMessageEventMessageType]) async -> Result<any TimelineProxyProtocol, RoomProxyError> {
do {
let timeline = try await TimelineProxy(timeline: room.messageFilteredTimeline(internalIdPrefix: nil, allowedMessageTypes: allowedMessageTypes),
kind: .media)
await timeline.subscribeForUpdates()
return .success(timeline)
} catch {
MXLog.error("Failed retrieving media events timeline with error: \(error)")
return .failure(.sdkError(error))
}
}
func redact(_ eventID: String) async -> Result<Void, RoomProxyError> {
do {
try await room.redact(eventId: eventID, reason: nil)
@@ -70,6 +70,8 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol {
func timelineFocusedOnEvent(eventID: String, numberOfEvents: UInt16) async -> Result<TimelineProxyProtocol, RoomProxyError>
func messageFilteredTimeline(allowedMessageTypes: [RoomMessageEventMessageType]) async -> Result<TimelineProxyProtocol, RoomProxyError>
func redact(_ eventID: String) async -> Result<Void, RoomProxyError>
func reportContent(_ eventID: String, reason: String?) async -> Result<Void, RoomProxyError>
@@ -23,6 +23,12 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
var paginationState: PaginationState = .initial {
didSet {
callbacks.send(.paginationState(paginationState))
}
}
var timelineItems: [RoomTimelineItemProtocol] = RoomTimelineItemFixtures.default
var timelineItemsTimestamp: [TimelineItemIdentifier: Date] = [:]
@@ -30,9 +36,18 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
init(timelineKind: TimelineKind = .live, listenForSignals: Bool = false) {
self.timelineKind = timelineKind
callbacks.send(.paginationState(PaginationState(backward: .idle, forward: .timelineEndReached)))
paginationState = PaginationState(backward: .idle, forward: .timelineEndReached)
callbacks.send(.isLive(true))
switch timelineKind {
case .media:
timelineItems = (0..<5).reduce([]) { partialResult, _ in
partialResult + RoomTimelineItemFixtures.mediaChunk
}
default:
break
}
guard listenForSignals else { return }
do {
@@ -56,7 +71,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
}
func paginateBackwards(requestSize: UInt16) async -> Result<Void, RoomTimelineControllerError> {
callbacks.send(.paginationState(PaginationState(backward: .paginating, forward: .timelineEndReached)))
paginationState = PaginationState(backward: .paginating, forward: .timelineEndReached)
if client == nil {
try? await simulateBackPagination()
@@ -170,8 +185,8 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
/// Prepends the next chunk of items to the `timelineItems` array.
private func simulateBackPagination() async throws {
defer {
callbacks.send(.paginationState(PaginationState(backward: backPaginationResponses.isEmpty ? .timelineEndReached : .idle,
forward: .timelineEndReached)))
paginationState = PaginationState(backward: backPaginationResponses.isEmpty ? .timelineEndReached : .idle,
forward: .timelineEndReached)
}
guard !backPaginationResponses.isEmpty else { return }
@@ -30,6 +30,12 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
}
private(set) var timelineItems = [RoomTimelineItemProtocol]()
private(set) var paginationState: PaginationState = .initial {
didSet {
callbacks.send(.paginationState(paginationState))
}
}
var roomID: String {
roomProxy.id
@@ -64,7 +70,8 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
}
Task {
callbacks.send(.paginationState(PaginationState(backward: .paginating, forward: .paginating)))
paginationState = PaginationState(backward: .paginating, forward: .paginating)
switch await focusOnEvent(initialFocussedEventID, timelineSize: 100) {
case .success:
break
@@ -368,7 +375,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
isSwitchingTimelines = true
// Inform the world that the initial items are loading from the store
callbacks.send(.paginationState(.init(backward: .paginating, forward: .paginating)))
paginationState = PaginationState(backward: .paginating, forward: .paginating)
callbacks.send(.isLive(activeTimelineProvider.kind == .live))
updateTimelineItemsCancellable = activeTimelineProvider
@@ -446,7 +453,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
}
callbacks.send(.updatedTimelineItems(timelineItems: newTimelineItems, isSwitchingTimelines: isNewTimeline))
callbacks.send(.paginationState(paginationState))
self.paginationState = paginationState
}
private func buildTimelineItem(for itemProxy: TimelineItemProxy) -> RoomTimelineItemProtocol? {
@@ -6,6 +6,7 @@
//
import Foundation
import MatrixRustSDK
struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
func buildRoomTimelineController(roomProxy: JoinedRoomProxyProtocol,
@@ -20,12 +21,13 @@ struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
appSettings: ServiceLocator.shared.settings)
}
func buildRoomPinnedTimelineController(roomProxy: JoinedRoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol? {
func buildPinnedEventsRoomTimelineController(roomProxy: JoinedRoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol? {
guard let pinnedEventsTimeline = await roomProxy.pinnedEventsTimeline else {
return nil
}
return RoomTimelineController(roomProxy: roomProxy,
timelineProxy: pinnedEventsTimeline,
initialFocussedEventID: nil,
@@ -33,4 +35,21 @@ struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
mediaProvider: mediaProvider,
appSettings: ServiceLocator.shared.settings)
}
func buildMessageFilteredRoomTimelineController(allowedMessageTypes: [RoomMessageEventMessageType],
roomProxy: JoinedRoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) async -> Result<RoomTimelineControllerProtocol, RoomTimelineFactoryControllerError> {
switch await roomProxy.messageFilteredTimeline(allowedMessageTypes: allowedMessageTypes) {
case .success(let timelineProxy):
return .success(RoomTimelineController(roomProxy: roomProxy,
timelineProxy: timelineProxy,
initialFocussedEventID: nil,
timelineItemFactory: timelineItemFactory,
mediaProvider: mediaProvider,
appSettings: ServiceLocator.shared.settings))
case .failure(let error):
return .failure(.roomProxyError(error))
}
}
}
@@ -6,6 +6,11 @@
//
import Foundation
import MatrixRustSDK
enum RoomTimelineFactoryControllerError: Error {
case roomProxyError(RoomProxyError)
}
@MainActor
protocol RoomTimelineControllerFactoryProtocol {
@@ -13,9 +18,15 @@ protocol RoomTimelineControllerFactoryProtocol {
initialFocussedEventID: String?,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) -> RoomTimelineControllerProtocol
func buildRoomPinnedTimelineController(roomProxy: JoinedRoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol?
func buildPinnedEventsRoomTimelineController(roomProxy: JoinedRoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) async -> RoomTimelineControllerProtocol?
func buildMessageFilteredRoomTimelineController(allowedMessageTypes: [RoomMessageEventMessageType],
roomProxy: JoinedRoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
mediaProvider: MediaProviderProtocol) async -> Result<RoomTimelineControllerProtocol, RoomTimelineFactoryControllerError>
}
// sourcery: AutoMockable
@@ -31,7 +31,12 @@ protocol RoomTimelineControllerProtocol {
var roomID: String { get }
var timelineKind: TimelineKind { get }
/// The currently known items, use only for setting up the intial state.
var timelineItems: [RoomTimelineItemProtocol] { get }
/// The current pagination state, use only for setting up the intial state
var paginationState: PaginationState { get }
var callbacks: PassthroughSubject<RoomTimelineControllerCallback, Never> { get }
func processItemAppearance(_ itemID: TimelineItemIdentifier) async
@@ -42,7 +42,7 @@ extension AggregatedReaction {
/// Whether to highlight the reaction, indicating that the current user sent this reaction.
var isHighlighted: Bool {
senders.contains(where: { $0.id == accountOwnerID })
senders.contains { $0.id == accountOwnerID }
}
/// The key to be displayed on screen. See `maxDisplayChars`.
@@ -13,7 +13,7 @@ import MatrixRustSDK
/// Its value is consistent only per timeline instance, it should **not** be used to identify an item across timeline instances.
/// - eventOrTransactionID: Contains the 2 possible identifiers of an event, either it has a remote event id or
/// a local transaction id, never both or none.
enum TimelineItemIdentifier: Hashable {
enum TimelineItemIdentifier: Hashable, Sendable {
case event(uniqueID: TimelineUniqueId, eventOrTransactionID: EventOrTransactionId)
case virtual(uniqueID: TimelineUniqueId)
@@ -61,9 +61,10 @@ struct VoiceMessageRoomPlaybackView: View {
if let url = playerState.fileURL {
WaveformView(audioURL: url,
configuration: .init(style: .striped(.init(color: .black, width: waveformLineWidth, spacing: waveformLinePadding)),
verticalScalingFactor: 1.0),
placeholder: { estimatedWaveformView })
.progressMask(progress: playerState.progress)
verticalScalingFactor: 1.0)) {
estimatedWaveformView
}
.progressMask(progress: playerState.progress)
} else {
estimatedWaveformView
}
@@ -92,7 +92,7 @@ final class TimelineProxy: TimelineProxyProtocol {
switch kind {
case .live:
return await paginateBackwardsOnLive(requestSize: requestSize)
case .detached:
case .detached, .media:
return await focussedPaginate(.backwards, requestSize: requestSize)
case .pinned:
return .success(())
@@ -319,7 +319,6 @@ final class TimelineProxy: TimelineProxyProtocol {
return .success(())
}
// swiftlint:disable:next function_parameter_count
func sendVideo(url: URL,
thumbnailURL: URL,
videoInfo: VideoInfo,
@@ -580,7 +579,7 @@ final class TimelineProxy: TimelineProxyProtocol {
MXLog.error("Failed to subscribe to back pagination status with error: \(error)")
}
forwardPaginationStatusSubject.send(.timelineEndReached)
case .detached:
case .detached, .media:
// Detached timelines don't support observation, set the initial state ourself.
backPaginationStatusSubject.send(.idle)
forwardPaginationStatusSubject.send(.idle)
@@ -13,6 +13,7 @@ enum TimelineKind {
case live
case detached
case pinned
case media
}
enum TimelineProxyError: Error {
@@ -232,7 +232,7 @@ class VoiceMessageRecorder: VoiceMessageRecorderProtocol {
private func finalizeRecording() async -> Result<Void, VoiceMessageRecorderError> {
MXLog.info("finalize audio recording")
guard let url = audioRecorder.audioFileURL, audioRecorder.currentTime > 0 else {
guard audioRecorder.audioFileURL != nil, audioRecorder.currentTime > 0 else {
return .failure(.previewNotAvailable)
}