RoomScreenViewModel is now TimelineViewModel (#3157)

Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
Mauro 2024-08-13 13:36:40 +02:00 committed by GitHub
parent 4519275559
commit 1ad361a6e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
88 changed files with 2266 additions and 1973 deletions

File diff suppressed because it is too large Load Diff

View File

@ -471,7 +471,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
break
case (.room, .presentPinnedEventsTimeline, .pinnedEventsTimeline):
presentPinnedEventsTimeline()
Task { await self.presentPinnedEventsTimeline() }
case (.pinnedEventsTimeline, .dismissPinnedEventsTimeline, .room):
break
@ -481,7 +481,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
break
case (.roomDetails, .presentPinnedEventsTimeline, .pinnedEventsTimeline):
presentPinnedEventsTimeline()
Task { await self.presentPinnedEventsTimeline() }
case (.pinnedEventsTimeline, .dismissPinnedEventsTimeline, .roomDetails):
break
@ -965,9 +965,24 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func presentPinnedEventsTimeline() {
private func presentPinnedEventsTimeline() async {
let userID = userSession.clientProxy.userID
let timelineItemFactory = RoomTimelineItemFactory(userID: userID,
encryptionAuthenticityEnabled: appSettings.timelineItemAuthenticityEnabled,
attributedStringBuilder: AttributedStringBuilder(mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID))
guard let timelineController = await roomTimelineControllerFactory.buildRoomPinnedTimelineController(roomProxy: roomProxy, timelineItemFactory: timelineItemFactory) else {
fatalError("This can never fail because we allow this view to be presented only when the timeline is fully loaded and not nil")
}
let stackCoordinator = NavigationStackCoordinator()
let coordinator = PinnedEventsTimelineScreenCoordinator(parameters: .init())
let coordinator = PinnedEventsTimelineScreenCoordinator(parameters: .init(roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: userSession.mediaProvider,
mediaPlayerProvider: MediaPlayerProvider(),
voiceMessageMediaManager: userSession.voiceMessageMediaManager,
appMediator: appMediator))
coordinator.actions
.sink { [weak self] action in

View File

@ -11508,6 +11508,76 @@ class RoomTimelineControllerFactoryMock: RoomTimelineControllerFactoryProtocol {
return buildRoomTimelineControllerRoomProxyInitialFocussedEventIDTimelineItemFactoryReturnValue
}
}
//MARK: - buildRoomPinnedTimelineController
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingCallsCount = 0
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryCallsCount: Int {
get {
if Thread.isMainThread {
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingCallsCount
} else {
var returnValue: Int? = nil
DispatchQueue.main.sync {
returnValue = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingCallsCount
}
return returnValue!
}
}
set {
if Thread.isMainThread {
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingCallsCount = newValue
} else {
DispatchQueue.main.sync {
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingCallsCount = newValue
}
}
}
}
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryCalled: Bool {
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryCallsCount > 0
}
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryReceivedArguments: (roomProxy: RoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol)?
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryReceivedInvocations: [(roomProxy: RoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol)] = []
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingReturnValue: RoomTimelineControllerProtocol?
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryReturnValue: RoomTimelineControllerProtocol? {
get {
if Thread.isMainThread {
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingReturnValue
} else {
var returnValue: RoomTimelineControllerProtocol?? = nil
DispatchQueue.main.sync {
returnValue = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingReturnValue
}
return returnValue!
}
}
set {
if Thread.isMainThread {
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingReturnValue = newValue
} else {
DispatchQueue.main.sync {
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryUnderlyingReturnValue = newValue
}
}
}
}
var buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryClosure: ((RoomProxyProtocol, RoomTimelineItemFactoryProtocol) async -> RoomTimelineControllerProtocol?)?
func buildRoomPinnedTimelineController(roomProxy: RoomProxyProtocol, timelineItemFactory: RoomTimelineItemFactoryProtocol) async -> RoomTimelineControllerProtocol? {
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryCallsCount += 1
buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryReceivedArguments = (roomProxy: roomProxy, timelineItemFactory: timelineItemFactory)
DispatchQueue.main.async {
self.buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryReceivedInvocations.append((roomProxy: roomProxy, timelineItemFactory: timelineItemFactory))
}
if let buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryClosure = buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryClosure {
return await buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryClosure(roomProxy, timelineItemFactory)
} else {
return buildRoomPinnedTimelineControllerRoomProxyTimelineItemFactoryReturnValue
}
}
}
class RoomTimelineProviderMock: RoomTimelineProviderProtocol {
var updatePublisher: AnyPublisher<([TimelineItemProxy], PaginationState), Never> {

View File

@ -18,7 +18,7 @@ import MatrixRustSDK
import SwiftUI
final class MessageTextView: UITextView, PillAttachmentViewProviderDelegate {
var roomContext: RoomScreenViewModel.Context?
var timelineContext: TimelineViewModel.Context?
var updateClosure: (() -> Void)?
private var pillViews = NSHashTable<UIView>.weakObjects()
@ -54,7 +54,7 @@ final class MessageTextView: UITextView, PillAttachmentViewProviderDelegate {
struct MessageText: UIViewRepresentable {
@Environment(\.openURL) private var openURLAction
@Environment(\.roomContext) private var viewModel
@Environment(\.timelineContext) private var viewModel
@State private var computedSizes = [Double: CGSize]()
@State var attributedString: AttributedString {
@ -66,7 +66,7 @@ struct MessageText: UIViewRepresentable {
func makeUIView(context: Context) -> MessageTextView {
// Need to use TextKit 1 for mentions
let textView = MessageTextView(usingTextLayoutManager: false)
textView.roomContext = viewModel
textView.timelineContext = viewModel
textView.updateClosure = { [weak textView] in
guard let textView else { return }
do {
@ -197,7 +197,7 @@ struct MessageText_Previews: PreviewProvider, TestablePreview {
static var attachmentPreview: some View {
MessageText(attributedString: attributedStringWithAttachment)
.border(Color.purple)
.environmentObject(RoomScreenViewModel.mock.context)
.environmentObject(TimelineViewModel.mock.context)
}
static var previews: some View {

View File

@ -21,7 +21,7 @@ import UIKit
import WysiwygComposer
protocol PillAttachmentViewProviderDelegate: AnyObject {
var roomContext: RoomScreenViewModel.Context? { get }
var timelineContext: TimelineViewModel.Context? { get }
func registerPillView(_ pillView: UIView)
func invalidateTextAttachmentsDisplay()
@ -56,9 +56,9 @@ final class PillAttachmentViewProvider: NSTextAttachmentViewProvider, NSSecureCo
// The mock viewModel simulates the loading logic for testing purposes
context = PillContext.mock(type: .loadUser(isOwn: false))
imageProvider = MockMediaProvider()
} else if let roomContext = delegate?.roomContext {
context = PillContext(roomContext: roomContext, data: pillData)
imageProvider = roomContext.imageProvider
} else if let timelineContext = delegate?.timelineContext {
context = PillContext(timelineContext: timelineContext, data: pillData)
imageProvider = timelineContext.imageProvider
} else {
MXLog.failure("[PillAttachmentViewProvider]: missing room context")
return
@ -93,21 +93,21 @@ final class PillAttachmentViewProvider: NSTextAttachmentViewProvider, NSSecureCo
}
final class ComposerMentionDisplayHelper: MentionDisplayHelper {
weak var roomContext: RoomScreenViewModel.Context?
weak var timelineContext: TimelineViewModel.Context?
init(roomContext: RoomScreenViewModel.Context) {
self.roomContext = roomContext
init(timelineContext: TimelineViewModel.Context) {
self.timelineContext = timelineContext
}
@MainActor
static var mock: Self {
Self(roomContext: RoomScreenViewModel.mock.context)
Self(timelineContext: TimelineViewModel.mock.context)
}
}
extension WysiwygTextView: PillAttachmentViewProviderDelegate {
var roomContext: RoomScreenViewModel.Context? {
(mentionDisplayHelper as? ComposerMentionDisplayHelper)?.roomContext
var timelineContext: TimelineViewModel.Context? {
(mentionDisplayHelper as? ComposerMentionDisplayHelper)?.timelineContext
}
func invalidateTextAttachmentsDisplay() { }

View File

@ -28,11 +28,11 @@ final class PillContext: ObservableObject {
private var cancellable: AnyCancellable?
init(roomContext: RoomScreenViewModel.Context, data: PillTextAttachmentData) {
init(timelineContext: TimelineViewModel.Context, data: PillTextAttachmentData) {
switch data.type {
case let .user(id):
let isOwnMention = id == roomContext.viewState.ownUserID
if let profile = roomContext.viewState.members[id] {
let isOwnMention = id == timelineContext.viewState.ownUserID
if let profile = timelineContext.viewState.members[id] {
var name = id
if let displayName = profile.displayName {
name = "@\(displayName)"
@ -40,7 +40,7 @@ final class PillContext: ObservableObject {
viewState = PillViewState(isOwnMention: isOwnMention, displayText: name)
} else {
viewState = PillViewState(isOwnMention: isOwnMention, displayText: id)
cancellable = roomContext.$viewState.sink { [weak self] viewState in
cancellable = timelineContext.$viewState.sink { [weak self] viewState in
guard let self else {
return
}
@ -73,7 +73,7 @@ extension PillContext {
switch type {
case .loadUser(let isOwn):
pillType = .user(userID: testID)
let viewModel = PillContext(roomContext: RoomScreenViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
let viewModel = PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
viewModel.viewState = PillViewState(isOwnMention: isOwn, displayText: testID)
Task {
try? await Task.sleep(for: .seconds(2))
@ -82,12 +82,12 @@ extension PillContext {
return viewModel
case .loadedUser(let isOwn):
pillType = .user(userID: "@test:test.com")
let viewModel = PillContext(roomContext: RoomScreenViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
let viewModel = PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
viewModel.viewState = PillViewState(isOwnMention: isOwn, displayText: "@Very Very Long Test Display Text")
return viewModel
case .allUsers:
pillType = .allUsers
return PillContext(roomContext: RoomScreenViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
return PillContext(timelineContext: TimelineViewModel.mock.context, data: PillTextAttachmentData(type: pillType, font: .preferredFont(forTextStyle: .body)))
}
}
}

View File

@ -17,7 +17,14 @@
import Combine
import SwiftUI
struct PinnedEventsTimelineScreenCoordinatorParameters { }
struct PinnedEventsTimelineScreenCoordinatorParameters {
let roomProxy: RoomProxyProtocol
let timelineController: RoomTimelineControllerProtocol
let mediaProvider: MediaProviderProtocol
let mediaPlayerProvider: MediaPlayerProviderProtocol
let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
let appMediator: AppMediatorProtocol
}
enum PinnedEventsTimelineScreenCoordinatorAction {
case dismiss
@ -28,6 +35,7 @@ enum PinnedEventsTimelineScreenCoordinatorAction {
final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
private let parameters: PinnedEventsTimelineScreenCoordinatorParameters
private let viewModel: PinnedEventsTimelineScreenViewModelProtocol
private let timelineViewModel: TimelineViewModelProtocol
private var cancellables = Set<AnyCancellable>()
@ -40,6 +48,15 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
self.parameters = parameters
viewModel = PinnedEventsTimelineScreenViewModel()
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
timelineController: parameters.timelineController,
mediaProvider: parameters.mediaProvider,
mediaPlayerProvider: parameters.mediaPlayerProvider,
voiceMessageMediaManager: parameters.voiceMessageMediaManager,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: parameters.appMediator,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
}
func start() {
@ -56,6 +73,6 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
}
func toPresentable() -> AnyView {
AnyView(PinnedEventsTimelineScreen(context: viewModel.context))
AnyView(PinnedEventsTimelineScreen(context: viewModel.context, timelineContext: timelineViewModel.context))
}
}

View File

@ -19,6 +19,7 @@ import SwiftUI
struct PinnedEventsTimelineScreen: View {
@ObservedObject var context: PinnedEventsTimelineScreenViewModel.Context
@ObservedObject var timelineContext: TimelineViewModel.Context
var body: some View {
content
@ -28,22 +29,29 @@ struct PinnedEventsTimelineScreen: View {
.background(.compound.bgCanvasDefault)
}
@ViewBuilder
private var content: some View {
// TODO: Implement switching between empty state and timeline
VStack(spacing: 16) {
HeroImage(icon: \.pin, style: .normal)
Text(L10n.screenPinnedTimelineEmptyStateHeadline)
.font(.compound.headingSMSemibold)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
Text(L10n.screenPinnedTimelineEmptyStateDescription(L10n.actionPin))
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.multilineTextAlignment(.center)
Spacer()
if timelineContext.viewState.timelineViewState.itemsDictionary.isEmpty {
VStack(spacing: 16) {
HeroImage(icon: \.pin, style: .normal)
Text(L10n.screenPinnedTimelineEmptyStateHeadline)
.font(.compound.headingSMSemibold)
.foregroundStyle(.compound.textPrimary)
.multilineTextAlignment(.center)
Text(L10n.screenPinnedTimelineEmptyStateDescription(L10n.actionPin))
.font(.compound.bodyMD)
.foregroundStyle(.compound.textSecondary)
.multilineTextAlignment(.center)
Spacer()
}
.padding(.top, 48)
.padding(.horizontal, 16)
} else {
TimelineView()
.id(timelineContext.viewState.roomID)
.environmentObject(timelineContext)
.environment(\.focussedEventID, timelineContext.viewState.timelineViewState.focussedEvent?.eventID)
}
.padding(.top, 48)
.padding(.horizontal, 16)
}
@ToolbarContentBuilder
@ -60,9 +68,24 @@ struct PinnedEventsTimelineScreen: View {
struct PinnedEventsTimelineScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = PinnedEventsTimelineScreenViewModel()
static let emptyTimelineViewModel: TimelineViewModel = {
let timelineController = MockRoomTimelineController()
timelineController.timelineItems = []
return TimelineViewModel(roomProxy: RoomProxyMock(.init(name: "Preview room")),
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: UserIndicatorControllerMock(),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
}()
static var previews: some View {
NavigationStack {
PinnedEventsTimelineScreen(context: viewModel.context)
PinnedEventsTimelineScreen(context: viewModel.context, timelineContext: emptyTimelineViewModel.context)
}
.previewDisplayName("Empty")
}
}

View File

@ -31,13 +31,13 @@ enum ComposerToolbarVoiceMessageAction {
}
enum ComposerToolbarViewModelAction {
case sendMessage(plain: String, html: String?, mode: RoomScreenComposerMode, intentionalMentions: IntentionalMentions)
case sendMessage(plain: String, html: String?, mode: ComposerMode, intentionalMentions: IntentionalMentions)
case editLastMessage
case attach(ComposerAttachmentType)
case handlePasteOrDrop(provider: NSItemProvider)
case composerModeChanged(mode: RoomScreenComposerMode)
case composerModeChanged(mode: ComposerMode)
case composerFocusedChanged(isFocused: Bool)
case voiceMessage(ComposerToolbarVoiceMessageAction)
@ -72,7 +72,7 @@ enum ComposerAttachmentType {
}
struct ComposerToolbarViewState: BindableState {
var composerMode: RoomScreenComposerMode = .default
var composerMode: ComposerMode = .default
var composerEmpty = true
var suggestions: [SuggestionItem] = []
var audioPlayerState: AudioPlayerState
@ -288,3 +288,61 @@ extension FormatType {
}
}
}
enum ComposerMode: Equatable {
case `default`
case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails, isThread: Bool)
case edit(originalItemId: TimelineItemIdentifier)
case recordVoiceMessage(state: AudioRecorderState)
case previewVoiceMessage(state: AudioPlayerState, waveform: WaveformSource, isUploading: Bool)
var isEdit: Bool {
switch self {
case .edit:
return true
default:
return false
}
}
var isTextEditingEnabled: Bool {
switch self {
case .default, .reply, .edit:
return true
case .recordVoiceMessage, .previewVoiceMessage:
return false
}
}
var isLoadingReply: Bool {
switch self {
case .reply(_, let replyDetails, _):
switch replyDetails {
case .loading:
return true
default:
return false
}
default:
return false
}
}
var replyEventID: String? {
switch self {
case .reply(let itemID, _, _):
return itemID.eventID
default:
return nil
}
}
var isComposingNewMessage: Bool {
switch self {
case .default, .reply:
return true
default:
return false
}
}
}

View File

@ -200,8 +200,8 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
}
}
func process(roomAction: RoomScreenComposerAction) {
switch roomAction {
func process(timelineAction: TimelineComposerAction) {
switch timelineAction {
case .setMode(mode: let mode):
if state.composerMode.isComposingNewMessage, mode.isEdit {
handleSaveDraft(isVolatile: true)
@ -223,19 +223,23 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
set(mode: .default)
set(text: "")
}
case .saveDraft:
handleSaveDraft(isVolatile: false)
case .loadDraft:
Task {
guard case let .success(draft) = await draftService.loadDraft(),
let draft else {
return
}
handleLoadDraft(draft)
}
}
}
func loadDraft() {
Task {
guard case let .success(draft) = await draftService.loadDraft(),
let draft else {
return
}
handleLoadDraft(draft)
}
}
func saveDraft() {
handleSaveDraft(isVolatile: false)
}
var keyCommands: [WysiwygKeyCommand] {
[
.enter { [weak self] in
@ -480,7 +484,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
}
}
private func set(mode: RoomScreenComposerMode) {
private func set(mode: ComposerMode) {
if state.composerMode.isLoadingReply, state.composerMode.replyEventID != mode.replyEventID {
replyLoadingTask?.cancel()
}

View File

@ -15,10 +15,15 @@
//
import Combine
import WysiwygComposer
// periphery: ignore - markdown protocol
protocol ComposerToolbarViewModelProtocol {
var actions: AnyPublisher<ComposerToolbarViewModelAction, Never> { get }
var context: ComposerToolbarViewModelType.Context { get }
func process(roomAction: RoomScreenComposerAction)
var keyCommands: [WysiwygKeyCommand] { get }
func process(timelineAction: TimelineComposerAction)
func loadDraft()
func saveDraft()
}

View File

@ -24,7 +24,7 @@ typealias PasteHandler = (NSItemProvider) -> Void
struct MessageComposer: View {
@Binding var plainComposerText: NSAttributedString
let composerView: WysiwygComposerView
let mode: RoomScreenComposerMode
let mode: ComposerMode
let composerFormattingEnabled: Bool
let showResizeGrabber: Bool
@Binding var isExpanded: Bool
@ -208,7 +208,7 @@ private struct MessageComposerHeaderLabelStyle: LabelStyle {
}
struct MessageComposer_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static let replyTypes: [TimelineItemReplyDetails] = [
.loaded(sender: .init(id: "Dave"),
@ -241,7 +241,7 @@ struct MessageComposer_Previews: PreviewProvider, TestablePreview {
]
static func messageComposer(_ content: NSAttributedString = .init(string: ""),
mode: RoomScreenComposerMode = .default) -> MessageComposer {
mode: ComposerMode = .default) -> MessageComposer {
let viewModel = WysiwygComposerViewModel(minHeight: 22,
maxExpandedHeight: 250)
viewModel.setMarkdownContent(content.string)

View File

@ -55,7 +55,7 @@ struct MessageComposerTextField: View {
}
private struct UITextViewWrapper: UIViewRepresentable {
@Environment(\.roomContext) private var roomContext
@Environment(\.timelineContext) private var timelineContext
@Binding var text: NSAttributedString
@ -69,7 +69,7 @@ private struct UITextViewWrapper: UIViewRepresentable {
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
// Need to use TextKit 1 for mentions
let textView = ElementTextView(usingTextLayoutManager: false)
textView.roomContext = roomContext
textView.timelineContext = timelineContext
textView.delegate = context.coordinator
textView.elementDelegate = context.coordinator
@ -182,7 +182,7 @@ private protocol ElementTextViewDelegate: AnyObject {
}
private class ElementTextView: UITextView, PillAttachmentViewProviderDelegate {
var roomContext: RoomScreenViewModel.Context?
var timelineContext: TimelineViewModel.Context?
weak var elementDelegate: ElementTextViewDelegate?

View File

@ -49,8 +49,9 @@ enum RoomScreenCoordinatorAction {
}
final class RoomScreenCoordinator: CoordinatorProtocol {
private var viewModel: RoomScreenViewModelProtocol
private var composerViewModel: ComposerToolbarViewModel
private var roomViewModel: RoomScreenViewModelProtocol
private var timelineViewModel: TimelineViewModelProtocol
private var composerViewModel: ComposerToolbarViewModelProtocol
private var wysiwygViewModel: WysiwygComposerViewModel
private var cancellables = Set<AnyCancellable>()
@ -61,31 +62,33 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
}
init(parameters: RoomScreenCoordinatorParameters) {
let viewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
focussedEventID: parameters.focussedEventID,
timelineController: parameters.timelineController,
mediaProvider: parameters.mediaProvider,
mediaPlayerProvider: parameters.mediaPlayerProvider,
voiceMessageMediaManager: parameters.voiceMessageMediaManager,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: parameters.appMediator,
appSettings: parameters.appSettings,
analyticsService: ServiceLocator.shared.analytics)
self.viewModel = viewModel
roomViewModel = RoomScreenViewModel()
timelineViewModel = TimelineViewModel(roomProxy: parameters.roomProxy,
focussedEventID: parameters.focussedEventID,
timelineController: parameters.timelineController,
mediaProvider: parameters.mediaProvider,
mediaPlayerProvider: parameters.mediaPlayerProvider,
voiceMessageMediaManager: parameters.voiceMessageMediaManager,
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: parameters.appMediator,
appSettings: parameters.appSettings,
analyticsService: ServiceLocator.shared.analytics)
wysiwygViewModel = WysiwygComposerViewModel(minHeight: ComposerConstant.minHeight,
maxCompressedHeight: ComposerConstant.maxHeight,
maxExpandedHeight: ComposerConstant.maxHeight,
parserStyle: .elementX)
composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: parameters.completionSuggestionService,
mediaProvider: parameters.mediaProvider,
mentionDisplayHelper: ComposerMentionDisplayHelper(roomContext: viewModel.context),
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: parameters.composerDraftService)
let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
completionSuggestionService: parameters.completionSuggestionService,
mediaProvider: parameters.mediaProvider,
mentionDisplayHelper: ComposerMentionDisplayHelper(timelineContext: timelineViewModel.context),
analyticsService: ServiceLocator.shared.analytics,
composerDraftService: parameters.composerDraftService)
self.composerViewModel = composerViewModel
NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification).sink { _ in
viewModel.saveDraft()
composerViewModel.saveDraft()
}
.store(in: &cancellables)
}
@ -93,7 +96,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
// MARK: - Public
func start() {
viewModel.actions
timelineViewModel.actions
.sink { [weak self] action in
guard let self else { return }
@ -123,7 +126,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
case .displayLocation(let body, let geoURI, let description):
actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI, description: description))
case .composer(let action):
composerViewModel.process(roomAction: action)
composerViewModel.process(timelineAction: action)
case .displayCallScreen:
actionsSubject.send(.presentCallScreen)
case .displayPinnedEventsTimeline:
@ -136,21 +139,27 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
.sink { [weak self] action in
guard let self else { return }
viewModel.process(composerAction: action)
timelineViewModel.process(composerAction: action)
}
.store(in: &cancellables)
roomViewModel.actions
.sink { [weak self] _ in
guard let self else { return }
}
.store(in: &cancellables)
// Loading the draft requires the subscriptions to be set up first otherwise the room won't be be able to propagate the information to the composer.
viewModel.loadDraft()
composerViewModel.loadDraft()
}
func focusOnEvent(eventID: String) {
Task { await viewModel.focusOnEvent(eventID: eventID) }
Task { await timelineViewModel.focusOnEvent(eventID: eventID) }
}
func stop() {
viewModel.saveDraft()
viewModel.stop()
composerViewModel.saveDraft()
timelineViewModel.stop()
}
func toPresentable() -> AnyView {
@ -158,10 +167,12 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
wysiwygViewModel: wysiwygViewModel,
keyCommands: composerViewModel.keyCommands)
return AnyView(RoomScreen(context: viewModel.context, composerToolbar: composerToolbar)
.onDisappear { [weak self] in
self?.viewModel.saveDraft()
})
return AnyView(RoomScreen(roomViewModel: roomViewModel,
timelineViewModel: timelineViewModel,
composerToolbar: composerToolbar)
.onDisappear { [weak self] in
self?.composerViewModel.saveDraft()
})
}
}

View File

@ -1,5 +1,5 @@
//
// Copyright 2022 New Vector Ltd
// Copyright 2024 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@ -14,431 +14,19 @@
// limitations under the License.
//
import Combine
import OrderedCollections
import SwiftUI
import Foundation
enum RoomScreenViewModelAction {
case displayRoomDetails
case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
case displayReportContent(itemID: TimelineItemIdentifier, senderID: String)
case displayCameraPicker
case displayMediaPicker
case displayDocumentPicker
case displayLocationPicker
case displayPollForm(mode: PollFormMode)
case displayMediaUploadPreviewScreen(url: URL)
case displayRoomMemberDetails(userID: String)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayLocation(body: String, geoURI: GeoURI, description: String?)
case composer(action: RoomScreenComposerAction)
case displayCallScreen
case displayPinnedEventsTimeline
enum RoomScreenViewModelAction { }
enum RoomScreenViewAction { }
struct RoomScreenViewState: BindableState {
var bindings: RoomScreenViewStateBindings
}
enum RoomScreenComposerMode: Equatable {
case `default`
case reply(itemID: TimelineItemIdentifier, replyDetails: TimelineItemReplyDetails, isThread: Bool)
case edit(originalItemId: TimelineItemIdentifier)
case recordVoiceMessage(state: AudioRecorderState)
case previewVoiceMessage(state: AudioPlayerState, waveform: WaveformSource, isUploading: Bool)
var isEdit: Bool {
switch self {
case .edit:
return true
default:
return false
}
}
var isTextEditingEnabled: Bool {
switch self {
case .default, .reply, .edit:
return true
case .recordVoiceMessage, .previewVoiceMessage:
return false
}
}
var isLoadingReply: Bool {
switch self {
case .reply(_, let replyDetails, _):
switch replyDetails {
case .loading:
return true
default:
return false
}
default:
return false
}
}
var replyEventID: String? {
switch self {
case .reply(let itemID, _, _):
return itemID.eventID
default:
return nil
}
}
var isComposingNewMessage: Bool {
switch self {
case .default, .reply:
return true
default:
return false
}
}
}
enum RoomScreenViewPollAction {
case selectOption(pollStartID: String, optionID: String)
case end(pollStartID: String)
case edit(pollStartID: String, poll: Poll)
}
enum RoomScreenAudioPlayerAction {
case playPause(itemID: TimelineItemIdentifier)
case seek(itemID: TimelineItemIdentifier, progress: Double)
}
enum RoomScreenViewAction {
case itemAppeared(itemID: TimelineItemIdentifier)
case itemDisappeared(itemID: TimelineItemIdentifier)
case itemTapped(itemID: TimelineItemIdentifier)
case itemSendInfoTapped(itemID: TimelineItemIdentifier)
case toggleReaction(key: String, itemID: TimelineItemIdentifier)
case sendReadReceiptIfNeeded(TimelineItemIdentifier)
case paginateBackwards
case paginateForwards
case scrollToBottom
case displayTimelineItemMenu(itemID: TimelineItemIdentifier)
case handleTimelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction)
case displayRoomDetails
case displayRoomMemberDetails(userID: String)
case displayReactionSummary(itemID: TimelineItemIdentifier, key: String)
case displayEmojiPicker(itemID: TimelineItemIdentifier)
case displayReadReceipts(itemID: TimelineItemIdentifier)
case displayCall
case handlePasteOrDrop(provider: NSItemProvider)
case handlePollAction(RoomScreenViewPollAction)
case handleAudioPlayerAction(RoomScreenAudioPlayerAction)
/// Focus the timeline onto the specified event ID (switching to a detached timeline if needed).
case focusOnEventID(String)
/// Switch back to a live timeline (from a detached one).
case focusLive
/// The timeline scrolled to reveal the focussed item.
case scrolledToFocussedItem
/// The table view has loaded the first items for a new timeline.
case hasSwitchedTimeline
case hasScrolled(direction: ScrollDirection)
case tappedPinnedEventsBanner
case viewAllPins
}
struct RoomScreenViewStateBindings { }
enum RoomScreenComposerAction {
case setMode(mode: RoomScreenComposerMode)
case setText(plainText: String, htmlText: String?)
case removeFocus
case clear
case saveDraft
case loadDraft
}
struct RoomScreenViewState: BindableState {
var roomID: String
var roomTitle = ""
var roomAvatar: RoomAvatar
var members: [String: RoomMemberState] = [:]
var typingMembers: [String] = []
var showLoading = false
var showReadReceipts = false
var isEncryptedOneToOneRoom = false
var timelineViewState: TimelineViewState // check the doc before changing this
var ownUserID: String
var canCurrentUserRedactOthers = false
var canCurrentUserRedactSelf = false
var canCurrentUserPin = false
var isViewSourceEnabled: Bool
var isPinningEnabled = false
var lastScrollDirection: ScrollDirection?
// The `pinnedEventIDs` are used only to determine if an item is already pinned or not.
// It's updated from the room info, so it's faster than using the timeline
var pinnedEventIDs: Set<String> = []
// This is used to control the banner
var pinnedEventsBannerState: PinnedEventsBannerState = .loading(numbersOfEvents: 0)
var shouldShowPinnedEventsBanner: Bool {
isPinningEnabled && !pinnedEventsBannerState.isEmpty && lastScrollDirection != .top
}
var canJoinCall = false
var hasOngoingCall = false
var bindings: RoomScreenViewStateBindings
/// A closure providing the associated audio player state for an item in the timeline.
var audioPlayerStateProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> AudioPlayerState?)?
}
struct RoomScreenViewStateBindings {
var isScrolledToBottom = true
/// The state of wether reactions listed on the timeline are expanded/collapsed.
/// Key is itemID, value is the collapsed state.
var reactionsCollapsed: [TimelineItemIdentifier: Bool]
/// A media item that will be previewed with QuickLook.
var mediaPreviewItem: MediaPreviewItem?
var alertInfo: AlertInfo<RoomScreenAlertInfoType>?
var debugInfo: TimelineItemDebugInfo?
var actionMenuInfo: TimelineItemActionMenuInfo?
var reactionSummaryInfo: ReactionSummaryInfo?
var readReceiptsSummaryInfo: ReadReceiptSummaryInfo?
}
struct TimelineItemActionMenuInfo: Equatable, Identifiable {
static func == (lhs: TimelineItemActionMenuInfo, rhs: TimelineItemActionMenuInfo) -> Bool {
lhs.id == rhs.id
}
let item: EventBasedTimelineItemProtocol
var id: TimelineItemIdentifier {
item.id
}
}
struct ReactionSummaryInfo: Identifiable {
let reactions: [AggregatedReaction]
let selectedKey: String
var id: String {
selectedKey
}
}
struct ReadReceiptSummaryInfo: Identifiable {
let orderedReceipts: [ReadReceipt]
let id: TimelineItemIdentifier
}
enum RoomScreenAlertInfoType: Hashable {
case audioRecodingPermissionError
case pollEndConfirmation(String)
case sendingFailed
case encryptionAuthenticity(String)
}
struct RoomMemberState {
let displayName: String?
let avatarURL: URL?
}
/// Used as the state for the TimelineView, to avoid having the context continuously refresh the list of items on each small change.
/// Is also nice to have this as a wrapper for any state that is directly connected to the timeline.
struct TimelineViewState {
var isLive = true
var paginationState = PaginationState.initial
/// The room is in the process of loading items from a new timeline (switching to/from a detached timeline).
var isSwitchingTimelines = false
struct FocussedEvent: Equatable {
enum Appearance {
/// The event should be shown using an animated scroll.
case animated
/// The event should be shown immediately, without any animation.
case immediate
/// The event has already been shown.
case hasAppeared
}
/// The ID of the event.
let eventID: String
/// How the event should be shown, or whether it has already appeared.
var appearance: Appearance
}
/// A focussed event that was navigated to via a permalink.
var focussedEvent: FocussedEvent?
// These can be removed when we have full swiftUI and moved as @State values in the view
var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
var itemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
var timelineIDs: [String] {
itemsDictionary.keys.elements
}
var itemViewStates: [RoomTimelineItemViewState] {
itemsDictionary.values.elements
}
func hasLoadedItem(with eventID: String) -> Bool {
itemViewStates.contains { $0.identifier.eventID == eventID }
}
}
enum ScrollDirection: Equatable {
case top
case bottom
}
struct PinnedEventsState: Equatable {
var pinnedEventContents: OrderedDictionary<String, AttributedString> = [:] {
didSet {
if selectedPinEventID == nil, !pinnedEventContents.keys.isEmpty {
// The default selected event should always be the last one.
selectedPinEventID = pinnedEventContents.keys.last
} else if pinnedEventContents.isEmpty {
selectedPinEventID = nil
} else if let selectedPinEventID, !pinnedEventContents.keys.set.contains(selectedPinEventID) {
self.selectedPinEventID = pinnedEventContents.keys.last
}
}
}
private(set) var selectedPinEventID: String?
var selectedPinIndex: Int {
let defaultValue = pinnedEventContents.isEmpty ? 0 : pinnedEventContents.count - 1
guard let selectedPinEventID else {
return defaultValue
}
return pinnedEventContents.keys.firstIndex(of: selectedPinEventID) ?? defaultValue
}
var selectedPinContent: AttributedString {
var content = AttributedString(" ")
if let selectedPinEventID,
let pinnedEventContent = pinnedEventContents[selectedPinEventID] {
content = pinnedEventContent
}
content.font = .compound.bodyMD
content.link = nil
return content
}
mutating func previousPin() {
guard !pinnedEventContents.isEmpty else {
return
}
let currentIndex = selectedPinIndex
let nextIndex = currentIndex - 1
if nextIndex == -1 {
selectedPinEventID = pinnedEventContents.keys.last
} else {
selectedPinEventID = pinnedEventContents.keys[nextIndex % pinnedEventContents.count]
}
}
}
enum PinnedEventsBannerState: Equatable {
case loading(numbersOfEvents: Int)
case loaded(state: PinnedEventsState)
var isEmpty: Bool {
switch self {
case .loaded(let state):
return state.pinnedEventContents.isEmpty
case .loading(let numberOfEvents):
return numberOfEvents == 0
}
}
var isLoading: Bool {
switch self {
case .loading:
return true
default:
return false
}
}
var selectedPinEventID: String? {
switch self {
case .loaded(let state):
return state.selectedPinEventID
default:
return nil
}
}
var count: Int {
switch self {
case .loaded(let state):
return state.pinnedEventContents.count
case .loading(let numberOfEvents):
return numberOfEvents
}
}
var selectedPinIndex: Int {
switch self {
case .loaded(let state):
return state.selectedPinIndex
case .loading(let numbersOfEvents):
// We always want the index to be the last one when loading, since is the default one.
return numbersOfEvents - 1
}
}
var displayedMessage: AttributedString {
switch self {
case .loading:
return AttributedString(L10n.screenRoomPinnedBannerLoadingDescription)
case .loaded(let state):
return state.selectedPinContent
}
}
var bannerIndicatorDescription: AttributedString {
let index = selectedPinIndex + 1
let boldPlaceholder = "{bold}"
var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder))
var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, count))
boldString.bold()
finalString.replace(boldPlaceholder, with: boldString)
return finalString
}
mutating func previousPin() {
switch self {
case .loaded(var state):
state.previousPin()
self = .loaded(state: state)
default:
break
}
}
mutating func setPinnedEventContents(_ pinnedEventContents: OrderedDictionary<String, AttributedString>) {
switch self {
case .loading:
// The default selected event should always be the last one.
self = .loaded(state: .init(pinnedEventContents: pinnedEventContents, selectedPinEventID: pinnedEventContents.keys.last))
case .loaded(var state):
state.pinnedEventContents = pinnedEventContents
self = .loaded(state: state)
}
}
}

View File

@ -1,5 +1,5 @@
//
// Copyright 2022 New Vector Ltd
// Copyright 2024 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@ -14,957 +14,37 @@
// limitations under the License.
//
import Algorithms
import Combine
import OrderedCollections
import Foundation
import SwiftUI
typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, RoomScreenViewAction>
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
private enum Constants {
static let paginationEventLimit: UInt16 = 20
static let detachedTimelineSize: UInt16 = 100
static let focusTimelineToastIndicatorID = "RoomScreenFocusTimelineToastIndicator"
static let toastErrorID = "RoomScreenToastError"
}
private let roomProxy: RoomProxyProtocol
private let timelineController: RoomTimelineControllerProtocol
private let mediaPlayerProvider: MediaPlayerProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let pinnedEventStringBuilder: RoomEventStringBuilder
private let roomScreenInteractionHandler: RoomScreenInteractionHandler
private let composerFocusedSubject = PassthroughSubject<Bool, Never>()
private let actionsSubject: PassthroughSubject<RoomScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<RoomScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private var paginateBackwardsTask: Task<Void, Never>?
private var paginateForwardsTask: Task<Void, Never>?
private var pinnedEventsTimelineProvider: RoomTimelineProviderProtocol? {
didSet {
guard let pinnedEventsTimelineProvider else {
return
}
buildPinnedEventContent(timelineItems: pinnedEventsTimelineProvider.itemProxies)
pinnedEventsTimelineProvider.updatePublisher
// When pinning or unpinning an item, the timeline might return empty for a short while, so we need to debounce it to prevent weird UI behaviours like the banner disappearing
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
.sink { [weak self] updatedItems, _ in
guard let self else { return }
buildPinnedEventContent(timelineItems: updatedItems)
}
.store(in: &cancellables)
}
}
init(roomProxy: RoomProxyProtocol,
focussedEventID: String? = nil,
timelineController: RoomTimelineControllerProtocol,
mediaProvider: MediaProviderProtocol,
mediaPlayerProvider: MediaPlayerProviderProtocol,
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
appMediator: AppMediatorProtocol,
appSettings: AppSettings,
analyticsService: AnalyticsService) {
self.timelineController = timelineController
self.mediaPlayerProvider = mediaPlayerProvider
self.roomProxy = roomProxy
self.appSettings = appSettings
self.analyticsService = analyticsService
self.userIndicatorController = userIndicatorController
self.appMediator = appMediator
pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID)
let voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider)
roomScreenInteractionHandler = RoomScreenInteractionHandler(roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: mediaProvider,
mediaPlayerProvider: mediaPlayerProvider,
voiceMessageMediaManager: voiceMessageMediaManager,
voiceMessageRecorder: voiceMessageRecorder,
userIndicatorController: userIndicatorController,
appMediator: appMediator,
appSettings: appSettings,
analyticsService: analyticsService)
super.init(initialViewState: RoomScreenViewState(roomID: roomProxy.id,
roomTitle: roomProxy.roomTitle,
roomAvatar: roomProxy.avatar,
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
timelineViewState: TimelineViewState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
ownUserID: roomProxy.ownUserID,
isViewSourceEnabled: appSettings.viewSourceEnabled,
hasOngoingCall: roomProxy.hasOngoingCall,
bindings: .init(reactionsCollapsed: [:])),
imageProvider: mediaProvider)
if focussedEventID != nil {
// The timeline controller will start loading a detached timeline.
showFocusLoadingIndicator()
}
setupSubscriptions()
setupDirectRoomSubscriptionsIfNeeded()
// Set initial values for redacting from the macOS context menu.
Task { await updatePermissions() }
state.audioPlayerStateProvider = { [weak self] itemID -> AudioPlayerState? in
guard let self else {
return nil
}
return self.roomScreenInteractionHandler.audioPlayerState(for: itemID)
}
buildTimelineViews(timelineItems: timelineController.timelineItems)
updateMembers(roomProxy.membersPublisher.value)
// Note: beware if we get to e.g. restore a reply / edit,
// maybe we are tracking a non-needed first initial state
trackComposerMode(.default)
Task {
let userID = roomProxy.ownUserID
if case let .success(permission) = await roomProxy.canUserJoinCall(userID: userID) {
state.canJoinCall = permission
}
}
}
// MARK: - Public
func loadDraft() {
actionsSubject.send(.composer(action: .loadDraft))
}
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewItem = nil
}
func saveDraft() {
actionsSubject.send(.composer(action: .saveDraft))
}
override func process(viewAction: RoomScreenViewAction) {
switch viewAction {
case .itemAppeared(let id):
Task { await timelineController.processItemAppearance(id) }
case .itemDisappeared(let id):
Task { await timelineController.processItemDisappearance(id) }
case .itemTapped(let id):
Task { await handleItemTapped(with: id) }
case .itemSendInfoTapped(let itemID):
handleItemSendInfoTapped(itemID: itemID)
case .toggleReaction(let emoji, let itemId):
Task { await timelineController.toggleReaction(emoji, to: itemId) }
case .sendReadReceiptIfNeeded(let lastVisibleItemID):
Task { await sendReadReceiptIfNeeded(for: lastVisibleItemID) }
case .paginateBackwards:
paginateBackwards()
case .paginateForwards:
paginateForwards()
case .scrollToBottom:
scrollToBottom()
case .displayTimelineItemMenu(let itemID):
roomScreenInteractionHandler.displayTimelineItemActionMenu(for: itemID)
case .handleTimelineItemMenuAction(let itemID, let action):
roomScreenInteractionHandler.handleTimelineItemMenuAction(action, itemID: itemID)
case .displayRoomDetails:
actionsSubject.send(.displayRoomDetails)
case .displayRoomMemberDetails(userID: let userID):
Task { await roomScreenInteractionHandler.displayRoomMemberDetails(userID: userID) }
case .displayEmojiPicker(let itemID):
roomScreenInteractionHandler.displayEmojiPicker(for: itemID)
case .displayReactionSummary(let itemID, let key):
displayReactionSummary(for: itemID, selectedKey: key)
case .displayReadReceipts(itemID: let itemID):
displayReadReceipts(for: itemID)
case .displayCall:
actionsSubject.send(.displayCallScreen)
analyticsService.trackInteraction(name: .MobileRoomCallButton)
case .handlePasteOrDrop(let provider):
roomScreenInteractionHandler.handlePasteOrDrop(provider)
case .handlePollAction(let pollAction):
handlePollAction(pollAction)
case .handleAudioPlayerAction(let audioPlayerAction):
handleAudioPlayerAction(audioPlayerAction)
case .focusOnEventID(let eventID):
Task { await focusOnEvent(eventID: eventID) }
case .focusLive:
focusLive()
case .scrolledToFocussedItem:
didScrollToFocussedItem()
case .hasSwitchedTimeline:
Task { state.timelineViewState.isSwitchingTimelines = false }
case let .hasScrolled(direction):
state.lastScrollDirection = direction
case .tappedPinnedEventsBanner:
if let eventID = state.pinnedEventsBannerState.selectedPinEventID {
Task { await focusOnEvent(eventID: eventID) }
}
state.pinnedEventsBannerState.previousPin()
case .viewAllPins:
actionsSubject.send(.displayPinnedEventsTimeline)
}
}
func process(composerAction: ComposerToolbarViewModelAction) {
switch composerAction {
case .sendMessage(let message, let html, let mode, let intentionalMentions):
Task {
await sendCurrentMessage(message,
html: html,
mode: mode,
intentionalMentions: intentionalMentions)
}
case .editLastMessage:
editLastMessage()
case .attach(let attachment):
attach(attachment)
case .handlePasteOrDrop(let provider):
roomScreenInteractionHandler.handlePasteOrDrop(provider)
case .composerModeChanged(mode: let mode):
trackComposerMode(mode)
case .composerFocusedChanged(isFocused: let isFocused):
composerFocusedSubject.send(isFocused)
case .voiceMessage(let voiceMessageAction):
processVoiceMessageAction(voiceMessageAction)
case .contentChanged(let isEmpty):
guard appSettings.sharePresence else {
return
}
Task {
await roomProxy.sendTypingNotification(isTyping: !isEmpty)
}
}
}
func focusOnEvent(eventID: String) async {
if state.timelineViewState.hasLoadedItem(with: eventID) {
state.timelineViewState.focussedEvent = .init(eventID: eventID, appearance: .animated)
return
}
showFocusLoadingIndicator()
defer { hideFocusLoadingIndicator() }
switch await timelineController.focusOnEvent(eventID, timelineSize: Constants.detachedTimelineSize) {
case .success:
state.timelineViewState.focussedEvent = .init(eventID: eventID, appearance: .immediate)
case .failure(let error):
MXLog.error("Failed to focus on event \(eventID)")
if case .eventNotFound = error {
displayErrorToast(L10n.errorMessageNotFound)
} else {
displayErrorToast(L10n.commonFailed)
}
}
}
// MARK: - Private
private func focusLive() {
timelineController.focusLive()
}
private func didScrollToFocussedItem() {
if var focussedEvent = state.timelineViewState.focussedEvent {
focussedEvent.appearance = .hasAppeared
state.timelineViewState.focussedEvent = focussedEvent
hideFocusLoadingIndicator()
}
}
private func editLastMessage() {
guard let item = timelineController.timelineItems.reversed().first(where: {
guard let item = $0 as? EventBasedMessageTimelineItemProtocol else {
return false
}
return item.sender.id == roomProxy.ownUserID && item.isEditable
}) else {
return
}
roomScreenInteractionHandler.handleTimelineItemMenuAction(.edit, itemID: item.id)
}
private func attach(_ attachment: ComposerAttachmentType) {
switch attachment {
case .camera:
actionsSubject.send(.displayCameraPicker)
case .photoLibrary:
actionsSubject.send(.displayMediaPicker)
case .file:
actionsSubject.send(.displayDocumentPicker)
case .location:
actionsSubject.send(.displayLocationPicker)
case .poll:
actionsSubject.send(.displayPollForm(mode: .new))
}
}
private func handlePollAction(_ action: RoomScreenViewPollAction) {
switch action {
case let .selectOption(pollStartID, optionID):
roomScreenInteractionHandler.sendPollResponse(pollStartID: pollStartID, optionID: optionID)
case let .end(pollStartID):
displayAlert(.pollEndConfirmation(pollStartID))
case .edit(let pollStartID, let poll):
actionsSubject.send(.displayPollForm(mode: .edit(eventID: pollStartID, poll: poll)))
}
}
private func handleAudioPlayerAction(_ action: RoomScreenAudioPlayerAction) {
switch action {
case .playPause(let itemID):
Task { await roomScreenInteractionHandler.playPauseAudio(for: itemID) }
case .seek(let itemID, let progress):
Task { await roomScreenInteractionHandler.seekAudio(for: itemID, progress: progress) }
}
}
private func processVoiceMessageAction(_ action: ComposerToolbarVoiceMessageAction) {
switch action {
case .startRecording:
Task {
await mediaPlayerProvider.detachAllStates(except: nil)
await roomScreenInteractionHandler.startRecordingVoiceMessage()
}
case .stopRecording:
Task { await roomScreenInteractionHandler.stopRecordingVoiceMessage() }
case .cancelRecording:
Task { await roomScreenInteractionHandler.cancelRecordingVoiceMessage() }
case .deleteRecording:
Task { await roomScreenInteractionHandler.deleteCurrentVoiceMessage() }
case .send:
Task { await roomScreenInteractionHandler.sendCurrentVoiceMessage() }
case .startPlayback:
Task { await roomScreenInteractionHandler.startPlayingRecordedVoiceMessage() }
case .pausePlayback:
roomScreenInteractionHandler.pausePlayingRecordedVoiceMessage()
case .seekPlayback(let progress):
Task { await roomScreenInteractionHandler.seekRecordedVoiceMessage(to: progress) }
case .scrubPlayback(let scrubbing):
Task { await roomScreenInteractionHandler.scrubVoiceMessagePlayback(scrubbing: scrubbing) }
}
}
private func updateMembers(_ members: [RoomMemberProxyProtocol]) {
state.members = members.reduce(into: [String: RoomMemberState]()) { dictionary, member in
dictionary[member.userID] = RoomMemberState(displayName: member.displayName, avatarURL: member.avatarURL)
}
}
private func updatePermissions() async {
if case let .success(value) = await roomProxy.canUserRedactOther(userID: roomProxy.ownUserID) {
state.canCurrentUserRedactOthers = value
} else {
state.canCurrentUserRedactOthers = false
}
if case let .success(value) = await roomProxy.canUserRedactOwn(userID: roomProxy.ownUserID) {
state.canCurrentUserRedactSelf = value
} else {
state.canCurrentUserRedactSelf = false
}
if state.isPinningEnabled,
case let .success(value) = await roomProxy.canUserPinOrUnpin(userID: roomProxy.ownUserID) {
state.canCurrentUserPin = value
} else {
state.canCurrentUserPin = false
}
}
private func setupSubscriptions() {
timelineController.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self else { return }
switch callback {
case .updatedTimelineItems(let updatedItems, let isSwitchingTimelines):
buildTimelineViews(timelineItems: updatedItems, isSwitchingTimelines: isSwitchingTimelines)
case .paginationState(let paginationState):
if state.timelineViewState.paginationState != paginationState {
state.timelineViewState.paginationState = paginationState
}
case .isLive(let isLive):
if state.timelineViewState.isLive != isLive {
state.timelineViewState.isLive = isLive
// Remove the event highlight *only* when transitioning from non-live to live.
if isLive, state.timelineViewState.focussedEvent != nil {
state.timelineViewState.focussedEvent = nil
}
}
}
}
.store(in: &cancellables)
let roomInfoSubscription = roomProxy
.actionsPublisher
.filter { $0 == .roomInfoUpdate }
roomInfoSubscription
.throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] _ in
guard let self else { return }
state.roomTitle = roomProxy.roomTitle
state.roomAvatar = roomProxy.avatar
state.hasOngoingCall = roomProxy.hasOngoingCall
}
.store(in: &cancellables)
Task { [weak self] in
// Don't guard let self here, otherwise the for await will strongify the self reference creating a strong reference cycle.
// If the subscription has sent a value before the Task has started it might be lost, so before entering the loop we always do an update.
await self?.updatePinnedEventIDs()
for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values {
guard !Task.isCancelled else {
return
}
await self?.updatePinnedEventIDs()
}
}
.store(in: &cancellables)
setupAppSettingsSubscriptions()
roomProxy.membersPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.updateMembers($0) }
.store(in: &cancellables)
roomProxy.typingMembersPublisher
.receive(on: DispatchQueue.main)
.filter { [weak self] _ in self?.appSettings.sharePresence ?? false }
.weakAssign(to: \.state.typingMembers, on: self)
.store(in: &cancellables)
roomScreenInteractionHandler.actions
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
guard let self else { return }
switch action {
case .composer(let action):
actionsSubject.send(.composer(action: action))
case .displayAudioRecorderPermissionError:
displayAlert(.audioRecodingPermissionError)
case .displayErrorToast(let title):
displayErrorToast(title)
case .displayEmojiPicker(let itemID, let selectedEmojis):
actionsSubject.send(.displayEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis))
case .displayMessageForwarding(let itemID):
Task { await self.forwardMessage(itemID: itemID) }
case .displayPollForm(let mode):
actionsSubject.send(.displayPollForm(mode: mode))
case .displayReportContent(let itemID, let senderID):
actionsSubject.send(.displayReportContent(itemID: itemID, senderID: senderID))
case .displayMediaUploadPreviewScreen(let url):
actionsSubject.send(.displayMediaUploadPreviewScreen(url: url))
case .displayRoomMemberDetails(userID: let userID):
actionsSubject.send(.displayRoomMemberDetails(userID: userID))
case .showActionMenu(let actionMenuInfo):
Task {
await self.updatePermissions()
self.state.bindings.actionMenuInfo = actionMenuInfo
}
case .showDebugInfo(let debugInfo):
state.bindings.debugInfo = debugInfo
}
}
.store(in: &cancellables)
appSettings.$pinningEnabled
.combineLatest(appMediator.networkMonitor.reachabilityPublisher)
.filter { $0.0 && $0.1 == .reachable }
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.setupPinnedEventsTimelineProviderIfNeeded()
}
.store(in: &cancellables)
}
private func setupAppSettingsSubscriptions() {
appSettings.$sharePresence
.weakAssign(to: \.state.showReadReceipts, on: self)
.store(in: &cancellables)
appSettings.$viewSourceEnabled
.weakAssign(to: \.state.isViewSourceEnabled, on: self)
.store(in: &cancellables)
appSettings.$pinningEnabled
.weakAssign(to: \.state.isPinningEnabled, on: self)
.store(in: &cancellables)
}
private func setupPinnedEventsTimelineProviderIfNeeded() {
guard pinnedEventsTimelineProvider == nil else {
return
}
Task {
guard let timelineProvider = await roomProxy.pinnedEventsTimeline?.timelineProvider else {
return
}
if pinnedEventsTimelineProvider == nil {
pinnedEventsTimelineProvider = timelineProvider
}
}
}
private func updatePinnedEventIDs() async {
let pinnedEventIDs = await roomProxy.pinnedEventIDs
// Only update the loading state of the banner
if state.pinnedEventsBannerState.isLoading {
state.pinnedEventsBannerState = .loading(numbersOfEvents: pinnedEventIDs.count)
}
state.pinnedEventIDs = pinnedEventIDs
}
private func setupDirectRoomSubscriptionsIfNeeded() {
guard roomProxy.isDirect else {
return
}
let shouldShowInviteAlert = composerFocusedSubject
.removeDuplicates()
.map { [weak self] isFocused in
guard let self else { return false }
return isFocused && self.roomProxy.isUserAloneInDirectRoom
}
// We want to show the alert just once, so we are taking the first "true" emitted
.first { $0 }
shouldShowInviteAlert
.sink { [weak self] _ in
self?.showInviteAlert()
}
.store(in: &cancellables)
}
private func paginateBackwards() {
guard paginateBackwardsTask == nil else {
return
}
paginateBackwardsTask = Task { [weak self] in
guard let self else {
return
}
switch await timelineController.paginateBackwards(requestSize: Constants.paginationEventLimit) {
case .failure:
displayErrorToast(L10n.errorFailedLoadingMessages)
default:
break
}
paginateBackwardsTask = nil
}
}
private func paginateForwards() {
guard paginateForwardsTask == nil else {
return
}
paginateForwardsTask = Task { [weak self] in
guard let self else {
return
}
switch await timelineController.paginateForwards(requestSize: Constants.paginationEventLimit) {
case .failure:
displayErrorToast(L10n.errorFailedLoadingMessages)
default:
break
}
if state.timelineViewState.paginationState.forward == .timelineEndReached {
focusLive()
}
paginateForwardsTask = nil
}
}
private func scrollToBottom() {
if state.timelineViewState.isLive {
state.timelineViewState.scrollToBottomPublisher.send(())
} else {
focusLive()
}
}
private func sendReadReceiptIfNeeded(for lastVisibleItemID: TimelineItemIdentifier) async {
guard appMediator.appState == .active else { return }
await timelineController.sendReadReceipt(for: lastVisibleItemID)
}
private func handleItemTapped(with itemID: TimelineItemIdentifier) async {
state.showLoading = true
let action = await roomScreenInteractionHandler.processItemTap(itemID)
switch action {
case .displayMediaFile(let file, let title):
actionsSubject.send(.composer(action: .removeFocus)) // Hide the keyboard otherwise a big white space is sometimes shown when dismissing the preview.
state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: title)
case .displayLocation(let body, let geoURI, let description):
actionsSubject.send(.displayLocation(body: body, geoURI: geoURI, description: description))
case .none:
break
}
state.showLoading = false
}
private func handleItemSendInfoTapped(itemID: TimelineItemIdentifier) {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else {
MXLog.warning("Couldn't find timeline item.")
return
}
guard let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
fatalError("Only events can have send info.")
}
if eventTimelineItem.properties.deliveryStatus == .sendingFailed {
displayAlert(.sendingFailed)
} else if let authenticityMessage = eventTimelineItem.properties.encryptionAuthenticity?.message {
displayAlert(.encryptionAuthenticity(authenticityMessage))
}
}
private func sendCurrentMessage(_ message: String, html: String?, mode: RoomScreenComposerMode, intentionalMentions: IntentionalMentions) async {
guard !message.isEmpty else {
fatalError("This message should never be empty")
}
actionsSubject.send(.composer(action: .clear))
switch mode {
case .reply(let itemId, _, _):
await timelineController.sendMessage(message,
html: html,
inReplyTo: itemId,
intentionalMentions: intentionalMentions)
case .edit(let originalItemId):
await timelineController.edit(originalItemId,
message: message,
html: html,
intentionalMentions: intentionalMentions)
case .default:
await timelineController.sendMessage(message,
html: html,
intentionalMentions: intentionalMentions)
case .recordVoiceMessage, .previewVoiceMessage:
fatalError("invalid composer mode.")
}
scrollToBottom()
}
private func trackComposerMode(_ mode: RoomScreenComposerMode) {
var isEdit = false
var isReply = false
switch mode {
case .edit:
isEdit = true
case .reply:
isReply = true
default:
break
}
analyticsService.trackComposer(inThread: false, isEditing: isEdit, isReply: isReply, startsThread: nil)
}
// MARK: - Timeline Item Building
private func buildPinnedEventContent(timelineItems: [TimelineItemProxy]) {
var pinnedEventContents = OrderedDictionary<String, AttributedString>()
for item in timelineItems {
// Only remote events are pinned
if case let .event(event) = item,
let eventID = event.id.eventID {
pinnedEventContents.updateValue(pinnedEventStringBuilder.buildAttributedString(for: event) ?? AttributedString(L10n.commonUnsupportedEvent),
forKey: eventID)
}
}
state.pinnedEventsBannerState.setPinnedEventContents(pinnedEventContents)
}
private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) {
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
timelineItems.filter { $0 is RedactedRoomTimelineItem }.forEach { timelineItem in
// Stops the audio player when a voice message is redacted.
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else {
return
}
Task { @MainActor in
playerState.detachAudioPlayer()
mediaPlayerProvider.unregister(audioPlayerState: playerState)
}
}
let itemsGroupedByTimelineDisplayStyle = timelineItems.chunked { current, next in
canGroupItem(timelineItem: current, with: next)
}
for itemGroup in itemsGroupedByTimelineDisplayStyle {
guard !itemGroup.isEmpty else {
MXLog.error("Found empty item group")
continue
}
if itemGroup.count == 1 {
if let firstItem = itemGroup.first {
timelineItemsDictionary.updateValue(updateViewState(item: firstItem, groupStyle: .single),
forKey: firstItem.id.timelineID)
}
} else {
for (index, item) in itemGroup.enumerated() {
if index == 0 {
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .first),
forKey: item.id.timelineID)
} else if index == itemGroup.count - 1 {
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .last),
forKey: item.id.timelineID)
} else {
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .middle),
forKey: item.id.timelineID)
}
}
}
}
if isSwitchingTimelines {
state.timelineViewState.isSwitchingTimelines = true
}
state.timelineViewState.itemsDictionary = timelineItemsDictionary
}
private func updateViewState(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewState {
if let timelineItemViewState = state.timelineViewState.itemsDictionary[item.id.timelineID] {
timelineItemViewState.groupStyle = groupStyle
timelineItemViewState.type = .init(item: item)
return timelineItemViewState
} else {
return RoomTimelineItemViewState(item: item, groupStyle: groupStyle)
}
}
private func canGroupItem(timelineItem: RoomTimelineItemProtocol, with otherTimelineItem: RoomTimelineItemProtocol) -> Bool {
if timelineItem is CollapsibleTimelineItem || otherTimelineItem is CollapsibleTimelineItem {
return false
}
guard let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol,
let otherEventTimelineItem = otherTimelineItem as? EventBasedTimelineItemProtocol else {
return false
}
// State events aren't rendered as messages so shouldn't be grouped.
if eventTimelineItem is StateRoomTimelineItem || otherEventTimelineItem is StateRoomTimelineItem {
return false
}
// can be improved by adding a date threshold
return eventTimelineItem.properties.reactions.isEmpty && eventTimelineItem.sender == otherEventTimelineItem.sender
}
// MARK: - Direct chats logics
private func showInviteAlert() {
userIndicatorController.alertInfo = .init(id: .init(),
title: L10n.screenRoomInviteAgainAlertTitle,
message: L10n.screenRoomInviteAgainAlertMessage,
primaryButton: .init(title: L10n.actionInvite, action: { [weak self] in self?.inviteOtherDMUserBack() }),
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
private let inviteLoadingIndicatorID = UUID().uuidString
private func inviteOtherDMUserBack() {
guard roomProxy.isUserAloneInDirectRoom else {
userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError)
return
}
Task {
userIndicatorController.submitIndicator(.init(id: inviteLoadingIndicatorID, type: .toast, title: L10n.commonLoading))
defer {
userIndicatorController.retractIndicatorWithId(inviteLoadingIndicatorID)
}
guard
let members = await roomProxy.members(),
members.count == 2,
let otherPerson = members.first(where: { $0.userID != roomProxy.ownUserID && $0.membership == .leave })
else {
userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError)
return
}
switch await roomProxy.invite(userID: otherPerson.userID) {
case .success:
break
case .failure:
userIndicatorController.alertInfo = .init(id: .init(),
title: L10n.commonUnableToInviteTitle,
message: L10n.commonUnableToInviteMessage)
}
}
}
// MARK: - Reactions
private func displayReactionSummary(for itemID: TimelineItemIdentifier, selectedKey: String) {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
return
}
state.bindings.reactionSummaryInfo = .init(reactions: eventTimelineItem.properties.reactions, selectedKey: selectedKey)
}
// MARK: - Read Receipts
private func displayReadReceipts(for itemID: TimelineItemIdentifier) {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
return
}
state.bindings.readReceiptsSummaryInfo = .init(orderedReceipts: eventTimelineItem.properties.orderedReadReceipts, id: eventTimelineItem.id)
}
// MARK: - Message forwarding
private func forwardMessage(itemID: TimelineItemIdentifier) async {
guard let content = await timelineController.messageEventContent(for: itemID) else { return }
actionsSubject.send(.displayMessageForwarding(forwardingItem: .init(id: itemID, roomID: roomProxy.id, content: content)))
}
// MARK: - User Indicators
private func showFocusLoadingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Constants.focusTimelineToastIndicatorID,
type: .toast(progress: .indeterminate),
title: L10n.commonLoading,
persistent: true))
}
private func hideFocusLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Constants.focusTimelineToastIndicatorID)
}
private func displayAlert(_ type: RoomScreenAlertInfoType) {
switch type {
case .audioRecodingPermissionError:
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() }),
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.roomScreenInteractionHandler.endPoll(pollStartID: pollStartID) }))
case .sendingFailed:
state.bindings.alertInfo = .init(id: type,
title: L10n.commonSendingFailed,
primaryButton: .init(title: L10n.actionOk, action: nil))
case .encryptionAuthenticity(let message):
state.bindings.alertInfo = .init(id: type,
title: message,
primaryButton: .init(title: L10n.actionOk, action: nil))
}
}
private func displayErrorToast(_ title: String) {
userIndicatorController.submitIndicator(UserIndicator(id: Constants.toastErrorID,
type: .toast,
title: title,
iconName: "xmark"))
init() {
super.init(initialViewState: .init(bindings: .init()))
}
}
private extension RoomProxyProtocol {
/// Checks if the other person left the room in a direct chat
var isUserAloneInDirectRoom: Bool {
isDirect && activeMembersCount == 1
}
}
// MARK: - Mocks
extension RoomScreenViewModel {
static let mock = RoomScreenViewModel(roomProxy: RoomProxyMock(.init(name: "Preview room")),
focussedEventID: nil,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
static func mock() -> RoomScreenViewModel {
RoomScreenViewModel()
}
}
private struct RoomContextKey: EnvironmentKey {
@MainActor static let defaultValue: RoomScreenViewModel.Context? = nil
}
private struct FocussedEventID: EnvironmentKey {
static let defaultValue: String? = nil
}
extension EnvironmentValues {
/// Used to access and inject the room context without observing it
var roomContext: RoomScreenViewModel.Context? {
get { self[RoomContextKey.self] }
set { self[RoomContextKey.self] = newValue }
}
/// An event ID which will be non-nil when a timeline item should show as focussed.
var focussedEventID: String? {
get { self[FocussedEventID.self] }
set { self[FocussedEventID.self] = newValue }
}
}

View File

@ -1,5 +1,5 @@
//
// Copyright 2022 New Vector Ltd
// Copyright 2024 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
@ -16,16 +16,8 @@
import Combine
import Foundation
import SwiftUI
@MainActor
protocol RoomScreenViewModelProtocol {
var actions: AnyPublisher<RoomScreenViewModelAction, Never> { get }
var context: RoomScreenViewModelType.Context { get }
func process(composerAction: ComposerToolbarViewModelAction)
/// Updates the timeline to show and highlight the item with the corresponding event ID.
func focusOnEvent(eventID: String) async
func stop()
func loadDraft()
func saveDraft()
var context: RoomScreenViewModel.Context { get }
}

View File

@ -19,13 +19,17 @@ import SwiftUI
import WysiwygComposer
struct RoomScreen: View {
@ObservedObject var context: RoomScreenViewModel.Context
@ObservedObject var roomContext: RoomScreenViewModel.Context
@ObservedObject var timelineContext: TimelineViewModel.Context
@ObservedObject private var composerToolbarContext: ComposerToolbarViewModel.Context
@State private var dragOver = false
let composerToolbar: ComposerToolbar
init(context: RoomScreenViewModel.Context, composerToolbar: ComposerToolbar) {
self.context = context
init(roomViewModel: RoomScreenViewModelProtocol,
timelineViewModel: TimelineViewModelProtocol,
composerToolbar: ComposerToolbar) {
roomContext = roomViewModel.context
timelineContext = timelineViewModel.context
self.composerToolbar = composerToolbar
composerToolbarContext = composerToolbar.context
}
@ -45,16 +49,16 @@ struct RoomScreen: View {
}
.padding(.top, 8)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.environmentObject(context)
.environment(\.roomContext, context)
.environmentObject(timelineContext)
.environment(\.timelineContext, timelineContext)
}
.overlay(alignment: .top) {
Group {
if context.viewState.shouldShowPinnedEventsBanner {
if timelineContext.viewState.shouldShowPinnedEventsBanner {
pinnedItemsBanner
}
}
.animation(.elementDefault, value: context.viewState.shouldShowPinnedEventsBanner)
.animation(.elementDefault, value: timelineContext.viewState.shouldShowPinnedEventsBanner)
}
.navigationTitle(L10n.screenRoomTitle) // Hidden but used for back button text.
.navigationBarTitleDisplayMode(.inline)
@ -62,30 +66,30 @@ struct RoomScreen: View {
.toolbar { toolbar }
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
.overlay { loadingIndicator }
.alert(item: $context.alertInfo)
.sheet(item: $context.debugInfo) { TimelineItemDebugView(info: $0) }
.sheet(item: $context.actionMenuInfo) { info in
.alert(item: $timelineContext.alertInfo)
.sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) }
.sheet(item: $timelineContext.actionMenuInfo) { info in
let actions = TimelineItemMenuActionProvider(timelineItem: info.item,
canCurrentUserRedactSelf: context.viewState.canCurrentUserRedactSelf,
canCurrentUserRedactOthers: context.viewState.canCurrentUserRedactOthers,
canCurrentUserPin: context.viewState.canCurrentUserPin,
pinnedEventIDs: context.viewState.pinnedEventIDs,
isDM: context.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: context.viewState.isViewSourceEnabled).makeActions()
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers,
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled).makeActions()
if let actions {
TimelineItemMenu(item: info.item, actions: actions)
.environmentObject(context)
.environmentObject(timelineContext)
}
}
.sheet(item: $context.reactionSummaryInfo) {
ReactionsSummaryView(reactions: $0.reactions, members: context.viewState.members, imageProvider: context.imageProvider, selectedReactionKey: $0.selectedKey)
.sheet(item: $timelineContext.reactionSummaryInfo) {
ReactionsSummaryView(reactions: $0.reactions, members: timelineContext.viewState.members, imageProvider: timelineContext.imageProvider, selectedReactionKey: $0.selectedKey)
.edgesIgnoringSafeArea([.bottom])
}
.sheet(item: $context.readReceiptsSummaryInfo) {
.sheet(item: $timelineContext.readReceiptsSummaryInfo) {
ReadReceiptsSummaryView(orderedReadReceipts: $0.orderedReceipts)
.environmentObject(context)
.environmentObject(timelineContext)
}
.interactiveQuickLook(item: $context.mediaPreviewItem)
.interactiveQuickLook(item: $timelineContext.mediaPreviewItem)
.track(screen: .Room)
.onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in
guard let provider = providers.first,
@ -93,7 +97,7 @@ struct RoomScreen: View {
return false
}
context.send(viewAction: .handlePasteOrDrop(provider: provider))
timelineContext.send(viewAction: .handlePasteOrDrop(provider: provider))
return true
}
.sentryTrace("\(Self.self)")
@ -101,23 +105,23 @@ struct RoomScreen: View {
private var timeline: some View {
TimelineView()
.id(context.viewState.roomID)
.environmentObject(context)
.environment(\.focussedEventID, context.viewState.timelineViewState.focussedEvent?.eventID)
.id(timelineContext.viewState.roomID)
.environmentObject(timelineContext)
.environment(\.focussedEventID, timelineContext.viewState.timelineViewState.focussedEvent?.eventID)
.overlay(alignment: .bottomTrailing) {
scrollToBottomButton
}
}
private var pinnedItemsBanner: some View {
PinnedItemsBannerView(state: context.viewState.pinnedEventsBannerState,
onMainButtonTap: { context.send(viewAction: .tappedPinnedEventsBanner) },
onViewAllButtonTap: { context.send(viewAction: .viewAllPins) })
PinnedItemsBannerView(state: timelineContext.viewState.pinnedEventsBannerState,
onMainButtonTap: { timelineContext.send(viewAction: .tappedPinnedEventsBanner) },
onViewAllButtonTap: { timelineContext.send(viewAction: .viewAllPins) })
.transition(.move(edge: .top))
}
private var scrollToBottomButton: some View {
Button { context.send(viewAction: .scrollToBottom) } label: {
Button { timelineContext.send(viewAction: .scrollToBottom) } label: {
Image(systemName: "chevron.down")
.font(.compound.bodyLG)
.fontWeight(.semibold)
@ -139,12 +143,12 @@ struct RoomScreen: View {
}
private var isAtBottomAndLive: Bool {
context.isScrolledToBottom && context.viewState.timelineViewState.isLive
timelineContext.isScrolledToBottom && timelineContext.viewState.timelineViewState.isLive
}
@ViewBuilder
private var loadingIndicator: some View {
if context.viewState.showLoading {
if timelineContext.viewState.showLoading {
ProgressView()
.progressViewStyle(.circular)
.tint(.compound.textPrimary)
@ -159,29 +163,29 @@ struct RoomScreen: View {
// .principal + .primaryAction works better than .navigation leading + trailing
// as the latter disables interaction in the action button for rooms with long names
ToolbarItem(placement: .principal) {
RoomHeaderView(roomName: context.viewState.roomTitle,
roomAvatar: context.viewState.roomAvatar,
imageProvider: context.imageProvider)
RoomHeaderView(roomName: timelineContext.viewState.roomTitle,
roomAvatar: timelineContext.viewState.roomAvatar,
imageProvider: timelineContext.imageProvider)
// Using a button stops it from getting truncated in the navigation bar
.contentShape(.rect)
.onTapGesture {
context.send(viewAction: .displayRoomDetails)
timelineContext.send(viewAction: .displayRoomDetails)
}
}
if !ProcessInfo.processInfo.isiOSAppOnMac {
ToolbarItem(placement: .primaryAction) {
callButton
.disabled(context.viewState.canJoinCall == false)
.disabled(timelineContext.viewState.canJoinCall == false)
}
}
}
@ViewBuilder
private var callButton: some View {
if context.viewState.hasOngoingCall {
if timelineContext.viewState.hasOngoingCall {
Button {
context.send(viewAction: .displayCall)
timelineContext.send(viewAction: .displayCall)
} label: {
Label(L10n.actionJoin, icon: \.videoCallSolid)
.labelStyle(.titleAndIcon)
@ -190,7 +194,7 @@ struct RoomScreen: View {
.accessibilityIdentifier(A11yIdentifiers.roomScreen.joinCall)
} else {
Button {
context.send(viewAction: .displayCall)
timelineContext.send(viewAction: .displayCall)
} label: {
CompoundIcon(\.videoCallSolid)
}
@ -206,21 +210,24 @@ struct RoomScreen: View {
// MARK: - Previews
struct RoomScreen_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(.init(id: "stable_id",
name: "Preview room",
hasOngoingCall: true)),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
static let roomViewModel = RoomScreenViewModel.mock()
static let timelineViewModel = TimelineViewModel(roomProxy: RoomProxyMock(.init(id: "stable_id",
name: "Preview room",
hasOngoingCall: true)),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
static var previews: some View {
NavigationStack {
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
RoomScreen(roomViewModel: roomViewModel,
timelineViewModel: timelineViewModel,
composerToolbar: ComposerToolbar.mock())
}
}
}

View File

@ -17,8 +17,8 @@
import Combine
import UIKit
enum RoomScreenInteractionHandlerAction {
case composer(action: RoomScreenComposerAction)
enum TimelineInteractionHandlerAction {
case composer(action: TimelineComposerAction)
case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
case displayReportContent(itemID: TimelineItemIdentifier, senderID: String)
@ -35,7 +35,7 @@ enum RoomScreenInteractionHandlerAction {
}
@MainActor
class RoomScreenInteractionHandler {
class TimelineInteractionHandler {
private let roomProxy: RoomProxyProtocol
private let timelineController: RoomTimelineControllerProtocol
private let mediaProvider: MediaProviderProtocol
@ -48,8 +48,8 @@ class RoomScreenInteractionHandler {
private let analyticsService: AnalyticsService
private let pollInteractionHandler: PollInteractionHandlerProtocol
private let actionsSubject: PassthroughSubject<RoomScreenInteractionHandlerAction, Never> = .init()
var actions: AnyPublisher<RoomScreenInteractionHandlerAction, Never> {
private let actionsSubject: PassthroughSubject<TimelineInteractionHandlerAction, Never> = .init()
var actions: AnyPublisher<TimelineInteractionHandlerAction, Never> {
actionsSubject.eraseToAnyPublisher()
}

View File

@ -0,0 +1,384 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import OrderedCollections
import SwiftUI
enum TimelineViewModelAction {
case displayRoomDetails
case displayEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
case displayReportContent(itemID: TimelineItemIdentifier, senderID: String)
case displayCameraPicker
case displayMediaPicker
case displayDocumentPicker
case displayLocationPicker
case displayPollForm(mode: PollFormMode)
case displayMediaUploadPreviewScreen(url: URL)
case displayRoomMemberDetails(userID: String)
case displayMessageForwarding(forwardingItem: MessageForwardingItem)
case displayLocation(body: String, geoURI: GeoURI, description: String?)
case composer(action: TimelineComposerAction)
case displayCallScreen
case displayPinnedEventsTimeline
}
enum TimelineViewPollAction {
case selectOption(pollStartID: String, optionID: String)
case end(pollStartID: String)
case edit(pollStartID: String, poll: Poll)
}
enum TimelineAudioPlayerAction {
case playPause(itemID: TimelineItemIdentifier)
case seek(itemID: TimelineItemIdentifier, progress: Double)
}
enum TimelineViewAction {
case itemAppeared(itemID: TimelineItemIdentifier) // t
case itemDisappeared(itemID: TimelineItemIdentifier) // t
case itemTapped(itemID: TimelineItemIdentifier) // t
case itemSendInfoTapped(itemID: TimelineItemIdentifier) // t
case toggleReaction(key: String, itemID: TimelineItemIdentifier) // t
case sendReadReceiptIfNeeded(TimelineItemIdentifier) // t
case paginateBackwards // t
case paginateForwards // t
case scrollToBottom // t
case displayTimelineItemMenu(itemID: TimelineItemIdentifier) // t
case handleTimelineItemMenuAction(itemID: TimelineItemIdentifier, action: TimelineItemMenuAction) // not t
case displayRoomDetails // not t
case displayRoomMemberDetails(userID: String) // t -> change name
case displayReactionSummary(itemID: TimelineItemIdentifier, key: String) // t -> handle externally
case displayEmojiPicker(itemID: TimelineItemIdentifier) // t -> handle externally
case displayReadReceipts(itemID: TimelineItemIdentifier) // t -> handle externally
case displayCall // not t
case handlePasteOrDrop(provider: NSItemProvider) // not t
case handlePollAction(TimelineViewPollAction) // t
case handleAudioPlayerAction(TimelineAudioPlayerAction) // t
/// Focus the timeline onto the specified event ID (switching to a detached timeline if needed).
case focusOnEventID(String) // t
/// Switch back to a live timeline (from a detached one).
case focusLive // t
/// The timeline scrolled to reveal the focussed item.
case scrolledToFocussedItem // t
/// The table view has loaded the first items for a new timeline.
case hasSwitchedTimeline // t
case hasScrolled(direction: ScrollDirection) // t
case tappedPinnedEventsBanner // not t
case viewAllPins // not t
}
enum TimelineComposerAction {
case setMode(mode: ComposerMode)
case setText(plainText: String, htmlText: String?)
case removeFocus
case clear
}
struct TimelineViewState: BindableState {
var roomID: String
var roomTitle = ""
var roomAvatar: RoomAvatar
var members: [String: RoomMemberState] = [:]
var typingMembers: [String] = []
var showLoading = false
var showReadReceipts = false
var isEncryptedOneToOneRoom = false
var timelineViewState: TimelineState // check the doc before changing this
var ownUserID: String
var canCurrentUserRedactOthers = false
var canCurrentUserRedactSelf = false
var canCurrentUserPin = false
var isViewSourceEnabled: Bool
var isPinningEnabled = false
var lastScrollDirection: ScrollDirection?
// The `pinnedEventIDs` are used only to determine if an item is already pinned or not.
// It's updated from the room info, so it's faster than using the timeline
var pinnedEventIDs: Set<String> = []
// This is used to control the banner
var pinnedEventsBannerState: PinnedEventsBannerState = .loading(numbersOfEvents: 0)
var shouldShowPinnedEventsBanner: Bool {
isPinningEnabled && !pinnedEventsBannerState.isEmpty && lastScrollDirection != .top
}
var canJoinCall = false
var hasOngoingCall = false
var bindings: TimelineViewStateBindings
/// A closure providing the associated audio player state for an item in the timeline.
var audioPlayerStateProvider: (@MainActor (_ itemId: TimelineItemIdentifier) -> AudioPlayerState?)?
}
struct TimelineViewStateBindings {
var isScrolledToBottom = true
/// The state of wether reactions listed on the timeline are expanded/collapsed.
/// Key is itemID, value is the collapsed state.
var reactionsCollapsed: [TimelineItemIdentifier: Bool]
/// A media item that will be previewed with QuickLook.
var mediaPreviewItem: MediaPreviewItem?
var alertInfo: AlertInfo<RoomScreenAlertInfoType>?
var debugInfo: TimelineItemDebugInfo?
var actionMenuInfo: TimelineItemActionMenuInfo?
var reactionSummaryInfo: ReactionSummaryInfo?
var readReceiptsSummaryInfo: ReadReceiptSummaryInfo?
}
struct TimelineItemActionMenuInfo: Equatable, Identifiable {
static func == (lhs: TimelineItemActionMenuInfo, rhs: TimelineItemActionMenuInfo) -> Bool {
lhs.id == rhs.id
}
let item: EventBasedTimelineItemProtocol
var id: TimelineItemIdentifier {
item.id
}
}
struct ReactionSummaryInfo: Identifiable {
let reactions: [AggregatedReaction]
let selectedKey: String
var id: String {
selectedKey
}
}
struct ReadReceiptSummaryInfo: Identifiable {
let orderedReceipts: [ReadReceipt]
let id: TimelineItemIdentifier
}
enum RoomScreenAlertInfoType: Hashable {
case audioRecodingPermissionError
case pollEndConfirmation(String)
case sendingFailed
case encryptionAuthenticity(String)
}
struct RoomMemberState {
let displayName: String?
let avatarURL: URL?
}
/// Used as the state for the TimelineView, to avoid having the context continuously refresh the list of items on each small change.
/// Is also nice to have this as a wrapper for any state that is directly connected to the timeline.
struct TimelineState {
var isLive = true
var paginationState = PaginationState.initial
/// The room is in the process of loading items from a new timeline (switching to/from a detached timeline).
var isSwitchingTimelines = false
struct FocussedEvent: Equatable {
enum Appearance {
/// The event should be shown using an animated scroll.
case animated
/// The event should be shown immediately, without any animation.
case immediate
/// The event has already been shown.
case hasAppeared
}
/// The ID of the event.
let eventID: String
/// How the event should be shown, or whether it has already appeared.
var appearance: Appearance
}
/// A focussed event that was navigated to via a permalink.
var focussedEvent: FocussedEvent?
// These can be removed when we have full swiftUI and moved as @State values in the view
var scrollToBottomPublisher = PassthroughSubject<Void, Never>()
var itemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
var timelineIDs: [String] {
itemsDictionary.keys.elements
}
var itemViewStates: [RoomTimelineItemViewState] {
itemsDictionary.values.elements
}
func hasLoadedItem(with eventID: String) -> Bool {
itemViewStates.contains { $0.identifier.eventID == eventID }
}
}
enum ScrollDirection: Equatable {
case top
case bottom
}
struct PinnedEventsState: Equatable {
var pinnedEventContents: OrderedDictionary<String, AttributedString> = [:] {
didSet {
if selectedPinEventID == nil, !pinnedEventContents.keys.isEmpty {
// The default selected event should always be the last one.
selectedPinEventID = pinnedEventContents.keys.last
} else if pinnedEventContents.isEmpty {
selectedPinEventID = nil
} else if let selectedPinEventID, !pinnedEventContents.keys.set.contains(selectedPinEventID) {
self.selectedPinEventID = pinnedEventContents.keys.last
}
}
}
private(set) var selectedPinEventID: String?
var selectedPinIndex: Int {
let defaultValue = pinnedEventContents.isEmpty ? 0 : pinnedEventContents.count - 1
guard let selectedPinEventID else {
return defaultValue
}
return pinnedEventContents.keys.firstIndex(of: selectedPinEventID) ?? defaultValue
}
var selectedPinContent: AttributedString {
var content = AttributedString(" ")
if let selectedPinEventID,
let pinnedEventContent = pinnedEventContents[selectedPinEventID] {
content = pinnedEventContent
}
content.font = .compound.bodyMD
content.link = nil
return content
}
mutating func previousPin() {
guard !pinnedEventContents.isEmpty else {
return
}
let currentIndex = selectedPinIndex
let nextIndex = currentIndex - 1
if nextIndex == -1 {
selectedPinEventID = pinnedEventContents.keys.last
} else {
selectedPinEventID = pinnedEventContents.keys[nextIndex % pinnedEventContents.count]
}
}
}
enum PinnedEventsBannerState: Equatable {
case loading(numbersOfEvents: Int)
case loaded(state: PinnedEventsState)
var isEmpty: Bool {
switch self {
case .loaded(let state):
return state.pinnedEventContents.isEmpty
case .loading(let numberOfEvents):
return numberOfEvents == 0
}
}
var isLoading: Bool {
switch self {
case .loading:
return true
default:
return false
}
}
var selectedPinEventID: String? {
switch self {
case .loaded(let state):
return state.selectedPinEventID
default:
return nil
}
}
var count: Int {
switch self {
case .loaded(let state):
return state.pinnedEventContents.count
case .loading(let numberOfEvents):
return numberOfEvents
}
}
var selectedPinIndex: Int {
switch self {
case .loaded(let state):
return state.selectedPinIndex
case .loading(let numbersOfEvents):
// We always want the index to be the last one when loading, since is the default one.
return numbersOfEvents - 1
}
}
var displayedMessage: AttributedString {
switch self {
case .loading:
return AttributedString(L10n.screenRoomPinnedBannerLoadingDescription)
case .loaded(let state):
return state.selectedPinContent
}
}
var bannerIndicatorDescription: AttributedString {
let index = selectedPinIndex + 1
let boldPlaceholder = "{bold}"
var finalString = AttributedString(L10n.screenRoomPinnedBannerIndicatorDescription(boldPlaceholder))
var boldString = AttributedString(L10n.screenRoomPinnedBannerIndicator(index, count))
boldString.bold()
finalString.replace(boldPlaceholder, with: boldString)
return finalString
}
mutating func previousPin() {
switch self {
case .loaded(var state):
state.previousPin()
self = .loaded(state: state)
default:
break
}
}
mutating func setPinnedEventContents(_ pinnedEventContents: OrderedDictionary<String, AttributedString>) {
switch self {
case .loading:
// The default selected event should always be the last one.
self = .loaded(state: .init(pinnedEventContents: pinnedEventContents, selectedPinEventID: pinnedEventContents.keys.last))
case .loaded(var state):
state.pinnedEventContents = pinnedEventContents
self = .loaded(state: state)
}
}
}

View File

@ -127,7 +127,7 @@ class TimelineTableViewController: UIViewController {
var isSwitchingTimelines = false
/// The focussed event if navigating to an event permalink within the room.
var focussedEvent: TimelineViewState.FocussedEvent? {
var focussedEvent: TimelineState.FocussedEvent? {
didSet {
guard let focussedEvent, focussedEvent.appearance != .hasAppeared else { return }
scrollToItem(eventID: focussedEvent.eventID, animated: focussedEvent.appearance == .animated)
@ -283,7 +283,7 @@ class TimelineTableViewController: UIViewController {
.id(id)
.frame(maxWidth: .infinity, alignment: .leading)
.environmentObject(coordinator.context) // Attempted fix at a crash in TimelineItemContextMenu
.environment(\.roomContext, coordinator.context)
.environment(\.timelineContext, coordinator.context)
}
.margins(.all, 0) // Margins are handled in the stylers
.minSize(height: 1)

View File

@ -0,0 +1,962 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Algorithms
import Combine
import OrderedCollections
import SwiftUI
typealias TimelineViewModelType = StateStoreViewModel<TimelineViewState, TimelineViewAction>
class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
private enum Constants {
static let paginationEventLimit: UInt16 = 20
static let detachedTimelineSize: UInt16 = 100
static let focusTimelineToastIndicatorID = "RoomScreenFocusTimelineToastIndicator"
static let toastErrorID = "RoomScreenToastError"
}
private let roomProxy: RoomProxyProtocol
private let timelineController: RoomTimelineControllerProtocol
private let mediaPlayerProvider: MediaPlayerProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let pinnedEventStringBuilder: RoomEventStringBuilder
private let timelineInteractionHandler: TimelineInteractionHandler
private let composerFocusedSubject = PassthroughSubject<Bool, Never>()
private let actionsSubject: PassthroughSubject<TimelineViewModelAction, Never> = .init()
var actions: AnyPublisher<TimelineViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private var paginateBackwardsTask: Task<Void, Never>?
private var paginateForwardsTask: Task<Void, Never>?
private var pinnedEventsTimelineProvider: RoomTimelineProviderProtocol? {
didSet {
guard let pinnedEventsTimelineProvider else {
return
}
buildPinnedEventContent(timelineItems: pinnedEventsTimelineProvider.itemProxies)
pinnedEventsTimelineProvider.updatePublisher
// When pinning or unpinning an item, the timeline might return empty for a short while, so we need to debounce it to prevent weird UI behaviours like the banner disappearing
.debounce(for: .milliseconds(100), scheduler: DispatchQueue.main)
.sink { [weak self] updatedItems, _ in
guard let self else { return }
buildPinnedEventContent(timelineItems: updatedItems)
}
.store(in: &cancellables)
}
}
init(roomProxy: RoomProxyProtocol,
focussedEventID: String? = nil,
timelineController: RoomTimelineControllerProtocol,
mediaProvider: MediaProviderProtocol,
mediaPlayerProvider: MediaPlayerProviderProtocol,
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
appMediator: AppMediatorProtocol,
appSettings: AppSettings,
analyticsService: AnalyticsService) {
self.timelineController = timelineController
self.mediaPlayerProvider = mediaPlayerProvider
self.roomProxy = roomProxy
self.appSettings = appSettings
self.analyticsService = analyticsService
self.userIndicatorController = userIndicatorController
self.appMediator = appMediator
pinnedEventStringBuilder = .pinnedEventStringBuilder(userID: roomProxy.ownUserID)
let voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider)
timelineInteractionHandler = TimelineInteractionHandler(roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: mediaProvider,
mediaPlayerProvider: mediaPlayerProvider,
voiceMessageMediaManager: voiceMessageMediaManager,
voiceMessageRecorder: voiceMessageRecorder,
userIndicatorController: userIndicatorController,
appMediator: appMediator,
appSettings: appSettings,
analyticsService: analyticsService)
super.init(initialViewState: TimelineViewState(roomID: roomProxy.id,
roomTitle: roomProxy.roomTitle,
roomAvatar: roomProxy.avatar,
isEncryptedOneToOneRoom: roomProxy.isEncryptedOneToOneRoom,
timelineViewState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
ownUserID: roomProxy.ownUserID,
isViewSourceEnabled: appSettings.viewSourceEnabled,
hasOngoingCall: roomProxy.hasOngoingCall,
bindings: .init(reactionsCollapsed: [:])),
imageProvider: mediaProvider)
if focussedEventID != nil {
// The timeline controller will start loading a detached timeline.
showFocusLoadingIndicator()
}
setupSubscriptions()
setupDirectRoomSubscriptionsIfNeeded()
// Set initial values for redacting from the macOS context menu.
Task { await updatePermissions() }
state.audioPlayerStateProvider = { [weak self] itemID -> AudioPlayerState? in
guard let self else {
return nil
}
return self.timelineInteractionHandler.audioPlayerState(for: itemID)
}
buildTimelineViews(timelineItems: timelineController.timelineItems)
updateMembers(roomProxy.membersPublisher.value)
// Note: beware if we get to e.g. restore a reply / edit,
// maybe we are tracking a non-needed first initial state
trackComposerMode(.default)
Task {
let userID = roomProxy.ownUserID
if case let .success(permission) = await roomProxy.canUserJoinCall(userID: userID) {
state.canJoinCall = permission
}
}
}
// MARK: - Public
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewItem = nil
}
override func process(viewAction: TimelineViewAction) {
switch viewAction {
case .itemAppeared(let id):
Task { await timelineController.processItemAppearance(id) }
case .itemDisappeared(let id):
Task { await timelineController.processItemDisappearance(id) }
case .itemTapped(let id):
Task { await handleItemTapped(with: id) }
case .itemSendInfoTapped(let itemID):
handleItemSendInfoTapped(itemID: itemID)
case .toggleReaction(let emoji, let itemId):
Task { await timelineController.toggleReaction(emoji, to: itemId) }
case .sendReadReceiptIfNeeded(let lastVisibleItemID):
Task { await sendReadReceiptIfNeeded(for: lastVisibleItemID) }
case .paginateBackwards:
paginateBackwards()
case .paginateForwards:
paginateForwards()
case .scrollToBottom:
scrollToBottom()
case .displayTimelineItemMenu(let itemID):
timelineInteractionHandler.displayTimelineItemActionMenu(for: itemID)
case .handleTimelineItemMenuAction(let itemID, let action):
timelineInteractionHandler.handleTimelineItemMenuAction(action, itemID: itemID)
case .displayRoomDetails:
actionsSubject.send(.displayRoomDetails)
case .displayRoomMemberDetails(userID: let userID):
Task { await timelineInteractionHandler.displayRoomMemberDetails(userID: userID) }
case .displayEmojiPicker(let itemID):
timelineInteractionHandler.displayEmojiPicker(for: itemID)
case .displayReactionSummary(let itemID, let key):
displayReactionSummary(for: itemID, selectedKey: key)
case .displayReadReceipts(itemID: let itemID):
displayReadReceipts(for: itemID)
case .displayCall:
actionsSubject.send(.displayCallScreen)
analyticsService.trackInteraction(name: .MobileRoomCallButton)
case .handlePasteOrDrop(let provider):
timelineInteractionHandler.handlePasteOrDrop(provider)
case .handlePollAction(let pollAction):
handlePollAction(pollAction)
case .handleAudioPlayerAction(let audioPlayerAction):
handleAudioPlayerAction(audioPlayerAction)
case .focusOnEventID(let eventID):
Task { await focusOnEvent(eventID: eventID) }
case .focusLive:
focusLive()
case .scrolledToFocussedItem:
didScrollToFocussedItem()
case .hasSwitchedTimeline:
Task { state.timelineViewState.isSwitchingTimelines = false }
case let .hasScrolled(direction):
state.lastScrollDirection = direction
case .tappedPinnedEventsBanner:
if let eventID = state.pinnedEventsBannerState.selectedPinEventID {
Task { await focusOnEvent(eventID: eventID) }
}
state.pinnedEventsBannerState.previousPin()
case .viewAllPins:
actionsSubject.send(.displayPinnedEventsTimeline)
}
}
func process(composerAction: ComposerToolbarViewModelAction) {
switch composerAction {
case .sendMessage(let message, let html, let mode, let intentionalMentions):
Task {
await sendCurrentMessage(message,
html: html,
mode: mode,
intentionalMentions: intentionalMentions)
}
case .editLastMessage:
editLastMessage()
case .attach(let attachment):
attach(attachment)
case .handlePasteOrDrop(let provider):
timelineInteractionHandler.handlePasteOrDrop(provider)
case .composerModeChanged(mode: let mode):
trackComposerMode(mode)
case .composerFocusedChanged(isFocused: let isFocused):
composerFocusedSubject.send(isFocused)
case .voiceMessage(let voiceMessageAction):
processVoiceMessageAction(voiceMessageAction)
case .contentChanged(let isEmpty):
guard appSettings.sharePresence else {
return
}
Task {
await roomProxy.sendTypingNotification(isTyping: !isEmpty)
}
}
}
func focusOnEvent(eventID: String) async {
if state.timelineViewState.hasLoadedItem(with: eventID) {
state.timelineViewState.focussedEvent = .init(eventID: eventID, appearance: .animated)
return
}
showFocusLoadingIndicator()
defer { hideFocusLoadingIndicator() }
switch await timelineController.focusOnEvent(eventID, timelineSize: Constants.detachedTimelineSize) {
case .success:
state.timelineViewState.focussedEvent = .init(eventID: eventID, appearance: .immediate)
case .failure(let error):
MXLog.error("Failed to focus on event \(eventID)")
if case .eventNotFound = error {
displayErrorToast(L10n.errorMessageNotFound)
} else {
displayErrorToast(L10n.commonFailed)
}
}
}
// MARK: - Private
private func focusLive() {
timelineController.focusLive()
}
private func didScrollToFocussedItem() {
if var focussedEvent = state.timelineViewState.focussedEvent {
focussedEvent.appearance = .hasAppeared
state.timelineViewState.focussedEvent = focussedEvent
hideFocusLoadingIndicator()
}
}
private func editLastMessage() {
guard let item = timelineController.timelineItems.reversed().first(where: {
guard let item = $0 as? EventBasedMessageTimelineItemProtocol else {
return false
}
return item.sender.id == roomProxy.ownUserID && item.isEditable
}) else {
return
}
timelineInteractionHandler.handleTimelineItemMenuAction(.edit, itemID: item.id)
}
private func attach(_ attachment: ComposerAttachmentType) {
switch attachment {
case .camera:
actionsSubject.send(.displayCameraPicker)
case .photoLibrary:
actionsSubject.send(.displayMediaPicker)
case .file:
actionsSubject.send(.displayDocumentPicker)
case .location:
actionsSubject.send(.displayLocationPicker)
case .poll:
actionsSubject.send(.displayPollForm(mode: .new))
}
}
private func handlePollAction(_ action: TimelineViewPollAction) {
switch action {
case let .selectOption(pollStartID, optionID):
timelineInteractionHandler.sendPollResponse(pollStartID: pollStartID, optionID: optionID)
case let .end(pollStartID):
displayAlert(.pollEndConfirmation(pollStartID))
case .edit(let pollStartID, let poll):
actionsSubject.send(.displayPollForm(mode: .edit(eventID: pollStartID, poll: poll)))
}
}
private func handleAudioPlayerAction(_ action: TimelineAudioPlayerAction) {
switch action {
case .playPause(let itemID):
Task { await timelineInteractionHandler.playPauseAudio(for: itemID) }
case .seek(let itemID, let progress):
Task { await timelineInteractionHandler.seekAudio(for: itemID, progress: progress) }
}
}
private func processVoiceMessageAction(_ action: ComposerToolbarVoiceMessageAction) {
switch action {
case .startRecording:
Task {
await mediaPlayerProvider.detachAllStates(except: nil)
await timelineInteractionHandler.startRecordingVoiceMessage()
}
case .stopRecording:
Task { await timelineInteractionHandler.stopRecordingVoiceMessage() }
case .cancelRecording:
Task { await timelineInteractionHandler.cancelRecordingVoiceMessage() }
case .deleteRecording:
Task { await timelineInteractionHandler.deleteCurrentVoiceMessage() }
case .send:
Task { await timelineInteractionHandler.sendCurrentVoiceMessage() }
case .startPlayback:
Task { await timelineInteractionHandler.startPlayingRecordedVoiceMessage() }
case .pausePlayback:
timelineInteractionHandler.pausePlayingRecordedVoiceMessage()
case .seekPlayback(let progress):
Task { await timelineInteractionHandler.seekRecordedVoiceMessage(to: progress) }
case .scrubPlayback(let scrubbing):
Task { await timelineInteractionHandler.scrubVoiceMessagePlayback(scrubbing: scrubbing) }
}
}
private func updateMembers(_ members: [RoomMemberProxyProtocol]) {
state.members = members.reduce(into: [String: RoomMemberState]()) { dictionary, member in
dictionary[member.userID] = RoomMemberState(displayName: member.displayName, avatarURL: member.avatarURL)
}
}
private func updatePermissions() async {
if case let .success(value) = await roomProxy.canUserRedactOther(userID: roomProxy.ownUserID) {
state.canCurrentUserRedactOthers = value
} else {
state.canCurrentUserRedactOthers = false
}
if case let .success(value) = await roomProxy.canUserRedactOwn(userID: roomProxy.ownUserID) {
state.canCurrentUserRedactSelf = value
} else {
state.canCurrentUserRedactSelf = false
}
if state.isPinningEnabled,
case let .success(value) = await roomProxy.canUserPinOrUnpin(userID: roomProxy.ownUserID) {
state.canCurrentUserPin = value
} else {
state.canCurrentUserPin = false
}
}
private func setupSubscriptions() {
timelineController.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self else { return }
switch callback {
case .updatedTimelineItems(let updatedItems, let isSwitchingTimelines):
buildTimelineViews(timelineItems: updatedItems, isSwitchingTimelines: isSwitchingTimelines)
case .paginationState(let paginationState):
if state.timelineViewState.paginationState != paginationState {
state.timelineViewState.paginationState = paginationState
}
case .isLive(let isLive):
if state.timelineViewState.isLive != isLive {
state.timelineViewState.isLive = isLive
// Remove the event highlight *only* when transitioning from non-live to live.
if isLive, state.timelineViewState.focussedEvent != nil {
state.timelineViewState.focussedEvent = nil
}
}
}
}
.store(in: &cancellables)
let roomInfoSubscription = roomProxy
.actionsPublisher
.filter { $0 == .roomInfoUpdate }
roomInfoSubscription
.throttle(for: .seconds(1), scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] _ in
guard let self else { return }
state.roomTitle = roomProxy.roomTitle
state.roomAvatar = roomProxy.avatar
state.hasOngoingCall = roomProxy.hasOngoingCall
}
.store(in: &cancellables)
Task { [weak self] in
// Don't guard let self here, otherwise the for await will strongify the self reference creating a strong reference cycle.
// If the subscription has sent a value before the Task has started it might be lost, so before entering the loop we always do an update.
await self?.updatePinnedEventIDs()
for await _ in roomInfoSubscription.receive(on: DispatchQueue.main).values {
guard !Task.isCancelled else {
return
}
await self?.updatePinnedEventIDs()
}
}
.store(in: &cancellables)
setupAppSettingsSubscriptions()
roomProxy.membersPublisher
.receive(on: DispatchQueue.main)
.sink { [weak self] in self?.updateMembers($0) }
.store(in: &cancellables)
roomProxy.typingMembersPublisher
.receive(on: DispatchQueue.main)
.filter { [weak self] _ in self?.appSettings.sharePresence ?? false }
.weakAssign(to: \.state.typingMembers, on: self)
.store(in: &cancellables)
timelineInteractionHandler.actions
.receive(on: DispatchQueue.main)
.sink { [weak self] action in
guard let self else { return }
switch action {
case .composer(let action):
actionsSubject.send(.composer(action: action))
case .displayAudioRecorderPermissionError:
displayAlert(.audioRecodingPermissionError)
case .displayErrorToast(let title):
displayErrorToast(title)
case .displayEmojiPicker(let itemID, let selectedEmojis):
actionsSubject.send(.displayEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis))
case .displayMessageForwarding(let itemID):
Task { await self.forwardMessage(itemID: itemID) }
case .displayPollForm(let mode):
actionsSubject.send(.displayPollForm(mode: mode))
case .displayReportContent(let itemID, let senderID):
actionsSubject.send(.displayReportContent(itemID: itemID, senderID: senderID))
case .displayMediaUploadPreviewScreen(let url):
actionsSubject.send(.displayMediaUploadPreviewScreen(url: url))
case .displayRoomMemberDetails(userID: let userID):
actionsSubject.send(.displayRoomMemberDetails(userID: userID))
case .showActionMenu(let actionMenuInfo):
Task {
await self.updatePermissions()
self.state.bindings.actionMenuInfo = actionMenuInfo
}
case .showDebugInfo(let debugInfo):
state.bindings.debugInfo = debugInfo
}
}
.store(in: &cancellables)
appSettings.$pinningEnabled
.combineLatest(appMediator.networkMonitor.reachabilityPublisher)
.filter { $0.0 && $0.1 == .reachable }
.receive(on: DispatchQueue.main)
.sink { [weak self] _ in
self?.setupPinnedEventsTimelineProviderIfNeeded()
}
.store(in: &cancellables)
}
private func setupAppSettingsSubscriptions() {
appSettings.$sharePresence
.weakAssign(to: \.state.showReadReceipts, on: self)
.store(in: &cancellables)
appSettings.$viewSourceEnabled
.weakAssign(to: \.state.isViewSourceEnabled, on: self)
.store(in: &cancellables)
appSettings.$pinningEnabled
.weakAssign(to: \.state.isPinningEnabled, on: self)
.store(in: &cancellables)
}
private func setupPinnedEventsTimelineProviderIfNeeded() {
guard pinnedEventsTimelineProvider == nil else {
return
}
Task {
guard let timelineProvider = await roomProxy.pinnedEventsTimeline?.timelineProvider else {
return
}
if pinnedEventsTimelineProvider == nil {
pinnedEventsTimelineProvider = timelineProvider
}
}
}
private func updatePinnedEventIDs() async {
let pinnedEventIDs = await roomProxy.pinnedEventIDs
// Only update the loading state of the banner
if state.pinnedEventsBannerState.isLoading {
state.pinnedEventsBannerState = .loading(numbersOfEvents: pinnedEventIDs.count)
}
state.pinnedEventIDs = pinnedEventIDs
}
private func setupDirectRoomSubscriptionsIfNeeded() {
guard roomProxy.isDirect else {
return
}
let shouldShowInviteAlert = composerFocusedSubject
.removeDuplicates()
.map { [weak self] isFocused in
guard let self else { return false }
return isFocused && self.roomProxy.isUserAloneInDirectRoom
}
// We want to show the alert just once, so we are taking the first "true" emitted
.first { $0 }
shouldShowInviteAlert
.sink { [weak self] _ in
self?.showInviteAlert()
}
.store(in: &cancellables)
}
private func paginateBackwards() {
guard paginateBackwardsTask == nil else {
return
}
paginateBackwardsTask = Task { [weak self] in
guard let self else {
return
}
switch await timelineController.paginateBackwards(requestSize: Constants.paginationEventLimit) {
case .failure:
displayErrorToast(L10n.errorFailedLoadingMessages)
default:
break
}
paginateBackwardsTask = nil
}
}
private func paginateForwards() {
guard paginateForwardsTask == nil else {
return
}
paginateForwardsTask = Task { [weak self] in
guard let self else {
return
}
switch await timelineController.paginateForwards(requestSize: Constants.paginationEventLimit) {
case .failure:
displayErrorToast(L10n.errorFailedLoadingMessages)
default:
break
}
if state.timelineViewState.paginationState.forward == .timelineEndReached {
focusLive()
}
paginateForwardsTask = nil
}
}
private func scrollToBottom() {
if state.timelineViewState.isLive {
state.timelineViewState.scrollToBottomPublisher.send(())
} else {
focusLive()
}
}
private func sendReadReceiptIfNeeded(for lastVisibleItemID: TimelineItemIdentifier) async {
guard appMediator.appState == .active else { return }
await timelineController.sendReadReceipt(for: lastVisibleItemID)
}
private func handleItemTapped(with itemID: TimelineItemIdentifier) async {
state.showLoading = true
let action = await timelineInteractionHandler.processItemTap(itemID)
switch action {
case .displayMediaFile(let file, let title):
actionsSubject.send(.composer(action: .removeFocus)) // Hide the keyboard otherwise a big white space is sometimes shown when dismissing the preview.
state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: title)
case .displayLocation(let body, let geoURI, let description):
actionsSubject.send(.displayLocation(body: body, geoURI: geoURI, description: description))
case .none:
break
}
state.showLoading = false
}
private func handleItemSendInfoTapped(itemID: TimelineItemIdentifier) {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID) else {
MXLog.warning("Couldn't find timeline item.")
return
}
guard let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
fatalError("Only events can have send info.")
}
if eventTimelineItem.properties.deliveryStatus == .sendingFailed {
displayAlert(.sendingFailed)
} else if let authenticityMessage = eventTimelineItem.properties.encryptionAuthenticity?.message {
displayAlert(.encryptionAuthenticity(authenticityMessage))
}
}
private func sendCurrentMessage(_ message: String, html: String?, mode: ComposerMode, intentionalMentions: IntentionalMentions) async {
guard !message.isEmpty else {
fatalError("This message should never be empty")
}
actionsSubject.send(.composer(action: .clear))
switch mode {
case .reply(let itemId, _, _):
await timelineController.sendMessage(message,
html: html,
inReplyTo: itemId,
intentionalMentions: intentionalMentions)
case .edit(let originalItemId):
await timelineController.edit(originalItemId,
message: message,
html: html,
intentionalMentions: intentionalMentions)
case .default:
await timelineController.sendMessage(message,
html: html,
intentionalMentions: intentionalMentions)
case .recordVoiceMessage, .previewVoiceMessage:
fatalError("invalid composer mode.")
}
scrollToBottom()
}
private func trackComposerMode(_ mode: ComposerMode) {
var isEdit = false
var isReply = false
switch mode {
case .edit:
isEdit = true
case .reply:
isReply = true
default:
break
}
analyticsService.trackComposer(inThread: false, isEditing: isEdit, isReply: isReply, startsThread: nil)
}
// MARK: - Timeline Item Building
private func buildPinnedEventContent(timelineItems: [TimelineItemProxy]) {
var pinnedEventContents = OrderedDictionary<String, AttributedString>()
for item in timelineItems {
// Only remote events are pinned
if case let .event(event) = item,
let eventID = event.id.eventID {
pinnedEventContents.updateValue(pinnedEventStringBuilder.buildAttributedString(for: event) ?? AttributedString(L10n.commonUnsupportedEvent),
forKey: eventID)
}
}
state.pinnedEventsBannerState.setPinnedEventContents(pinnedEventContents)
}
private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) {
var timelineItemsDictionary = OrderedDictionary<String, RoomTimelineItemViewState>()
timelineItems.filter { $0 is RedactedRoomTimelineItem }.forEach { timelineItem in
// Stops the audio player when a voice message is redacted.
guard let playerState = mediaPlayerProvider.playerState(for: .timelineItemIdentifier(timelineItem.id)) else {
return
}
Task { @MainActor in
playerState.detachAudioPlayer()
mediaPlayerProvider.unregister(audioPlayerState: playerState)
}
}
let itemsGroupedByTimelineDisplayStyle = timelineItems.chunked { current, next in
canGroupItem(timelineItem: current, with: next)
}
for itemGroup in itemsGroupedByTimelineDisplayStyle {
guard !itemGroup.isEmpty else {
MXLog.error("Found empty item group")
continue
}
if itemGroup.count == 1 {
if let firstItem = itemGroup.first {
timelineItemsDictionary.updateValue(updateViewState(item: firstItem, groupStyle: .single),
forKey: firstItem.id.timelineID)
}
} else {
for (index, item) in itemGroup.enumerated() {
if index == 0 {
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .first),
forKey: item.id.timelineID)
} else if index == itemGroup.count - 1 {
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .last),
forKey: item.id.timelineID)
} else {
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: .middle),
forKey: item.id.timelineID)
}
}
}
}
if isSwitchingTimelines {
state.timelineViewState.isSwitchingTimelines = true
}
state.timelineViewState.itemsDictionary = timelineItemsDictionary
}
private func updateViewState(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewState {
if let timelineItemViewState = state.timelineViewState.itemsDictionary[item.id.timelineID] {
timelineItemViewState.groupStyle = groupStyle
timelineItemViewState.type = .init(item: item)
return timelineItemViewState
} else {
return RoomTimelineItemViewState(item: item, groupStyle: groupStyle)
}
}
private func canGroupItem(timelineItem: RoomTimelineItemProtocol, with otherTimelineItem: RoomTimelineItemProtocol) -> Bool {
if timelineItem is CollapsibleTimelineItem || otherTimelineItem is CollapsibleTimelineItem {
return false
}
guard let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol,
let otherEventTimelineItem = otherTimelineItem as? EventBasedTimelineItemProtocol else {
return false
}
// State events aren't rendered as messages so shouldn't be grouped.
if eventTimelineItem is StateRoomTimelineItem || otherEventTimelineItem is StateRoomTimelineItem {
return false
}
// can be improved by adding a date threshold
return eventTimelineItem.properties.reactions.isEmpty && eventTimelineItem.sender == otherEventTimelineItem.sender
}
// MARK: - Direct chats logics
private func showInviteAlert() {
userIndicatorController.alertInfo = .init(id: .init(),
title: L10n.screenRoomInviteAgainAlertTitle,
message: L10n.screenRoomInviteAgainAlertMessage,
primaryButton: .init(title: L10n.actionInvite, action: { [weak self] in self?.inviteOtherDMUserBack() }),
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
private let inviteLoadingIndicatorID = UUID().uuidString
private func inviteOtherDMUserBack() {
guard roomProxy.isUserAloneInDirectRoom else {
userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError)
return
}
Task {
userIndicatorController.submitIndicator(.init(id: inviteLoadingIndicatorID, type: .toast, title: L10n.commonLoading))
defer {
userIndicatorController.retractIndicatorWithId(inviteLoadingIndicatorID)
}
guard
let members = await roomProxy.members(),
members.count == 2,
let otherPerson = members.first(where: { $0.userID != roomProxy.ownUserID && $0.membership == .leave })
else {
userIndicatorController.alertInfo = .init(id: .init(), title: L10n.commonError)
return
}
switch await roomProxy.invite(userID: otherPerson.userID) {
case .success:
break
case .failure:
userIndicatorController.alertInfo = .init(id: .init(),
title: L10n.commonUnableToInviteTitle,
message: L10n.commonUnableToInviteMessage)
}
}
}
// MARK: - Reactions
private func displayReactionSummary(for itemID: TimelineItemIdentifier, selectedKey: String) {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
return
}
state.bindings.reactionSummaryInfo = .init(reactions: eventTimelineItem.properties.reactions, selectedKey: selectedKey)
}
// MARK: - Read Receipts
private func displayReadReceipts(for itemID: TimelineItemIdentifier) {
guard let timelineItem = timelineController.timelineItems.firstUsingStableID(itemID),
let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol else {
return
}
state.bindings.readReceiptsSummaryInfo = .init(orderedReceipts: eventTimelineItem.properties.orderedReadReceipts, id: eventTimelineItem.id)
}
// MARK: - Message forwarding
private func forwardMessage(itemID: TimelineItemIdentifier) async {
guard let content = await timelineController.messageEventContent(for: itemID) else { return }
actionsSubject.send(.displayMessageForwarding(forwardingItem: .init(id: itemID, roomID: roomProxy.id, content: content)))
}
// MARK: - User Indicators
private func showFocusLoadingIndicator() {
userIndicatorController.submitIndicator(UserIndicator(id: Constants.focusTimelineToastIndicatorID,
type: .toast(progress: .indeterminate),
title: L10n.commonLoading,
persistent: true))
}
private func hideFocusLoadingIndicator() {
userIndicatorController.retractIndicatorWithId(Constants.focusTimelineToastIndicatorID)
}
private func displayAlert(_ type: RoomScreenAlertInfoType) {
switch type {
case .audioRecodingPermissionError:
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() }),
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) }))
case .sendingFailed:
state.bindings.alertInfo = .init(id: type,
title: L10n.commonSendingFailed,
primaryButton: .init(title: L10n.actionOk, action: nil))
case .encryptionAuthenticity(let message):
state.bindings.alertInfo = .init(id: type,
title: message,
primaryButton: .init(title: L10n.actionOk, action: nil))
}
}
private func displayErrorToast(_ title: String) {
userIndicatorController.submitIndicator(UserIndicator(id: Constants.toastErrorID,
type: .toast,
title: title,
iconName: "xmark"))
}
}
private extension RoomProxyProtocol {
/// Checks if the other person left the room in a direct chat
var isUserAloneInDirectRoom: Bool {
isDirect && activeMembersCount == 1
}
}
// MARK: - Mocks
extension TimelineViewModel {
static let mock = TimelineViewModel(roomProxy: RoomProxyMock(.init(name: "Preview room")),
focussedEventID: nil,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
}
private struct TimelineContextKey: EnvironmentKey {
@MainActor static let defaultValue: TimelineViewModel.Context? = nil
}
private struct FocussedEventID: EnvironmentKey {
static let defaultValue: String? = nil
}
extension EnvironmentValues {
/// Used to access and inject the room context without observing it
var timelineContext: TimelineViewModel.Context? {
get { self[TimelineContextKey.self] }
set { self[TimelineContextKey.self] = newValue }
}
/// An event ID which will be non-nil when a timeline item should show as focussed.
var focussedEventID: String? {
get { self[FocussedEventID.self] }
set { self[FocussedEventID.self] = newValue }
}
}

View File

@ -0,0 +1,29 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import Foundation
import SwiftUI
@MainActor
protocol TimelineViewModelProtocol {
var actions: AnyPublisher<TimelineViewModelAction, Never> { get }
var context: TimelineViewModel.Context { get }
func process(composerAction: ComposerToolbarViewModelAction)
/// Updates the timeline to show and highlight the item with the corresponding event ID.
func focusOnEvent(eventID: String) async
func stop()
}

View File

@ -18,7 +18,7 @@ import Compound
import SwiftUI
struct TimelineItemMenu: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@EnvironmentObject private var context: TimelineViewModel.Context
@Environment(\.dismiss) private var dismiss
@State private var reactionsFrame = CGRect.zero
@ -191,7 +191,7 @@ private extension EncryptionAuthenticity {
// MARK: - Previews
struct TimelineItemMenu_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static let (item, actions) = makeItem()
static let (backupItem, _) = makeItem(authenticity: .notGuaranteed(color: .gray))
static let (unsignedItem, _) = makeItem(authenticity: .unsignedDevice(color: .red))

View File

@ -18,7 +18,7 @@ import SwiftUI
struct ReadReceiptsSummaryView: View {
let orderedReadReceipts: [ReadReceipt]
@EnvironmentObject private var context: RoomScreenViewModel.Context
@EnvironmentObject private var context: TimelineViewModel.Context
var body: some View {
VStack(alignment: .leading, spacing: 16) {
@ -52,15 +52,15 @@ struct ReadReceiptsSummaryView_Previews: PreviewProvider, TestablePreview {
.mockDan
]
let roomProxyMock = RoomProxyMock(.init(name: "Room", members: members))
let mock = RoomScreenViewModel(roomProxy: roomProxyMock,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: UserIndicatorControllerMock(),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
let mock = TimelineViewModel(roomProxy: roomProxyMock,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: UserIndicatorControllerMock(),
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
return mock
}()

View File

@ -122,7 +122,7 @@ struct TimelineReplyView: View {
let cornerRadii: Double
}
@EnvironmentObject private var context: RoomScreenViewModel.Context
@EnvironmentObject private var context: TimelineViewModel.Context
@ScaledMetric private var imageContainerSize = 36.0
let sender: TimelineItemSender
@ -216,7 +216,7 @@ struct TimelineReplyView: View {
}
struct TimelineReplyView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static let attributedStringWithMention = {
var attributedString = AttributedString("To be replaced")

View File

@ -67,7 +67,7 @@ struct LongPressWithFeedback_Previews: PreviewProvider, TestablePreview {
static var previews: some View { Preview() }
struct Preview: View {
private let viewModel = RoomScreenViewModel.mock
private let viewModel = TimelineViewModel.mock
@State private var isPresentingSheet = false
var body: some View {

View File

@ -18,7 +18,7 @@ import Compound
import SwiftUI
struct TimelineItemBubbledStylerView<Content: View>: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@EnvironmentObject private var context: TimelineViewModel.Context
@Environment(\.timelineGroupStyle) private var timelineGroupStyle
@Environment(\.focussedEventID) private var focussedEventID
@ -318,7 +318,7 @@ private extension EdgeInsets {
// MARK: - Previews
struct TimelineItemBubbledStylerView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
mockTimeline

View File

@ -21,7 +21,7 @@ extension View {
/// Adds the send info (timestamp along indicators for edits and delivery/encryption issues) for the given timeline item to this view.
func timelineItemSendInfo(timelineItem: EventBasedTimelineItemProtocol,
adjustedDeliveryStatus: TimelineItemDeliveryStatus?,
context: RoomScreenViewModel.Context) -> some View {
context: TimelineViewModel.Context) -> some View {
modifier(TimelineItemSendInfoModifier(sendInfo: .init(timelineItem: timelineItem,
adjustedDeliveryStatus: adjustedDeliveryStatus),
context: context))
@ -31,7 +31,7 @@ extension View {
/// Adds the send info to a view with the correct layout.
private struct TimelineItemSendInfoModifier: ViewModifier {
let sendInfo: TimelineItemSendInfo
let context: RoomScreenViewModel.Context
let context: TimelineViewModel.Context
var layout: AnyLayout {
switch sendInfo.layoutType {

View File

@ -62,7 +62,7 @@ struct TimelineStyler<Content: View>: View {
}
struct TimelineItemStyler_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static let base = TextRoomTimelineItem(id: .random,
timestamp: "Now",

View File

@ -20,7 +20,7 @@ import SwiftUI
struct TimelineItemStatusView: View {
let timelineItem: EventBasedTimelineItemProtocol
let adjustedDeliveryStatus: TimelineItemDeliveryStatus?
@EnvironmentObject private var context: RoomScreenViewModel.Context
@EnvironmentObject private var context: TimelineViewModel.Context
private var isLastOutgoingMessage: Bool {
timelineItem.isOutgoing && context.viewState.timelineViewState.timelineIDs.last == timelineItem.id.timelineID

View File

@ -22,14 +22,14 @@ struct TimelineReactionsView: View {
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
@Environment(\.layoutDirection) private var layoutDirection: LayoutDirection
let context: RoomScreenViewModel.Context
let context: TimelineViewModel.Context
let itemID: TimelineItemIdentifier
let reactions: [AggregatedReaction]
let isLayoutRTL: Bool
private var collapsed: Binding<Bool>
init(context: RoomScreenViewModel.Context,
init(context: TimelineViewModel.Context,
itemID: TimelineItemIdentifier,
reactions: [AggregatedReaction],
isLayoutRTL: Bool = false) {
@ -204,20 +204,20 @@ struct TimelineReactionAddMoreButtonLabel: View {
struct TimelineReactionViewPreviewsContainer: View {
var body: some View {
VStack(spacing: 8) {
TimelineReactionsView(context: RoomScreenViewModel.mock.context,
TimelineReactionsView(context: TimelineViewModel.mock.context,
itemID: .init(timelineID: "1"),
reactions: [AggregatedReaction.mockReactionWithLongText,
AggregatedReaction.mockReactionWithLongTextRTL])
Divider()
TimelineReactionsView(context: RoomScreenViewModel.mock.context,
TimelineReactionsView(context: TimelineViewModel.mock.context,
itemID: .init(timelineID: "2"),
reactions: Array(AggregatedReaction.mockReactions.prefix(3)))
Divider()
TimelineReactionsView(context: RoomScreenViewModel.mock.context,
TimelineReactionsView(context: TimelineViewModel.mock.context,
itemID: .init(timelineID: "3"),
reactions: AggregatedReaction.mockReactions)
Divider()
TimelineReactionsView(context: RoomScreenViewModel.mock.context,
TimelineReactionsView(context: TimelineViewModel.mock.context,
itemID: .init(timelineID: "4"),
reactions: AggregatedReaction.mockReactions,
isLayoutRTL: true)

View File

@ -19,7 +19,7 @@ import SwiftUI
struct TimelineReadReceiptsView: View {
let displayNumber = 3
let timelineItem: EventBasedTimelineItemProtocol
@EnvironmentObject private var context: RoomScreenViewModel.Context
@EnvironmentObject private var context: TimelineViewModel.Context
var body: some View {
HStack(spacing: 2) {
@ -90,15 +90,15 @@ struct TimelineReadReceiptsView_Previews: PreviewProvider, TestablePreview {
.mockMe
]
static let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(.init(name: "Test", members: members)),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
static let viewModel = TimelineViewModel(roomProxy: RoomProxyMock(.init(name: "Test", members: members)),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
static let singleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now")]
static let doubleReceipt = [ReadReceipt(userID: RoomMemberProxyMock.mockAlice.userID, formattedTimestamp: "Now"),

View File

@ -36,7 +36,7 @@ struct AudioRoomTimelineView: View {
}
struct AudioRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)

View File

@ -32,7 +32,7 @@ struct CallInviteRoomTimelineView: View {
}
struct CallInviteRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)

View File

@ -19,7 +19,7 @@ import Foundation
import SwiftUI
struct CallNotificationRoomTimelineView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@EnvironmentObject private var context: TimelineViewModel.Context
let timelineItem: CallNotificationRoomTimelineItem
@ -62,7 +62,7 @@ struct CallNotificationRoomTimelineView: View {
}
struct CallNotificationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)

View File

@ -32,7 +32,7 @@ struct EmoteRoomTimelineView: View, TextBasedRoomTimelineViewProtocol {
}
struct EmoteRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)

View File

@ -56,7 +56,7 @@ struct RoomTimelineViewLabelStyle: LabelStyle {
}
struct EncryptedRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)

View File

@ -36,7 +36,7 @@ struct FileRoomTimelineView: View {
}
struct FileRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)

View File

@ -224,7 +224,7 @@ private struct PreviewBubbleModifier: ViewModifier {
.padding(8)
.background(Color.compound._bgBubbleOutgoing)
.cornerRadius(12)
.environmentObject(RoomScreenViewModel.mock.context)
.environmentObject(TimelineViewModel.mock.context)
}
}

View File

@ -93,21 +93,24 @@ struct HighlightedTimelineItemModifier_Previews: PreviewProvider, TestablePrevie
/// A preview that allows quick testing of the highlight appearance across various timeline scenarios.
struct HighlightedTimelineItemTimeline_Previews: PreviewProvider {
static let roomViewModel = RoomScreenViewModel.mock()
static let focussedEventID = "RoomTimelineItemFixtures.default.5"
static let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(.init(name: "Preview room")),
focussedEventID: focussedEventID,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
static let timelineViewModel = TimelineViewModel(roomProxy: RoomProxyMock(.init(name: "Preview room")),
focussedEventID: focussedEventID,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
static var previews: some View {
NavigationStack {
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
RoomScreen(roomViewModel: roomViewModel,
timelineViewModel: timelineViewModel,
composerToolbar: ComposerToolbar.mock())
}
.previewDisplayName("Timeline")
}

View File

@ -18,7 +18,7 @@ import Foundation
import SwiftUI
struct ImageRoomTimelineView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@EnvironmentObject private var context: TimelineViewModel.Context
let timelineItem: ImageRoomTimelineItem
var body: some View {
@ -56,7 +56,7 @@ struct ImageRoomTimelineView: View {
}
struct ImageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body

View File

@ -81,7 +81,7 @@ private extension MapLibreStaticMapView {
}
struct LocationRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
ScrollView {

View File

@ -44,7 +44,7 @@ struct NoticeRoomTimelineView: View, TextBasedRoomTimelineViewProtocol {
}
struct NoticeRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)

View File

@ -18,7 +18,7 @@ import SwiftUI
struct PollRoomTimelineView: View {
let timelineItem: PollRoomTimelineItem
@EnvironmentObject private var context: RoomScreenViewModel.Context
@EnvironmentObject private var context: TimelineViewModel.Context
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
@ -50,7 +50,7 @@ struct PollRoomTimelineView: View {
}
struct PollRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
PollRoomTimelineView(timelineItem: .mock(poll: .disclosed(), isOutgoing: false))

View File

@ -36,7 +36,7 @@ struct ReadMarkerRoomTimelineView: View {
}
struct ReadMarkerRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static let item = ReadMarkerRoomTimelineItem(id: .init(timelineID: .init(UUID().uuidString)))

View File

@ -30,7 +30,7 @@ struct RedactedRoomTimelineView: View {
}
struct RedactedRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
VStack(alignment: .leading, spacing: 20.0) {

View File

@ -18,7 +18,7 @@ import Foundation
import SwiftUI
struct StickerRoomTimelineView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@EnvironmentObject private var context: TimelineViewModel.Context
let timelineItem: StickerRoomTimelineItem
var body: some View {
@ -48,7 +48,7 @@ struct StickerRoomTimelineView: View {
}
struct StickerRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)

View File

@ -36,7 +36,7 @@ struct TextRoomTimelineView: View, TextBasedRoomTimelineViewProtocol {
}
struct TextRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)

View File

@ -40,7 +40,7 @@ struct UnsupportedRoomTimelineView: View {
}
struct UnsupportedRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)

View File

@ -18,7 +18,7 @@ import Foundation
import SwiftUI
struct VideoRoomTimelineView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@EnvironmentObject private var context: TimelineViewModel.Context
let timelineItem: VideoRoomTimelineItem
var body: some View {
@ -68,7 +68,7 @@ struct VideoRoomTimelineView: View {
}
struct VideoRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static var previews: some View {
body.environmentObject(viewModel.context)

View File

@ -18,7 +18,7 @@ import Foundation
import SwiftUI
struct TimelineSenderAvatarView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@EnvironmentObject private var context: TimelineViewModel.Context
let timelineItem: EventBasedTimelineItemProtocol

View File

@ -19,7 +19,7 @@ import WysiwygComposer
/// A table view wrapper that displays the timeline of a room.
struct TimelineView: UIViewControllerRepresentable {
@EnvironmentObject private var viewModelContext: RoomScreenViewModel.Context
@EnvironmentObject private var viewModelContext: TimelineViewModel.Context
func makeUIViewController(context: Context) -> TimelineTableViewController {
let tableViewController = TimelineTableViewController(coordinator: context.coordinator,
@ -40,9 +40,9 @@ struct TimelineView: UIViewControllerRepresentable {
@MainActor
class Coordinator {
let context: RoomScreenViewModel.Context
let context: TimelineViewModel.Context
init(viewModelContext: RoomScreenViewModel.Context) {
init(viewModelContext: TimelineViewModel.Context) {
context = viewModelContext
}
@ -70,7 +70,7 @@ struct TimelineView: UIViewControllerRepresentable {
}
}
func send(viewAction: RoomScreenViewAction) {
func send(viewAction: TimelineViewAction) {
context.send(viewAction: viewAction)
}
}
@ -79,20 +79,23 @@ struct TimelineView: UIViewControllerRepresentable {
// MARK: - Previews
struct TimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(.init(id: "stable_id",
name: "Preview room")),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
static let roomViewModel = RoomScreenViewModel.mock()
static let timelineViewModel = TimelineViewModel(roomProxy: RoomProxyMock(.init(id: "stable_id",
name: "Preview room")),
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
static var previews: some View {
NavigationStack {
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
RoomScreen(roomViewModel: roomViewModel,
timelineViewModel: timelineViewModel,
composerToolbar: ComposerToolbar.mock())
}
}
}

View File

@ -26,4 +26,16 @@ struct RoomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol {
timelineItemFactory: timelineItemFactory,
appSettings: ServiceLocator.shared.settings)
}
func buildRoomPinnedTimelineController(roomProxy: RoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol) async -> RoomTimelineControllerProtocol? {
guard let pinnedEventsTimeline = await roomProxy.pinnedEventsTimeline else {
return nil
}
return RoomTimelineController(roomProxy: roomProxy,
timelineProxy: pinnedEventsTimeline,
initialFocussedEventID: nil,
timelineItemFactory: timelineItemFactory,
appSettings: ServiceLocator.shared.settings)
}
}

View File

@ -21,6 +21,8 @@ protocol RoomTimelineControllerFactoryProtocol {
func buildRoomTimelineController(roomProxy: RoomProxyProtocol,
initialFocussedEventID: String?,
timelineItemFactory: RoomTimelineItemFactoryProtocol) -> RoomTimelineControllerProtocol
func buildRoomPinnedTimelineController(roomProxy: RoomProxyProtocol,
timelineItemFactory: RoomTimelineItemFactoryProtocol) async -> RoomTimelineControllerProtocol?
}
// sourcery: AutoMockable

View File

@ -18,7 +18,7 @@ import Foundation
import SwiftUI
struct VoiceMessageRoomTimelineView: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@EnvironmentObject private var context: TimelineViewModel.Context
private let timelineItem: VoiceMessageRoomTimelineItem
private let playerState: AudioPlayerState
@State private var resumePlaybackAfterScrubbing = false
@ -63,7 +63,7 @@ struct VoiceMessageRoomTimelineView: View {
}
struct VoiceMessageRoomTimelineView_Previews: PreviewProvider, TestablePreview {
static let viewModel = RoomScreenViewModel.mock
static let viewModel = TimelineViewModel.mock
static let timelineItemIdentifier = TimelineItemIdentifier.random
static let voiceRoomTimelineItem = VoiceMessageRoomTimelineItem(id: timelineItemIdentifier,
timestamp: "Now",

View File

@ -16,7 +16,7 @@
import SwiftUI
struct RoomTimelineItemView: View {
@Environment(\.roomContext) var context
@Environment(\.timelineContext) var context
@ObservedObject var viewState: RoomTimelineItemViewState
var body: some View {

View File

@ -50,22 +50,22 @@ class ComposerToolbarViewModelTests: XCTestCase {
}
func testComposerFocus() {
viewModel.process(roomAction: .setMode(mode: .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock"))))
viewModel.process(timelineAction: .setMode(mode: .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock"))))
XCTAssertTrue(viewModel.state.bindings.composerFocused)
viewModel.process(roomAction: .removeFocus)
viewModel.process(timelineAction: .removeFocus)
XCTAssertFalse(viewModel.state.bindings.composerFocused)
}
func testComposerMode() {
let mode: RoomScreenComposerMode = .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock"))
viewModel.process(roomAction: .setMode(mode: mode))
let mode: ComposerMode = .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock"))
viewModel.process(timelineAction: .setMode(mode: mode))
XCTAssertEqual(viewModel.state.composerMode, mode)
viewModel.process(roomAction: .clear)
viewModel.process(timelineAction: .clear)
XCTAssertEqual(viewModel.state.composerMode, .default)
}
func testComposerModeIsPublished() {
let mode: RoomScreenComposerMode = .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock"))
let mode: ComposerMode = .edit(originalItemId: TimelineItemIdentifier(timelineID: "mock"))
let expectation = expectation(description: "Composer mode is published")
let cancellable = viewModel
.context
@ -78,7 +78,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
expectation.fulfill()
})
viewModel.process(roomAction: .setMode(mode: mode))
viewModel.process(timelineAction: .setMode(mode: mode))
wait(for: [expectation], timeout: 2.0)
cancellable.cancel()
@ -206,7 +206,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
viewModel.context.composerFormattingEnabled = false
viewModel.context.plainComposerText = .init(string: "Hello world!")
viewModel.process(roomAction: .saveDraft)
viewModel.saveDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1)
@ -226,7 +226,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
viewModel.context.composerFormattingEnabled = true
wysiwygViewModel.setHtmlContent("<strong>Hello</strong> world!")
viewModel.process(roomAction: .saveDraft)
viewModel.saveDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1)
@ -245,9 +245,9 @@ class ComposerToolbarViewModelTests: XCTestCase {
}
viewModel.context.composerFormattingEnabled = false
viewModel.process(roomAction: .setMode(mode: .edit(originalItemId: .init(timelineID: "", eventID: "testID"))))
viewModel.process(timelineAction: .setMode(mode: .edit(originalItemId: .init(timelineID: "", eventID: "testID"))))
viewModel.context.plainComposerText = .init(string: "Hello world!")
viewModel.process(roomAction: .saveDraft)
viewModel.saveDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1)
@ -266,14 +266,14 @@ class ComposerToolbarViewModelTests: XCTestCase {
}
viewModel.context.composerFormattingEnabled = false
viewModel.process(roomAction: .setMode(mode: .reply(itemID: .init(timelineID: "",
eventID: "testID"),
viewModel.process(timelineAction: .setMode(mode: .reply(itemID: .init(timelineID: "",
eventID: "testID"),
replyDetails: .loaded(sender: .init(id: ""),
eventID: "testID",
eventContent: .message(.text(.init(body: "reply text")))),
isThread: false)))
viewModel.context.plainComposerText = .init(string: "Hello world!")
viewModel.process(roomAction: .saveDraft)
viewModel.saveDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1)
@ -292,13 +292,13 @@ class ComposerToolbarViewModelTests: XCTestCase {
}
viewModel.context.composerFormattingEnabled = false
viewModel.process(roomAction: .setMode(mode: .reply(itemID: .init(timelineID: "",
eventID: "testID"),
viewModel.process(timelineAction: .setMode(mode: .reply(itemID: .init(timelineID: "",
eventID: "testID"),
replyDetails: .loaded(sender: .init(id: ""),
eventID: "testID",
eventContent: .message(.text(.init(body: "reply text")))),
isThread: false)))
viewModel.process(roomAction: .saveDraft)
viewModel.saveDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertEqual(draftServiceMock.saveDraftCallsCount, 1)
@ -314,7 +314,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
}
viewModel.context.composerFormattingEnabled = false
viewModel.process(roomAction: .saveDraft)
viewModel.saveDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertFalse(draftServiceMock.saveDraftCalled)
@ -332,8 +332,8 @@ class ComposerToolbarViewModelTests: XCTestCase {
viewModel.context.composerFormattingEnabled = false
let waveformData: [Float] = Array(repeating: 1.0, count: 1000)
viewModel.context.plainComposerText = .init(string: "Hello world!")
viewModel.process(roomAction: .setMode(mode: .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, duration: 10.0), waveform: .data(waveformData), isUploading: false)))
viewModel.process(roomAction: .saveDraft)
viewModel.process(timelineAction: .setMode(mode: .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, duration: 10.0), waveform: .data(waveformData), isUploading: false)))
viewModel.saveDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertFalse(draftServiceMock.saveDraftCalled)
@ -349,7 +349,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
return .success(nil)
}
viewModel.process(roomAction: .loadDraft)
viewModel.loadDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled)
XCTAssertTrue(viewModel.state.composerEmpty)
@ -365,7 +365,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
htmlText: nil,
draftType: .newMessage))
}
viewModel.process(roomAction: .loadDraft)
viewModel.loadDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled)
@ -382,7 +382,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
htmlText: "<strong>Hello</strong> world!",
draftType: .newMessage))
}
viewModel.process(roomAction: .loadDraft)
viewModel.loadDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertTrue(viewModel.context.composerFormattingEnabled)
@ -400,7 +400,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
htmlText: nil,
draftType: .edit(eventID: "testID")))
}
viewModel.process(roomAction: .loadDraft)
viewModel.loadDraft()
await fulfillment(of: [expectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled)
@ -433,7 +433,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
return .success(.init(details: loadedReply,
isThreaded: true))
}
viewModel.process(roomAction: .loadDraft)
viewModel.loadDraft()
await fulfillment(of: [draftExpectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled)
@ -474,7 +474,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
return .success(.init(details: loadedReply,
isThreaded: true))
}
viewModel.process(roomAction: .loadDraft)
viewModel.loadDraft()
await fulfillment(of: [draftExpectation], timeout: 10)
XCTAssertFalse(viewModel.context.composerFormattingEnabled)
@ -493,7 +493,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
func testSaveVolatileDraftWhenEditing() {
viewModel.context.composerFormattingEnabled = false
viewModel.context.plainComposerText = .init(string: "Hello world!")
viewModel.process(roomAction: .setMode(mode: .edit(originalItemId: .random)))
viewModel.process(timelineAction: .setMode(mode: .edit(originalItemId: .random)))
let draft = draftServiceMock.saveVolatileDraftReceivedDraft
XCTAssertNotNil(draft)
@ -530,7 +530,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
expectation2.fulfill()
}
viewModel.process(roomAction: .clear)
viewModel.process(timelineAction: .clear)
await fulfillment(of: [expectation1, expectation2])
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world"))
}
@ -549,7 +549,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
expectation2.fulfill()
}
viewModel.process(roomAction: .clear)
viewModel.process(timelineAction: .clear)
await fulfillment(of: [expectation1, expectation2])
XCTAssertEqual(viewModel.context.plainComposerText, NSAttributedString(string: "Hello world"))
}
@ -557,7 +557,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
func testRestoreUserMentionInPlainText() async throws {
viewModel.context.composerFormattingEnabled = false
let text = "Hello [TestName](https://matrix.to/#/@test:matrix.org)!"
viewModel.process(roomAction: .setText(plainText: text, htmlText: nil))
viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil))
let deferred = deferFulfillment(viewModel.actions) { action in
switch action {
@ -577,7 +577,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
func testRestoreAllUsersMentionInPlainText() async throws {
viewModel.context.composerFormattingEnabled = false
let text = "Hello @room"
viewModel.process(roomAction: .setText(plainText: text, htmlText: nil))
viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil))
let deferred = deferFulfillment(viewModel.actions) { action in
switch action {
@ -596,7 +596,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
func testRestoreMixedMentionsInPlainText() async throws {
viewModel.context.composerFormattingEnabled = false
let text = "Hello [User1](https://matrix.to/#/@user1:matrix.org), [User2](https://matrix.to/#/@user2:matrix.org) and @room"
viewModel.process(roomAction: .setText(plainText: text, htmlText: nil))
viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil))
let deferred = deferFulfillment(viewModel.actions) { action in
switch action {
@ -616,7 +616,7 @@ class ComposerToolbarViewModelTests: XCTestCase {
func testRestoreAmbiguousMention() async throws {
viewModel.context.composerFormattingEnabled = false
let text = "Hello [User1](https://matrix.to/#/@roomuser:matrix.org)"
viewModel.process(roomAction: .setText(plainText: text, htmlText: nil))
viewModel.process(timelineAction: .setText(plainText: text, htmlText: nil))
let deferred = deferFulfillment(viewModel.actions) { action in
switch action {

View File

@ -26,16 +26,16 @@ class PillContextTests: XCTestCase {
let proxyMock = RoomProxyMock(.init(name: "Test"))
let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([])
proxyMock.membersPublisher = subject.asCurrentValuePublisher()
let mock = RoomScreenViewModel(roomProxy: proxyMock,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
let context = PillContext(roomContext: mock.context, data: PillTextAttachmentData(type: .user(userID: id), font: .preferredFont(forTextStyle: .body)))
let mock = TimelineViewModel(roomProxy: proxyMock,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .user(userID: id), font: .preferredFont(forTextStyle: .body)))
XCTAssertFalse(context.viewState.isOwnMention)
XCTAssertEqual(context.viewState.displayText, id)
@ -54,16 +54,16 @@ class PillContextTests: XCTestCase {
let proxyMock = RoomProxyMock(.init(name: "Test", ownUserID: id))
let subject = CurrentValueSubject<[RoomMemberProxyProtocol], Never>([])
proxyMock.membersPublisher = subject.asCurrentValuePublisher()
let mock = RoomScreenViewModel(roomProxy: proxyMock,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
let context = PillContext(roomContext: mock.context, data: PillTextAttachmentData(type: .user(userID: id), font: .preferredFont(forTextStyle: .body)))
let mock = TimelineViewModel(roomProxy: proxyMock,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .user(userID: id), font: .preferredFont(forTextStyle: .body)))
XCTAssertTrue(context.viewState.isOwnMention)
}
@ -75,16 +75,16 @@ class PillContextTests: XCTestCase {
let proxyMock = RoomProxyMock(.init(id: id, name: displayName, avatarURL: avatarURL))
let mockController = MockRoomTimelineController()
mockController.roomProxy = proxyMock
let mock = RoomScreenViewModel(roomProxy: proxyMock,
timelineController: mockController,
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
let context = PillContext(roomContext: mock.context, data: PillTextAttachmentData(type: .allUsers, font: .preferredFont(forTextStyle: .body)))
let mock = TimelineViewModel(roomProxy: proxyMock,
timelineController: mockController,
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
let context = PillContext(timelineContext: mock.context, data: PillTextAttachmentData(type: .allUsers, font: .preferredFont(forTextStyle: .body)))
XCTAssertTrue(context.viewState.isOwnMention)
XCTAssertEqual(context.viewState.displayText, PillConstants.atRoom)

View File

@ -20,7 +20,7 @@ import Combine
import XCTest
@MainActor
class RoomScreenViewModelTests: XCTestCase {
class TimelineViewModelTests: XCTestCase {
var userIndicatorControllerMock: UserIndicatorControllerMock!
var cancellables = Set<AnyCancellable>()
@ -327,7 +327,7 @@ class RoomScreenViewModelTests: XCTestCase {
// swiftlint:enable force_unwrapping
// swiftlint:disable:next large_tuple
private func readReceiptsConfiguration(with items: [RoomTimelineItemProtocol]) -> (RoomScreenViewModel,
private func readReceiptsConfiguration(with items: [RoomTimelineItemProtocol]) -> (TimelineViewModel,
RoomProxyMock,
TimelineProxyMock,
MockRoomTimelineController) {
@ -343,15 +343,15 @@ class RoomScreenViewModelTests: XCTestCase {
timelineController.timelineItems = items
timelineController.roomProxy = roomProxy
let viewModel = RoomScreenViewModel(roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: userIndicatorControllerMock,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
let viewModel = TimelineViewModel(roomProxy: roomProxy,
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: userIndicatorControllerMock,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
return (viewModel, roomProxy, timelineProxy, timelineController)
}
@ -367,15 +367,15 @@ class RoomScreenViewModelTests: XCTestCase {
// When showing them in a timeline.
let timelineController = MockRoomTimelineController()
timelineController.timelineItems = [message]
let viewModel = RoomScreenViewModel(roomProxy: RoomProxyMock(.init(name: "", members: [RoomMemberProxyMock.mockAlice, RoomMemberProxyMock.mockCharlie])),
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: userIndicatorControllerMock,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
let viewModel = TimelineViewModel(roomProxy: RoomProxyMock(.init(name: "", members: [RoomMemberProxyMock.mockAlice, RoomMemberProxyMock.mockCharlie])),
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: userIndicatorControllerMock,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
let deferred = deferFulfillment(viewModel.context.$viewState) { value in
value.bindings.readReceiptsSummaryInfo?.orderedReceipts == receipts
@ -389,17 +389,17 @@ class RoomScreenViewModelTests: XCTestCase {
private func makeViewModel(roomProxy: RoomProxyProtocol? = nil,
focussedEventID: String? = nil,
timelineController: RoomTimelineControllerProtocol) -> RoomScreenViewModel {
RoomScreenViewModel(roomProxy: roomProxy ?? RoomProxyMock(.init(name: "")),
focussedEventID: focussedEventID,
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: userIndicatorControllerMock,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
timelineController: RoomTimelineControllerProtocol) -> TimelineViewModel {
TimelineViewModel(roomProxy: roomProxy ?? RoomProxyMock(.init(name: "")),
focussedEventID: focussedEventID,
timelineController: timelineController,
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: userIndicatorControllerMock,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
}
}