Merge commit 'aaadcc73674cc8886e363693a7d7c08ac9b4f516' into feature/4260_merge_foss_1_10_2

# Conflicts:
#	Config/AppVersion.xcconfig
#	Podfile
#	Podfile.lock
#	Riot.xcworkspace/xcshareddata/swiftpm/Package.resolved
#	Riot/Managers/EncryptionKeyManager/EncryptionKeyManager.swift
#	Riot/Modules/Application/LegacyAppDelegate.m
#	Riot/Modules/Authentication/AuthenticationCoordinator.swift
#	Riot/Modules/Authentication/Legacy/LegacyAuthenticationCoordinator.swift
#	Riot/Modules/ContextMenu/ActionProviders/RoomActionProvider.swift
#	Riot/Modules/Home/AllChats/AllChatsViewController.swift
#	Riot/Modules/Room/RoomInfo/RoomInfoCoordinator.swift
#	Riot/Modules/Room/RoomInfo/RoomInfoList/RoomInfoListViewController.swift
#	Riot/Modules/Room/Settings/RoomSettingsViewController.m
#	fastlane/Fastfile
This commit is contained in:
JanNiklas Grabowski
2023-02-15 14:56:55 +01:00
279 changed files with 7285 additions and 2433 deletions
@@ -1,63 +0,0 @@
//
// 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 SwiftUI
typealias AllChatsOnboardingViewModelType = StateStoreViewModel<AllChatsOnboardingViewState, AllChatsOnboardingViewAction>
class AllChatsOnboardingViewModel: AllChatsOnboardingViewModelType, AllChatsOnboardingViewModelProtocol {
// MARK: - Properties
// MARK: Private
// MARK: Public
var completion: ((AllChatsOnboardingViewModelResult) -> Void)?
// MARK: - Setup
static func makeAllChatsOnboardingViewModel() -> AllChatsOnboardingViewModelProtocol {
AllChatsOnboardingViewModel()
}
private init() {
super.init(initialViewState: Self.defaultState())
}
private static func defaultState() -> AllChatsOnboardingViewState {
AllChatsOnboardingViewState(pages: [
AllChatsOnboardingPageData(image: Asset.Images.allChatsOnboarding1.image,
title: VectorL10n.allChatsOnboardingPageTitle1,
message: VectorL10n.allChatsOnboardingPageMessage1),
AllChatsOnboardingPageData(image: Asset.Images.allChatsOnboarding2.image,
title: VectorL10n.allChatsOnboardingPageTitle2,
message: VectorL10n.allChatsOnboardingPageMessage2),
AllChatsOnboardingPageData(image: Asset.Images.allChatsOnboarding3.image,
title: VectorL10n.allChatsOnboardingPageTitle3,
message: VectorL10n.allChatsOnboardingPageMessage3)
])
}
// MARK: - Public
override func process(viewAction: AllChatsOnboardingViewAction) {
switch viewAction {
case .cancel:
completion?(.cancel)
}
}
}
@@ -1,92 +0,0 @@
//
// 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 CommonKit
import SwiftUI
/// All Chats onboarding screen
final class AllChatsOnboardingCoordinator: NSObject, Coordinator, Presentable {
// MARK: - Properties
// MARK: Private
private let hostingController: UIViewController
private var viewModel: AllChatsOnboardingViewModelProtocol
private var indicatorPresenter: UserIndicatorTypePresenterProtocol
private var loadingIndicator: UserIndicator?
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: (() -> Void)?
// MARK: - Setup
override init() {
let viewModel = AllChatsOnboardingViewModel.makeAllChatsOnboardingViewModel()
let view = AllChatsOnboarding(viewModel: viewModel.context)
self.viewModel = viewModel
hostingController = VectorHostingController(rootView: view)
indicatorPresenter = UserIndicatorTypePresenter(presentingViewController: hostingController)
super.init()
hostingController.presentationController?.delegate = self
}
// MARK: - Public
func start() {
MXLog.debug("[AllChatsOnboardingCoordinator] did start.")
viewModel.completion = { [weak self] result in
guard let self = self else { return }
MXLog.debug("[AllChatsOnboardingCoordinator] AllChatsOnboardingViewModel did complete with result: \(result).")
switch result {
case .cancel:
self.completion?()
}
}
}
func toPresentable() -> UIViewController {
hostingController
}
// MARK: - Private
/// Show an activity indicator whilst loading.
/// - Parameters:
/// - label: The label to show on the indicator.
/// - isInteractionBlocking: Whether the indicator should block any user interaction.
private func startLoading(label: String = VectorL10n.loading, isInteractionBlocking: Bool = true) {
loadingIndicator = indicatorPresenter.present(.loading(label: label, isInteractionBlocking: isInteractionBlocking))
}
/// Hide the currently displayed activity indicator.
private func stopLoading() {
loadingIndicator = nil
}
}
// MARK: - UIAdaptivePresentationControllerDelegate
extension AllChatsOnboardingCoordinator: UIAdaptivePresentationControllerDelegate {
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
completion?()
}
}
@@ -1,63 +0,0 @@
//
// 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 AllChatsOnboardingCoordinatorBridgePresenterDelegate {
func allChatsOnboardingCoordinatorBridgePresenterDidCancel(_ coordinatorBridgePresenter: AllChatsOnboardingCoordinatorBridgePresenter)
}
/// `AllChatsOnboardingCoordinatorBridgePresenter` enables to start `AllChatsOnboardingCoordinator` from a view controller.
/// This bridge is used while waiting for global usage of coordinator pattern.
/// It breaks the Coordinator abstraction and it has been introduced for Objective-C compatibility (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 AllChatsOnboardingCoordinatorBridgePresenter: NSObject {
// MARK: - Properties
// MARK: Private
private var coordinator: AllChatsOnboardingCoordinator?
// MARK: Public
var completion: (() -> Void)?
// MARK: - Public
func present(from viewController: UIViewController, animated: Bool) {
let coordinator = AllChatsOnboardingCoordinator()
coordinator.completion = { [weak self] in
guard let self = self else { return }
self.completion?()
}
let presentable = coordinator.toPresentable()
viewController.present(presentable, animated: animated, completion: nil)
coordinator.start()
self.coordinator = coordinator
}
func dismiss(animated: Bool, completion: (() -> Void)?) {
guard let coordinator = coordinator else {
return
}
coordinator.toPresentable().dismiss(animated: animated) {
self.coordinator = nil
completion?()
}
}
}
@@ -1,80 +0,0 @@
//
// 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
struct AllChatsOnboarding: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
@State private var selectedTab = 0
// MARK: Public
@ObservedObject var viewModel: AllChatsOnboardingViewModel.Context
var body: some View {
VStack {
Text(VectorL10n.allChatsOnboardingTitle)
.font(theme.fonts.title3SB)
.foregroundColor(theme.colors.primaryContent)
.padding()
TabView(selection: $selectedTab) {
ForEach(viewModel.viewState.pages.indices, id: \.self) { index in
let page = viewModel.viewState.pages[index]
AllChatsOnboardingPage(image: page.image,
title: page.title,
message: page.message)
.tag(index)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
.indexViewStyle(.page(backgroundDisplayMode: .always))
Button { onCallToAction() } label: {
Text(selectedTab == viewModel.viewState.pages.count - 1 ? VectorL10n.allChatsOnboardingTryIt : VectorL10n.next)
.animation(nil)
}
.buttonStyle(PrimaryActionButtonStyle())
.padding()
}
.background(theme.colors.background.ignoresSafeArea())
.frame(maxHeight: .infinity)
}
// MARK: - Private
private func onCallToAction() {
if selectedTab == viewModel.viewState.pages.count - 1 {
viewModel.send(viewAction: .cancel)
} else {
withAnimation {
selectedTab += 1
}
}
}
}
// MARK: - Previews
struct AllChatsOnboarding_Previews: PreviewProvider {
static var previews: some View {
AllChatsOnboarding(viewModel: AllChatsOnboardingViewModel.makeAllChatsOnboardingViewModel().context).theme(.light).preferredColorScheme(.light)
AllChatsOnboarding(viewModel: AllChatsOnboardingViewModel.makeAllChatsOnboardingViewModel().context).theme(.dark).preferredColorScheme(.dark)
}
}
@@ -1,62 +0,0 @@
//
// 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 AllChatsOnboardingPage: View {
// MARK: - Properties
let image: UIImage
let title: String
let message: String
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
var body: some View {
VStack {
Spacer()
Image(uiImage: image)
Spacer()
Text(title)
.font(theme.fonts.title2B)
.foregroundColor(theme.colors.primaryContent)
.padding(.bottom, 16)
Text(message)
.multilineTextAlignment(.center)
.font(theme.fonts.callout)
.foregroundColor(theme.colors.primaryContent)
Spacer()
}
.padding(.horizontal)
}
}
// MARK: - Previews
struct AllChatsOnboardingPage_Previews: PreviewProvider {
static var previews: some View {
preview.theme(.light).preferredColorScheme(.light)
preview.theme(.dark).preferredColorScheme(.dark)
}
private static var preview: some View {
AllChatsOnboardingPage(image: Asset.Images.allChatsOnboarding1.image,
title: VectorL10n.allChatsOnboardingPageTitle1,
message: VectorL10n.allChatsOnboardingPageMessage1)
}
}
@@ -59,20 +59,31 @@ extension ComposerLinkActionViewState {
}
var isSaveButtonDisabled: Bool {
guard isValidLink else { return true }
guard !bindings.linkUrl.isEmpty else { return true }
switch linkAction {
case .createWithText: return bindings.text.isEmpty
default: return false
case .create: return false
case .edit: return !bindings.hasEditedUrl
}
}
private var isValidLink: Bool {
guard let url = URL(string: bindings.linkUrl) else { return false }
return UIApplication.shared.canOpenURL(url)
}
}
struct ComposerLinkActionBindings {
var text: String
var linkUrl: String
private let initialLinkUrl: String
fileprivate var hasEditedUrl = false
var linkUrl: String {
didSet {
if !hasEditedUrl && linkUrl != initialLinkUrl {
hasEditedUrl = true
}
}
}
init(text: String, linkUrl: String) {
self.text = text
self.linkUrl = linkUrl
self.initialLinkUrl = linkUrl
}
}
@@ -29,9 +29,7 @@ final class ComposerLinkActionUITests: MockScreenTestCase {
let linkTextField = app.textFields["linkTextField"]
XCTAssertTrue(linkTextField.exists)
linkTextField.tap()
linkTextField.typeText("invalid url")
XCTAssertFalse(saveButton.isEnabled)
linkTextField.clearAndTypeText("https://element.io")
linkTextField.clearAndTypeText("element.io")
XCTAssertTrue(saveButton.isEnabled)
}
@@ -47,7 +45,7 @@ final class ComposerLinkActionUITests: MockScreenTestCase {
let linkTextField = app.textFields["linkTextField"]
XCTAssertTrue(linkTextField.exists)
linkTextField.tap()
linkTextField.typeText("https://element.io")
linkTextField.typeText("element.io")
XCTAssertFalse(saveButton.isEnabled)
textTextField.tap()
textTextField.typeText("test")
@@ -60,13 +58,15 @@ final class ComposerLinkActionUITests: MockScreenTestCase {
XCTAssertTrue(app.buttons[VectorL10n.cancel].exists)
let saveButton = app.buttons[VectorL10n.save]
XCTAssertTrue(saveButton.exists)
XCTAssertTrue(saveButton.isEnabled)
XCTAssertFalse(saveButton.isEnabled)
XCTAssertFalse(app.textFields["textTextField"].exists)
let linkTextField = app.textFields["linkTextField"]
XCTAssertTrue(linkTextField.exists)
let value = linkTextField.value as? String
XCTAssertEqual(value, "https://element.io")
linkTextField.clearAndTypeText("invalid url")
linkTextField.clearAndTypeText("")
XCTAssertFalse(saveButton.isEnabled)
linkTextField.clearAndTypeText("matrix.org")
XCTAssertTrue(saveButton.isEnabled)
}
}
@@ -53,29 +53,20 @@ final class ComposerLinkActionViewModelTests: XCTestCase {
}
func testEditDefaultState() {
let link = "https://element.io"
let link = "element.io"
setUp(with: .edit(link: link))
XCTAssertEqual(context.viewState.bindings.text, "")
XCTAssertEqual(context.viewState.bindings.linkUrl, link)
XCTAssertFalse(context.viewState.isSaveButtonDisabled)
XCTAssertTrue(context.viewState.isSaveButtonDisabled)
XCTAssertTrue(context.viewState.shouldDisplayRemoveButton)
XCTAssertFalse(context.viewState.shouldDisplayTextField)
XCTAssertEqual(context.viewState.title, VectorL10n.wysiwygComposerLinkActionEditTitle)
}
func testUrlValidityCheck() {
setUp(with: .create)
XCTAssertTrue(context.viewState.isSaveButtonDisabled)
context.linkUrl = "invalid url"
XCTAssertTrue(context.viewState.isSaveButtonDisabled)
context.linkUrl = "https://element.io"
XCTAssertFalse(context.viewState.isSaveButtonDisabled)
}
func testTextNotEmptyCheck() {
setUp(with: .createWithText)
XCTAssertTrue(context.viewState.isSaveButtonDisabled)
context.linkUrl = "https://element.io"
context.linkUrl = "element.io"
XCTAssertTrue(context.viewState.isSaveButtonDisabled)
context.text = "text"
XCTAssertFalse(context.viewState.isSaveButtonDisabled)
@@ -92,7 +83,7 @@ final class ComposerLinkActionViewModelTests: XCTestCase {
}
func testRemoveAction() {
setUp(with: .edit(link: "https://element.io"))
setUp(with: .edit(link: "element.io"))
var result: ComposerLinkActionViewModelResult!
viewModel.callback = { value in
result = value
@@ -107,7 +98,7 @@ final class ComposerLinkActionViewModelTests: XCTestCase {
viewModel.callback = { value in
result = value
}
let link = "https://element.io"
let link = "element.io"
context.linkUrl = link
context.send(viewAction: .save)
XCTAssertEqual(result, .performOperation(.setLink(urlString: link)))
@@ -119,7 +110,7 @@ final class ComposerLinkActionViewModelTests: XCTestCase {
viewModel.callback = { value in
result = value
}
let link = "https://element.io"
let link = "element.io"
context.linkUrl = link
let text = "test"
context.text = text
@@ -128,13 +119,15 @@ final class ComposerLinkActionViewModelTests: XCTestCase {
}
func testSaveActionForEdit() {
setUp(with: .edit(link: "https://element.io"))
setUp(with: .edit(link: "element.io"))
var result: ComposerLinkActionViewModelResult!
viewModel.callback = { value in
result = value
}
let link = "https://matrix.org"
XCTAssertTrue(context.viewState.isSaveButtonDisabled)
let link = "matrix.org"
context.linkUrl = link
XCTAssertFalse(context.viewState.isSaveButtonDisabled)
context.send(viewAction: .save)
XCTAssertEqual(result, .performOperation(.setLink(urlString: link)))
}
@@ -34,7 +34,13 @@ enum FormatType {
case italic
case underline
case strikethrough
case unorderedList
case orderedList
case indent
case unIndent
case inlineCode
case codeBlock
case quote
case link
}
@@ -54,14 +60,26 @@ extension FormatItem {
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
case .link:
return Asset.Images.link.name
case .strikethrough:
return Asset.Images.strikethrough.name
case .unorderedList:
return Asset.Images.bulletList.name
case .orderedList:
return Asset.Images.numberedList.name
case .indent:
return Asset.Images.indentIncrease.name
case .unIndent:
return Asset.Images.indentDecrease.name
case .inlineCode:
return Asset.Images.code.name
case .codeBlock:
return Asset.Images.codeBlock.name
case .quote:
return Asset.Images.quote.name
case .link:
return Asset.Images.link.name
}
}
@@ -71,14 +89,26 @@ extension FormatItem {
return "boldButton"
case .italic:
return "italicButton"
case .strikethrough:
return "strikethroughButton"
case .underline:
return "underlineButton"
case .link:
return "linkButton"
case .strikethrough:
return "strikethroughButton"
case .unorderedList:
return "unorderedListButton"
case .orderedList:
return "orderedListButton"
case .indent:
return "indentListButton"
case .unIndent:
return "unIndentButton"
case .inlineCode:
return "inlineCodeButton"
case .codeBlock:
return "codeBlockButton"
case .quote:
return "quoteButton"
case .link:
return "linkButton"
}
}
@@ -88,14 +118,26 @@ extension FormatItem {
return VectorL10n.wysiwygComposerFormatActionBold
case .italic:
return VectorL10n.wysiwygComposerFormatActionItalic
case .strikethrough:
return VectorL10n.wysiwygComposerFormatActionStrikethrough
case .underline:
return VectorL10n.wysiwygComposerFormatActionUnderline
case .link:
return VectorL10n.wysiwygComposerFormatActionLink
case .strikethrough:
return VectorL10n.wysiwygComposerFormatActionStrikethrough
case .unorderedList:
return VectorL10n.wysiwygComposerFormatActionUnorderedList
case .orderedList:
return VectorL10n.wysiwygComposerFormatActionOrderedList
case .indent:
return VectorL10n.wysiwygComposerFormatActionIndent
case .unIndent:
return VectorL10n.wysiwygComposerFormatActionUnIndent
case .inlineCode:
return VectorL10n.wysiwygComposerFormatActionInlineCode
case .codeBlock:
return VectorL10n.wysiwygComposerFormatActionCodeBlock
case .quote:
return VectorL10n.wysiwygComposerFormatActionQuote
case .link:
return VectorL10n.wysiwygComposerFormatActionLink
}
}
}
@@ -108,14 +150,26 @@ extension FormatType {
return .bold
case .italic:
return .italic
case .strikethrough:
return .strikeThrough
case .underline:
return .underline
case .link:
return .link
case .strikethrough:
return .strikeThrough
case .unorderedList:
return .unorderedList
case .orderedList:
return .orderedList
case .indent:
return .indent
case .unIndent:
return .unIndent
case .inlineCode:
return .inlineCode
case .codeBlock:
return .codeBlock
case .quote:
return .quote
case .link:
return .link
}
}
@@ -127,14 +181,26 @@ extension FormatType {
return .bold
case .italic:
return .italic
case .strikethrough:
return .strikeThrough
case .underline:
return .underline
case .link:
return .link
case .strikethrough:
return .strikeThrough
case .unorderedList:
return .unorderedList
case .orderedList:
return .orderedList
case .indent:
return .indent
case .unIndent:
return .unIndent
case .inlineCode:
return .inlineCode
case .codeBlock:
return .codeBlock
case .quote:
return .quote
case .link:
return .link
}
}
}
@@ -167,5 +233,3 @@ final class LinkActionWrapper: NSObject {
super.init()
}
}
@@ -32,21 +32,23 @@ struct FormattingToolbar: View {
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(getForegroundColor(for: item))
ScrollView(.horizontal) {
HStack(spacing: 4) {
ForEach(formatItems) { item in
Button {
formatAction(item.type)
} label: {
Image(item.icon)
.renderingMode(.template)
.foregroundColor(getForegroundColor(for: item))
}
.disabled(item.state == .disabled)
.frame(width: 44, height: 44)
.background(getBackgroundColor(for: item))
.cornerRadius(8)
.accessibilityIdentifier(item.accessibilityIdentifier)
.accessibilityLabel(item.accessibilityLabel)
}
.disabled(item.state == .disabled)
.frame(width: 44, height: 44)
.background(getBackgroundColor(for: item))
.cornerRadius(8)
.accessibilityIdentifier(item.accessibilityIdentifier)
.accessibilityLabel(item.accessibilityLabel)
}
}
}
@@ -51,7 +51,7 @@ final class RoomNotificationSettingsCoordinator: RoomNotificationSettingsCoordin
)
let avatarService: AvatarServiceProtocol = AvatarService(mediaManager: room.mxSession.mediaManager)
let view = RoomNotificationSettings(viewModel: viewModel, presentedModally: presentedModally)
.addDependency(avatarService)
.environmentObject(AvatarViewModel(avatarService: avatarService))
let viewController = VectorHostingController(rootView: view)
roomNotificationSettingsViewModel = viewModel
roomNotificationSettingsViewController = viewController
@@ -30,6 +30,10 @@ final class MXRoomNotificationSettingsService: RoomNotificationSettingsServiceTy
private var observers: [ObjectIdentifier] = []
private var notificationCenter: MXNotificationCenter? {
room.mxSession?.notificationCenter
}
// MARK: Public
var notificationState: RoomNotificationState {
@@ -166,7 +170,7 @@ final class MXRoomNotificationSettingsService: RoomNotificationSettingsServiceTy
}
handleFailureCallback(completion)
room.mxSession.notificationCenter.addRoomRule(
notificationCenter?.addRoomRule(
room.roomId,
notify: false,
sound: false,
@@ -184,7 +188,7 @@ final class MXRoomNotificationSettingsService: RoomNotificationSettingsServiceTy
}
handleFailureCallback(completion)
room.mxSession.notificationCenter.addOverrideRule(
notificationCenter?.addOverrideRule(
withId: roomId,
conditions: [["kind": "event_match", "key": "room_id", "pattern": roomId]],
notify: false,
@@ -196,11 +200,11 @@ final class MXRoomNotificationSettingsService: RoomNotificationSettingsServiceTy
private func removePushRule(rule: MXPushRule, completion: @escaping Completion) {
handleUpdateCallback(completion) { [weak self] in
guard let self = self else { return true }
return self.room.mxSession.notificationCenter.rule(byId: rule.ruleId) == nil
return self.notificationCenter?.rule(byId: rule.ruleId) == nil
}
handleFailureCallback(completion)
room.mxSession.notificationCenter.removeRule(rule)
notificationCenter?.removeRule(rule)
}
private func enablePushRule(rule: MXPushRule, completion: @escaping Completion) {
@@ -210,7 +214,7 @@ final class MXRoomNotificationSettingsService: RoomNotificationSettingsServiceTy
}
handleFailureCallback(completion)
room.mxSession.notificationCenter.enableRule(rule, isEnabled: true)
notificationCenter?.enableRule(rule, isEnabled: true)
}
private func handleUpdateCallback(_ completion: @escaping Completion, releaseCheck: @escaping () -> Bool) {
@@ -283,14 +287,14 @@ private extension MXRoom {
}
var overridePushRule: MXPushRule? {
guard let overrideRules = mxSession.notificationCenter.rules.global.override else {
guard let overrideRules = mxSession?.notificationCenter?.rules?.global?.override else {
return nil
}
return getRoomRule(from: overrideRules)
}
var roomPushRule: MXPushRule? {
guard let roomRules = mxSession.notificationCenter.rules.global.room else {
guard let roomRules = mxSession?.notificationCenter?.rules?.global?.room else {
return nil
}
return getRoomRule(from: roomRules)
@@ -85,13 +85,13 @@ struct RoomNotificationSettings_Previews: PreviewProvider {
NavigationView {
RoomNotificationSettings(viewModel: mockViewModel, presentedModally: true)
.navigationBarTitleDisplayMode(.inline)
.addDependency(MockAvatarService.example)
.environmentObject(AvatarViewModel.withMockedServices())
}
NavigationView {
RoomNotificationSettings(viewModel: mockViewModel, presentedModally: true)
.navigationBarTitleDisplayMode(.inline)
.theme(ThemeIdentifier.dark)
.addDependency(MockAvatarService.example)
.environmentObject(AvatarViewModel.withMockedServices())
}
}
}
@@ -43,6 +43,6 @@ struct RoomNotificationSettingsHeader_Previews: PreviewProvider {
static let name = "Element"
static var previews: some View {
RoomNotificationSettingsHeader(avatarData: MockAvatarInput.example, displayName: name)
.addDependency(MockAvatarService.example)
.environmentObject(AvatarViewModel.withMockedServices())
}
}
@@ -0,0 +1,108 @@
//
// 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 CommonKit
import MatrixSDK
import SwiftUI
struct PollHistoryCoordinatorParameters {
let mode: PollHistoryMode
let room: MXRoom
let navigationRouter: NavigationRouterType
}
final class PollHistoryCoordinator: NSObject, Coordinator, Presentable {
private let parameters: PollHistoryCoordinatorParameters
private let pollHistoryHostingController: UIViewController
private var pollHistoryViewModel: PollHistoryViewModelProtocol
private let navigationRouter: NavigationRouterType
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((MXEvent) -> Void)?
init(parameters: PollHistoryCoordinatorParameters) {
self.parameters = parameters
let viewModel = PollHistoryViewModel(mode: parameters.mode, pollService: PollHistoryService(room: parameters.room, chunkSizeInDays: PollHistoryConstants.chunkSizeInDays))
let view = PollHistory(viewModel: viewModel.context)
pollHistoryViewModel = viewModel
pollHistoryHostingController = VectorHostingController(rootView: view)
navigationRouter = parameters.navigationRouter
}
// MARK: - Public
func start() {
MXLog.debug("[PollHistoryCoordinator] did start.")
pollHistoryViewModel.completion = { [weak self] result in
switch result {
case .showPollDetail(let poll):
self?.showPollDetail(poll)
}
}
}
func showPollDetail(_ poll: TimelinePollDetails) {
guard let event = parameters.room.mxSession.store.event(withEventId: poll.id, inRoom: parameters.room.roomId),
let detailCoordinator: PollHistoryDetailCoordinator = try? .init(parameters: .init(event: event, poll: poll, room: parameters.room)) else {
pollHistoryViewModel.context.alertInfo = .init(id: true, title: VectorL10n.settingsDiscoveryErrorMessage)
return
}
detailCoordinator.toPresentable().presentationController?.delegate = self
detailCoordinator.completion = { [weak self, weak detailCoordinator, weak event] result in
guard let self, let coordinator = detailCoordinator, let event = event else { return }
self.handlePollDetailResult(result, coordinator: coordinator, event: event, poll: poll)
}
add(childCoordinator: detailCoordinator)
detailCoordinator.start()
toPresentable().present(detailCoordinator.toPresentable(), animated: true)
}
func toPresentable() -> UIViewController {
pollHistoryHostingController
}
private func handlePollDetailResult(_ result: PollHistoryDetailViewModelResult, coordinator: Coordinator, event: MXEvent, poll: TimelinePollDetails) {
switch result {
case .dismiss:
toPresentable().dismiss(animated: true)
remove(childCoordinator: coordinator)
case .viewInTimeline:
toPresentable().dismiss(animated: false)
remove(childCoordinator: coordinator)
var event = event
if poll.closed {
let room = parameters.room
let relatedEvents = room.mxSession.store.relations(forEvent: event.eventId, inRoom: room.roomId, relationType: MXEventRelationTypeReference)
let pollEndedEvent = relatedEvents.first(where: { $0.eventType == .pollEnd })
event = pollEndedEvent ?? event
}
completion?(event)
}
}
}
// MARK: UIAdaptivePresentationControllerDelegate
extension PollHistoryCoordinator: UIAdaptivePresentationControllerDelegate {
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
guard let coordinator = childCoordinators.last else {
return
}
remove(childCoordinator: coordinator)
}
}
@@ -0,0 +1,98 @@
//
// 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 Foundation
import SwiftUI
/// Using an enum for the screen allows you define the different state cases with
/// the relevant associated data for each case.
enum MockPollHistoryScreenState: MockScreenState, CaseIterable {
// A case for each state you want to represent
// with specific, minimal associated data that will allow you
// mock that screen.
case active
case past
case activeNoMoreContent
case contentLoading
case empty
case emptyLoading
case emptyNoMoreContent
case loading
/// The associated screen
var screenType: Any.Type {
PollHistory.self
}
/// Generate the view struct for the screen state.
var screenView: ([Any], AnyView) {
var pollHistoryMode: PollHistoryMode = .active
let pollService = MockPollHistoryService()
switch self {
case .active:
pollHistoryMode = .active
case .activeNoMoreContent:
pollHistoryMode = .active
pollService.hasNextBatch = false
case .past:
pollHistoryMode = .past
case .contentLoading:
pollService.nextBatchPublishers.append(MockPollPublisher.loadingPolls)
case .empty:
pollHistoryMode = .active
pollService.nextBatchPublishers = [MockPollPublisher.emptyPolls]
case .emptyLoading:
pollService.nextBatchPublishers = [MockPollPublisher.emptyPolls, MockPollPublisher.loadingPolls]
case .emptyNoMoreContent:
pollService.hasNextBatch = false
pollService.nextBatchPublishers = [MockPollPublisher.emptyPolls]
case .loading:
pollService.nextBatchPublishers = [MockPollPublisher.loadingPolls]
}
let viewModel = PollHistoryViewModel(mode: pollHistoryMode, pollService: pollService)
// can simulate service and viewModel actions here if needs be.
switch self {
case .contentLoading, .emptyLoading:
viewModel.process(viewAction: .loadMoreContent)
default:
break
}
return (
[pollHistoryMode, viewModel],
AnyView(PollHistory(viewModel: viewModel.context)
.environmentObject(AvatarViewModel.withMockedServices()))
)
}
}
enum MockPollPublisher {
static var emptyPolls: AnyPublisher<TimelinePollDetails, Error> {
Empty<TimelinePollDetails, Error>(completeImmediately: true).eraseToAnyPublisher()
}
static var loadingPolls: AnyPublisher<TimelinePollDetails, Error> {
Empty<TimelinePollDetails, Error>(completeImmediately: false).eraseToAnyPublisher()
}
static var failure: AnyPublisher<TimelinePollDetails, Error> {
Fail(error: NSError(domain: "fake", code: 1)).eraseToAnyPublisher()
}
}
@@ -0,0 +1,66 @@
//
// 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 CommonKit
import MatrixSDK
import SwiftUI
struct PollHistoryDetailCoordinatorParameters {
let event: MXEvent
let poll: TimelinePollDetails
let room: MXRoom
}
final class PollHistoryDetailCoordinator: Coordinator, Presentable {
private let parameters: PollHistoryDetailCoordinatorParameters
private let pollHistoryDetailHostingController: UIViewController
private var pollHistoryDetailViewModel: PollHistoryDetailViewModelProtocol
// Must be used only internally
var childCoordinators: [Coordinator] = []
var completion: ((PollHistoryDetailViewModelResult) -> Void)?
init(parameters: PollHistoryDetailCoordinatorParameters) throws {
self.parameters = parameters
let timelinePollCoordinator = try TimelinePollCoordinator(parameters: .init(session: parameters.room.mxSession, room: parameters.room, pollEvent: parameters.event))
let viewModel = PollHistoryDetailViewModel(poll: parameters.poll)
let view = PollHistoryDetail(viewModel: viewModel.context, contentPoll: timelinePollCoordinator.toView())
pollHistoryDetailViewModel = viewModel
pollHistoryDetailHostingController = VectorHostingController(rootView: view)
add(childCoordinator: timelinePollCoordinator)
}
// MARK: - Public
func start() {
MXLog.debug("[PollHistoryDetailCoordinator] did start.")
pollHistoryDetailViewModel.completion = { [weak self] result in
guard let self = self else { return }
switch result {
case .dismiss:
self.completion?(.dismiss)
case .viewInTimeline:
self.completion?(.viewInTimeline)
}
}
}
func toPresentable() -> UIViewController {
pollHistoryDetailHostingController
}
}
@@ -0,0 +1,56 @@
//
// 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 Foundation
import SwiftUI
enum MockPollHistoryDetailScreenState: MockScreenState, CaseIterable {
case openDisclosed
case closedDisclosed
case openUndisclosed
case closedUndisclosed
case closedPollEnded
var screenType: Any.Type {
PollHistoryDetail.self
}
var poll: TimelinePollDetails {
let answerOptions = [TimelinePollAnswerOption(id: "1", text: "First", count: 10, winner: false, selected: false),
TimelinePollAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true),
TimelinePollAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false)]
let poll = TimelinePollDetails(id: "id",
question: "Question",
answerOptions: answerOptions,
closed: self == .closedDisclosed || self == .closedUndisclosed ? true : false,
startDate: .init(timeIntervalSinceReferenceDate: 0),
totalAnswerCount: 20,
type: self == .closedDisclosed || self == .openDisclosed ? .disclosed : .undisclosed,
eventType: self == .closedPollEnded ? .ended : .started,
maxAllowedSelections: 1,
hasBeenEdited: false,
hasDecryptionError: false)
return poll
}
var screenView: ([Any], AnyView) {
let timelineViewModel = TimelinePollViewModel(timelinePollDetails: poll)
let viewModel = PollHistoryDetailViewModel(poll: poll)
return ([viewModel], AnyView(PollHistoryDetail(viewModel: viewModel.context, contentPoll: TimelinePollView(viewModel: timelineViewModel.context))))
}
}
@@ -15,29 +15,31 @@
//
import Foundation
import UIKit
import SwiftUI
// MARK: - Coordinator
// MARK: View model
typealias PollHistoryDetailViewModelCallback = (PollHistoryDetailViewModelResult) -> Void
enum AllChatsOnboardingViewModelResult {
case cancel
enum PollHistoryDetailViewModelResult {
case dismiss
case viewInTimeline
}
// MARK: View
struct AllChatsOnboardingPageData: Identifiable {
let id = UUID().uuidString
let image: UIImage
let title: String
let message: String
struct PollHistoryDetailViewState: BindableState {
var poll: TimelinePollDetails
var pollStartDate: Date {
poll.startDate
}
var isPollClosed: Bool {
poll.closed
}
}
struct AllChatsOnboardingViewState: BindableState {
let pages: [AllChatsOnboardingPageData]
}
enum AllChatsOnboardingViewAction {
case cancel
enum PollHistoryDetailViewAction {
case dismiss
case viewInTimeline
}
@@ -0,0 +1,43 @@
//
// 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 SwiftUI
typealias PollHistoryDetailViewModelType = StateStoreViewModel<PollHistoryDetailViewState, PollHistoryDetailViewAction>
class PollHistoryDetailViewModel: PollHistoryDetailViewModelType, PollHistoryDetailViewModelProtocol {
// MARK: Public
var completion: PollHistoryDetailViewModelCallback?
// MARK: - Setup
init(poll: TimelinePollDetails) {
super.init(initialViewState: PollHistoryDetailViewState(poll: poll))
}
// MARK: - Public
override func process(viewAction: PollHistoryDetailViewAction) {
switch viewAction {
case .dismiss:
completion?(.dismiss)
case .viewInTimeline:
completion?(.viewInTimeline)
}
}
}
@@ -16,8 +16,7 @@
import Foundation
protocol AllChatsOnboardingViewModelProtocol {
var completion: ((AllChatsOnboardingViewModelResult) -> Void)? { get set }
static func makeAllChatsOnboardingViewModel() -> AllChatsOnboardingViewModelProtocol
var context: AllChatsOnboardingViewModelType.Context { get }
protocol PollHistoryDetailViewModelProtocol {
var completion: PollHistoryDetailViewModelCallback? { get set }
var context: PollHistoryDetailViewModelType.Context { get }
}
@@ -0,0 +1,36 @@
//
// 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 RiotSwiftUI
import XCTest
class PollHistoryDetailUITests: MockScreenTestCase {
func testPollHistoryDetailOpenPoll() {
app.goToScreenWithIdentifier(MockPollHistoryDetailScreenState.openDisclosed.title)
let title = app.navigationBars.staticTexts.firstMatch.label
XCTAssertEqual(title, VectorL10n.pollHistoryActiveSegmentTitle)
XCTAssertEqual(app.staticTexts["PollHistoryDetail.date"].label, "1/1/01")
XCTAssertEqual(app.buttons["PollHistoryDetail.viewInTimeLineButton"].label, VectorL10n.pollHistoryDetailViewInTimeline)
}
func testPollHistoryDetailClosedPoll() {
app.goToScreenWithIdentifier(MockPollHistoryDetailScreenState.closedDisclosed.title)
let title = app.navigationBars.staticTexts.firstMatch.label
XCTAssertEqual(title, VectorL10n.pollHistoryPastSegmentTitle)
XCTAssertEqual(app.staticTexts["PollHistoryDetail.date"].label, "1/1/01")
XCTAssertEqual(app.buttons["PollHistoryDetail.viewInTimeLineButton"].label, VectorL10n.pollHistoryDetailViewInTimeline)
}
}
@@ -0,0 +1,67 @@
//
// 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 XCTest
@testable import RiotSwiftUI
class PollHistoryDetailViewModelTests: XCTestCase {
private enum Constants {
static let counterInitialValue = 0
}
var viewModel: PollHistoryDetailViewModel!
var context: PollHistoryDetailViewModelType.Context!
override func setUpWithError() throws {
let answerOptions = [TimelinePollAnswerOption(id: "1", text: "1", count: 1, winner: false, selected: false),
TimelinePollAnswerOption(id: "2", text: "2", count: 1, winner: false, selected: false),
TimelinePollAnswerOption(id: "3", text: "3", count: 1, winner: false, selected: false)]
let timelinePoll = TimelinePollDetails(id: "poll-id",
question: "Question",
answerOptions: answerOptions,
closed: false,
startDate: .init(),
totalAnswerCount: 3,
type: .disclosed,
eventType: .started,
maxAllowedSelections: 1,
hasBeenEdited: false,
hasDecryptionError: false)
viewModel = PollHistoryDetailViewModel(poll: timelinePoll)
context = viewModel.context
}
func testInitialState() {
XCTAssertFalse(context.viewState.isPollClosed)
}
func testProcessAction() {
viewModel.completion = { result in
XCTAssertEqual(result, .viewInTimeline)
}
viewModel.process(viewAction: .viewInTimeline)
}
func testProcessDismiss() {
viewModel.completion = { result in
XCTAssertEqual(result, .dismiss)
}
viewModel.process(viewAction: .dismiss)
}
}
@@ -0,0 +1,111 @@
//
// 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
struct PollHistoryDetail: View {
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
@ObservedObject var viewModel: PollHistoryDetailViewModel.Context
var contentPoll: any View
var body: some View {
navigation
}
private var navigation: some View {
if #available(iOS 16.0, *) {
return NavigationStack {
content
}
} else {
return NavigationView {
content
}
}
}
private var content: some View {
ScrollView {
VStack(alignment: .leading) {
Text(DateFormatter.pollShortDateFormatter.string(from: viewModel.viewState.pollStartDate))
.foregroundColor(theme.colors.tertiaryContent)
.font(theme.fonts.caption1)
.padding([.top])
.accessibilityIdentifier("PollHistoryDetail.date")
AnyView(contentPoll)
.navigationTitle(navigationTitle)
.navigationBarTitleDisplayMode(.inline)
.navigationBarBackButtonHidden(true)
.navigationBarItems(leading: backButton, trailing: doneButton)
viewInTimeline
}
}
.padding([.horizontal], 16)
.padding([.top, .bottom])
.background(theme.colors.background.ignoresSafeArea())
}
private var backButton: some View {
Button(action: {
viewModel.send(viewAction: .dismiss)
}) {
Image(systemName: "chevron.left")
.aspectRatio(contentMode: .fit)
.foregroundColor(theme.colors.accent)
}
}
private var doneButton: some View {
Button {
viewModel.send(viewAction: .dismiss)
} label: {
Text(VectorL10n.done)
}
.accentColor(theme.colors.accent)
}
private var viewInTimeline: some View {
Button {
viewModel.send(viewAction: .viewInTimeline)
} label: {
Text(VectorL10n.pollHistoryDetailViewInTimeline)
}
.accentColor(theme.colors.accent)
.accessibilityIdentifier("PollHistoryDetail.viewInTimeLineButton")
}
private var navigationTitle: String {
if viewModel.viewState.isPollClosed {
return VectorL10n.pollHistoryPastSegmentTitle
} else {
return VectorL10n.pollHistoryActiveSegmentTitle
}
}
}
// MARK: - Previews
struct PollHistoryDetail_Previews: PreviewProvider {
static let stateRenderer = MockPollHistoryDetailScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
}
}
@@ -0,0 +1,57 @@
//
// 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.
//
// MARK: View model
enum PollHistoryConstants {
static let chunkSizeInDays: UInt = 30
}
enum PollHistoryViewModelResult {
case showPollDetail(poll: TimelinePollDetails)
}
// MARK: View
enum PollHistoryMode: CaseIterable {
case active
case past
}
struct PollHistoryViewBindings {
var mode: PollHistoryMode
var alertInfo: AlertInfo<Bool>?
}
struct PollHistoryViewState: BindableState {
init(mode: PollHistoryMode) {
bindings = .init(mode: mode)
}
var bindings: PollHistoryViewBindings
var isLoading = false
var canLoadMoreContent = true
var polls: [TimelinePollDetails]?
var syncStartDate: Date = .init()
var syncedUpTo: Date = .distantFuture
}
enum PollHistoryViewAction {
case viewAppeared
case segmentDidChange
case showPollDetail(poll: TimelinePollDetails)
case loadMoreContent
}
@@ -0,0 +1,153 @@
//
// 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 SwiftUI
typealias PollHistoryViewModelType = StateStoreViewModel<PollHistoryViewState, PollHistoryViewAction>
final class PollHistoryViewModel: PollHistoryViewModelType, PollHistoryViewModelProtocol {
private let pollService: PollHistoryServiceProtocol
private var polls: [TimelinePollDetails]?
private var subcriptions: Set<AnyCancellable> = .init()
var completion: ((PollHistoryViewModelResult) -> Void)?
init(mode: PollHistoryMode, pollService: PollHistoryServiceProtocol) {
self.pollService = pollService
super.init(initialViewState: PollHistoryViewState(mode: mode))
state.canLoadMoreContent = pollService.hasNextBatch
}
// MARK: - Public
override func process(viewAction: PollHistoryViewAction) {
switch viewAction {
case .viewAppeared:
setupUpdateSubscriptions()
fetchContent()
case .segmentDidChange:
updateViewState()
case .showPollDetail(let poll):
completion?(.showPollDetail(poll: poll))
case .loadMoreContent:
fetchContent()
}
}
}
private extension PollHistoryViewModel {
func fetchContent() {
state.isLoading = true
pollService
.nextBatch()
.collect()
.sink { [weak self] completion in
self?.handleBatchEnded(completion: completion)
} receiveValue: { [weak self] polls in
self?.add(polls: polls)
}
.store(in: &subcriptions)
}
func handleBatchEnded(completion: Subscribers.Completion<Error>) {
state.isLoading = false
state.canLoadMoreContent = pollService.hasNextBatch
switch completion {
case .finished:
break
case .failure:
polls = polls ?? []
state.bindings.alertInfo = .init(id: true, title: VectorL10n.pollHistoryFetchingError)
}
updateViewState()
}
func setupUpdateSubscriptions() {
subcriptions.removeAll()
pollService
.updates
.sink { [weak self] detail in
self?.update(poll: detail)
self?.updateViewState()
}
.store(in: &subcriptions)
pollService
.fetchedUpTo
.weakAssign(to: \.state.syncedUpTo, on: self)
.store(in: &subcriptions)
pollService
.livePolls
.sink { [weak self] livePoll in
self?.add(polls: [livePoll])
self?.updateViewState()
}
.store(in: &subcriptions)
}
func update(poll: TimelinePollDetails) {
guard let pollIndex = polls?.firstIndex(where: { $0.id == poll.id }) else {
return
}
polls?[pollIndex] = poll
}
func add(polls: [TimelinePollDetails]) {
self.polls = (self.polls ?? []) + polls
}
func updateViewState() {
let renderedPolls: [TimelinePollDetails]?
switch context.mode {
case .active:
renderedPolls = polls?.filter { $0.closed == false }
case .past:
renderedPolls = polls?.filter { $0.closed == true }
}
state.polls = renderedPolls?.sorted(by: { $0.startDate > $1.startDate })
}
}
extension PollHistoryViewModel.Context {
var emptyPollsText: String {
switch (viewState.bindings.mode, viewState.canLoadMoreContent) {
case (.active, true):
return VectorL10n.pollHistoryNoActivePollPeriodText("\(syncedPastDays)")
case (.active, false):
return VectorL10n.pollHistoryNoActivePollText
case (.past, true):
return VectorL10n.pollHistoryNoPastPollPeriodText("\(syncedPastDays)")
case (.past, false):
return VectorL10n.pollHistoryNoPastPollText
}
}
var syncedPastDays: Int {
guard let days = Calendar.current.dateComponents([.day], from: viewState.syncedUpTo, to: viewState.syncStartDate).day else {
return 0
}
return max(0, days)
}
}
@@ -0,0 +1,22 @@
//
// 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 Foundation
protocol PollHistoryViewModelProtocol {
var completion: ((PollHistoryViewModelResult) -> Void)? { get set }
var context: PollHistoryViewModelType.Context { get }
}
@@ -0,0 +1,233 @@
//
// Copyright 2023 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 Foundation
import MatrixSDK
final class PollHistoryService: PollHistoryServiceProtocol {
private let room: MXRoom
private let timeline: MXEventTimeline
private let chunkSizeInDays: UInt
private var timelineListener: Any?
private var roomListener: Any?
// polls aggregation
private var pollAggregationContexts: [String: PollAggregationContext] = [:]
// polls
private var currentBatchSubject: PassthroughSubject<TimelinePollDetails, Error>?
private var livePollsSubject: PassthroughSubject<TimelinePollDetails, Never> = .init()
// polls updates
private let updatesSubject: PassthroughSubject<TimelinePollDetails, Never> = .init()
// timestamps
private var targetTimestamp: Date = .init()
private var oldestEventDateSubject: CurrentValueSubject<Date, Never> = .init(.init())
var updates: AnyPublisher<TimelinePollDetails, Never> {
updatesSubject.eraseToAnyPublisher()
}
init(room: MXRoom, chunkSizeInDays: UInt) {
self.room = room
self.chunkSizeInDays = chunkSizeInDays
timeline = MXRoomEventTimeline(room: room, andInitialEventId: nil)
setupTimeline()
setupLiveUpdates()
}
func nextBatch() -> AnyPublisher<TimelinePollDetails, Error> {
currentBatchSubject?.eraseToAnyPublisher() ?? startPagination()
}
var hasNextBatch: Bool {
timeline.canPaginate(.backwards)
}
var fetchedUpTo: AnyPublisher<Date, Never> {
oldestEventDateSubject.eraseToAnyPublisher()
}
var livePolls: AnyPublisher<TimelinePollDetails, Never> {
livePollsSubject.eraseToAnyPublisher()
}
deinit {
guard let roomListener = roomListener else {
return
}
room.removeListener(roomListener)
}
class PollAggregationContext {
var pollAggregator: PollAggregator?
let isLivePoll: Bool
var published: Bool
init(pollAggregator: PollAggregator? = nil, isLivePoll: Bool, published: Bool = false) {
self.pollAggregator = pollAggregator
self.isLivePoll = isLivePoll
self.published = published
}
}
}
private extension PollHistoryService {
enum Constants {
static let pageSize: UInt = 250
}
func setupTimeline() {
timeline.resetPagination()
timelineListener = timeline.listenToEvents { [weak self] event, _, _ in
if event.eventType == .pollStart {
self?.aggregatePoll(pollStartEvent: event, isLivePoll: false)
}
self?.updateTimestamp(event: event)
}
}
func setupLiveUpdates() {
roomListener = room.listen(toEventsOfTypes: [kMXEventTypeStringPollStart, kMXEventTypeStringPollStartMSC3381]) { [weak self] event, _, _ in
if event.eventType == .pollStart {
self?.aggregatePoll(pollStartEvent: event, isLivePoll: true)
}
}
}
func updateTimestamp(event: MXEvent) {
oldestEventDate = min(event.originServerDate, oldestEventDate)
}
func startPagination() -> AnyPublisher<TimelinePollDetails, Error> {
let startingTimestamp = oldestEventDate
targetTimestamp = startingTimestamp.subtractingDays(chunkSizeInDays) ?? startingTimestamp
let batchSubject = PassthroughSubject<TimelinePollDetails, Error>()
currentBatchSubject = batchSubject
DispatchQueue.main.async { [weak self] in
guard let self = self else {
return
}
self.paginate()
}
return batchSubject.eraseToAnyPublisher()
}
func paginate() {
timeline.paginate(Constants.pageSize, direction: .backwards, onlyFromStore: false) { [weak self] response in
guard let self = self else {
return
}
switch response {
case .success:
if self.timeline.canPaginate(.backwards), self.timestampTargetReached == false {
self.paginate()
} else {
self.completeBatch(completion: .finished)
}
case .failure(let error):
self.completeBatch(completion: .failure(error))
}
}
}
func completeBatch(completion: Subscribers.Completion<Error>) {
currentBatchSubject?.send(completion: completion)
currentBatchSubject = nil
}
func aggregatePoll(pollStartEvent: MXEvent, isLivePoll: Bool) {
let eventId: String = pollStartEvent.eventId
guard pollAggregationContexts[eventId] == nil else {
return
}
let newContext: PollAggregationContext = .init(isLivePoll: isLivePoll)
pollAggregationContexts[eventId] = newContext
do {
newContext.pollAggregator = try PollAggregator(session: room.mxSession, room: room, pollEvent: pollStartEvent, delegate: self)
} catch {
pollAggregationContexts.removeValue(forKey: eventId)
}
}
var timestampTargetReached: Bool {
oldestEventDate <= targetTimestamp
}
var oldestEventDate: Date {
get {
oldestEventDateSubject.value
}
set {
oldestEventDateSubject.send(newValue)
}
}
}
private extension Date {
func subtractingDays(_ days: UInt) -> Date? {
Calendar.current.date(byAdding: DateComponents(day: -Int(days)), to: self)
}
}
private extension MXEvent {
var originServerDate: Date {
.init(timeIntervalSince1970: Double(originServerTs) / 1000)
}
}
// MARK: - PollAggregatorDelegate
extension PollHistoryService: PollAggregatorDelegate {
func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) { }
func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { }
func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) {
guard let context = pollAggregationContexts[aggregator.poll.id], context.published == false else {
return
}
context.published = true
let newPoll: TimelinePollDetails = .init(poll: aggregator.poll, represent: .started)
if context.isLivePoll {
livePollsSubject.send(newPoll)
} else {
currentBatchSubject?.send(newPoll)
}
}
func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) {
guard let context = pollAggregationContexts[aggregator.poll.id], context.published else {
return
}
updatesSubject.send(.init(poll: aggregator.poll, represent: .started))
}
}
@@ -0,0 +1,83 @@
//
// Copyright 2023 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
final class MockPollHistoryService: PollHistoryServiceProtocol {
lazy var nextBatchPublishers: [AnyPublisher<TimelinePollDetails, Error>] = [
(activePollsData + pastPollsData)
.publisher
.setFailureType(to: Error.self)
.eraseToAnyPublisher()
]
func nextBatch() -> AnyPublisher<TimelinePollDetails, Error> {
nextBatchPublishers.isEmpty ? Empty().eraseToAnyPublisher() : nextBatchPublishers.removeFirst()
}
var updatesPublisher: AnyPublisher<TimelinePollDetails, Never> = Empty().eraseToAnyPublisher()
var updates: AnyPublisher<TimelinePollDetails, Never> {
updatesPublisher
}
var hasNextBatch = true
var fetchedUpToPublisher: AnyPublisher<Date, Never> = Just(.init()).eraseToAnyPublisher()
var fetchedUpTo: AnyPublisher<Date, Never> {
fetchedUpToPublisher
}
var livePollsPublisher: AnyPublisher<TimelinePollDetails, Never> = Empty().eraseToAnyPublisher()
var livePolls: AnyPublisher<TimelinePollDetails, Never> {
livePollsPublisher
}
}
private extension MockPollHistoryService {
var activePollsData: [TimelinePollDetails] {
(1...3)
.map { index in
TimelinePollDetails(id: "a\(index)",
question: "Do you like the active poll number \(index)?",
answerOptions: [],
closed: false,
startDate: .init().addingTimeInterval(TimeInterval(-index) * 3600 * 24),
totalAnswerCount: 30,
type: .disclosed,
eventType: .started,
maxAllowedSelections: 1,
hasBeenEdited: false,
hasDecryptionError: false)
}
}
var pastPollsData: [TimelinePollDetails] {
(1...3)
.map { index in
TimelinePollDetails(id: "p\(index)",
question: "Do you like the active poll number \(index)?",
answerOptions: [.init(id: "id", text: "Yes, of course!", count: 20, winner: true, selected: true)],
closed: true,
startDate: .init().addingTimeInterval(TimeInterval(-index) * 3600 * 24),
totalAnswerCount: 30,
type: .disclosed,
eventType: .started,
maxAllowedSelections: 1,
hasBeenEdited: false,
hasDecryptionError: false)
}
}
}
@@ -0,0 +1,37 @@
//
// Copyright 2023 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
protocol PollHistoryServiceProtocol {
/// Returns a Publisher publishing the polls in the next batch.
/// Implementations should return the same publisher if `nextBatch()` is called again before the previous publisher completes.
func nextBatch() -> AnyPublisher<TimelinePollDetails, Error>
/// Publishes updates for the polls previously pusblished by the `nextBatch()` or `livePolls` publishers.
var updates: AnyPublisher<TimelinePollDetails, Never> { get }
/// Publishes live polls not related with the current batch.
var livePolls: AnyPublisher<TimelinePollDetails, Never> { get }
/// Returns true every time the service can fetch another batch.
/// There is no guarantee the `nextBatch()` returned publisher will publish something anyway.
var hasNextBatch: Bool { get }
/// Publishes the date up to the service is synced (in the past).
/// This date doesn't need to be related with any poll event.
var fetchedUpTo: AnyPublisher<Date, Never> { get }
}
@@ -0,0 +1,115 @@
//
// 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 RiotSwiftUI
import XCTest
final class PollHistoryUITests: MockScreenTestCase {
func testActivePollHistoryHasContent() {
app.goToScreenWithIdentifier(MockPollHistoryScreenState.active.title)
let title = app.navigationBars.firstMatch.identifier
let emptyText = app.staticTexts["PollHistory.emptyText"]
let items = app.staticTexts["PollListItem.title"]
let selectedSegment = app.buttons[VectorL10n.pollHistoryActiveSegmentTitle]
let loadMoreButton = app.buttons["PollHistory.loadMore"]
let winningOption = app.staticTexts["PollListData.winningOption"]
XCTAssertEqual(title, VectorL10n.pollHistoryTitle)
XCTAssertTrue(items.exists)
XCTAssertFalse(emptyText.exists)
XCTAssertTrue(selectedSegment.exists)
XCTAssertEqual(selectedSegment.value as? String, VectorL10n.accessibilitySelected)
XCTAssertTrue(loadMoreButton.exists)
XCTAssertFalse(winningOption.exists)
}
func testPastPollHistoryHasContent() {
app.goToScreenWithIdentifier(MockPollHistoryScreenState.past.title)
let title = app.navigationBars.firstMatch.identifier
let emptyText = app.staticTexts["PollHistory.emptyText"]
let items = app.staticTexts["PollListItem.title"]
let selectedSegment = app.buttons[VectorL10n.pollHistoryPastSegmentTitle]
let loadMoreButton = app.buttons["PollHistory.loadMore"]
let winningOption = app.buttons["PollAnswerOption0"]
XCTAssertEqual(title, VectorL10n.pollHistoryTitle)
XCTAssertTrue(items.exists)
XCTAssertFalse(emptyText.exists)
XCTAssertTrue(selectedSegment.exists)
XCTAssertEqual(selectedSegment.value as? String, VectorL10n.accessibilitySelected)
XCTAssertTrue(loadMoreButton.exists)
XCTAssertTrue(winningOption.exists)
}
func testActivePollHistoryHasContentAndCantLoadMore() {
app.goToScreenWithIdentifier(MockPollHistoryScreenState.activeNoMoreContent.title)
let emptyText = app.staticTexts["PollHistory.emptyText"]
let items = app.staticTexts["PollListItem.title"]
let loadMoreButton = app.buttons["PollHistory.loadMore"]
XCTAssertTrue(items.exists)
XCTAssertFalse(emptyText.exists)
XCTAssertFalse(loadMoreButton.exists)
}
func testActivePollHistoryHasContentAndCanLoadMore() {
app.goToScreenWithIdentifier(MockPollHistoryScreenState.contentLoading.title)
let title = app.navigationBars.firstMatch.identifier
let emptyText = app.staticTexts["PollHistory.emptyText"]
let items = app.staticTexts["PollListItem.title"]
let loadMoreButton = app.buttons["PollHistory.loadMore"]
XCTAssertTrue(items.exists)
XCTAssertFalse(emptyText.exists)
XCTAssertTrue(loadMoreButton.exists)
XCTAssertFalse(loadMoreButton.isEnabled)
}
func testActivePollHistoryEmptyAndCanLoadMore() {
app.goToScreenWithIdentifier(MockPollHistoryScreenState.empty.title)
let emptyText = app.staticTexts["PollHistory.emptyText"]
let items = app.staticTexts["PollListItem.title"]
let loadMoreButton = app.buttons["PollHistory.loadMore"]
XCTAssertFalse(items.exists)
XCTAssertTrue(emptyText.exists)
XCTAssertTrue(loadMoreButton.exists)
XCTAssertTrue(loadMoreButton.isEnabled)
}
func testActivePollHistoryEmptyAndLoading() {
app.goToScreenWithIdentifier(MockPollHistoryScreenState.emptyLoading.title)
let emptyText = app.staticTexts["PollHistory.emptyText"]
let items = app.staticTexts["PollListItem.title"]
let loadMoreButton = app.buttons["PollHistory.loadMore"]
XCTAssertFalse(items.exists)
XCTAssertTrue(emptyText.exists)
XCTAssertTrue(loadMoreButton.exists)
XCTAssertFalse(loadMoreButton.isEnabled)
}
func testActivePollHistoryEmptyAndCantLoadMore() {
app.goToScreenWithIdentifier(MockPollHistoryScreenState.emptyNoMoreContent.title)
let emptyText = app.staticTexts["PollHistory.emptyText"]
let items = app.staticTexts["PollListItem.title"]
let loadMoreButton = app.buttons["PollHistory.loadMore"]
XCTAssertFalse(items.exists)
XCTAssertTrue(emptyText.exists)
XCTAssertFalse(loadMoreButton.exists)
}
}
@@ -0,0 +1,135 @@
//
// Copyright 2023 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
@testable import RiotSwiftUI
import XCTest
final class PollHistoryViewModelTests: XCTestCase {
private var viewModel: PollHistoryViewModel!
private var pollHistoryService: MockPollHistoryService = .init()
override func setUpWithError() throws {
pollHistoryService = .init()
viewModel = .init(mode: .active, pollService: pollHistoryService)
}
func testEmitsContentOnLanding() throws {
XCTAssert(viewModel.state.polls == nil)
viewModel.process(viewAction: .viewAppeared)
XCTAssertFalse(try polls.isEmpty)
}
func testLoadingState() throws {
XCTAssertFalse(viewModel.state.isLoading)
viewModel.process(viewAction: .viewAppeared)
XCTAssertFalse(viewModel.state.isLoading)
XCTAssertFalse(try polls.isEmpty)
}
func testLoadingStateIsTrueWhileLoading() {
XCTAssertFalse(viewModel.state.isLoading)
pollHistoryService.nextBatchPublishers = [MockPollPublisher.loadingPolls, MockPollPublisher.emptyPolls]
viewModel.process(viewAction: .viewAppeared)
XCTAssertTrue(viewModel.state.isLoading)
viewModel.process(viewAction: .viewAppeared)
XCTAssertFalse(viewModel.state.isLoading)
}
func testUpdatesAreHandled() throws {
let mockUpdates: PassthroughSubject<TimelinePollDetails, Never> = .init()
pollHistoryService.updatesPublisher = mockUpdates.eraseToAnyPublisher()
viewModel.process(viewAction: .viewAppeared)
var firstPoll = try XCTUnwrap(try polls.first)
XCTAssertEqual(firstPoll.question, "Do you like the active poll number 1?")
firstPoll.question = "foo"
mockUpdates.send(firstPoll)
let updatedPoll = try XCTUnwrap(viewModel.state.polls?.first)
XCTAssertEqual(updatedPoll.question, "foo")
}
func testSegmentsAreUpdated() throws {
viewModel.process(viewAction: .viewAppeared)
XCTAssertFalse(try polls.isEmpty)
XCTAssertTrue(try polls.allSatisfy { !$0.closed })
viewModel.state.bindings.mode = .past
viewModel.process(viewAction: .segmentDidChange)
XCTAssertTrue(try polls.allSatisfy(\.closed))
}
func testPollsAreReverseOrdered() throws {
viewModel.process(viewAction: .viewAppeared)
let pollDates = try polls.map(\.startDate)
XCTAssertEqual(pollDates, pollDates.sorted(by: { $0 > $1 }))
}
func testLivePollsAreHandled() throws {
pollHistoryService.nextBatchPublishers = [MockPollPublisher.emptyPolls]
pollHistoryService.livePollsPublisher = Just(mockPoll).eraseToAnyPublisher()
viewModel.process(viewAction: .viewAppeared)
XCTAssertEqual(viewModel.state.polls?.count, 1)
XCTAssertEqual(viewModel.state.polls?.first?.id, "id")
}
func testLivePollsDontChangeLoadingState() throws {
let livePolls = PassthroughSubject<TimelinePollDetails, Never>()
pollHistoryService.nextBatchPublishers = [MockPollPublisher.loadingPolls]
pollHistoryService.livePollsPublisher = livePolls.eraseToAnyPublisher()
viewModel.process(viewAction: .viewAppeared)
XCTAssertTrue(viewModel.state.isLoading)
XCTAssertNil(viewModel.state.polls)
livePolls.send(mockPoll)
XCTAssertTrue(viewModel.state.isLoading)
XCTAssertNotNil(viewModel.state.polls)
XCTAssertEqual(viewModel.state.polls?.count, 1)
}
func testAfterFailureCompletionIsCalled() throws {
pollHistoryService.nextBatchPublishers = [MockPollPublisher.failure]
viewModel.process(viewAction: .viewAppeared)
XCTAssertFalse(viewModel.state.isLoading)
XCTAssertNotNil(viewModel.state.polls)
XCTAssertNotNil(viewModel.state.bindings.alertInfo)
}
}
private extension PollHistoryViewModelTests {
var polls: [TimelinePollDetails] {
get throws {
try XCTUnwrap(viewModel.state.polls)
}
}
var mockPoll: TimelinePollDetails {
.init(id: "id",
question: "Do you like polls?",
answerOptions: [],
closed: false,
startDate: .init(),
totalAnswerCount: 3,
type: .undisclosed,
eventType: .started,
maxAllowedSelections: 1,
hasBeenEdited: false,
hasDecryptionError: false)
}
}
@@ -0,0 +1,157 @@
//
// 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
struct PollHistory: View {
@Environment(\.theme) private var theme
@ObservedObject var viewModel: PollHistoryViewModel.Context
var body: some View {
VStack {
SegmentedPicker(
segments: PollHistoryMode.allCases,
selection: $viewModel.mode,
interSegmentSpacing: 14
)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 16)
content
}
.padding(.top, 32)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(theme.colors.background.ignoresSafeArea())
.accentColor(theme.colors.accent)
.navigationTitle(VectorL10n.pollHistoryTitle)
.onAppear {
viewModel.send(viewAction: .viewAppeared)
}
.onChange(of: viewModel.mode) { _ in
viewModel.send(viewAction: .segmentDidChange)
}
.alert(item: $viewModel.alertInfo) {
$0.alert
}
}
@ViewBuilder
private var content: some View {
if viewModel.viewState.polls == nil {
loadingView
} else if viewModel.viewState.polls?.isEmpty == true {
noPollsView
} else {
pollListView
}
}
private var pollListView: some View {
ScrollView {
LazyVStack(spacing: 32) {
ForEach(viewModel.viewState.polls ?? []) { pollData in
Button(action: {
viewModel.send(viewAction: .showPollDetail(poll: pollData))
}) {
PollListItem(pollData: pollData)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
loadMoreButton
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.top, 32)
.padding(.horizontal, 16)
}
}
@ViewBuilder
private var loadMoreButton: some View {
if viewModel.viewState.canLoadMoreContent {
HStack(spacing: 8) {
if viewModel.viewState.isLoading {
spinner
}
Button {
viewModel.send(viewAction: .loadMoreContent)
} label: {
Text(VectorL10n.pollHistoryLoadMore)
.font(theme.fonts.body)
}
.accessibilityIdentifier("PollHistory.loadMore")
.disabled(viewModel.viewState.isLoading)
}
}
}
private var spinner: some View {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
}
private var noPollsView: some View {
VStack(spacing: 32) {
Text(viewModel.emptyPollsText)
.font(theme.fonts.body)
.multilineTextAlignment(.center)
.foregroundColor(theme.colors.secondaryContent)
.padding(.horizontal, 16)
.accessibilityIdentifier("PollHistory.emptyText")
if viewModel.viewState.canLoadMoreContent {
loadMoreButton
}
}
.frame(maxHeight: .infinity)
}
private var loadingView: some View {
HStack(spacing: 8) {
spinner
Text(VectorL10n.pollHistoryLoadingText)
.font(theme.fonts.body)
.foregroundColor(theme.colors.secondaryContent)
.frame(maxHeight: .infinity)
.accessibilityIdentifier("PollHistory.loadingText")
}
.padding(.horizontal, 16)
}
}
extension PollHistoryMode: CustomStringConvertible {
var description: String {
switch self {
case .active:
return VectorL10n.pollHistoryActiveSegmentTitle
case .past:
return VectorL10n.pollHistoryPastSegmentTitle
}
}
}
// MARK: - Previews
struct PollHistory_Previews: PreviewProvider {
static let stateRenderer = MockPollHistoryScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
}
}
@@ -0,0 +1,131 @@
//
// Copyright 2023 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 PollListItem: View {
@Environment(\.theme) private var theme
private let pollData: TimelinePollDetails
@ScaledMetric private var imageSize = 16
init(pollData: TimelinePollDetails) {
self.pollData = pollData
}
var body: some View {
VStack(alignment: .leading, spacing: 12) {
Text(DateFormatter.pollShortDateFormatter.string(from: pollData.startDate))
.foregroundColor(theme.colors.tertiaryContent)
.font(theme.fonts.caption1)
HStack(alignment: .firstTextBaseline, spacing: 8) {
Image(uiImage: Asset.Images.pollHistory.image)
.resizable()
.frame(width: imageSize, height: imageSize)
Text(pollData.question)
.foregroundColor(theme.colors.primaryContent)
.font(theme.fonts.body)
.lineLimit(2)
.accessibilityLabel("PollListItem.title")
}
.frame(maxWidth: .infinity, alignment: .leading)
if pollData.closed {
VStack(alignment: .leading, spacing: 12) {
let winningOptions = pollData.answerOptions.filter(\.winner)
ForEach(winningOptions) {
TimelinePollAnswerOptionButton(poll: pollData, answerOption: $0, action: nil)
}
resultView
}
}
}
}
private var resultView: some View {
let text = pollData.totalAnswerCount == 1 ? VectorL10n.pollTimelineTotalFinalResultsOneVote : VectorL10n.pollTimelineTotalFinalResults(Int(pollData.totalAnswerCount))
return Text(text)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.tertiaryContent)
}
}
extension DateFormatter {
static let pollShortDateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.timeStyle = .none
formatter.dateStyle = .short
formatter.timeZone = .init(identifier: "UTC")
return formatter
}()
}
// MARK: - Previews
struct PollListItem_Previews: PreviewProvider {
static var previews: some View {
Group {
let pollData1 = TimelinePollDetails(id: UUID().uuidString,
question: "Do you like polls?",
answerOptions: [.init(id: "id", text: "Yes, of course!", count: 18, winner: true, selected: true)],
closed: true,
startDate: .init(),
totalAnswerCount: 30,
type: .disclosed,
eventType: .started,
maxAllowedSelections: 1,
hasBeenEdited: false,
hasDecryptionError: false)
let pollData2 = TimelinePollDetails(id: UUID().uuidString,
question: "Do you like polls?",
answerOptions: [.init(id: "id", text: "Yes, of course!", count: 18, winner: true, selected: true)],
closed: false,
startDate: .init(),
totalAnswerCount: 30,
type: .disclosed,
eventType: .started,
maxAllowedSelections: 1,
hasBeenEdited: false,
hasDecryptionError: false)
let pollData3 = TimelinePollDetails(id: UUID().uuidString,
question: "Do you like polls?",
answerOptions: [
.init(id: "id1", text: "Yes, of course!", count: 15, winner: true, selected: true),
.init(id: "id2", text: "No, I don't :-(", count: 15, winner: true, selected: true)
],
closed: true,
startDate: .init(),
totalAnswerCount: 30,
type: .disclosed,
eventType: .started,
maxAllowedSelections: 1,
hasBeenEdited: false,
hasDecryptionError: false)
ForEach([pollData1, pollData2, pollData3]) { poll in
PollListItem(pollData: poll)
}
}
.padding()
}
}
@@ -0,0 +1,89 @@
//
// Copyright 2023 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 SegmentedPicker<Segment: Hashable & CustomStringConvertible>: View {
private let segments: [Segment]
private let selection: Binding<Segment>
private let interSegmentSpacing: CGFloat
@Environment(\.theme) private var theme
init(segments: [Segment], selection: Binding<Segment>, interSegmentSpacing: CGFloat) {
self.segments = segments
self.selection = selection
self.interSegmentSpacing = interSegmentSpacing
}
var body: some View {
HStack(spacing: interSegmentSpacing) {
ForEach(segments, id: \.hashValue) { segment in
let isSelectedSegment = segment == selection.wrappedValue
Button {
selection.wrappedValue = segment
} label: {
Text(segment.description)
.font(isSelectedSegment ? theme.fonts.headline : theme.fonts.body)
.underlineBar(isSelectedSegment)
}
.accentColor(isSelectedSegment ? theme.colors.accent : theme.colors.primaryContent)
.accessibilityLabel(segment.description)
.accessibilityValue(isSelectedSegment ? VectorL10n.accessibilitySelected : "")
}
}
}
}
private extension Text {
@ViewBuilder
func underlineBar(_ isActive: Bool) -> some View {
if #available(iOS 15.0, *) {
overlay(alignment: .bottom) {
if isActive {
Rectangle()
.frame(height: 1)
.offset(y: 2)
}
}
} else {
underline(isActive)
}
}
}
struct SegmentedPicker_Previews: PreviewProvider {
static var previews: some View {
SegmentedPicker(
segments: [
"Segment 1",
"Segment 2"
],
selection: .constant("Segment 1"),
interSegmentSpacing: 14
)
SegmentedPicker(
segments: [
"Segment 1",
"Segment 2"
],
selection: .constant("Segment 2"),
interSegmentSpacing: 14
)
}
}
@@ -51,7 +51,7 @@ enum MockRoomAccessTypeChooserScreenState: MockScreenState, CaseIterable {
return (
[service, viewModel],
AnyView(RoomAccessTypeChooser(viewModel: viewModel.context, roomName: "Room Name")
.addDependency(MockAvatarService.example))
.environmentObject(AvatarViewModel.withMockedServices()))
)
}
}
@@ -45,7 +45,7 @@ final class RoomUpgradeCoordinator: Coordinator, Presentable {
self.parameters = parameters
let viewModel = RoomUpgradeViewModel.makeRoomUpgradeViewModel(roomUpgradeService: RoomUpgradeService(session: parameters.session, roomId: parameters.roomId, parentSpaceId: parameters.parentSpaceId, versionOverride: parameters.versionOverride))
let view = RoomUpgrade(viewModel: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.session.mediaManager)))
roomUpgradeViewModel = viewModel
roomUpgradeHostingController = VectorHostingController(rootView: view)
roomUpgradeHostingController.view.backgroundColor = .clear
@@ -49,7 +49,7 @@ enum MockRoomUpgradeScreenState: MockScreenState, CaseIterable {
return (
[service, viewModel],
AnyView(RoomUpgrade(viewModel: viewModel.context)
.addDependency(MockAvatarService.example))
.environmentObject(AvatarViewModel.withMockedServices()))
)
}
}
@@ -33,7 +33,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
private let selectedAnswerIdentifiersSubject = PassthroughSubject<[String], Never>()
private var pollAggregator: PollAggregator
private var viewModel: TimelinePollViewModelProtocol!
private(set) var viewModel: TimelinePollViewModelProtocol!
private var cancellables = Set<AnyCancellable>()
// MARK: Public
@@ -86,6 +86,10 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
func toPresentable() -> UIViewController {
VectorHostingController(rootView: TimelinePollView(viewModel: viewModel.context))
}
func toView() -> any View {
TimelinePollView(viewModel: viewModel.context)
}
func canEndPoll() -> Bool {
pollAggregator.poll.isClosed == false
@@ -114,10 +118,17 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { }
// MARK: - Private
// PollProtocol is intentionally not available in the SwiftUI target as we don't want
// to add the SDK as a dependency to it. We need to translate from one to the other on this level.
func buildTimelinePollFrom(_ poll: PollProtocol) -> TimelinePollDetails {
let representedType: TimelinePollEventType = parameters.pollEvent.eventType == .pollStart ? .started : .ended
return .init(poll: poll, represent: representedType)
}
}
// PollProtocol is intentionally not available in the SwiftUI target as we don't want
// to add the SDK as a dependency to it. We need to translate from one to the other on this level.
extension TimelinePollDetails {
init(poll: PollProtocol, represent eventType: TimelinePollEventType) {
let answerOptions = poll.answerOptions.map { pollAnswerOption in
TimelinePollAnswerOption(id: pollAnswerOption.id,
text: pollAnswerOption.text,
@@ -126,21 +137,27 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
selected: pollAnswerOption.isCurrentUserSelection)
}
return TimelinePollDetails(question: poll.text,
answerOptions: answerOptions,
closed: poll.isClosed,
totalAnswerCount: poll.totalAnswerCount,
type: pollKindToTimelinePollType(poll.kind),
eventType: parameters.pollEvent.eventType == .pollStart ? .started : .ended,
maxAllowedSelections: poll.maxAllowedSelections,
hasBeenEdited: poll.hasBeenEdited,
hasDecryptionError: poll.hasDecryptionError)
}
private func pollKindToTimelinePollType(_ kind: PollKind) -> TimelinePollType {
let mapping = [PollKind.disclosed: TimelinePollType.disclosed,
PollKind.undisclosed: TimelinePollType.undisclosed]
return mapping[kind] ?? .disclosed
self.init(id: poll.id,
question: poll.text,
answerOptions: answerOptions,
closed: poll.isClosed,
startDate: poll.startDate,
totalAnswerCount: poll.totalAnswerCount,
type: poll.kind.timelinePollType,
eventType: eventType,
maxAllowedSelections: poll.maxAllowedSelections,
hasBeenEdited: poll.hasBeenEdited,
hasDecryptionError: poll.hasDecryptionError)
}
}
private extension PollKind {
var timelinePollType: TimelinePollType {
switch self {
case .disclosed:
return .disclosed
case .undisclosed:
return .undisclosed
}
}
}
@@ -29,9 +29,11 @@ class TimelinePollViewModelTests: XCTestCase {
TimelinePollAnswerOption(id: "2", text: "2", count: 1, winner: false, selected: false),
TimelinePollAnswerOption(id: "3", text: "3", count: 1, winner: false, selected: false)]
let timelinePoll = TimelinePollDetails(question: "Question",
let timelinePoll = TimelinePollDetails(id: "poll-id",
question: "Question",
answerOptions: answerOptions,
closed: false,
startDate: .init(),
totalAnswerCount: 3,
type: .disclosed,
eventType: .started,
@@ -62,37 +62,20 @@ extension MutableCollection where Element == TimelinePollAnswerOption {
}
struct TimelinePollDetails {
var id: String
var question: String
var answerOptions: [TimelinePollAnswerOption]
var closed: Bool
var startDate: Date
var totalAnswerCount: UInt
var type: TimelinePollType
var eventType: TimelinePollEventType
var maxAllowedSelections: UInt
var hasBeenEdited = true
var hasBeenEdited: Bool
var hasDecryptionError: Bool
init(question: String, answerOptions: [TimelinePollAnswerOption],
closed: Bool,
totalAnswerCount: UInt,
type: TimelinePollType,
eventType: TimelinePollEventType,
maxAllowedSelections: UInt,
hasBeenEdited: Bool,
hasDecryptionError: Bool) {
self.question = question
self.answerOptions = answerOptions
self.closed = closed
self.totalAnswerCount = totalAnswerCount
self.type = type
self.eventType = eventType
self.maxAllowedSelections = maxAllowedSelections
self.hasBeenEdited = hasBeenEdited
self.hasDecryptionError = hasDecryptionError
}
var hasCurrentUserVoted: Bool {
answerOptions.filter { $0.selected == true }.count > 0
answerOptions.contains(where: \.selected)
}
var shouldDiscloseResults: Bool {
@@ -108,6 +91,8 @@ struct TimelinePollDetails {
}
}
extension TimelinePollDetails: Identifiable { }
struct TimelinePollViewState: BindableState {
var poll: TimelinePollDetails
var bindings: TimelinePollViewStateBindings
@@ -33,9 +33,11 @@ enum MockTimelinePollScreenState: MockScreenState, CaseIterable {
TimelinePollAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true),
TimelinePollAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false)]
let poll = TimelinePollDetails(question: "Question",
let poll = TimelinePollDetails(id: "id",
question: "Question",
answerOptions: answerOptions,
closed: self == .closedDisclosed || self == .closedUndisclosed ? true : false,
startDate: .init(),
totalAnswerCount: 20,
type: self == .closedDisclosed || self == .openDisclosed ? .disclosed : .undisclosed,
eventType: self == .closedPollEnded ? .ended : .started,
@@ -25,23 +25,26 @@ struct TimelinePollAnswerOptionButton: View {
let poll: TimelinePollDetails
let answerOption: TimelinePollAnswerOption
let action: () -> Void
let action: (() -> Void)?
// MARK: Public
var body: some View {
Button(action: action) {
Button {
action?()
} label: {
let rect = RoundedRectangle(cornerRadius: 4.0)
answerOptionLabel
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 8.0)
.padding(.top, 12.0)
.padding(.bottom, 12.0)
.padding(.bottom, 8.0)
.clipShape(rect)
.overlay(rect.stroke(borderAccentColor, lineWidth: 1.0))
.accentColor(progressViewAccentColor)
}
.accessibilityIdentifier("PollAnswerOption\(optionIndex)")
.disabled(action == nil)
}
var answerOptionLabel: some View {
@@ -55,20 +58,12 @@ struct TimelinePollAnswerOptionButton: View {
.font(theme.fonts.body)
.foregroundColor(theme.colors.primaryContent)
.accessibilityIdentifier("PollAnswerOption\(optionIndex)Label")
.frame(maxWidth: .infinity, alignment: .leading)
if poll.closed, answerOption.winner {
Spacer()
Image(uiImage: Asset.Images.pollWinnerIcon.image)
}
}
if poll.type == .disclosed || poll.closed {
HStack {
ProgressView(value: Double(poll.shouldDiscloseResults ? answerOption.count : 0),
total: Double(poll.totalAnswerCount))
.progressViewStyle(LinearProgressViewStyle())
.scaleEffect(x: 1.0, y: 1.2, anchor: .center)
.accessibilityIdentifier("PollAnswerOption\(optionIndex)Progress")
HStack(spacing: 6) {
if poll.closed, answerOption.winner {
Image(uiImage: Asset.Images.pollWinnerIcon.image)
}
if poll.shouldDiscloseResults {
Text(answerOption.count == 1 ? VectorL10n.pollTimelineOneVote : VectorL10n.pollTimelineVotesCount(Int(answerOption.count)))
@@ -78,6 +73,13 @@ struct TimelinePollAnswerOptionButton: View {
}
}
}
if poll.type == .disclosed || poll.closed {
ProgressView(value: Double(poll.shouldDiscloseResults ? answerOption.count : 0), total: Double(poll.totalAnswerCount))
.progressViewStyle(LinearProgressViewStyle.linear)
.scaleEffect(x: 1.0, y: 1.2, anchor: .center)
.accessibilityIdentifier("PollAnswerOption\(optionIndex)Progress")
}
}
}
@@ -143,12 +145,15 @@ struct TimelinePollAnswerOptionButton_Previews: PreviewProvider {
}
}
}
.padding()
}
static func buildPoll(closed: Bool, type: TimelinePollType) -> TimelinePollDetails {
TimelinePollDetails(question: "",
TimelinePollDetails(id: UUID().uuidString,
question: "",
answerOptions: [],
closed: closed,
startDate: .init(),
totalAnswerCount: 100,
type: type,
eventType: .started,
@@ -61,7 +61,7 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService)
let view = UserSuggestionList(viewModel: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.mediaManager))
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager)))
userSuggestionViewModel = viewModel
userSuggestionHostingController = VectorHostingController(rootView: view)
@@ -105,7 +105,7 @@ final class UserSuggestionCoordinator: Coordinator, Presentable {
private func calculateViewHeight() -> CGFloat {
let viewModel = UserSuggestionViewModel(userSuggestionService: userSuggestionService)
let view = UserSuggestionList(viewModel: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.mediaManager))
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.mediaManager)))
let controller = VectorHostingController(rootView: view)
guard let view = controller.view else {
@@ -37,7 +37,7 @@ enum MockUserSuggestionScreenState: MockScreenState, CaseIterable {
return (
[service, listViewModel],
AnyView(UserSuggestionListWithInput(viewModel: viewModel)
.addDependency(MockAvatarService.example))
.environmentObject(AvatarViewModel.withMockedServices()))
)
}
}
@@ -55,6 +55,6 @@ struct UserSuggestionListItem: View {
struct UserSuggestionHeader_Previews: PreviewProvider {
static var previews: some View {
UserSuggestionListItem(avatar: MockAvatarInput.example, displayName: "Alice", userId: "@alice:matrix.org")
.addDependency(MockAvatarService.example)
.environmentObject(AvatarViewModel.withMockedServices())
}
}
@@ -57,7 +57,8 @@ final class VoiceBroadcastPlaybackCoordinator: Coordinator, Presentable {
}
deinit {
viewModel.context.send(viewAction: .redact)
// If init has failed, our viewmodel will be nil.
viewModel?.context.send(viewAction: .redact)
}
// MARK: - Public
@@ -66,7 +67,7 @@ final class VoiceBroadcastPlaybackCoordinator: Coordinator, Presentable {
func toPresentable() -> UIViewController {
let view = VoiceBroadcastPlaybackView(viewModel: viewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.session.mediaManager)))
return VectorHostingController(rootView: view)
}
@@ -80,8 +81,15 @@ final class VoiceBroadcastPlaybackCoordinator: Coordinator, Presentable {
}
func endVoiceBroadcast() {}
func pausePlaying() {
viewModel.context.send(viewAction: .pause)
}
func pausePlayingInProgressVoiceBroadcast() {
// Pause the playback if we are playing a live voice broadcast (or waiting for more chunks)
if [.playing, .buffering].contains(viewModel.context.viewState.playbackState), viewModel.context.viewState.broadcastState != .stopped {
viewModel.context.send(viewAction: .pause)
}
}
}
@@ -78,6 +78,12 @@ import Foundation
}
}
@objc public func pausePlayingInProgressVoiceBroadcast() {
coordinatorsForEventIdentifiers.forEach { _, coordinator in
coordinator.pausePlayingInProgressVoiceBroadcast()
}
}
private func handleEvent(event: MXEvent, direction: MXTimelineDirection, customObject: Any?) {
if direction == .backwards {
// ignore backwards events
@@ -16,6 +16,7 @@
import Combine
import SwiftUI
import MediaPlayer
// TODO: VoiceBroadcastPlaybackViewModel must be revisited in order to not depend on MatrixSDK
// We need a VoiceBroadcastPlaybackServiceProtocol and VoiceBroadcastAggregatorProtocol
@@ -43,7 +44,23 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
private var reloadVoiceBroadcastChunkQueue: Bool = false
private var seekToChunkTime: TimeInterval?
/// The last chunk we tried to load
private var lastChunkProcessed: UInt = 0
/// The last chunk correctly loaded and added to the player's queue
private var lastChunkAddedToPlayer: UInt = 0
private var hasAttachmentErrors: Bool = false {
didSet {
updateErrorState()
}
}
private var isPlayingLastChunk: Bool {
// We can't play the last chunk if the brodcast is not stopped
guard state.broadcastState == .stopped else {
return false
}
let chunks = reorderVoiceBroadcastChunks(chunks: Array(voiceBroadcastAggregator.voiceBroadcast.chunks))
guard let chunkDuration = chunks.last?.duration else {
return false
@@ -52,6 +69,21 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
return state.bindings.progress + 1000 >= state.playingState.duration - Float(chunkDuration)
}
/// Current chunk loaded in the audio player
private var currentChunk: VoiceBroadcastChunk? {
guard let currentAudioPlayerUrl = audioPlayer?.currentUrl,
let currentEventId = voiceBroadcastAttachmentCacheManagerLoadResults.first(where: { result in
result.url == currentAudioPlayerUrl
})?.eventIdentifier else {
return nil
}
let currentChunk = voiceBroadcastAggregator.voiceBroadcast.chunks.first(where: { chunk in
chunk.attachment.eventId == currentEventId
})
return currentChunk
}
private var isLivePlayback: Bool {
return (!isPlaybackInitialized || isPlayingLastChunk) && (state.broadcastState == .started || state.broadcastState == .resumed)
}
@@ -89,7 +121,9 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
broadcastState: voiceBroadcastAggregator.voiceBroadcastState,
playbackState: .stopped,
playingState: VoiceBroadcastPlayingState(duration: Float(voiceBroadcastAggregator.voiceBroadcast.duration), isLive: false, canMoveForward: false, canMoveBackward: false),
bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0))
bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0),
decryptionState: VoiceBroadcastPlaybackDecryptionState(errorCount: 0),
showPlaybackError: false)
super.init(initialViewState: viewState)
displayLink = CADisplayLink(target: WeakTarget(self, selector: #selector(handleDisplayLinkTick)), selector: WeakTarget.triggerSelector)
@@ -168,11 +202,24 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
private func stopIfVoiceBroadcastOver() {
MXLog.debug("[VoiceBroadcastPlaybackViewModel] stopIfVoiceBroadcastOver")
var shouldStop = false
// Check if the broadcast is over before stopping everything
// If not, the player should not stopped. The view state must be move to buffering
if state.broadcastState == .stopped, isPlayingLastChunk {
if state.broadcastState == .stopped {
// If we known the last chunk sequence, use it to check if we need to stop
// Note: it's possible to be in .stopped state and to still have a last chunk sequence at 0 (old versions or a crash during recording). In this case, we use isPlayingLastChunk as a fallback solution
if voiceBroadcastAggregator.voiceBroadcastLastChunkSequence > 0 {
// we should stop only if we have already processed the last chunk
shouldStop = (lastChunkProcessed == voiceBroadcastAggregator.voiceBroadcastLastChunkSequence)
} else {
shouldStop = isPlayingLastChunk
}
}
if shouldStop {
stop()
} else {
// If not, the player should not stopped. The view state must be move to buffering
state.playbackState = .buffering
}
}
@@ -200,9 +247,12 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
private func seek(to seekTime: Float) {
// Flush the chunks queue and the current audio player playlist
lastChunkProcessed = 0
lastChunkAddedToPlayer = 0
voiceBroadcastChunkQueue = []
reloadVoiceBroadcastChunkQueue = isProcessingVoiceBroadcastChunk
audioPlayer?.removeAllPlayerItems()
hasAttachmentErrors = false
let chunks = reorderVoiceBroadcastChunks(chunks: Array(voiceBroadcastAggregator.voiceBroadcast.chunks))
@@ -289,12 +339,14 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
return
}
self.lastChunkProcessed = chunk.sequence
switch result {
case .success(let result):
guard result.eventIdentifier == chunk.attachment.eventId else {
return
}
self.lastChunkAddedToPlayer = max(self.lastChunkAddedToPlayer, chunk.sequence)
self.voiceBroadcastAttachmentCacheManagerLoadResults.append(result)
// Instanciate audioPlayer if needed.
@@ -302,6 +354,7 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
// Init and start the player on the first chunk
let audioPlayer = self.mediaServiceProvider.audioPlayerForIdentifier(result.eventIdentifier)
audioPlayer.registerDelegate(self)
self.mediaServiceProvider.registerNowPlayingInfoDelegate(self, forPlayer: audioPlayer)
audioPlayer.loadContentFromURL(result.url, displayName: chunk.attachment.originalFileName)
self.audioPlayer = audioPlayer
@@ -331,19 +384,46 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
audioPlayer.seekToTime(time)
self.seekToChunkTime = nil
}
self.hasAttachmentErrors = false
self.processNextVoiceBroadcastChunk()
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
MXLog.error("[VoiceBroadcastPlaybackViewModel] processVoiceBroadcastChunkQueue: loadAttachment error", context: ["error": error, "chunk": chunk.sequence])
self.hasAttachmentErrors = true
// If nothing has been added to the player's queue, exit the buffer state
if self.lastChunkAddedToPlayer == 0 {
self.pause()
}
}
self.processNextVoiceBroadcastChunk()
}
}
private func resetErrorState() {
state.showPlaybackError = false
}
private func updateErrorState() {
// Show an error if the playback state is .error
var showPlaybackError = state.playbackState == .error
// Or if there is an attachment error
if hasAttachmentErrors {
// only if the audio player is not playing and has nothing left to play
let audioPlayerIsPlaying = audioPlayer?.isPlaying ?? false
let currentPlayerTime = audioPlayer?.currentTime ?? 0
let currentPlayerDuration = audioPlayer?.duration ?? 0
let currentChunkSequence = currentChunk?.sequence ?? 0
let hasNoMoreChunkToPlay = (currentChunk == nil && lastChunkAddedToPlayer == 0) || (currentChunkSequence == lastChunkAddedToPlayer)
if !audioPlayerIsPlaying && hasNoMoreChunkToPlay && (currentPlayerDuration - currentPlayerTime < 0.2) {
showPlaybackError = true
}
}
state.showPlaybackError = showPlaybackError
}
private func updateDuration() {
let duration = voiceBroadcastAggregator.voiceBroadcast.duration
state.playingState.duration = Float(duration)
@@ -366,23 +446,23 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
} else {
seek(to: state.bindings.progress)
}
resetErrorState()
}
@objc private func handleDisplayLinkTick() {
guard let playingEventId = voiceBroadcastAttachmentCacheManagerLoadResults.first(where: { result in
result.url == audioPlayer?.currentUrl
})?.eventIdentifier,
let playingSequence = voiceBroadcastAggregator.voiceBroadcast.chunks.first(where: { chunk in
chunk.attachment.eventId == playingEventId
})?.sequence else {
guard let playingSequence = self.currentChunk?.sequence else {
return
}
let progress = Double(voiceBroadcastAggregator.voiceBroadcast.chunks.filter { chunk in
chunk.sequence < playingSequence
}.reduce(0) { $0 + $1.duration}) + (audioPlayer?.currentTime.rounded() ?? 0) * 1000
state.bindings.progress = Float(progress)
// Get the audioPlayer current time, which is the elapsed time in the currently playing media item.
// Note: if the audioPlayer is not ready (eg. after a seek), its currentTime will be 0 and we shouldn't update the progress to avoid visual glitches.
let currentTime = audioPlayer?.currentTime ?? .zero
if currentTime > 0 {
let progress = Double(voiceBroadcastAggregator.voiceBroadcast.chunks.filter { chunk in
chunk.sequence < playingSequence
}.reduce(0) { $0 + $1.duration}) + currentTime * 1000
state.bindings.progress = Float(progress)
}
updateUI()
}
@@ -401,7 +481,7 @@ class VoiceBroadcastPlaybackViewModel: VoiceBroadcastPlaybackViewModelType, Voic
state.playingState.remainingTimeLabel = label
state.playingState.canMoveBackward = state.bindings.progress > 0
state.playingState.canMoveForward = state.bindings.progress < state.playingState.duration
state.playingState.canMoveForward = (state.playingState.duration - state.bindings.progress) > 500
}
private func handleVoiceBroadcastChunksProcessing() {
@@ -436,49 +516,123 @@ extension VoiceBroadcastPlaybackViewModel: VoiceBroadcastAggregatorDelegate {
// Handle the live icon appearance
state.playingState.isLive = isLivePlayback
// Handle the case where the playback state is .buffering and the new broadcast state is .stopped
if didReceiveState == .stopped, self.state.playbackState == .buffering {
stopIfVoiceBroadcastOver()
}
}
func voiceBroadcastAggregatorDidUpdateData(_ aggregator: VoiceBroadcastAggregator) {
updateDuration()
if state.playbackState != .stopped, !isActuallyPaused {
handleVoiceBroadcastChunksProcessing()
}
}
func voiceBroadcastAggregator(_ aggregator: VoiceBroadcastAggregator, didUpdateUndecryptableEventList events: Set<MXEvent>) {
state.decryptionState.errorCount = events.count
if events.count > 0 {
MXLog.debug("[VoiceBroadcastPlaybackViewModel] voice broadcast decryption error count: \(events.count)/\(aggregator.voiceBroadcast.chunks.count)")
if [.playing, .buffering].contains(state.playbackState) {
pause()
}
}
}
}
// MARK: - VoiceMessageAudioPlayerDelegate
extension VoiceBroadcastPlaybackViewModel: VoiceMessageAudioPlayerDelegate {
func audioPlayerDidFinishLoading(_ audioPlayer: VoiceMessageAudioPlayer) {
updateErrorState()
}
func audioPlayerDidStartPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
state.playbackState = .playing
state.playingState.isLive = isLivePlayback
isPlaybackInitialized = true
displayLink.isPaused = false
resetErrorState()
}
func audioPlayerDidPausePlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
state.playbackState = .paused
state.playingState.isLive = false
displayLink.isPaused = true
}
func audioPlayerDidStopPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
MXLog.debug("[VoiceBroadcastPlaybackViewModel] audioPlayerDidStopPlaying")
state.playbackState = .stopped
updateErrorState()
state.playingState.isLive = false
audioPlayer.deregisterDelegate(self)
self.mediaServiceProvider.deregisterNowPlayingInfoDelegate(forPlayer: audioPlayer)
self.audioPlayer = nil
displayLink.isPaused = true
}
func audioPlayer(_ audioPlayer: VoiceMessageAudioPlayer, didFailWithError error: Error) {
state.playbackState = .error
updateErrorState()
}
func audioPlayerDidFinishPlaying(_ audioPlayer: VoiceMessageAudioPlayer) {
MXLog.debug("[VoiceBroadcastPlaybackViewModel] audioPlayerDidFinishPlaying: \(audioPlayer.playerItems.count)")
stopIfVoiceBroadcastOver()
if hasAttachmentErrors {
stop()
} else {
stopIfVoiceBroadcastOver()
}
}
}
// MARK: - VoiceMessageNowPlayingInfoDelegate
extension VoiceBroadcastPlaybackViewModel: VoiceMessageNowPlayingInfoDelegate {
func shouldSetupRemoteCommandCenter(audioPlayer player: VoiceMessageAudioPlayer) -> Bool {
guard BuildSettings.allowBackgroundAudioMessagePlayback, audioPlayer != nil, audioPlayer === player else {
return false
}
// we should setup the remote command center only for ended voice broadcast because we won't get new chunk if the app is in background.
return state.broadcastState == .stopped
}
func shouldDisconnectFromNowPlayingInfoCenter(audioPlayer player: VoiceMessageAudioPlayer) -> Bool {
guard BuildSettings.allowBackgroundAudioMessagePlayback, audioPlayer != nil, audioPlayer === player else {
return true
}
// we should disconnect from the now playing info center if the playback is stopped or if the broadcast is in progress
return state.playbackState == .stopped || state.broadcastState != .stopped
}
func updateNowPlayingInfoCenter(forPlayer player: VoiceMessageAudioPlayer) {
guard audioPlayer != nil, audioPlayer === player else {
return
}
// Don't update the NowPlayingInfoCenter for live broadcasts
guard state.broadcastState == .stopped else {
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
return
}
let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default()
nowPlayingInfoCenter.nowPlayingInfo = [
// Title
MPMediaItemPropertyTitle: VectorL10n.voiceBroadcastPlaybackLockScreenPlaceholder,
// Duration
MPMediaItemPropertyPlaybackDuration: (state.playingState.duration / 1000.0) as Any,
// Elapsed time
MPNowPlayingInfoPropertyElapsedPlaybackTime: (state.bindings.progress / 1000.0) as Any,
]
}
}
@@ -0,0 +1,47 @@
//
// Copyright 2023 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 VoiceBroadcastPlaybackDecryptionErrorView: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
var body: some View {
ZStack {
HStack(spacing: 0) {
Image(uiImage: Asset.Images.errorIcon.image)
.frame(width: 40, height: 40)
Text(VectorL10n.voiceBroadcastPlaybackUnableToDecrypt)
.multilineTextAlignment(.center)
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.alert)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct VoiceBroadcastPlaybackDecryptionErrorView_Previews: PreviewProvider {
static var previews: some View {
VoiceBroadcastPlaybackDecryptionErrorView()
}
}
@@ -28,19 +28,17 @@ struct VoiceBroadcastPlaybackErrorView: View {
var action: (() -> Void)?
var body: some View {
VStack {
VStack {
ZStack {
HStack {
Image(uiImage: Asset.Images.errorIcon.image)
.frame(width: 40, height: 40)
Text(VectorL10n.voiceBroadcastPlaybackLoadingError)
.multilineTextAlignment(.center)
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.primaryContent)
.foregroundColor(theme.colors.alert)
}
.padding()
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(theme.colors.system.ignoresSafeArea())
}
}
@@ -91,7 +91,7 @@ struct VoiceBroadcastPlaybackView: View {
}
}
}.frame(maxWidth: .infinity, alignment: .leading)
if viewModel.viewState.broadcastState != .stopped {
Label {
Text(VectorL10n.voiceBroadcastLive)
@@ -109,7 +109,12 @@ struct VoiceBroadcastPlaybackView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.padding(EdgeInsets(top: 0.0, leading: 0.0, bottom: 4.0, trailing: 0.0))
if viewModel.viewState.playbackState == .error {
if viewModel.viewState.decryptionState.errorCount > 0 {
VoiceBroadcastPlaybackDecryptionErrorView()
.fixedSize(horizontal: false, vertical: true)
.accessibilityIdentifier("decryptionErrorView")
}
else if viewModel.viewState.showPlaybackError {
VoiceBroadcastPlaybackErrorView()
} else {
HStack (spacing: 34.0) {
@@ -156,8 +161,8 @@ struct VoiceBroadcastPlaybackView: View {
}
VoiceBroadcastSlider(value: $viewModel.progress,
minValue: 0.0,
maxValue: viewModel.viewState.playingState.duration) { didChange in
minValue: 0.0,
maxValue: viewModel.viewState.playingState.duration) { didChange in
viewModel.send(viewAction: .sliderChange(didChange: didChange))
}
@@ -48,12 +48,18 @@ struct VoiceBroadcastPlayingState {
var canMoveBackward: Bool
}
struct VoiceBroadcastPlaybackDecryptionState {
var errorCount: Int
}
struct VoiceBroadcastPlaybackViewState: BindableState {
var details: VoiceBroadcastPlaybackDetails
var broadcastState: VoiceBroadcastInfoState
var playbackState: VoiceBroadcastPlaybackState
var playingState: VoiceBroadcastPlayingState
var bindings: VoiceBroadcastPlaybackViewStateBindings
var decryptionState: VoiceBroadcastPlaybackDecryptionState
var showPlaybackError: Bool
}
struct VoiceBroadcastPlaybackViewStateBindings {
@@ -43,11 +43,12 @@ enum MockVoiceBroadcastPlaybackScreenState: MockScreenState, CaseIterable {
var screenView: ([Any], AnyView) {
let details = VoiceBroadcastPlaybackDetails(senderDisplayName: "Alice", avatarData: AvatarInput(mxContentUri: "", matrixItemId: "!fakeroomid:matrix.org", displayName: "The name of the room"))
let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .started, playbackState: .stopped, playingState: VoiceBroadcastPlayingState(duration: 10.0, isLive: true, canMoveForward: false, canMoveBackward: false), bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0)))
let viewModel = MockVoiceBroadcastPlaybackViewModel(initialViewState: VoiceBroadcastPlaybackViewState(details: details, broadcastState: .started, playbackState: .stopped, playingState: VoiceBroadcastPlayingState(duration: 10.0, isLive: true, canMoveForward: false, canMoveBackward: false), bindings: VoiceBroadcastPlaybackViewStateBindings(progress: 0), decryptionState: VoiceBroadcastPlaybackDecryptionState(errorCount: 0), showPlaybackError: false))
return (
[false, viewModel],
AnyView(VoiceBroadcastPlaybackView(viewModel: viewModel.context))
AnyView(VoiceBroadcastPlaybackView(viewModel: viewModel.context)
.environmentObject(AvatarViewModel.withMockedServices()))
)
}
}
@@ -61,13 +61,22 @@ final class VoiceBroadcastRecorderCoordinator: Coordinator, Presentable {
func toPresentable() -> UIViewController {
let view = VoiceBroadcastRecorderView(viewModel: voiceBroadcastRecorderViewModel.context)
.addDependency(AvatarService.instantiate(mediaManager: parameters.session.mediaManager))
.environmentObject(AvatarViewModel(avatarService: AvatarService(mediaManager: parameters.session.mediaManager)))
return VectorHostingController(rootView: view)
}
func pauseRecording() {
voiceBroadcastRecorderViewModel.context.send(viewAction: .pause)
}
func pauseRecordingOnError() {
voiceBroadcastRecorderViewModel.context.send(viewAction: .pauseOnError)
}
func isVoiceBroadcastRecording() -> Bool {
return voiceBroadcastRecorderService.isRecording
}
// MARK: - Private
}
@@ -1,4 +1,4 @@
//
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
@@ -32,13 +32,16 @@ import Foundation
coordinatorsForEventIdentifiers.removeAll()
}
}
didSet {
sessionState = session?.state
}
}
private var coordinatorsForEventIdentifiers = [String: VoiceBroadcastRecorderCoordinator]() {
didSet {
if !self.coordinatorsForEventIdentifiers.isEmpty && self.redactionsListener == nil {
redactionsListener = session?.listenToEvents([MXEventType(identifier: kMXEventTypeStringRoomRedaction)], self.handleRedactedEvent)
}
if self.coordinatorsForEventIdentifiers.isEmpty && self.redactionsListener != nil {
session?.removeListener(self.redactionsListener)
self.redactionsListener = nil
@@ -49,9 +52,19 @@ import Foundation
// MARK: Private
private var currentEventIdentifier: String?
private var sessionState: MXSessionState?
private var sessionStateDidChangeObserver: Any?
// MARK: - Setup
private override init() { }
private override init() {
super.init()
self.registerNotificationObservers()
}
deinit {
unregisterNotificationObservers()
}
// MARK: - Public
@@ -85,6 +98,19 @@ import Foundation
voiceBroadcastRecorderCoordinatorForCurrentEvent()?.pauseRecording()
}
/// Pause current voice broadcast recording without sending pending events.
@objc public func pauseRecordingOnError() {
voiceBroadcastRecorderCoordinatorForCurrentEvent()?.pauseRecordingOnError()
}
@objc public func isVoiceBroadcastRecording() -> Bool {
guard let coordinator = voiceBroadcastRecorderCoordinatorForCurrentEvent() else {
return false
}
return coordinator.isVoiceBroadcastRecording()
}
// MARK: - Private
/// Retrieve the voiceBroadcast recorder coordinator for the current event or nil if it hasn't been created yet
@@ -92,7 +118,7 @@ import Foundation
guard let currentEventIdentifier = currentEventIdentifier else {
return nil
}
return coordinatorsForEventIdentifiers[currentEventIdentifier]
}
@@ -101,11 +127,43 @@ import Foundation
// ignore backwards events
return
}
var coordinator = coordinatorsForEventIdentifiers.removeValue(forKey: event.redacts)
coordinator?.toPresentable().dismiss(animated: false) {
coordinator = nil
coordinator = nil
}
}
// MARK: - Notification handling
private func registerNotificationObservers() {
self.sessionStateDidChangeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.mxSessionStateDidChange, object: session, queue: nil) { [weak self] notification in
guard let self else { return }
guard let concernedSession = notification.object as? MXSession, self.session === concernedSession else { return }
self.update(sessionState: concernedSession.state)
}
}
private func unregisterNotificationObservers() {
if let observer = self.sessionStateDidChangeObserver {
NotificationCenter.default.removeObserver(observer)
}
}
// MARK: - Session state
private func update(sessionState: MXSessionState) {
let oldState = self.sessionState
self.sessionState = sessionState
switch (oldState, sessionState) {
case (_, .homeserverNotReachable):
pauseRecordingOnError()
case (_, .running):
pauseRecording()
default:
break
}
}
}
@@ -44,6 +44,9 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol {
// MARK: Public
weak var serviceDelegate: VoiceBroadcastRecorderServiceDelegate?
var isRecording: Bool {
return audioEngine.isRunning
}
// MARK: - Setup
@@ -113,6 +116,8 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol {
// Discard the service on VoiceBroadcastService error. We keep the service in case of other error type
if error as? VoiceBroadcastServiceError != nil {
self.tearDownVoiceBroadcastService()
} else {
AppDelegate.theDelegate().showError(asAlert: error)
}
})
}
@@ -133,6 +138,10 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol {
}
}, failure: { error in
MXLog.error("[VoiceBroadcastRecorderService] Failed to pause voice broadcast", context: error)
// Pause voice broadcast recording without sending pending events.
if error is VoiceBroadcastServiceError == false {
AppDelegate.theDelegate().showError(asAlert: error)
}
})
}
@@ -148,6 +157,9 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol {
UIApplication.shared.isIdleTimerDisabled = true
}, failure: { error in
MXLog.error("[VoiceBroadcastRecorderService] Failed to resume voice broadcast", context: error)
if error is VoiceBroadcastServiceError == false {
AppDelegate.theDelegate().showError(asAlert: error)
}
})
}
@@ -166,6 +178,15 @@ class VoiceBroadcastRecorderService: VoiceBroadcastRecorderServiceProtocol {
self.tearDownVoiceBroadcastService()
}
func pauseOnErrorRecordingVoiceBroadcast() {
audioEngine.pause()
UIApplication.shared.isIdleTimerDisabled = false
invalidateTimer()
// Update state
self.serviceDelegate?.voiceBroadcastRecorderService(self, didUpdateState: .error)
}
// MARK: - Private
/// Reset chunk values.
private func resetValues() {
@@ -25,6 +25,9 @@ protocol VoiceBroadcastRecorderServiceProtocol {
/// Service delegate
var serviceDelegate: VoiceBroadcastRecorderServiceDelegate? { get set }
/// Returns if a voice broadcast is currently recording.
var isRecording: Bool { get }
/// Start voice broadcast recording.
func startRecordingVoiceBroadcast()
@@ -39,4 +42,7 @@ protocol VoiceBroadcastRecorderServiceProtocol {
/// Cancel voice broadcast recording after redacted it.
func cancelRecordingVoiceBroadcast()
/// Pause voice broadcast recording without sending pending events.
func pauseOnErrorRecordingVoiceBroadcast()
}
@@ -0,0 +1,49 @@
//
// Copyright 2023 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 VoiceBroadcastRecorderConnectionErrorView: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
var action: (() -> Void)?
var body: some View {
ZStack {
HStack(spacing: 0) {
Image(uiImage: Asset.Images.errorIcon.image)
.frame(width: 40, height: 40)
Text(VectorL10n.voiceBroadcastRecorderConnectionError)
.multilineTextAlignment(.center)
.font(theme.fonts.caption1)
.foregroundColor(theme.colors.alert)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
struct VoiceBroadcastRecorderConnectionErrorView_Previews: PreviewProvider {
static var previews: some View {
VoiceBroadcastRecorderConnectionErrorView()
}
}
@@ -26,7 +26,7 @@ struct VoiceBroadcastRecorderView: View {
@State private var showingStopAlert = false
private var backgroundColor: Color {
if viewModel.viewState.recordingState != .paused {
if viewModel.viewState.recordingState != .paused, viewModel.viewState.recordingState != .error {
return theme.colors.alert
}
return theme.colors.quarterlyContent
@@ -78,47 +78,53 @@ struct VoiceBroadcastRecorderView: View {
.accessibilityIdentifier("liveButton")
}
HStack(alignment: .top, spacing: 34.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)
if viewModel.viewState.recordingState == .error {
VoiceBroadcastRecorderConnectionErrorView()
} else {
HStack(alignment: .top, spacing: 34.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)
case .error:
break
}
} label: {
if viewModel.viewState.recordingState == .started || viewModel.viewState.recordingState == .resumed {
Image("voice_broadcast_record_pause")
.renderingMode(.original)
} else {
Image("voice_broadcast_record")
.renderingMode(.original)
}
}
} label: {
if viewModel.viewState.recordingState == .started || viewModel.viewState.recordingState == .resumed {
Image("voice_broadcast_record_pause")
.renderingMode(.original)
} else {
Image("voice_broadcast_record")
.accessibilityIdentifier("recordButton")
Button {
showingStopAlert = true
} label: {
Image("voice_broadcast_stop")
.renderingMode(.original)
}
.alert(isPresented:$showingStopAlert) {
Alert(title: Text(VectorL10n.voiceBroadcastStopAlertTitle),
message: Text(VectorL10n.voiceBroadcastStopAlertDescription),
primaryButton: .cancel(),
secondaryButton: .default(Text(VectorL10n.voiceBroadcastStopAlertAgreeButton),
action: {
viewModel.send(viewAction: .stop)
}))
}
.accessibilityIdentifier("stopButton")
.disabled(viewModel.viewState.recordingState == .stopped)
.mask(Color.black.opacity(viewModel.viewState.recordingState == .stopped ? 0.3 : 1.0))
}
.accessibilityIdentifier("recordButton")
Button {
showingStopAlert = true
} label: {
Image("voice_broadcast_stop")
.renderingMode(.original)
}
.alert(isPresented:$showingStopAlert) {
Alert(title: Text(VectorL10n.voiceBroadcastStopAlertTitle),
message: Text(VectorL10n.voiceBroadcastStopAlertDescription),
primaryButton: .cancel(),
secondaryButton: .default(Text(VectorL10n.voiceBroadcastStopAlertAgreeButton),
action: {
viewModel.send(viewAction: .stop)
}))
}
.accessibilityIdentifier("stopButton")
.disabled(viewModel.viewState.recordingState == .stopped)
.mask(Color.black.opacity(viewModel.viewState.recordingState == .stopped ? 0.3 : 1.0))
.padding(EdgeInsets(top: 10.0, leading: 0.0, bottom: 10.0, trailing: 0.0))
}
.padding(EdgeInsets(top: 10.0, leading: 0.0, bottom: 10.0, trailing: 0.0))
}
.padding(EdgeInsets(top: 12.0, leading: 4.0, bottom: 12.0, trailing: 4.0))
}
@@ -21,6 +21,7 @@ enum VoiceBroadcastRecorderViewAction {
case stop
case pause
case resume
case pauseOnError
}
enum VoiceBroadcastRecorderState {
@@ -28,6 +29,7 @@ enum VoiceBroadcastRecorderState {
case stopped
case paused
case resumed
case error
}
struct VoiceBroadcastRecorderDetails {
@@ -56,6 +56,8 @@ class VoiceBroadcastRecorderViewModel: VoiceBroadcastRecorderViewModelType, Voic
pause()
case .resume:
resume()
case .pauseOnError:
pauseOnError()
}
}
@@ -80,6 +82,10 @@ class VoiceBroadcastRecorderViewModel: VoiceBroadcastRecorderViewModelType, Voic
voiceBroadcastRecorderService.resumeRecordingVoiceBroadcast()
}
private func pauseOnError() {
voiceBroadcastRecorderService.pauseOnErrorRecordingVoiceBroadcast()
}
private func updateRemainingTime(_ remainingTime: UInt) {
state.currentRecordingState = VoiceBroadcastRecorderViewModel.currentRecordingState(from: remainingTime)
}