diff --git a/.github/workflows/triage-move-labelled.yml b/.github/workflows/triage-move-labelled.yml
index e82c227de..44704833b 100644
--- a/.github/workflows/triage-move-labelled.yml
+++ b/.github/workflows/triage-move-labelled.yml
@@ -47,23 +47,8 @@ jobs:
PROJECT_ID: "PN_kwDOAM0swc0sUA"
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
- spaces_issues_to_old_board:
- name: Spaces issues to old Delight project board
- runs-on: ubuntu-latest
- if: >
- contains(github.event.issue.labels.*.name, 'A-Spaces') ||
- contains(github.event.issue.labels.*.name, 'A-Space-Settings') ||
- contains(github.event.issue.labels.*.name, 'A-Subspaces')
- steps:
- - uses: konradpabjan/move-labeled-or-milestoned-issue@219d384e03fa4b6460cd24f9f37d19eb033a4338
- with:
- action-token: "${{ secrets.ELEMENT_BOT_TOKEN }}"
- project-url: "https://github.com/orgs/vector-im/projects/6"
- column-name: "📥 Inbox"
- label-name: "A-Spaces"
-
- spaces_issues_to_new_board:
- name: Spaces issues to new Delight project board
+ Delight_issues_to_board:
+ name: Spaces issues to Delight project board
runs-on: ubuntu-latest
if: >
contains(github.event.issue.labels.*.name, 'A-Spaces') ||
@@ -132,3 +117,26 @@ jobs:
env:
PROJECT_ID: "PN_kwDOAM0swc0rRA"
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+
+ move_message_bubble_issues:
+ name: A-Message-Bubbles to Message bubble board
+ runs-on: ubuntu-latest
+ if: >
+ contains(github.event.issue.labels.*.name, 'A-Message-Bubbles')
+ steps:
+ - uses: octokit/graphql-action@v2.x
+ with:
+ headers: '{"GraphQL-Features": "projects_next_graphql"}'
+ query: |
+ mutation add_to_project($projectid:String!,$contentid:String!) {
+ addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) {
+ projectNextItem {
+ id
+ }
+ }
+ }
+ projectid: ${{ env.PROJECT_ID }}
+ contentid: ${{ github.event.issue.node_id }}
+ env:
+ PROJECT_ID: "PN_kwDOAM0swc3m-g"
+ GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
diff --git a/Config/BuildSettings.swift b/Config/BuildSettings.swift
index 0919c08fd..525870b51 100644
--- a/Config/BuildSettings.swift
+++ b/Config/BuildSettings.swift
@@ -285,14 +285,7 @@ final class BuildSettings: NSObject {
static let roomScreenAllowMediaLibraryAction: Bool = true
static let roomScreenAllowStickerAction: Bool = true
static let roomScreenAllowFilesAction: Bool = true
- static var roomScreenAllowPollsAction: Bool {
- guard #available(iOS 14, *) else {
- return false
- }
-
- return false
- }
-
+
/// Allow split view detail view stacking
static let allowSplitViewDetailsScreenStacking: Bool = true
@@ -349,4 +342,14 @@ final class BuildSettings: NSObject {
// MARK: - Secrets Recovery
static let secretsRecoveryAllowReset = true
+
+ // MARK: - Polls
+
+ static var pollsEnabled: Bool {
+ guard #available(iOS 14, *) else {
+ return false
+ }
+
+ return true
+ }
}
diff --git a/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_default.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_default.imageset/Contents.json
new file mode 100644
index 000000000..dad70857c
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_default.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "poll_checkbox_default.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "poll_checkbox_default@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "poll_checkbox_default@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_default.imageset/poll_checkbox_default.png b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_default.imageset/poll_checkbox_default.png
new file mode 100644
index 000000000..84e419079
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_default.imageset/poll_checkbox_default.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_default.imageset/poll_checkbox_default@2x.png b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_default.imageset/poll_checkbox_default@2x.png
new file mode 100644
index 000000000..7e6083bc3
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_default.imageset/poll_checkbox_default@2x.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_default.imageset/poll_checkbox_default@3x.png b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_default.imageset/poll_checkbox_default@3x.png
new file mode 100644
index 000000000..316a8eab7
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_default.imageset/poll_checkbox_default@3x.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_selected.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_selected.imageset/Contents.json
new file mode 100644
index 000000000..e1f28a0dc
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_selected.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "poll_checkbox_selected.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "poll_checkbox_selected@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "poll_checkbox_selected@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_selected.imageset/poll_checkbox_selected.png b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_selected.imageset/poll_checkbox_selected.png
new file mode 100644
index 000000000..8771564f4
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_selected.imageset/poll_checkbox_selected.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_selected.imageset/poll_checkbox_selected@2x.png b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_selected.imageset/poll_checkbox_selected@2x.png
new file mode 100644
index 000000000..deee479e9
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_selected.imageset/poll_checkbox_selected@2x.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_selected.imageset/poll_checkbox_selected@3x.png b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_selected.imageset/poll_checkbox_selected@3x.png
new file mode 100644
index 000000000..cb6195cb5
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Polls/poll_checkbox_selected.imageset/poll_checkbox_selected@3x.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/Polls/poll_winner_icon.imageset/Contents.json b/Riot/Assets/Images.xcassets/Room/Polls/poll_winner_icon.imageset/Contents.json
new file mode 100644
index 000000000..61c735d9b
--- /dev/null
+++ b/Riot/Assets/Images.xcassets/Room/Polls/poll_winner_icon.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "filename" : "poll_winner_icon.png",
+ "idiom" : "universal",
+ "scale" : "1x"
+ },
+ {
+ "filename" : "poll_winner_icon@2x.png",
+ "idiom" : "universal",
+ "scale" : "2x"
+ },
+ {
+ "filename" : "poll_winner_icon@3x.png",
+ "idiom" : "universal",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
+}
diff --git a/Riot/Assets/Images.xcassets/Room/Polls/poll_winner_icon.imageset/poll_winner_icon.png b/Riot/Assets/Images.xcassets/Room/Polls/poll_winner_icon.imageset/poll_winner_icon.png
new file mode 100644
index 000000000..255cdff73
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Polls/poll_winner_icon.imageset/poll_winner_icon.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/Polls/poll_winner_icon.imageset/poll_winner_icon@2x.png b/Riot/Assets/Images.xcassets/Room/Polls/poll_winner_icon.imageset/poll_winner_icon@2x.png
new file mode 100644
index 000000000..a9a25f56d
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Polls/poll_winner_icon.imageset/poll_winner_icon@2x.png differ
diff --git a/Riot/Assets/Images.xcassets/Room/Polls/poll_winner_icon.imageset/poll_winner_icon@3x.png b/Riot/Assets/Images.xcassets/Room/Polls/poll_winner_icon.imageset/poll_winner_icon@3x.png
new file mode 100644
index 000000000..1ee4637cd
Binary files /dev/null and b/Riot/Assets/Images.xcassets/Room/Polls/poll_winner_icon.imageset/poll_winner_icon@3x.png differ
diff --git a/Riot/Assets/en.lproj/Vector.strings b/Riot/Assets/en.lproj/Vector.strings
index a0ee6ef65..897b5c8bb 100644
--- a/Riot/Assets/en.lproj/Vector.strings
+++ b/Riot/Assets/en.lproj/Vector.strings
@@ -358,6 +358,8 @@ Tap the + to start adding people.";
"room_delete_unsent_messages" = "Delete unsent messages";
"room_event_action_copy" = "Copy";
"room_event_action_quote" = "Quote";
+"room_event_action_remove_poll" = "Remove poll";
+"room_event_action_end_poll" = "End poll";
"room_event_action_redact" = "Remove";
"room_event_action_more" = "More";
"room_event_action_share" = "Share";
@@ -568,7 +570,7 @@ Tap the + to start adding people.";
"settings_labs_create_conference_with_jitsi" = "Create conference calls with jitsi";
"settings_labs_message_reaction" = "React to messages with emoji";
"settings_labs_enable_ringing_for_group_calls" = "Ring for group calls";
-"settings_labs_voice_messages" = "Voice messages";
+"settings_labs_enabled_polls" = "Polls";
"settings_version" = "Version %@";
"settings_olm_version" = "Olm Version %@";
@@ -1803,6 +1805,42 @@ Tap the + to start adding people.";
"poll_edit_form_create_options" = "Create options";
-"poll_edit_form_option_number" = "Option %d";
+"poll_edit_form_option_number" = "Option %lu";
"poll_edit_form_add_option" = "Add option";
+
+"poll_edit_form_post_failure_title" = "Failed to post poll";
+
+"poll_edit_form_post_failure_subtitle" = "Please try again";
+
+"poll_edit_form_post_failure_action" = "OK";
+
+"poll_timeline_one_vote" = "1 vote";
+
+"poll_timeline_votes_count" = "%lu votes";
+
+"poll_timeline_total_no_votes" = "No votes cast";
+
+"poll_timeline_total_one_vote" = "1 vote cast";
+
+"poll_timeline_total_votes" = "%lu votes cast";
+
+"poll_timeline_total_one_vote_not_voted" = "1 vote cast. Vote to the see the results";
+
+"poll_timeline_total_votes_not_voted" = "%lu votes cast. Vote to the see the results";
+
+"poll_timeline_total_final_results_one_vote" = "Final results based on 1 vote";
+
+"poll_timeline_total_final_results" = "Final results based on %lu votes";
+
+"poll_timeline_vote_not_registered_title" = "Vote not registered";
+
+"poll_timeline_vote_not_registered_subtitle" = "Sorry, your vote was not registered, please try again";
+
+"poll_timeline_vote_not_registered_action" = "OK";
+
+"poll_timeline_not_closed_title" = "Failed to end poll";
+
+"poll_timeline_not_closed_subtitle" = "Please try again";
+
+"poll_timeline_not_closed_action" = "OK";
diff --git a/Riot/Generated/Images.swift b/Riot/Generated/Images.swift
index b65cfa90f..55af9da4a 100644
--- a/Riot/Generated/Images.swift
+++ b/Riot/Generated/Images.swift
@@ -143,10 +143,13 @@ internal enum Asset {
internal static let videoCall = ImageAsset(name: "video_call")
internal static let voiceCallHangonIcon = ImageAsset(name: "voice_call_hangon_icon")
internal static let voiceCallHangupIcon = ImageAsset(name: "voice_call_hangup_icon")
+ internal static let pollCheckboxDefault = ImageAsset(name: "poll_checkbox_default")
+ internal static let pollCheckboxSelected = ImageAsset(name: "poll_checkbox_selected")
internal static let pollDeleteIcon = ImageAsset(name: "poll_delete_icon")
internal static let pollDeleteOptionIcon = ImageAsset(name: "poll_delete_option_icon")
internal static let pollEditIcon = ImageAsset(name: "poll_edit_icon")
internal static let pollEndIcon = ImageAsset(name: "poll_end_icon")
+ internal static let pollWinnerIcon = ImageAsset(name: "poll_winner_icon")
internal static let urlPreviewClose = ImageAsset(name: "url_preview_close")
internal static let urlPreviewCloseDark = ImageAsset(name: "url_preview_close_dark")
internal static let voiceMessageCancelGradient = ImageAsset(name: "voice_message_cancel_gradient")
diff --git a/Riot/Generated/Strings.swift b/Riot/Generated/Strings.swift
index cc6fa5133..a49c2590b 100644
--- a/Riot/Generated/Strings.swift
+++ b/Riot/Generated/Strings.swift
@@ -2379,7 +2379,7 @@ public class VectorL10n: NSObject {
public static var pollEditFormInputPlaceholder: String {
return VectorL10n.tr("Vector", "poll_edit_form_input_placeholder")
}
- /// Option %d
+ /// Option %lu
public static func pollEditFormOptionNumber(_ p1: Int) -> String {
return VectorL10n.tr("Vector", "poll_edit_form_option_number", p1)
}
@@ -2387,10 +2387,82 @@ public class VectorL10n: NSObject {
public static var pollEditFormPollQuestionOrTopic: String {
return VectorL10n.tr("Vector", "poll_edit_form_poll_question_or_topic")
}
+ /// OK
+ public static var pollEditFormPostFailureAction: String {
+ return VectorL10n.tr("Vector", "poll_edit_form_post_failure_action")
+ }
+ /// Please try again
+ public static var pollEditFormPostFailureSubtitle: String {
+ return VectorL10n.tr("Vector", "poll_edit_form_post_failure_subtitle")
+ }
+ /// Failed to post poll
+ public static var pollEditFormPostFailureTitle: String {
+ return VectorL10n.tr("Vector", "poll_edit_form_post_failure_title")
+ }
/// Question or topic
public static var pollEditFormQuestionOrTopic: String {
return VectorL10n.tr("Vector", "poll_edit_form_question_or_topic")
}
+ /// OK
+ public static var pollTimelineNotClosedAction: String {
+ return VectorL10n.tr("Vector", "poll_timeline_not_closed_action")
+ }
+ /// Please try again
+ public static var pollTimelineNotClosedSubtitle: String {
+ return VectorL10n.tr("Vector", "poll_timeline_not_closed_subtitle")
+ }
+ /// Failed to end poll
+ public static var pollTimelineNotClosedTitle: String {
+ return VectorL10n.tr("Vector", "poll_timeline_not_closed_title")
+ }
+ /// 1 vote
+ public static var pollTimelineOneVote: String {
+ return VectorL10n.tr("Vector", "poll_timeline_one_vote")
+ }
+ /// Final results based on %lu votes
+ public static func pollTimelineTotalFinalResults(_ p1: Int) -> String {
+ return VectorL10n.tr("Vector", "poll_timeline_total_final_results", p1)
+ }
+ /// Final results based on 1 vote
+ public static var pollTimelineTotalFinalResultsOneVote: String {
+ return VectorL10n.tr("Vector", "poll_timeline_total_final_results_one_vote")
+ }
+ /// No votes cast
+ public static var pollTimelineTotalNoVotes: String {
+ return VectorL10n.tr("Vector", "poll_timeline_total_no_votes")
+ }
+ /// 1 vote cast
+ public static var pollTimelineTotalOneVote: String {
+ return VectorL10n.tr("Vector", "poll_timeline_total_one_vote")
+ }
+ /// 1 vote cast. Vote to the see the results
+ public static var pollTimelineTotalOneVoteNotVoted: String {
+ return VectorL10n.tr("Vector", "poll_timeline_total_one_vote_not_voted")
+ }
+ /// %lu votes cast
+ public static func pollTimelineTotalVotes(_ p1: Int) -> String {
+ return VectorL10n.tr("Vector", "poll_timeline_total_votes", p1)
+ }
+ /// %lu votes cast. Vote to the see the results
+ public static func pollTimelineTotalVotesNotVoted(_ p1: Int) -> String {
+ return VectorL10n.tr("Vector", "poll_timeline_total_votes_not_voted", p1)
+ }
+ /// OK
+ public static var pollTimelineVoteNotRegisteredAction: String {
+ return VectorL10n.tr("Vector", "poll_timeline_vote_not_registered_action")
+ }
+ /// Sorry, your vote was not registered, please try again
+ public static var pollTimelineVoteNotRegisteredSubtitle: String {
+ return VectorL10n.tr("Vector", "poll_timeline_vote_not_registered_subtitle")
+ }
+ /// Vote not registered
+ public static var pollTimelineVoteNotRegisteredTitle: String {
+ return VectorL10n.tr("Vector", "poll_timeline_vote_not_registered_title")
+ }
+ /// %lu votes
+ public static func pollTimelineVotesCount(_ p1: Int) -> String {
+ return VectorL10n.tr("Vector", "poll_timeline_votes_count", p1)
+ }
/// Preview
public static var preview: String {
return VectorL10n.tr("Vector", "preview")
@@ -2915,6 +2987,10 @@ public class VectorL10n: NSObject {
public static var roomEventActionEdit: String {
return VectorL10n.tr("Vector", "room_event_action_edit")
}
+ /// End poll
+ public static var roomEventActionEndPoll: String {
+ return VectorL10n.tr("Vector", "room_event_action_end_poll")
+ }
/// Forward
public static var roomEventActionForward: String {
return VectorL10n.tr("Vector", "room_event_action_forward")
@@ -2951,6 +3027,10 @@ public class VectorL10n: NSObject {
public static var roomEventActionRedact: String {
return VectorL10n.tr("Vector", "room_event_action_redact")
}
+ /// Remove poll
+ public static var roomEventActionRemovePoll: String {
+ return VectorL10n.tr("Vector", "room_event_action_remove_poll")
+ }
/// Reply
public static var roomEventActionReply: String {
return VectorL10n.tr("Vector", "room_event_action_reply")
@@ -4539,14 +4619,14 @@ public class VectorL10n: NSObject {
public static var settingsLabsEnableRingingForGroupCalls: String {
return VectorL10n.tr("Vector", "settings_labs_enable_ringing_for_group_calls")
}
+ /// Polls
+ public static var settingsLabsEnabledPolls: String {
+ return VectorL10n.tr("Vector", "settings_labs_enabled_polls")
+ }
/// React to messages with emoji
public static var settingsLabsMessageReaction: String {
return VectorL10n.tr("Vector", "settings_labs_message_reaction")
}
- /// Voice messages
- public static var settingsLabsVoiceMessages: String {
- return VectorL10n.tr("Vector", "settings_labs_voice_messages")
- }
/// LINKS
public static var settingsLinks: String {
return VectorL10n.tr("Vector", "settings_links")
diff --git a/Riot/Managers/Settings/RiotSettings.swift b/Riot/Managers/Settings/RiotSettings.swift
index dade3bafd..5b31329d4 100644
--- a/Riot/Managers/Settings/RiotSettings.swift
+++ b/Riot/Managers/Settings/RiotSettings.swift
@@ -165,6 +165,9 @@ final class RiotSettings: NSObject {
@UserDefault(key: "roomScreenAllowFilesAction", defaultValue: BuildSettings.roomScreenAllowFilesAction, storage: defaults)
var roomScreenAllowFilesAction
+
+ @UserDefault(key: "roomScreenAllowPollsAction", defaultValue: false, storage: defaults)
+ var roomScreenAllowPollsAction
@UserDefault(key: "roomScreenShowsURLPreviews", defaultValue: true, storage: defaults)
var roomScreenShowsURLPreviews
diff --git a/Riot/Modules/Common/Recents/RecentsViewController.m b/Riot/Modules/Common/Recents/RecentsViewController.m
index 6e8adb76e..b8f0d0a80 100644
--- a/Riot/Modules/Common/Recents/RecentsViewController.m
+++ b/Riot/Modules/Common/Recents/RecentsViewController.m
@@ -2050,6 +2050,11 @@ NSString *const RecentsViewControllerDataReadyNotification = @"RecentsViewContro
- (void)scrollToTheTopTheNextRoomWithMissedNotificationsInSection:(NSInteger)section
{
+ if (section < 0)
+ {
+ return;
+ }
+
UITableViewCell *firstVisibleCell;
NSIndexPath *firstVisibleCellIndexPath;
diff --git a/Riot/Modules/MatrixKit/Models/MXKAppSettings.m b/Riot/Modules/MatrixKit/Models/MXKAppSettings.m
index 22b48f831..88aa527f7 100644
--- a/Riot/Modules/MatrixKit/Models/MXKAppSettings.m
+++ b/Riot/Modules/MatrixKit/Models/MXKAppSettings.m
@@ -145,7 +145,9 @@ static NSString *const kMXAppGroupID = @"group.org.matrix";
kMXEventTypeStringCallNegotiate,
kMXEventTypeStringSticker,
kMXEventTypeStringKeyVerificationCancel,
- kMXEventTypeStringKeyVerificationDone
+ kMXEventTypeStringKeyVerificationDone,
+ kMXEventTypeStringPollStart,
+ kMXEventTypeStringPollStartMSC3381
].mutableCopy;
@@ -175,7 +177,9 @@ static NSString *const kMXAppGroupID = @"group.org.matrix";
kMXEventTypeStringCallNegotiate,
kMXEventTypeStringSticker,
kMXEventTypeStringKeyVerificationCancel,
- kMXEventTypeStringKeyVerificationDone
+ kMXEventTypeStringKeyVerificationDone,
+ kMXEventTypeStringPollStart,
+ kMXEventTypeStringPollStartMSC3381
].mutableCopy;
lastMessageEventTypesAllowList = @[
@@ -186,7 +190,9 @@ static NSString *const kMXAppGroupID = @"group.org.matrix";
kMXEventTypeStringCallInvite,
kMXEventTypeStringCallAnswer,
kMXEventTypeStringCallHangup,
- kMXEventTypeStringSticker
+ kMXEventTypeStringSticker,
+ kMXEventTypeStringPollStart,
+ kMXEventTypeStringPollStartMSC3381
].mutableCopy;
_messageDetailsAllowSharing = YES;
diff --git a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m
index cfe7e1a31..43ab3e623 100644
--- a/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m
+++ b/Riot/Modules/MatrixKit/Models/Room/MXKRoomDataSource.m
@@ -2697,11 +2697,27 @@ typedef NS_ENUM (NSUInteger, MXKRoomDataSourceError) {
- (BOOL)canPerformActionOnEvent:(MXEvent*)event
{
BOOL isSent = event.sentState == MXEventSentStateSent;
- BOOL isRoomMessage = event.eventType == MXEventTypeRoomMessage;
+
+ if (!isSent) {
+ return NO;
+ }
+
+ if (event.eventType == MXEventTypePollStart) {
+ return YES;
+ }
+
+ BOOL isRoomMessage = (event.eventType == MXEventTypeRoomMessage);
+
+ if (!isRoomMessage) {
+ return NO;
+ }
NSString *messageType = event.content[@"msgtype"];
+ if (messageType == nil || [messageType isEqualToString:@"m.bad.encrypted"]) {
+ return NO;
+ }
- return isSent && isRoomMessage && messageType && ![messageType isEqualToString:@"m.bad.encrypted"];
+ return YES;
}
- (void)setState:(MXKDataSourceState)newState
diff --git a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m
index d28641dbf..9177cc5be 100644
--- a/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m
+++ b/Riot/Modules/MatrixKit/Utils/EventFormatter/MXKEventFormatter.m
@@ -1596,7 +1596,11 @@ static NSString *const kHTMLATagRegexPattern = @"([^<]*)";
}
break;
}
-
+ case MXEventTypePollStart:
+ {
+ displayText = [MXEventContentPollStart modelFromJSON:event.content].question;
+ break;
+ }
default:
*error = MXKEventFormatterErrorUnknownEventType;
break;
diff --git a/Riot/Modules/People/PeopleViewController.m b/Riot/Modules/People/PeopleViewController.m
index d74cddc98..ee80313a5 100644
--- a/Riot/Modules/People/PeopleViewController.m
+++ b/Riot/Modules/People/PeopleViewController.m
@@ -141,7 +141,7 @@
// Check whether the recents data source is correctly configured.
if (recentsDataSource.recentsDataSourceMode == RecentsDataSourceModePeople)
{
- [self scrollToTheTopTheNextRoomWithMissedNotificationsInSection:recentsDataSource.conversationSection];
+ [self scrollToTheTopTheNextRoomWithMissedNotificationsInSection:recentsDataSource.peopleSection];
}
}
diff --git a/Riot/Modules/Room/CellData/RoomBubbleCellData.h b/Riot/Modules/Room/CellData/RoomBubbleCellData.h
index 526abea1b..ca0058be5 100644
--- a/Riot/Modules/Room/CellData/RoomBubbleCellData.h
+++ b/Riot/Modules/Room/CellData/RoomBubbleCellData.h
@@ -31,7 +31,8 @@ typedef NS_ENUM(NSInteger, RoomBubbleCellDataTag)
RoomBubbleCellDataTagKeyVerificationConclusion,
RoomBubbleCellDataTagCall,
RoomBubbleCellDataTagGroupCall,
- RoomBubbleCellDataTagRoomCreationIntro
+ RoomBubbleCellDataTagRoomCreationIntro,
+ RoomBubbleCellDataTagPoll
};
/**
diff --git a/Riot/Modules/Room/CellData/RoomBubbleCellData.m b/Riot/Modules/Room/CellData/RoomBubbleCellData.m
index f73e211c3..4c9e9a4e2 100644
--- a/Riot/Modules/Room/CellData/RoomBubbleCellData.m
+++ b/Riot/Modules/Room/CellData/RoomBubbleCellData.m
@@ -149,6 +149,15 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat
// Show timestamps always on right
self.displayTimestampForSelectedComponentOnLeftWhenPossible = NO;
+ break;
+ }
+ case MXEventTypePollStart:
+ {
+ self.tag = RoomBubbleCellDataTagPoll;
+ self.collapsable = NO;
+ self.collapsed = NO;
+
+ break;
}
case MXEventTypeCustom:
{
@@ -259,6 +268,11 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat
return NO;
}
+ if (self.tag == RoomBubbleCellDataTagPoll)
+ {
+ return NO;
+ }
+
return [super hasNoDisplay];
}
@@ -417,7 +431,9 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat
{
__block NSInteger firstVisibleComponentIndex = NSNotFound;
- if (self.attachment && self.bubbleComponents.count)
+ BOOL isPoll = (self.events.firstObject.eventType == MXEventTypePollStart);
+
+ if ((isPoll || self.attachment) && self.bubbleComponents.count)
{
firstVisibleComponentIndex = 0;
}
@@ -826,10 +842,15 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat
case RoomBubbleCellDataTagRoomCreationIntro:
shouldAddEvent = NO;
break;
+ case RoomBubbleCellDataTagPoll:
+ shouldAddEvent = NO;
+ break;
default:
break;
}
+ // If the current bubbleData supports adding events then check
+ // if the incoming event can be added in
if (shouldAddEvent)
{
switch (event.eventType)
@@ -873,6 +894,9 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat
case MXEventTypeCallReject:
shouldAddEvent = NO;
break;
+ case MXEventTypePollStart:
+ shouldAddEvent = NO;
+ break;
case MXEventTypeCustom:
{
if ([event.type isEqualToString:kWidgetMatrixEventTypeString]
diff --git a/Riot/Modules/Room/RoomCoordinator.swift b/Riot/Modules/Room/RoomCoordinator.swift
index 735a47592..2ade4af20 100644
--- a/Riot/Modules/Room/RoomCoordinator.swift
+++ b/Riot/Modules/Room/RoomCoordinator.swift
@@ -78,6 +78,10 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol {
self.activityIndicatorPresenter = ActivityIndicatorPresenter()
self.roomViewController.parentSpaceId = parameters.parentSpaceId
+
+ if #available(iOS 14, *) {
+ PollTimelineProvider.shared.session = parameters.session
+ }
super.init()
}
@@ -259,10 +263,25 @@ extension RoomCoordinator: RoomViewControllerDelegate {
return
}
- let parameters = PollEditFormCoordinatorParameters(navigationRouter: self.navigationRouter)
-
+ let parameters = PollEditFormCoordinatorParameters(navigationRouter: self.navigationRouter, room: roomViewController.roomDataSource.room)
pollEditFormCoordinator = PollEditFormCoordinator(parameters: parameters)
pollEditFormCoordinator?.start()
}
+
+ func roomViewController(_ roomViewController: RoomViewController, canEndPollWithEventIdentifier eventIdentifier: String) -> Bool {
+ guard #available(iOS 14.0, *) else {
+ return false
+ }
+
+ return PollTimelineProvider.shared.pollTimelineCoordinatorForEventIdentifier(eventIdentifier)?.canEndPoll() ?? false
+ }
+
+ func roomViewController(_ roomViewController: RoomViewController, endPollWithEventIdentifier eventIdentifier: String) {
+ guard #available(iOS 14.0, *) else {
+ return
+ }
+
+ PollTimelineProvider.shared.pollTimelineCoordinatorForEventIdentifier(eventIdentifier)?.endPoll()
+ }
}
diff --git a/Riot/Modules/Room/RoomViewController.h b/Riot/Modules/Room/RoomViewController.h
index 2c0548684..446b9e525 100644
--- a/Riot/Modules/Room/RoomViewController.h
+++ b/Riot/Modules/Room/RoomViewController.h
@@ -188,6 +188,12 @@ handleUniversalLinkWithParameters:(UniversalLinkParameters*)parameters;
*/
- (void)roomViewControllerDidRequestPollCreationFormPresentation:(RoomViewController *)roomViewController;
+- (BOOL)roomViewController:(RoomViewController *)roomViewController
+canEndPollWithEventIdentifier:(NSString *)eventIdentifier;
+
+- (void)roomViewController:(RoomViewController *)roomViewController
+endPollWithEventIdentifier:(NSString *)eventIdentifier;
+
@end
NS_ASSUME_NONNULL_END
diff --git a/Riot/Modules/Room/RoomViewController.m b/Riot/Modules/Room/RoomViewController.m
index fdf9eef6d..2c871c23e 100644
--- a/Riot/Modules/Room/RoomViewController.m
+++ b/Riot/Modules/Room/RoomViewController.m
@@ -416,6 +416,10 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
[self.bubblesTableView registerClass:VoiceMessageWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:VoiceMessageWithoutSenderInfoBubbleCell.defaultReuseIdentifier];
[self.bubblesTableView registerClass:VoiceMessageWithPaginationTitleBubbleCell.class forCellReuseIdentifier:VoiceMessageWithPaginationTitleBubbleCell.defaultReuseIdentifier];
+ [self.bubblesTableView registerClass:PollBubbleCell.class forCellReuseIdentifier:PollBubbleCell.defaultReuseIdentifier];
+ [self.bubblesTableView registerClass:PollWithoutSenderInfoBubbleCell.class forCellReuseIdentifier:PollWithoutSenderInfoBubbleCell.defaultReuseIdentifier];
+ [self.bubblesTableView registerClass:PollWithPaginationTitleBubbleCell.class forCellReuseIdentifier:PollWithPaginationTitleBubbleCell.defaultReuseIdentifier];
+
[self vc_removeBackTitle];
// Display leftBarButtonItems or leftBarButtonItem to the right of the Back button
@@ -2016,7 +2020,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
[self roomInputToolbarViewDidTapFileUpload];
}]];
}
- if (BuildSettings.roomScreenAllowPollsAction)
+ if (RiotSettings.shared.roomScreenAllowPollsAction)
{
[actionItems addObject:[[RoomActionItem alloc] initWithImage:[UIImage imageNamed:@"action_poll"] andAction:^{
MXStrongifyAndReturnIfNil(self);
@@ -2618,193 +2622,210 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
BOOL showEncryptionBadge = NO;
// Sanity check
- if ([cellData conformsToProtocol:@protocol(MXKRoomBubbleCellDataStoring)])
+ if (![cellData conformsToProtocol:@protocol(MXKRoomBubbleCellDataStoring)])
{
- id bubbleData = (id)cellData;
+ return nil;
+ }
- MXKRoomBubbleCellData *roomBubbleCellData;
-
- if ([bubbleData isKindOfClass:MXKRoomBubbleCellData.class])
+ id bubbleData = (id)cellData;
+
+ MXKRoomBubbleCellData *roomBubbleCellData;
+
+ if ([bubbleData isKindOfClass:MXKRoomBubbleCellData.class])
+ {
+ roomBubbleCellData = (MXKRoomBubbleCellData*)bubbleData;
+ showEncryptionBadge = roomBubbleCellData.containsBubbleComponentWithEncryptionBadge;
+ }
+
+ // Select the suitable table view cell class, by considering first the empty bubble cell.
+ if (bubbleData.hasNoDisplay)
+ {
+ cellViewClass = RoomEmptyBubbleCell.class;
+ }
+ else if (bubbleData.tag == RoomBubbleCellDataTagRoomCreationIntro)
+ {
+ cellViewClass = RoomCreationIntroCell.class;
+ }
+ else if (bubbleData.tag == RoomBubbleCellDataTagRoomCreateWithPredecessor)
+ {
+ cellViewClass = RoomPredecessorBubbleCell.class;
+ }
+ else if (bubbleData.tag == RoomBubbleCellDataTagKeyVerificationRequestIncomingApproval)
+ {
+ cellViewClass = bubbleData.isPaginationFirstBubble ? KeyVerificationIncomingRequestApprovalWithPaginationTitleBubbleCell.class : KeyVerificationIncomingRequestApprovalBubbleCell.class;
+ }
+ else if (bubbleData.tag == RoomBubbleCellDataTagKeyVerificationRequest)
+ {
+ cellViewClass = bubbleData.isPaginationFirstBubble ? KeyVerificationRequestStatusWithPaginationTitleBubbleCell.class : KeyVerificationRequestStatusBubbleCell.class;
+ }
+ else if (bubbleData.tag == RoomBubbleCellDataTagKeyVerificationConclusion)
+ {
+ cellViewClass = bubbleData.isPaginationFirstBubble ? KeyVerificationConclusionWithPaginationTitleBubbleCell.class : KeyVerificationConclusionBubbleCell.class;
+ }
+ else if (bubbleData.tag == RoomBubbleCellDataTagMembership)
+ {
+ if (bubbleData.collapsed)
{
- roomBubbleCellData = (MXKRoomBubbleCellData*)bubbleData;
- showEncryptionBadge = roomBubbleCellData.containsBubbleComponentWithEncryptionBadge;
- }
-
- // Select the suitable table view cell class, by considering first the empty bubble cell.
- if (bubbleData.hasNoDisplay)
- {
- cellViewClass = RoomEmptyBubbleCell.class;
- }
- else if (bubbleData.tag == RoomBubbleCellDataTagRoomCreationIntro)
- {
- cellViewClass = RoomCreationIntroCell.class;
- }
- else if (bubbleData.tag == RoomBubbleCellDataTagRoomCreateWithPredecessor)
- {
- cellViewClass = RoomPredecessorBubbleCell.class;
- }
- else if (bubbleData.tag == RoomBubbleCellDataTagKeyVerificationRequestIncomingApproval)
- {
- cellViewClass = bubbleData.isPaginationFirstBubble ? KeyVerificationIncomingRequestApprovalWithPaginationTitleBubbleCell.class : KeyVerificationIncomingRequestApprovalBubbleCell.class;
- }
- else if (bubbleData.tag == RoomBubbleCellDataTagKeyVerificationRequest)
- {
- cellViewClass = bubbleData.isPaginationFirstBubble ? KeyVerificationRequestStatusWithPaginationTitleBubbleCell.class : KeyVerificationRequestStatusBubbleCell.class;
- }
- else if (bubbleData.tag == RoomBubbleCellDataTagKeyVerificationConclusion)
- {
- cellViewClass = bubbleData.isPaginationFirstBubble ? KeyVerificationConclusionWithPaginationTitleBubbleCell.class : KeyVerificationConclusionBubbleCell.class;
- }
- else if (bubbleData.tag == RoomBubbleCellDataTagMembership)
- {
- if (bubbleData.collapsed)
+ if (bubbleData.nextCollapsableCellData)
{
- if (bubbleData.nextCollapsableCellData)
- {
- cellViewClass = bubbleData.isPaginationFirstBubble ? RoomMembershipCollapsedWithPaginationTitleBubbleCell.class : RoomMembershipCollapsedBubbleCell.class;
- }
- else
- {
- // Use a normal membership cell for a single membership event
- cellViewClass = bubbleData.isPaginationFirstBubble ? RoomMembershipWithPaginationTitleBubbleCell.class : RoomMembershipBubbleCell.class;
- }
- }
- else if (bubbleData.collapsedAttributedTextMessage)
- {
- // The cell (and its series) is not collapsed but this cell is the first
- // of the series. So, use the cell with the "collapse" button.
- cellViewClass = bubbleData.isPaginationFirstBubble ? RoomMembershipExpandedWithPaginationTitleBubbleCell.class : RoomMembershipExpandedBubbleCell.class;
+ cellViewClass = bubbleData.isPaginationFirstBubble ? RoomMembershipCollapsedWithPaginationTitleBubbleCell.class : RoomMembershipCollapsedBubbleCell.class;
}
else
{
+ // Use a normal membership cell for a single membership event
cellViewClass = bubbleData.isPaginationFirstBubble ? RoomMembershipWithPaginationTitleBubbleCell.class : RoomMembershipBubbleCell.class;
}
}
- else if (bubbleData.tag == RoomBubbleCellDataTagRoomCreateConfiguration)
+ else if (bubbleData.collapsedAttributedTextMessage)
{
- cellViewClass = bubbleData.isPaginationFirstBubble ? RoomCreationWithPaginationCollapsedBubbleCell.class : RoomCreationCollapsedBubbleCell.class;
+ // The cell (and its series) is not collapsed but this cell is the first
+ // of the series. So, use the cell with the "collapse" button.
+ cellViewClass = bubbleData.isPaginationFirstBubble ? RoomMembershipExpandedWithPaginationTitleBubbleCell.class : RoomMembershipExpandedBubbleCell.class;
}
- else if (bubbleData.tag == RoomBubbleCellDataTagCall)
+ else
{
- cellViewClass = RoomDirectCallStatusBubbleCell.class;
+ cellViewClass = bubbleData.isPaginationFirstBubble ? RoomMembershipWithPaginationTitleBubbleCell.class : RoomMembershipBubbleCell.class;
}
- else if (bubbleData.tag == RoomBubbleCellDataTagGroupCall)
+ }
+ else if (bubbleData.tag == RoomBubbleCellDataTagRoomCreateConfiguration)
+ {
+ cellViewClass = bubbleData.isPaginationFirstBubble ? RoomCreationWithPaginationCollapsedBubbleCell.class : RoomCreationCollapsedBubbleCell.class;
+ }
+ else if (bubbleData.tag == RoomBubbleCellDataTagCall)
+ {
+ cellViewClass = RoomDirectCallStatusBubbleCell.class;
+ }
+ else if (bubbleData.tag == RoomBubbleCellDataTagGroupCall)
+ {
+ cellViewClass = RoomGroupCallStatusBubbleCell.class;
+ }
+ else if (bubbleData.attachment.type == MXKAttachmentTypeVoiceMessage || bubbleData.attachment.type == MXKAttachmentTypeAudio)
+ {
+ if (bubbleData.isPaginationFirstBubble)
{
- cellViewClass = RoomGroupCallStatusBubbleCell.class;
+ cellViewClass = VoiceMessageWithPaginationTitleBubbleCell.class;
}
- else if (bubbleData.attachment.type == MXKAttachmentTypeVoiceMessage || bubbleData.attachment.type == MXKAttachmentTypeAudio)
+ else if (bubbleData.shouldHideSenderInformation)
{
- if (bubbleData.isPaginationFirstBubble)
+ cellViewClass = VoiceMessageWithoutSenderInfoBubbleCell.class;
+ }
+ else
+ {
+ cellViewClass = VoiceMessageBubbleCell.class;
+ }
+ }
+ else if (bubbleData.tag == RoomBubbleCellDataTagPoll)
+ {
+ if (bubbleData.isPaginationFirstBubble)
+ {
+ cellViewClass = PollWithPaginationTitleBubbleCell.class;
+ }
+ else if (bubbleData.shouldHideSenderInformation)
+ {
+ cellViewClass = PollWithoutSenderInfoBubbleCell.class;
+ }
+ else
+ {
+ cellViewClass = PollBubbleCell.class;
+ }
+ }
+ else if (bubbleData.isIncoming)
+ {
+ if (bubbleData.isAttachmentWithThumbnail)
+ {
+ // Check whether the provided celldata corresponds to a selected sticker
+ if (customizedRoomDataSource.selectedEventId && (bubbleData.attachment.type == MXKAttachmentTypeSticker) && [bubbleData.attachment.eventId isEqualToString:customizedRoomDataSource.selectedEventId])
{
- cellViewClass = VoiceMessageWithPaginationTitleBubbleCell.class;
+ cellViewClass = RoomSelectedStickerBubbleCell.class;
+ }
+ else if (bubbleData.isPaginationFirstBubble)
+ {
+ cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedAttachmentWithPaginationTitleBubbleCell.class : RoomIncomingAttachmentWithPaginationTitleBubbleCell.class;
}
else if (bubbleData.shouldHideSenderInformation)
{
- cellViewClass = VoiceMessageWithoutSenderInfoBubbleCell.class;
+ cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedAttachmentWithoutSenderInfoBubbleCell.class : RoomIncomingAttachmentWithoutSenderInfoBubbleCell.class;
}
else
{
- cellViewClass = VoiceMessageBubbleCell.class;
- }
- }
- else if (bubbleData.isIncoming)
- {
- if (bubbleData.isAttachmentWithThumbnail)
- {
- // Check whether the provided celldata corresponds to a selected sticker
- if (customizedRoomDataSource.selectedEventId && (bubbleData.attachment.type == MXKAttachmentTypeSticker) && [bubbleData.attachment.eventId isEqualToString:customizedRoomDataSource.selectedEventId])
- {
- cellViewClass = RoomSelectedStickerBubbleCell.class;
- }
- else if (bubbleData.isPaginationFirstBubble)
- {
- cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedAttachmentWithPaginationTitleBubbleCell.class : RoomIncomingAttachmentWithPaginationTitleBubbleCell.class;
- }
- else if (bubbleData.shouldHideSenderInformation)
- {
- cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedAttachmentWithoutSenderInfoBubbleCell.class : RoomIncomingAttachmentWithoutSenderInfoBubbleCell.class;
- }
- else
- {
- cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedAttachmentBubbleCell.class : RoomIncomingAttachmentBubbleCell.class;
- }
- }
- else
- {
- if (bubbleData.isPaginationFirstBubble)
- {
- if (bubbleData.shouldHideSenderName)
- {
- cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedTextMsgWithPaginationTitleWithoutSenderNameBubbleCell.class : RoomIncomingTextMsgWithPaginationTitleWithoutSenderNameBubbleCell.class;
- }
- else
- {
- cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedTextMsgWithPaginationTitleBubbleCell.class : RoomIncomingTextMsgWithPaginationTitleBubbleCell.class;
- }
- }
- else if (bubbleData.shouldHideSenderInformation)
- {
- cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedTextMsgWithoutSenderInfoBubbleCell.class : RoomIncomingTextMsgWithoutSenderInfoBubbleCell.class;
- }
- else if (bubbleData.shouldHideSenderName)
- {
- cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedTextMsgWithoutSenderNameBubbleCell.class : RoomIncomingTextMsgWithoutSenderNameBubbleCell.class;
- }
- else
- {
- cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedTextMsgBubbleCell.class : RoomIncomingTextMsgBubbleCell.class;
- }
+ cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedAttachmentBubbleCell.class : RoomIncomingAttachmentBubbleCell.class;
}
}
else
{
- // Handle here outgoing bubbles
- if (bubbleData.isAttachmentWithThumbnail)
+ if (bubbleData.isPaginationFirstBubble)
{
- // Check whether the provided celldata corresponds to a selected sticker
- if (customizedRoomDataSource.selectedEventId && (bubbleData.attachment.type == MXKAttachmentTypeSticker) && [bubbleData.attachment.eventId isEqualToString:customizedRoomDataSource.selectedEventId])
+ if (bubbleData.shouldHideSenderName)
{
- cellViewClass = RoomSelectedStickerBubbleCell.class;
- }
- else if (bubbleData.isPaginationFirstBubble)
- {
- cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedAttachmentWithPaginationTitleBubbleCell.class :RoomOutgoingAttachmentWithPaginationTitleBubbleCell.class;
- }
- else if (bubbleData.shouldHideSenderInformation)
- {
- cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedAttachmentWithoutSenderInfoBubbleCell.class : RoomOutgoingAttachmentWithoutSenderInfoBubbleCell.class;
+ cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedTextMsgWithPaginationTitleWithoutSenderNameBubbleCell.class : RoomIncomingTextMsgWithPaginationTitleWithoutSenderNameBubbleCell.class;
}
else
{
- cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedAttachmentBubbleCell.class : RoomOutgoingAttachmentBubbleCell.class;
+ cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedTextMsgWithPaginationTitleBubbleCell.class : RoomIncomingTextMsgWithPaginationTitleBubbleCell.class;
}
}
+ else if (bubbleData.shouldHideSenderInformation)
+ {
+ cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedTextMsgWithoutSenderInfoBubbleCell.class : RoomIncomingTextMsgWithoutSenderInfoBubbleCell.class;
+ }
+ else if (bubbleData.shouldHideSenderName)
+ {
+ cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedTextMsgWithoutSenderNameBubbleCell.class : RoomIncomingTextMsgWithoutSenderNameBubbleCell.class;
+ }
else
{
- if (bubbleData.isPaginationFirstBubble)
+ cellViewClass = showEncryptionBadge ? RoomIncomingEncryptedTextMsgBubbleCell.class : RoomIncomingTextMsgBubbleCell.class;
+ }
+ }
+ }
+ else
+ {
+ // Handle here outgoing bubbles
+ if (bubbleData.isAttachmentWithThumbnail)
+ {
+ // Check whether the provided celldata corresponds to a selected sticker
+ if (customizedRoomDataSource.selectedEventId && (bubbleData.attachment.type == MXKAttachmentTypeSticker) && [bubbleData.attachment.eventId isEqualToString:customizedRoomDataSource.selectedEventId])
+ {
+ cellViewClass = RoomSelectedStickerBubbleCell.class;
+ }
+ else if (bubbleData.isPaginationFirstBubble)
+ {
+ cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedAttachmentWithPaginationTitleBubbleCell.class :RoomOutgoingAttachmentWithPaginationTitleBubbleCell.class;
+ }
+ else if (bubbleData.shouldHideSenderInformation)
+ {
+ cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedAttachmentWithoutSenderInfoBubbleCell.class : RoomOutgoingAttachmentWithoutSenderInfoBubbleCell.class;
+ }
+ else
+ {
+ cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedAttachmentBubbleCell.class : RoomOutgoingAttachmentBubbleCell.class;
+ }
+ }
+ else
+ {
+ if (bubbleData.isPaginationFirstBubble)
+ {
+ if (bubbleData.shouldHideSenderName)
{
- if (bubbleData.shouldHideSenderName)
- {
- cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedTextMsgWithPaginationTitleWithoutSenderNameBubbleCell.class : RoomOutgoingTextMsgWithPaginationTitleWithoutSenderNameBubbleCell.class;
- }
- else
- {
- cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedTextMsgWithPaginationTitleBubbleCell.class : RoomOutgoingTextMsgWithPaginationTitleBubbleCell.class;
- }
- }
- else if (bubbleData.shouldHideSenderInformation)
- {
- cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedTextMsgWithoutSenderInfoBubbleCell.class :RoomOutgoingTextMsgWithoutSenderInfoBubbleCell.class;
- }
- else if (bubbleData.shouldHideSenderName)
- {
- cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedTextMsgWithoutSenderNameBubbleCell.class : RoomOutgoingTextMsgWithoutSenderNameBubbleCell.class;
+ cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedTextMsgWithPaginationTitleWithoutSenderNameBubbleCell.class : RoomOutgoingTextMsgWithPaginationTitleWithoutSenderNameBubbleCell.class;
}
else
{
- cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedTextMsgBubbleCell.class : RoomOutgoingTextMsgBubbleCell.class;
+ cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedTextMsgWithPaginationTitleBubbleCell.class : RoomOutgoingTextMsgWithPaginationTitleBubbleCell.class;
}
}
+ else if (bubbleData.shouldHideSenderInformation)
+ {
+ cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedTextMsgWithoutSenderInfoBubbleCell.class :RoomOutgoingTextMsgWithoutSenderInfoBubbleCell.class;
+ }
+ else if (bubbleData.shouldHideSenderName)
+ {
+ cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedTextMsgWithoutSenderNameBubbleCell.class : RoomOutgoingTextMsgWithoutSenderNameBubbleCell.class;
+ }
+ else
+ {
+ cellViewClass = showEncryptionBadge ? RoomOutgoingEncryptedTextMsgBubbleCell.class : RoomOutgoingTextMsgBubbleCell.class;
+ }
}
}
@@ -3225,7 +3246,8 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
}]];
}
- if (selectedEvent.sentState == MXEventSentStateSent) {
+ if (selectedEvent.sentState == MXEventSentStateSent && selectedEvent.eventType != MXEventTypePollStart)
+ {
[actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionForward]
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
@@ -3234,7 +3256,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
}]];
}
- if (!isJitsiCallEvent)
+ if (!isJitsiCallEvent && selectedEvent.eventType != MXEventTypePollStart)
{
[actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionQuote]
style:UIAlertActionStyleDefault
@@ -3251,7 +3273,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
}]];
}
- if (!isJitsiCallEvent && BuildSettings.messageDetailsAllowShare)
+ if (!isJitsiCallEvent && BuildSettings.messageDetailsAllowShare && selectedEvent.eventType != MXEventTypePollStart)
{
[actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionShare]
style:UIAlertActionStyleDefault
@@ -3435,7 +3457,17 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
// because it breaks everything
if (selectedEvent.eventType != MXEventTypeRoomEncryption)
{
- [actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionRedact]
+ NSString *title;
+ if (selectedEvent.eventType == MXEventTypePollStart)
+ {
+ title = [VectorL10n roomEventActionRemovePoll];
+ }
+ else
+ {
+ title = [VectorL10n roomEventActionRedact];
+ }
+
+ [actionsMenu addAction:[UIAlertAction actionWithTitle:title
style:UIAlertActionStyleDefault
handler:^(UIAlertAction * action) {
MXStrongifyAndReturnIfNil(self);
@@ -3459,6 +3491,28 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
}]];
}
+ if (selectedEvent.eventType == MXEventTypePollStart && [selectedEvent.sender isEqualToString:self.mainSession.myUser.userId]) {
+ if ([self.delegate roomViewController:self canEndPollWithEventIdentifier:selectedEvent.eventId]) {
+ [actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionEndPoll]
+ style:UIAlertActionStyleDefault
+ handler:^(UIAlertAction * action) {
+ MXStrongifyAndReturnIfNil(self);
+
+ [self.delegate roomViewController:self endPollWithEventIdentifier:selectedEvent.eventId];
+
+ [self hideContextualMenuAnimated:YES];
+ }]];
+ }
+ }
+
+ [actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel]
+ style:UIAlertActionStyleCancel
+ handler:^(UIAlertAction * action) {
+ MXStrongifyAndReturnIfNil(self);
+
+ [self hideContextualMenuAnimated:YES];
+ }]];
+
if (BuildSettings.messageDetailsAllowPermalink)
{
[actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionPermalink]
@@ -3621,9 +3675,9 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
if (!isJitsiCallEvent && self.roomDataSource.room.summary.isEncrypted)
{
- [currentAlert addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionViewEncryption]
- style:UIAlertActionStyleDefault
- handler:^(UIAlertAction * action) {
+ [actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionViewEncryption]
+ style:UIAlertActionStyleDefault
+ handler:^(UIAlertAction * action) {
MXStrongifyAndReturnIfNil(self);
[self cancelEventSelection];
@@ -3632,15 +3686,8 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
[self showEncryptionInformation:selectedEvent];
}]];
}
- }
-
- [actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n cancel]
- style:UIAlertActionStyleCancel
- handler:^(UIAlertAction * action) {
- MXStrongifyAndReturnIfNil(self);
- [self hideContextualMenuAnimated:YES];
- }]];
+ }
// Do not display empty action sheet
if (actionsMenu.actions.count > 1)
@@ -6040,7 +6087,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
MXWeakify(self);
- BOOL isCopyActionEnabled = !attachment || attachment.type != MXKAttachmentTypeSticker;
+ BOOL isCopyActionEnabled = (event.eventType != MXEventTypePollStart && (!attachment || attachment.type != MXKAttachmentTypeSticker));
if (attachment && !BuildSettings.messageDetailsAllowCopyMedia)
{
diff --git a/Riot/Modules/Room/Views/BubbleCells/BaseBubbleCell/BubbleCellContentView.xib b/Riot/Modules/Room/Views/BubbleCells/BaseBubbleCell/BubbleCellContentView.xib
index 47244effb..56c26c56c 100644
--- a/Riot/Modules/Room/Views/BubbleCells/BaseBubbleCell/BubbleCellContentView.xib
+++ b/Riot/Modules/Room/Views/BubbleCells/BaseBubbleCell/BubbleCellContentView.xib
@@ -1,9 +1,9 @@
-
+
-
+
@@ -17,7 +17,7 @@
-
+
@@ -74,11 +74,11 @@
-
+
+
-
+
-
-
-
-
-
-
-
-
-
-
+
-
+
-
-
@@ -227,7 +225,7 @@
-
+
@@ -247,6 +245,6 @@
-
+
diff --git a/Riot/Modules/Room/Views/BubbleCells/Poll/PollBubbleCell.swift b/Riot/Modules/Room/Views/BubbleCells/Poll/PollBubbleCell.swift
new file mode 100644
index 000000000..4d3d42e28
--- /dev/null
+++ b/Riot/Modules/Room/Views/BubbleCells/Poll/PollBubbleCell.swift
@@ -0,0 +1,59 @@
+//
+// Copyright 2021 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+class PollBubbleCell: SizableBaseBubbleCell, BubbleCellReactionsDisplayable {
+
+ private var pollView: UIView?
+ private var event: MXEvent?
+
+ override func render(_ cellData: MXKCellData!) {
+ super.render(cellData)
+
+ guard #available(iOS 14.0, *),
+ let contentView = bubbleCellContentView?.innerContentView,
+ let bubbleData = cellData as? RoomBubbleCellData,
+ let event = bubbleData.events.last,
+ event.eventType == __MXEventType.pollStart,
+ let view = PollTimelineProvider.shared.buildPollTimelineViewForEvent(event) else {
+ return
+ }
+
+ self.event = event
+
+ pollView?.removeFromSuperview()
+ contentView.vc_addSubViewMatchingParent(view)
+ pollView = view
+ }
+
+ override func setupViews() {
+ super.setupViews()
+
+ bubbleCellContentView?.backgroundColor = .clear
+ bubbleCellContentView?.showSenderInfo = true
+ bubbleCellContentView?.showPaginationTitle = false
+ }
+
+ // The normal flow for tapping on cell content views doesn't work for bubbles without attributed strings
+ func onContentViewTap(_ sender: UITapGestureRecognizer) {
+ guard let event = self.event else {
+ return
+ }
+
+ delegate.cell(self, didRecognizeAction: kMXKRoomBubbleCellTapOnContentView, userInfo: [kMXKRoomBubbleCellEventKey: event])
+ }
+}
diff --git a/Riot/Modules/Room/Views/BubbleCells/Poll/PollWithPaginationTitleBubbleCell.swift b/Riot/Modules/Room/Views/BubbleCells/Poll/PollWithPaginationTitleBubbleCell.swift
new file mode 100644
index 000000000..de6e92041
--- /dev/null
+++ b/Riot/Modules/Room/Views/BubbleCells/Poll/PollWithPaginationTitleBubbleCell.swift
@@ -0,0 +1,27 @@
+//
+// Copyright 2021 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+class PollWithPaginationTitleBubbleCell: PollBubbleCell {
+
+ override func setupViews() {
+ super.setupViews()
+
+ bubbleCellContentView?.showPaginationTitle = true
+ }
+
+}
diff --git a/Riot/Modules/Room/Views/BubbleCells/Poll/PollWithoutSenderInfoBubbleCell.swift b/Riot/Modules/Room/Views/BubbleCells/Poll/PollWithoutSenderInfoBubbleCell.swift
new file mode 100644
index 000000000..880ef0cd4
--- /dev/null
+++ b/Riot/Modules/Room/Views/BubbleCells/Poll/PollWithoutSenderInfoBubbleCell.swift
@@ -0,0 +1,27 @@
+//
+// Copyright 2021 New Vector Ltd
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+//
+
+import Foundation
+
+class PollWithoutSenderInfoBubbleCell: PollBubbleCell {
+
+ override func setupViews() {
+ super.setupViews()
+
+ bubbleCellContentView?.showSenderInfo = false
+ }
+
+}
diff --git a/Riot/Modules/Settings/SettingsViewController.m b/Riot/Modules/Settings/SettingsViewController.m
index 91fa9afa7..1993ab9f7 100644
--- a/Riot/Modules/Settings/SettingsViewController.m
+++ b/Riot/Modules/Settings/SettingsViewController.m
@@ -153,9 +153,10 @@ enum
ABOUT_THIRD_PARTY_INDEX,
};
-enum
+typedef NS_ENUM(NSUInteger, LABS_ENABLE)
{
- LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX = 0
+ LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX,
+ LABS_ENABLE_POLLS
};
enum
@@ -551,6 +552,8 @@ TableViewSectionsDelegate>
{
Section *sectionLabs = [Section sectionWithTag:SECTION_TAG_LABS];
[sectionLabs addRowWithTag:LABS_ENABLE_RINGING_FOR_GROUP_CALLS_INDEX];
+ [sectionLabs addRowWithTag:LABS_ENABLE_POLLS];
+
sectionLabs.headerTitle = [VectorL10n settingsLabs];
if (sectionLabs.hasAnyRows)
{
@@ -2401,6 +2404,19 @@ TableViewSectionsDelegate>
cell = labelAndSwitchCell;
}
+
+ if (row == LABS_ENABLE_POLLS && BuildSettings.pollsEnabled)
+ {
+ MXKTableViewCellWithLabelAndSwitch *labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath];
+
+ labelAndSwitchCell.mxkLabel.text = [VectorL10n settingsLabsEnabledPolls];
+ labelAndSwitchCell.mxkSwitch.on = RiotSettings.shared.roomScreenAllowPollsAction;
+ labelAndSwitchCell.mxkSwitch.onTintColor = ThemeService.shared.theme.tintColor;
+
+ [labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnablePolls:) forControlEvents:UIControlEventValueChanged];
+
+ cell = labelAndSwitchCell;
+ }
}
else if (section == SECTION_TAG_FLAIR)
{
@@ -3135,6 +3151,11 @@ TableViewSectionsDelegate>
RiotSettings.shared.enableRingingForGroupCalls = sender.isOn;
}
+- (void)toggleEnablePolls:(UISwitch *)sender
+{
+ RiotSettings.shared.roomScreenAllowPollsAction = sender.isOn;
+}
+
- (void)togglePinRoomsWithMissedNotif:(UISwitch *)sender
{
RiotSettings.shared.pinRoomsWithMissedNotificationsOnHome = sender.isOn;
diff --git a/RiotNSE/NotificationService.swift b/RiotNSE/NotificationService.swift
index 9464453ca..2dfaec7f3 100644
--- a/RiotNSE/NotificationService.swift
+++ b/RiotNSE/NotificationService.swift
@@ -500,6 +500,9 @@ class NotificationService: UNNotificationServiceExtension {
additionalUserInfo = [Constants.userInfoKeyPresentNotificationOnForeground: true]
}
}
+ case .pollStart:
+ notificationTitle = self.messageTitle(for: eventSenderName, in: roomDisplayName)
+ notificationBody = MXEventContentPollStart(fromJSON: event.content)?.question
default:
break
}
diff --git a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift
index 51c3e182c..bceefcd26 100644
--- a/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift
+++ b/RiotSwiftUI/Modules/Common/Mock/MockAppScreens.swift
@@ -24,7 +24,8 @@ enum MockAppScreens {
MockTemplateRoomListScreenState.self,
MockTemplateRoomChatScreenState.self,
MockUserSuggestionScreenState.self,
- MockPollEditFormScreenState.self
+ MockPollEditFormScreenState.self,
+ MockPollTimelineScreenState.self
]
}
diff --git a/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift b/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift
index 1df71686e..da63d7098 100644
--- a/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift
+++ b/RiotSwiftUI/Modules/Room/PollEditForm/Coordinator/PollEditFormCoordinator.swift
@@ -22,6 +22,7 @@ import SwiftUI
struct PollEditFormCoordinatorParameters {
let navigationRouter: NavigationRouterType?
+ let room: MXRoom
}
final class PollEditFormCoordinator: Coordinator {
@@ -60,12 +61,10 @@ final class PollEditFormCoordinator: Coordinator {
// MARK: - Public
func start() {
guard #available(iOS 14.0, *) else {
- MXLog.debug("[PollEditFormCoordinator] start: Invalid iOS version, returning.")
+ MXLog.error("[PollEditFormCoordinator] start: Invalid iOS version, returning.")
return
}
- MXLog.debug("[PollEditFormCoordinator] did start.")
-
parameters.navigationRouter?.present(pollEditFormHostingController, animated: true)
pollEditFormViewModel.completion = { [weak self] result in
@@ -73,8 +72,30 @@ final class PollEditFormCoordinator: Coordinator {
switch result {
case .cancel:
self.parameters.navigationRouter?.dismissModule(animated: true, completion: nil)
- case .create(_, _):
- break
+ case .create(let question, let answerOptions):
+ var options = [MXEventContentPollStartAnswerOption]()
+ for answerOption in answerOptions {
+ options.append(MXEventContentPollStartAnswerOption(uuid: UUID().uuidString, text: answerOption))
+ }
+
+ let pollStartContent = MXEventContentPollStart(question: question,
+ kind: kMXMessageContentKeyExtensiblePollKindDisclosed,
+ maxSelections: 1,
+ answerOptions: options)
+
+ self.pollEditFormViewModel.dispatch(action: .startLoading)
+
+ self.parameters.room.sendPollStart(withContent: pollStartContent, localEcho: nil) { [weak self] result in
+ guard let self = self else { return }
+
+ self.parameters.navigationRouter?.dismissModule(animated: true, completion: nil)
+ self.pollEditFormViewModel.dispatch(action: .stopLoading(nil))
+ } failure: { [weak self] error in
+ guard let self = self else { return }
+
+ MXLog.error("Failed creating poll with error: \(String(describing: error))")
+ self.pollEditFormViewModel.dispatch(action: .stopLoading(error))
+ }
}
}
}
diff --git a/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormModels.swift b/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormModels.swift
index 9e6657506..9c7d856c8 100644
--- a/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormModels.swift
+++ b/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormModels.swift
@@ -21,6 +21,8 @@ import SwiftUI
enum PollEditFormStateAction {
case viewAction(PollEditFormViewAction)
+ case startLoading
+ case stopLoading(Error?)
}
enum PollEditFormViewAction {
@@ -58,7 +60,7 @@ struct PollEditFormAnswerOption: Identifiable, Equatable {
}
struct PollEditFormViewState: BindableState {
- let maxAnswerOptionsCount: Int
+ var maxAnswerOptionsCount: Int
var bindings: PollEditFormViewStateBindings
var confirmationButtonEnabled: Bool {
@@ -69,9 +71,13 @@ struct PollEditFormViewState: BindableState {
var addAnswerOptionButtonEnabled: Bool {
bindings.answerOptions.count < maxAnswerOptionsCount
}
+
+ var showLoadingIndicator: Bool = false
}
struct PollEditFormViewStateBindings {
var question: PollEditFormQuestion
var answerOptions: [PollEditFormAnswerOption]
+
+ var showsFailureAlert: Bool = false
}
diff --git a/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModel.swift b/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModel.swift
index b77274bcb..f38ac9fc9 100644
--- a/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModel.swift
+++ b/RiotSwiftUI/Modules/Room/PollEditForm/PollEditFormViewModel.swift
@@ -28,8 +28,8 @@ class PollEditFormViewModel: PollEditFormViewModelType {
private struct Constants {
static let maxAnswerOptionsCount = 20
- static let maxQuestionLength = 200
- static let maxAnswerOptionLength = 200
+ static let maxQuestionLength = 340
+ static let maxAnswerOptionLength = 340
}
// MARK: - Properties
@@ -86,6 +86,16 @@ class PollEditFormViewModel: PollEditFormViewModelType {
default:
break
}
+ case .startLoading:
+ state.showLoadingIndicator = true
+ break
+ case .stopLoading(let error):
+ state.showLoadingIndicator = false
+
+ if error != nil {
+ state.bindings.showsFailureAlert = true
+ }
+ break
}
}
}
diff --git a/RiotSwiftUI/Modules/Room/PollEditForm/View/PollEditForm.swift b/RiotSwiftUI/Modules/Room/PollEditForm/View/PollEditForm.swift
index 1d874688b..bdf54e7bd 100644
--- a/RiotSwiftUI/Modules/Room/PollEditForm/View/PollEditForm.swift
+++ b/RiotSwiftUI/Modules/Room/PollEditForm/View/PollEditForm.swift
@@ -59,14 +59,18 @@ struct PollEditForm: View {
ForEach(0..()
+
+ private var pollAggregator: PollAggregator
+ private var pollTimelineViewModel: PollTimelineViewModel!
+ private var cancellables = Set()
+
+ // MARK: Public
+
+ // Must be used only internally
+ var childCoordinators: [Coordinator] = []
+
+ // MARK: - Setup
+
+ @available(iOS 14.0, *)
+ init(parameters: PollTimelineCoordinatorParameters) throws {
+ self.parameters = parameters
+
+ try pollAggregator = PollAggregator(session: parameters.session, room: parameters.room, pollStartEvent: parameters.pollStartEvent)
+ pollAggregator.delegate = self
+
+ pollTimelineViewModel = PollTimelineViewModel(timelinePoll: buildTimelinePollFrom(pollAggregator.poll))
+ pollTimelineViewModel.callback = { [weak self] result in
+ guard let self = self else { return }
+
+ switch result {
+ case .selectedAnswerOptionsWithIdentifiers(let identifiers):
+ self.selectedAnswerIdentifiersSubject.send(identifiers)
+ }
+ }
+
+ selectedAnswerIdentifiersSubject
+ .debounce(for: 1.0, scheduler: RunLoop.main)
+ .removeDuplicates()
+ .sink { [weak self] identifiers in
+ guard let self = self else { return }
+
+ self.parameters.room.sendPollResponse(for: parameters.pollStartEvent,
+ withAnswerIdentifiers: identifiers,
+ localEcho: nil, success: nil) { [weak self] error in
+ guard let self = self else { return }
+
+ MXLog.error("[PollTimelineCoordinator]] Failed submitting response with error \(String(describing: error))")
+
+ self.pollTimelineViewModel.dispatch(action: .showAnsweringFailure)
+ }
+ }
+ .store(in: &cancellables)
+ }
+
+ // MARK: - Public
+ func start() {
+
+ }
+
+ func toPresentable() -> UIViewController {
+ return VectorHostingController(rootView: PollTimelineView(viewModel: pollTimelineViewModel.context))
+ }
+
+ func canEndPoll() -> Bool {
+ return pollAggregator.poll.isClosed == false
+ }
+
+ func endPoll() {
+ parameters.room.sendPollEnd(for: parameters.pollStartEvent, localEcho: nil, success: nil) { [weak self] error in
+ self?.pollTimelineViewModel.dispatch(action: .showClosingFailure)
+ }
+ }
+
+ // MARK: - PollAggregatorDelegate
+
+ func pollAggregatorDidUpdateData(_ aggregator: PollAggregator) {
+ pollTimelineViewModel.dispatch(action: .updateWithPoll(buildTimelinePollFrom(aggregator.poll)))
+ }
+
+ func pollAggregatorDidStartLoading(_ aggregator: PollAggregator) {
+
+ }
+
+ func pollAggregatorDidEndLoading(_ aggregator: PollAggregator) {
+
+ }
+
+ func pollAggregator(_ aggregator: PollAggregator, didFailWithError: Error) {
+
+ }
+
+ // MARK: - Private
+
+ // PollProtocol is intentionally not available in the SwiftUI target as we don't want
+ // to add the SDK as a dependency to it. We need to translate from one to the other on this level.
+ func buildTimelinePollFrom(_ poll: PollProtocol) -> TimelinePoll {
+ let answerOptions = poll.answerOptions.map { pollAnswerOption in
+ TimelineAnswerOption(id: pollAnswerOption.id,
+ text: pollAnswerOption.text,
+ count: pollAnswerOption.count,
+ winner: pollAnswerOption.isWinner,
+ selected: pollAnswerOption.isCurrentUserSelection)
+ }
+
+ return TimelinePoll(question: poll.text,
+ answerOptions: answerOptions,
+ closed: poll.isClosed,
+ totalAnswerCount: poll.totalAnswerCount,
+ type: (poll.kind == .disclosed ? .disclosed : .undisclosed),
+ maxAllowedSelections: poll.maxAllowedSelections)
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/PollTimeline/Coordinator/PollTimelineProvider.swift b/RiotSwiftUI/Modules/Room/PollTimeline/Coordinator/PollTimelineProvider.swift
new file mode 100644
index 000000000..d778cbedf
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/PollTimeline/Coordinator/PollTimelineProvider.swift
@@ -0,0 +1,55 @@
+//
+// Copyright 2021 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
+
+@available(iOS 14, *)
+class PollTimelineProvider {
+ static let shared = PollTimelineProvider()
+
+ var session: MXSession?
+ var coordinatorsForEventIdentifiers = [String: PollTimelineCoordinator]()
+
+ private init() {
+
+ }
+
+ /// Create or retrieve the poll timeline coordinator for this event and return
+ /// a view to be displayed in the timeline
+ func buildPollTimelineViewForEvent(_ event: MXEvent) -> UIView? {
+ guard let session = session, let room = session.room(withRoomId: event.roomId) else {
+ return nil
+ }
+
+ if let coordinator = coordinatorsForEventIdentifiers[event.eventId] {
+ return coordinator.toPresentable().view
+ }
+
+ let parameters = PollTimelineCoordinatorParameters(session: session, room: room, pollStartEvent: event)
+ guard let coordinator = try? PollTimelineCoordinator(parameters: parameters) else {
+ return nil
+ }
+
+ coordinatorsForEventIdentifiers[event.eventId] = coordinator
+
+ return coordinator.toPresentable().view
+ }
+
+ /// Retrieve the poll timeline coordinator for the given event or nil if it hasn't been created yet
+ func pollTimelineCoordinatorForEventIdentifier(_ eventIdentifier: String) -> PollTimelineCoordinator? {
+ return coordinatorsForEventIdentifiers[eventIdentifier]
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/PollTimeline/PollTimelineModels.swift b/RiotSwiftUI/Modules/Room/PollTimeline/PollTimelineModels.swift
new file mode 100644
index 000000000..d01eaf864
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/PollTimeline/PollTimelineModels.swift
@@ -0,0 +1,91 @@
+// File created from SimpleUserProfileExample
+// $ createScreen.sh Room/PollTimeline PollTimeline
+//
+// Copyright 2021 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
+import SwiftUI
+
+typealias PollTimelineViewModelCallback = ((PollTimelineViewModelResult) -> Void)
+
+enum PollTimelineStateAction {
+ case viewAction(PollTimelineViewAction, PollTimelineViewModelCallback?)
+ case updateWithPoll(TimelinePoll)
+ case showAnsweringFailure
+ case showClosingFailure
+}
+
+enum PollTimelineViewAction {
+ case selectAnswerOptionWithIdentifier(String)
+}
+
+enum PollTimelineViewModelResult {
+ case selectedAnswerOptionsWithIdentifiers([String])
+}
+
+enum TimelinePollType {
+ case disclosed
+ case undisclosed
+}
+
+class TimelineAnswerOption: Identifiable {
+ var id: String
+ var text: String
+ var count: UInt
+ var winner: Bool
+ var selected: Bool
+
+ init(id: String, text: String, count: UInt, winner: Bool, selected: Bool) {
+ self.id = id
+ self.text = text
+ self.count = count
+ self.winner = winner
+ self.selected = selected
+ }
+}
+
+class TimelinePoll {
+ var question: String
+ var answerOptions: [TimelineAnswerOption]
+ var closed: Bool
+ var totalAnswerCount: UInt
+ var type: TimelinePollType
+ var maxAllowedSelections: UInt
+
+ init(question: String, answerOptions: [TimelineAnswerOption], closed: Bool, totalAnswerCount: UInt, type: TimelinePollType, maxAllowedSelections: UInt) {
+ self.question = question
+ self.answerOptions = answerOptions
+ self.closed = closed
+ self.totalAnswerCount = totalAnswerCount
+ self.type = type
+ self.maxAllowedSelections = maxAllowedSelections
+ }
+
+ var hasCurrentUserVoted: Bool {
+ answerOptions.filter { $0.selected == true}.count > 0
+ }
+}
+
+struct PollTimelineViewState: BindableState {
+ var poll: TimelinePoll
+ var bindings: PollTimelineViewStateBindings
+}
+
+struct PollTimelineViewStateBindings {
+ var showsAnsweringFailureAlert: Bool = false
+ var showsClosingFailureAlert: Bool = false
+}
+
diff --git a/RiotSwiftUI/Modules/Room/PollTimeline/PollTimelineScreenState.swift b/RiotSwiftUI/Modules/Room/PollTimeline/PollTimelineScreenState.swift
new file mode 100644
index 000000000..b7752acf5
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/PollTimeline/PollTimelineScreenState.swift
@@ -0,0 +1,47 @@
+// File created from SimpleUserProfileExample
+// $ createScreen.sh Room/UserSuggestion UserSuggestion
+//
+// Copyright 2021 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
+import SwiftUI
+
+@available(iOS 14.0, *)
+enum MockPollTimelineScreenState: MockScreenState, CaseIterable {
+ case open
+ case closed
+
+ var screenType: Any.Type {
+ MockPollTimelineScreenState.self
+ }
+
+ var screenView: ([Any], AnyView) {
+ let answerOptions = [TimelineAnswerOption(id: "1", text: "First", count: 10, winner: false, selected: false),
+ TimelineAnswerOption(id: "2", text: "Second", count: 5, winner: false, selected: true),
+ TimelineAnswerOption(id: "3", text: "Third", count: 15, winner: true, selected: false)]
+
+ let poll = TimelinePoll(question: "Question",
+ answerOptions: answerOptions,
+ closed: (self == .closed ? true : false),
+ totalAnswerCount: 20,
+ type: .disclosed,
+ maxAllowedSelections: 1)
+
+ let viewModel = PollTimelineViewModel(timelinePoll: poll)
+
+ return ([viewModel], AnyView(PollTimelineView(viewModel: viewModel.context)))
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/PollTimeline/PollTimelineViewModel.swift b/RiotSwiftUI/Modules/Room/PollTimeline/PollTimelineViewModel.swift
new file mode 100644
index 000000000..eb802509b
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/PollTimeline/PollTimelineViewModel.swift
@@ -0,0 +1,132 @@
+// File created from SimpleUserProfileExample
+// $ createScreen.sh Room/PollTimeline PollTimeline
+//
+// Copyright 2021 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
+import Combine
+
+@available(iOS 14, *)
+typealias PollTimelineViewModelType = StateStoreViewModel
+@available(iOS 14, *)
+class PollTimelineViewModel: PollTimelineViewModelType {
+
+ // MARK: - Properties
+
+ // MARK: Private
+
+ // MARK: Public
+
+ var callback: PollTimelineViewModelCallback?
+
+ // MARK: - Setup
+
+ init(timelinePoll: TimelinePoll) {
+ super.init(initialViewState: PollTimelineViewState(poll: timelinePoll, bindings: PollTimelineViewStateBindings()))
+ }
+
+ // MARK: - Public
+
+ override func process(viewAction: PollTimelineViewAction) {
+ switch viewAction {
+ case .selectAnswerOptionWithIdentifier(_):
+ dispatch(action: .viewAction(viewAction, callback))
+ }
+ }
+
+ override class func reducer(state: inout PollTimelineViewState, action: PollTimelineStateAction) {
+ switch action {
+ case .viewAction(let viewAction, let callback):
+ switch viewAction {
+
+ // Update local state. An update will be pushed from the coordinator once sent.
+ case .selectAnswerOptionWithIdentifier(let identifier):
+ guard !state.poll.closed else {
+ return
+ }
+
+ if (state.poll.maxAllowedSelections == 1) {
+ updateSingleSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: callback)
+ } else {
+ updateMultiSelectPollLocalState(&state, selectedAnswerIdentifier: identifier, callback: callback)
+ }
+ }
+ case .updateWithPoll(let poll):
+ state.poll = poll
+ case .showAnsweringFailure:
+ state.bindings.showsAnsweringFailureAlert = true
+ case .showClosingFailure:
+ state.bindings.showsClosingFailureAlert = true
+ }
+ }
+
+ // MARK: - Private
+
+ static func updateSingleSelectPollLocalState(_ state: inout PollTimelineViewState, selectedAnswerIdentifier: String, callback: PollTimelineViewModelCallback?) {
+ for answerOption in state.poll.answerOptions {
+ if answerOption.selected {
+ answerOption.selected = false
+
+ if(answerOption.count > 0) {
+ answerOption.count = answerOption.count - 1
+ state.poll.totalAnswerCount -= 1
+ }
+ }
+
+ if answerOption.id == selectedAnswerIdentifier {
+ answerOption.selected = true
+ answerOption.count += 1
+ state.poll.totalAnswerCount += 1
+ }
+ }
+
+ informCoordinatorOfSelectionUpdate(state: state, callback: callback)
+ }
+
+ static func updateMultiSelectPollLocalState(_ state: inout PollTimelineViewState, selectedAnswerIdentifier: String, callback: PollTimelineViewModelCallback?) {
+ let selectedAnswerOptions = state.poll.answerOptions.filter { $0.selected == true }
+
+ let isDeselecting = selectedAnswerOptions.filter { $0.id == selectedAnswerIdentifier }.count > 0
+
+ if !isDeselecting && selectedAnswerOptions.count >= state.poll.maxAllowedSelections {
+ return
+ }
+
+ for answerOption in state.poll.answerOptions where answerOption.id == selectedAnswerIdentifier {
+ if answerOption.selected {
+ answerOption.selected = false
+ answerOption.count -= 1
+ state.poll.totalAnswerCount -= 1
+ } else {
+ answerOption.selected = true
+ answerOption.count += 1
+ state.poll.totalAnswerCount += 1
+ }
+ }
+
+ informCoordinatorOfSelectionUpdate(state: state, callback: callback)
+ }
+
+ static func informCoordinatorOfSelectionUpdate(state: PollTimelineViewState, callback: PollTimelineViewModelCallback?) {
+ let selectedIdentifiers = state.poll.answerOptions.compactMap { answerOption in
+ answerOption.selected ? answerOption.id : nil
+ }
+
+ callback?(.selectedAnswerOptionsWithIdentifiers(selectedIdentifiers))
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/PollTimeline/Test/UI/PollTimelineUITests.swift b/RiotSwiftUI/Modules/Room/PollTimeline/Test/UI/PollTimelineUITests.swift
new file mode 100644
index 000000000..798c07f3c
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/PollTimeline/Test/UI/PollTimelineUITests.swift
@@ -0,0 +1,98 @@
+// File created from SimpleUserProfileExample
+// $ createScreen.sh Room/PollTimeline PollTimeline
+//
+// Copyright 2021 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 XCTest
+import RiotSwiftUI
+
+@available(iOS 14.0, *)
+class PollTimelineUITests: XCTestCase {
+
+ private var app: XCUIApplication!
+
+ override func setUp() {
+ continueAfterFailure = false
+
+ app = XCUIApplication()
+ app.launch()
+ }
+
+ func testOpenPoll() {
+ app.buttons[MockPollTimelineScreenState.screenStateKeys.first!].tap()
+
+ XCTAssert(app.staticTexts["Question"].exists)
+ XCTAssert(app.staticTexts["20 votes cast"].exists)
+
+ XCTAssert(app.buttons["First, 10 votes"].exists)
+ XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
+
+ XCTAssert(app.buttons["Second, 5 votes"].exists)
+ XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%")
+
+ XCTAssert(app.buttons["Third, 15 votes"].exists)
+ XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
+
+ app.buttons["First, 10 votes"].tap()
+
+ XCTAssert(app.buttons["First, 11 votes"].exists)
+ XCTAssertEqual(app.buttons["First, 11 votes"].value as! String, "55%")
+
+ XCTAssert(app.buttons["Second, 4 votes"].exists)
+ XCTAssertEqual(app.buttons["Second, 4 votes"].value as! String, "20%")
+
+ XCTAssert(app.buttons["Third, 15 votes"].exists)
+ XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
+
+ app.buttons["Third, 15 votes"].tap()
+
+ XCTAssert(app.buttons["First, 10 votes"].exists)
+ XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
+
+ XCTAssert(app.buttons["Second, 4 votes"].exists)
+ XCTAssertEqual(app.buttons["Second, 4 votes"].value as! String, "20%")
+
+ XCTAssert(app.buttons["Third, 16 votes"].exists)
+ XCTAssertEqual(app.buttons["Third, 16 votes"].value as! String, "80%")
+ }
+
+ func testClosedPoll() {
+ app.buttons[MockPollTimelineScreenState.screenStateKeys.last!].tap()
+
+ XCTAssert(app.staticTexts["Question"].exists)
+ XCTAssert(app.staticTexts["Final results based on 20 votes"].exists)
+
+ XCTAssert(app.buttons["First, 10 votes"].exists)
+ XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
+
+ XCTAssert(app.buttons["Second, 5 votes"].exists)
+ XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%")
+
+ XCTAssert(app.buttons["Third, 15 votes"].exists)
+ XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
+
+ app.buttons["First, 10 votes"].tap()
+
+ XCTAssert(app.buttons["First, 10 votes"].exists)
+ XCTAssertEqual(app.buttons["First, 10 votes"].value as! String, "50%")
+
+ XCTAssert(app.buttons["Second, 5 votes"].exists)
+ XCTAssertEqual(app.buttons["Second, 5 votes"].value as! String, "25%")
+
+ XCTAssert(app.buttons["Third, 15 votes"].exists)
+ XCTAssertEqual(app.buttons["Third, 15 votes"].value as! String, "75%")
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/PollTimeline/Test/Unit/PollTimelineViewModelTests.swift b/RiotSwiftUI/Modules/Room/PollTimeline/Test/Unit/PollTimelineViewModelTests.swift
new file mode 100644
index 000000000..9ad80e5b9
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/PollTimeline/Test/Unit/PollTimelineViewModelTests.swift
@@ -0,0 +1,149 @@
+// File created from SimpleUserProfileExample
+// $ createScreen.sh Room/PollTimeline PollTimeline
+//
+// Copyright 2021 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 XCTest
+import Combine
+
+@testable import RiotSwiftUI
+
+@available(iOS 14.0, *)
+class PollTimelineViewModelTests: XCTestCase {
+ var viewModel: PollTimelineViewModel!
+ var context: PollTimelineViewModelType.Context!
+ var cancellables = Set()
+
+ override func setUpWithError() throws {
+ let answerOptions = [TimelineAnswerOption(id: "1", text: "1", count: 1, winner: false, selected: false),
+ TimelineAnswerOption(id: "2", text: "2", count: 1, winner: false, selected: false),
+ TimelineAnswerOption(id: "3", text: "3", count: 1, winner: false, selected: false)]
+
+ let timelinePoll = TimelinePoll(question: "Question",
+ answerOptions: answerOptions,
+ closed: false,
+ totalAnswerCount: 3,
+ type: .disclosed,
+ maxAllowedSelections: 1)
+
+ viewModel = PollTimelineViewModel(timelinePoll: timelinePoll)
+ context = viewModel.context
+ }
+
+ func testInitialState() {
+ XCTAssertEqual(context.viewState.poll.answerOptions.count, 3)
+ XCTAssertFalse(context.viewState.poll.closed)
+ XCTAssertEqual(context.viewState.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)
+ }
+
+ 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)
+ }
+
+ 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)
+ }
+
+ 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)
+ }
+
+ func testClosedSelection() {
+ context.viewState.poll.closed = true
+
+ 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)
+ }
+
+ func testSingleSelectionOnMax2Allowed() {
+ context.viewState.poll.maxAllowedSelections = 2
+
+ 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)
+ }
+
+ func testSingleReselectionOnMax2Allowed() {
+ context.viewState.poll.maxAllowedSelections = 2
+
+ 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)
+ }
+
+ func testMultipleSelectionOnMax2Allowed() {
+ context.viewState.poll.maxAllowedSelections = 2
+
+ 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)
+
+ 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)
+
+ 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)
+
+ 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)
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/PollTimeline/View/PollTimelineAnswerOptionButton.swift b/RiotSwiftUI/Modules/Room/PollTimeline/View/PollTimelineAnswerOptionButton.swift
new file mode 100644
index 000000000..e5daad842
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/PollTimeline/View/PollTimelineAnswerOptionButton.swift
@@ -0,0 +1,155 @@
+//
+// Copyright 2021 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
+
+@available(iOS 14.0, *)
+struct PollTimelineAnswerOptionButton: View {
+
+ // MARK: - Properties
+
+ // MARK: Private
+
+ @Environment(\.theme) private var theme: ThemeSwiftUI
+
+ let answerOption: TimelineAnswerOption
+ let pollClosed: Bool
+ let showResults: Bool
+ let totalAnswerCount: UInt
+ let action: () -> Void
+
+ // MARK: Public
+
+ var body: some View {
+ Button(action: action) {
+ let rect = RoundedRectangle(cornerRadius: 4.0)
+ answerOptionLabel
+ .padding(.horizontal, 8.0)
+ .padding(.top, 12.0)
+ .padding(.bottom, 4.0)
+ .clipShape(rect)
+ .overlay(rect.stroke(borderAccentColor, lineWidth: 1.0))
+ .accentColor(progressViewAccentColor)
+ }
+ }
+
+ var answerOptionLabel: some View {
+ VStack(alignment: .leading, spacing: 12.0) {
+ HStack(alignment: .top, spacing: 8.0) {
+
+ if !pollClosed {
+ Image(uiImage: answerOption.selected ? Asset.Images.pollCheckboxSelected.image : Asset.Images.pollCheckboxDefault.image)
+ }
+
+ Text(answerOption.text)
+ .font(theme.fonts.body)
+ .foregroundColor(theme.colors.primaryContent)
+
+ if pollClosed && answerOption.winner {
+ Spacer()
+ Image(uiImage: Asset.Images.pollWinnerIcon.image)
+ }
+ }
+
+ HStack {
+ ProgressView(value: Double(showResults ? answerOption.count : 0),
+ total: Double(totalAnswerCount))
+ .progressViewStyle(LinearProgressViewStyle())
+ .scaleEffect(x: 1.0, y: 1.2, anchor: .center)
+ .padding(.vertical, 8.0)
+
+ if (showResults) {
+ Text(answerOption.count == 1 ? VectorL10n.pollTimelineOneVote : VectorL10n.pollTimelineVotesCount(Int(answerOption.count)))
+ .font(theme.fonts.footnote)
+ .foregroundColor(pollClosed && answerOption.winner ? theme.colors.accent : theme.colors.secondaryContent)
+ }
+ }
+ }
+ }
+
+ var borderAccentColor: Color {
+ guard !pollClosed else {
+ return (answerOption.winner ? theme.colors.accent : theme.colors.quinaryContent)
+ }
+
+ return answerOption.selected ? theme.colors.accent : theme.colors.quinaryContent
+ }
+
+ var progressViewAccentColor: Color {
+ guard !pollClosed else {
+ return (answerOption.winner ? theme.colors.accent : theme.colors.quarterlyContent)
+ }
+
+ return answerOption.selected ? theme.colors.accent : theme.colors.quarterlyContent
+ }
+}
+
+@available(iOS 14.0, *)
+struct PollTimelineAnswerOptionButton_Previews: PreviewProvider {
+ static let stateRenderer = MockPollTimelineScreenState.stateRenderer
+ static var previews: some View {
+
+ Group {
+ VStack {
+ PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 5, winner: false, selected: false),
+ pollClosed: false, showResults: true, totalAnswerCount: 100, action: {})
+
+ PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 5, winner: false, selected: false),
+ pollClosed: false, showResults: false, totalAnswerCount: 100, action: {})
+
+ PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 8, winner: false, selected: true),
+ pollClosed: false, showResults: true, totalAnswerCount: 100, action: {})
+
+ PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 8, winner: false, selected: true),
+ pollClosed: false, showResults: false, totalAnswerCount: 100, action: {})
+
+ PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "",
+ text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
+ count: 200, winner: false, selected: false),
+ pollClosed: false, showResults: true, totalAnswerCount: 1000, action: {})
+
+ PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "",
+ text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
+ count: 200, winner: false, selected: false),
+ pollClosed: false, showResults: false, totalAnswerCount: 1000, action: {})
+ }
+
+ VStack {
+ PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 5, winner: false, selected: false),
+ pollClosed: true, showResults: true, totalAnswerCount: 100, action: {})
+
+ PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 5, winner: true, selected: false),
+ pollClosed: true, showResults: true, totalAnswerCount: 100, action: {})
+
+ PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 8, winner: false, selected: true),
+ pollClosed: true, showResults: true, totalAnswerCount: 100, action: {})
+
+ PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "", text: "Test", count: 8, winner: true, selected: true),
+ pollClosed: true, showResults: true, totalAnswerCount: 100, action: {})
+
+ PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "",
+ text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
+ count: 200, winner: false, selected: false),
+ pollClosed: true, showResults: true, totalAnswerCount: 1000, action: {})
+
+ PollTimelineAnswerOptionButton(answerOption: TimelineAnswerOption(id: "",
+ text: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.",
+ count: 200, winner: true, selected: false),
+ pollClosed: true, showResults: true, totalAnswerCount: 1000, action: {})
+ }
+ }
+ }
+}
diff --git a/RiotSwiftUI/Modules/Room/PollTimeline/View/PollTimelineView.swift b/RiotSwiftUI/Modules/Room/PollTimeline/View/PollTimelineView.swift
new file mode 100644
index 000000000..22a94bb68
--- /dev/null
+++ b/RiotSwiftUI/Modules/Room/PollTimeline/View/PollTimelineView.swift
@@ -0,0 +1,116 @@
+// File created from SimpleUserProfileExample
+// $ createScreen.sh Room/PollEditForm PollEditForm
+//
+// Copyright 2021 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
+
+@available(iOS 14.0, *)
+struct PollTimelineView: View {
+
+ // MARK: - Properties
+
+ // MARK: Private
+
+ @Environment(\.theme) private var theme: ThemeSwiftUI
+
+ // MARK: Public
+
+ @ObservedObject var viewModel: PollTimelineViewModel.Context
+
+ var body: some View {
+ let poll = viewModel.viewState.poll
+
+ VStack(alignment: .leading, spacing: 16.0) {
+ Text(poll.question)
+ .font(theme.fonts.bodySB)
+
+ VStack(spacing: 24.0) {
+ ForEach(poll.answerOptions) { answerOption in
+ PollTimelineAnswerOptionButton(answerOption: answerOption,
+ pollClosed: poll.closed,
+ showResults: shouldDiscloseResults,
+ totalAnswerCount: poll.totalAnswerCount) {
+ viewModel.send(viewAction: .selectAnswerOptionWithIdentifier(answerOption.id))
+ }
+ }
+ .alert(isPresented: $viewModel.showsClosingFailureAlert) {
+ Alert(title: Text(VectorL10n.pollTimelineNotClosedTitle),
+ message: Text(VectorL10n.pollTimelineNotClosedSubtitle),
+ dismissButton: .default(Text(VectorL10n.pollTimelineNotClosedAction)))
+ }
+ }
+ .disabled(poll.closed)
+ .fixedSize(horizontal: false, vertical: true)
+
+ Text(totalVotesString)
+ .font(theme.fonts.footnote)
+ .foregroundColor(theme.colors.tertiaryContent)
+ .alert(isPresented: $viewModel.showsAnsweringFailureAlert) {
+ Alert(title: Text(VectorL10n.pollTimelineVoteNotRegisteredTitle),
+ message: Text(VectorL10n.pollTimelineVoteNotRegisteredSubtitle),
+ dismissButton: .default(Text(VectorL10n.pollTimelineVoteNotRegisteredAction)))
+ }
+ }
+ .padding([.horizontal, .top], 2.0)
+ .padding([.bottom])
+ }
+
+ private var totalVotesString: String {
+ let poll = viewModel.viewState.poll
+
+ if poll.closed {
+ if poll.totalAnswerCount == 1 {
+ return VectorL10n.pollTimelineTotalFinalResultsOneVote
+ } else {
+ return VectorL10n.pollTimelineTotalFinalResults(Int(poll.totalAnswerCount))
+ }
+ }
+
+ switch poll.totalAnswerCount {
+ case 0:
+ return VectorL10n.pollTimelineTotalNoVotes
+ case 1:
+ return (poll.hasCurrentUserVoted ?
+ VectorL10n.pollTimelineTotalOneVote :
+ VectorL10n.pollTimelineTotalOneVoteNotVoted)
+ default:
+ return (poll.hasCurrentUserVoted ?
+ VectorL10n.pollTimelineTotalVotes(Int(poll.totalAnswerCount)) :
+ VectorL10n.pollTimelineTotalVotesNotVoted(Int(poll.totalAnswerCount)))
+ }
+ }
+
+ private var shouldDiscloseResults: Bool {
+ let poll = viewModel.viewState.poll
+
+ if poll.closed {
+ return poll.totalAnswerCount > 0
+ } else {
+ return poll.type == .disclosed && poll.totalAnswerCount > 0 && poll.hasCurrentUserVoted
+ }
+ }
+}
+
+// MARK: - Previews
+
+@available(iOS 14.0, *)
+struct PollTimelineView_Previews: PreviewProvider {
+ static let stateRenderer = MockPollTimelineScreenState.stateRenderer
+ static var previews: some View {
+ stateRenderer.screenGroup()
+ }
+}
diff --git a/changelog.d/5114.feature b/changelog.d/5114.feature
new file mode 100644
index 000000000..489c8dbd8
--- /dev/null
+++ b/changelog.d/5114.feature
@@ -0,0 +1 @@
+Added support for creating, displaying and interacting with polls in the timeline.
\ No newline at end of file
diff --git a/changelog.d/5190.bugfix b/changelog.d/5190.bugfix
new file mode 100644
index 000000000..d9dc6460d
--- /dev/null
+++ b/changelog.d/5190.bugfix
@@ -0,0 +1 @@
+Fix crash when trying to scroll the people's tab to the top.
\ No newline at end of file
diff --git a/changelog.d/5285.misc b/changelog.d/5285.misc
new file mode 100644
index 000000000..b0515608c
--- /dev/null
+++ b/changelog.d/5285.misc
@@ -0,0 +1 @@
+Update issue workflow automation for the Delight team
diff --git a/changelog.d/5289.misc b/changelog.d/5289.misc
new file mode 100644
index 000000000..ffdd1fa4c
--- /dev/null
+++ b/changelog.d/5289.misc
@@ -0,0 +1 @@
+Update workflow to add automation for the new Message Bubbles board