mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 06:55:59 +00:00
Edit poll UX (#2151)
* Add edit poll on room proxy * Add CreatePollMode * Add “edit poll” presentation flow * Add delete poll section * Inject editing poll * Add submit action * Refactor validation logic * Add edit/delete actions * Fix bubble timestamp for polls * Update localisations * Refactor CreatePoll -> PollForm * Refactor tests * Update rust sdk to 0.0.5-november23 * Update confirmation alerts * Add edit support in TimelineItem menu * Refactor a11y id * Cleanup * Fix failing tests * Add tests * Refine isEditable workaround * Refactor timestamp in TimelineItemBubbledStylerView
This commit is contained in:
@@ -182,9 +182,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case (.dismissNotificationSettingsScreen, .notificationSettingsScreen(let roomID)):
|
||||
return .roomDetails(roomID: roomID, isRoot: false)
|
||||
|
||||
case (.presentCreatePollForm, .room(let roomID)):
|
||||
return .createPollForm(roomID: roomID)
|
||||
case (.dismissCreatePollForm, .createPollForm(let roomID)):
|
||||
case (.presentPollForm, .room(let roomID)):
|
||||
return .pollForm(roomID: roomID)
|
||||
case (.dismissPollForm, .pollForm(let roomID)):
|
||||
return .room(roomID: roomID)
|
||||
|
||||
default:
|
||||
@@ -260,9 +260,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
case (.notificationSettingsScreen, .dismissNotificationSettingsScreen, .roomDetails):
|
||||
break
|
||||
|
||||
case (.room, .presentCreatePollForm, .createPollForm):
|
||||
presentCreatePollForm()
|
||||
case (.createPollForm, .dismissCreatePollForm, .room):
|
||||
case (.room, .presentPollForm(let mode), .pollForm):
|
||||
presentPollForm(mode: mode)
|
||||
case (.pollForm, .dismissPollForm, .room):
|
||||
break
|
||||
|
||||
default:
|
||||
@@ -374,8 +374,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
stateMachine.tryEvent(.presentEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis))
|
||||
case .presentLocationPicker:
|
||||
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .picker))
|
||||
case .presentPollForm:
|
||||
stateMachine.tryEvent(.presentCreatePollForm)
|
||||
case .presentPollForm(let mode):
|
||||
stateMachine.tryEvent(.presentPollForm(mode: mode))
|
||||
case .presentLocationViewer(_, let geoURI, let description):
|
||||
stateMachine.tryEvent(.presentMapNavigator(interactionMode: .viewOnly(geoURI: geoURI, description: description)))
|
||||
case .presentRoomMemberDetails(member: let member):
|
||||
@@ -631,9 +631,9 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
}
|
||||
}
|
||||
|
||||
private func presentCreatePollForm() {
|
||||
private func presentPollForm(mode: PollFormMode) {
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let coordinator = CreatePollScreenCoordinator(parameters: .init())
|
||||
let coordinator = PollFormScreenCoordinator(parameters: .init(mode: mode))
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
|
||||
coordinator.actions
|
||||
@@ -647,39 +647,86 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
switch action {
|
||||
case .cancel:
|
||||
break
|
||||
case let .create(question, options, pollKind):
|
||||
Task {
|
||||
guard let roomProxy = self.roomProxy else {
|
||||
self.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
|
||||
return
|
||||
}
|
||||
|
||||
let result = await roomProxy.createPoll(question: question, answers: options, pollKind: pollKind)
|
||||
|
||||
self.analytics.trackComposer(inThread: false,
|
||||
isEditing: false,
|
||||
isReply: false,
|
||||
messageType: .poll,
|
||||
startsThread: nil)
|
||||
|
||||
self.analytics.trackPollCreated(isUndisclosed: pollKind == .undisclosed, numberOfAnswers: options.count)
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure:
|
||||
self.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
|
||||
}
|
||||
case .delete:
|
||||
deletePoll(mode: mode)
|
||||
case let .submit(question, options, pollKind):
|
||||
switch mode {
|
||||
case .new:
|
||||
createPoll(question: question, options: options, pollKind: pollKind)
|
||||
case .edit(let eventID, _):
|
||||
editPoll(pollStartID: eventID, question: question, options: options, pollKind: pollKind)
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
navigationSplitCoordinator.setSheetCoordinator(navigationStackCoordinator) { [weak self] in
|
||||
self?.stateMachine.tryEvent(.dismissCreatePollForm)
|
||||
self?.stateMachine.tryEvent(.dismissPollForm)
|
||||
}
|
||||
}
|
||||
|
||||
private func createPoll(question: String, options: [String], pollKind: Poll.Kind) {
|
||||
Task {
|
||||
guard let roomProxy = self.roomProxy else {
|
||||
self.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
|
||||
return
|
||||
}
|
||||
|
||||
let result = await roomProxy.createPoll(question: question, answers: options, pollKind: pollKind)
|
||||
|
||||
self.analytics.trackComposer(inThread: false,
|
||||
isEditing: false,
|
||||
isReply: false,
|
||||
messageType: .poll,
|
||||
startsThread: nil)
|
||||
|
||||
self.analytics.trackPollCreated(isUndisclosed: pollKind == .undisclosed, numberOfAnswers: options.count)
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure:
|
||||
self.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func editPoll(pollStartID: String, question: String, options: [String], pollKind: Poll.Kind) {
|
||||
Task {
|
||||
guard let roomProxy = self.roomProxy else {
|
||||
self.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
|
||||
return
|
||||
}
|
||||
|
||||
let result = await roomProxy.editPoll(original: pollStartID, question: question, answers: options, pollKind: pollKind)
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure:
|
||||
self.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deletePoll(mode: PollFormMode) {
|
||||
Task {
|
||||
guard case .edit(let pollStartID, _) = mode, let roomProxy = self.roomProxy else {
|
||||
self.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
|
||||
return
|
||||
}
|
||||
|
||||
let result = await roomProxy.redact(pollStartID)
|
||||
|
||||
switch result {
|
||||
case .success:
|
||||
break
|
||||
case .failure:
|
||||
self.userIndicatorController.submitIndicator(UserIndicator(title: L10n.errorUnknown))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func presentRoomMemberDetails(member: RoomMemberProxyProtocol) {
|
||||
guard let roomProxy else {
|
||||
fatalError()
|
||||
@@ -803,7 +850,7 @@ private extension RoomFlowCoordinator {
|
||||
case roomMemberDetails(roomID: String, member: HashableRoomMemberWrapper)
|
||||
case messageForwarding(roomID: String, itemID: TimelineItemIdentifier)
|
||||
case notificationSettingsScreen(roomID: String)
|
||||
case createPollForm(roomID: String)
|
||||
case pollForm(roomID: String)
|
||||
}
|
||||
|
||||
struct EventUserInfo {
|
||||
@@ -842,8 +889,8 @@ private extension RoomFlowCoordinator {
|
||||
case presentNotificationSettingsScreen
|
||||
case dismissNotificationSettingsScreen
|
||||
|
||||
case presentCreatePollForm
|
||||
case dismissCreatePollForm
|
||||
case presentPollForm(mode: PollFormMode)
|
||||
case dismissPollForm
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -884,9 +884,9 @@ public enum L10n {
|
||||
public static func screenCreatePollAnswerHint(_ p1: Int) -> String {
|
||||
return L10n.tr("Localizable", "screen_create_poll_answer_hint", p1)
|
||||
}
|
||||
/// Are you sure you want to discard this poll?
|
||||
/// Your changes won’t be saved
|
||||
public static var screenCreatePollDiscardConfirmation: String { return L10n.tr("Localizable", "screen_create_poll_discard_confirmation") }
|
||||
/// Discard Poll
|
||||
/// Cancel Poll
|
||||
public static var screenCreatePollDiscardConfirmationTitle: String { return L10n.tr("Localizable", "screen_create_poll_discard_confirmation_title") }
|
||||
/// Question or topic
|
||||
public static var screenCreatePollQuestionDesc: String { return L10n.tr("Localizable", "screen_create_poll_question_desc") }
|
||||
@@ -928,6 +928,10 @@ public enum L10n {
|
||||
public static var screenDmDetailsUnblockAlertDescription: String { return L10n.tr("Localizable", "screen_dm_details_unblock_alert_description") }
|
||||
/// Unblock user
|
||||
public static var screenDmDetailsUnblockUser: String { return L10n.tr("Localizable", "screen_dm_details_unblock_user") }
|
||||
/// Are you sure you want to delete this poll?
|
||||
public static var screenEditPollDeleteConfirmation: String { return L10n.tr("Localizable", "screen_edit_poll_delete_confirmation") }
|
||||
/// Delete Poll
|
||||
public static var screenEditPollDeleteConfirmationTitle: String { return L10n.tr("Localizable", "screen_edit_poll_delete_confirmation_title") }
|
||||
/// Edit poll
|
||||
public static var screenEditPollTitle: String { return L10n.tr("Localizable", "screen_edit_poll_title") }
|
||||
/// Display name
|
||||
|
||||
@@ -2634,6 +2634,27 @@ class RoomProxyMock: RoomProxyProtocol {
|
||||
return createPollQuestionAnswersPollKindReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - editPoll
|
||||
|
||||
var editPollOriginalQuestionAnswersPollKindCallsCount = 0
|
||||
var editPollOriginalQuestionAnswersPollKindCalled: Bool {
|
||||
return editPollOriginalQuestionAnswersPollKindCallsCount > 0
|
||||
}
|
||||
var editPollOriginalQuestionAnswersPollKindReceivedArguments: (eventID: String, question: String, answers: [String], pollKind: Poll.Kind)?
|
||||
var editPollOriginalQuestionAnswersPollKindReceivedInvocations: [(eventID: String, question: String, answers: [String], pollKind: Poll.Kind)] = []
|
||||
var editPollOriginalQuestionAnswersPollKindReturnValue: Result<Void, RoomProxyError>!
|
||||
var editPollOriginalQuestionAnswersPollKindClosure: ((String, String, [String], Poll.Kind) async -> Result<Void, RoomProxyError>)?
|
||||
|
||||
func editPoll(original eventID: String, question: String, answers: [String], pollKind: Poll.Kind) async -> Result<Void, RoomProxyError> {
|
||||
editPollOriginalQuestionAnswersPollKindCallsCount += 1
|
||||
editPollOriginalQuestionAnswersPollKindReceivedArguments = (eventID: eventID, question: question, answers: answers, pollKind: pollKind)
|
||||
editPollOriginalQuestionAnswersPollKindReceivedInvocations.append((eventID: eventID, question: question, answers: answers, pollKind: pollKind))
|
||||
if let editPollOriginalQuestionAnswersPollKindClosure = editPollOriginalQuestionAnswersPollKindClosure {
|
||||
return await editPollOriginalQuestionAnswersPollKindClosure(eventID, question, answers, pollKind)
|
||||
} else {
|
||||
return editPollOriginalQuestionAnswersPollKindReturnValue
|
||||
}
|
||||
}
|
||||
//MARK: - sendPollResponse
|
||||
|
||||
var sendPollResponsePollStartIDAnswersCallsCount = 0
|
||||
|
||||
@@ -67,6 +67,15 @@ extension Poll {
|
||||
.mock(text: "USA 🇺🇸", votes: 2, allVotes: 10)],
|
||||
ended: true)
|
||||
}
|
||||
|
||||
static var emptyDisclosed: Self {
|
||||
mock(question: "What country do you like most?",
|
||||
pollKind: .disclosed,
|
||||
options: [.mock(text: "Italy 🇮🇹", votes: 0, allVotes: 0),
|
||||
.mock(text: "China 🇨🇳", votes: 0, allVotes: 0),
|
||||
.mock(text: "USA 🇺🇸", votes: 0, allVotes: 0)],
|
||||
createdByAccountOwner: true)
|
||||
}
|
||||
}
|
||||
|
||||
extension Poll.Option {
|
||||
@@ -81,13 +90,13 @@ extension Poll.Option {
|
||||
}
|
||||
|
||||
extension PollRoomTimelineItem {
|
||||
static func mock(poll: Poll, isOutgoing: Bool = true) -> Self {
|
||||
static func mock(poll: Poll, isOutgoing: Bool = true, isEditable: Bool = false) -> Self {
|
||||
.init(id: .init(timelineID: UUID().uuidString, eventID: UUID().uuidString),
|
||||
poll: poll,
|
||||
body: "poll",
|
||||
timestamp: "Now",
|
||||
isOutgoing: isOutgoing,
|
||||
isEditable: false,
|
||||
isEditable: isEditable,
|
||||
canBeRepliedTo: true,
|
||||
sender: .init(id: "userID"),
|
||||
properties: .init())
|
||||
|
||||
@@ -45,7 +45,7 @@ enum A11yIdentifiers {
|
||||
static let migrationScreen = MigrationScreen()
|
||||
static let notificationSettingsScreen = NotificationSettingsScreen()
|
||||
static let notificationSettingsEditScreen = NotificationSettingsEditScreen()
|
||||
static let createPollScreen = CreatePollScreen()
|
||||
static let pollFormScreen = PollFormScreen()
|
||||
|
||||
struct AlertInfo {
|
||||
let primaryButton = "alert_info-primary_button"
|
||||
@@ -234,14 +234,13 @@ enum A11yIdentifiers {
|
||||
let roomTopic = "create_room-room_topic"
|
||||
}
|
||||
|
||||
struct CreatePollScreen {
|
||||
let question = "create_poll-question"
|
||||
let create = "create_poll-create"
|
||||
let addOption = "create_poll-add_option"
|
||||
let pollKind = "create_poll-kind"
|
||||
|
||||
private let optionPrefix = "create_poll-option"
|
||||
struct PollFormScreen {
|
||||
let addOption = "poll_form-add_option"
|
||||
let pollKind = "poll_form-kind"
|
||||
let question = "poll_form-question"
|
||||
let submit = "poll_form-submit"
|
||||
|
||||
private let optionPrefix = "poll_form-option"
|
||||
func optionID(_ index: Int) -> String {
|
||||
"\(optionPrefix)-\(index)"
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ enum ComposerToolbarViewModelAction {
|
||||
case displayMediaPicker
|
||||
case displayDocumentPicker
|
||||
case displayLocationPicker
|
||||
case displayPollForm
|
||||
case displayNewPollForm
|
||||
|
||||
case handlePasteOrDrop(provider: NSItemProvider)
|
||||
|
||||
@@ -55,7 +55,7 @@ enum ComposerToolbarViewAction {
|
||||
case displayMediaPicker
|
||||
case displayDocumentPicker
|
||||
case displayLocationPicker
|
||||
case displayPollForm
|
||||
case displayNewPollForm
|
||||
case handlePasteOrDrop(provider: NSItemProvider)
|
||||
case enableTextFormatting
|
||||
case composerAction(action: ComposerAction)
|
||||
|
||||
@@ -128,8 +128,8 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
actionsSubject.send(.displayDocumentPicker)
|
||||
case .displayLocationPicker:
|
||||
actionsSubject.send(.displayLocationPicker)
|
||||
case .displayPollForm:
|
||||
actionsSubject.send(.displayPollForm)
|
||||
case .displayNewPollForm:
|
||||
actionsSubject.send(.displayNewPollForm)
|
||||
case .handlePasteOrDrop(let provider):
|
||||
actionsSubject.send(.handlePasteOrDrop(provider: provider))
|
||||
case .enableTextFormatting:
|
||||
|
||||
@@ -77,7 +77,7 @@ struct RoomAttachmentPicker: View {
|
||||
|
||||
Button {
|
||||
context.showAttachmentPopover = false
|
||||
context.send(viewAction: .displayPollForm)
|
||||
context.send(viewAction: .displayNewPollForm)
|
||||
} label: {
|
||||
Label(L10n.screenRoomAttachmentSourcePoll, icon: \.polls)
|
||||
.labelStyle(.menuSheet)
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
enum CreatePollScreenViewModelAction {
|
||||
case create(question: String, options: [String], pollKind: Poll.Kind)
|
||||
case cancel
|
||||
}
|
||||
|
||||
struct CreatePollScreenViewState: BindableState {
|
||||
let maxNumberOfOptions = 20
|
||||
var bindings: CreatePollScreenViewStateBindings = .init()
|
||||
}
|
||||
|
||||
struct CreatePollScreenViewStateBindings {
|
||||
var question = ""
|
||||
var options: [Option] = [.init(), .init()]
|
||||
var isUndisclosed = false
|
||||
|
||||
struct Option: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
var text = ""
|
||||
}
|
||||
|
||||
var isCreateButtonDisabled: Bool {
|
||||
question.isEmpty || options.count < 2 || options.contains { $0.text.isEmpty }
|
||||
}
|
||||
|
||||
var hasContent: Bool {
|
||||
!question.isEmpty || options.contains(where: { !$0.text.isEmpty }) || isUndisclosed
|
||||
}
|
||||
|
||||
var alertInfo: AlertInfo<UUID>?
|
||||
}
|
||||
|
||||
enum CreatePollScreenViewAction {
|
||||
case cancel
|
||||
case create
|
||||
case deleteOption(index: Int)
|
||||
case addOption
|
||||
}
|
||||
+20
-15
@@ -17,27 +17,30 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
struct CreatePollScreenCoordinatorParameters { }
|
||||
|
||||
enum CreatePollScreenCoordinatorAction {
|
||||
case cancel
|
||||
case create(question: String, options: [String], pollKind: Poll.Kind)
|
||||
struct PollFormScreenCoordinatorParameters {
|
||||
let mode: PollFormMode
|
||||
}
|
||||
|
||||
final class CreatePollScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: CreatePollScreenCoordinatorParameters
|
||||
private var viewModel: CreatePollScreenViewModelProtocol
|
||||
private let actionsSubject: PassthroughSubject<CreatePollScreenCoordinatorAction, Never> = .init()
|
||||
enum PollFormScreenCoordinatorAction {
|
||||
case cancel
|
||||
case delete
|
||||
case submit(question: String, options: [String], pollKind: Poll.Kind)
|
||||
}
|
||||
|
||||
final class PollFormScreenCoordinator: CoordinatorProtocol {
|
||||
private let parameters: PollFormScreenCoordinatorParameters
|
||||
private var viewModel: PollFormScreenViewModelProtocol
|
||||
private let actionsSubject: PassthroughSubject<PollFormScreenCoordinatorAction, Never> = .init()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
var actions: AnyPublisher<CreatePollScreenCoordinatorAction, Never> {
|
||||
var actions: AnyPublisher<PollFormScreenCoordinatorAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init(parameters: CreatePollScreenCoordinatorParameters) {
|
||||
init(parameters: PollFormScreenCoordinatorParameters) {
|
||||
self.parameters = parameters
|
||||
|
||||
viewModel = CreatePollScreenViewModel()
|
||||
viewModel = PollFormScreenViewModel(mode: parameters.mode)
|
||||
}
|
||||
|
||||
func start() {
|
||||
@@ -46,16 +49,18 @@ final class CreatePollScreenCoordinator: CoordinatorProtocol {
|
||||
|
||||
guard let self else { return }
|
||||
switch action {
|
||||
case let .create(question, options, pollKind):
|
||||
self.actionsSubject.send(.create(question: question, options: options, pollKind: pollKind))
|
||||
case .cancel:
|
||||
self.actionsSubject.send(.cancel)
|
||||
case .delete:
|
||||
self.actionsSubject.send(.delete)
|
||||
case let .submit(question, options, pollKind):
|
||||
self.actionsSubject.send(.submit(question: question, options: options, pollKind: pollKind))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(CreatePollScreen(context: viewModel.context))
|
||||
AnyView(PollFormScreen(context: viewModel.context))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//
|
||||
// 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 PollFormScreenViewModelAction: Equatable {
|
||||
case cancel
|
||||
case delete
|
||||
case submit(question: String, options: [String], pollKind: Poll.Kind)
|
||||
}
|
||||
|
||||
struct PollFormScreenViewState: BindableState {
|
||||
let mode: PollFormMode
|
||||
let maxNumberOfOptions = 20
|
||||
var bindings: PollFormScreenViewStateBindings = .init()
|
||||
|
||||
init(mode: PollFormMode) {
|
||||
self.mode = mode
|
||||
|
||||
switch mode {
|
||||
case .new:
|
||||
bindings = .init()
|
||||
case .edit(_, let poll):
|
||||
bindings = .init(poll: poll)
|
||||
}
|
||||
}
|
||||
|
||||
var navigationTitle: String {
|
||||
switch mode {
|
||||
case .new:
|
||||
return L10n.screenCreatePollTitle
|
||||
case .edit:
|
||||
return L10n.screenEditPollTitle
|
||||
}
|
||||
}
|
||||
|
||||
var submitButtonTitle: String {
|
||||
switch mode {
|
||||
case .new:
|
||||
return L10n.actionCreate
|
||||
case .edit:
|
||||
return L10n.actionDone
|
||||
}
|
||||
}
|
||||
|
||||
var isSubmitButtonDisabled: Bool {
|
||||
switch mode {
|
||||
case .new:
|
||||
return !bindings.hasValidContent
|
||||
case .edit:
|
||||
return !bindings.hasValidContent || !formContentHasChanged
|
||||
}
|
||||
}
|
||||
|
||||
var formContentHasChanged: Bool {
|
||||
let initialBindings: PollFormScreenViewStateBindings
|
||||
|
||||
switch mode {
|
||||
case .new:
|
||||
initialBindings = .init()
|
||||
case .edit(_, let poll):
|
||||
initialBindings = .init(poll: poll)
|
||||
}
|
||||
|
||||
return bindings != initialBindings
|
||||
}
|
||||
}
|
||||
|
||||
enum PollFormMode: Hashable {
|
||||
case new
|
||||
case edit(eventID: String, poll: Poll)
|
||||
}
|
||||
|
||||
struct PollFormScreenViewStateBindings: Equatable {
|
||||
var question = ""
|
||||
var options: [Option] = [.init(), .init()]
|
||||
var isUndisclosed = false
|
||||
|
||||
struct Option: Identifiable, Equatable {
|
||||
let id = UUID()
|
||||
var text = ""
|
||||
}
|
||||
|
||||
var hasValidContent: Bool {
|
||||
!question.isEmpty && options.count >= 2 && options.allSatisfy { !$0.text.isEmpty }
|
||||
}
|
||||
|
||||
var alertInfo: AlertInfo<UUID>?
|
||||
|
||||
static func == (lhs: PollFormScreenViewStateBindings, rhs: PollFormScreenViewStateBindings) -> Bool {
|
||||
lhs.question == rhs.question && lhs.options.map(\.text) == rhs.options.map(\.text) && lhs.isUndisclosed == rhs.isUndisclosed
|
||||
}
|
||||
}
|
||||
|
||||
extension PollFormScreenViewStateBindings {
|
||||
init(poll: Poll) {
|
||||
self.init(question: poll.question,
|
||||
options: poll.options.map { .init(text: $0.text) },
|
||||
isUndisclosed: poll.kind == .undisclosed)
|
||||
}
|
||||
}
|
||||
|
||||
enum PollFormScreenViewAction {
|
||||
case cancel
|
||||
case submit
|
||||
case delete
|
||||
case deleteOption(index: Int)
|
||||
case addOption
|
||||
}
|
||||
+17
-11
@@ -17,29 +17,35 @@
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
typealias CreatePollScreenViewModelType = StateStoreViewModel<CreatePollScreenViewState, CreatePollScreenViewAction>
|
||||
typealias PollFormScreenViewModelType = StateStoreViewModel<PollFormScreenViewState, PollFormScreenViewAction>
|
||||
|
||||
class CreatePollScreenViewModel: CreatePollScreenViewModelType, CreatePollScreenViewModelProtocol {
|
||||
private var actionsSubject: PassthroughSubject<CreatePollScreenViewModelAction, Never> = .init()
|
||||
class PollFormScreenViewModel: PollFormScreenViewModelType, PollFormScreenViewModelProtocol {
|
||||
private var actionsSubject: PassthroughSubject<PollFormScreenViewModelAction, Never> = .init()
|
||||
|
||||
var actions: AnyPublisher<CreatePollScreenViewModelAction, Never> {
|
||||
var actions: AnyPublisher<PollFormScreenViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init(initialViewState: .init())
|
||||
|
||||
init(mode: PollFormMode) {
|
||||
super.init(initialViewState: .init(mode: mode))
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
override func process(viewAction: CreatePollScreenViewAction) {
|
||||
override func process(viewAction: PollFormScreenViewAction) {
|
||||
switch viewAction {
|
||||
case .create:
|
||||
actionsSubject.send(.create(question: state.bindings.question,
|
||||
case .submit:
|
||||
actionsSubject.send(.submit(question: state.bindings.question,
|
||||
options: state.bindings.options.map(\.text),
|
||||
pollKind: state.bindings.isUndisclosed ? .undisclosed : .disclosed))
|
||||
case .delete:
|
||||
state.bindings.alertInfo = .init(id: .init(),
|
||||
title: L10n.screenEditPollDeleteConfirmationTitle,
|
||||
message: L10n.screenEditPollDeleteConfirmation,
|
||||
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
|
||||
secondaryButton: .init(title: L10n.actionOk, action: { self.actionsSubject.send(.delete) }))
|
||||
case .cancel:
|
||||
if state.bindings.hasContent {
|
||||
if state.formContentHasChanged {
|
||||
state.bindings.alertInfo = .init(id: .init(),
|
||||
title: L10n.screenCreatePollDiscardConfirmationTitle,
|
||||
message: L10n.screenCreatePollDiscardConfirmation,
|
||||
+3
-3
@@ -17,7 +17,7 @@
|
||||
import Combine
|
||||
|
||||
@MainActor
|
||||
protocol CreatePollScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<CreatePollScreenViewModelAction, Never> { get }
|
||||
var context: CreatePollScreenViewModelType.Context { get }
|
||||
protocol PollFormScreenViewModelProtocol {
|
||||
var actions: AnyPublisher<PollFormScreenViewModelAction, Never> { get }
|
||||
var context: PollFormScreenViewModelType.Context { get }
|
||||
}
|
||||
+61
-31
@@ -17,35 +17,36 @@
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct CreatePollScreen: View {
|
||||
@ObservedObject var context: CreatePollScreenViewModel.Context
|
||||
struct PollFormScreen: View {
|
||||
@ObservedObject var context: PollFormScreenViewModel.Context
|
||||
@FocusState var focus: Focus?
|
||||
|
||||
|
||||
enum Focus: Hashable {
|
||||
case question
|
||||
case option(index: Int)
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
questionSection
|
||||
optionsSection
|
||||
showResultsSection
|
||||
deletePollSection
|
||||
}
|
||||
.track(screen: .createPoll)
|
||||
.trackAnalyticsIfNeeded(context: context)
|
||||
.compoundForm()
|
||||
.scrollDismissesKeyboard(.immediately)
|
||||
.environment(\.editMode, .constant(.active))
|
||||
.navigationTitle(L10n.screenCreatePollTitle)
|
||||
.navigationTitle(context.viewState.navigationTitle)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar { toolbar }
|
||||
.animation(.elementDefault, value: context.options)
|
||||
.interactiveDismissDisabled(context.viewState.bindings.hasContent)
|
||||
.interactiveDismissDisabled(context.viewState.formContentHasChanged)
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
|
||||
private var questionSection: some View {
|
||||
Section(L10n.screenCreatePollQuestionDesc) {
|
||||
TextField(text: $context.question) {
|
||||
@@ -57,7 +58,7 @@ struct CreatePollScreen: View {
|
||||
}
|
||||
.textFieldStyle(.compoundForm)
|
||||
.focused($focus, equals: .question)
|
||||
.accessibilityIdentifier(A11yIdentifiers.createPollScreen.question)
|
||||
.accessibilityIdentifier(A11yIdentifiers.pollFormScreen.question)
|
||||
.onSubmit {
|
||||
focus = context.options.indices.first.map { .option(index: $0) }
|
||||
}
|
||||
@@ -65,22 +66,22 @@ struct CreatePollScreen: View {
|
||||
}
|
||||
.compoundFormSection()
|
||||
}
|
||||
|
||||
|
||||
private var optionsSection: some View {
|
||||
Section {
|
||||
ForEach(context.options) { option in
|
||||
if let index = context.options.firstIndex(of: option) {
|
||||
CreatePollOptionView(text: $context.options[index].text.limited(to: 240),
|
||||
placeholder: L10n.screenCreatePollAnswerHint(index + 1),
|
||||
canDeleteItem: context.options.count > 2) {
|
||||
PollFormOptionView(text: $context.options[index].text.limited(to: 240),
|
||||
placeholder: L10n.screenCreatePollAnswerHint(index + 1),
|
||||
canDeleteItem: context.options.count > 2) {
|
||||
if case .option(let focusedIndex) = focus, focusedIndex == index {
|
||||
focus = nil
|
||||
}
|
||||
|
||||
|
||||
context.send(viewAction: .deleteOption(index: index))
|
||||
}
|
||||
.focused($focus, equals: .option(index: index))
|
||||
.accessibilityIdentifier(A11yIdentifiers.createPollScreen.optionID(index))
|
||||
.accessibilityIdentifier(A11yIdentifiers.pollFormScreen.optionID(index))
|
||||
.onSubmit {
|
||||
let nextOptionIndex = index == context.options.endIndex - 1 ? nil : index + 1
|
||||
focus = nextOptionIndex.map { .option(index: $0) }
|
||||
@@ -91,26 +92,43 @@ struct CreatePollScreen: View {
|
||||
.onMove { offsets, toOffset in
|
||||
context.options.move(fromOffsets: offsets, toOffset: toOffset)
|
||||
}
|
||||
|
||||
|
||||
if context.options.count < context.viewState.maxNumberOfOptions {
|
||||
Button(L10n.screenCreatePollAddOptionBtn) {
|
||||
context.send(viewAction: .addOption)
|
||||
focus = context.options.indices.last.map { .option(index: $0) }
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.createPollScreen.addOption)
|
||||
.accessibilityIdentifier(A11yIdentifiers.pollFormScreen.addOption)
|
||||
}
|
||||
}
|
||||
.compoundFormSection()
|
||||
}
|
||||
|
||||
|
||||
private var showResultsSection: some View {
|
||||
Section {
|
||||
Toggle(L10n.screenCreatePollAnonymousDesc, isOn: $context.isUndisclosed)
|
||||
.accessibilityIdentifier(A11yIdentifiers.createPollScreen.pollKind)
|
||||
.accessibilityIdentifier(A11yIdentifiers.pollFormScreen.pollKind)
|
||||
}
|
||||
.compoundFormSection()
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var deletePollSection: some View {
|
||||
switch context.viewState.mode {
|
||||
case .edit:
|
||||
Section {
|
||||
Button(role: .destructive) {
|
||||
context.send(viewAction: .delete)
|
||||
} label: {
|
||||
Text(L10n.actionDeletePoll)
|
||||
}
|
||||
}
|
||||
.compoundFormSection()
|
||||
case .new:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@ToolbarContentBuilder
|
||||
private var toolbar: some ToolbarContent {
|
||||
ToolbarItem(placement: .navigationBarLeading) {
|
||||
@@ -118,24 +136,36 @@ struct CreatePollScreen: View {
|
||||
context.send(viewAction: .cancel)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(L10n.actionCreate) {
|
||||
context.send(viewAction: .create)
|
||||
Button(context.viewState.submitButtonTitle) {
|
||||
context.send(viewAction: .submit)
|
||||
}
|
||||
.disabled(context.viewState.bindings.isCreateButtonDisabled)
|
||||
.accessibilityIdentifier(A11yIdentifiers.createPollScreen.create)
|
||||
.disabled(context.viewState.isSubmitButtonDisabled)
|
||||
.accessibilityIdentifier(A11yIdentifiers.pollFormScreen.submit)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct CreatePollOptionView: View {
|
||||
private extension View {
|
||||
@MainActor @ViewBuilder
|
||||
func trackAnalyticsIfNeeded(context: PollFormScreenViewModel.Context) -> some View {
|
||||
switch context.viewState.mode {
|
||||
case .edit:
|
||||
self
|
||||
case .new:
|
||||
track(screen: .createPoll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct PollFormOptionView: View {
|
||||
@Environment(\.editMode) var editMode
|
||||
@Binding var text: String
|
||||
let placeholder: String
|
||||
let canDeleteItem: Bool
|
||||
let deleteAction: () -> Void
|
||||
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
if editMode?.wrappedValue == .active {
|
||||
@@ -160,11 +190,11 @@ private struct CreatePollOptionView: View {
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct CreatePollScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = CreatePollScreenViewModel()
|
||||
struct PollFormScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = PollFormScreenViewModel(mode: .new)
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
CreatePollScreen(context: viewModel.context)
|
||||
PollFormScreen(context: viewModel.context)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -36,7 +36,7 @@ enum RoomScreenCoordinatorAction {
|
||||
case presentMediaUploadPreviewScreen(URL)
|
||||
case presentRoomDetails
|
||||
case presentLocationPicker
|
||||
case presentPollForm
|
||||
case presentPollForm(mode: PollFormMode)
|
||||
case presentLocationViewer(body: String, geoURI: GeoURI, description: String?)
|
||||
case presentEmojiPicker(itemID: TimelineItemIdentifier, selectedEmojis: Set<String>)
|
||||
case presentRoomMemberDetails(member: RoomMemberProxyProtocol)
|
||||
@@ -104,8 +104,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.send(.presentMediaUploadPicker(.documents))
|
||||
case .displayLocationPicker:
|
||||
actionsSubject.send(.presentLocationPicker)
|
||||
case .displayPollForm:
|
||||
actionsSubject.send(.presentPollForm)
|
||||
case .displayPollForm(let mode):
|
||||
actionsSubject.send(.presentPollForm(mode: mode))
|
||||
case .displayMediaUploadPreviewScreen(let url):
|
||||
actionsSubject.send(.presentMediaUploadPreviewScreen(url))
|
||||
case .displayRoomMemberDetails(let member):
|
||||
|
||||
@@ -24,6 +24,7 @@ enum RoomScreenInteractionHandlerAction {
|
||||
case displayReportContent(itemID: TimelineItemIdentifier, senderID: String)
|
||||
case displayMessageForwarding(itemID: TimelineItemIdentifier)
|
||||
case displayMediaUploadPreviewScreen(url: URL)
|
||||
case displayPollForm(mode: PollFormMode)
|
||||
case displayRoomMemberDetails(member: RoomMemberProxyProtocol)
|
||||
case showActionMenu(TimelineItemActionMenuInfo)
|
||||
case showDebugInfo(TimelineItemDebugInfo)
|
||||
@@ -185,30 +186,18 @@ class RoomScreenInteractionHandler {
|
||||
|
||||
UIPasteboard.general.string = messageTimelineItem.body
|
||||
case .edit:
|
||||
guard let messageTimelineItem = timelineItem as? EventBasedMessageTimelineItemProtocol else {
|
||||
return
|
||||
}
|
||||
|
||||
let text: String
|
||||
switch messageTimelineItem.contentType {
|
||||
case .text(let textItem):
|
||||
if ServiceLocator.shared.settings.richTextEditorEnabled, let formattedBodyHTMLString = textItem.formattedBodyHTMLString {
|
||||
text = formattedBodyHTMLString
|
||||
} else {
|
||||
text = messageTimelineItem.body
|
||||
}
|
||||
case .emote(let emoteItem):
|
||||
if ServiceLocator.shared.settings.richTextEditorEnabled, let formattedBodyHTMLString = emoteItem.formattedBodyHTMLString {
|
||||
text = "/me " + formattedBodyHTMLString
|
||||
} else {
|
||||
text = "/me " + messageTimelineItem.body
|
||||
switch timelineItem {
|
||||
case let messageTimelineItem as EventBasedMessageTimelineItemProtocol:
|
||||
processEditMessageEvent(messageTimelineItem)
|
||||
case let pollTimelineItem as PollRoomTimelineItem:
|
||||
guard let eventID = pollTimelineItem.id.eventID else {
|
||||
MXLog.error("Cannot edit poll with id: \(timelineItem.id)")
|
||||
return
|
||||
}
|
||||
actionsSubject.send(.displayPollForm(mode: .edit(eventID: eventID, poll: pollTimelineItem.poll)))
|
||||
default:
|
||||
text = messageTimelineItem.body
|
||||
MXLog.error("Cannot edit item with id: \(timelineItem.id)")
|
||||
}
|
||||
|
||||
actionsSubject.send(.composer(action: .setText(text: text)))
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .edit(originalItemId: messageTimelineItem.id))))
|
||||
case .copyPermalink:
|
||||
do {
|
||||
guard let eventID = eventTimelineItem.id.eventID else {
|
||||
@@ -258,6 +247,29 @@ class RoomScreenInteractionHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private func processEditMessageEvent(_ messageTimelineItem: EventBasedMessageTimelineItemProtocol) {
|
||||
let text: String
|
||||
switch messageTimelineItem.contentType {
|
||||
case .text(let textItem):
|
||||
if ServiceLocator.shared.settings.richTextEditorEnabled, let formattedBodyHTMLString = textItem.formattedBodyHTMLString {
|
||||
text = formattedBodyHTMLString
|
||||
} else {
|
||||
text = messageTimelineItem.body
|
||||
}
|
||||
case .emote(let emoteItem):
|
||||
if ServiceLocator.shared.settings.richTextEditorEnabled, let formattedBodyHTMLString = emoteItem.formattedBodyHTMLString {
|
||||
text = "/me " + formattedBodyHTMLString
|
||||
} else {
|
||||
text = "/me " + messageTimelineItem.body
|
||||
}
|
||||
default:
|
||||
text = messageTimelineItem.body
|
||||
}
|
||||
|
||||
actionsSubject.send(.composer(action: .setText(text: text)))
|
||||
actionsSubject.send(.composer(action: .setMode(mode: .edit(originalItemId: messageTimelineItem.id))))
|
||||
}
|
||||
|
||||
// MARK: Polls
|
||||
|
||||
func sendPollResponse(pollStartID: String, optionID: String) {
|
||||
|
||||
@@ -28,7 +28,7 @@ enum RoomScreenViewModelAction {
|
||||
case displayMediaPicker
|
||||
case displayDocumentPicker
|
||||
case displayLocationPicker
|
||||
case displayPollForm
|
||||
case displayPollForm(mode: PollFormMode)
|
||||
case displayMediaUploadPreviewScreen(url: URL)
|
||||
case displayRoomMemberDetails(member: RoomMemberProxyProtocol)
|
||||
case displayMessageForwarding(itemID: TimelineItemIdentifier)
|
||||
@@ -66,6 +66,7 @@ enum RoomScreenComposerMode: Equatable {
|
||||
enum RoomScreenViewPollAction {
|
||||
case selectOption(pollStartID: String, optionID: String)
|
||||
case end(pollStartID: String)
|
||||
case edit(pollStartID: String, poll: Poll)
|
||||
}
|
||||
|
||||
enum RoomScreenViewAudioAction {
|
||||
|
||||
@@ -188,8 +188,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
actionsSubject.send(.displayDocumentPicker)
|
||||
case .displayLocationPicker:
|
||||
actionsSubject.send(.displayLocationPicker)
|
||||
case .displayPollForm:
|
||||
actionsSubject.send(.displayPollForm)
|
||||
case .displayNewPollForm:
|
||||
actionsSubject.send(.displayPollForm(mode: .new))
|
||||
case .handlePasteOrDrop(let provider):
|
||||
roomScreenInteractionHandler.handlePasteOrDrop(provider)
|
||||
case .composerModeChanged(mode: let mode):
|
||||
@@ -213,6 +213,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
message: L10n.commonPollEndConfirmation,
|
||||
primaryButton: .init(title: L10n.actionCancel, role: .cancel, action: nil),
|
||||
secondaryButton: .init(title: L10n.actionOk, action: { self.roomScreenInteractionHandler.endPoll(pollStartID: pollStartID) }))
|
||||
case .edit(let pollStartID, let poll):
|
||||
actionsSubject.send(.displayPollForm(mode: .edit(eventID: pollStartID, poll: poll)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -319,6 +321,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
actionsSubject.send(.displayEmojiPicker(itemID: itemID, selectedEmojis: selectedEmojis))
|
||||
case .displayMessageForwarding(let itemID):
|
||||
actionsSubject.send(.displayMessageForwarding(itemID: itemID))
|
||||
case .displayPollForm(let mode):
|
||||
actionsSubject.send(.displayPollForm(mode: mode))
|
||||
case .displayReportContent(let itemID, let senderID):
|
||||
actionsSubject.send(.displayReportContent(itemID: itemID, senderID: senderID))
|
||||
case .displayMediaUploadPreviewScreen(let url):
|
||||
|
||||
@@ -202,12 +202,8 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
|
||||
@ViewBuilder
|
||||
var localizedSendInfo: some View {
|
||||
HStack(spacing: 4) {
|
||||
if let timelineItem = timelineItem as? TextBasedRoomTimelineItem {
|
||||
Text(timelineItem.localizedSendInfo)
|
||||
} else {
|
||||
Text(timelineItem.timestamp)
|
||||
}
|
||||
|
||||
Text(timelineItem.localizedSendInfo)
|
||||
|
||||
if adjustedDeliveryStatus == .sendingFailed {
|
||||
CompoundIcon(\.error, size: .xSmall, relativeTo: .compound.bodyXS)
|
||||
.accessibilityLabel(L10n.commonSendingFailed)
|
||||
|
||||
@@ -89,11 +89,11 @@ struct PollRoomTimelineView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var toolbarView: some View {
|
||||
if !poll.hasEnded, poll.createdByAccountOwner, let eventID {
|
||||
if !poll.hasEnded, poll.createdByAccountOwner {
|
||||
Button {
|
||||
context.send(viewAction: .poll(.end(pollStartID: eventID)))
|
||||
toolbarAction()
|
||||
} label: {
|
||||
Text(L10n.actionEndPoll)
|
||||
Text(timelineItem.isEditable ? L10n.actionEditPoll : L10n.actionEndPoll)
|
||||
.lineLimit(2, reservesSpace: false)
|
||||
.font(.compound.bodyLGSemibold)
|
||||
.foregroundColor(.compound.textOnSolidPrimary)
|
||||
@@ -108,6 +108,18 @@ struct PollRoomTimelineView: View {
|
||||
.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)))
|
||||
}
|
||||
}
|
||||
|
||||
private func progressBarColor(for option: Poll.Option) -> Color {
|
||||
if poll.hasEnded {
|
||||
@@ -194,5 +206,15 @@ struct PollRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
.environment(\.timelineStyle, .plain)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Creator, disclosed, Plain")
|
||||
|
||||
PollRoomTimelineView(timelineItem: .mock(poll: .emptyDisclosed, isEditable: true))
|
||||
.environment(\.timelineStyle, .bubbles)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Creator, no votes, Bubble")
|
||||
|
||||
PollRoomTimelineView(timelineItem: .mock(poll: .emptyDisclosed, isEditable: true))
|
||||
.environment(\.timelineStyle, .plain)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Creator, no votes, Plain")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,6 +726,21 @@ class RoomProxy: RoomProxyProtocol {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func editPoll(original eventID: String,
|
||||
question: String,
|
||||
answers: [String],
|
||||
pollKind: Poll.Kind) async -> Result<Void, RoomProxyError> {
|
||||
await Task.dispatch(on: .global()) {
|
||||
do {
|
||||
let originalEvent = try self.room.getEventTimelineItemByEventId(eventId: eventID)
|
||||
return try .success(self.room.editPoll(question: question, answers: answers, maxSelections: 1, pollKind: .init(pollKind: pollKind), editItem: originalEvent))
|
||||
} catch {
|
||||
MXLog.error("Failed editing the poll: \(error), eventID: \(eventID)")
|
||||
return .failure(.failedEditingPoll)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendPollResponse(pollStartID: String, answers: [String]) async -> Result<Void, RoomProxyError> {
|
||||
await Task.dispatch(on: .global()) {
|
||||
|
||||
@@ -45,6 +45,7 @@ enum RoomProxyError: Error, Equatable {
|
||||
case failedCreatingPoll
|
||||
case failedSendingPollResponse
|
||||
case failedEndingPoll
|
||||
case failedEditingPoll
|
||||
}
|
||||
|
||||
// sourcery: AutoMockable
|
||||
@@ -186,6 +187,8 @@ protocol RoomProxyProtocol {
|
||||
func canUserTriggerRoomNotification(userID: String) async -> Result<Bool, RoomProxyError>
|
||||
|
||||
func createPoll(question: String, answers: [String], pollKind: Poll.Kind) async -> Result<Void, RoomProxyError>
|
||||
|
||||
func editPoll(original eventID: String, question: String, answers: [String], pollKind: Poll.Kind) async -> Result<Void, RoomProxyError>
|
||||
|
||||
func sendPollResponse(pollStartID: String, answers: [String]) async -> Result<Void, RoomProxyError>
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ struct RoomEventStringBuilder {
|
||||
member: sender.id,
|
||||
memberIsYou: isOutgoing)
|
||||
.map(AttributedString.init)
|
||||
case .poll(let question, _, _, _, _, _):
|
||||
case .poll(let question, _, _, _, _, _, _):
|
||||
return prefix(L10n.commonPollSummary(question), with: senderDisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -28,7 +28,7 @@ struct PollRoomTimelineItem: Equatable, EventBasedTimelineItemProtocol {
|
||||
var properties: RoomTimelineItemProperties
|
||||
}
|
||||
|
||||
struct Poll: Equatable {
|
||||
struct Poll: Hashable {
|
||||
let question: String
|
||||
let kind: Kind
|
||||
let maxSelections: Int
|
||||
@@ -41,13 +41,17 @@ struct Poll: Equatable {
|
||||
var hasEnded: Bool {
|
||||
endDate != nil
|
||||
}
|
||||
|
||||
var hasVotes: Bool {
|
||||
votes.values.contains(where: { !$0.isEmpty })
|
||||
}
|
||||
|
||||
enum Kind: Equatable {
|
||||
enum Kind: Hashable {
|
||||
case disclosed
|
||||
case undisclosed
|
||||
}
|
||||
|
||||
struct Option: Equatable {
|
||||
struct Option: Hashable {
|
||||
let id: String
|
||||
let text: String
|
||||
let votes: Int
|
||||
|
||||
@@ -71,8 +71,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
avatarURLString: avatarUrl,
|
||||
previousAvatarURLString: prevAvatarUrl,
|
||||
isOutgoing: isOutgoing)
|
||||
case .poll(question: let question, kind: let kind, maxSelections: let maxSelections, answers: let answers, votes: let votes, endTime: let endTime):
|
||||
return buildPollTimelineItem(question, kind, maxSelections, answers, votes, endTime, eventItemProxy, isOutgoing)
|
||||
case .poll(question: let question, kind: let kind, maxSelections: let maxSelections, answers: let answers, votes: let votes, endTime: let endTime, let edited):
|
||||
return buildPollTimelineItem(question, kind, maxSelections, answers, votes, endTime, eventItemProxy, isOutgoing, edited)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -379,7 +379,8 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
_ votes: [String: [String]],
|
||||
_ endTime: UInt64?,
|
||||
_ eventItemProxy: EventTimelineItemProxy,
|
||||
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
|
||||
_ isOutgoing: Bool,
|
||||
_ edited: Bool) -> RoomTimelineItemProtocol {
|
||||
let allVotes = votes.reduce(0) { count, pair in
|
||||
count + pair.value.count
|
||||
}
|
||||
@@ -410,10 +411,11 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
body: poll.question,
|
||||
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
|
||||
isOutgoing: isOutgoing,
|
||||
isEditable: eventItemProxy.isEditable,
|
||||
// FIX ME: `eventItemProxy.isEditable` needs to be fixed on the rust side (now returns always false)
|
||||
isEditable: eventItemProxy.isOwn && !poll.hasVotes && !poll.hasEnded,
|
||||
canBeRepliedTo: eventItemProxy.canBeRepliedTo,
|
||||
sender: eventItemProxy.sender,
|
||||
properties: RoomTimelineItemProperties(isEdited: false,
|
||||
properties: RoomTimelineItemProperties(isEdited: edited,
|
||||
reactions: aggregateReactions(eventItemProxy.reactions),
|
||||
deliveryStatus: eventItemProxy.deliveryStatus,
|
||||
orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts)))
|
||||
@@ -633,7 +635,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
|
||||
switch timelineItem.kind() {
|
||||
case .message:
|
||||
return timelineItemReplyDetails(for: timelineItem.asMessage()?.msgtype(), sender: sender)
|
||||
case .poll(let question, _, _, _, _, _):
|
||||
case .poll(let question, _, _, _, _, _, _):
|
||||
replyContent = .poll(question: question)
|
||||
case .sticker(let body, _, _):
|
||||
replyContent = .message(.text(.init(body: body)))
|
||||
|
||||
@@ -876,7 +876,7 @@ class MockScreen: Identifiable {
|
||||
return navigationStackCoordinator
|
||||
case .createPoll:
|
||||
let navigationStackCoordinator = NavigationStackCoordinator()
|
||||
let coordinator = CreatePollScreenCoordinator(parameters: .init())
|
||||
let coordinator = PollFormScreenCoordinator(parameters: .init(mode: .new))
|
||||
navigationStackCoordinator.setRootCoordinator(coordinator)
|
||||
return navigationStackCoordinator
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user