Merge pull request #7518 from vector-im/nimau/7497_timeline_closed_polls

Fix: allow to render a TimelinePoll even if the poll is loading
This commit is contained in:
Nicolas Mauri 2023-04-27 19:04:42 +02:00 committed by GitHub
commit ef4dc46c97
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 173 additions and 94 deletions

View file

@ -2405,6 +2405,8 @@ Tap the + to start adding people.";
"poll_timeline_reply_ended_poll" = "Ended poll";
"poll_timeline_loading" = "Loading...";
// MARK: - Location sharing
"location_sharing_title" = "Location";

View file

@ -4927,6 +4927,10 @@ public class VectorL10n: NSObject {
public static var pollTimelineEndedText: String {
return VectorL10n.tr("Vector", "poll_timeline_ended_text")
}
/// Loading...
public static var pollTimelineLoading: String {
return VectorL10n.tr("Vector", "poll_timeline_loading")
}
/// Please try again
public static var pollTimelineNotClosedSubtitle: String {
return VectorL10n.tr("Vector", "poll_timeline_not_closed_subtitle")

View file

@ -48,7 +48,7 @@ enum MockPollHistoryDetailScreenState: MockScreenState, CaseIterable {
}
var screenView: ([Any], AnyView) {
let timelineViewModel = TimelinePollViewModel(timelinePollDetails: poll)
let timelineViewModel = TimelinePollViewModel(timelinePollDetailsState: .loaded(poll))
let viewModel = PollHistoryDetailViewModel(poll: poll)
return ([viewModel], AnyView(PollHistoryDetail(viewModel: viewModel.context, contentPoll: TimelinePollView(viewModel: timelineViewModel.context))))

View file

@ -209,13 +209,13 @@ extension PollHistoryService: PollAggregatorDelegate {
func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { }
func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) {
guard let context = pollAggregationContexts[aggregator.poll.id], context.published == false else {
guard let poll = aggregator.poll, let context = pollAggregationContexts[poll.id], context.published == false else {
return
}
context.published = true
let newPoll: TimelinePollDetails = .init(poll: aggregator.poll, represent: .started)
let newPoll: TimelinePollDetails = .init(poll: poll, represent: .started)
if context.isLivePoll {
livePollsSubject.send(newPoll)
@ -225,9 +225,9 @@ extension PollHistoryService: PollAggregatorDelegate {
}
func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) {
guard let context = pollAggregationContexts[aggregator.poll.id], context.published else {
guard let poll = aggregator.poll, let context = pollAggregationContexts[poll.id], context.published else {
return
}
updatesSubject.send(.init(poll: aggregator.poll, represent: .started))
updatesSubject.send(.init(poll: poll, represent: .started))
}
}

View file

@ -32,7 +32,7 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
private let parameters: TimelinePollCoordinatorParameters
private let selectedAnswerIdentifiersSubject = PassthroughSubject<[String], Never>()
private var pollAggregator: PollAggregator
private var pollAggregator: PollAggregator!
private(set) var viewModel: TimelinePollViewModelProtocol!
private var cancellables = Set<AnyCancellable>()
@ -46,10 +46,9 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
init(parameters: TimelinePollCoordinatorParameters) throws {
self.parameters = parameters
try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollEvent: parameters.pollEvent)
pollAggregator.delegate = self
viewModel = TimelinePollViewModel(timelinePollDetailsState: .loading)
try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollEvent: parameters.pollEvent, delegate: self)
viewModel = TimelinePollViewModel(timelinePollDetails: buildTimelinePollFrom(pollAggregator.poll))
viewModel.completion = { [weak self] result in
guard let self = self else { return }
@ -92,11 +91,11 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
}
func canEndPoll() -> Bool {
pollAggregator.poll.isClosed == false
pollAggregator.poll?.isClosed == false
}
func canEditPoll() -> Bool {
pollAggregator.poll.isClosed == false && pollAggregator.poll.totalAnswerCount == 0
pollAggregator.poll?.isClosed == false && pollAggregator.poll?.totalAnswerCount == 0
}
func endPoll() {
@ -108,14 +107,23 @@ final class TimelinePollCoordinator: Coordinator, Presentable, PollAggregatorDel
// MARK: - PollAggregatorDelegate
func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) {
viewModel.updateWithPollDetails(buildTimelinePollFrom(aggregator.poll))
if let poll = aggregator.poll {
viewModel.updateWithPollDetailsState(.loaded(buildTimelinePollFrom(poll)))
}
}
func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) { }
func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) { }
func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) {
guard let poll = aggregator.poll else {
return
}
viewModel.updateWithPollDetailsState(.loaded(buildTimelinePollFrom(poll)))
}
func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) { }
func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) {
viewModel.updateWithPollDetailsState(.errored)
}
// MARK: - Private

