* Fix Timeline on Xcode 14/iOS 16
Raise requirement to iOS 16+
Reduce pagination jumping.
Sonarcloud fixes.
Fix verification test.
Adopt if let optional { syntax.
* Remove unused ScrollViewReader
The ScrollViewReader didn't appear to change the behaviour.
* Fix warnings on Run Scripts.
Run script build phase 'SwiftLint' will be run during every build because it does not specify any outputs. To address this warning, either add output dependencies to the script phase, or configure it to run in every build by unchecking "Based on dependency analysis" in the script phase.
215 lines
7.8 KiB
Swift
215 lines
7.8 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)
|
|
}
|
|
}
|
|
.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:
|
|
state.isBackPaginating = true
|
|
|
|
switch await timelineController.paginateBackwards(Constants.backPaginationPageSize) {
|
|
default:
|
|
state.isBackPaginating = false
|
|
}
|
|
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)
|
|
}
|
|
}
|
|
}
|