From 0e7a48d961cc385c799b2b25dda85d6167912999 Mon Sep 17 00:00:00 2001 From: Alfonso Grillo Date: Thu, 27 Jul 2023 15:34:27 +0200 Subject: [PATCH] "View only" polls in the timeline (#1399) * Add stub methods in RoomTimelineItemFactory * Add PollRoomTimelineView and PollEndRoomTimelineView * Add timeline skeleton * Start poll mapping * Add comment * Refine poll UI * Add feature flags for polls * Cleanup * Delete poll end event * Refine poll mapping * Refine poll layout * Reuse FormRowAccessory * Refine poll bubble layout * Localise strings * Update project * Refine poll preview * Map poll properties --- ElementX.xcodeproj/project.pbxproj | 14 ++- .../images/polls/Contents.json | 6 + .../polls/equalizer.imageset/Contents.json | 12 ++ .../polls/equalizer.imageset/equalizer.pdf | Bin 0 -> 1655 bytes .../en.lproj/Localizable.stringsdict | 16 +++ .../Sources/Application/AppSettings.swift | 4 + ElementX/Sources/Generated/Assets.swift | 1 + ElementX/Sources/Generated/Strings.swift | 4 + .../Form Styles/FormButtonStyles.swift | 2 +- .../Style/TimelineItemBubbledStylerView.swift | 40 ++++--- .../View/Timeline/PollOptionView.swift | 68 +++++++++++ .../View/Timeline/PollRoomTimelineView.swift | 91 +++++++++++++++ .../DeveloperOptionsScreenModels.swift | 1 + .../View/DeveloperOptionsScreen.swift | 6 + .../RoomSummary/RoomEventStringBuilder.swift | 3 +- .../Items/Other/PollRoomTimelineItem.swift | 50 ++++++++ .../RoomTimelineItemFactory.swift | 109 ++++++++++++++---- .../TimelineItems/RoomTimelineItemView.swift | 2 + .../RoomTimelineItemViewState.swift | 8 +- 19 files changed, 394 insertions(+), 43 deletions(-) create mode 100644 ElementX/Resources/Assets.xcassets/images/polls/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/polls/equalizer.imageset/Contents.json create mode 100644 ElementX/Resources/Assets.xcassets/images/polls/equalizer.imageset/equalizer.pdf create mode 100644 ElementX/Sources/Screens/RoomScreen/View/Timeline/PollOptionView.swift create mode 100644 ElementX/Sources/Screens/RoomScreen/View/Timeline/PollRoomTimelineView.swift create mode 100644 ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/PollRoomTimelineItem.swift diff --git a/ElementX.xcodeproj/project.pbxproj b/ElementX.xcodeproj/project.pbxproj index 5b1028fdd3..e516aef7bd 100644 --- a/ElementX.xcodeproj/project.pbxproj +++ b/ElementX.xcodeproj/project.pbxproj @@ -57,6 +57,7 @@ 149D1942DC005D0485FB8D93 /* LoggingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DC1943ADE6A62ED5129D7C8 /* LoggingTests.swift */; }; 14E99D27628B1A6F0CB46FEA /* SeparatorRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6A9F49B3EE59147AF2F70BB /* SeparatorRoomTimelineItem.swift */; }; 152AE2B8650FB23AFD2E28B9 /* MockAuthenticationServiceProxy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 65C2B80DD0BF6F10BB5FA922 /* MockAuthenticationServiceProxy.swift */; }; + 153E22E8227F46545E5D681C /* PollRoomTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C936FDD017808FE416742D64 /* PollRoomTimelineView.swift */; }; 155063E980E763D4910EA3CF /* Analytics+SwiftUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4CFE236419E830E8946639C /* Analytics+SwiftUI.swift */; }; 1555A7643D85187D4851040C /* TemplateScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4549FCB53F43DB0B278374BC /* TemplateScreen.swift */; }; 157E5FDDF419C0B2CA7E2C28 /* TimelineItemBubbledStylerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98A2932515EA11D3DD8A3506 /* TimelineItemBubbledStylerView.swift */; }; @@ -289,6 +290,7 @@ 6AD722DD92E465E56D2885AB /* BugReportScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA919F521E9F0EE3638AFC85 /* BugReportScreen.swift */; }; 6B15FF984906AAFCF9DC4F58 /* OnboardingUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C88046D6A070D9827181C4D /* OnboardingUITests.swift */; }; 6B31508C6334C617360C2EAB /* RoomMemberDetailsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC589E641AE46EFB2962534D /* RoomMemberDetailsViewModelTests.swift */; }; + 6B4BF4A6450F55939B49FAEF /* PollOptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67779D9A1B797285A09B7720 /* PollOptionView.swift */; }; 6BB6944443C421C722ED1E7D /* portrait_test_video.mp4 in Resources */ = {isa = PBXBuildFile; fileRef = F2D513D2477B57F90E98EEC0 /* portrait_test_video.mp4 */; }; 6C34237AFB808E38FC8776B9 /* RoomStateEventStringBuilder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D55702474F279D910D2D162 /* RoomStateEventStringBuilder.swift */; }; 6C5A2C454E6C198AB39ED760 /* SharedUserDefaultsKeys.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBA8DC95C079805B0B56E8A9 /* SharedUserDefaultsKeys.swift */; }; @@ -367,6 +369,7 @@ 858C04B62166B5BAFCD20F2D /* TimelineItemMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1BF12A5E7C76777C4BF0F2B /* TimelineItemMenu.swift */; }; 85AFBB433AD56704A880F8A0 /* FramePreferenceKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4798B3B7A1E8AE3901CEE8C6 /* FramePreferenceKey.swift */; }; 85F89F3F320F4FADCFFFE68B /* ServerSelectionScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3059CFA00C67D8787273B20 /* ServerSelectionScreenViewModel.swift */; }; + 864C0D3A4077BF433DBC691F /* PollRoomTimelineItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5281C5CDC4A712265A0B5FBF /* PollRoomTimelineItem.swift */; }; 864C69CF951BF36D25BE0C03 /* DeveloperOptionsScreenViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D0A27607AB09784C8501B5C /* DeveloperOptionsScreenViewModelTests.swift */; }; 8658F5034EAD7357CE7F9AC7 /* MatrixUserShareLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 50E31AB0E77BB70E2BC77463 /* MatrixUserShareLink.swift */; }; 86675910612A12409262DFBD /* SessionVerificationStateMachineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1C22B1B5FA3A765EADB2CC9 /* SessionVerificationStateMachineTests.swift */; }; @@ -1036,6 +1039,7 @@ 51C454AE59914B551A6D02C0 /* UserProfileProxy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileProxy.swift; sourceTree = ""; }; 52135BD9E0E7A091688F627A /* MessageForwardingScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenModels.swift; sourceTree = ""; }; 5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreen.swift; sourceTree = ""; }; + 5281C5CDC4A712265A0B5FBF /* PollRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollRoomTimelineItem.swift; sourceTree = ""; }; 52BD6ED18E2EB61E28C340AD /* AttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedString.swift; sourceTree = ""; }; 52D7074991B3267B26D89B22 /* MockRoomTimelineController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineController.swift; sourceTree = ""; }; 53482ECA4B6633961EC224F5 /* ScrollViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewAdapter.swift; sourceTree = ""; }; @@ -1090,6 +1094,7 @@ 66901977F6469D03C333DF32 /* RoomNotificationSettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenUITests.swift; sourceTree = ""; }; 669F35C505ACE1110589F875 /* MediaUploadingPreprocessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadingPreprocessor.swift; sourceTree = ""; }; 66F2402D738694F98729A441 /* RoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineProvider.swift; sourceTree = ""; }; + 67779D9A1B797285A09B7720 /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = ""; }; 6861FE915C7B5466E6962BBA /* StartChatScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreen.swift; sourceTree = ""; }; 686BCFA37AC6C67FF973CE67 /* OnboardingBackgroundImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingBackgroundImage.swift; sourceTree = ""; }; 693E16574C6F7F9FA1015A8C /* Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = ""; }; @@ -1360,6 +1365,7 @@ C833673B334A0651AB46F30B /* StaticLocationScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticLocationScreenViewModelTests.swift; sourceTree = ""; }; C843CF833BF6485B64AC87E1 /* AppRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouter.swift; sourceTree = ""; }; C8F2A7A4E3F5060F52ACFFB0 /* RedactedRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineView.swift; sourceTree = ""; }; + C936FDD017808FE416742D64 /* PollRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollRoomTimelineView.swift; sourceTree = ""; }; C99FDEEB71173C4C6FA2734C /* UserSessionFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinator.swift; sourceTree = ""; }; CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinator.swift; sourceTree = ""; }; CA2A71915C1F075E403F559C /* InvitesScreenCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenCell.swift; sourceTree = ""; }; @@ -3126,6 +3132,7 @@ children = ( A7C4EA55DA62F9D0F984A2AE /* CollapsibleTimelineItem.swift */, 5351EBD7A0B9610548E4B7B2 /* EncryptedRoomTimelineItem.swift */, + 5281C5CDC4A712265A0B5FBF /* PollRoomTimelineItem.swift */, E6E6BDF9D26DB05C88901416 /* RedactedRoomTimelineItem.swift */, B16048D30F0438731C41F775 /* StateRoomTimelineItem.swift */, 818695BED971753243FEF897 /* StickerRoomTimelineItem.swift */, @@ -3194,6 +3201,8 @@ 772334731A8BF8E6D90B194D /* LocationRoomTimelineView.swift */, B5B243E7818E5E9F6A4EDC7A /* NoticeRoomTimelineView.swift */, 42EEA67A6796BDC2761619C5 /* PaginationIndicatorRoomTimelineView.swift */, + 67779D9A1B797285A09B7720 /* PollOptionView.swift */, + C936FDD017808FE416742D64 /* PollRoomTimelineView.swift */, B6311F21F911E23BE4DF51B4 /* ReadMarkerRoomTimelineView.swift */, C8F2A7A4E3F5060F52ACFFB0 /* RedactedRoomTimelineView.swift */, 6390A6DC140CA3D6865A66FF /* SeparatorRoomTimelineView.swift */, @@ -3661,7 +3670,7 @@ path = Timeline; sourceTree = ""; }; - "TEMP_1004AC97-C57C-4A90-9A10-6D1551087256" /* element-x-ios */ = { + "TEMP_F71C267F-2DD4-418B-8D20-9C82F28F166B" /* element-x-ios */ = { isa = PBXGroup; children = ( 41553551C55AD59885840F0E /* secrets.xcconfig */, @@ -4495,6 +4504,9 @@ 962A4F8AD6312804E2C6BB6E /* PhotoLibraryPicker.swift in Sources */, 9D79B94493FB32249F7E472F /* PlaceholderAvatarImage.swift in Sources */, 1BA04D05EBC6646958B1BE60 /* PlaceholderScreenCoordinator.swift in Sources */, + 6B4BF4A6450F55939B49FAEF /* PollOptionView.swift in Sources */, + 864C0D3A4077BF433DBC691F /* PollRoomTimelineItem.swift in Sources */, + 153E22E8227F46545E5D681C /* PollRoomTimelineView.swift in Sources */, DF504B10A4918F971A57BEF2 /* PostHogAnalyticsClient.swift in Sources */, 2835FD52F3F618D07F799B3D /* Publisher.swift in Sources */, 9095B9E40DB5CF8BA26CE0D8 /* ReactionsSummaryView.swift in Sources */, diff --git a/ElementX/Resources/Assets.xcassets/images/polls/Contents.json b/ElementX/Resources/Assets.xcassets/images/polls/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/polls/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/polls/equalizer.imageset/Contents.json b/ElementX/Resources/Assets.xcassets/images/polls/equalizer.imageset/Contents.json new file mode 100644 index 0000000000..a8e3b81fd9 --- /dev/null +++ b/ElementX/Resources/Assets.xcassets/images/polls/equalizer.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "equalizer.pdf", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ElementX/Resources/Assets.xcassets/images/polls/equalizer.imageset/equalizer.pdf b/ElementX/Resources/Assets.xcassets/images/polls/equalizer.imageset/equalizer.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ce6547a311097f8c375fd515ad69ebc5d60a55fb GIT binary patch literal 1655 zcmZXVPj8z*5XJBFDdu7+IRsc^AeN#?iQQCHTh%4KMLiI_aj5vy0;$q|`pjbXEx{bD z@o#tDyjj-c#cq3dp}d4halp;vFJivB;_GYD_1=7?<&^thbbaWbITVDz@pc;!sX8>R}%O1vQ6I)})SS27{$z=mQ1NB7A`4 z^h^)|tthnJfT29}K_HM57%Y~KB~}#bFa!yZT;A{aLcm}KN+76b@=i|&45E`@z8#y* z=_puRTit2`u_~7VF|-AZJwVf4A0WA1c;A5TsyHTHBcx%%>$%VaEg}6d{-zbv&1lQiG7%1$d members + common_poll_votes_count + + NSStringLocalizedFormatKey + %#@COUNT@ + COUNT + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + one + 1 vote + other + %d votes + + notification_compat_summary_line_for_room NSStringLocalizedFormatKey diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 1cc20c5c44..012ef06623 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -35,6 +35,7 @@ final class AppSettings { case hasShownWelcomeScreen case notificationSettingsEnabled case swiftUITimelineEnabled + case pollsInTimeline } private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier @@ -217,4 +218,7 @@ final class AppSettings { @UserPreference(key: UserDefaultsKeys.swiftUITimelineEnabled, defaultValue: false, storageType: .volatile) var swiftUITimelineEnabled + + @UserPreference(key: UserDefaultsKeys.pollsInTimeline, defaultValue: false, storageType: .userDefaults(store)) + var pollsInTimelineEnabled } diff --git a/ElementX/Sources/Generated/Assets.swift b/ElementX/Sources/Generated/Assets.swift index 7e0c88a246..99da368470 100644 --- a/ElementX/Sources/Generated/Assets.swift +++ b/ElementX/Sources/Generated/Assets.swift @@ -41,6 +41,7 @@ internal enum Asset { internal static let locationPin = ImageAsset(name: "images/location-pin") internal static let locationPointerFull = ImageAsset(name: "images/location-pointer-full") internal static let locationPointer = ImageAsset(name: "images/location-pointer") + internal static let equalizer = ImageAsset(name: "images/equalizer") internal static let timelineComposerSendMessage = ImageAsset(name: "images/timeline-composer-send-message") internal static let timelineReactionAddMore = ImageAsset(name: "images/timeline-reaction-add-more") internal static let waitingGradient = ImageAsset(name: "images/waiting-gradient") diff --git a/ElementX/Sources/Generated/Strings.swift b/ElementX/Sources/Generated/Strings.swift index 2e83f1b429..f23ac42df4 100644 --- a/ElementX/Sources/Generated/Strings.swift +++ b/ElementX/Sources/Generated/Strings.swift @@ -208,6 +208,10 @@ public enum L10n { public static var commonPeople: String { return L10n.tr("Localizable", "common_people") } /// Permalink public static var commonPermalink: String { return L10n.tr("Localizable", "common_permalink") } + /// Plural format key: "%#@COUNT@" + public static func commonPollVotesCount(_ p1: Int) -> String { + return L10n.tr("Localizable", "common_poll_votes_count", p1) + } /// Privacy policy public static var commonPrivacyPolicy: String { return L10n.tr("Localizable", "common_privacy_policy") } /// Reactions diff --git a/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift b/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift index a11d6e8a7c..80226570f4 100644 --- a/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift +++ b/ElementX/Sources/Other/SwiftUI/Form Styles/FormButtonStyles.swift @@ -66,7 +66,7 @@ struct FormRowAccessory: View { } } - private init(kind: Kind) { + init(kind: Kind) { self.kind = kind } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index a92d08bbd5..4f62c088fd 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -196,8 +196,6 @@ struct TimelineItemBubbledStylerView: View { case .vertical: GridRow { localizedSendInfo - .padding(.bottom, 4) - .padding(.trailing, 4) .gridColumnAlignment(.trailing) } } @@ -284,7 +282,7 @@ struct TimelineItemBubbledStylerView: View { } private extension View { - func bubbleStyle(insets: CGFloat, color: Color? = nil, cornerRadius: CGFloat = 12, corners: UIRectCorner) -> some View { + func bubbleStyle(insets: EdgeInsets, color: Color? = nil, cornerRadius: CGFloat = 12, corners: UIRectCorner) -> some View { padding(insets) .background(color) .cornerRadius(cornerRadius, corners: corners) @@ -293,18 +291,18 @@ private extension View { // Describes how the content and the send info should be arranged inside a bubble private enum BubbleSendInfoLayoutType { - case horizontal - case vertical + case horizontal(spacing: CGFloat = 4) + case vertical(spacing: CGFloat = 4) case overlay(capsuleStyle: Bool) var layout: AnyLayout { let layout: any Layout switch self { - case .horizontal: - layout = HStackLayout(alignment: .bottom, spacing: 4) - case .vertical: - layout = GridLayout(alignment: .leading, verticalSpacing: 4) + case .horizontal(let spacing): + layout = HStackLayout(alignment: .bottom, spacing: spacing) + case .vertical(let spacing): + layout = GridLayout(alignment: .leading, verticalSpacing: spacing) case .overlay: layout = ZStackLayout(alignment: .bottomTrailing) } @@ -329,23 +327,25 @@ private extension EventBasedTimelineItemProtocol { // The insets for the full bubble content. // Padding affecting just the "send info" should be added inside `layoutedLocalizedSendInfo` - var bubbleInsets: CGFloat { - let defaultPadding: CGFloat = 8 + var bubbleInsets: EdgeInsets { + let defaultInsets: EdgeInsets = .init(around: 8) switch self { case is ImageRoomTimelineItem, is VideoRoomTimelineItem, is StickerRoomTimelineItem: - return 0 + return .zero + case is PollRoomTimelineItem: + return .init(top: 12, leading: 12, bottom: 4, trailing: 12) case let locationTimelineItem as LocationRoomTimelineItem: - return locationTimelineItem.content.geoURI == nil ? defaultPadding : 0 + return locationTimelineItem.content.geoURI == nil ? defaultInsets : .zero default: - return defaultPadding + return defaultInsets } } var bubbleSendInfoLayoutType: BubbleSendInfoLayoutType { - let defaultTimestampLayout: BubbleSendInfoLayoutType = .horizontal + let defaultTimestampLayout: BubbleSendInfoLayoutType = .horizontal() switch self { case is TextBasedRoomTimelineItem: @@ -356,6 +356,8 @@ private extension EventBasedTimelineItemProtocol { return .overlay(capsuleStyle: true) case let locationTimelineItem as LocationRoomTimelineItem: return .overlay(capsuleStyle: locationTimelineItem.content.geoURI != nil) + case is PollRoomTimelineItem: + return .vertical(spacing: 16) default: return defaultTimestampLayout } @@ -415,3 +417,11 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider { .environmentObject(viewModel.context) } } + +private extension EdgeInsets { + init(around: CGFloat) { + self.init(top: around, leading: around, bottom: around, trailing: around) + } + + static var zero: Self = .init(around: 0) +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/PollOptionView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/PollOptionView.swift new file mode 100644 index 0000000000..08981f1960 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/PollOptionView.swift @@ -0,0 +1,68 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct PollOptionView: View { + let pollOption: Poll.Option + + var body: some View { + HStack(alignment: .top, spacing: 8) { + FormRowAccessory(kind: .multipleSelection(isSelected: pollOption.isSelected)) + + VStack(spacing: 10) { + HStack(alignment: .lastTextBaseline) { + Text(pollOption.text) + .frame(maxWidth: .infinity, alignment: .leading) + + Text(L10n.commonPollVotesCount(pollOption.votes)) + .font(.compound.bodySM) + .foregroundColor(.compound.textSecondary) + } + + progressView + } + } + } + + // MARK: - Private + + private var progressView: some View { + ProgressView(value: Double(pollOption.votes) / Double(pollOption.allVotes)) + .progressViewStyle(LinearProgressViewStyle(tint: .compound.textPrimary)) + } +} + +struct PollOptionView_Previews: PreviewProvider { + static var previews: some View { + VStack { + Group { + PollOptionView(pollOption: .init(id: "1", + text: "Italian 🇮🇹", + votes: 1, + allVotes: 10, + isSelected: true)) + + PollOptionView(pollOption: .init(id: "2", + text: "Chinese 🇨🇳", + votes: 9, + allVotes: 10, + isSelected: false)) + } + .padding() + } + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/PollRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/PollRoomTimelineView.swift new file mode 100644 index 0000000000..83a506e652 --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/PollRoomTimelineView.swift @@ -0,0 +1,91 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import SwiftUI + +struct PollRoomTimelineView: View { + let timelineItem: PollRoomTimelineItem + @Environment(\.timelineStyle) var timelineStyle + + var body: some View { + TimelineStyler(timelineItem: timelineItem) { + VStack(alignment: .leading, spacing: 16) { + questionView + + ForEach(poll.options, id: \.id) { option in + Button { } label: { + PollOptionView(pollOption: option) + } + } + } + .frame(maxWidth: 450) + } + } + + // MARK: - Private + + private var poll: Poll { + timelineItem.poll + } + + private var questionView: some View { + HStack(spacing: 4) { + Image(Asset.Images.equalizer.name) + + Text(poll.question) + .font(.compound.bodyLGSemibold) + } + } +} + +struct PollRoomTimelineView_Previews: PreviewProvider { + static let viewModel = RoomScreenViewModel.mock + + static var previews: some View { + PollRoomTimelineView(timelineItem: .init(id: .random, + poll: .mock, + body: "Foo", + timestamp: "Now", + isOutgoing: false, + isEditable: false, + sender: .init(id: "Bob"), + properties: .init())) + .environment(\.timelineStyle, .bubbles) + .environmentObject(viewModel.context) + .previewDisplayName("Poll bubble style") + + PollRoomTimelineView(timelineItem: .init(id: .random, + poll: .mock, + body: "Foo", + timestamp: "Now", + isOutgoing: false, + isEditable: false, + sender: .init(id: "Bob"), + properties: .init())) + .environment(\.timelineStyle, .plain) + .environmentObject(viewModel.context) + .previewDisplayName("Poll plain style") + } +} + +private extension Poll { + static let mock: Self = .init(question: "Do you like polls?", + pollKind: .disclosed, + maxSelections: 1, + options: [.init(id: "1", text: "Yes", votes: 1, allVotes: 3, isSelected: true), .init(id: "2", text: "No", votes: 2, allVotes: 3, isSelected: false)], + votes: [:], + endDate: nil) +} diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index f1a35d8931..a3255f19d7 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -48,6 +48,7 @@ protocol DeveloperOptionsProtocol: AnyObject { var readReceiptsEnabled: Bool { get set } var notificationSettingsEnabled: Bool { get set } var swiftUITimelineEnabled: Bool { get set } + var pollsInTimelineEnabled: Bool { get set } } extension AppSettings: DeveloperOptionsProtocol { } diff --git a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift index 2a564fea64..58f74e649b 100644 --- a/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift +++ b/ElementX/Sources/Screens/Settings/DeveloperOptionsScreen/View/DeveloperOptionsScreen.swift @@ -50,6 +50,12 @@ struct DeveloperOptionsScreen: View { } } + Section("Polls") { + Toggle(isOn: $context.pollsInTimelineEnabled) { + Text("View polls in timeline") + } + } + Section { Button { showConfetti = true diff --git a/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift b/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift index ce6d444709..2cff2804f1 100644 --- a/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift +++ b/ElementX/Sources/Services/Room/RoomSummary/RoomEventStringBuilder.swift @@ -23,7 +23,7 @@ struct RoomEventStringBuilder { self.stateEventStringBuilder = stateEventStringBuilder } - // swiftlint:disable:next cyclomatic_complexity + // swiftlint:disable:next cyclomatic_complexity function_body_length func buildAttributedString(for eventItemProxy: EventTimelineItemProxy) -> AttributedString? { let sender = eventItemProxy.sender let isOutgoing = eventItemProxy.isOwn @@ -77,6 +77,7 @@ struct RoomEventStringBuilder { memberIsYou: isOutgoing) .map(AttributedString.init) case .poll, .pollEnd: + // The Rust SDK doesn't support poll events as room summaries yet return nil } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/PollRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/PollRoomTimelineItem.swift new file mode 100644 index 0000000000..7e6e6d80bb --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/PollRoomTimelineItem.swift @@ -0,0 +1,50 @@ +// +// Copyright 2023 New Vector Ltd +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +import Foundation + +struct PollRoomTimelineItem: Equatable, EventBasedTimelineItemProtocol { + let id: TimelineItemIdentifier + let poll: Poll + let body: String + let timestamp: String + let isOutgoing: Bool + let isEditable: Bool + let sender: TimelineItemSender + var properties: RoomTimelineItemProperties +} + +struct Poll: Equatable { + let question: String + let pollKind: Kind + let maxSelections: Int + let options: [Option] + let votes: [String: [String]] + let endDate: Date? + + enum Kind: Equatable { + case disclosed + case undisclosed + } + + struct Option: Equatable { + let id: String + let text: String + let votes: Int + let allVotes: Int + let isSelected: Bool + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 2963a05420..3d6074ffda 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -57,28 +57,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { case .failedToParseState(let eventType, _, let error): return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing) case .message: - guard let messageTimelineItem = eventItemProxy.content.asMessage() else { fatalError("Invalid message timeline item: \(eventItemProxy)") } - - switch messageTimelineItem.msgtype() { - case .text(content: let content): - return buildTextTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) - case .image(content: let content): - return buildImageTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) - case .video(let content): - return buildVideoTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) - case .file(let content): - return buildFileTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) - case .notice(content: let content): - return buildNoticeTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) - case .emote(content: let content): - return buildEmoteTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) - case .audio(let content): - return buildAudioTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) - case .location(let content): - return buildLocationTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) - case .none: - return nil - } + return buildMessageTimelineItem(eventItemProxy, isOutgoing) case .state(let stateKey, let content): return buildStateTimelineItem(for: eventItemProxy, state: content, stateKey: stateKey, isOutgoing: isOutgoing) case .roomMembership(userId: let userID, change: let change): @@ -90,12 +69,44 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { avatarURLString: avatarUrl, previousAvatarURLString: prevAvatarUrl, isOutgoing: isOutgoing) - case .poll, .pollEnd: + case .poll(question: let question, kind: let kind, maxSelections: let maxSelections, answers: let answers, votes: let votes, endTime: let endTime): + guard ServiceLocator.shared.settings.pollsInTimelineEnabled else { + return nil + } + return buildPollTimelineItem(question, kind, maxSelections, answers, votes, endTime, eventItemProxy, isOutgoing) + case .pollEnd: return nil } } // MARK: - Message Events + + private func buildMessageTimelineItem(_ eventItemProxy: EventTimelineItemProxy, _ isOutgoing: Bool) -> RoomTimelineItemProtocol? { + guard let messageTimelineItem = eventItemProxy.content.asMessage() else { + fatalError("Invalid message timeline item: \(eventItemProxy)") + } + + switch messageTimelineItem.msgtype() { + case .text(content: let content): + return buildTextTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + case .image(content: let content): + return buildImageTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + case .video(let content): + return buildVideoTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + case .file(let content): + return buildFileTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + case .notice(content: let content): + return buildNoticeTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + case .emote(content: let content): + return buildEmoteTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + case .audio(let content): + return buildAudioTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + case .location(let content): + return buildLocationTimelineItem(for: eventItemProxy, messageTimelineItem, content, isOutgoing) + case .none: + return nil + } + } private func buildUnsupportedTimelineItem(_ eventItemProxy: EventTimelineItemProxy, _ eventType: String, @@ -309,6 +320,47 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { deliveryStatus: eventItemProxy.deliveryStatus, orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts))) } + + // swiftlint:disable:next function_parameter_count + private func buildPollTimelineItem(_ question: String, + _ pollKind: PollKind, + _ maxSelections: UInt64, + _ answers: [PollAnswer], + _ votes: [String: [String]], + _ endTime: UInt64?, + _ eventItemProxy: EventTimelineItemProxy, + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { + let allVotes = votes.reduce(0) { count, pair in + count + pair.value.count + } + + let options = answers.map { answer in + Poll.Option(id: answer.id, + text: answer.text, + votes: votes[answer.id]?.count ?? 0, + allVotes: allVotes, + isSelected: votes[answer.id]?.contains(userID) ?? false) + } + + let poll = Poll(question: question, + pollKind: .init(pollKind: pollKind), + maxSelections: Int(maxSelections), + options: options, + votes: votes, + endDate: endTime.map { Date(timeIntervalSince1970: TimeInterval($0 / 1000)) }) + + return PollRoomTimelineItem(id: eventItemProxy.id, + poll: poll, + body: poll.question, + timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), + isOutgoing: isOutgoing, + isEditable: eventItemProxy.isEditable, + sender: eventItemProxy.sender, + properties: RoomTimelineItemProperties(isEdited: false, + reactions: aggregateReactions(eventItemProxy.reactions), + deliveryStatus: eventItemProxy.deliveryStatus, + orderedReadReceipts: orderReadReceipts(eventItemProxy.readReceipts))) + } private func aggregateReactions(_ reactions: [Reaction]) -> [AggregatedReaction] { reactions.map { reaction in @@ -552,3 +604,14 @@ private extension LocationRoomTimelineItemContent.AssetType { } } } + +private extension Poll.Kind { + init(pollKind: MatrixRustSDK.PollKind) { + switch pollKind { + case .disclosed: + self = .disclosed + case .undisclosed: + self = .undisclosed + } + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift index 4b874c7fb3..8ab3307cb2 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemView.swift @@ -77,6 +77,8 @@ struct RoomTimelineItemView: View { CollapsibleRoomTimelineView(timelineItem: item) case .location(let item): LocationRoomTimelineView(timelineItem: item) + case .poll(let item): + PollRoomTimelineView(timelineItem: item) } } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift index f65497e461..e8cd0a0a06 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemViewState.swift @@ -68,6 +68,7 @@ enum RoomTimelineItemType: Equatable { case state(StateRoomTimelineItem) case group(CollapsibleTimelineItem) case location(LocationRoomTimelineItem) + case poll(PollRoomTimelineItem) // swiftlint:disable:next cyclomatic_complexity init(item: RoomTimelineItemProtocol) { @@ -110,6 +111,8 @@ enum RoomTimelineItemType: Equatable { self = .group(item) case let item as LocationRoomTimelineItem: self = .location(item) + case let item as PollRoomTimelineItem: + self = .poll(item) default: fatalError("Unknown timeline item") } @@ -135,7 +138,8 @@ enum RoomTimelineItemType: Equatable { .encryptedHistory(let item as RoomTimelineItemProtocol), .state(let item as RoomTimelineItemProtocol), .group(let item as RoomTimelineItemProtocol), - .location(let item as RoomTimelineItemProtocol): + .location(let item as RoomTimelineItemProtocol), + .poll(let item as RoomTimelineItemProtocol): return item.id } } @@ -143,7 +147,7 @@ enum RoomTimelineItemType: Equatable { /// Whether or not it is possible to send a reaction to this timeline item. var isReactable: Bool { switch self { - case .text, .image, .video, .audio, .file, .emote, .notice, .sticker, .location: + case .text, .image, .video, .audio, .file, .emote, .notice, .sticker, .location, .poll: return true case .redacted, .encrypted, .unsupported, .state: // Event based items that aren't reactable return false