mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 03:06:37 +00:00
22d14ff8e4
* create DependenciesProtocol and replace ServiceLocator registration with it replace mocking calls for ServiceLocator to Dependencies replace ServiceLocator with instance level calls replace ServiceLocator configs with TestDependencies config in Tests replace servicelocator usage in analytics service replace remaining ServiceLocator calls in unit tests that depended on the UnitTestAppCoordinator configuration with per suite dependencies instances fix swiftlint warning remove remaining references to ServiceLocator make dependencies properties consistent in tests delete Dependencies and DependenciesProtocol fix whitespace modifications updates from PR feedback a pass over converting AppSettings and AnalyticsService test instance properties to per test instantiation fix a missed PR feedback Sync up after rebase create DependenciesProtocol and replace ServiceLocator registration with it replace mocking calls for ServiceLocator to Dependencies replace ServiceLocator with instance level calls replace ServiceLocator configs with TestDependencies config in Tests replace servicelocator usage in analytics service replace remaining ServiceLocator calls in unit tests that depended on the UnitTestAppCoordinator configuration with per suite dependencies instances fix swiftlint warning remove remaining references to ServiceLocator make dependencies properties consistent in tests delete Dependencies and DependenciesProtocol * AppSettings is instance level mockable now * replace mocked and preview usages of reset + AppSettings global init with AppSettings(store: UserDefaultsMock()) * AppSettings.UserDefaultsKeys is now a RawRep struct, making it extensible * update tests with new AppSettings so it builds * update remaining AppSettings test/mock inits * AppSettings.UserDefaultsKey conformed to ExpressibleByStringLiteral * addressed issues found in first pass ai review * update AppSettings instantiations after rebase * fix accessibility tests coordinator after rebase * Rename to VolatileUserDefaults * AppSettings init uses mandatory parameter * Revert UserDefaultsKey to enum * Add TrackedUserDefaults to be able to reset the defaults object * Added volatile static func to AppSettings * Use .volatile() static func instead of full init * review fixups * Resolve build issues after rebase * updated enterprise module * remove the static reset settings call from the preview tests --------- Co-authored-by: Mauro Romito <mauro.romito@element.io>
1151 lines
53 KiB
Swift
1151 lines
53 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2022-2025 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 userSession: UserSessionProtocol
|
|
private let mediaPlayerProvider: MediaPlayerProviderProtocol
|
|
private let userIndicatorController: UserIndicatorControllerProtocol
|
|
private let appMediator: AppMediatorProtocol
|
|
private let appSettings: AppSettings
|
|
private let analyticsService: AnalyticsServiceProtocol
|
|
private let emojiProvider: EmojiProviderProtocol
|
|
private let timelineControllerFactory: TimelineControllerFactoryProtocol
|
|
|
|
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,
|
|
userSession: UserSessionProtocol,
|
|
mediaPlayerProvider: MediaPlayerProviderProtocol,
|
|
userIndicatorController: UserIndicatorControllerProtocol,
|
|
appMediator: AppMediatorProtocol,
|
|
appSettings: AppSettings,
|
|
analyticsService: AnalyticsServiceProtocol,
|
|
emojiProvider: EmojiProviderProtocol,
|
|
linkMetadataProvider: LinkMetadataProviderProtocol,
|
|
timelineControllerFactory: TimelineControllerFactoryProtocol) {
|
|
self.roomProxy = roomProxy
|
|
self.timelineController = timelineController
|
|
self.userSession = userSession
|
|
self.mediaPlayerProvider = mediaPlayerProvider
|
|
self.appSettings = appSettings
|
|
self.analyticsService = analyticsService
|
|
self.userIndicatorController = userIndicatorController
|
|
self.appMediator = appMediator
|
|
self.emojiProvider = emojiProvider
|
|
self.timelineControllerFactory = timelineControllerFactory
|
|
|
|
let voiceMessageRecorder = VoiceMessageRecorder(audioRecorder: AudioRecorder(), mediaPlayerProvider: mediaPlayerProvider)
|
|
|
|
timelineInteractionHandler = TimelineInteractionHandler(roomProxy: roomProxy,
|
|
timelineController: timelineController,
|
|
userSession: userSession,
|
|
mediaPlayerProvider: mediaPlayerProvider,
|
|
voiceMessageRecorder: voiceMessageRecorder,
|
|
userIndicatorController: userIndicatorController,
|
|
appMediator: appMediator,
|
|
appSettings: appSettings,
|
|
analyticsService: analyticsService,
|
|
emojiProvider: emojiProvider,
|
|
linkMetadataProvider: linkMetadataProvider,
|
|
timelineControllerFactory: timelineControllerFactory)
|
|
|
|
let hideTimelineMedia = switch userSession.clientProxy.timelineMediaVisibilityPublisher.value {
|
|
case .always:
|
|
false
|
|
case .privateOnly:
|
|
!(roomProxy.infoPublisher.value.isPrivate ?? true)
|
|
case .never:
|
|
true
|
|
}
|
|
super.init(initialViewState: TimelineViewState(timelineKind: timelineController.timelineKind,
|
|
roomID: roomProxy.id,
|
|
isDM: roomProxy.infoPublisher.value.isDM,
|
|
timelineState: TimelineState(focussedEvent: focussedEventID.map { .init(eventID: $0, appearance: .immediate) }),
|
|
ownUserID: roomProxy.ownUserID,
|
|
hideTimelineMedia: hideTimelineMedia,
|
|
isViewSourceEnabled: appSettings.viewSourceEnabled,
|
|
areThreadsEnabled: appSettings.threadsEnabled,
|
|
linkPreviewsEnabled: appSettings.linkPreviewsEnabled,
|
|
jumpToReadMarkerEnabled: appSettings.jumpToReadMarkerEnabled,
|
|
hasPredecessor: roomProxy.predecessorRoom != nil,
|
|
pinnedEventIDs: roomProxy.infoPublisher.value.pinnedEventIDs,
|
|
emojiProvider: emojiProvider,
|
|
linkMetadataProvider: hideTimelineMedia ? nil : linkMetadataProvider,
|
|
mapTilerConfiguration: appSettings.mapTilerConfiguration,
|
|
bindings: .init(reactionsCollapsed: [:])),
|
|
mediaProvider: userSession.mediaProvider)
|
|
|
|
if focussedEventID != nil {
|
|
// The timeline controller will start loading a detached timeline.
|
|
showFocusLoadingIndicator()
|
|
}
|
|
|
|
setupSubscriptions()
|
|
setupDirectRoomSubscriptionsIfNeeded()
|
|
|
|
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?.userSession.clientProxy.roomSummaryForIdentifier(roomID)?.name
|
|
}
|
|
|
|
state.roomNameForAliasResolver = { [weak self] alias in
|
|
self?.userSession.clientProxy.roomSummaryForAlias(alias)?.name
|
|
}
|
|
|
|
state.timelineState.paginationState = timelineController.paginationState
|
|
buildTimelineViews(timelineItems: timelineController.timelineItems)
|
|
|
|
updateRoomInfo(roomProxy.infoPublisher.value)
|
|
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 .scrollToFirstItemForCurrentDate:
|
|
state.timelineState.scrollToFirstItemForDatePublisher.send()
|
|
case .scrollToReadMarker:
|
|
Task { await scrollToReadMarker() }
|
|
case .markAllAsRead:
|
|
state.bindings.hasNewMessagesAtBottom = false
|
|
Task {
|
|
_ = await roomProxy.markAsRead(receiptType: .fullyRead)
|
|
// Clear locally so the jump-to-unread button hides without waiting
|
|
// for the SDK to push a refreshed RoomInfo. Doing this after the
|
|
// await means any stale RoomInfo update racing the mark-as-read
|
|
// call has already landed and can't overwrite this clear.
|
|
state.timelineState.fullyReadEventID = nil
|
|
}
|
|
case .displayTimelineItemMenu(let itemID):
|
|
timelineInteractionHandler.displayTimelineItemActionMenu(for: itemID)
|
|
case .handleTimelineItemMenuAction(let itemID, let action):
|
|
timelineInteractionHandler.handleTimelineItemMenuAction(action, itemID: itemID)
|
|
case .tappedOnSenderDetails(let sender):
|
|
handleTappedOnSenderDetails(sender: sender)
|
|
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(let itemID):
|
|
actionsSubject.send(.displayThread(itemID: itemID))
|
|
case .handlePasteOrDrop(let providers):
|
|
timelineInteractionHandler.handlePasteOrDrop(providers)
|
|
case .handlePollAction(let pollAction):
|
|
handlePollAction(pollAction)
|
|
case .handleAudioPlayerAction(let audioPlayerAction):
|
|
handleAudioPlayerAction(audioPlayerAction)
|
|
case .stopLiveLocationSharing(let id):
|
|
state.stoppedLiveLocationIDs.insert(id)
|
|
Task { await stopLiveLocationSharing() }
|
|
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 .displayPredecessorRoom:
|
|
guard let predecessorID = roomProxy.predecessorRoom?.roomId else {
|
|
fatalError("Predecessor room should exist if this action is triggered.")
|
|
}
|
|
let serverNames = roomProxy.knownServerNames(maxCount: 50) // Limit to the same number used by ClientProxy.resolveRoomAlias(_:)
|
|
actionsSubject.send(.displayRoom(roomID: predecessorID, via: Array(serverNames)))
|
|
}
|
|
}
|
|
|
|
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 providers):
|
|
timelineInteractionHandler.handlePasteOrDrop(providers)
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
|
|
func stopLiveLocationSharing() async {
|
|
await userSession.liveLocationManager.stopLiveLocation(roomID: roomProxy.id)
|
|
}
|
|
|
|
func makeForwardingItem(for itemID: TimelineItemIdentifier) async -> MessageForwardingItem? {
|
|
guard let content = await timelineController.messageEventContent(for: itemID) else { return nil }
|
|
return .init(id: itemID, roomID: roomProxy.id, content: content)
|
|
}
|
|
|
|
// MARK: - Private
|
|
|
|
private func handleTappedOnSenderDetails(sender: TimelineItemSender) {
|
|
let memberDetails: ManageRoomMemberDetails = if let memberProxy = roomProxy.membersPublisher.value.first(where: { $0.userID == sender.id }) {
|
|
.memberDetails(roomMember: .init(withProxy: memberProxy))
|
|
} else {
|
|
.loadingMemberDetails(sender: sender)
|
|
}
|
|
|
|
let viewModel = ManageRoomMemberSheetViewModel(memberDetails: memberDetails,
|
|
permissions: .init(canKick: state.canCurrentUserKick,
|
|
canBan: state.canCurrentUserBan,
|
|
ownPowerLevel: currentUserProxy?.powerLevel ?? .init(value: 0)),
|
|
roomProxy: roomProxy,
|
|
userIndicatorController: userIndicatorController,
|
|
analyticsService: analyticsService,
|
|
mediaProvider: userSession.mediaProvider)
|
|
|
|
viewModel.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: sender.id))
|
|
}
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
state.bindings.manageMemberViewModel = viewModel
|
|
}
|
|
|
|
private func focusLive() {
|
|
timelineController.focusLive()
|
|
}
|
|
|
|
private func didScrollToFocussedItem() {
|
|
if var focussedEvent = state.timelineState.focussedEvent {
|
|
focussedEvent.appearance = .hasAppeared
|
|
state.timelineState.focussedEvent = focussedEvent
|
|
hideFocusLoadingIndicator()
|
|
analyticsService.signpost.finishTransaction(.notificationToMessage)
|
|
}
|
|
}
|
|
|
|
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) }
|
|
case .changePlaybackSpeed(let itemID):
|
|
timelineInteractionHandler.changePlaybackSpeed(for: itemID)
|
|
}
|
|
}
|
|
|
|
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 updateRoomInfo(_ roomInfo: RoomInfoProxyProtocol) {
|
|
state.pinnedEventIDs = roomInfo.pinnedEventIDs
|
|
state.isDM = roomInfo.isDM
|
|
state.timelineState.fullyReadEventID = roomInfo.fullyReadEventID
|
|
|
|
if let powerLevels = roomInfo.powerLevels {
|
|
state.canCurrentUserSendMessage = powerLevels.canOwnUser(sendMessage: .roomMessage)
|
|
state.canCurrentUserRedactOthers = powerLevels.canOwnUserRedactOther()
|
|
state.canCurrentUserRedactSelf = powerLevels.canOwnUserRedactOwn()
|
|
state.canCurrentUserPin = powerLevels.canOwnUserPinOrUnpin()
|
|
state.canCurrentUserKick = powerLevels.canOwnUserKick()
|
|
state.canCurrentUserBan = powerLevels.canOwnUserBan()
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
if !updatedItems.isEmpty {
|
|
analyticsService.signpost.finishTransaction(.openRoom)
|
|
}
|
|
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)
|
|
|
|
roomProxy.infoPublisher
|
|
.receive(on: DispatchQueue.main)
|
|
.sink { [weak self] roomInfo in
|
|
self?.updateRoomInfo(roomInfo)
|
|
}
|
|
.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 mediaURLs):
|
|
actionsSubject.send(.displayMediaUploadPreviewScreen(mediaURLs: mediaURLs))
|
|
case .showActionMenu(let actionMenuInfo):
|
|
if case .media(.mediaFilesScreen) = timelineController.timelineKind,
|
|
let item = actionMenuInfo.item as? EventBasedMessageTimelineItemProtocol {
|
|
actionsSubject.send(.displayMediaDetails(item: item))
|
|
} else {
|
|
self.state.bindings.actionMenuInfo = actionMenuInfo
|
|
}
|
|
case .showDebugInfo(let debugInfo):
|
|
state.bindings.debugInfo = debugInfo
|
|
case .viewInRoomTimeline(let eventID):
|
|
Task { await self.viewInRoomTimeline(eventID: eventID) }
|
|
case .displayThread(let itemID):
|
|
actionsSubject.send(.displayThread(itemID: itemID))
|
|
case .showTranslation(let text):
|
|
self.state.bindings.textToBeTranslated = text
|
|
self.state.bindings.showTranslation = true
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
}
|
|
|
|
func viewInRoomTimeline(eventID: String) async {
|
|
switch await roomProxy.loadOrFetchEventDetails(for: eventID) {
|
|
case .success(let event):
|
|
let threadRootEventID: String? = if appSettings.threadsEnabled {
|
|
event.threadRootEventId()
|
|
} else {
|
|
nil
|
|
}
|
|
actionsSubject.send(.viewInRoomTimeline(eventID: eventID, threadRootEventID: threadRootEventID))
|
|
case .failure:
|
|
userIndicatorController.submitIndicator(.init(title: L10n.errorUnknown))
|
|
}
|
|
}
|
|
|
|
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.$threadsEnabled
|
|
.weakAssign(to: \.state.areThreadsEnabled, on: self)
|
|
.store(in: &cancellables)
|
|
|
|
appSettings.$jumpToReadMarkerEnabled
|
|
.weakAssign(to: \.state.jumpToReadMarkerEnabled, on: self)
|
|
.store(in: &cancellables)
|
|
|
|
userSession.clientProxy.timelineMediaVisibilityPublisher
|
|
.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 ?? false) }
|
|
.removeDuplicates()
|
|
.eraseToAnyPublisher()
|
|
}
|
|
}
|
|
.receive(on: DispatchQueue.main)
|
|
.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?.displayAlert(.inviteAgain)
|
|
}
|
|
.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 == .endReached {
|
|
focusLive()
|
|
}
|
|
|
|
paginateForwardsTask = nil
|
|
}
|
|
}
|
|
|
|
private func scrollToBottom() {
|
|
if state.timelineState.isLive {
|
|
state.timelineState.scrollToBottomPublisher.send(())
|
|
} else {
|
|
focusLive()
|
|
}
|
|
}
|
|
|
|
private func scrollToReadMarker() async {
|
|
// Primary: SDK has materialised the virtual ReadMarker. Smooth in-window scroll.
|
|
if let readMarkerID = state.timelineState.readMarkerUniqueID {
|
|
state.timelineState.scrollToReadMarkerPublisher.send(readMarkerID)
|
|
return
|
|
}
|
|
|
|
// Fallback: mirror the same-room permalink flow exactly
|
|
// (RoomFlowCoordinator.handleChildEventRoute lines 223-258): pre-fetch the
|
|
// event first to prime the SDK's event cache, then focus on it via the
|
|
// same focusOnEvent path permalinks use.
|
|
guard let fullyReadEventID = state.timelineState.fullyReadEventID else { return }
|
|
|
|
switch await roomProxy.loadOrFetchEventDetails(for: fullyReadEventID) {
|
|
case .success:
|
|
await focusOnEvent(eventID: fullyReadEventID)
|
|
case .failure:
|
|
displayErrorToast(L10n.errorMessageNotFound)
|
|
}
|
|
}
|
|
|
|
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 location):
|
|
actionsSubject.send(.displayLocation(location))
|
|
case .displayLiveLocation(let sender, let initialLiveLocationShare):
|
|
actionsSubject.send(.displayLiveLocation(sender: sender, initialLiveLocationShare: initialLiveLocationShare))
|
|
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 forwarderMessage = eventTimelineItem.properties.encryptionForwarder?.message {
|
|
displayAlert(.encryptionForwarder(forwarderMessage))
|
|
} 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) async {
|
|
guard let alias = String(message.dropFirst(SlashCommand.join.rawValue.count))
|
|
.components(separatedBy: .whitespacesAndNewlines)
|
|
.first,
|
|
case let .success(resolvedAlias) = await userSession.clientProxy.resolveRoomAlias(alias) else {
|
|
return
|
|
}
|
|
|
|
actionsSubject.send(.displayRoom(roomID: resolvedAlias.roomId, via: resolvedAlias.servers))
|
|
}
|
|
|
|
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:
|
|
await 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: userSession.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
|
|
}
|
|
|
|
updateHasNewMessagesAtBottom(with: timelineItemsDictionary)
|
|
|
|
state.timelineState.itemsDictionary = timelineItemsDictionary
|
|
state.timelineState.recomputeReadMarkerUniqueID()
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
return eventTimelineItem.sender == otherEventTimelineItem.sender
|
|
&& eventTimelineItem.properties.reactions.isEmpty // Reactions break the grouping.
|
|
&& otherEventTimelineItem.timestamp.timeIntervalSince(eventTimelineItem.timestamp) < 5 * 60 // As does the passage of time.
|
|
}
|
|
|
|
/// Sets `hasNewMessagesAtBottom` to `true` when newer items arrive while the user is scrolled
|
|
/// up in a live timeline. Skips initial load and timeline switches.
|
|
private func updateHasNewMessagesAtBottom(with newTimelineItems: OrderedDictionary<TimelineItemIdentifier.UniqueID, RoomTimelineItemViewState>) {
|
|
guard state.jumpToReadMarkerEnabled,
|
|
state.timelineState.isLive,
|
|
!state.timelineState.isSwitchingTimelines,
|
|
!state.bindings.isScrolledToBottom,
|
|
!state.bindings.hasNewMessagesAtBottom else {
|
|
return
|
|
}
|
|
|
|
let oldDictionary = state.timelineState.itemsDictionary
|
|
guard !oldDictionary.isEmpty,
|
|
!newTimelineItems.isEmpty,
|
|
oldDictionary.keys.last != newTimelineItems.keys.last else {
|
|
return
|
|
}
|
|
|
|
state.bindings.hasNewMessagesAtBottom = true
|
|
}
|
|
|
|
// MARK: - Direct chats logics
|
|
|
|
private let inviteLoadingIndicatorID = UUID().uuidString
|
|
|
|
private func inviteOtherDMUserBack() {
|
|
guard roomProxy.infoPublisher.value.isUserAloneInDirectRoom else {
|
|
displayAlert(.unknown)
|
|
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 {
|
|
displayAlert(.unknown)
|
|
return
|
|
}
|
|
|
|
switch await roomProxy.invite(userID: otherPerson.userID) {
|
|
case .success:
|
|
break
|
|
case .failure:
|
|
displayAlert(.unableToInvite)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 forwardingItem = await makeForwardingItem(for: itemID) else { return }
|
|
actionsSubject.send(.displayMessageForwarding(forwardingItem: forwardingItem))
|
|
}
|
|
|
|
// 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 = userSession.clientProxy.roomSummaryForAlias(alias)
|
|
pillViewState = .reference(displayText: PillUtilities.eventPillDisplayText(roomName: roomSummary?.name, rawRoomText: alias))
|
|
case .roomID(let id):
|
|
let roomSummary = userSession.clientProxy.roomSummaryForIdentifier(id)
|
|
pillViewState = .reference(displayText: PillUtilities.eventPillDisplayText(roomName: roomSummary?.name, rawRoomText: id))
|
|
}
|
|
pillContext.viewState = pillViewState
|
|
case .roomAlias(let alias):
|
|
let roomSummary = userSession.clientProxy.roomSummaryForAlias(alias)
|
|
pillContext.viewState = .reference(displayText: PillUtilities.roomPillDisplayText(roomName: roomSummary?.name, rawRoomText: alias))
|
|
case .roomID(let id):
|
|
let roomSummary = userSession.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))
|
|
case .encryptionForwarder(let message):
|
|
state.bindings.alertInfo = .init(id: type,
|
|
title: message,
|
|
primaryButton: .init(title: L10n.actionOk, action: nil),
|
|
secondaryButton: .init(title: L10n.actionLearnMore) { [weak self] in
|
|
guard let self else { return }
|
|
appMediator.open(appSettings.historySharingDetailsURL)
|
|
})
|
|
case .inviteAgain:
|
|
state.bindings.alertInfo = .init(id: .inviteAgain,
|
|
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))
|
|
case .unableToInvite:
|
|
state.bindings.alertInfo = .init(id: .unableToInvite,
|
|
title: L10n.commonUnableToInviteTitle,
|
|
message: L10n.commonUnableToInviteMessage)
|
|
case .unknown:
|
|
state.bindings.alertInfo = .init(id: .unknown, title: L10n.commonError)
|
|
}
|
|
}
|
|
|
|
private func displayErrorToast(_ title: String) {
|
|
userIndicatorController.submitIndicator(UserIndicator(id: Constants.toastErrorID,
|
|
type: .toast,
|
|
title: title,
|
|
iconName: "xmark"))
|
|
}
|
|
}
|
|
|
|
// MARK: - Mocks
|
|
|
|
extension TimelineViewModel {
|
|
static let mock = mock(timelineKind: .live)
|
|
|
|
static func mock(timelineKind: TimelineKind = .live, timelineController: MockTimelineController? = nil, hasPredecessor: Bool = false) -> 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")
|
|
let roomProxy = JoinedRoomProxyMock(.init(name: "Preview room", predecessor: hasPredecessor ? .init(roomId: UUID().uuidString) : nil))
|
|
|
|
let appSettings = AppSettings.volatile()
|
|
|
|
return TimelineViewModel(roomProxy: roomProxy,
|
|
focussedEventID: nil,
|
|
timelineController: timelineController ?? MockTimelineController(timelineKind: timelineKind),
|
|
userSession: UserSessionMock(.init(clientProxy: clientProxyMock)),
|
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
|
userIndicatorController: UserIndicatorControllerMock.default,
|
|
appMediator: AppMediatorMock.default,
|
|
appSettings: appSettings,
|
|
analyticsService: AnalyticsServiceMock.default,
|
|
emojiProvider: EmojiProvider(appSettings: appSettings),
|
|
linkMetadataProvider: LinkMetadataProvider(),
|
|
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
|
|
}
|
|
}
|
|
|
|
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 "
|
|
}
|