View file

@ -41,111 +41,134 @@ class TimelinePollViewModelTests: XCTestCase {
hasBeenEdited: false,
hasDecryptionError: false)
viewModel = TimelinePollViewModel(timelinePollDetails: timelinePoll)
viewModel = TimelinePollViewModel(timelinePollDetailsState: .loaded(timelinePoll))
context = viewModel.context
}
func testInitialState() {
XCTAssertEqual(context.viewState.poll.answerOptions.count, 3)
XCTAssertFalse(context.viewState.poll.closed)
XCTAssertEqual(context.viewState.poll.type, .disclosed)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions.count, 3)
XCTAssertEqual(context.viewState.pollState.poll?.closed, false)
XCTAssertEqual(context.viewState.pollState.poll?.type, .disclosed)
}
func testSingleSelectionOnMax1Allowed() {
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
XCTAssertTrue(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[2].selected)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, true)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false)
}
func testSingleReselectionOnMax1Allowed() {
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
XCTAssertTrue(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[2].selected)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, true)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false)
}
func testMultipleSelectionOnMax1Allowed() {
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
XCTAssertFalse(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertTrue(context.viewState.poll.answerOptions[2].selected)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true)
}
func testMultipleReselectionOnMax1Allowed() {
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
XCTAssertFalse(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertTrue(context.viewState.poll.answerOptions[2].selected)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true)
}
func testClosedSelection() {
viewModel.state.poll.closed = true
guard case var .loaded(poll) = context.viewState.pollState else {
return XCTFail()
}
poll.closed = true
viewModel.updateWithPollDetailsState(.loaded(poll))
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
XCTAssertFalse(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[2].selected)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false)
}
func testSingleSelectionOnMax2Allowed() {
viewModel.state.poll.maxAllowedSelections = 2
guard case var .loaded(poll) = context.viewState.pollState else {
return XCTFail()
}
poll.maxAllowedSelections = 2
viewModel.updateWithPollDetailsState(.loaded(poll))
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
XCTAssertTrue(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[2].selected)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, true)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false)
}
func testSingleReselectionOnMax2Allowed() {
viewModel.state.poll.maxAllowedSelections = 2
guard case var .loaded(poll) = context.viewState.pollState else {
return XCTFail()
}
poll.maxAllowedSelections = 2
viewModel.updateWithPollDetailsState(.loaded(poll))
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
XCTAssertFalse(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[2].selected)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false)
}
func testMultipleSelectionOnMax2Allowed() {
viewModel.state.poll.maxAllowedSelections = 2
guard case var .loaded(poll) = context.viewState.pollState else {
return XCTFail()
}
poll.maxAllowedSelections = 2
viewModel.updateWithPollDetailsState(.loaded(poll))
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
context.send(viewAction: .selectAnswerOptionWithIdentifier("2"))
XCTAssertTrue(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertTrue(context.viewState.poll.answerOptions[2].selected)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, true)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true)
context.send(viewAction: .selectAnswerOptionWithIdentifier("1"))
XCTAssertFalse(context.viewState.poll.answerOptions[0].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[1].selected)
XCTAssertTrue(context.viewState.poll.answerOptions[2].selected)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true)
context.send(viewAction: .selectAnswerOptionWithIdentifier("2"))
XCTAssertFalse(context.viewState.poll.answerOptions[0].selected)
XCTAssertTrue(context.viewState.poll.answerOptions[1].selected)
XCTAssertTrue(context.viewState.poll.answerOptions[2].selected)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, true)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, true)
context.send(viewAction: .selectAnswerOptionWithIdentifier("3"))
XCTAssertFalse(context.viewState.poll.answerOptions[0].selected)
XCTAssertTrue(context.viewState.poll.answerOptions[1].selected)
XCTAssertFalse(context.viewState.poll.answerOptions[2].selected)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[0].selected, false)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[1].selected, true)
XCTAssertEqual(context.viewState.pollState.poll?.answerOptions[2].selected, false)
}
}
private extension TimelinePollDetailsState {
var poll: TimelinePollDetails? {
switch self {
case .loaded(let poll):
return poll
default:
return nil
}
}
}

View file

