mirror of
https://github.com/element-hq/element-x-ios.git
synced 2025-03-10 21:39:12 +00:00
RoomScreenViewModel is now TimelineViewModel (#3157)
Co-authored-by: Stefan Ceriu <stefanc@matrix.org>
This commit is contained in:
parent
4519275559
commit
1ad361a6e8
File diff suppressed because it is too large
Load Diff
@ -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
|
||||
|
@ -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> {
|
||||
|
@ -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 {
|
||||
|
@ -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() { }
|
||||
|
@ -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)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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?
|
||||
|
||||
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
@ -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 }
|
||||
}
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
}
|
||||
|
384
ElementX/Sources/Screens/Timeline/TimelineModels.swift
Normal file
384
ElementX/Sources/Screens/Timeline/TimelineModels.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
962
ElementX/Sources/Screens/Timeline/TimelineViewModel.swift
Normal file
962
ElementX/Sources/Screens/Timeline/TimelineViewModel.swift
Normal 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 }
|
||||
}
|
||||
}
|
@ -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()
|
||||
}
|
@ -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))
|
@ -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
|
||||
}()
|
||||
|
@ -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")
|
@ -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 {
|
@ -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
|
@ -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 {
|
@ -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",
|
@ -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
|
@ -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)
|
@ -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"),
|
@ -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)
|
@ -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)
|
@ -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)
|
@ -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)
|
@ -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)
|
@ -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)
|
@ -224,7 +224,7 @@ private struct PreviewBubbleModifier: ViewModifier {
|
||||
.padding(8)
|
||||
.background(Color.compound._bgBubbleOutgoing)
|
||||
.cornerRadius(12)
|
||||
.environmentObject(RoomScreenViewModel.mock.context)
|
||||
.environmentObject(TimelineViewModel.mock.context)
|
||||
}
|
||||
}
|
||||
|
@ -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")
|
||||
}
|
@ -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
|
@ -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 {
|
@ -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)
|
@ -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))
|
@ -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)))
|
||||
|
@ -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) {
|
@ -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)
|
@ -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)
|
@ -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)
|
@ -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)
|
@ -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
|
||||
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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",
|
||||
|
@ -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 {
|
||||
|
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedEventsTimelineScreen-iPad-en-GB.1.png
(Stored with Git LFS)
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedEventsTimelineScreen-iPad-en-GB.1.png
(Stored with Git LFS)
Binary file not shown.
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedEventsTimelineScreen-iPad-en-GB.Empty.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedEventsTimelineScreen-iPad-en-GB.Empty.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedEventsTimelineScreen-iPad-pseudo.1.png
(Stored with Git LFS)
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedEventsTimelineScreen-iPad-pseudo.1.png
(Stored with Git LFS)
Binary file not shown.
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedEventsTimelineScreen-iPad-pseudo.Empty.png
(Stored with Git LFS)
Normal file
BIN
PreviewTests/__Snapshots__/PreviewTests/test_pinnedEventsTimelineScreen-iPad-pseudo.Empty.png
(Stored with Git LFS)
Normal file
Binary file not shown.
@ -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 {
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user