mirror of
https://gitlab.opencode.de/bwi/bundesmessenger/clients/bundesmessenger-ios.git
synced 2026-05-03 22:56:57 +02:00
Merge commit 'ace42be63764c1f1aec82d6e3448ca8980adc784' into feature/3746_merge_element_1.9.10
# Conflicts: # Config/AppConfiguration.swift # Config/AppVersion.xcconfig # Podfile.lock # Riot/Modules/Application/AppCoordinator.swift # Riot/Modules/Common/Avatar/AvatarView.swift # Riot/Modules/Room/TimelineCells/Styles/Bubble/BubbleRoomTimelineCellProvider.m # Riot/Modules/Room/TimelineCells/Styles/Plain/PlainRoomTimelineCellProvider.m # Riot/Modules/Settings/Security/SecurityViewController.m # Riot/Modules/Settings/SettingsViewController.m # Riot/Modules/TabBar/TabBarCoordinator.swift # Riot/target.yml # fastlane/Fastfile # project.yml
This commit is contained in:
+90
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
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 Foundation
|
||||
|
||||
@objc protocol ComposerCreateActionListBridgePresenterDelegate {
|
||||
func composerCreateActionListBridgePresenterDelegateDidComplete(_ coordinatorBridgePresenter: ComposerCreateActionListBridgePresenter, action: ComposerCreateAction)
|
||||
func composerCreateActionListBridgePresenterDidDismissInteractively(_ coordinatorBridgePresenter: ComposerCreateActionListBridgePresenter)
|
||||
}
|
||||
|
||||
/// ComposerCreateActionListBridgePresenter enables to start ComposerCreateActionList from a view controller.
|
||||
/// This bridge is used while waiting for global usage of coordinator pattern.
|
||||
/// **WARNING**: This class breaks the Coordinator abstraction and it has been introduced for **Objective-C compatibility only**
|
||||
/// (mainly for integration in legacy view controllers). Each bridge should be removed once the underlying Coordinator has been integrated by another Coordinator.
|
||||
@objcMembers
|
||||
final class ComposerCreateActionListBridgePresenter: NSObject {
|
||||
// MARK: - Constants
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let actions: [ComposerCreateAction]
|
||||
private var coordinator: ComposerCreateActionListCoordinator?
|
||||
|
||||
// MARK: Public
|
||||
|
||||
weak var delegate: ComposerCreateActionListBridgePresenterDelegate?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(actions: [Int]) {
|
||||
self.actions = actions.compactMap {
|
||||
ComposerCreateAction(rawValue: $0)
|
||||
}
|
||||
super.init()
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
// NOTE: Default value feature is not compatible with Objective-C.
|
||||
// func present(from viewController: UIViewController, animated: Bool) {
|
||||
// self.present(from: viewController, animated: animated)
|
||||
// }
|
||||
|
||||
func present(from viewController: UIViewController, animated: Bool) {
|
||||
let composerCreateActionListCoordinator = ComposerCreateActionListCoordinator(actions: actions)
|
||||
composerCreateActionListCoordinator.callback = { [weak self] action in
|
||||
guard let self = self else { return }
|
||||
switch action {
|
||||
case .done(let composeAction):
|
||||
self.delegate?.composerCreateActionListBridgePresenterDelegateDidComplete(self, action: composeAction)
|
||||
case .cancel:
|
||||
self.delegate?.composerCreateActionListBridgePresenterDidDismissInteractively(self)
|
||||
}
|
||||
}
|
||||
let presentable = composerCreateActionListCoordinator.toPresentable()
|
||||
viewController.present(presentable, animated: animated, completion: nil)
|
||||
composerCreateActionListCoordinator.start()
|
||||
|
||||
coordinator = composerCreateActionListCoordinator
|
||||
}
|
||||
|
||||
func dismiss(animated: Bool, completion: (() -> Void)?) {
|
||||
guard let coordinator = coordinator else {
|
||||
return
|
||||
}
|
||||
// Dismiss modal
|
||||
coordinator.toPresentable().dismiss(animated: animated) {
|
||||
self.coordinator = nil
|
||||
|
||||
if let completion = completion {
|
||||
completion()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
/// Actions returned by the coordinator callback
|
||||
enum ComposerCreateActionListCoordinatorAction {
|
||||
case done(ComposerCreateAction)
|
||||
case cancel
|
||||
}
|
||||
|
||||
final class ComposerCreateActionListCoordinator: NSObject, Coordinator, Presentable, UISheetPresentationControllerDelegate {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let hostingController: UIViewController
|
||||
private var view: ComposerCreateActionList
|
||||
private var viewModel: ComposerCreateActionListViewModel
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
var callback: ((ComposerCreateActionListCoordinatorAction) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(actions: [ComposerCreateAction]) {
|
||||
viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState(actions: actions))
|
||||
view = ComposerCreateActionList(viewModel: viewModel.context)
|
||||
let hostingVC = VectorHostingController(rootView: view)
|
||||
hostingVC.bottomSheetPreferences = VectorHostingBottomSheetPreferences(
|
||||
detents: [.medium],
|
||||
prefersGrabberVisible: true,
|
||||
cornerRadius: 20
|
||||
)
|
||||
hostingController = hostingVC
|
||||
super.init()
|
||||
hostingVC.presentationController?.delegate = self
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() {
|
||||
MXLog.debug("[ComposerCreateActionListCoordinator] did start.")
|
||||
viewModel.callback = { result in
|
||||
switch result {
|
||||
case .done(let action):
|
||||
self.callback?(.done(action))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
hostingController
|
||||
}
|
||||
|
||||
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
|
||||
callback?(.cancel)
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum MockComposerCreateActionListScreenState: MockScreenState, CaseIterable {
|
||||
case partialList
|
||||
case fullList
|
||||
|
||||
var screenType: Any.Type {
|
||||
ComposerCreateActionList.self
|
||||
}
|
||||
|
||||
var screenView: ([Any], AnyView) {
|
||||
let actions: [ComposerCreateAction]
|
||||
switch self {
|
||||
case .partialList:
|
||||
actions = [.photoLibrary, .polls]
|
||||
case .fullList:
|
||||
actions = ComposerCreateAction.allCases
|
||||
}
|
||||
let viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState(actions: actions))
|
||||
|
||||
return (
|
||||
[viewModel],
|
||||
AnyView(ComposerCreateActionList(viewModel: viewModel.context))
|
||||
)
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
// MARK: View model
|
||||
|
||||
enum ComposerCreateActionListViewAction {
|
||||
// The user selected an action
|
||||
case selectAction(ComposerCreateAction)
|
||||
}
|
||||
|
||||
enum ComposerCreateActionListViewModelResult: Equatable {
|
||||
// The user selected an action and is done with the screen
|
||||
case done(ComposerCreateAction)
|
||||
}
|
||||
|
||||
// MARK: View
|
||||
|
||||
struct ComposerCreateActionListViewState: BindableState {
|
||||
/// The list of composer create actions to display to the user
|
||||
let actions: [ComposerCreateAction]
|
||||
}
|
||||
|
||||
@objc enum ComposerCreateAction: Int {
|
||||
/// Upload a photo/video from the media library
|
||||
case photoLibrary
|
||||
/// Add a sticker
|
||||
case stickers
|
||||
/// Upload an attachment
|
||||
case attachments
|
||||
/// Voice broadcast
|
||||
case voiceBroadcast
|
||||
/// Create a Poll
|
||||
case polls
|
||||
/// Add a location
|
||||
case location
|
||||
/// Upload a photo or video from the camera
|
||||
case camera
|
||||
}
|
||||
|
||||
extension ComposerCreateAction: Equatable, CaseIterable, Identifiable {
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
extension ComposerCreateAction {
|
||||
var title: String {
|
||||
switch self {
|
||||
case .photoLibrary:
|
||||
return VectorL10n.wysiwygComposerStartActionMediaPicker
|
||||
case .stickers:
|
||||
return VectorL10n.wysiwygComposerStartActionStickers
|
||||
case .attachments:
|
||||
return VectorL10n.wysiwygComposerStartActionAttachments
|
||||
case .voiceBroadcast:
|
||||
return VectorL10n.wysiwygComposerStartActionVoiceBroadcast
|
||||
case .polls:
|
||||
return VectorL10n.wysiwygComposerStartActionPolls
|
||||
case .location:
|
||||
return VectorL10n.wysiwygComposerStartActionLocation
|
||||
case .camera:
|
||||
return VectorL10n.wysiwygComposerStartActionCamera
|
||||
}
|
||||
}
|
||||
|
||||
var accessibilityIdentifier: String {
|
||||
switch self {
|
||||
case .photoLibrary:
|
||||
return "photoLibraryAction"
|
||||
case .stickers:
|
||||
return "stickersAction"
|
||||
case .attachments:
|
||||
return "attachmentsAction"
|
||||
case .voiceBroadcast:
|
||||
return "voiceBroadcastAction"
|
||||
case .polls:
|
||||
return "pollsAction"
|
||||
case .location:
|
||||
return "locationAction"
|
||||
case .camera:
|
||||
return "cameraAction"
|
||||
}
|
||||
}
|
||||
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .photoLibrary:
|
||||
return Asset.Images.actionMediaLibrary.name
|
||||
case .stickers:
|
||||
return Asset.Images.actionSticker.name
|
||||
case .attachments:
|
||||
return Asset.Images.actionFile.name
|
||||
case .voiceBroadcast:
|
||||
return Asset.Images.actionLive.name
|
||||
case .polls:
|
||||
return Asset.Images.actionPoll.name
|
||||
case .location:
|
||||
return Asset.Images.actionLocation.name
|
||||
case .camera:
|
||||
return Asset.Images.actionCamera.name
|
||||
}
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// 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 RiotSwiftUI
|
||||
import XCTest
|
||||
|
||||
class ComposerCreateActionListUITests: MockScreenTestCase {
|
||||
func testFullList() throws {
|
||||
app.goToScreenWithIdentifier(MockComposerCreateActionListScreenState.fullList.title)
|
||||
|
||||
XCTAssert(app.staticTexts[ComposerCreateAction.photoLibrary.accessibilityIdentifier].exists)
|
||||
XCTAssert(app.staticTexts[ComposerCreateAction.location.accessibilityIdentifier].exists)
|
||||
}
|
||||
|
||||
func testPartialList() throws {
|
||||
app.goToScreenWithIdentifier(MockComposerCreateActionListScreenState.partialList.title)
|
||||
|
||||
XCTAssert(app.staticTexts[ComposerCreateAction.photoLibrary.accessibilityIdentifier].exists)
|
||||
XCTAssertFalse(app.staticTexts[ComposerCreateAction.location.accessibilityIdentifier].exists)
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
@testable import RiotSwiftUI
|
||||
import SwiftUI
|
||||
import XCTest
|
||||
|
||||
class ComposerCreateActionListTests: XCTestCase {
|
||||
var viewModel: ComposerCreateActionListViewModel!
|
||||
var context: ComposerCreateActionListViewModel.Context!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState(actions: ComposerCreateAction.allCases))
|
||||
context = viewModel.context
|
||||
}
|
||||
|
||||
func testSelection() throws {
|
||||
let actionToSelect: ComposerCreateAction = .attachments
|
||||
var result: ComposerCreateActionListViewModelResult?
|
||||
viewModel.callback = { callbackResult in
|
||||
result = callbackResult
|
||||
}
|
||||
|
||||
viewModel.context.send(viewAction: .selectAction(actionToSelect))
|
||||
|
||||
XCTAssertEqual(result, .done(actionToSelect))
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
struct ComposerCreateActionList: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var viewModel: ComposerCreateActionListViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
VStack(alignment: .leading) {
|
||||
ForEach(viewModel.viewState.actions) { action in
|
||||
HStack(spacing: 16) {
|
||||
Image(action.icon)
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(theme.colors.accent)
|
||||
Text(action.title)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.font(theme.fonts.body)
|
||||
.accessibilityIdentifier(action.accessibilityIdentifier)
|
||||
Spacer()
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
viewModel.send(viewAction: .selectAction(action))
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
Spacer()
|
||||
}.background(theme.colors.background.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct ComposerCreateActionList_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockComposerCreateActionListScreenState.stateRenderer
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup()
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
protocol ComposerCreateActionListViewModelProtocol {
|
||||
var callback: ((ComposerCreateActionListViewModelResult) -> Void)? { get set }
|
||||
var context: ComposerCreateActionListViewModelType.Context { get }
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
//
|
||||
// Copyright 2021 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 ComposerCreateActionListViewModelType = StateStoreViewModel<ComposerCreateActionListViewState, ComposerCreateActionListViewAction>
|
||||
|
||||
class ComposerCreateActionListViewModel: ComposerCreateActionListViewModelType, ComposerCreateActionListViewModelProtocol {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: ((ComposerCreateActionListViewModelResult) -> Void)?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: ComposerCreateActionListViewAction) {
|
||||
switch viewAction {
|
||||
case .selectAction(let action):
|
||||
callback?(.done(action))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
enum MockComposerScreenState: MockScreenState, CaseIterable {
|
||||
case send
|
||||
case edit
|
||||
case reply
|
||||
|
||||
var screenType: Any.Type {
|
||||
Composer.self
|
||||
}
|
||||
|
||||
var screenView: ([Any], AnyView) {
|
||||
let viewModel: ComposerViewModel
|
||||
|
||||
switch self {
|
||||
case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState())
|
||||
case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit))
|
||||
case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", sendMode: .reply))
|
||||
}
|
||||
|
||||
let wysiwygviewModel = WysiwygComposerViewModel(minHeight: 20, maxHeight: 360)
|
||||
|
||||
viewModel.callback = { [weak viewModel, weak wysiwygviewModel] result in
|
||||
guard let viewModel = viewModel else { return }
|
||||
switch result {
|
||||
case .cancel:
|
||||
if viewModel.sendMode == .edit {
|
||||
wysiwygviewModel?.setHtmlContent("")
|
||||
}
|
||||
viewModel.sendMode = .send
|
||||
default: break
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
[viewModel, wysiwygviewModel],
|
||||
AnyView(VStack {
|
||||
Spacer()
|
||||
Composer(viewModel: viewModel.context, wysiwygViewModel: wysiwygviewModel, sendMessageAction: { _ in }, showSendMediaActions: { })
|
||||
}.frame(
|
||||
minWidth: 0,
|
||||
maxWidth: .infinity,
|
||||
minHeight: 0,
|
||||
maxHeight: .infinity,
|
||||
alignment: .topLeading
|
||||
))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
// MARK: View
|
||||
|
||||
/// An item in the toolbar
|
||||
struct FormatItem {
|
||||
/// The type of the item
|
||||
let type: FormatType
|
||||
/// Whether it is active(highlighted)
|
||||
let active: Bool
|
||||
/// Whether it is disabled or enabled
|
||||
let disabled: Bool
|
||||
}
|
||||
|
||||
/// The types of formatting actions
|
||||
enum FormatType {
|
||||
case bold
|
||||
case italic
|
||||
case strikethrough
|
||||
case underline
|
||||
}
|
||||
|
||||
extension FormatType: CaseIterable, Identifiable {
|
||||
var id: Self { self }
|
||||
}
|
||||
|
||||
extension FormatItem: Identifiable {
|
||||
var id: FormatType { type }
|
||||
}
|
||||
|
||||
extension FormatItem {
|
||||
/// The icon for the item
|
||||
var icon: String {
|
||||
switch type {
|
||||
case .bold:
|
||||
return Asset.Images.bold.name
|
||||
case .italic:
|
||||
return Asset.Images.italic.name
|
||||
case .strikethrough:
|
||||
return Asset.Images.strikethrough.name
|
||||
case .underline:
|
||||
return Asset.Images.underlined.name
|
||||
}
|
||||
}
|
||||
|
||||
var accessibilityIdentifier: String {
|
||||
switch type {
|
||||
case .bold:
|
||||
return "boldButton"
|
||||
case .italic:
|
||||
return "italicButton"
|
||||
case .strikethrough:
|
||||
return "strikethroughButton"
|
||||
case .underline:
|
||||
return "underlineButton"
|
||||
}
|
||||
}
|
||||
|
||||
var accessibilityLabel: String {
|
||||
switch type {
|
||||
case .bold:
|
||||
return VectorL10n.wysiwygComposerFormatActionBold
|
||||
case .italic:
|
||||
return VectorL10n.wysiwygComposerFormatActionItalic
|
||||
case .strikethrough:
|
||||
return VectorL10n.wysiwygComposerFormatActionStrikethrough
|
||||
case .underline:
|
||||
return VectorL10n.wysiwygComposerFormatActionUnderline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FormatType {
|
||||
/// Convenience method to map it to the external ViewModel action
|
||||
var action: WysiwygAction {
|
||||
switch self {
|
||||
case .bold:
|
||||
return .bold
|
||||
case .italic:
|
||||
return .italic
|
||||
case .strikethrough:
|
||||
return .strikeThrough
|
||||
case .underline:
|
||||
return .underline
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: We probably don't need to expose this, clean up.
|
||||
|
||||
/// Convenience method to map it to the external rust binging action
|
||||
var composerAction: ComposerAction {
|
||||
switch self {
|
||||
case .bold:
|
||||
return .bold
|
||||
case .italic:
|
||||
return .italic
|
||||
case .strikethrough:
|
||||
return .strikeThrough
|
||||
case .underline:
|
||||
return .underline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ComposerSendMode: Equatable {
|
||||
case send
|
||||
case edit
|
||||
case reply
|
||||
case createDM
|
||||
}
|
||||
|
||||
enum ComposerViewAction: Equatable {
|
||||
case cancel
|
||||
case contentDidChange(isEmpty: Bool)
|
||||
}
|
||||
|
||||
enum ComposerViewModelResult: Equatable {
|
||||
case cancel
|
||||
case contentDidChange(isEmpty: Bool)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
struct ComposerViewState: BindableState {
|
||||
var eventSenderDisplayName: String?
|
||||
var sendMode: ComposerSendMode = .send
|
||||
var placeholder: String?
|
||||
}
|
||||
|
||||
extension ComposerViewState {
|
||||
var shouldDisplayContext: Bool {
|
||||
return sendMode == .edit || sendMode == .reply
|
||||
}
|
||||
|
||||
var contextDescription: String? {
|
||||
switch sendMode {
|
||||
case .reply:
|
||||
guard let eventSenderDisplayName = eventSenderDisplayName else { return nil }
|
||||
return VectorL10n.roomMessageReplyingTo(eventSenderDisplayName)
|
||||
case .edit: return VectorL10n.roomMessageEditing
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
var contextImageName: String? {
|
||||
switch sendMode {
|
||||
case .edit: return Asset.Images.inputEditIcon.name
|
||||
case .reply: return Asset.Images.inputReplyIcon.name
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
//
|
||||
// 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 RiotSwiftUI
|
||||
import XCTest
|
||||
|
||||
final class ComposerUITests: MockScreenTestCase {
|
||||
func testSendMode() throws {
|
||||
app.goToScreenWithIdentifier(MockComposerScreenState.send.title)
|
||||
|
||||
XCTAssertFalse(app.buttons["cancelButton"].exists)
|
||||
let wysiwygTextView = app.textViews.allElementsBoundByIndex[0]
|
||||
XCTAssertTrue(wysiwygTextView.exists)
|
||||
let sendButton = app.buttons["sendButton"]
|
||||
XCTAssertFalse(sendButton.exists)
|
||||
wysiwygTextView.tap()
|
||||
wysiwygTextView.typeText("test")
|
||||
XCTAssertTrue(sendButton.exists)
|
||||
XCTAssertFalse(app.buttons["editButton"].exists)
|
||||
|
||||
let maximiseButton = app.buttons["maximiseButton"]
|
||||
let minimiseButton = app.buttons["minimiseButton"]
|
||||
XCTAssertFalse(minimiseButton.exists)
|
||||
XCTAssertTrue(maximiseButton.exists)
|
||||
|
||||
maximiseButton.tap()
|
||||
XCTAssertTrue(minimiseButton.exists)
|
||||
XCTAssertFalse(maximiseButton.exists)
|
||||
|
||||
minimiseButton.tap()
|
||||
XCTAssertFalse(minimiseButton.exists)
|
||||
XCTAssertTrue(maximiseButton.exists)
|
||||
}
|
||||
|
||||
func testReplyMode() throws {
|
||||
app.goToScreenWithIdentifier(MockComposerScreenState.reply.title)
|
||||
|
||||
let wysiwygTextView = app.textViews.allElementsBoundByIndex[0]
|
||||
XCTAssertTrue(wysiwygTextView.exists)
|
||||
let sendButton = app.buttons["sendButton"]
|
||||
XCTAssertFalse(sendButton.exists)
|
||||
|
||||
let cancelButton = app.buttons["cancelButton"]
|
||||
XCTAssertTrue(cancelButton.exists)
|
||||
|
||||
let contextDescription = app.staticTexts["contextDescription"]
|
||||
XCTAssertTrue(contextDescription.exists)
|
||||
XCTAssert(contextDescription.label == VectorL10n.roomMessageReplyingTo("TestUser"))
|
||||
|
||||
wysiwygTextView.tap()
|
||||
wysiwygTextView.typeText("test")
|
||||
XCTAssertTrue(sendButton.exists)
|
||||
XCTAssertFalse(app.buttons["editButton"].exists)
|
||||
|
||||
cancelButton.tap()
|
||||
let textViewContent = wysiwygTextView.value as! String
|
||||
XCTAssertFalse(textViewContent.isEmpty)
|
||||
XCTAssertFalse(cancelButton.exists)
|
||||
|
||||
let maximiseButton = app.buttons["maximiseButton"]
|
||||
let minimiseButton = app.buttons["minimiseButton"]
|
||||
XCTAssertFalse(minimiseButton.exists)
|
||||
XCTAssertTrue(maximiseButton.exists)
|
||||
|
||||
maximiseButton.tap()
|
||||
XCTAssertTrue(minimiseButton.exists)
|
||||
XCTAssertFalse(maximiseButton.exists)
|
||||
|
||||
minimiseButton.tap()
|
||||
XCTAssertFalse(minimiseButton.exists)
|
||||
XCTAssertTrue(maximiseButton.exists)
|
||||
}
|
||||
|
||||
func testEditMode() throws {
|
||||
app.goToScreenWithIdentifier(MockComposerScreenState.edit.title)
|
||||
|
||||
let wysiwygTextView = app.textViews.allElementsBoundByIndex[0]
|
||||
XCTAssertTrue(wysiwygTextView.exists)
|
||||
let editButton = app.buttons["editButton"]
|
||||
XCTAssertFalse(editButton.exists)
|
||||
|
||||
let cancelButton = app.buttons["cancelButton"]
|
||||
XCTAssertTrue(cancelButton.exists)
|
||||
|
||||
let contextDescription = app.staticTexts["contextDescription"]
|
||||
XCTAssertTrue(contextDescription.exists)
|
||||
XCTAssert(contextDescription.label == VectorL10n.roomMessageEditing)
|
||||
|
||||
wysiwygTextView.tap()
|
||||
wysiwygTextView.typeText("test")
|
||||
XCTAssertTrue(editButton.exists)
|
||||
XCTAssertFalse(app.buttons["sendButton"].exists)
|
||||
|
||||
cancelButton.tap()
|
||||
let textViewContent = wysiwygTextView.value as! String
|
||||
XCTAssertTrue(textViewContent.isEmpty)
|
||||
XCTAssertFalse(cancelButton.exists)
|
||||
|
||||
let maximiseButton = app.buttons["maximiseButton"]
|
||||
let minimiseButton = app.buttons["minimiseButton"]
|
||||
XCTAssertFalse(minimiseButton.exists)
|
||||
XCTAssertTrue(maximiseButton.exists)
|
||||
|
||||
maximiseButton.tap()
|
||||
XCTAssertTrue(minimiseButton.exists)
|
||||
XCTAssertFalse(maximiseButton.exists)
|
||||
|
||||
minimiseButton.tap()
|
||||
XCTAssertFalse(minimiseButton.exists)
|
||||
XCTAssertTrue(maximiseButton.exists)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
|
||||
@testable import RiotSwiftUI
|
||||
import SwiftUI
|
||||
import XCTest
|
||||
|
||||
final class ComposerViewModelTests: XCTestCase {
|
||||
var viewModel: ComposerViewModel!
|
||||
var context: ComposerViewModel.Context!
|
||||
|
||||
override func setUpWithError() throws {
|
||||
viewModel = ComposerViewModel(initialViewState: ComposerViewState())
|
||||
context = viewModel.context
|
||||
}
|
||||
|
||||
func testSendState() {
|
||||
viewModel.sendMode = .send
|
||||
XCTAssert(context.viewState.sendMode == .send)
|
||||
XCTAssert(context.viewState.shouldDisplayContext == false)
|
||||
XCTAssert(context.viewState.eventSenderDisplayName == nil)
|
||||
XCTAssert(context.viewState.contextImageName == nil)
|
||||
XCTAssert(context.viewState.contextDescription == nil)
|
||||
}
|
||||
|
||||
func testEditState() {
|
||||
viewModel.sendMode = .edit
|
||||
XCTAssert(context.viewState.sendMode == .edit)
|
||||
XCTAssert(context.viewState.shouldDisplayContext == true)
|
||||
XCTAssert(context.viewState.eventSenderDisplayName == nil)
|
||||
XCTAssert(context.viewState.contextImageName == Asset.Images.inputEditIcon.name)
|
||||
XCTAssert(context.viewState.contextDescription == VectorL10n.roomMessageEditing)
|
||||
}
|
||||
|
||||
func testReplyState() {
|
||||
viewModel.eventSenderDisplayName = "TestUser"
|
||||
viewModel.sendMode = .reply
|
||||
XCTAssert(context.viewState.sendMode == .reply)
|
||||
XCTAssert(context.viewState.shouldDisplayContext == true)
|
||||
XCTAssert(context.viewState.eventSenderDisplayName == "TestUser")
|
||||
XCTAssert(context.viewState.contextImageName == Asset.Images.inputReplyIcon.name)
|
||||
XCTAssert(context.viewState.contextDescription == VectorL10n.roomMessageReplyingTo("TestUser"))
|
||||
}
|
||||
|
||||
func testCancelTapped() {
|
||||
var result: ComposerViewModelResult!
|
||||
viewModel.callback = { value in
|
||||
result = value
|
||||
}
|
||||
context.send(viewAction: .cancel)
|
||||
XCTAssert(result == .cancel)
|
||||
}
|
||||
|
||||
func testPlaceholder() {
|
||||
XCTAssert(context.viewState.placeholder == nil)
|
||||
viewModel.placeholder = "Placeholder Test"
|
||||
XCTAssert(context.viewState.placeholder == "Placeholder Test")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//
|
||||
// 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 DSBottomSheet
|
||||
import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
struct Composer: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
@State private var focused = false
|
||||
@State private var isActionButtonShowing = false
|
||||
|
||||
private let horizontalPadding: CGFloat = 12
|
||||
private let borderHeight: CGFloat = 40
|
||||
private let minTextViewHeight: CGFloat = 20
|
||||
private var verticalPadding: CGFloat {
|
||||
(borderHeight - minTextViewHeight) / 2
|
||||
}
|
||||
|
||||
private var topPadding: CGFloat {
|
||||
viewModel.viewState.shouldDisplayContext ? 0 : verticalPadding
|
||||
}
|
||||
|
||||
private var cornerRadius: CGFloat {
|
||||
if viewModel.viewState.shouldDisplayContext || wysiwygViewModel.idealHeight > minTextViewHeight {
|
||||
return 14
|
||||
} else {
|
||||
return borderHeight / 2
|
||||
}
|
||||
}
|
||||
|
||||
private var actionButtonAccessibilityIdentifier: String {
|
||||
viewModel.viewState.sendMode == .edit ? "editButton" : "sendButton"
|
||||
}
|
||||
|
||||
private var toggleButtonAcccessibilityIdentifier: String {
|
||||
wysiwygViewModel.maximised ? "minimiseButton" : "maximiseButton"
|
||||
}
|
||||
|
||||
private var toggleButtonImageName: String {
|
||||
wysiwygViewModel.maximised ? Asset.Images.minimiseComposer.name : Asset.Images.maximiseComposer.name
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
focused ? theme.colors.quarterlyContent : theme.colors.quinaryContent
|
||||
}
|
||||
|
||||
private var formatItems: [FormatItem] {
|
||||
FormatType.allCases.map { type in
|
||||
FormatItem(
|
||||
type: type,
|
||||
active: wysiwygViewModel.reversedActions.contains(type.composerAction),
|
||||
disabled: wysiwygViewModel.disabledActions.contains(type.composerAction)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var viewModel: ComposerViewModelType.Context
|
||||
@ObservedObject var wysiwygViewModel: WysiwygComposerViewModel
|
||||
|
||||
let sendMessageAction: (WysiwygComposerContent) -> Void
|
||||
let showSendMediaActions: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
let rect = RoundedRectangle(cornerRadius: cornerRadius)
|
||||
VStack(spacing: 12) {
|
||||
if viewModel.viewState.shouldDisplayContext {
|
||||
HStack {
|
||||
if let imageName = viewModel.viewState.contextImageName {
|
||||
Image(imageName)
|
||||
.foregroundColor(theme.colors.tertiaryContent)
|
||||
}
|
||||
if let contextDescription = viewModel.viewState.contextDescription {
|
||||
Text(contextDescription)
|
||||
.accessibilityIdentifier("contextDescription")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundColor(theme.colors.secondaryContent)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
viewModel.send(viewAction: .cancel)
|
||||
} label: {
|
||||
Image(Asset.Images.inputCloseIcon.name)
|
||||
.foregroundColor(theme.colors.tertiaryContent)
|
||||
}
|
||||
.accessibilityIdentifier("cancelButton")
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
}
|
||||
HStack(alignment: .top, spacing: 0) {
|
||||
WysiwygComposerView(
|
||||
focused: $focused,
|
||||
content: wysiwygViewModel.content,
|
||||
replaceText: wysiwygViewModel.replaceText,
|
||||
select: wysiwygViewModel.select,
|
||||
didUpdateText: wysiwygViewModel.didUpdateText
|
||||
)
|
||||
.tintColor(theme.colors.accent)
|
||||
.placeholder(viewModel.viewState.placeholder, color: theme.colors.tertiaryContent)
|
||||
.frame(height: wysiwygViewModel.idealHeight)
|
||||
.onAppear {
|
||||
wysiwygViewModel.setup()
|
||||
}
|
||||
Button {
|
||||
wysiwygViewModel.maximised.toggle()
|
||||
} label: {
|
||||
Image(toggleButtonImageName)
|
||||
.resizable()
|
||||
.foregroundColor(theme.colors.tertiaryContent)
|
||||
.frame(width: 16, height: 16)
|
||||
}
|
||||
.accessibilityIdentifier(toggleButtonAcccessibilityIdentifier)
|
||||
.padding(.leading, 12)
|
||||
.padding(.trailing, 4)
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.top, topPadding)
|
||||
.padding(.bottom, verticalPadding)
|
||||
}
|
||||
.clipShape(rect)
|
||||
.overlay(rect.stroke(borderColor, lineWidth: 1))
|
||||
.animation(.easeInOut(duration: 0.1), value: wysiwygViewModel.idealHeight)
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.top, 8)
|
||||
.onTapGesture {
|
||||
if !focused {
|
||||
focused = true
|
||||
}
|
||||
}
|
||||
HStack(spacing: 0) {
|
||||
Button {
|
||||
showSendMediaActions()
|
||||
} label: {
|
||||
Image(Asset.Images.startComposeModule.name)
|
||||
.resizable()
|
||||
.foregroundColor(theme.colors.tertiaryContent)
|
||||
.frame(width: 14, height: 14)
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
.background(Circle().fill(theme.colors.system))
|
||||
.padding(.trailing, 8)
|
||||
.accessibilityLabel(VectorL10n.create)
|
||||
FormattingToolbar(formatItems: formatItems) { type in
|
||||
wysiwygViewModel.apply(type.action)
|
||||
}
|
||||
.frame(height: 44)
|
||||
Spacer()
|
||||
Button {
|
||||
sendMessageAction(wysiwygViewModel.content)
|
||||
wysiwygViewModel.clearContent()
|
||||
} label: {
|
||||
if viewModel.viewState.sendMode == .edit {
|
||||
Image(Asset.Images.saveIcon.name)
|
||||
} else {
|
||||
Image(Asset.Images.sendIcon.name)
|
||||
}
|
||||
}
|
||||
.frame(width: 36, height: 36)
|
||||
.padding(.leading, 8)
|
||||
.isHidden(!isActionButtonShowing)
|
||||
.accessibilityIdentifier(actionButtonAccessibilityIdentifier)
|
||||
.accessibilityLabel(VectorL10n.send)
|
||||
.onChange(of: wysiwygViewModel.isContentEmpty) { isEmpty in
|
||||
viewModel.send(viewAction: .contentDidChange(isEmpty: isEmpty))
|
||||
withAnimation(.easeInOut(duration: 0.15)) {
|
||||
isActionButtonShowing = !isEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Previews
|
||||
|
||||
struct Composer_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockComposerScreenState.stateRenderer
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//
|
||||
// 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
|
||||
import WysiwygComposer
|
||||
|
||||
struct FormattingToolbar: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
/// The list of items to render in the toolbar
|
||||
var formatItems: [FormatItem]
|
||||
/// The action when an item is selected
|
||||
var formatAction: (FormatType) -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(formatItems) { item in
|
||||
Button {
|
||||
formatAction(item.type)
|
||||
} label: {
|
||||
Image(item.icon)
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(item.active ? theme.colors.accent : theme.colors.tertiaryContent)
|
||||
}
|
||||
.disabled(item.disabled)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(item.active ? theme.colors.accent.opacity(0.1) : theme.colors.background)
|
||||
.cornerRadius(8)
|
||||
.accessibilityIdentifier(item.accessibilityIdentifier)
|
||||
.accessibilityLabel(item.accessibilityLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct FormattingToolbar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
FormattingToolbar(formatItems: [
|
||||
FormatItem(type: .bold, active: true, disabled: false),
|
||||
FormatItem(type: .italic, active: false, disabled: false),
|
||||
FormatItem(type: .strikethrough, active: true, disabled: false),
|
||||
FormatItem(type: .underline, active: false, disabled: true)
|
||||
], formatAction: { _ in })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// 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 ComposerViewModelType = StateStoreViewModel<ComposerViewState, ComposerViewAction>
|
||||
|
||||
final class ComposerViewModel: ComposerViewModelType, ComposerViewModelProtocol {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var callback: ((ComposerViewModelResult) -> Void)?
|
||||
|
||||
var sendMode: ComposerSendMode {
|
||||
get {
|
||||
state.sendMode
|
||||
}
|
||||
set {
|
||||
state.sendMode = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var eventSenderDisplayName: String? {
|
||||
get {
|
||||
state.eventSenderDisplayName
|
||||
}
|
||||
set {
|
||||
state.eventSenderDisplayName = newValue
|
||||
}
|
||||
}
|
||||
|
||||
var placeholder: String? {
|
||||
get {
|
||||
state.placeholder
|
||||
}
|
||||
set {
|
||||
state.placeholder = newValue
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: ComposerViewAction) {
|
||||
switch viewAction {
|
||||
case .cancel:
|
||||
callback?(.cancel)
|
||||
case let .contentDidChange(isEmpty):
|
||||
callback?(.contentDidChange(isEmpty: isEmpty))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
protocol ComposerViewModelProtocol {
|
||||
var context: ComposerViewModelType.Context { get }
|
||||
var callback: ((ComposerViewModelResult) -> Void)? { get set }
|
||||
var sendMode: ComposerSendMode { get set }
|
||||
var eventSenderDisplayName: String? { get set }
|
||||
var placeholder: String? { get set }
|
||||
}
|
||||
@@ -84,8 +84,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
|
||||
func start() { }
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
VectorHostingController(rootView: TimelinePollView(viewModel: viewModel.context),
|
||||
forceZeroSafeAreaInsets: true)
|
||||
VectorHostingController(rootView: TimelinePollView(viewModel: viewModel.context))
|
||||
}
|
||||
|
||||
func canEndPoll() -> Bool {
|
||||
|
||||
@@ -26,13 +26,13 @@ class TimelinePollProvider {
|
||||
|
||||
/// Create or retrieve the poll timeline coordinator for this event and return
|
||||
/// a view to be displayed in the timeline
|
||||
func buildTimelinePollViewForEvent(_ event: MXEvent) -> UIView? {
|
||||
func buildTimelinePollVCForEvent(_ event: MXEvent) -> UIViewController? {
|
||||
guard let session = session, let room = session.room(withRoomId: event.roomId) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let coordinator = coordinatorsForEventIdentifiers[event.eventId] {
|
||||
return coordinator.toPresentable().view
|
||||
return coordinator.toPresentable()
|
||||
}
|
||||
|
||||
let parameters = TimelinePollCoordinatorParameters(session: session, room: room, pollStartEvent: event)
|
||||
@@ -42,7 +42,7 @@ class TimelinePollProvider {
|
||||
|
||||
coordinatorsForEventIdentifiers[event.eventId] = coordinator
|
||||
|
||||
return coordinator.toPresentable().view
|
||||
return coordinator.toPresentable()
|
||||
}
|
||||
|
||||
/// Retrieve the poll timeline coordinator for the given event or nil if it hasn't been created yet
|
||||
|
||||
@@ -24,36 +24,45 @@ class TimelinePollUITests: MockScreenTestCase {
|
||||
XCTAssert(app.staticTexts["Question"].exists)
|
||||
XCTAssert(app.staticTexts["20 votes cast"].exists)
|
||||
|
||||
XCTAssert(app.buttons["First, 10 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "10 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "50%")
|
||||
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "5 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "25%")
|
||||
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "15 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "75%")
|
||||
|
||||
XCTAssert(app.buttons["Second, 5 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%")
|
||||
app.buttons["PollAnswerOption0"].tap()
|
||||
|
||||
XCTAssert(app.buttons["Third, 15 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "11 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "55%")
|
||||
|
||||
app.buttons["First, 10 votes"].tap()
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "4 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "20%")
|
||||
|
||||
XCTAssert(app.buttons["First, 11 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["First, 11 votes"].value as! String, "55%")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "15 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "75%")
|
||||
|
||||
XCTAssert(app.buttons["Second, 4 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["Second, 4 votes"].value as! String, "20%")
|
||||
app.buttons["PollAnswerOption2"].tap()
|
||||
|
||||
XCTAssert(app.buttons["Third, 15 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "10 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "50%")
|
||||
|
||||
app.buttons["Third, 15 votes"].tap()
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "4 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "20%")
|
||||
|
||||
XCTAssert(app.buttons["First, 10 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
|
||||
|
||||
XCTAssert(app.buttons["Second, 4 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["Second, 4 votes"].value as! String, "20%")
|
||||
|
||||
XCTAssert(app.buttons["Third, 16 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["Third, 16 votes"].value as! String, "80%")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "16 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "80%")
|
||||
}
|
||||
|
||||
func testOpenUndisclosedPoll() {
|
||||
@@ -62,29 +71,29 @@ class TimelinePollUITests: MockScreenTestCase {
|
||||
XCTAssert(app.staticTexts["Question"].exists)
|
||||
XCTAssert(app.staticTexts["20 votes cast"].exists)
|
||||
|
||||
XCTAssert(!app.buttons["First, 10 votes"].exists)
|
||||
XCTAssert(app.buttons["First"].exists)
|
||||
XCTAssertTrue((app.buttons["First"].value as! String).isEmpty)
|
||||
|
||||
XCTAssert(!app.buttons["Second, 5 votes"].exists)
|
||||
XCTAssert(app.buttons["Second"].exists)
|
||||
XCTAssertTrue((app.buttons["Second"].value as! String).isEmpty)
|
||||
|
||||
XCTAssert(!app.buttons["Third, 15 votes"].exists)
|
||||
XCTAssert(app.buttons["Third"].exists)
|
||||
XCTAssertTrue((app.buttons["Third"].value as! String).isEmpty)
|
||||
|
||||
app.buttons["First"].tap()
|
||||
|
||||
XCTAssert(app.buttons["First"].exists)
|
||||
XCTAssert(app.buttons["Second"].exists)
|
||||
XCTAssert(app.buttons["Third"].exists)
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
|
||||
XCTAssert(!app.staticTexts["PollAnswerOption0Count"].exists)
|
||||
XCTAssert(!app.progressIndicators["PollAnswerOption0Progress"].exists)
|
||||
|
||||
app.buttons["Third"].tap()
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
|
||||
XCTAssert(!app.staticTexts["PollAnswerOption1Count"].exists)
|
||||
XCTAssert(!app.progressIndicators["PollAnswerOption1Progress"].exists)
|
||||
|
||||
XCTAssert(app.buttons["First"].exists)
|
||||
XCTAssert(app.buttons["Second"].exists)
|
||||
XCTAssert(app.buttons["Third"].exists)
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
|
||||
XCTAssert(!app.staticTexts["PollAnswerOption2Count"].exists)
|
||||
XCTAssert(!app.progressIndicators["PollAnswerOption2Progress"].exists)
|
||||
|
||||
app.buttons["PollAnswerOption0"].tap()
|
||||
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
|
||||
|
||||
app.buttons["PollAnswerOption2"].tap()
|
||||
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
|
||||
}
|
||||
|
||||
func testClosedDisclosedPoll() {
|
||||
@@ -100,25 +109,31 @@ class TimelinePollUITests: MockScreenTestCase {
|
||||
private func checkClosedPoll() {
|
||||
XCTAssert(app.staticTexts["Question"].exists)
|
||||
XCTAssert(app.staticTexts["Final results based on 20 votes"].exists)
|
||||
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "10 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "50%")
|
||||
|
||||
XCTAssert(app.buttons["First, 10 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "5 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "25%")
|
||||
|
||||
XCTAssert(app.buttons["Second, 5 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "15 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "75%")
|
||||
|
||||
XCTAssert(app.buttons["Third, 15 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
|
||||
app.buttons["PollAnswerOption0"].tap()
|
||||
|
||||
app.buttons["First, 10 votes"].tap()
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption0Label"].label, "First")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption0Count"].label, "10 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption0Progress"].value as? String, "50%")
|
||||
|
||||
XCTAssert(app.buttons["First, 10 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption1Label"].label, "Second")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption1Count"].label, "5 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption1Progress"].value as? String, "25%")
|
||||
|
||||
XCTAssert(app.buttons["Second, 5 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%")
|
||||
|
||||
XCTAssert(app.buttons["Third, 15 votes"].exists)
|
||||
XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption2Label"].label, "Third")
|
||||
XCTAssertEqual(app.staticTexts["PollAnswerOption2Count"].label, "15 votes")
|
||||
XCTAssertEqual(app.progressIndicators["PollAnswerOption2Progress"].value as? String, "75%")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ struct TimelinePollAnswerOptionButton: View {
|
||||
.overlay(rect.stroke(borderAccentColor, lineWidth: 1.0))
|
||||
.accentColor(progressViewAccentColor)
|
||||
}
|
||||
.accessibilityIdentifier("PollAnswerOption\(optionIndex)")
|
||||
}
|
||||
|
||||
var answerOptionLabel: some View {
|
||||
@@ -53,6 +54,7 @@ struct TimelinePollAnswerOptionButton: View {
|
||||
Text(answerOption.text)
|
||||
.font(theme.fonts.body)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
.accessibilityIdentifier("PollAnswerOption\(optionIndex)Label")
|
||||
|
||||
if poll.closed, answerOption.winner {
|
||||
Spacer()
|
||||
@@ -66,11 +68,13 @@ struct TimelinePollAnswerOptionButton: View {
|
||||
total: Double(poll.totalAnswerCount))
|
||||
.progressViewStyle(LinearProgressViewStyle())
|
||||
.scaleEffect(x: 1.0, y: 1.2, anchor: .center)
|
||||
.accessibilityIdentifier("PollAnswerOption\(optionIndex)Progress")
|
||||
|
||||
if poll.shouldDiscloseResults {
|
||||
Text(answerOption.count == 1 ? VectorL10n.pollTimelineOneVote : VectorL10n.pollTimelineVotesCount(Int(answerOption.count)))
|
||||
.font(theme.fonts.footnote)
|
||||
.foregroundColor(poll.closed && answerOption.winner ? theme.colors.accent : theme.colors.secondaryContent)
|
||||
.accessibilityIdentifier("PollAnswerOption\(optionIndex)Count")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,6 +96,10 @@ struct TimelinePollAnswerOptionButton: View {
|
||||
|
||||
return answerOption.selected ? theme.colors.accent : theme.colors.quarterlyContent
|
||||
}
|
||||
|
||||
var optionIndex: Int {
|
||||
poll.answerOptions.firstIndex { $0.id == answerOption.id } ?? Int.max
|
||||
}
|
||||
}
|
||||
|
||||
struct TimelinePollAnswerOptionButton_Previews: PreviewProvider {
|
||||
|
||||
@@ -21,10 +21,7 @@ class UserSuggestionUITests: MockScreenTestCase {
|
||||
func testUserSuggestionScreen() throws {
|
||||
app.goToScreenWithIdentifier(MockUserSuggestionScreenState.multipleResults.title)
|
||||
|
||||
XCTAssert(app.tables.firstMatch.waitForExistence(timeout: 1))
|
||||
|
||||
let firstButton = app.tables.firstMatch.buttons.firstMatch
|
||||
_ = firstButton.waitForExistence(timeout: 10)
|
||||
XCTAssert(firstButton.identifier == "displayNameText-userIdText")
|
||||
let firstButton = app.buttons["displayNameText-userIdText"].firstMatch
|
||||
XCTAssert(firstButton.waitForExistence(timeout: 10))
|
||||
}
|
||||
}
|
||||
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// Copyright 2021 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 MatrixSDK
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceBroadcastPlaybackCoordinatorParameters {
|
||||
let session: MXSession
|
||||
let room: MXRoom
|
||||
let voiceBroadcastStartEvent: MXEvent
|
||||
let voiceBroadcastState: VoiceBroadcastInfo.State
|
||||
let senderDisplayName: String?
|
||||
}
|
||||
|
||||
final class VoiceBroadcastPlaybackCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: VoiceBroadcastPlaybackCoordinatorParameters
|
||||
|
||||
private var viewModel: VoiceBroadcastPlaybackViewModelProtocol!
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: VoiceBroadcastPlaybackCoordinatorParameters) throws {
|
||||
self.parameters = parameters
|
||||
|
||||
let voiceBroadcastAggregator = try VoiceBroadcastAggregator(session: parameters.session, room: parameters.room, voiceBroadcastStartEventId: parameters.voiceBroadcastStartEvent.eventId, voiceBroadcastState: parameters.voiceBroadcastState)
|
||||
|
||||
let details = VoiceBroadcastPlaybackDetails(senderDisplayName: parameters.senderDisplayName)
|
||||
viewModel = VoiceBroadcastPlaybackViewModel(details: details,
|
||||
mediaServiceProvider: VoiceMessageMediaServiceProvider.sharedProvider,
|
||||
cacheManager: VoiceMessageAttachmentCacheManager.sharedManager,
|
||||
voiceBroadcastAggregator: voiceBroadcastAggregator)
|
||||
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() { }
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
VectorHostingController(rootView: VoiceBroadcastPlaybackView(viewModel: viewModel.context))
|
||||
}
|
||||
|
||||
func canEndVoiceBroadcast() -> Bool {
|
||||
// TODO: VB check is voicebroadcast stopped
|
||||
return false
|
||||
}
|
||||
|
||||
func canEditVoiceBroadcast() -> Bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func endVoiceBroadcast() {}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
class VoiceBroadcastPlaybackProvider {
|
||||
static let shared = VoiceBroadcastPlaybackProvider()
|
||||
|
||||
var session: MXSession?
|
||||
var coordinatorsForEventIdentifiers = [String: VoiceBroadcastPlaybackCoordinator]()
|
||||
|
||||
private init() { }
|
||||
|
||||
/// Create or retrieve the voiceBroadcast timeline coordinator for this event and return
|
||||
/// a view to be displayed in the timeline
|
||||
func buildVoiceBroadcastPlaybackVCForEvent(_ event: MXEvent, senderDisplayName: String?) -> UIViewController? {
|
||||
guard let session = session, let room = session.room(withRoomId: event.roomId) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let coordinator = coordinatorsForEventIdentifiers[event.eventId] {
|
||||
return coordinator.toPresentable()
|
||||
}
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
dispatchGroup.enter()
|
||||
var voiceBroadcastState = VoiceBroadcastInfo.State.stopped
|
||||
|
||||
room.state { roomState in
|
||||
if let stateEvent = roomState?.stateEvents(with: .custom(VoiceBroadcastSettings.voiceBroadcastInfoContentKeyType))?.last,
|
||||
stateEvent.stateKey == event.stateKey,
|
||||
let voiceBroadcastInfo = VoiceBroadcastInfo(fromJSON: stateEvent.content),
|
||||
(stateEvent.eventId == event.eventId || voiceBroadcastInfo.eventId == event.eventId),
|
||||
let state = VoiceBroadcastInfo.State(rawValue: voiceBroadcastInfo.state) {
|
||||
voiceBroadcastState = state
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
|
||||
let parameters = VoiceBroadcastPlaybackCoordinatorParameters(session: session,
|
||||
room: room,
|
||||
voiceBroadcastStartEvent: event,
|
||||
voiceBroadcastState: voiceBroadcastState,
|
||||
senderDisplayName: senderDisplayName)
|
||||
guard let coordinator = try? VoiceBroadcastPlaybackCoordinator(parameters: parameters) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
coordinatorsForEventIdentifiers[event.eventId] = coordinator
|
||||
|
||||
return coordinator.toPresentable()
|
||||
|
||||
}
|
||||
|
||||
/// Retrieve the voiceBroadcast timeline coordinator for the given event or nil if it hasn't been created yet
|
||||
func voiceBroadcastPlaybackCoordinatorForEventIdentifier(_ eventIdentifier: String) -> VoiceBroadcastPlaybackCoordinator? {
|
||||
coordinatorsForEventIdentifiers[eventIdentifier]
|
||||
}
|
||||
}
|
||||
+334
@@ -0,0 +1,334 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
// TODO: VoiceBroadcastPlaybackViewModel must be revisited in order to not depend on MatrixSDK
|
||||
// We need a VoiceBroadcastPlaybackServiceProtocol and VoiceBroadcastAggregatorProtocol
|
||||
import MatrixSDK
|
||||
|
||||
class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, VoiceBroadcastPlaybackViewModelProtocol {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
private var voiceBroadcastAggregator: VoiceBroadcastAggregator
|
||||
private let mediaServiceProvider: VoiceMessageMediaServiceProvider
|
||||
private let cacheManager: VoiceMessageAttachmentCacheManager
|
||||
private var audioPlayer: VoiceMessageAudioPlayer?
|
||||
|
||||
private var voiceBroadcastChunkQueue: [VoiceBroadcastChunk] = []
|
||||
|
||||
private var isLivePlayback = false
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(details: VoiceBroadcastPlaybackDetails,
|
||||
mediaServiceProvider: VoiceMessageMediaServiceProvider,
|
||||
cacheManager: VoiceMessageAttachmentCacheManager,
|
||||
voiceBroadcastAggregator: VoiceBroadcastAggregator) {
|
||||
self.mediaServiceProvider = mediaServiceProvider
|
||||
self.cacheManager = cacheManager
|
||||
self.voiceBroadcastAggregator = voiceBroadcastAggregator
|
||||
|
||||
let viewState = VoiceBroadcastPlaybackViewState(details: details,
|
||||
broadcastState: VoiceBroadcastPlaybackViewModel.getBroadcastState(from: voiceBroadcastAggregator.voiceBroadcastState),
|
||||
playbackState: .stopped,
|
||||
bindings: VoiceBroadcastPlaybackViewStateBindings())
|
||||
super.init(initialViewState: viewState)
|
||||
|
||||
self.voiceBroadcastAggregator.delegate = self
|
||||
}
|
||||
|
||||
private func release() {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] release")
|
||||
if let audioPlayer = audioPlayer {
|
||||
audioPlayer.deregisterDelegate(self)
|
||||
self.audioPlayer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: VoiceBroadcastPlaybackViewAction) {
|
||||
switch viewAction {
|
||||
case .play:
|
||||
play()
|
||||
case .playLive:
|
||||
playLive()
|
||||
case .pause:
|
||||
pause()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Listen voice broadcast
|
||||
private func play() {
|
||||
isLivePlayback = false
|
||||
|
||||
if voiceBroadcastAggregator.isStarted == false {
|
||||
// Start the streaming by fetching broadcast chunks
|
||||
// The audio player will automatically start the playback on incoming chunks
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: Start streaming")
|
||||
state.playbackState = .buffering
|
||||
voiceBroadcastAggregator.start()
|
||||
}
|
||||
else if let audioPlayer = audioPlayer {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: resume")
|
||||
audioPlayer.play()
|
||||
}
|
||||
else {
|
||||
let chunks = voiceBroadcastAggregator.voiceBroadcast.chunks
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] play: restart from the beginning: \(chunks.count) chunks")
|
||||
|
||||
// Reinject all the chuncks we already have and play them
|
||||
voiceBroadcastChunkQueue.append(contentsOf: chunks)
|
||||
processPendingVoiceBroadcastChunks()
|
||||
}
|
||||
}
|
||||
|
||||
private func playLive() {
|
||||
guard isLivePlayback == false else {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: Already playing live")
|
||||
return
|
||||
}
|
||||
|
||||
isLivePlayback = true
|
||||
|
||||
// Flush the current audio player playlist
|
||||
audioPlayer?.removeAllPlayerItems()
|
||||
|
||||
if voiceBroadcastAggregator.isStarted == false {
|
||||
// Start the streaming by fetching broadcast chunks
|
||||
// The audio player will automatically start the playback on incoming chunks
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: Start streaming")
|
||||
state.playbackState = .buffering
|
||||
voiceBroadcastAggregator.start()
|
||||
}
|
||||
else {
|
||||
let chunks = voiceBroadcastAggregator.voiceBroadcast.chunks
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] playLive: restart from the last chunk: \(chunks.count) chunks")
|
||||
|
||||
// Reinject all the chuncks we already have and play the last one
|
||||
voiceBroadcastChunkQueue.append(contentsOf: chunks)
|
||||
processPendingVoiceBroadcastChunksForLivePlayback()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop voice broadcast
|
||||
private func pause() {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] pause")
|
||||
|
||||
isLivePlayback = false
|
||||
|
||||
if let audioPlayer = audioPlayer, audioPlayer.isPlaying {
|
||||
audioPlayer.pause()
|
||||
}
|
||||
}
|
||||
|
||||
private func stopIfVoiceBroadcastOver() {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] stopIfVoiceBroadcastOver")
|
||||
|
||||
// TODO: Check if the broadcast is over before stopping everything
|
||||
// If not, the player should not stopped. The view state must be move to buffering
|
||||
stop()
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] stop")
|
||||
|
||||
isLivePlayback = false
|
||||
|
||||
// Objects will be released on audioPlayerDidStopPlaying
|
||||
audioPlayer?.stop()
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Voice broadcast chunks playback
|
||||
|
||||
/// Start the playback from the beginning or push more chunks to it
|
||||
private func processPendingVoiceBroadcastChunks() {
|
||||
reorderPendingVoiceBroadcastChunks()
|
||||
processNextVoiceBroadcastChunk()
|
||||
}
|
||||
|
||||
/// Start the playback from the last known chunk
|
||||
private func processPendingVoiceBroadcastChunksForLivePlayback() {
|
||||
let chunks = reorderVoiceBroadcastChunks(chunks: Array(voiceBroadcastAggregator.voiceBroadcast.chunks))
|
||||
if let lastChunk = chunks.last {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] processPendingVoiceBroadcastChunksForLivePlayback. Use the last chunk: sequence: \(lastChunk.sequence) out of the \(voiceBroadcastChunkQueue.count) chunks")
|
||||
voiceBroadcastChunkQueue = [lastChunk]
|
||||
}
|
||||
processNextVoiceBroadcastChunk()
|
||||
}
|
||||
|
||||
private func reorderPendingVoiceBroadcastChunks() {
|
||||
// Make sure we download and process chunks in the right order
|
||||
voiceBroadcastChunkQueue = reorderVoiceBroadcastChunks(chunks: voiceBroadcastChunkQueue)
|
||||
}
|
||||
private func reorderVoiceBroadcastChunks(chunks: [VoiceBroadcastChunk]) -> [VoiceBroadcastChunk] {
|
||||
chunks.sorted(by: {$0.sequence < $1.sequence})
|
||||
}
|
||||
|
||||
private func processNextVoiceBroadcastChunk() {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: \(voiceBroadcastChunkQueue.count) chunks remaining")
|
||||
|
||||
guard voiceBroadcastChunkQueue.count > 0 else {
|
||||
// We cached all chunks. Nothing more to do
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Control the download rate to avoid to download all chunk in mass
|
||||
// We could synchronise it with the number of chunks in the player playlist (audioPlayer.playerItems)
|
||||
|
||||
let chunk = voiceBroadcastChunkQueue.removeFirst()
|
||||
|
||||
// numberOfSamples is for the equalizer view we do not support yet
|
||||
cacheManager.loadAttachment(chunk.attachment, numberOfSamples: 1) { [weak self] result in
|
||||
guard let self = self else {
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Make sure there has no new incoming chunk that should be before this attachment
|
||||
// Be careful that this new chunk is not older than the chunk being played by the audio player. Else
|
||||
// we will get an unexecpted rewind.
|
||||
|
||||
switch result {
|
||||
case .success(let result):
|
||||
guard result.eventIdentifier == chunk.attachment.eventId else {
|
||||
return
|
||||
}
|
||||
|
||||
if let audioPlayer = self.audioPlayer {
|
||||
// Append the chunk to the current playlist
|
||||
audioPlayer.addContentFromURL(result.url)
|
||||
|
||||
// Resume the player. Needed after a pause
|
||||
if audioPlayer.isPlaying == false {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] processNextVoiceBroadcastChunk: Resume the player")
|
||||
audioPlayer.play()
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Init and start the player on the first chunk
|
||||
let audioPlayer = self.mediaServiceProvider.audioPlayerForIdentifier(result.eventIdentifier)
|
||||
audioPlayer.registerDelegate(self)
|
||||
|
||||
audioPlayer.loadContentFromURL(result.url, displayName: chunk.attachment.originalFileName)
|
||||
audioPlayer.play()
|
||||
self.audioPlayer = audioPlayer
|
||||
}
|
||||
|
||||
case .failure (let error):
|
||||
MXLog.error("[VoiceBroadcastPlaybackViewModel] processVoiceBroadcastChunkQueue: loadAttachment error", context: error)
|
||||
if self.voiceBroadcastChunkQueue.count == 0 {
|
||||
// No more chunk to try. Go to error
|
||||
self.state.playbackState = .error
|
||||
}
|
||||
}
|
||||
|
||||
self.processNextVoiceBroadcastChunk()
|
||||
}
|
||||
}
|
||||
|
||||
private static func getBroadcastState(from state: VoiceBroadcastInfo.State) -> VoiceBroadcastState {
|
||||
var broadcastState: VoiceBroadcastState
|
||||
switch state {
|
||||
case .started:
|
||||
broadcastState = VoiceBroadcastState.live
|
||||
case .paused:
|
||||
broadcastState = VoiceBroadcastState.paused
|
||||
case .resumed:
|
||||
broadcastState = VoiceBroadcastState.live
|
||||
case .stopped:
|
||||
broadcastState = VoiceBroadcastState.stopped
|
||||
}
|
||||
|
||||
return broadcastState
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: VoiceBroadcastAggregatorDelegate
|
||||
extension VoiceBroadcastPlaybackViewModel: VoiceBroadcastAggregatorDelegate {
|
||||
func voiceBroadcastAggregatorDidStartLoading(_ aggregator: VoiceBroadcastAggregator) {
|
||||
}
|
||||
|
||||
func voiceBroadcastAggregatorDidEndLoading(_ aggregator: VoiceBroadcastAggregator) {
|
||||
}
|
||||
|
||||
func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didFailWithError: Error) {
|
||||
MXLog.error("[VoiceBroadcastPlaybackViewModel] voiceBroadcastAggregator didFailWithError:", context: didFailWithError)
|
||||
}
|
||||
|
||||
func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveChunk: VoiceBroadcastChunk) {
|
||||
voiceBroadcastChunkQueue.append(didReceiveChunk)
|
||||
}
|
||||
|
||||
func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didReceiveState: VoiceBroadcastInfo.State) {
|
||||
state.broadcastState = VoiceBroadcastPlaybackViewModel.getBroadcastState(from: didReceiveState)
|
||||
}
|
||||
|
||||
func voiceBroadcastAggregatorDidUpdateData(_ aggregator: VoiceBroadcastAggregator) {
|
||||
if isLivePlayback && state.playbackState == .buffering {
|
||||
// We started directly with a live playback but there was no known chuncks at that time
|
||||
// These are the first chunks we get. Start the playback on the latest one
|
||||
processPendingVoiceBroadcastChunksForLivePlayback()
|
||||
}
|
||||
else {
|
||||
processPendingVoiceBroadcastChunks()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - VoiceMessageAudioPlayerDelegate
|
||||
extension VoiceBroadcastPlaybackViewModel: VoiceMessageAudioPlayerDelegate {
|
||||
func audioPlayerDidFinishLoading(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
}
|
||||
|
||||
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
if isLivePlayback {
|
||||
state.playbackState = .playingLive
|
||||
}
|
||||
else {
|
||||
state.playbackState = .playing
|
||||
}
|
||||
}
|
||||
|
||||
func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
state.playbackState = .paused
|
||||
}
|
||||
|
||||
func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] audioPlayerDidStopPlaying")
|
||||
state.playbackState = .stopped
|
||||
release()
|
||||
}
|
||||
|
||||
func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError error: Error) {
|
||||
state.playbackState = .error
|
||||
}
|
||||
|
||||
func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
|
||||
MXLog.debug("[VoiceBroadcastPlaybackViewModel] audioPlayerDidFinishPlaying: \(audioPlayer.playerItems.count)")
|
||||
stopIfVoiceBroadcastOver()
|
||||
}
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
struct VoiceBroadcastPlaybackErrorView: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
// MARK: Public
|
||||
|
||||
var action: (() -> Void)?
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
VStack {
|
||||
Image(uiImage: Asset.Images.errorIcon.image)
|
||||
.frame(width: 40, height: 40)
|
||||
Text(VectorL10n.voiceBroadcastPlaybackLoadingError)
|
||||
.multilineTextAlignment(.center)
|
||||
.font(theme.fonts.caption1)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(theme.colors.system.ignoresSafeArea())
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceBroadcastPlaybackErrorView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VoiceBroadcastPlaybackErrorView()
|
||||
}
|
||||
}
|
||||
+121
@@ -0,0 +1,121 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
// TODO: To remove
|
||||
// VoiceBroadcastPlaybackViewModel must be revisited in order to not depend on MatrixSDK
|
||||
#if canImport(MatrixSDK)
|
||||
typealias VoiceBroadcastPlaybackViewModelImpl = VoiceBroadcastPlaybackViewModel
|
||||
#else
|
||||
typealias VoiceBroadcastPlaybackViewModelImpl = MockVoiceBroadcastPlaybackViewModel
|
||||
#endif
|
||||
|
||||
struct VoiceBroadcastPlaybackView: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
private var backgroundColor: Color {
|
||||
if viewModel.viewState.playbackState == .playingLive {
|
||||
return theme.colors.alert
|
||||
}
|
||||
return theme.colors.quarterlyContent
|
||||
}
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var viewModel: VoiceBroadcastPlaybackViewModelImpl.Context
|
||||
|
||||
var body: some View {
|
||||
let details = viewModel.viewState.details
|
||||
|
||||
VStack(alignment: .center, spacing: 16.0) {
|
||||
|
||||
HStack {
|
||||
Text(details.senderDisplayName ?? "")
|
||||
//Text(VectorL10n.voiceBroadcastInTimelineTitle)
|
||||
.font(theme.fonts.bodySB)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
|
||||
if viewModel.viewState.broadcastState == .live {
|
||||
Button { viewModel.send(viewAction: .playLive) } label:
|
||||
{
|
||||
HStack {
|
||||
Image(uiImage: Asset.Images.voiceBroadcastLive.image)
|
||||
.renderingMode(.original)
|
||||
Text("Live")
|
||||
.font(theme.fonts.bodySB)
|
||||
.foregroundColor(Color.white)
|
||||
}
|
||||
|
||||
}
|
||||
.padding(5.0)
|
||||
.background(RoundedRectangle(cornerRadius: 4, style: .continuous)
|
||||
.fill(backgroundColor))
|
||||
.accessibilityIdentifier("liveButton")
|
||||
}
|
||||
}
|
||||
|
||||
if viewModel.viewState.playbackState == .error {
|
||||
VoiceBroadcastPlaybackErrorView()
|
||||
} else {
|
||||
ZStack {
|
||||
if viewModel.viewState.playbackState == .playing ||
|
||||
viewModel.viewState.playbackState == .playingLive {
|
||||
Button { viewModel.send(viewAction: .pause) } label: {
|
||||
Image(uiImage: Asset.Images.voiceBroadcastPause.image)
|
||||
.renderingMode(.original)
|
||||
}
|
||||
.accessibilityIdentifier("pauseButton")
|
||||
} else {
|
||||
Button {
|
||||
if viewModel.viewState.broadcastState == .live &&
|
||||
viewModel.viewState.playbackState == .stopped {
|
||||
viewModel.send(viewAction: .playLive)
|
||||
} else {
|
||||
viewModel.send(viewAction: .play)
|
||||
}
|
||||
} label: {
|
||||
Image(uiImage: Asset.Images.voiceBroadcastPlay.image)
|
||||
.renderingMode(.original)
|
||||
}
|
||||
.disabled(viewModel.viewState.playbackState == .buffering)
|
||||
.accessibilityIdentifier("playButton")
|
||||
}
|
||||
}
|
||||
.activityIndicator(show: viewModel.viewState.playbackState == .buffering)
|
||||
}
|
||||
|
||||
}
|
||||
.padding([.horizontal, .top], 2.0)
|
||||
.padding([.bottom])
|
||||
.alert(item: $viewModel.alertInfo) { info in
|
||||
info.alert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct VoiceBroadcastPlaybackView_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockVoiceBroadcastPlaybackScreenState.stateRenderer
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
import SwiftUI
|
||||
|
||||
enum VoiceBroadcastPlaybackViewAction {
|
||||
case play
|
||||
case playLive
|
||||
case pause
|
||||
}
|
||||
|
||||
enum VoiceBroadcastPlaybackState {
|
||||
case stopped
|
||||
case buffering
|
||||
case playing
|
||||
case playingLive
|
||||
case paused
|
||||
case error
|
||||
}
|
||||
|
||||
struct VoiceBroadcastPlaybackDetails {
|
||||
let senderDisplayName: String?
|
||||
}
|
||||
|
||||
enum VoiceBroadcastState {
|
||||
case unknown
|
||||
case stopped
|
||||
case live
|
||||
case paused
|
||||
}
|
||||
|
||||
struct VoiceBroadcastPlaybackViewState: BindableState {
|
||||
var details: VoiceBroadcastPlaybackDetails
|
||||
var broadcastState: VoiceBroadcastState
|
||||
var playbackState: VoiceBroadcastPlaybackState
|
||||
var bindings: VoiceBroadcastPlaybackViewStateBindings
|
||||
}
|
||||
|
||||
struct VoiceBroadcastPlaybackViewStateBindings {
|
||||
// TODO: Neeeded?
|
||||
var alertInfo: AlertInfo<VoiceBroadcastPlaybackAlertType>?
|
||||
}
|
||||
|
||||
enum VoiceBroadcastPlaybackAlertType {
|
||||
// TODO: What is it?
|
||||
case failedClosingVoiceBroadcast
|
||||
}
|
||||
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
import SwiftUI
|
||||
|
||||
typealias MockVoiceBroadcastPlaybackViewModelType = StateStoreViewModel<VoiceBroadcastPlaybackViewState, VoiceBroadcastPlaybackViewAction>
|
||||
class MockVoiceBroadcastPlaybackViewModel: MockVoiceBroadcastPlaybackViewModelType, VoiceBroadcastPlaybackViewModelProtocol {
|
||||
}
|
||||
|
||||
/// Using an enum for the screen allows you define the different state cases with
|
||||
/// the relevant associated data for each case.
|
||||
enum MockVoiceBroadcastPlaybackScreenState: MockScreenState, CaseIterable {
|
||||
// A case for each state you want to represent
|
||||
// with specific, minimal associated data that will allow you
|
||||
// mock that screen.
|
||||
case animated
|
||||
|
||||
/// The associated screen
|
||||
var screenType: Any.Type {
|
||||
VoiceBroadcastPlaybackView.self
|
||||
}
|
||||
|
||||
/// A list of screen state definitions
|
||||
static var allCases: [MockVoiceBroadcastPlaybackScreenState] {
|
||||
[.animated]
|
||||
}
|
||||
|
||||
/// Generate the view struct for the screen state.
|
||||
var screenView: ([Any], AnyView) {
|
||||
|
||||
let details = VoiceBroadcastPlaybackDetails(senderDisplayName: "Alice")
|
||||
let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .live, playbackState: .stopped, bindings: VoiceBroadcastPlaybackViewStateBindings()))
|
||||
|
||||
return (
|
||||
[false, viewModel],
|
||||
AnyView(VoiceBroadcastPlaybackView(viewModel: viewModel.context))
|
||||
)
|
||||
}
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
typealias VoiceBroadcastPlaybackViewModelType = StateStoreViewModel<VoiceBroadcastPlaybackViewState, VoiceBroadcastPlaybackViewAction>
|
||||
|
||||
protocol VoiceBroadcastPlaybackViewModelProtocol {
|
||||
var context: VoiceBroadcastPlaybackViewModelType.Context { get }
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
struct VoiceBroadcastRecorderCoordinatorParameters {
|
||||
let session: MXSession
|
||||
let room: MXRoom
|
||||
let voiceBroadcastStartEvent: MXEvent
|
||||
let senderDisplayName: String?
|
||||
}
|
||||
|
||||
final class VoiceBroadcastRecorderCoordinator: Coordinator, Presentable {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let parameters: VoiceBroadcastRecorderCoordinatorParameters
|
||||
|
||||
private var voiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol
|
||||
private var voiceBroadcastRecorderViewModel: VoiceBroadcastRecorderViewModelProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// Must be used only internally
|
||||
var childCoordinators: [Coordinator] = []
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(parameters: VoiceBroadcastRecorderCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
voiceBroadcastRecorderService = VoiceBroadcastRecorderService(session: parameters.session, roomId: parameters.room.matrixItemId)
|
||||
|
||||
let details = VoiceBroadcastRecorderDetails(senderDisplayName: parameters.senderDisplayName)
|
||||
let viewModel = VoiceBroadcastRecorderViewModel(details: details,
|
||||
recorderService: voiceBroadcastRecorderService)
|
||||
voiceBroadcastRecorderViewModel = viewModel
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
func start() { }
|
||||
|
||||
func toPresentable() -> UIViewController {
|
||||
VectorHostingController(rootView: VoiceBroadcastRecorderView(viewModel: voiceBroadcastRecorderViewModel.context))
|
||||
}
|
||||
|
||||
func pauseRecording() {
|
||||
voiceBroadcastRecorderViewModel.context.send(viewAction: .pause)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
@objc public class VoiceBroadcastRecorderProvider: NSObject {
|
||||
|
||||
// MARK: - Constants
|
||||
@objc public static let shared = VoiceBroadcastRecorderProvider()
|
||||
|
||||
// MARK: - Properties
|
||||
// MARK: Public
|
||||
var session: MXSession?
|
||||
var coordinatorsForEventIdentifiers = [String: VoiceBroadcastRecorderCoordinator]()
|
||||
|
||||
// MARK: Private
|
||||
private var currentEventIdentifier: String?
|
||||
|
||||
// MARK: - Setup
|
||||
private override init() { }
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
/// Create or retrieve the voiceBroadcast timeline coordinator for this event and return
|
||||
/// a view to be displayed in the timeline
|
||||
func buildVoiceBroadcastRecorderViewForEvent(_ event: MXEvent, senderDisplayName: String?) -> UIView? {
|
||||
guard let session = session,
|
||||
let room = session.room(withRoomId: event.roomId) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.currentEventIdentifier = event.eventId
|
||||
|
||||
if let coordinator = coordinatorsForEventIdentifiers[event.eventId] {
|
||||
return coordinator.toPresentable().view
|
||||
}
|
||||
|
||||
let parameters = VoiceBroadcastRecorderCoordinatorParameters(session: session,
|
||||
room: room,
|
||||
voiceBroadcastStartEvent: event,
|
||||
senderDisplayName: senderDisplayName)
|
||||
let coordinator = VoiceBroadcastRecorderCoordinator(parameters: parameters)
|
||||
|
||||
coordinatorsForEventIdentifiers[event.eventId] = coordinator
|
||||
|
||||
return coordinator.toPresentable().view
|
||||
}
|
||||
|
||||
/// Pause current voice broadcast recording.
|
||||
@objc public func pauseRecording() {
|
||||
voiceBroadcastRecorderCoordinatorForCurrentEvent()?.pauseRecording()
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
/// Retrieve the voiceBroadcast recorder coordinator for the current event or nil if it hasn't been created yet
|
||||
private func voiceBroadcastRecorderCoordinatorForCurrentEvent() -> VoiceBroadcastRecorderCoordinator? {
|
||||
guard let currentEventIdentifier = currentEventIdentifier else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return coordinatorsForEventIdentifiers[currentEventIdentifier]
|
||||
}
|
||||
}
|
||||
+274
@@ -0,0 +1,274 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private let roomId: String
|
||||
private let session: MXSession
|
||||
private var voiceBroadcastService: VoiceBroadcastService? {
|
||||
session.voiceBroadcastService
|
||||
}
|
||||
|
||||
private let audioEngine = AVAudioEngine()
|
||||
private let audioNodeBus = AVAudioNodeBus(0)
|
||||
|
||||
private var chunkFile: AVAudioFile! = nil
|
||||
private var chunkFrames: AVAudioFrameCount = 0
|
||||
private var chunkFileNumber: Int = 1
|
||||
|
||||
// MARK: Public
|
||||
|
||||
weak var serviceDelegate: VoiceBroadcastRecorderServiceDelegate?
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(session: MXSession, roomId: String) {
|
||||
self.session = session
|
||||
self.roomId = roomId
|
||||
}
|
||||
|
||||
// MARK: - VoiceBroadcastRecorderServiceProtocol
|
||||
|
||||
func startRecordingVoiceBroadcast() {
|
||||
let inputNode = audioEngine.inputNode
|
||||
|
||||
let inputFormat = inputNode.inputFormat(forBus: audioNodeBus)
|
||||
MXLog.debug("[VoiceBroadcastRecorderService] Start recording voice broadcast for bus name : \(String(describing: inputNode.name(forInputBus: audioNodeBus)))")
|
||||
|
||||
inputNode.installTap(onBus: audioNodeBus,
|
||||
bufferSize: 512,
|
||||
format: inputFormat) { (buffer, time) -> Void in
|
||||
DispatchQueue.main.async {
|
||||
self.writeBuffer(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
try? audioEngine.start()
|
||||
}
|
||||
|
||||
func stopRecordingVoiceBroadcast() {
|
||||
MXLog.debug("[VoiceBroadcastRecorderService] Stop recording voice broadcast")
|
||||
audioEngine.stop()
|
||||
audioEngine.inputNode.removeTap(onBus: audioNodeBus)
|
||||
|
||||
resetValues()
|
||||
|
||||
voiceBroadcastService?.stopVoiceBroadcast(success: { [weak self] _ in
|
||||
MXLog.debug("[VoiceBroadcastRecorderService] Stopped")
|
||||
|
||||
guard let self = self else { return }
|
||||
|
||||
// Update state
|
||||
self.serviceDelegate?.voiceBroadcastRecorderService(self, didUpdateState: .stopped)
|
||||
|
||||
// Send current chunk
|
||||
if self.chunkFile != nil {
|
||||
self.sendChunkFile(at: self.chunkFile.url, sequence: self.chunkFileNumber)
|
||||
}
|
||||
|
||||
self.session.tearDownVoiceBroadcastService()
|
||||
}, failure: { error in
|
||||
MXLog.error("[VoiceBroadcastRecorderService] Failed to stop voice broadcast", context: error)
|
||||
})
|
||||
}
|
||||
|
||||
func pauseRecordingVoiceBroadcast() {
|
||||
audioEngine.pause()
|
||||
|
||||
voiceBroadcastService?.pauseVoiceBroadcast(success: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Send current chunk
|
||||
self.sendChunkFile(at: self.chunkFile.url, sequence: self.chunkFileNumber)
|
||||
self.chunkFile = nil
|
||||
|
||||
}, failure: { error in
|
||||
MXLog.error("[VoiceBroadcastRecorderService] Failed to pause voice broadcast", context: error)
|
||||
})
|
||||
}
|
||||
|
||||
func resumeRecordingVoiceBroadcast() {
|
||||
try? audioEngine.start()
|
||||
|
||||
voiceBroadcastService?.resumeVoiceBroadcast(success: { [weak self] _ in
|
||||
guard let self = self else { return }
|
||||
|
||||
// Update state
|
||||
self.serviceDelegate?.voiceBroadcastRecorderService(self, didUpdateState: .started)
|
||||
}, failure: { error in
|
||||
MXLog.error("[VoiceBroadcastRecorderService] Failed to resume voice broadcast", context: error)
|
||||
})
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
/// Reset chunk values.
|
||||
private func resetValues() {
|
||||
chunkFrames = 0
|
||||
chunkFileNumber = 1
|
||||
}
|
||||
|
||||
/// Write audio buffer to chunk file.
|
||||
private func writeBuffer(_ buffer: AVAudioPCMBuffer) {
|
||||
let sampleRate = buffer.format.sampleRate
|
||||
|
||||
if chunkFile == nil {
|
||||
createNewChunkFile(channelsCount: buffer.format.channelCount, sampleRate: sampleRate)
|
||||
}
|
||||
try? chunkFile.write(from: buffer)
|
||||
|
||||
chunkFrames += buffer.frameLength
|
||||
|
||||
if chunkFrames > AVAudioFrameCount(Double(BuildSettings.voiceBroadcastChunkLength) * sampleRate) {
|
||||
sendChunkFile(at: chunkFile.url, sequence: self.chunkFileNumber)
|
||||
// Reset chunkFile
|
||||
chunkFile = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Create new chunk file with sample rate.
|
||||
private func createNewChunkFile(channelsCount: AVAudioChannelCount, sampleRate: Float64) {
|
||||
guard let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first else {
|
||||
// FIXME: Manage error
|
||||
return
|
||||
}
|
||||
let temporaryFileName = "VoiceBroadcastChunk-\(roomId)-\(chunkFileNumber)"
|
||||
let fileUrl = directory
|
||||
.appendingPathComponent(temporaryFileName)
|
||||
.appendingPathExtension("aac")
|
||||
MXLog.debug("[VoiceBroadcastRecorderService] Create chunk file to \(fileUrl)")
|
||||
|
||||
let settings: [String: Any] = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||||
AVSampleRateKey: sampleRate,
|
||||
AVEncoderBitRateKey: 128000,
|
||||
AVNumberOfChannelsKey: channelsCount,
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue]
|
||||
|
||||
chunkFile = try? AVAudioFile(forWriting: fileUrl, settings: settings)
|
||||
|
||||
if chunkFile != nil {
|
||||
chunkFileNumber += 1
|
||||
chunkFrames = 0
|
||||
} else {
|
||||
stopRecordingVoiceBroadcast()
|
||||
// FIXME: Manage error ?
|
||||
}
|
||||
}
|
||||
|
||||
/// Send chunk file to the server.
|
||||
private func sendChunkFile(at url: URL, sequence: Int) {
|
||||
guard let voiceBroadcastService = voiceBroadcastService else {
|
||||
// FIXME: Manage error
|
||||
return
|
||||
}
|
||||
|
||||
let dispatchGroup = DispatchGroup()
|
||||
var duration = 0.0
|
||||
|
||||
dispatchGroup.enter()
|
||||
VoiceMessageAudioConverter.mediaDurationAt(url) { result in
|
||||
switch result {
|
||||
case .success:
|
||||
if let someDuration = try? result.get() {
|
||||
duration = someDuration
|
||||
} else {
|
||||
MXLog.error("[VoiceBroadcastRecorderService] Failed to retrieve media duration")
|
||||
}
|
||||
case .failure(let error):
|
||||
MXLog.error("[VoiceBroadcastRecorderService] Failed to get audio duration", context: error)
|
||||
}
|
||||
|
||||
dispatchGroup.leave()
|
||||
}
|
||||
|
||||
convertAACToM4A(at: url) { [weak self] convertedUrl in
|
||||
guard let self = self else { return }
|
||||
|
||||
if let convertedUrl = convertedUrl {
|
||||
dispatchGroup.notify(queue: .main) {
|
||||
self.voiceBroadcastService?.sendChunkOfVoiceBroadcast(audioFileLocalURL: convertedUrl,
|
||||
mimeType: "audio/mp4",
|
||||
duration: UInt(duration * 1000),
|
||||
samples: nil,
|
||||
sequence: UInt(sequence)) { eventId in
|
||||
MXLog.debug("[VoiceBroadcastRecorderService] Send voice broadcast chunk with success.")
|
||||
if eventId != nil {
|
||||
self.deleteRecording(at: url)
|
||||
}
|
||||
} failure: { error in
|
||||
MXLog.error("[VoiceBroadcastRecorderService] Failed to send voice broadcast chunk.", context: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete voice broadcast chunk at URL.
|
||||
private func deleteRecording(at url: URL?) {
|
||||
guard let url = url else {
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try FileManager.default.removeItem(at: url)
|
||||
} catch {
|
||||
MXLog.error("[VoiceBroadcastRecorderService] Delete chunk file error.", context: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert AAC file into m4a one.
|
||||
private func convertAACToM4A(at url: URL, completion: @escaping (URL?) -> Void) {
|
||||
// FIXME: Manage errors at completion
|
||||
let asset = AVURLAsset(url: url)
|
||||
let updatedPath = url.path.replacingOccurrences(of: ".aac", with: ".m4a")
|
||||
let outputUrl = URL(string: "file://" + updatedPath)
|
||||
MXLog.debug("[VoiceBroadcastRecorderService] convertAACToM4A updatedPath : \(updatedPath).")
|
||||
|
||||
if FileManager.default.fileExists(atPath: updatedPath) {
|
||||
try? FileManager.default.removeItem(atPath: updatedPath)
|
||||
}
|
||||
|
||||
guard let exportSession = AVAssetExportSession(asset: asset,
|
||||
presetName: AVAssetExportPresetPassthrough) else {
|
||||
completion(nil)
|
||||
return
|
||||
}
|
||||
|
||||
exportSession.outputURL = outputUrl
|
||||
exportSession.outputFileType = AVFileType.m4a
|
||||
let start = CMTimeMakeWithSeconds(0.0, preferredTimescale: 0)
|
||||
let range = CMTimeRangeMake(start: start, duration: asset.duration)
|
||||
exportSession.timeRange = range
|
||||
exportSession.exportAsynchronously() {
|
||||
switch exportSession.status {
|
||||
case .failed:
|
||||
MXLog.error("[VoiceBroadcastRecorderService] convertAACToM4A error", context: exportSession.error)
|
||||
completion(nil)
|
||||
case .completed:
|
||||
MXLog.debug("[VoiceBroadcastRecorderService] convertAACToM4A success.")
|
||||
completion(outputUrl)
|
||||
default:
|
||||
MXLog.debug("[VoiceBroadcastRecorderService] convertAACToM4A other cases.")
|
||||
completion(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
protocol VoiceBroadcastRecorderServiceDelegate: AnyObject {
|
||||
func voiceBroadcastRecorderService(_ service: VoiceBroadcastRecorderServiceProtocol, didUpdateState state: VoiceBroadcastRecorderState)
|
||||
}
|
||||
|
||||
protocol VoiceBroadcastRecorderServiceProtocol {
|
||||
/// Service delegate
|
||||
var serviceDelegate: VoiceBroadcastRecorderServiceDelegate? { get set }
|
||||
|
||||
/// Start voice broadcast recording.
|
||||
func startRecordingVoiceBroadcast()
|
||||
|
||||
/// Stop voice broadcast recording.
|
||||
func stopRecordingVoiceBroadcast()
|
||||
|
||||
/// Pause voice broadcast recording.
|
||||
func pauseRecordingVoiceBroadcast()
|
||||
|
||||
/// Resume voice broadcast recording after paused it.
|
||||
func resumeRecordingVoiceBroadcast()
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//
|
||||
// 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
|
||||
|
||||
struct VoiceBroadcastRecorderView: View {
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
@Environment(\.theme) private var theme: ThemeSwiftUI
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@ObservedObject var viewModel: VoiceBroadcastRecorderViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
let details = viewModel.viewState.details
|
||||
|
||||
VStack(alignment: .leading, spacing: 16.0) {
|
||||
Text(details.senderDisplayName ?? "")
|
||||
.font(theme.fonts.bodySB)
|
||||
.foregroundColor(theme.colors.primaryContent)
|
||||
|
||||
HStack(alignment: .top, spacing: 16.0) {
|
||||
Button {
|
||||
switch viewModel.viewState.recordingState {
|
||||
case .started, .resumed:
|
||||
viewModel.send(viewAction: .pause)
|
||||
case .stopped:
|
||||
viewModel.send(viewAction: .start)
|
||||
case .paused:
|
||||
viewModel.send(viewAction: .resume)
|
||||
}
|
||||
} label: {
|
||||
if viewModel.viewState.recordingState == .started || viewModel.viewState.recordingState == .resumed {
|
||||
Image("voice_broadcast_record_pause")
|
||||
.renderingMode(.original)
|
||||
} else {
|
||||
Image("voice_broadcast_record")
|
||||
.renderingMode(.original)
|
||||
}
|
||||
}
|
||||
.accessibilityIdentifier("recordButton")
|
||||
|
||||
Button {
|
||||
viewModel.send(viewAction: .stop)
|
||||
} label: {
|
||||
Image("voice_broadcast_stop")
|
||||
.renderingMode(.original)
|
||||
}
|
||||
.accessibilityIdentifier("stopButton")
|
||||
.disabled(viewModel.viewState.recordingState == .stopped)
|
||||
.mask(Color.black.opacity(viewModel.viewState.recordingState == .stopped ? 0.3 : 1.0))
|
||||
}
|
||||
}
|
||||
.padding([.horizontal, .top], 2.0)
|
||||
.padding([.bottom])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct VoiceBroadcastRecorderView_Previews: PreviewProvider {
|
||||
static let stateRenderer = MockVoiceBroadcastRecorderScreenState.stateRenderer
|
||||
static var previews: some View {
|
||||
stateRenderer.screenGroup()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
enum VoiceBroadcastRecorderViewAction {
|
||||
case start
|
||||
case stop
|
||||
case pause
|
||||
case resume
|
||||
}
|
||||
|
||||
enum VoiceBroadcastRecorderState {
|
||||
case started
|
||||
case stopped
|
||||
case paused
|
||||
case resumed
|
||||
}
|
||||
|
||||
struct VoiceBroadcastRecorderDetails {
|
||||
let senderDisplayName: String?
|
||||
}
|
||||
|
||||
struct VoiceBroadcastRecorderViewState: BindableState {
|
||||
var details: VoiceBroadcastRecorderDetails
|
||||
var recordingState: VoiceBroadcastRecorderState
|
||||
var bindings: VoiceBroadcastRecorderViewStateBindings
|
||||
}
|
||||
|
||||
struct VoiceBroadcastRecorderViewStateBindings {
|
||||
}
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
import SwiftUI
|
||||
|
||||
typealias MockVoiceBroadcastRecorderViewModelType = StateStoreViewModel<VoiceBroadcastRecorderViewState, VoiceBroadcastRecorderViewAction>
|
||||
class MockVoiceBroadcastRecorderViewModel: MockVoiceBroadcastRecorderViewModelType, VoiceBroadcastRecorderViewModelProtocol {
|
||||
|
||||
}
|
||||
|
||||
/// Using an enum for the screen allows you define the different state cases with
|
||||
/// the relevant associated data for each case.
|
||||
enum MockVoiceBroadcastRecorderScreenState: MockScreenState, CaseIterable {
|
||||
|
||||
var screenType: Any.Type {
|
||||
VoiceBroadcastRecorderView.self
|
||||
}
|
||||
|
||||
var screenView: ([Any], AnyView) {
|
||||
let details = VoiceBroadcastRecorderDetails(senderDisplayName: "")
|
||||
let viewModel = MockVoiceBroadcastRecorderViewModel(initialViewState: VoiceBroadcastRecorderViewState(details: details, recordingState: .started, bindings: VoiceBroadcastRecorderViewStateBindings()))
|
||||
|
||||
return (
|
||||
[false, viewModel],
|
||||
AnyView(VoiceBroadcastRecorderView(viewModel: viewModel.context))
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// 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 VoiceBroadcastRecorderViewModelType = StateStoreViewModel<VoiceBroadcastRecorderViewState, VoiceBroadcastRecorderViewAction>
|
||||
|
||||
class VoiceBroadcastRecorderViewModel: VoiceBroadcastRecorderViewModelType, VoiceBroadcastRecorderViewModelProtocol {
|
||||
|
||||
// MARK: - Properties
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private var voiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol
|
||||
|
||||
// MARK: Public
|
||||
|
||||
// MARK: - Setup
|
||||
|
||||
init(details: VoiceBroadcastRecorderDetails,
|
||||
recorderService: VoiceBroadcastRecorderServiceProtocol) {
|
||||
self.voiceBroadcastRecorderService = recorderService
|
||||
super.init(initialViewState: VoiceBroadcastRecorderViewState(details: details,
|
||||
recordingState: .stopped,
|
||||
bindings: VoiceBroadcastRecorderViewStateBindings()))
|
||||
|
||||
self.voiceBroadcastRecorderService.serviceDelegate = self
|
||||
process(viewAction: .start)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: VoiceBroadcastRecorderViewAction) {
|
||||
switch viewAction {
|
||||
case .start:
|
||||
start()
|
||||
case .stop:
|
||||
stop()
|
||||
case .pause:
|
||||
pause()
|
||||
case .resume:
|
||||
resume()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
private func start() {
|
||||
self.state.recordingState = .started
|
||||
voiceBroadcastRecorderService.startRecordingVoiceBroadcast()
|
||||
}
|
||||
|
||||
private func stop() {
|
||||
self.state.recordingState = .stopped
|
||||
voiceBroadcastRecorderService.stopRecordingVoiceBroadcast()
|
||||
}
|
||||
|
||||
private func pause() {
|
||||
self.state.recordingState = .paused
|
||||
voiceBroadcastRecorderService.pauseRecordingVoiceBroadcast()
|
||||
}
|
||||
|
||||
private func resume() {
|
||||
self.state.recordingState = .resumed
|
||||
voiceBroadcastRecorderService.resumeRecordingVoiceBroadcast()
|
||||
}
|
||||
}
|
||||
|
||||
extension VoiceBroadcastRecorderViewModel: VoiceBroadcastRecorderServiceDelegate {
|
||||
func voiceBroadcastRecorderService(_ service: VoiceBroadcastRecorderServiceProtocol, didUpdateState state: VoiceBroadcastRecorderState) {
|
||||
self.state.recordingState = state
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// 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 Foundation
|
||||
|
||||
protocol VoiceBroadcastRecorderViewModelProtocol {
|
||||
var context: VoiceBroadcastRecorderViewModelType.Context { get }
|
||||
}
|
||||
Reference in New Issue
Block a user