"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
This commit is contained in:
Alfonso Grillo
2023-07-27 15:34:27 +02:00
committed by GitHub
parent 8f2904701b
commit 0e7a48d961
19 changed files with 483 additions and 43 deletions
+13 -1
View File
@@ -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 = "<group>"; };
52135BD9E0E7A091688F627A /* MessageForwardingScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageForwardingScreenModels.swift; sourceTree = "<group>"; };
5221DFDF809142A2D6AC82B9 /* RoomScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomScreen.swift; sourceTree = "<group>"; };
5281C5CDC4A712265A0B5FBF /* PollRoomTimelineItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollRoomTimelineItem.swift; sourceTree = "<group>"; };
52BD6ED18E2EB61E28C340AD /* AttributedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttributedString.swift; sourceTree = "<group>"; };
52D7074991B3267B26D89B22 /* MockRoomTimelineController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MockRoomTimelineController.swift; sourceTree = "<group>"; };
53482ECA4B6633961EC224F5 /* ScrollViewAdapter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollViewAdapter.swift; sourceTree = "<group>"; };
@@ -1090,6 +1094,7 @@
66901977F6469D03C333DF32 /* RoomNotificationSettingsScreenUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomNotificationSettingsScreenUITests.swift; sourceTree = "<group>"; };
669F35C505ACE1110589F875 /* MediaUploadingPreprocessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaUploadingPreprocessor.swift; sourceTree = "<group>"; };
66F2402D738694F98729A441 /* RoomTimelineProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomTimelineProvider.swift; sourceTree = "<group>"; };
67779D9A1B797285A09B7720 /* PollOptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollOptionView.swift; sourceTree = "<group>"; };
6861FE915C7B5466E6962BBA /* StartChatScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartChatScreen.swift; sourceTree = "<group>"; };
686BCFA37AC6C67FF973CE67 /* OnboardingBackgroundImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingBackgroundImage.swift; sourceTree = "<group>"; };
693E16574C6F7F9FA1015A8C /* Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = "<group>"; };
@@ -1360,6 +1365,7 @@
C833673B334A0651AB46F30B /* StaticLocationScreenViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticLocationScreenViewModelTests.swift; sourceTree = "<group>"; };
C843CF833BF6485B64AC87E1 /* AppRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppRouter.swift; sourceTree = "<group>"; };
C8F2A7A4E3F5060F52ACFFB0 /* RedactedRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RedactedRoomTimelineView.swift; sourceTree = "<group>"; };
C936FDD017808FE416742D64 /* PollRoomTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PollRoomTimelineView.swift; sourceTree = "<group>"; };
C99FDEEB71173C4C6FA2734C /* UserSessionFlowCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSessionFlowCoordinator.swift; sourceTree = "<group>"; };
CA28F29C9F93E93CC3C2C715 /* NavigationRootCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationRootCoordinator.swift; sourceTree = "<group>"; };
CA2A71915C1F075E403F559C /* InvitesScreenCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InvitesScreenCell.swift; sourceTree = "<group>"; };
@@ -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 = "<group>";
};
"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 */,
@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,12 @@
{
"images" : [
{
"filename" : "equalizer.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}
@@ -0,0 +1,89 @@
%PDF-1.7
1 0 obj
<< >>
endobj
2 0 obj
<< /Length 3 0 R >>
stream
/DeviceRGB CS
/DeviceRGB cs
q
1.000000 0.000000 -0.000000 1.000000 4.000000 4.000000 cm
0.196078 0.196078 0.196078 scn
8.000000 0.000000 m
9.100000 0.000000 10.000000 0.900000 10.000000 2.000000 c
10.000000 14.000000 l
10.000000 15.100000 9.100000 16.000000 8.000000 16.000000 c
6.900000 16.000000 6.000000 15.100000 6.000000 14.000000 c
6.000000 2.000000 l
6.000000 0.900000 6.900000 0.000000 8.000000 0.000000 c
h
2.000000 0.000000 m
3.100000 0.000000 4.000000 0.900000 4.000000 2.000000 c
4.000000 6.000000 l
4.000000 7.100000 3.100000 8.000000 2.000000 8.000000 c
0.900000 8.000000 0.000000 7.100000 0.000000 6.000000 c
0.000000 2.000000 l
0.000000 0.900000 0.900000 0.000000 2.000000 0.000000 c
h
12.000000 9.000000 m
12.000000 2.000000 l
12.000000 0.900000 12.900000 0.000000 14.000000 0.000000 c
15.100000 0.000000 16.000000 0.900000 16.000000 2.000000 c
16.000000 9.000000 l
16.000000 10.100000 15.100000 11.000000 14.000000 11.000000 c
12.900000 11.000000 12.000000 10.100000 12.000000 9.000000 c
h
f
n
Q
endstream
endobj
3 0 obj
1014
endobj
4 0 obj
<< /Annots []
/Type /Page
/MediaBox [ 0.000000 0.000000 24.000000 24.000000 ]
/Resources 1 0 R
/Contents 2 0 R
/Parent 5 0 R
>>
endobj
5 0 obj
<< /Kids [ 4 0 R ]
/Count 1
/Type /Pages
>>
endobj
6 0 obj
<< /Pages 5 0 R
/Type /Catalog
>>
endobj
xref
0 7
0000000000 65535 f
0000000010 00000 n
0000000034 00000 n
0000001104 00000 n
0000001127 00000 n
0000001300 00000 n
0000001374 00000 n
trailer
<< /ID [ (some) (id) ]
/Root 6 0 R
/Size 7
>>
startxref
1433
%%EOF
@@ -18,6 +18,22 @@
<string>%1$d members</string>
</dict>
</dict>
<key>common_poll_votes_count</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
<string>%#@COUNT@</string>
<key>COUNT</key>
<dict>
<key>NSStringFormatSpecTypeKey</key>
<string>NSStringPluralRuleType</string>
<key>NSStringFormatValueTypeKey</key>
<string>d</string>
<key>one</key>
<string>1 vote</string>
<key>other</key>
<string>%d votes</string>
</dict>
</dict>
<key>notification_compat_summary_line_for_room</key>
<dict>
<key>NSStringLocalizedFormatKey</key>
@@ -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
}
+1
View File
@@ -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")
+4
View File
@@ -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
@@ -66,7 +66,7 @@ struct FormRowAccessory: View {
}
}
private init(kind: Kind) {
init(kind: Kind) {
self.kind = kind
}
}
@@ -196,8 +196,6 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
case .vertical:
GridRow {
localizedSendInfo
.padding(.bottom, 4)
.padding(.trailing, 4)
.gridColumnAlignment(.trailing)
}
}
@@ -284,7 +282,7 @@ struct TimelineItemBubbledStylerView<Content: View>: 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)
}
@@ -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()
}
}
}
@@ -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)
}
@@ -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 { }
@@ -50,6 +50,12 @@ struct DeveloperOptionsScreen: View {
}
}
Section("Polls") {
Toggle(isOn: $context.pollsInTimelineEnabled) {
Text("View polls in timeline")
}
}
Section {
Button {
showConfetti = true
@@ -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
}
}
@@ -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
}
}
@@ -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
}
}
}
@@ -77,6 +77,8 @@ struct RoomTimelineItemView: View {
CollapsibleRoomTimelineView(timelineItem: item)
case .location(let item):
LocationRoomTimelineView(timelineItem: item)
case .poll(let item):
PollRoomTimelineView(timelineItem: item)
}
}
@@ -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