@ -37,6 +37,12 @@ enum TimelinePollEventType {
case ended
}
enum TimelinePollDetailsState {
case loading
case loaded(TimelinePollDetails)
case errored
}
struct TimelinePollAnswerOption: Identifiable {
var id: String
var text: String
@ -94,7 +100,7 @@ struct TimelinePollDetails {
extension TimelinePollDetails: Identifiable { }
struct TimelinePollViewState: BindableState {
var poll: TimelinePollDetails
var pollState: TimelinePollDetailsState
var bindings: TimelinePollViewStateBindings
}

View file

@ -23,6 +23,9 @@ enum MockTimelinePollScreenState: MockScreenState, CaseIterable {
case openUndisclosed
case closedUndisclosed
case closedPollEnded
case loading
case invalidStartEvent
case withAlert
var screenType: Any.Type {
TimelinePollDetails.self
@ -45,7 +48,20 @@ enum MockTimelinePollScreenState: MockScreenState, CaseIterable {
hasBeenEdited: false,
hasDecryptionError: false)
let viewModel = TimelinePollViewModel(timelinePollDetails: poll)
let viewModel: TimelinePollViewModel
switch self {
case .loading:
viewModel = TimelinePollViewModel(timelinePollDetailsState: .loading)
case .invalidStartEvent:
viewModel = TimelinePollViewModel(timelinePollDetailsState: .errored)
default:
viewModel = TimelinePollViewModel(timelinePollDetailsState: .loaded(poll))
}
if self == .withAlert {
viewModel.showAnsweringFailure()
}
return ([viewModel], AnyView(TimelinePollView(viewModel: viewModel.context)))
}

View file

@ -30,8 +30,8 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro
// MARK: - Setup
init(timelinePollDetails: TimelinePollDetails) {
super.init(initialViewState: TimelinePollViewState(poll: timelinePollDetails, bindings: TimelinePollViewStateBindings()))
init(timelinePollDetailsState: TimelinePollDetailsState) {
super.init(initialViewState: TimelinePollViewState(pollState: timelinePollDetailsState, bindings: TimelinePollViewStateBindings()))
}
// MARK: - Public
@ -40,11 +40,11 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro
switch viewAction {
// Update local state. An update will be pushed from the coordinator once sent.
case .selectAnswerOptionWithIdentifier(let identifier):
guard !state.poll.closed else {
// only if the poll is ready and not closed
guard case let .loaded(poll) = state.pollState, !poll.closed else {
return
}
if state.poll.maxAllowedSelections == 1 {
if poll.maxAllowedSelections == 1 {
updateSingleSelectPollLocalState(selectedAnswerIdentifier: identifier, callback: completion)
} else {
updateMultiSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: completion)
@ -54,8 +54,8 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro
// MARK: - TimelinePollViewModelProtocol
func updateWithPollDetails(_ pollDetails: TimelinePollDetails) {
state.poll = pollDetails
func updateWithPollDetailsState(_ pollDetailsState: TimelinePollDetailsState) {
state.pollState = pollDetailsState
}
func showAnsweringFailure() {
@ -73,33 +73,40 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro
// MARK: - Private
func updateSingleSelectPollLocalState(selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) {
state.poll.answerOptions.updateEach { answerOption in
guard case var .loaded(poll) = state.pollState else { return }
var pollAnswerOptions = poll.answerOptions
pollAnswerOptions.updateEach { answerOption in
if answerOption.selected {
answerOption.selected = false
answerOption.count = UInt(max(0, Int(answerOption.count) - 1))
state.poll.totalAnswerCount = UInt(max(0, Int(state.poll.totalAnswerCount) - 1))
poll.totalAnswerCount = UInt(max(0, Int(poll.totalAnswerCount) - 1))
}
if answerOption.id == selectedAnswerIdentifier {
answerOption.selected = true
answerOption.count += 1
state.poll.totalAnswerCount += 1
poll.totalAnswerCount += 1
}
}
poll.answerOptions = pollAnswerOptions
state.pollState = .loaded(poll)
informCoordinatorOfSelectionUpdate(state: state, callback: callback)
}
func updateMultiSelectPollLocalState(_ state: inout TimelinePollViewState, selectedAnswerIdentifier: String, callback: TimelinePollViewModelCallback?) {
let selectedAnswerOptions = state.poll.answerOptions.filter { $0.selected == true }
guard case .loaded(var poll) = state.pollState else { return }
let selectedAnswerOptions = poll.answerOptions.filter { $0.selected == true }
let isDeselecting = selectedAnswerOptions.filter { $0.id == selectedAnswerIdentifier }.count > 0
if !isDeselecting, selectedAnswerOptions.count >= state.poll.maxAllowedSelections {
if !isDeselecting, selectedAnswerOptions.count >= poll.maxAllowedSelections {
return
}
state.poll.answerOptions.updateEach { answerOption in
var pollAnswerOptions = poll.answerOptions
pollAnswerOptions.updateEach { answerOption in
if answerOption.id != selectedAnswerIdentifier {
return
}
@ -107,22 +114,24 @@ class TimelinePollViewModel: TimelinePollViewModelType, TimelinePollViewModelPro
if answerOption.selected {
answerOption.selected = false
answerOption.count = UInt(max(0, Int(answerOption.count) - 1))
state.poll.totalAnswerCount = UInt(max(0, Int(state.poll.totalAnswerCount) - 1))
poll.totalAnswerCount = UInt(max(0, Int(poll.totalAnswerCount) - 1))
} else {
answerOption.selected = true
answerOption.count += 1
state.poll.totalAnswerCount += 1
poll.totalAnswerCount += 1
}
}
poll.answerOptions = pollAnswerOptions
state.pollState = .loaded(poll)
informCoordinatorOfSelectionUpdate(state: state, callback: callback)
}
func informCoordinatorOfSelectionUpdate(state: TimelinePollViewState, callback: TimelinePollViewModelCallback?) {
let selectedIdentifiers = state.poll.answerOptions.compactMap { answerOption in
guard case .loaded(let poll) = state.pollState else { return }
let selectedIdentifiers = poll.answerOptions.compactMap { answerOption in
answerOption.selected ? answerOption.id : nil
}
callback?(.selectedAnswerOptionsWithIdentifiers(selectedIdentifiers))
}
}

View file

@ -20,7 +20,7 @@ protocol TimelinePollViewModelProtocol {
var context: TimelinePollViewModelType.Context { get }
var completion: ((TimelinePollViewModelResult) -> Void)? { get set }
func updateWithPollDetails(_ pollDetails: TimelinePollDetails)
func updateWithPollDetailsState(_ pollDetailsState: TimelinePollDetailsState)
func showAnsweringFailure()
func showClosingFailure()
}

View file

@ -28,8 +28,23 @@ struct TimelinePollView: View {
@ObservedObject var viewModel: TimelinePollViewModel.Context
var body: some View {
let poll = viewModel.viewState.poll
Group {
switch viewModel.viewState.pollState {
case .loading:
TimelinePollMessageView(message: VectorL10n.pollTimelineLoading)
case .loaded(let poll):
pollContent(poll)
case .errored:
TimelinePollMessageView(message: VectorL10n.pollTimelineReplyEndedPoll)
}
}
.alert(item: $viewModel.alertInfo) { info in
info.alert
}
}
@ViewBuilder
private func pollContent(_ poll: TimelinePollDetails) -> some View {
VStack(alignment: .leading, spacing: 16.0) {
if poll.representsPollEndedEvent {
Text(VectorL10n.pollTimelineEndedText)
@ -40,7 +55,7 @@ struct TimelinePollView: View {
Text(poll.question)
.font(theme.fonts.bodySB)
.foregroundColor(theme.colors.primaryContent) +
Text(editedText)
Text(editedText(poll))
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.secondaryContent)
@ -54,21 +69,16 @@ struct TimelinePollView: View {
.disabled(poll.closed)
.fixedSize(horizontal: false, vertical: true)
Text(totalVotesString)
Text(totalVotesString(poll))
.lineLimit(2)
.font(theme.fonts.footnote)
.foregroundColor(theme.colors.tertiaryContent)
}
.padding([.horizontal, .top], 2.0)
.padding([.bottom])
.alert(item: $viewModel.alertInfo) { info in
info.alert
}
}
private var totalVotesString: String {
let poll = viewModel.viewState.poll
private func totalVotesString(_ poll: TimelinePollDetails) -> String {
if poll.hasDecryptionError, poll.totalAnswerCount > 0 {
return VectorL10n.pollTimelineDecryptionError
}
@ -95,8 +105,8 @@ struct TimelinePollView: View {
}
}
private var editedText: String {
viewModel.viewState.poll.hasBeenEdited ? " \(VectorL10n.eventFormatterMessageEditedMention)" : ""
private func editedText(_ poll: TimelinePollDetails) -> String {
poll.hasBeenEdited ? " \(VectorL10n.eventFormatterMessageEditedMention)" : ""
}
}

1
changelog.d/7497.bugfix Normal file
View file

@ -0,0 +1 @@
Poll: The timeline sometimes displayed closed polls in the wrong order.