vector-im/element-ios/issues/5114 - Polls in the timeline.

This commit is contained in:
Stefan Ceriu
2021-11-19 17:40:52 +02:00
parent ef4abe6ed2
commit ac6561a3d3
47 changed files with 1717 additions and 223 deletions

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 615 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 632 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 630 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 695 B

View File

@@ -357,6 +357,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";
@@ -567,7 +569,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 %@";
@@ -1793,6 +1795,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";

View File

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

View File

@@ -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")
@@ -4535,14 +4615,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")

View File

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

View File

@@ -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;

View File

@@ -36,6 +36,11 @@ static NSAttributedString *messageSeparator = nil;
- (BOOL)addEvent:(MXEvent*)event andRoomState:(MXRoomState*)roomState
{
// Never merge polls
if (self.events.firstObject.eventType == MXEventTypePollStart) {
return NO;
}
// We group together text messages from the same user (attachments are not merged).
if ([event.sender isEqualToString:self.senderId] && (self.attachment == nil) && (self.bubbleComponents.count < self.maxComponentCount))
{
@@ -85,6 +90,11 @@ static NSAttributedString *messageSeparator = nil;
- (BOOL)mergeWithBubbleCellData:(id<MXKRoomBubbleCellDataStoring>)bubbleCellData
{
// Never merge polls
if (self.events.firstObject.eventType == MXEventTypePollStart) {
return NO;
}
if ([self hasSameSenderAsBubbleCellData:bubbleCellData])
{
MXKRoomBubbleCellData *cellData = (MXKRoomBubbleCellData*)bubbleCellData;

View File

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

View File

@@ -1596,7 +1596,11 @@ static NSString *const kHTMLATagRegexPattern = @"<a href=\"(.*?)\">([^<]*)</a>";
}
break;
}
case MXEventTypePollStart:
{
displayText = [MXEventContentPollStart modelFromJSON:event.content].question;
break;
}
default:
*error = MXKEventFormatterErrorUnknownEventType;
break;

View File

@@ -31,7 +31,8 @@ typedef NS_ENUM(NSInteger, RoomBubbleCellDataTag)
RoomBubbleCellDataTagKeyVerificationConclusion,
RoomBubbleCellDataTagCall,
RoomBubbleCellDataTagGroupCall,
RoomBubbleCellDataTagRoomCreationIntro
RoomBubbleCellDataTagRoomCreationIntro,
RoomBubbleCellDataTagPoll
};
/**

View File

@@ -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;
}
@@ -873,6 +889,9 @@ NSString *const URLPreviewDidUpdateNotification = @"URLPreviewDidUpdateNotificat
case MXEventTypeCallReject:
shouldAddEvent = NO;
break;
case MXEventTypePollStart:
shouldAddEvent = NO;
break;
case MXEventTypeCustom:
{
if ([event.type isEqualToString:kWidgetMatrixEventTypeString]

View File

@@ -77,6 +77,10 @@ final class RoomCoordinator: NSObject, RoomCoordinatorProtocol {
self.roomViewController = RoomViewController.instantiate()
self.activityIndicatorPresenter = ActivityIndicatorPresenter()
if #available(iOS 14, *) {
PollTimelineProvider.shared.session = parameters.session
}
super.init()
}
@@ -257,10 +261,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()
}
}

View File

@@ -183,6 +183,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

View File

@@ -415,6 +415,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
@@ -2013,7 +2017,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);
@@ -2615,193 +2619,210 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
BOOL showEncryptionBadge = NO;
// Sanity check
if ([cellData conformsToProtocol:@protocol(MXKRoomBubbleCellDataStoring)])
if (![cellData conformsToProtocol:@protocol(MXKRoomBubbleCellDataStoring)])
{
id<MXKRoomBubbleCellDataStoring> bubbleData = (id<MXKRoomBubbleCellDataStoring>)cellData;
return nil;
}
MXKRoomBubbleCellData *roomBubbleCellData;
if ([bubbleData isKindOfClass:MXKRoomBubbleCellData.class])
id<MXKRoomBubbleCellDataStoring> bubbleData = (id<MXKRoomBubbleCellDataStoring>)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;
}
}
}
@@ -3222,7 +3243,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) {
@@ -3231,7 +3253,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
}]];
}
if (!isJitsiCallEvent)
if (!isJitsiCallEvent && selectedEvent.eventType != MXEventTypePollStart)
{
[actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionQuote]
style:UIAlertActionStyleDefault
@@ -3248,7 +3270,7 @@ const NSTimeInterval kResizeComposerAnimationDuration = .05;
}]];
}
if (!isJitsiCallEvent && BuildSettings.messageDetailsAllowShare)
if (!isJitsiCallEvent && BuildSettings.messageDetailsAllowShare && selectedEvent.eventType != MXEventTypePollStart)
{
[actionsMenu addAction:[UIAlertAction actionWithTitle:[VectorL10n roomEventActionShare]
style:UIAlertActionStyleDefault
@@ -3432,7 +3454,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);
@@ -3456,6 +3488,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]
@@ -3618,9 +3672,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];
@@ -3629,15 +3683,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)
@@ -6037,7 +6084,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)
{

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="16097.2" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="16087"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
@@ -17,7 +17,7 @@
<rect key="frame" x="0.0" y="0.0" width="595" height="97"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</view>
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" translatesAutoresizingMaskIntoConstraints="NO" id="5GX-gn-bK1">
<stackView opaque="NO" contentMode="scaleToFill" axis="vertical" distribution="equalSpacing" translatesAutoresizingMaskIntoConstraints="NO" id="5GX-gn-bK1">
<rect key="frame" x="0.0" y="0.0" width="595" height="97"/>
<subviews>
<view hidden="YES" contentMode="scaleToFill" verticalHuggingPriority="251" translatesAutoresizingMaskIntoConstraints="NO" id="u1e-Q2-PhY">
@@ -74,11 +74,11 @@
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="ohU-Sc-mgb">
<rect key="frame" x="46" y="4" width="484" height="30"/>
<rect key="frame" x="46" y="4" width="534" height="30"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
</view>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="User name:" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" minimumFontSize="8" translatesAutoresizingMaskIntoConstraints="NO" id="meG-P8-61b">
<rect key="frame" x="56" y="10" width="474" height="18"/>
<rect key="frame" x="56" y="10" width="524" height="18"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<accessibility key="accessibilityConfiguration" identifier="UserNameLabel"/>
<constraints>
@@ -105,10 +105,10 @@
<rect key="frame" x="0.0" y="0.0" width="595" height="97"/>
<subviews>
<stackView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="4d4-XQ-ido">
<rect key="frame" x="41" y="0.0" width="0.0" height="97"/>
<rect key="frame" x="0.0" y="0.0" width="0.0" height="97"/>
<subviews>
<view hidden="YES" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="uHE-o7-sCe">
<rect key="frame" x="0.0" y="0.0" width="16" height="97"/>
<rect key="frame" x="0.0" y="16" width="16" height="97"/>
<subviews>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="encryption_warning" translatesAutoresizingMaskIntoConstraints="NO" id="Ujc-3c-e5B">
<rect key="frame" x="0.0" y="3" width="16" height="16"/>
@@ -125,37 +125,35 @@
<constraint firstItem="Ujc-3c-e5B" firstAttribute="centerX" secondItem="uHE-o7-sCe" secondAttribute="centerX" id="z4k-EX-K17"/>
</constraints>
</view>
<view hidden="YES" userInteractionEnabled="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="7Y6-Py-paB">
<rect key="frame" x="0.0" y="16" width="50" height="97"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<accessibility key="accessibilityConfiguration" identifier="BubbleInfoContainer"/>
<constraints>
<constraint firstAttribute="width" constant="50" id="LtA-zk-OCc"/>
</constraints>
</view>
</subviews>
<constraints>
<constraint firstItem="uHE-o7-sCe" firstAttribute="height" secondItem="4d4-XQ-ido" secondAttribute="height" id="DhB-EC-rCE"/>
<constraint firstItem="7Y6-Py-paB" firstAttribute="top" secondItem="4d4-XQ-ido" secondAttribute="top" constant="16" id="Wx9-0o-vzm"/>
</constraints>
</stackView>
<view clipsSubviews="YES" contentMode="scaleAspectFit" translatesAutoresizingMaskIntoConstraints="NO" id="oeI-eO-mFK">
<rect key="frame" x="56" y="3" width="474" height="91"/>
<rect key="frame" x="56" y="3" width="524" height="91"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</view>
<view hidden="YES" userInteractionEnabled="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="7Y6-Py-paB">
<rect key="frame" x="530" y="3" width="50" height="91"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<accessibility key="accessibilityConfiguration" identifier="BubbleInfoContainer"/>
<constraints>
<constraint firstAttribute="width" constant="50" id="LtA-zk-OCc"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstItem="oeI-eO-mFK" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="vcq-cR-uBc" secondAttribute="leading" constant="56" id="0Fr-0L-9tU"/>
<constraint firstItem="7Y6-Py-paB" firstAttribute="top" secondItem="vcq-cR-uBc" secondAttribute="top" constant="3" id="16j-F9-tL8"/>
<constraint firstAttribute="bottom" secondItem="oeI-eO-mFK" secondAttribute="bottom" constant="3" id="8M5-uW-82s"/>
<constraint firstItem="oeI-eO-mFK" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="4d4-XQ-ido" secondAttribute="trailing" constant="6" id="9By-U1-wTY"/>
<constraint firstItem="7Y6-Py-paB" firstAttribute="leading" secondItem="oeI-eO-mFK" secondAttribute="trailing" id="9V6-A8-9i0"/>
<constraint firstAttribute="trailing" secondItem="oeI-eO-mFK" secondAttribute="trailing" constant="15" id="Pbe-4d-q6Y"/>
<constraint firstAttribute="bottom" secondItem="4d4-XQ-ido" secondAttribute="bottom" id="Tkw-p1-CYF"/>
<constraint firstItem="4d4-XQ-ido" firstAttribute="leading" secondItem="vcq-cR-uBc" secondAttribute="leading" constant="41" id="cbh-iX-gKz"/>
<constraint firstItem="4d4-XQ-ido" firstAttribute="leading" secondItem="vcq-cR-uBc" secondAttribute="leading" id="cbh-iX-gKz"/>
<constraint firstItem="4d4-XQ-ido" firstAttribute="top" secondItem="vcq-cR-uBc" secondAttribute="top" id="hOM-gq-1au"/>
<constraint firstAttribute="height" priority="250" id="lRu-Kd-3JZ"/>
<constraint firstAttribute="bottom" secondItem="7Y6-Py-paB" secondAttribute="bottom" constant="3" id="lee-yN-381"/>
<constraint firstAttribute="trailing" secondItem="7Y6-Py-paB" secondAttribute="trailing" constant="15" id="rG0-0L-I4O"/>
<constraint firstItem="oeI-eO-mFK" firstAttribute="top" secondItem="vcq-cR-uBc" secondAttribute="top" constant="3" id="uZZ-I6-Xtq"/>
<constraint firstItem="oeI-eO-mFK" firstAttribute="leading" secondItem="vcq-cR-uBc" secondAttribute="leading" priority="750" id="vsh-pW-S46"/>
</constraints>
@@ -227,7 +225,7 @@
<connections>
<outlet property="avatarImageView" destination="yXz-Za-4yR" id="f56-93-gxa"/>
<outlet property="bubbleInfoContainer" destination="7Y6-Py-paB" id="uLv-MM-HIL"/>
<outlet property="bubbleInfoContainerTopConstraint" destination="16j-F9-tL8" id="zxd-pd-SSx"/>
<outlet property="bubbleInfoContainerTopConstraint" destination="Wx9-0o-vzm" id="nLG-nC-lwV"/>
<outlet property="bubbleOverlayContainer" destination="XQw-Mj-NZY" id="6d1-EN-LPY"/>
<outlet property="encryptionImageView" destination="Ujc-3c-e5B" id="7zc-Y7-1jT"/>
<outlet property="encryptionStatusContainerView" destination="uHE-o7-sCe" id="Dl7-QS-WKl"/>
@@ -247,6 +245,6 @@
</view>
</objects>
<resources>
<image name="encryption_warning" width="16" height="16"/>
<image name="encryption_warning" width="12" height="12"/>
</resources>
</document>

View File

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

View File

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

View File

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

View File

@@ -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;

View File

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

View File

@@ -24,7 +24,8 @@ enum MockAppScreens {
MockTemplateRoomListScreenState.self,
MockTemplateRoomChatScreenState.self,
MockUserSuggestionScreenState.self,
MockPollEditFormScreenState.self
MockPollEditFormScreenState.self,
MockPollTimelineScreenState.self
]
}

View File

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

View File

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

View File

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

View File

@@ -59,14 +59,18 @@ struct PollEditForm: View {
ForEach(0..<viewModel.answerOptions.count, id: \.self) { index in
SafeBindingCollectionEnumerator($viewModel.answerOptions, index: index) { binding in
AnswerOptionGroup(text: binding.text, index: index) {
viewModel.send(viewAction: .deleteAnswerOption(viewModel.answerOptions[index]))
withAnimation(.easeInOut(duration: 0.2)) {
viewModel.send(viewAction: .deleteAnswerOption(viewModel.answerOptions[index]))
}
}
}
}
}
Button(VectorL10n.pollEditFormAddOption) {
viewModel.send(viewAction: .addAnswerOption)
withAnimation(.easeInOut(duration: 0.2)) {
viewModel.send(viewAction: .addAnswerOption)
}
}
.disabled(!viewModel.viewState.addAnswerOptionButtonEnabled)
@@ -78,8 +82,13 @@ struct PollEditForm: View {
.buttonStyle(PrimaryActionButtonStyle(enabled: viewModel.viewState.confirmationButtonEnabled))
.disabled(!viewModel.viewState.confirmationButtonEnabled)
}
.animation(.easeInOut(duration: 0.2))
.padding()
.activityIndicator(show: viewModel.viewState.showLoadingIndicator)
.alert(isPresented: $viewModel.showsFailureAlert) {
Alert(title: Text(VectorL10n.pollEditFormPostFailureTitle),
message: Text(VectorL10n.pollEditFormPostFailureSubtitle),
dismissButton: .default(Text(VectorL10n.pollEditFormPostFailureAction)))
}
.frame(minHeight: proxy.size.height) // Make the VStack fill the ScrollView's parent
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
@@ -98,6 +107,7 @@ struct PollEditForm: View {
}
}
.accentColor(theme.colors.accent)
.navigationViewStyle(StackNavigationViewStyle())
}
}

View File

@@ -0,0 +1,143 @@
// 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 MatrixSDK
import Combine
struct PollTimelineCoordinatorParameters {
let session: MXSession
let room: MXRoom
let pollStartEvent: MXEvent
}
@available(iOS 14.0, *)
final class PollTimelineCoordinator: Coordinator, PollAggregatorDelegate {
// MARK: - Properties
// MARK: Private
private let parameters: PollTimelineCoordinatorParameters
private let selectedAnswerIdentifiersSubject = PassthroughSubject<[String], Never>()
private var pollAggregator: PollAggregator
private var pollTimelineViewModel: PollTimelineViewModel!
private var cancellables = Set<AnyCancellable>()
// 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)
}
}

View File

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

View File

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

View File

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

View File

@@ -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<PollTimelineViewState,
PollTimelineStateAction,
PollTimelineViewAction>
@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))
}
}

View File

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

View File

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

View File

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

View File

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

1
changelog.d/5114.feature Normal file
View File

@@ -0,0 +1 @@
Added support for creating, displaying and interacting with polls in the timeline.