Files
element-x-ios/ElementX/Sources/Screens/RoomDetailsScreen/RoomDetailsScreenViewModel.swift
T

318 lines
13 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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 SwiftUI
typealias RoomDetailsScreenViewModelType = StateStoreViewModel<RoomDetailsScreenViewState, RoomDetailsScreenViewAction>
class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScreenViewModelProtocol {
private let accountUserID: String
private let roomProxy: RoomProxyProtocol
private let mediaProvider: MediaProviderProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private let notificationSettingsProxy: NotificationSettingsProxyProtocol
private let attributedStringBuilder: AttributedStringBuilderProtocol
private var accountOwner: RoomMemberProxyProtocol? {
didSet { updatePowerLevelPermissions() }
}
private var dmRecipient: RoomMemberProxyProtocol?
private var actionsSubject: PassthroughSubject<RoomDetailsScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<RoomDetailsScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(accountUserID: String,
roomProxy: RoomProxyProtocol,
mediaProvider: MediaProviderProtocol,
userIndicatorController: UserIndicatorControllerProtocol,
notificationSettingsProxy: NotificationSettingsProxyProtocol,
attributedStringBuilder: AttributedStringBuilderProtocol) {
self.accountUserID = accountUserID
self.roomProxy = roomProxy
self.mediaProvider = mediaProvider
self.userIndicatorController = userIndicatorController
self.notificationSettingsProxy = notificationSettingsProxy
self.attributedStringBuilder = attributedStringBuilder
let topic = attributedStringBuilder.fromPlain(roomProxy.topic)
super.init(initialViewState: .init(roomId: roomProxy.id,
canonicalAlias: roomProxy.canonicalAlias,
isEncrypted: roomProxy.isEncrypted,
isDirect: roomProxy.isDirect,
permalink: roomProxy.permalink,
title: roomProxy.roomTitle,
topic: topic,
topicSummary: topic?.unattributedStringByReplacingNewlinesWithSpaces(),
avatarURL: roomProxy.avatarURL,
joinedMembersCount: roomProxy.joinedMembersCount,
notificationSettingsState: .loading,
bindings: .init()),
imageProvider: mediaProvider)
setupRoomSubscription()
fetchMembers()
setupNotificationSettingsSubscription()
fetchNotificationSettings()
}
// MARK: - Public
func stop() {
// Work around QLPreviewController dismissal issues, see the InteractiveQuickLookModifier.
state.bindings.mediaPreviewItem = nil
}
override func process(viewAction: RoomDetailsScreenViewAction) {
switch viewAction {
case .processTapPeople:
actionsSubject.send(.requestMemberDetailsPresentation)
case .processTapInvite:
actionsSubject.send(.requestInvitePeoplePresentation)
case .processTapLeave:
guard state.joinedMembersCount > 1 else {
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomId: roomProxy.id, state: .empty)
return
}
state.bindings.leaveRoomAlertItem = LeaveRoomAlertItem(roomId: roomProxy.id, state: roomProxy.isPublic ? .public : .private)
case .confirmLeave:
Task { await leaveRoom() }
case .processTapIgnore:
state.bindings.ignoreUserRoomAlertItem = .init(action: .ignore)
case .processTapUnignore:
state.bindings.ignoreUserRoomAlertItem = .init(action: .unignore)
case .processTapEdit, .processTapAddTopic:
guard let accountOwner else {
MXLog.error("Missing account owner when presenting the room's edit details screen")
return
}
actionsSubject.send(.requestEditDetailsPresentation(accountOwner))
case .ignoreConfirmed:
Task { await ignore() }
case .unignoreConfirmed:
Task { await unignore() }
case .processTapNotifications:
if state.notificationSettingsState.isError {
fetchNotificationSettings()
} else {
actionsSubject.send(.requestNotificationSettingsPresentation)
}
case .processToogleMuteNotifications:
Task { await toggleMuteNotifications() }
case .displayAvatar:
displayFullScreenAvatar()
}
}
// MARK: - Private
private func setupRoomSubscription() {
roomProxy.actions
.filter { $0 == .stateUpdate }
.throttle(for: .milliseconds(200), scheduler: DispatchQueue.main, latest: true)
.sink { [weak self] _ in
guard let self else { return }
self.state.title = self.roomProxy.roomTitle
let topic = attributedStringBuilder.fromPlain(self.roomProxy.topic)
self.state.topic = topic
self.state.topicSummary = topic?.unattributedStringByReplacingNewlinesWithSpaces()
self.state.avatarURL = self.roomProxy.avatarURL
self.state.joinedMembersCount = self.roomProxy.joinedMembersCount
}
.store(in: &cancellables)
}
private func fetchMembers() {
Task {
await fetchMembersIfNeeded()
await fetchAccountOwner()
}
}
private func fetchMembersIfNeeded() async {
// We need to fetch members just in 1-to-1 chat to get the member object for the other person
guard roomProxy.isEncryptedOneToOneRoom else {
return
}
roomProxy.members
.receive(on: DispatchQueue.main)
.sink { [weak self] members in
guard let self else { return }
let dmRecipient = members.first(where: { !$0.isAccountOwner })
self.dmRecipient = dmRecipient
self.state.dmRecipient = dmRecipient.map(RoomMemberDetails.init(withProxy:))
}
.store(in: &cancellables)
await roomProxy.updateMembers()
}
private func fetchAccountOwner() async {
switch await roomProxy.getMember(userID: accountUserID) {
case .success(let member):
accountOwner = member
case .failure(let error):
MXLog.error("Failed (error: \(error) to get account owner member with id: \(accountUserID), in room: \(roomProxy.id)")
}
}
private func updatePowerLevelPermissions() {
state.canInviteUsers = accountOwner?.canInviteUsers ?? false
state.canEditRoomName = accountOwner?.canSendStateEvent(type: .roomName) ?? false
state.canEditRoomTopic = accountOwner?.canSendStateEvent(type: .roomTopic) ?? false
state.canEditRoomAvatar = accountOwner?.canSendStateEvent(type: .roomAvatar) ?? false
}
private func setupNotificationSettingsSubscription() {
notificationSettingsProxy.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self else { return }
switch callback {
case .settingsDidChange:
self.fetchNotificationSettings()
}
}
.store(in: &cancellables)
}
private func fetchNotificationSettings() {
Task {
await fetchRoomNotificationSettings()
}
}
private func fetchRoomNotificationSettings() async {
do {
let notificationMode = try await notificationSettingsProxy.getNotificationSettings(roomId: roomProxy.id,
isEncrypted: roomProxy.isEncrypted,
isOneToOne: roomProxy.activeMembersCount == 2)
state.notificationSettingsState = .loaded(settings: notificationMode)
} catch {
state.notificationSettingsState = .error
state.bindings.alertInfo = AlertInfo(id: .alert,
title: L10n.commonError,
message: L10n.screenRoomDetailsErrorLoadingNotificationSettings)
}
}
private func toggleMuteNotifications() async {
guard case .loaded(let notificationMode) = state.notificationSettingsState else { return }
state.isProcessingMuteToggleAction = true
switch notificationMode.mode {
case .mute:
do {
try await notificationSettingsProxy.unmuteRoom(roomId: roomProxy.id,
isEncrypted: roomProxy.isEncrypted,
isOneToOne: roomProxy.activeMembersCount == 2)
} catch {
state.bindings.alertInfo = AlertInfo(id: .alert,
title: L10n.commonError,
message: L10n.screenRoomDetailsErrorUnmuting)
}
default:
do {
try await notificationSettingsProxy.setNotificationMode(roomId: roomProxy.id, mode: .mute)
} catch {
state.bindings.alertInfo = AlertInfo(id: .alert,
title: L10n.commonError,
message: L10n.screenRoomDetailsErrorMuting)
}
}
state.isProcessingMuteToggleAction = false
}
private static let leaveRoomLoadingID = "LeaveRoomLoading"
private func leaveRoom() async {
userIndicatorController.submitIndicator(UserIndicator(id: Self.leaveRoomLoadingID, type: .modal, title: L10n.commonLeavingRoom, persistent: true))
let result = await roomProxy.leaveRoom()
userIndicatorController.retractIndicatorWithId(Self.leaveRoomLoadingID)
switch result {
case .failure:
state.bindings.alertInfo = AlertInfo(id: .unknown)
case .success:
actionsSubject.send(.leftRoom)
}
}
private func ignore() async {
state.isProcessingIgnoreRequest = true
let result = await dmRecipient?.ignoreUser()
state.isProcessingIgnoreRequest = false
switch result {
case .success:
// Mutating the optional in place when built for Release crashes 🤷‍♂️
var dmRecipient = state.dmRecipient
dmRecipient?.isIgnored = true
state.dmRecipient = dmRecipient
case .failure, .none:
state.bindings.alertInfo = .init(id: .unknown)
}
}
private func unignore() async {
state.isProcessingIgnoreRequest = true
let result = await dmRecipient?.unignoreUser()
state.isProcessingIgnoreRequest = false
switch result {
case .success:
// Mutating the optional in place when built for Release crashes 🤷‍♂️
var dmRecipient = state.dmRecipient
dmRecipient?.isIgnored = false
state.dmRecipient = dmRecipient
case .failure, .none:
state.bindings.alertInfo = .init(id: .unknown)
}
}
private func displayFullScreenAvatar() {
guard let avatarURL = roomProxy.avatarURL else {
return
}
let loadingIndicatorIdentifier = "roomAvatarLoadingIndicator"
userIndicatorController.submitIndicator(UserIndicator(id: loadingIndicatorIdentifier, type: .modal, title: L10n.commonLoading, persistent: true))
Task {
defer {
userIndicatorController.retractIndicatorWithId(loadingIndicatorIdentifier)
}
// We don't actually know the mime type here, assume it's an image.
if case let .success(file) = await mediaProvider.loadFileFromSource(.init(url: avatarURL, mimeType: "image/jpeg")) {
state.bindings.mediaPreviewItem = MediaPreviewItem(file: file, title: roomProxy.roomTitle)
}
}
}
}
private extension AttributedString {
/// Returns a new string without attributes and in which newlines are replaced with spaces
func unattributedStringByReplacingNewlinesWithSpaces() -> AttributedString {
AttributedString(characters.map { $0.isNewline ? " " : $0 })
}
}