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
@@ -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)
}
}