Files
letro-ios/ElementX/Sources/Screens/Timeline/TimelineViewModel.swift
Stefan Ceriu be4c5365ad Introduce a TimelineItemThreadSummary object (#4032)
* Introduce a `TimelineItemThreadSummary` object to hold details about threads starting from that particular item

This patch introduces a thread summary object that will be available on main timeline messages that are the root for a given thread.
It currently provides the latest message content and sender for that thread but it will grow to provide info on the number of replies, unreads etc.

It also add a new UI component called `TimelineThreadSummaryView` that makes use of this data and is in turn used by the bubbled styler to render it in the timeline.

The rest of the PR is about refactoring on the `RoomTimelineItemFactory` so that replies and thread summaries use similar paths and builders.

* Add a feature flag for threads

* Address PR comments

* Converge on single implementation for message previews
2025-04-16 16:19:29 +03:00

1055 lines
48 KiB
Swift

//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Algorithms
import Combine
import MatrixRustSDK
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: JoinedRoomProxyProtocol
private let timelineController: TimelineControllerProtocol
private let mediaProvider: MediaProviderProtocol
private let mediaPlayerProvider: MediaPlayerProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let appMediator: AppMediatorProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let emojiProvider: EmojiProviderProtocol
private let timelineControllerFactory: TimelineControllerFactoryProtocol
private let clientProxy: ClientProxyProtocol
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 currentUserProxy: RoomMemberProxyProtocol?
private var paginateBackwardsTask: Task<Void, Never>?
private var paginateForwardsTask: Task<Void, Never>?
init(roomProxy: JoinedRoomProxyProtocol,
focussedEventID: String? = nil,
timelineController: TimelineControllerProtocol,
mediaProvider: MediaProviderProtocol,
mediaPlayerProvider: MediaPlayerProviderProtocol,
voiceMessageMediaManager: VoiceMessageMediaManagerProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
appMediator: AppMediatorProtocol,
appSettings: AppSettings,
analyticsService: AnalyticsService,
emojiProvider: EmojiProviderProtocol,
timelineControllerFactory: TimelineControllerFactoryProtocol,
clientProxy: ClientProxyProtocol) {
self.timelineController = timelineController
self.mediaProvider = mediaProvider
self.mediaPlayerProvider = mediaPlayerProvider
self.roomProxy = roomProxy
self.appSettings = appSettings
self.analyticsService = analyticsService
self.userIndicatorController = userIndicatorController
self.appMediator = appMediator
self.emojiProvider = emojiProvider
self.timelineControllerFactory = timelineControllerFactory
self.clientProxy = clientProxy
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,
emojiProvider: emojiProvider,
timelineControllerFactory: timelineControllerFactory,
clientProxy: clientProxy)
let hideTimelineMedia = switch appSettings.timelineMediaVisibility {
case .always:
false
case .privateOnly:
!roomProxy.infoPublisher.value.isPrivate
case .never:
true
}
super.init(initialViewState: TimelineViewState(timelineKind: timelineController.timelineKind,
roomID: roomProxy.id,
isDirectOneToOneRoom: roomProxy.isDirectOneToOneRoom,
timelineState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
ownUserID: roomProxy.ownUserID,
isViewSourceEnabled: appSettings.viewSourceEnabled,
areThreadsEnabled: appSettings.threadsEnabled,
hideTimelineMedia: hideTimelineMedia,
pinnedEventIDs: roomProxy.infoPublisher.value.pinnedEventIDs,
emojiProvider: emojiProvider,
mapTilerConfiguration: appSettings.mapTilerConfiguration,
bindings: .init(reactionsCollapsed: [:])),
mediaProvider: 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)
}
state.pillContextUpdater = { [weak self] pillContext in
self?.pillContextUpdater(pillContext)
}
state.roomNameForIDResolver = { [weak self] roomID in
self?.clientProxy.roomSummaryForIdentifier(roomID)?.name
}
state.roomNameForAliasResolver = { [weak self] alias in
self?.clientProxy.roomSummaryForAlias(alias)?.name
}
state.timelineState.paginationState = timelineController.paginationState
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)
}
// MARK: - Public
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 .mediaTapped(let id):
Task { await handleMediaTapped(with: id) }
case .itemSendInfoTapped(let itemID):
handleItemSendInfoTapped(itemID: itemID)
case .toggleReaction(let emoji, let itemID):
emojiProvider.markEmojiAsFrequentlyUsed(emoji)
guard case let .event(_, eventOrTransactionID) = itemID else {
fatalError()
}
Task { await timelineController.toggleReaction(emoji, to: eventOrTransactionID) }
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 .tappedOnSenderDetails(let userID):
handleTappedOnSenderDetails(userID: userID)
case .displayEmojiPicker(let itemID):
timelineInteractionHandler.displayEmojiPicker(for: itemID)
case .displayReactionSummary(let itemID, let key):
displayReactionSummary(for: itemID, selectedKey: key)
case .displayReadReceipts(let itemID):
displayReadReceipts(for: itemID)
case .displayThread:
break
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.timelineState.isSwitchingTimelines = false }
case let .hasScrolled(direction):
actionsSubject.send(.hasScrolled(direction: direction))
case .setOpenURLAction(let action):
state.openURL = action
}
}
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.timelineState.hasLoadedItem(with: eventID) {
state.timelineState.focussedEvent = .init(eventID: eventID, appearance: .animated)
return
}
showFocusLoadingIndicator()
defer { hideFocusLoadingIndicator() }
switch await timelineController.focusOnEvent(eventID, timelineSize: Constants.detachedTimelineSize) {
case .success:
state.timelineState.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 handleTappedOnSenderDetails(userID: String) {
// We also need to make sure the user is in the joined state, otherwise we just show the details.
guard let memberProxy = roomProxy.membersPublisher.value.first(where: { $0.userID == userID && $0.membership == .join }),
let currentUserProxy,
currentUserProxy.powerLevel > memberProxy.powerLevel else {
actionsSubject.send(.displaySenderDetails(userID: userID))
return
}
if state.canCurrentUserBan || state.canCurrentUserKick {
let member = RoomMemberDetails(withProxy: memberProxy)
let manageMemeberViewModel = ManageRoomMemberSheetViewModel(member: member,
canKick: state.canCurrentUserKick,
canBan: state.canCurrentUserBan,
roomProxy: roomProxy,
userIndicatorController: userIndicatorController,
analyticsService: analyticsService,
mediaProvider: mediaProvider)
manageMemeberViewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .dismiss(let shouldShowDetails):
state.bindings.manageMemberViewModel = nil
if shouldShowDetails {
actionsSubject.send(.displaySenderDetails(userID: userID))
}
}
}
.store(in: &cancellables)
state.bindings.manageMemberViewModel = manageMemeberViewModel
} else {
actionsSubject.send(.displaySenderDetails(userID: userID))
}
}
private func focusLive() {
timelineController.focusLive()
}
private func didScrollToFocussedItem() {
if var focussedEvent = state.timelineState.focussedEvent {
focussedEvent.appearance = .hasAppeared
state.timelineState.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)
if member.userID == roomProxy.ownUserID {
currentUserProxy = member
}
}
}
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 case let .success(value) = await roomProxy.canUserPinOrUnpin(userID: roomProxy.ownUserID) {
state.canCurrentUserPin = value
} else {
state.canCurrentUserPin = false
}
if case let .success(value) = await roomProxy.canUserKick(userID: roomProxy.ownUserID) {
state.canCurrentUserKick = value
} else {
state.canCurrentUserKick = false
}
if case let .success(value) = await roomProxy.canUserBan(userID: roomProxy.ownUserID) {
state.canCurrentUserBan = value
} else {
state.canCurrentUserBan = 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.timelineState.paginationState != paginationState {
state.timelineState.paginationState = paginationState
}
case .isLive(let isLive):
if state.timelineState.isLive != isLive {
state.timelineState.isLive = isLive
// Remove the event highlight *only* when transitioning from non-live to live.
if isLive, state.timelineState.focussedEvent != nil {
state.timelineState.focussedEvent = nil
}
}
}
}
.store(in: &cancellables)
let roomInfoSubscription = roomProxy.infoPublisher
Task { [weak self] in
for await roomInfo in roomInfoSubscription.receive(on: DispatchQueue.main).values {
guard !Task.isCancelled else {
return
}
self?.state.pinnedEventIDs = roomInfo.pinnedEventIDs
await self?.updatePermissions()
}
}
.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 .showActionMenu(let actionMenuInfo):
Task {
await self.updatePermissions()
self.state.bindings.actionMenuInfo = actionMenuInfo
}
case .showDebugInfo(let debugInfo):
state.bindings.debugInfo = debugInfo
case .viewInRoomTimeline(let eventID):
actionsSubject.send(.viewInRoomTimeline(eventID: eventID))
}
}
.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.$timelineMediaVisibility
.removeDuplicates()
.flatMap { [weak self] timelineMediaVisibility -> AnyPublisher<Bool, Never> in
switch timelineMediaVisibility {
case .always:
return Just(false).eraseToAnyPublisher()
case .never:
return Just(true).eraseToAnyPublisher()
case .privateOnly:
guard let self else { return Just(false).eraseToAnyPublisher() }
return roomProxy.infoPublisher
.map { !$0.isPrivate }
.removeDuplicates()
.receive(on: DispatchQueue.main)
.eraseToAnyPublisher()
}
}
.weakAssign(to: \.state.hideTimelineMedia, on: self)
.store(in: &cancellables)
}
private func setupDirectRoomSubscriptionsIfNeeded() {
guard roomProxy.infoPublisher.value.isDirect else {
return
}
let shouldShowInviteAlert = composerFocusedSubject
.removeDuplicates()
.map { [weak self] isFocused in
guard let self else { return false }
return isFocused && self.roomProxy.infoPublisher.value.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.timelineState.paginationState.forward == .timelineEndReached {
focusLive()
}
paginateForwardsTask = nil
}
}
private func scrollToBottom() {
if state.timelineState.isLive {
state.timelineState.scrollToBottomPublisher.send(())
} else {
focusLive()
}
}
private func sendReadReceiptIfNeeded(for lastVisibleItemID: TimelineItemIdentifier) async {
guard appMediator.appState == .active else { return }
await timelineController.sendReadReceipt(for: lastVisibleItemID)
}
private func handleMediaTapped(with itemID: TimelineItemIdentifier) async {
state.showLoading = true
let action = await timelineInteractionHandler.processItemTap(itemID)
switch action {
case .displayMediaPreview(let item, let timelineViewModelKind):
actionsSubject.send(.composer(action: .removeFocus)) // Hide the keyboard otherwise a big white space is sometimes shown when dismissing the preview.
let mediaPreviewViewModel = makeMediaPreviewViewModel(item: item, timelineViewModelKind: timelineViewModelKind)
actionsSubject.send(.displayMediaPreview(mediaPreviewViewModel))
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 case .sendingFailed(.unknown) = eventTimelineItem.properties.deliveryStatus {
displayAlert(.sendingFailed)
} else if case let .sendingFailed(.verifiedUser(failure)) = eventTimelineItem.properties.deliveryStatus {
guard let sendHandle = timelineController.sendHandle(for: itemID) else {
MXLog.error("Cannot find send handle for \(itemID).")
return
}
actionsSubject.send(.displayResolveSendFailure(failure: failure,
sendHandle: sendHandle))
} else if let authenticityMessage = eventTimelineItem.properties.encryptionAuthenticity?.message {
displayAlert(.encryptionAuthenticity(authenticityMessage))
}
}
private func slashCommand(message: String) -> SlashCommand? {
for command in SlashCommand.allCases where message.starts(with: command.rawValue) {
return command
}
return nil
}
private func handleJoinCommand(message: String) {
guard let alias = String(message.dropFirst(SlashCommand.join.rawValue.count))
.components(separatedBy: .whitespacesAndNewlines)
.first,
let urlString = try? matrixToRoomAliasPermalink(roomAlias: alias),
let url = URL(string: urlString) else {
return
}
state.openURL?(url)
}
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 eventID, _, _):
await timelineController.sendMessage(message,
html: html,
inReplyToEventID: eventID,
intentionalMentions: intentionalMentions)
case .edit(let originalEventOrTransactionID, .default):
await timelineController.edit(originalEventOrTransactionID,
message: message,
html: html,
intentionalMentions: intentionalMentions)
case .edit(let originalEventOrTransactionID, .addCaption),
.edit(let originalEventOrTransactionID, .editCaption):
await timelineController.editCaption(originalEventOrTransactionID,
message: message,
html: html,
intentionalMentions: intentionalMentions)
case .default:
switch slashCommand(message: message) {
case .join:
handleJoinCommand(message: message)
case .none:
await timelineController.sendMessage(message,
html: html,
inReplyToEventID: nil,
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)
}
private func makeMediaPreviewViewModel(item: EventBasedMessageTimelineItemProtocol,
timelineViewModelKind: TimelineControllerAction.TimelineViewModelKind) -> TimelineMediaPreviewViewModel {
let timelineViewModel = switch timelineViewModelKind {
case .active: self
case .new(let newViewModel): newViewModel
}
return TimelineMediaPreviewViewModel(initialItem: item,
timelineViewModel: timelineViewModel,
mediaProvider: mediaProvider,
photoLibraryManager: PhotoLibraryManager(),
userIndicatorController: userIndicatorController,
appMediator: appMediator)
}
// MARK: - Timeline Item Building
private func buildTimelineViews(timelineItems: [RoomTimelineItemProtocol], isSwitchingTimelines: Bool = false) {
var timelineItemsDictionary = OrderedDictionary<TimelineItemIdentifier.UniqueID, 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.uniqueID)
}
} else {
for (index, item) in itemGroup.enumerated() {
if index == 0 {
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.timelineKind == .pinned ? .single : .first),
forKey: item.id.uniqueID)
} else if index == itemGroup.count - 1 {
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.timelineKind == .pinned ? .single : .last),
forKey: item.id.uniqueID)
} else {
timelineItemsDictionary.updateValue(updateViewState(item: item, groupStyle: state.timelineKind == .pinned ? .single : .middle),
forKey: item.id.uniqueID)
}
}
}
}
if isSwitchingTimelines {
state.timelineState.isSwitchingTimelines = true
}
state.timelineState.itemsDictionary = timelineItemsDictionary
}
private func updateViewState(item: RoomTimelineItemProtocol, groupStyle: TimelineGroupStyle) -> RoomTimelineItemViewState {
if let timelineItemViewState = state.timelineState.itemsDictionary[item.id.uniqueID] {
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) { [weak self] in self?.inviteOtherDMUserBack() },
secondaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil))
}
private let inviteLoadingIndicatorID = UUID().uuidString
private func inviteOtherDMUserBack() {
guard roomProxy.infoPublisher.value.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: Pills
private func pillContextUpdater(_ pillContext: PillContext) {
switch pillContext.data.type {
case let .user(id):
let isOwnMention = id == state.ownUserID
if let profile = state.members[id] {
pillContext.viewState = .mention(isOwnMention: isOwnMention, displayText: PillUtilities.userPillDisplayText(username: profile.displayName, userID: id))
} else {
pillContext.viewState = .mention(isOwnMention: isOwnMention, displayText: id)
pillContext.cancellable = context.$viewState
.compactMap { $0.members[id] }
.sink { [weak pillContext] profile in
guard let pillContext else {
return
}
pillContext.viewState = .mention(isOwnMention: isOwnMention, displayText: PillUtilities.userPillDisplayText(username: profile.displayName, userID: id))
pillContext.cancellable = nil
}
}
case .allUsers:
pillContext.viewState = .mention(isOwnMention: true, displayText: PillUtilities.atRoom)
case .event(let room):
let pillViewState: PillViewState
switch room {
case .roomAlias(let alias):
let roomSummary = clientProxy.roomSummaryForAlias(alias)
pillViewState = .reference(displayText: PillUtilities.eventPillDisplayText(roomName: roomSummary?.name, rawRoomText: alias))
case .roomID(let id):
let roomSummary = clientProxy.roomSummaryForIdentifier(id)
pillViewState = .reference(displayText: PillUtilities.eventPillDisplayText(roomName: roomSummary?.name, rawRoomText: id))
}
pillContext.viewState = pillViewState
case .roomAlias(let alias):
let roomSummary = clientProxy.roomSummaryForAlias(alias)
pillContext.viewState = .reference(displayText: PillUtilities.roomPillDisplayText(roomName: roomSummary?.name, rawRoomText: alias))
case .roomID(let id):
let roomSummary = clientProxy.roomSummaryForIdentifier(id)
pillContext.viewState = .reference(displayText: PillUtilities.roomPillDisplayText(roomName: roomSummary?.name, rawRoomText: id))
}
}
// 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: TimelineAlertInfoType) {
switch type {
case .audioRecodingPermissionError:
state.bindings.alertInfo = .init(id: type,
title: L10n.dialogPermissionMicrophoneTitleIos(InfoPlistReader.main.bundleDisplayName),
message: L10n.dialogPermissionMicrophoneDescriptionIos,
primaryButton: .init(title: L10n.commonSettings) { [weak self] in self?.appMediator.openAppSettings() },
secondaryButton: .init(title: L10n.actionNotNow, role: .cancel, action: nil))
case .pollEndConfirmation(let pollStartID):
state.bindings.alertInfo = .init(id: type,
title: L10n.actionEndPoll,
message: L10n.commonPollEndConfirmation,
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
secondaryButton: .init(title: L10n.actionOk) { 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 RoomInfoProxy {
/// 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 = mock(timelineKind: .live)
static func mock(timelineKind: TimelineKind = .live, timelineController: MockTimelineController? = nil) -> TimelineViewModel {
let clientProxyMock = ClientProxyMock(.init())
clientProxyMock.roomSummaryForAliasReturnValue = .mock(id: "!room:matrix.org", name: "Room")
clientProxyMock.roomSummaryForIdentifierReturnValue = .mock(id: "!room:matrix.org", name: "Room", canonicalAlias: "#room:matrix.org")
return TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")),
focussedEventID: nil,
timelineController: timelineController ?? MockTimelineController(timelineKind: timelineKind),
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: clientProxyMock)
}
}
extension EnvironmentValues {
/// Used to access and inject the room context without observing it
@Entry var timelineContext: TimelineViewModel.Context?
/// An event ID which will be non-nil when a timeline item should show as focussed.
@Entry var focussedEventID: String?
}
private enum SlashCommand: String, CaseIterable {
case join = "/join "
}