mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 11:58:32 +00:00
Polls history (#2244)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+60
@@ -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
|
||||
}
|
||||
+200
@@ -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())
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ struct DeveloperOptionsScreen: View {
|
||||
Text("Use encryption")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Section {
|
||||
Button {
|
||||
showConfetti = true
|
||||
|
||||
Reference in New Issue
Block a user