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:
Frank Rotermund
2022-11-02 14:05:36 +01:00
467 changed files with 18708 additions and 2131 deletions
@@ -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()
}
}
}
}
@@ -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)
}
}
@@ -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))
)
}
}
@@ -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
}
}
}
@@ -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)
}
}
@@ -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))
}
}
@@ -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()
}
}
@@ -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 }
}
@@ -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))
}
}
@@ -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() {}
}
@@ -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]
}
}
@@ -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()
}
}
@@ -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()
}
}
@@ -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
}
@@ -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))
)
}
}
@@ -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 }
}
@@ -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
}
@@ -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]
}
}
@@ -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)
}
}
}
}
@@ -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 {
}
@@ -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
}
}
@@ -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 }
}