Polls history (#2244)

This commit is contained in:
Nicolas Mauri
2023-12-18 16:38:39 +01:00
committed by GitHub
parent 4ac0ad51d7
commit 72a84badcb
73 changed files with 1516 additions and 202 deletions
@@ -94,6 +94,7 @@
"action_try_again" = "Try again";
"action_view_source" = "View source";
"action_yes" = "Yes";
"action.load_more" = "Load more";
"common_about" = "About";
"common_acceptable_use_policy" = "Acceptable use policy";
"common_advanced_settings" = "Advanced settings";
@@ -444,6 +445,11 @@
"screen_onboarding_welcome_message" = "Welcome to the fastest Element ever. Supercharged for speed and simplicity.";
"screen_onboarding_welcome_subtitle" = "Welcome to %1$@. Supercharged, for speed and simplicity.";
"screen_onboarding_welcome_title" = "Be in your element";
"screen_polls_history_empty_ongoing" = "Can't find any ongoing polls.";
"screen_polls_history_empty_past" = "Can't find any past polls.";
"screen_polls_history_filter_ongoing" = "Ongoing";
"screen_polls_history_filter_past" = "Past";
"screen_polls_history_title" = "Polls";
"screen_recovery_key_change_description" = "Get a new recovery key if you've lost your existing one. After changing your recovery key, your old one will no longer work.";
"screen_recovery_key_change_generate_key" = "Generate a new recovery key";
"screen_recovery_key_change_generate_key_description" = "Make sure you can store your recovery key somewhere safe";
@@ -264,7 +264,7 @@ final class AppSettings {
@UserPreference(key: UserDefaultsKeys.chatBackupEnabled, defaultValue: false, storageType: .userDefaults(store))
var chatBackupEnabled
#endif
// MARK: - Shared
@@ -206,12 +206,22 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
return .mapNavigator(roomID: roomID)
case (.mapNavigator(let roomID), .dismissMapNavigator):
return .room(roomID: roomID)
case (.room(let roomID), .presentPollForm):
return .pollForm(roomID: roomID)
case (.pollForm(let roomID), .dismissPollForm):
return .room(roomID: roomID)
case (.roomDetails(let roomID, _), .presentPollsHistory):
return .pollsHistory(roomID: roomID)
case (.pollsHistory(let roomID), .dismissPollsHistory):
return .roomDetails(roomID: roomID, isRoot: false)
case (.pollsHistory(let roomID), .presentPollForm):
return .pollsHistoryForm(roomID: roomID)
case (.pollsHistoryForm(let roomID), .dismissPollForm):
return .pollsHistory(roomID: roomID)
default:
return nil
}
@@ -320,6 +330,16 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
case (.pollForm, .dismissPollForm, .room):
break
case (.roomDetails(let roomID, _), .presentPollsHistory, .pollsHistory):
presentPollsHistory(roomID: roomID)
case (.pollsHistory, .dismissPollsHistory, .roomDetails):
break
case (.pollsHistory, .presentPollForm(let mode), .pollsHistoryForm):
presentPollForm(mode: mode)
case (.pollsHistoryForm, .dismissPollForm, .pollsHistory):
break
default:
fatalError("Unknown transition: \(context)")
}
@@ -516,6 +536,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.presentNotificationSettingsScreen)
case .presentInviteUsersScreen:
stateMachine.tryEvent(.presentInviteUsersScreen)
case .presentPollsHistory:
stateMachine.tryEvent(.presentPollsHistory)
}
}
.store(in: &cancellables)
@@ -845,6 +867,58 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
}
}
private func presentPollsHistory(roomID: String) {
Task {
await asyncPresentRoomPollsHistory(roomID: roomID)
}
}
private func asyncPresentRoomPollsHistory(roomID: String) async {
let roomProxy: RoomProxyProtocol
guard let proxy = await userSession.clientProxy.roomForIdentifier(roomID) else {
MXLog.error("Invalid room identifier: \(roomID)")
stateMachine.tryEvent(.dismissPollsHistory)
return
}
roomProxy = proxy
await roomProxy.subscribeForUpdates()
let userID = userSession.clientProxy.userID
let timelineItemFactory = RoomTimelineItemFactory(userID: userID,
mediaProvider: userSession.mediaProvider,
attributedStringBuilder: AttributedStringBuilder(permalinkBaseURL: appSettings.permalinkBaseURL,
mentionBuilder: MentionBuilder()),
stateEventStringBuilder: RoomStateEventStringBuilder(userID: userID),
appSettings: appSettings)
let roomTimelineController = roomTimelineControllerFactory.buildRoomTimelineController(roomProxy: roomProxy,
timelineItemFactory: timelineItemFactory,
secureBackupController: userSession.clientProxy.secureBackupController)
let parameters = RoomPollsHistoryScreenCoordinatorParameters(roomProxy: roomProxy,
pollInteractionHandler: PollInteractionHandler(analyticsService: analytics, roomProxy: roomProxy),
roomTimelineController: roomTimelineController)
let coordinator = RoomPollsHistoryScreenCoordinator(parameters: parameters)
coordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .editPoll(let pollStartID, let poll):
stateMachine.tryEvent(.presentPollForm(mode: .edit(eventID: pollStartID, poll: poll)))
}
}
.store(in: &cancellables)
navigationStackCoordinator.push(coordinator) { [weak self] in
self?.stateMachine.tryEvent(.dismissPollsHistory)
}
}
private func presentRoomMemberDetails(member: RoomMemberProxyProtocol) {
guard let roomProxy else {
fatalError()
@@ -1106,6 +1180,8 @@ private extension RoomFlowCoordinator {
case messageForwarding(roomID: String, itemID: TimelineItemIdentifier)
case reportContent(roomID: String, itemID: TimelineItemIdentifier, senderID: String)
case pollForm(roomID: String)
case pollsHistory(roomID: String)
case pollsHistoryForm(roomID: String)
}
struct EventUserInfo {
@@ -1158,6 +1234,9 @@ private extension RoomFlowCoordinator {
case presentPollForm(mode: PollFormMode)
case dismissPollForm
case presentPollsHistory
case dismissPollsHistory
}
}
+15
View File
@@ -1084,6 +1084,16 @@ public enum L10n {
}
/// Be in your element
public static var screenOnboardingWelcomeTitle: String { return L10n.tr("Localizable", "screen_onboarding_welcome_title") }
/// Can't find any ongoing polls.
public static var screenPollsHistoryEmptyOngoing: String { return L10n.tr("Localizable", "screen_polls_history_empty_ongoing") }
/// Can't find any past polls.
public static var screenPollsHistoryEmptyPast: String { return L10n.tr("Localizable", "screen_polls_history_empty_past") }
/// Ongoing
public static var screenPollsHistoryFilterOngoing: String { return L10n.tr("Localizable", "screen_polls_history_filter_ongoing") }
/// Past
public static var screenPollsHistoryFilterPast: String { return L10n.tr("Localizable", "screen_polls_history_filter_past") }
/// Polls
public static var screenPollsHistoryTitle: String { return L10n.tr("Localizable", "screen_polls_history_title") }
/// Get a new recovery key if you've lost your existing one. After changing your recovery key, your old one will no longer work.
public static var screenRecoveryKeyChangeDescription: String { return L10n.tr("Localizable", "screen_recovery_key_change_description") }
/// Generate a new recovery key
@@ -1626,6 +1636,11 @@ public enum L10n {
public static var testLanguageIdentifier: String { return L10n.tr("Localizable", "test_language_identifier") }
/// en
public static var testUntranslatedDefaultLanguageIdentifier: String { return L10n.tr("Localizable", "test_untranslated_default_language_identifier") }
public enum Action {
/// Load more
public static var loadMore: String { return L10n.tr("Localizable", "action.load_more") }
}
}
// swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length
// swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces
@@ -1632,6 +1632,51 @@ class NotificationSettingsProxyMock: NotificationSettingsProxyProtocol {
}
}
}
class PollInteractionHandlerMock: PollInteractionHandlerProtocol {
//MARK: - sendPollResponse
var sendPollResponsePollStartIDOptionIDCallsCount = 0
var sendPollResponsePollStartIDOptionIDCalled: Bool {
return sendPollResponsePollStartIDOptionIDCallsCount > 0
}
var sendPollResponsePollStartIDOptionIDReceivedArguments: (pollStartID: String, optionID: String)?
var sendPollResponsePollStartIDOptionIDReceivedInvocations: [(pollStartID: String, optionID: String)] = []
var sendPollResponsePollStartIDOptionIDReturnValue: Result<Void, Error>!
var sendPollResponsePollStartIDOptionIDClosure: ((String, String) async -> Result<Void, Error>)?
func sendPollResponse(pollStartID: String, optionID: String) async -> Result<Void, Error> {
sendPollResponsePollStartIDOptionIDCallsCount += 1
sendPollResponsePollStartIDOptionIDReceivedArguments = (pollStartID: pollStartID, optionID: optionID)
sendPollResponsePollStartIDOptionIDReceivedInvocations.append((pollStartID: pollStartID, optionID: optionID))
if let sendPollResponsePollStartIDOptionIDClosure = sendPollResponsePollStartIDOptionIDClosure {
return await sendPollResponsePollStartIDOptionIDClosure(pollStartID, optionID)
} else {
return sendPollResponsePollStartIDOptionIDReturnValue
}
}
//MARK: - endPoll
var endPollPollStartIDCallsCount = 0
var endPollPollStartIDCalled: Bool {
return endPollPollStartIDCallsCount > 0
}
var endPollPollStartIDReceivedPollStartID: String?
var endPollPollStartIDReceivedInvocations: [String] = []
var endPollPollStartIDReturnValue: Result<Void, Error>!
var endPollPollStartIDClosure: ((String) async -> Result<Void, Error>)?
func endPoll(pollStartID: String) async -> Result<Void, Error> {
endPollPollStartIDCallsCount += 1
endPollPollStartIDReceivedPollStartID = pollStartID
endPollPollStartIDReceivedInvocations.append(pollStartID)
if let endPollPollStartIDClosure = endPollPollStartIDClosure {
return await endPollPollStartIDClosure(pollStartID)
} else {
return endPollPollStartIDReturnValue
}
}
}
class RoomMemberProxyMock: RoomMemberProxyProtocol {
var userID: String {
get { return underlyingUserID }
@@ -2402,6 +2447,11 @@ class TimelineProxyMock: TimelineProxyProtocol {
set(value) { underlyingTimelineProvider = value }
}
var underlyingTimelineProvider: RoomTimelineProviderProtocol!
var timelineStartReached: Bool {
get { return underlyingTimelineStartReached }
set(value) { underlyingTimelineStartReached = value }
}
var underlyingTimelineStartReached: Bool!
//MARK: - subscribeForUpdates
@@ -2523,6 +2573,27 @@ class TimelineProxyMock: TimelineProxyProtocol {
}
//MARK: - paginateBackwards
var paginateBackwardsRequestSizeCallsCount = 0
var paginateBackwardsRequestSizeCalled: Bool {
return paginateBackwardsRequestSizeCallsCount > 0
}
var paginateBackwardsRequestSizeReceivedRequestSize: UInt?
var paginateBackwardsRequestSizeReceivedInvocations: [UInt] = []
var paginateBackwardsRequestSizeReturnValue: Result<Void, TimelineProxyError>!
var paginateBackwardsRequestSizeClosure: ((UInt) async -> Result<Void, TimelineProxyError>)?
func paginateBackwards(requestSize: UInt) async -> Result<Void, TimelineProxyError> {
paginateBackwardsRequestSizeCallsCount += 1
paginateBackwardsRequestSizeReceivedRequestSize = requestSize
paginateBackwardsRequestSizeReceivedInvocations.append(requestSize)
if let paginateBackwardsRequestSizeClosure = paginateBackwardsRequestSizeClosure {
return await paginateBackwardsRequestSizeClosure(requestSize)
} else {
return paginateBackwardsRequestSizeReturnValue
}
}
//MARK: - paginateBackwards
var paginateBackwardsRequestSizeUntilNumberOfItemsCallsCount = 0
var paginateBackwardsRequestSizeUntilNumberOfItemsCalled: Bool {
return paginateBackwardsRequestSizeUntilNumberOfItemsCallsCount > 0
@@ -35,6 +35,7 @@ struct RoomProxyMockConfiguration {
var timeline = {
let mock = TimelineProxyMock()
mock.underlyingActions = Empty(completeImmediately: false).eraseToAnyPublisher()
mock.timelineStartReached = false
return mock
}()
@@ -46,6 +46,7 @@ enum A11yIdentifiers {
static let notificationSettingsScreen = NotificationSettingsScreen()
static let notificationSettingsEditScreen = NotificationSettingsEditScreen()
static let pollFormScreen = PollFormScreen()
static let roomPollsHistoryScreen = RoomPollsHistoryScreen()
struct AlertInfo {
let primaryButton = "alert_info-primary_button"
@@ -169,6 +170,7 @@ enum A11yIdentifiers {
let people = "room_details-people"
let invite = "room_details-invite"
let notifications = "room_details-notifications"
let pollsHistory = "romm_details-polls_history"
}
struct RoomMemberDetailsScreen {
@@ -263,4 +265,8 @@ enum A11yIdentifiers {
"\(roomNamePrefix):\(name)"
}
}
struct RoomPollsHistoryScreen {
let loadMore = "room_polls_history_screen-load_more"
}
}
@@ -32,6 +32,7 @@ enum RoomDetailsScreenCoordinatorAction {
case presentRoomDetailsEditScreen(accountOwner: RoomMemberProxyProtocol)
case presentNotificationSettingsScreen
case presentInviteUsersScreen
case presentPollsHistory
}
final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
@@ -71,6 +72,8 @@ final class RoomDetailsScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentRoomDetailsEditScreen(accountOwner: accountOwner))
case .requestNotificationSettingsPresentation:
actionsSubject.send(.presentNotificationSettingsScreen)
case .requestPollsHistoryPresentation:
actionsSubject.send(.presentPollsHistory)
}
}
.store(in: &cancellables)
@@ -27,6 +27,7 @@ enum RoomDetailsScreenViewModelAction {
case requestInvitePeoplePresentation
case leftRoom
case requestEditDetailsPresentation(RoomMemberProxyProtocol)
case requestPollsHistoryPresentation
}
// MARK: View
@@ -174,6 +175,7 @@ enum RoomDetailsScreenViewAction {
case processTapNotifications
case processToogleMuteNotifications
case displayAvatar
case processTapPolls
}
enum RoomDetailsScreenViewShortcut {
@@ -67,7 +67,7 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
notificationSettingsState: .loading,
bindings: .init()),
imageProvider: mediaProvider)
setupRoomSubscription()
fetchMembers()
@@ -120,6 +120,8 @@ class RoomDetailsScreenViewModel: RoomDetailsScreenViewModelType, RoomDetailsScr
Task { await toggleMuteNotifications() }
case .displayAvatar:
displayFullScreenAvatar()
case .processTapPolls:
actionsSubject.send(.requestPollsHistoryPresentation)
}
}
@@ -34,9 +34,7 @@ struct RoomDetailsScreen: View {
notificationSection
if context.viewState.dmRecipient == nil {
aboutSection
}
aboutSection
securitySection
@@ -150,22 +148,30 @@ struct RoomDetailsScreen: View {
private var aboutSection: some View {
Section {
ListRow(label: .default(title: L10n.commonPeople,
icon: CompoundIcon(asset: Asset.Images.user)),
details: .title(String(context.viewState.joinedMembersCount)),
kind: .navigationLink {
context.send(viewAction: .processTapPeople)
})
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.people)
if context.viewState.canInviteUsers {
ListRow(label: .default(title: L10n.screenRoomDetailsInvitePeopleTitle,
icon: CompoundIcon(asset: Asset.Images.userAdd)),
if context.viewState.dmRecipient == nil {
ListRow(label: .default(title: L10n.commonPeople,
icon: CompoundIcon(asset: Asset.Images.user)),
details: .title(String(context.viewState.joinedMembersCount)),
kind: .navigationLink {
context.send(viewAction: .processTapInvite)
context.send(viewAction: .processTapPeople)
})
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.invite)
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.people)
if context.viewState.canInviteUsers {
ListRow(label: .default(title: L10n.screenRoomDetailsInvitePeopleTitle,
icon: CompoundIcon(asset: Asset.Images.userAdd)),
kind: .navigationLink {
context.send(viewAction: .processTapInvite)
})
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.invite)
}
}
ListRow(label: .default(title: L10n.screenPollsHistoryTitle,
icon: CompoundIcon(asset: Asset.Images.polls)),
kind: .navigationLink {
context.send(viewAction: .processTapPolls)
})
.accessibilityIdentifier(A11yIdentifiers.roomDetailsScreen.pollsHistory)
}
}
@@ -0,0 +1,60 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import SwiftUI
struct RoomPollsHistoryScreenCoordinatorParameters {
let roomProxy: RoomProxyProtocol
let pollInteractionHandler: PollInteractionHandlerProtocol
let roomTimelineController: RoomTimelineControllerProtocol
}
enum RoomPollsHistoryScreenCoordinatorAction {
case editPoll(pollStartID: String, poll: Poll)
}
final class RoomPollsHistoryScreenCoordinator: CoordinatorProtocol {
private var viewModel: RoomPollsHistoryScreenViewModelProtocol
private let actionsSubject: PassthroughSubject<RoomPollsHistoryScreenCoordinatorAction, Never> = .init()
private var cancellables = Set<AnyCancellable>()
var actions: AnyPublisher<RoomPollsHistoryScreenCoordinatorAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(parameters: RoomPollsHistoryScreenCoordinatorParameters) {
viewModel = RoomPollsHistoryScreenViewModel(roomProxy: parameters.roomProxy,
pollInteractionHandler: parameters.pollInteractionHandler,
roomTimelineController: parameters.roomTimelineController,
userIndicatorController: ServiceLocator.shared.userIndicatorController)
}
func start() {
viewModel.actions.sink { [weak self] action in
guard let self else { return }
switch action {
case .editPoll(let pollStartID, let poll):
actionsSubject.send(.editPoll(pollStartID: pollStartID, poll: poll))
}
}
.store(in: &cancellables)
}
func toPresentable() -> AnyView {
AnyView(RoomPollsHistoryScreen(context: viewModel.context))
}
}
@@ -0,0 +1,81 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Foundation
enum RoomPollsHistoryScreenViewModelAction {
case editPoll(pollStartID: String, poll: Poll)
}
struct RoomPollsHistoryScreenViewState: BindableState {
let title: String
let filters: [RoomPollsHistoryFilter] = [.ongoing, .past]
var pollTimelineItems: [RoomPollsHistoryPollDetails] = []
var canBackPaginate = false
var isBackPaginating = false
var bindings: RoomPollsHistoryScreenViewStateBindings
var emptyStateMessage: String {
switch bindings.filter {
case .ongoing:
L10n.screenPollsHistoryEmptyOngoing
case .past:
L10n.screenPollsHistoryEmptyPast
}
}
}
struct RoomPollsHistoryScreenViewStateBindings {
/// Polls list filter
var filter: RoomPollsHistoryFilter
/// Information describing the currently displayed alert.
var alertInfo: AlertInfo<RoomPollsHistoryScreenErrorType>?
}
enum RoomPollsHistoryScreenViewAction {
case filter(RoomPollsHistoryFilter)
case end(pollStartID: String)
case edit(pollStartID: String, poll: Poll)
case sendPollResponse(pollStartID: String, optionID: String)
case loadMore
}
enum RoomPollsHistoryFilter: Equatable {
case ongoing
case past
}
struct RoomPollsHistoryPollDetails {
let timestamp: Date
let item: PollRoomTimelineItem
}
extension RoomPollsHistoryFilter: CustomStringConvertible {
var description: String {
switch self {
case .ongoing:
L10n.screenPollsHistoryFilterOngoing
case .past:
L10n.screenPollsHistoryFilterPast
}
}
}
enum RoomPollsHistoryScreenErrorType: Hashable {
/// A specific error message shown in an alert.
case alert
}
@@ -0,0 +1,200 @@
//
// 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 Algorithms
import Combine
import OrderedCollections
import SwiftUI
typealias RoomPollsHistoryScreenViewModelType = StateStoreViewModel<RoomPollsHistoryScreenViewState, RoomPollsHistoryScreenViewAction>
class RoomPollsHistoryScreenViewModel: RoomPollsHistoryScreenViewModelType, RoomPollsHistoryScreenViewModelProtocol {
private enum Constants {
static let backPaginationEventLimit: UInt = 250
}
private let roomProxy: RoomProxyProtocol
private let pollInteractionHandler: PollInteractionHandlerProtocol
private let roomTimelineController: RoomTimelineControllerProtocol
private let userIndicatorController: UserIndicatorControllerProtocol
private var paginateBackwardsTask: Task<Void, Never>?
private let isPaginatingIndicatorID = UUID().uuidString
private var actionsSubject: PassthroughSubject<RoomPollsHistoryScreenViewModelAction, Never> = .init()
var actions: AnyPublisher<RoomPollsHistoryScreenViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
init(roomProxy: RoomProxyProtocol,
pollInteractionHandler: PollInteractionHandlerProtocol,
roomTimelineController: RoomTimelineControllerProtocol,
userIndicatorController: UserIndicatorControllerProtocol) {
self.roomProxy = roomProxy
self.pollInteractionHandler = pollInteractionHandler
self.roomTimelineController = roomTimelineController
self.userIndicatorController = userIndicatorController
super.init(initialViewState: RoomPollsHistoryScreenViewState(title: L10n.screenPollsHistoryTitle,
canBackPaginate: true,
bindings: .init(filter: .ongoing)))
setupSubscriptions()
updatePollsList(filter: state.bindings.filter)
}
// MARK: - Public
override func process(viewAction: RoomPollsHistoryScreenViewAction) {
switch viewAction {
case .edit(let pollStartID, let poll):
actionsSubject.send(.editPoll(pollStartID: pollStartID, poll: poll))
case .end(let pollStartID):
endPoll(pollStartID: pollStartID)
case .filter(let filter):
updatePollsList(filter: filter)
case .loadMore:
paginateBackwards()
case .sendPollResponse(let pollStartID, let optionID):
sendPollResponse(pollStartID: pollStartID, optionID: optionID)
}
}
// MARK: - Private
private func setupSubscriptions() {
roomTimelineController.callbacks
.receive(on: DispatchQueue.main)
.sink { [weak self] callback in
guard let self else { return }
switch callback {
case .updatedTimelineItems:
self.updatePollsList(filter: state.bindings.filter)
case .canBackPaginate(let canBackPaginate):
if self.state.canBackPaginate != canBackPaginate {
self.state.canBackPaginate = canBackPaginate
}
case .isBackPaginating:
break
}
}
.store(in: &cancellables)
context.$viewState
.map(\.isBackPaginating)
.removeDuplicates()
.receive(on: DispatchQueue.main)
.sink { [weak self] isBackPaginating in
guard let self else { return }
if isBackPaginating {
userIndicatorController.submitIndicator(.init(id: isPaginatingIndicatorID, type: .modal(progress: .indeterminate, interactiveDismissDisabled: true, allowsInteraction: false), title: L10n.commonLoading))
} else {
userIndicatorController.retractIndicatorWithId(isPaginatingIndicatorID)
}
}
.store(in: &cancellables)
}
private func displayError(message: String) {
state.bindings.alertInfo = .init(id: .alert, title: message)
}
// MARK: - Poll Interaction Handler
private func endPoll(pollStartID: String) {
Task {
do {
try await pollInteractionHandler.endPoll(pollStartID: pollStartID).get()
} catch {
MXLog.error("Failed to end poll. \(error)")
displayError(message: L10n.errorUnknown)
}
}
}
private func sendPollResponse(pollStartID: String, optionID: String) {
Task {
do {
try await pollInteractionHandler.sendPollResponse(pollStartID: pollStartID, optionID: optionID).get()
} catch {
MXLog.error("Failed to send poll response. \(error)")
displayError(message: L10n.errorUnknown)
}
}
}
// MARK: - Timeline
private func updatePollsList(filter: RoomPollsHistoryFilter) {
// Get the poll timeline items to display
var items: [PollRoomTimelineItem] = []
for timelineItem in roomTimelineController.timelineItems {
if let pollRoomTimelineItem = timelineItem as? PollRoomTimelineItem {
// Apply the filter
switch filter {
case .ongoing where !pollRoomTimelineItem.poll.hasEnded:
items.append(pollRoomTimelineItem)
case .past where pollRoomTimelineItem.poll.hasEnded:
items.append(pollRoomTimelineItem)
default:
break
}
}
}
// Map into RoomPollsHistoryPollDetails to have both the event timestamp and the timeline item
state.pollTimelineItems = items.map { item in
guard let timestamp = roomTimelineController.eventTimestamp(for: item.id) else {
return nil
}
return RoomPollsHistoryPollDetails(timestamp: timestamp, item: item)
}
.compactMap { $0 }
.sorted { $0.timestamp > $1.timestamp }
}
private func paginateBackwards() {
guard paginateBackwardsTask == nil else {
return
}
paginateBackwardsTask = Task { [weak self] in
guard let self else {
return
}
state.isBackPaginating = true
switch await roomTimelineController.paginateBackwards(requestSize: Constants.backPaginationEventLimit) {
case .failure(let error):
MXLog.error("failed to back paginate. \(error)")
default:
break
}
paginateBackwardsTask = nil
state.isBackPaginating = false
}
}
}
// MARK: - Mocks
extension RoomPollsHistoryScreenViewModel {
static let mock = RoomPollsHistoryScreenViewModel(roomProxy: RoomProxyMock(),
pollInteractionHandler: PollInteractionHandlerMock(),
roomTimelineController: MockRoomTimelineController(),
userIndicatorController: UserIndicatorControllerMock())
}
@@ -0,0 +1,23 @@
//
// Copyright 2022 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
@MainActor
protocol RoomPollsHistoryScreenViewModelProtocol {
var actions: AnyPublisher<RoomPollsHistoryScreenViewModelAction, Never> { get }
var context: RoomPollsHistoryScreenViewModelType.Context { get }
}
@@ -0,0 +1,174 @@
//
// 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 Compound
import SwiftUI
struct RoomPollsHistoryScreen: View {
@ObservedObject var context: RoomPollsHistoryScreenViewModel.Context
var body: some View {
ScrollView {
VStack(alignment: .center, spacing: 16) {
modePicker
polls
if context.viewState.pollTimelineItems.isEmpty {
emptyStateMessage
.padding(.top, 48)
}
if context.viewState.canBackPaginate {
loadMoreButton
.padding(.top, context.viewState.pollTimelineItems.isEmpty ? 0 : 16)
}
}
.padding()
}
.alert(item: $context.alertInfo)
.scrollContentBackground(.hidden)
.background(.compound.bgSubtleSecondaryLevel0)
.navigationTitle(context.viewState.title)
.navigationBarTitleDisplayMode(.inline)
}
// MARK: - Private
private var modePicker: some View {
Picker("", selection: $context.filter) {
ForEach(context.viewState.filters, id: \.self) { filter in
Text(filter.description)
}
}
.pickerStyle(.segmented)
.readableFrame(maxWidth: 475)
.onChange(of: context.filter) { value in
context.send(viewAction: .filter(value))
}
}
private var polls: some View {
ForEach(context.viewState.pollTimelineItems, id: \.item.id.eventID) { pollTimelineItem in
VStack(alignment: .leading, spacing: 8) {
Text(DateFormatter.pollTimestamp.string(from: pollTimelineItem.timestamp))
.font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
PollView(poll: pollTimelineItem.item.poll, editable: pollTimelineItem.item.isEditable) { action in
switch action {
case .selectOption(let optionID):
guard let pollStartID = pollTimelineItem.item.id.eventID else { return }
context.send(viewAction: .sendPollResponse(pollStartID: pollStartID, optionID: optionID))
case .edit:
guard let pollStartID = pollTimelineItem.item.id.eventID else { return }
context.send(viewAction: .edit(pollStartID: pollStartID, poll: pollTimelineItem.item.poll))
case .end:
guard let pollStartID = pollTimelineItem.item.id.eventID else { return }
context.send(viewAction: .end(pollStartID: pollStartID))
}
}
}
.padding(.init(top: 12, leading: 12, bottom: 12, trailing: 12))
.background(.compound.bgCanvasDefaultLevel1)
.cornerRadius(12, corners: .allCorners)
}
}
private var emptyStateMessage: some View {
Text(context.viewState.emptyStateMessage)
.font(.compound.bodyLG)
.foregroundColor(.compound.textSecondary)
.multilineTextAlignment(.center)
.padding(.vertical, 12)
}
private var loadMoreButton: some View {
Button {
context.send(viewAction: .loadMore)
} label: {
Text(L10n.Action.loadMore)
.font(.compound.bodyLGSemibold)
.padding(.horizontal, 12)
}
.accessibilityIdentifier(A11yIdentifiers.roomPollsHistoryScreen.loadMore)
.buttonStyle(.compound(.secondary))
.fixedSize()
.disabled(context.viewState.isBackPaginating)
}
}
private extension DateFormatter {
static let pollTimestamp: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.dateStyle = .short
return dateFormatter
}()
}
// MARK: - Previews
struct RoomPollsHistoryScreen_Previews: PreviewProvider, TestablePreview {
static let viewModelEmpty: RoomPollsHistoryScreenViewModel = {
let roomTimelineController = MockRoomTimelineController()
roomTimelineController.timelineItems = []
let roomProxyMockConfiguration = RoomProxyMockConfiguration(displayName: "Polls")
roomProxyMockConfiguration.timeline.timelineStartReached = false
let viewModel = RoomPollsHistoryScreenViewModel(roomProxy: RoomProxyMock(with: roomProxyMockConfiguration),
pollInteractionHandler: PollInteractionHandlerMock(),
roomTimelineController: roomTimelineController,
userIndicatorController: UserIndicatorControllerMock())
return viewModel
}()
static let viewModel: RoomPollsHistoryScreenViewModel = {
let roomTimelineController = MockRoomTimelineController()
let polls = [PollRoomTimelineItem.mock(poll: .disclosed(createdByAccountOwner: false)),
PollRoomTimelineItem.mock(poll: .disclosed(createdByAccountOwner: true)),
PollRoomTimelineItem.mock(poll: .emptyDisclosed, isEditable: true)]
roomTimelineController.timelineItems = polls
for i in 0..<polls.count {
let item = polls[i]
let date: Date! = DateComponents(calendar: .current, timeZone: .gmt, year: 2023, month: 12, day: 1 + i, hour: 12).date
roomTimelineController.timelineItemsTimestamp[item.id] = date
}
let roomProxyMockConfiguration = RoomProxyMockConfiguration(displayName: "Polls")
roomProxyMockConfiguration.timeline.timelineStartReached = true
let viewModel = RoomPollsHistoryScreenViewModel(roomProxy: RoomProxyMock(with: roomProxyMockConfiguration),
pollInteractionHandler: PollInteractionHandlerMock(),
roomTimelineController: roomTimelineController,
userIndicatorController: UserIndicatorControllerMock())
return viewModel
}()
static var previews: some View {
NavigationStack {
RoomPollsHistoryScreen(context: viewModelEmpty.context)
}
.previewDisplayName("No polls")
.snapshot(delay: 1.0)
NavigationStack {
RoomPollsHistoryScreen(context: viewModel.context)
}
.previewDisplayName("polls")
.snapshot(delay: 1.0)
}
}
@@ -43,6 +43,7 @@ class RoomScreenInteractionHandler {
private let application: ApplicationProtocol
private let appSettings: AppSettings
private let analyticsService: AnalyticsService
private let pollInteractionHandler: PollInteractionHandlerProtocol
private let actionsSubject: PassthroughSubject<RoomScreenInteractionHandlerAction, Never> = .init()
var actions: AnyPublisher<RoomScreenInteractionHandlerAction, Never> {
@@ -73,6 +74,7 @@ class RoomScreenInteractionHandler {
self.application = application
self.appSettings = appSettings
self.analyticsService = analyticsService
pollInteractionHandler = PollInteractionHandler(analyticsService: analyticsService, roomProxy: roomProxy)
}
// MARK: Timeline Item Action Menu
@@ -274,9 +276,8 @@ class RoomScreenInteractionHandler {
func sendPollResponse(pollStartID: String, optionID: String) {
Task {
let sendPollResponseResult = await roomProxy.timeline.sendPollResponse(pollStartID: pollStartID, answers: [optionID])
analyticsService.trackPollVote()
let sendPollResponseResult = await pollInteractionHandler.sendPollResponse(pollStartID: pollStartID, optionID: optionID)
switch sendPollResponseResult {
case .success:
break
@@ -288,9 +289,8 @@ class RoomScreenInteractionHandler {
func endPoll(pollStartID: String) {
Task {
let endPollResult = await roomProxy.timeline.endPoll(pollStartID: pollStartID,
text: "The poll with event id: \(pollStartID) has ended")
analyticsService.trackPollEnd()
let endPollResult = await pollInteractionHandler.endPoll(pollStartID: pollStartID)
switch endPollResult {
case .success:
break
@@ -0,0 +1,174 @@
//
// 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
enum PollViewAction {
case selectOption(optionID: String)
case edit
case end
}
struct PollView: View {
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
let poll: Poll
let editable: Bool
let actionHandler: (PollViewAction) -> Void
var body: some View {
VStack(alignment: .leading, spacing: 16) {
questionView
optionsView
summaryView
toolbarView
}
.frame(maxWidth: 450)
}
// MARK: - Private
private var questionView: some View {
HStack(alignment: .top, spacing: 12) {
let asset = poll.hasEnded ? Asset.Images.pollsEnd : Asset.Images.polls
Image(asset.name)
.resizable()
.scaledFrame(size: 22)
.accessibilityHidden(true)
Text(poll.question)
.multilineTextAlignment(.leading)
.font(.compound.bodyLGSemibold)
}
}
private var optionsView: some View {
ForEach(poll.options, id: \.id) { option in
Button {
guard !option.isSelected else { return }
actionHandler(.selectOption(optionID: option.id))
feedbackGenerator.impactOccurred()
} label: {
PollOptionView(pollOption: option,
showVotes: showVotes,
isFinalResult: poll.hasEnded)
.foregroundColor(progressBarColor(for: option))
}
.disabled(poll.hasEnded)
}
}
@ViewBuilder
private var summaryView: some View {
if let summaryText = poll.summaryText {
Text(summaryText)
.font(.compound.bodySM)
.scaledPadding(.leading, showVotes ? 0 : 32)
.foregroundColor(.compound.textSecondary)
.frame(maxWidth: .infinity, alignment: showVotes ? .trailing : .leading)
}
}
@ViewBuilder
private var toolbarView: some View {
if !poll.hasEnded, poll.createdByAccountOwner {
Button {
toolbarAction()
} label: {
Text(editable ? L10n.actionEditPoll : L10n.actionEndPoll)
.lineLimit(2, reservesSpace: false)
.font(.compound.bodyLGSemibold)
.foregroundColor(.compound.textOnSolidPrimary)
.padding(.horizontal, 24)
.padding(.vertical, 10)
.frame(maxWidth: .infinity)
.background {
Capsule()
.foregroundColor(.compound.bgActionPrimaryRest)
}
}
.padding(.top, 8)
}
}
private func toolbarAction() {
if editable {
actionHandler(.edit)
} else {
actionHandler(.end)
}
}
private func progressBarColor(for option: Poll.Option) -> Color {
if poll.hasEnded {
return option.isWinning ? .compound.textActionAccent : .compound.textDisabled
} else {
return .compound.textPrimary
}
}
private var showVotes: Bool {
poll.hasEnded || poll.kind == .disclosed
}
}
private extension Poll {
var summaryText: String? {
guard !hasEnded else {
return options.first.map {
L10n.commonPollTotalVotes($0.allVotes)
}
}
switch kind {
case .disclosed:
return options.first.map {
L10n.commonPollTotalVotes($0.allVotes)
}
case .undisclosed:
return L10n.commonPollUndisclosedText
}
}
}
struct PollView_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
PollView(poll: .disclosed(), editable: false) { _ in }
.padding()
.previewDisplayName("Disclosed")
PollView(poll: .undisclosed(), editable: false) { _ in }
.padding()
.previewDisplayName("Undisclosed")
PollView(poll: .endedDisclosed, editable: false) { _ in }
.padding()
.previewDisplayName("Ended, Disclosed")
PollView(poll: .endedUndisclosed, editable: false) { _ in }
.padding()
.previewDisplayName("Ended, Undisclosed")
PollView(poll: .disclosed(createdByAccountOwner: true), editable: true) { _ in }
.padding()
.previewDisplayName("Creator, disclosed")
PollView(poll: .emptyDisclosed, editable: true) { _ in }
.padding()
.previewDisplayName("Creator, no votes")
}
}
@@ -19,136 +19,35 @@ import SwiftUI
struct PollRoomTimelineView: View {
let timelineItem: PollRoomTimelineItem
@EnvironmentObject private var context: RoomScreenViewModel.Context
private let feedbackGenerator = UIImpactFeedbackGenerator(style: .heavy)
var body: some View {
TimelineStyler(timelineItem: timelineItem) {
VStack(alignment: .leading, spacing: 16) {
questionView
optionsView
summaryView
toolbarView
PollView(poll: poll, editable: timelineItem.isEditable) { action in
switch action {
case .selectOption(let optionID):
guard let eventID, let option = poll.options.first(where: { $0.id == optionID }), !option.isSelected else { return }
context.send(viewAction: .poll(.selectOption(pollStartID: eventID, optionID: option.id)))
case .edit:
guard let eventID else { return }
context.send(viewAction: .poll(.edit(pollStartID: eventID, poll: poll)))
case .end:
guard let eventID else { return }
context.send(viewAction: .poll(.end(pollStartID: eventID)))
}
}
.frame(maxWidth: 450)
}
}
// MARK: - Private
private var poll: Poll {
timelineItem.poll
}
private var eventID: String? {
timelineItem.id.eventID
}
private var questionView: some View {
HStack(alignment: .top, spacing: 12) {
let asset = poll.hasEnded ? Asset.Images.pollsEnd : Asset.Images.polls
Image(asset.name)
.resizable()
.scaledFrame(size: 22)
.accessibilityHidden(true)
Text(poll.question)
.multilineTextAlignment(.leading)
.font(.compound.bodyLGSemibold)
}
}
private var optionsView: some View {
ForEach(poll.options, id: \.id) { option in
Button {
guard let eventID, !option.isSelected else { return }
context.send(viewAction: .poll(.selectOption(pollStartID: eventID, optionID: option.id)))
feedbackGenerator.impactOccurred()
} label: {
PollOptionView(pollOption: option,
showVotes: showVotes,
isFinalResult: poll.hasEnded)
.foregroundColor(progressBarColor(for: option))
}
.disabled(poll.hasEnded || eventID == nil)
}
}
@ViewBuilder
private var summaryView: some View {
if let summaryText = poll.summaryText {
Text(summaryText)
.font(.compound.bodySM)
.scaledPadding(.leading, showVotes ? 0 : 32)
.foregroundColor(.compound.textSecondary)
.frame(maxWidth: .infinity, alignment: showVotes ? .trailing : .leading)
}
}
@ViewBuilder
private var toolbarView: some View {
if !poll.hasEnded, poll.createdByAccountOwner {
Button {
toolbarAction()
} label: {
Text(timelineItem.isEditable ? L10n.actionEditPoll : L10n.actionEndPoll)
.lineLimit(2, reservesSpace: false)
.font(.compound.bodyLGSemibold)
.foregroundColor(.compound.textOnSolidPrimary)
.padding(.horizontal, 24)
.padding(.vertical, 10)
.frame(maxWidth: .infinity)
.background {
Capsule()
.foregroundColor(.compound.bgActionPrimaryRest)
}
}
.padding(.top, 8)
}
}
private func toolbarAction() {
guard let eventID else {
return
}
if timelineItem.isEditable {
context.send(viewAction: .poll(.edit(pollStartID: eventID, poll: poll)))
} else {
context.send(viewAction: .poll(.end(pollStartID: eventID)))
}
// MARK: - Private
private var poll: Poll {
timelineItem.poll
}
private func progressBarColor(for option: Poll.Option) -> Color {
if poll.hasEnded {
return option.isWinning ? .compound.textActionAccent : .compound.textDisabled
} else {
return .compound.textPrimary
}
}
private var showVotes: Bool {
poll.hasEnded || poll.kind == .disclosed
}
}
private extension Poll {
var summaryText: String? {
guard !hasEnded else {
return options.first.map {
L10n.commonPollTotalVotes($0.allVotes)
}
}
switch kind {
case .disclosed:
return options.first.map {
L10n.commonPollTotalVotes($0.allVotes)
}
case .undisclosed:
return L10n.commonPollUndisclosedText
}
private var eventID: String? {
timelineItem.id.eventID
}
}
@@ -74,7 +74,7 @@ struct DeveloperOptionsScreen: View {
Text("Use encryption")
}
}
Section {
Button {
showConfetti = true
@@ -0,0 +1,41 @@
//
// 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 Foundation
class PollInteractionHandler: PollInteractionHandlerProtocol {
let analyticsService: AnalyticsService
let roomProxy: RoomProxyProtocol
init(analyticsService: AnalyticsService, roomProxy: RoomProxyProtocol) {
self.analyticsService = analyticsService
self.roomProxy = roomProxy
}
func sendPollResponse(pollStartID: String, optionID: String) async -> Result<Void, Error> {
let sendPollResponseResult = await roomProxy.timeline.sendPollResponse(pollStartID: pollStartID, answers: [optionID])
analyticsService.trackPollVote()
return sendPollResponseResult.mapError { $0 }
}
func endPoll(pollStartID: String) async -> Result<Void, Error> {
let endPollResult = await roomProxy.timeline.endPoll(pollStartID: pollStartID,
text: "The poll with event id: \(pollStartID) has ended")
analyticsService.trackPollEnd()
return endPollResult.mapError { $0 }
}
}
@@ -0,0 +1,25 @@
//
// 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 Foundation
protocol PollInteractionHandlerProtocol {
func sendPollResponse(pollStartID: String, optionID: String) async -> Result<Void, Error>
func endPoll(pollStartID: String) async -> Result<Void, Error>
}
// sourcery: AutoMockable
extension PollInteractionHandlerProtocol { }
@@ -31,6 +31,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
let callbacks = PassthroughSubject<RoomTimelineControllerCallback, Never>()
var timelineItems: [RoomTimelineItemProtocol] = RoomTimelineItemFixtures.default
var timelineItemsTimestamp: [TimelineItemIdentifier: Date] = [:]
private var client: UITestsSignalling.Client?
@@ -43,7 +44,12 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
fatalError("Failure setting up signalling: \(error)")
}
}
func paginateBackwards(requestSize: UInt) async -> Result<Void, RoomTimelineControllerError> {
try? await simulateBackPagination()
return .success(())
}
func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result<Void, RoomTimelineControllerError> {
callbacks.send(.canBackPaginate(false))
return .success(())
@@ -99,6 +105,10 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
await roomProxy?.timeline.cancelSend(transactionID: transactionID)
}
func eventTimestamp(for itemID: TimelineItemIdentifier) -> Date? {
timelineItemsTimestamp[itemID] ?? .now
}
// MARK: - UI Test signalling
/// The cancellable used for UI Tests signalling.
@@ -153,6 +163,7 @@ class MockRoomTimelineController: RoomTimelineControllerProtocol {
timelineItems.insert(contentsOf: newItems, at: 0)
callbacks.send(.updatedTimelineItems)
callbacks.send(.isBackPaginating(false))
callbacks.send(.canBackPaginate(!backPaginationResponses.isEmpty))
try client?.send(.success)
}
@@ -69,6 +69,18 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil)
}
func paginateBackwards(requestSize: UInt) async -> Result<Void, RoomTimelineControllerError> {
MXLog.info("Started back pagination request")
switch await roomProxy.timeline.paginateBackwards(requestSize: requestSize) {
case .success:
MXLog.info("Finished back pagination request")
return .success(())
case .failure(let error):
MXLog.error("Failed back pagination request with error: \(error)")
return .failure(.generic)
}
}
func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result<Void, RoomTimelineControllerError> {
MXLog.info("Started back pagination request")
switch await roomProxy.timeline.paginateBackwards(requestSize: requestSize, untilNumberOfItems: untilNumberOfItems) {
@@ -236,7 +248,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
private func updateTimelineItems() {
var newTimelineItems = [RoomTimelineItemProtocol]()
var canBackPaginate = true
var canBackPaginate = !roomProxy.timeline.timelineStartReached
var isBackPaginating = false
var lastEncryptedHistoryItemIndex: Int?
@@ -299,7 +311,7 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
break
}
}
timelineItems = newTimelineItems
callbacks.send(.updatedTimelineItems)
@@ -384,4 +396,20 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
break
}
}
func eventTimestamp(for itemID: TimelineItemIdentifier) -> Date? {
for itemProxy in roomProxy.timeline.timelineProvider.itemProxies {
switch itemProxy {
case .event(let eventTimelineItemProxy):
if eventTimelineItemProxy.id == itemID {
return eventTimelineItemProxy.timestamp
}
case .virtual:
break
case .unknown:
break
}
}
return nil
}
}
@@ -45,6 +45,8 @@ protocol RoomTimelineControllerProtocol {
func processItemDisappearance(_ itemID: TimelineItemIdentifier) async
func paginateBackwards(requestSize: UInt) async -> Result<Void, RoomTimelineControllerError>
func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result<Void, RoomTimelineControllerError>
func sendReadReceipt(for itemID: TimelineItemIdentifier) async -> Result<Void, RoomTimelineControllerError>
@@ -70,6 +72,8 @@ protocol RoomTimelineControllerProtocol {
func retrySending(itemID: TimelineItemIdentifier) async
func cancelSending(itemID: TimelineItemIdentifier) async
func eventTimestamp(for itemID: TimelineItemIdentifier) -> Date?
}
extension RoomTimelineControllerProtocol {
@@ -34,6 +34,8 @@ final class TimelineProxy: TimelineProxyProtocol {
private let backPaginationStateSubject = PassthroughSubject<BackPaginationStatus, Never>()
private let timelineUpdatesSubject = PassthroughSubject<[TimelineDiff], Never>()
private(set) var timelineStartReached = false
private let actionsSubject = PassthroughSubject<TimelineProxyAction, Never>()
var actions: AnyPublisher<TimelineProxyAction, Never> {
@@ -134,6 +136,18 @@ final class TimelineProxy: TimelineProxyProtocol {
try? timeline.getTimelineEventContentByEventId(eventId: eventID)
}
func paginateBackwards(requestSize: UInt) async -> Result<Void, TimelineProxyError> {
do {
try await Task.dispatch(on: .global()) {
try self.timeline.paginateBackwards(opts: .simpleRequest(eventLimit: UInt16(requestSize), waitForToken: true))
}
return .success(())
} catch {
return .failure(.failedPaginatingBackwards)
}
}
func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result<Void, TimelineProxyError> {
do {
try await Task.dispatch(on: .global()) {
@@ -480,6 +494,9 @@ final class TimelineProxy: TimelineProxyProtocol {
private func subscribeToBackpagination() {
let listener = RoomBackpaginationStatusListener { [weak self] status in
if status == .timelineStartReached {
self?.timelineStartReached = true
}
self?.backPaginationStateSubject.send(status)
}
do {
@@ -43,6 +43,8 @@ protocol TimelineProxyProtocol {
var timelineProvider: RoomTimelineProviderProtocol { get }
var timelineStartReached: Bool { get }
func subscribeForUpdates() async
/// Cancels a failed message given its transaction ID from the timeline
@@ -62,6 +64,8 @@ protocol TimelineProxyProtocol {
/// Retries sending a failed message given its transaction ID
func retrySend(transactionID: String) async
func paginateBackwards(requestSize: UInt) async -> Result<Void, TimelineProxyError>
func paginateBackwards(requestSize: UInt, untilNumberOfItems: UInt) async -> Result<Void, TimelineProxyError>
func sendAudio(url: URL,
@@ -877,6 +877,40 @@ class MockScreen: Identifiable {
let coordinator = PollFormScreenCoordinator(parameters: .init(mode: .new))
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .roomPollsHistoryEmptyLoadMore:
let navigationStackCoordinator = NavigationStackCoordinator()
let interactionHandler = PollInteractionHandlerMock()
let roomTimelineController = MockRoomTimelineController()
roomTimelineController.backPaginationResponses = [
[],
[]
]
let roomProxyMockConfiguration = RoomProxyMockConfiguration(displayName: "Polls")
roomProxyMockConfiguration.timeline.timelineStartReached = false
let parameters = RoomPollsHistoryScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: roomProxyMockConfiguration),
pollInteractionHandler: interactionHandler,
roomTimelineController: roomTimelineController)
let coordinator = RoomPollsHistoryScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
case .roomPollsHistoryLoadMore:
let navigationStackCoordinator = NavigationStackCoordinator()
let interactionHandler = PollInteractionHandlerMock()
let roomTimelineController = MockRoomTimelineController()
let poll = PollRoomTimelineItem.mock(poll: .emptyDisclosed, isEditable: true)
roomTimelineController.timelineItems = [poll]
let date: Date! = DateComponents(calendar: .current, timeZone: .gmt, year: 2023, month: 12, day: 1, hour: 12).date
roomTimelineController.timelineItemsTimestamp = [poll.id: date]
let roomProxyMockConfiguration = RoomProxyMockConfiguration(displayName: "Polls")
roomProxyMockConfiguration.timeline.timelineStartReached = false
let parameters = RoomPollsHistoryScreenCoordinatorParameters(roomProxy: RoomProxyMock(with: roomProxyMockConfiguration),
pollInteractionHandler: interactionHandler,
roomTimelineController: roomTimelineController)
let coordinator = RoomPollsHistoryScreenCoordinator(parameters: parameters)
navigationStackCoordinator.setRootCoordinator(coordinator)
return navigationStackCoordinator
}
}()
}
@@ -86,6 +86,8 @@ enum UITestsScreenIdentifier: String {
case createRoom
case createRoomNoUsers
case createPoll
case roomPollsHistoryEmptyLoadMore
case roomPollsHistoryLoadMore
}
extension UITestsScreenIdentifier: CustomStringConvertible {