Files
letro-ios/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift
Stefan Ceriu 4def45a57b New timeline (#276) (#280)
* Fixes #276 - Rebuilt room timeline:
    - Removed the need for the ListCollectionViewAdapter
    - Rewrote the TimelineItemList without using introspection
    - Added ReversedScrollView for laying out items at the bottom/trailing
    - Rewrote TimelineProvider diffing through CollectionDifference (similar to the RoomSummaryProvider)
    - Added back `scrollDismissesKeyboard`  behavior
    - Various other tweaks and fixes
- Fixed various warnings:
    - removed async AttributedStringBuilder as AttributedString is non-sendable, made the RoomTimelineItemFactory synchronous
    - removed unused virtual timeline items
    - removed unused isOutgoing property from the FormattedBodyText
* Make TimelineItemContextMenuActions indentifiable and specify contextMenu identifiers
* Bump the matrix-rust-components-swift to v1.0.16-alpha
* Add changes file and changelog contribution guide
* Fix attributed string builder unit tests
2022-11-02 13:03:34 +02:00

221 lines
8.1 KiB
Swift

//
// 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 SwiftUI
typealias RoomScreenViewModelType = StateStoreViewModel<RoomScreenViewState, RoomScreenViewAction>
class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol {
private enum Constants {
static let backPaginationPageSize: UInt = 50
}
private let timelineController: RoomTimelineControllerProtocol
private let timelineViewFactory: RoomTimelineViewFactoryProtocol
private let mediaProvider: MediaProviderProtocol
// MARK: - Setup
init(timelineController: RoomTimelineControllerProtocol,
timelineViewFactory: RoomTimelineViewFactoryProtocol,
mediaProvider: MediaProviderProtocol,
roomName: String?,
roomAvatarUrl: String? = nil) {
self.timelineController = timelineController
self.timelineViewFactory = timelineViewFactory
self.mediaProvider = mediaProvider
super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomId,
roomTitle: roomName ?? "Unknown room 💥",
roomAvatar: nil,
bindings: .init(composerText: "", composerFocused: false)))
timelineController.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self else { return }
switch callback {
case .updatedTimelineItems:
self.buildTimelineViews()
case .updatedTimelineItem(let itemId):
guard let timelineItem = self.timelineController.timelineItems.first(where: { $0.id == itemId }),
let viewIndex = self.state.items.firstIndex(where: { $0.id == itemId }) else {
return
}
self.state.items[viewIndex] = timelineViewFactory.buildTimelineViewFor(timelineItem: timelineItem)
case .startedBackPaginating:
self.state.isBackPaginating = true
case .finishedBackPaginating:
self.state.isBackPaginating = false
}
}
.store(in: &cancellables)
state.contextMenuBuilder = buildContexMenuForItemId(_:)
buildTimelineViews()
if let roomAvatarUrl {
Task {
if case let .success(avatar) = await mediaProvider.loadImageFromURLString(roomAvatarUrl,
avatarSize: .room(on: .timeline)) {
state.roomAvatar = avatar
}
}
}
}
// MARK: - Public
override func process(viewAction: RoomScreenViewAction) async {
switch viewAction {
case .loadPreviousPage:
guard !state.isBackPaginating else {
return
}
switch await timelineController.paginateBackwards(Constants.backPaginationPageSize) {
default:
#warning("Treat errors")
}
case .itemAppeared(let id):
await timelineController.processItemAppearance(id)
case .itemDisappeared(let id):
await timelineController.processItemDisappearance(id)
case .linkClicked(let url):
MXLog.warning("Link clicked: \(url)")
case .sendMessage:
await sendCurrentMessage()
case .sendReaction(let key, _):
#warning("Reaction implementation awaiting SDK support.")
MXLog.warning("React with \(key) failed. Not implemented.")
case .cancelReply:
state.composerMode = .default
}
}
func stop() {
cancellables.forEach { $0.cancel() }
cancellables.removeAll()
state.contextMenuBuilder = nil
}
// MARK: - Private
private func buildTimelineViews() {
let stateItems = timelineController.timelineItems.map { item in
timelineViewFactory.buildTimelineViewFor(timelineItem: item)
}
state.items = stateItems
}
private func sendCurrentMessage() async {
guard !state.bindings.composerText.isEmpty else {
fatalError("This message should never be empty")
}
let currentMessage = state.bindings.composerText
let currentComposerState = state.composerMode
state.bindings.composerText = ""
state.composerMode = .default
switch currentComposerState {
case .reply(let itemId, _):
await timelineController.sendReply(currentMessage, to: itemId)
default:
await timelineController.sendMessage(currentMessage)
}
}
private func displayError(_ type: RoomScreenErrorType) {
switch type {
case .alert(let message):
state.bindings.alertInfo = AlertInfo(id: type,
title: ElementL10n.dialogTitleError,
message: message)
}
}
// MARK: ContextMenus
private func buildContexMenuForItemId(_ itemId: String) -> TimelineItemContextMenu {
TimelineItemContextMenu(contextMenuActions: contextMenuActionsForItemId(itemId)) { [weak self] action in
self?.processContentMenuAction(action, itemId: itemId)
}
}
private func contextMenuActionsForItemId(_ itemId: String) -> [TimelineItemContextMenuAction] {
guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemId }),
timelineItem is EventBasedTimelineItemProtocol else {
return []
}
var actions: [TimelineItemContextMenuAction] = [
.copy, .quote, .copyPermalink, .reply
]
if let item = timelineItem as? EventBasedTimelineItemProtocol, item.isOutgoing {
actions.append(.redact)
}
return actions
}
private func processContentMenuAction(_ action: TimelineItemContextMenuAction, itemId: String) {
guard let timelineItem = timelineController.timelineItems.first(where: { $0.id == itemId }),
let item = timelineItem as? EventBasedTimelineItemProtocol else {
return
}
switch action {
case .copy:
UIPasteboard.general.string = item.text
case .quote:
state.bindings.composerFocused = true
state.bindings.composerText = "> \(item.text)"
case .copyPermalink:
do {
let permalink = try PermalinkBuilder.permalinkTo(eventIdentifier: item.id, roomIdentifier: timelineController.roomId)
UIPasteboard.general.url = permalink
} catch {
displayError(.alert(ElementL10n.roomTimelinePermalinkCreationFailure))
}
case .redact:
redact(itemId)
case .reply:
state.bindings.composerFocused = true
state.composerMode = .reply(id: item.id, displayName: item.senderDisplayName ?? item.senderId)
}
switch action {
case .reply:
break
default:
state.composerMode = .default
}
}
private func redact(_ eventID: String) {
Task {
await timelineController.redact(eventID)
}
}
}