From bf19e3e00b40c3b2bc8e0a4e87fa77e73d051d7f Mon Sep 17 00:00:00 2001 From: Stefan Ceriu Date: Wed, 15 Feb 2023 17:14:50 +0200 Subject: [PATCH] Made room state event collapsing configurable, expose it in the developer menu. Allow the UserSetting property wrappers to only store values in memory (+11 squashed commits) Squashed commits: [42e45fc] Even more tweaks following code review [5dcd5be] Add swift-algoritms and switch bubbling detection to its `chunked` method [4ac70ed] Move the groupBy implementation to Collection instead of Array [6aeffc3] Tweaks following code review [0ca5ac2] Bubbles working again, grouping computed closer to the UI level [3a66030] Refactor how timeline items are built in the RoomTimelineController [57d51e9] Remove grouping from timeline items [8608950] Remove the RoomTimelineViewFactory, update the GroupRoomTimelineView [9e52e61] Add array grouping extension, unit tests and switched the timeline controller to it [7d213a1] First attempt [90bb1d7] Remove now unused `RoomTimelineController` `updatedTimelineItem` calback --- .../xcshareddata/swiftpm/Package.resolved | 18 ++ .../en.lproj/Untranslated.strings | 2 + .../Sources/Application/AppSettings.swift | 16 +- .../Generated/Strings+Untranslated.swift | 4 + ElementX/Sources/Other/Extensions/Array.swift | 69 +++++++ .../Other/UserSettingPropertyWrapper.swift | 40 ++-- .../DeveloperOptionsScreenModels.swift | 8 +- .../DeveloperOptionsScreenViewModel.swift | 15 +- .../View/DeveloperOptionsScreenScreen.swift | 9 + .../RoomScreen/RoomScreenCoordinator.swift | 1 - .../RoomScreen/RoomScreenViewModel.swift | 64 ++++-- .../RoomScreen/View/RoomHeaderView.swift | 4 +- .../Screens/RoomScreen/View/RoomScreen.swift | 1 - .../Style/TimelineItemBubbledStylerView.swift | 39 +++- .../Style/TimelineItemPlainStylerView.swift | 9 +- .../RoomScreen/View/Style/TimelineStyle.swift | 29 +++ .../View/Timeline/AudioRoomTimelineView.swift | 1 - .../CollapsibleRoomTimelineView.swift | 77 +++++++ .../View/Timeline/EmoteRoomTimelineView.swift | 1 - .../Timeline/EncryptedRoomTimelineView.swift | 1 - .../View/Timeline/FileRoomTimelineView.swift | 3 - .../View/Timeline/ImageRoomTimelineView.swift | 3 - .../Timeline/NoticeRoomTimelineView.swift | 1 - .../Timeline/ReadMarkerRoomTimelineView.swift | 10 +- .../Timeline/RedactedRoomTimelineView.swift | 1 - .../View/Timeline/StateRoomTimelineView.swift | 1 - .../Timeline/StickerRoomTimelineView.swift | 3 - .../View/Timeline/TextRoomTimelineView.swift | 1 - .../UnsupportedRoomTimelineView.swift | 1 - .../View/Timeline/VideoRoomTimelineView.swift | 3 - .../RoomScreen/View/TimelineView.swift | 1 - .../Fixtures/RoomTimelineItemFixtures.swift | 14 +- .../RoomTimelineController.swift | 155 +++++++------- .../RoomTimelineControllerProtocol.swift | 1 - .../Services/Timeline/TimelineItemProxy.swift | 14 -- .../EventBasedTimelineItemProtocol.swift | 43 ---- .../Messages/AudioRoomTimelineItem.swift | 1 - .../Messages/EmoteRoomTimelineItem.swift | 1 - .../Items/Messages/FileRoomTimelineItem.swift | 1 - .../Messages/ImageRoomTimelineItem.swift | 1 - .../Messages/NoticeRoomTimelineItem.swift | 1 - .../Items/Messages/TextRoomTimelineItem.swift | 1 - .../Messages/VideoRoomTimelineItem.swift | 1 - .../Items/Other/CollapsibleTimelineItem.swift | 43 ++++ .../Other/EncryptedRoomTimelineItem.swift | 1 - .../Other/RedactedRoomTimelineItem.swift | 1 - .../Items/Other/StateRoomTimelineItem.swift | 1 - .../Items/Other/StickerRoomTimelineItem.swift | 1 - .../Other/UnsupportedRoomTimelineItem.swift | 1 - .../RoomTimelineItemFactory.swift | 85 +++----- .../RoomTimelineItemFactoryProtocol.swift | 3 +- .../RoomTimelineViewFactory.swift | 59 ------ .../RoomTimelineViewFactoryProtocol.swift | 22 -- .../RoomTimelineViewProvider.swift | 189 ++++++++++++------ ElementX/SupportingFiles/target.yml | 1 + UnitTests/Sources/ArrayTests.swift | 45 +++++ UnitTests/Sources/LoggingTests.swift | 12 +- project.yml | 3 + 58 files changed, 679 insertions(+), 458 deletions(-) create mode 100644 ElementX/Sources/Other/Extensions/Array.swift create mode 100644 ElementX/Sources/Screens/RoomScreen/View/Timeline/CollapsibleRoomTimelineView.swift create mode 100644 ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/CollapsibleTimelineItem.swift delete mode 100644 ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactory.swift delete mode 100644 ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactoryProtocol.swift create mode 100644 UnitTests/Sources/ArrayTests.swift diff --git a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 87497c584..8e6949eab 100644 --- a/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ElementX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -117,6 +117,15 @@ "version" : "7.30.2" } }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms", + "state" : { + "revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e", + "version" : "1.0.0" + } + }, { "identity" : "swift-collections", "kind" : "remoteSourceControl", @@ -126,6 +135,15 @@ "version" : "1.0.4" } }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics", + "state" : { + "revision" : "0a5bc04095a675662cf24757cc0640aa2204253b", + "version" : "1.0.2" + } + }, { "identity" : "swift-snapshot-testing", "kind" : "remoteSourceControl", diff --git a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings index 81ebc4eff..ad3caa218 100644 --- a/ElementX/Resources/Localizations/en.lproj/Untranslated.strings +++ b/ElementX/Resources/Localizations/en.lproj/Untranslated.strings @@ -33,6 +33,8 @@ "room_timeline_image_gif" = "GIF"; "room_timeline_read_marker_title" = "New"; +"room_timeline_state_changes" = "%d room changes"; + "noticeRoomInviteAccepted" = "%1$@ accepted the invite"; "noticeRoomInviteAcceptedByYou" = "You accepted the invite"; "noticeRoomKnock" = "%1$@ requested to join"; diff --git a/ElementX/Sources/Application/AppSettings.swift b/ElementX/Sources/Application/AppSettings.swift index 63f212c00..1f619ec9a 100644 --- a/ElementX/Sources/Application/AppSettings.swift +++ b/ElementX/Sources/Application/AppSettings.swift @@ -26,6 +26,7 @@ final class AppSettings: ObservableObject { case isIdentifiedForAnalytics case enableInAppNotifications case pusherProfileTag + case shouldCollapseRoomStateEvents } private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier @@ -61,7 +62,7 @@ final class AppSettings: ObservableObject { /// The last known version of the app that was launched on this device, which is /// used to detect when migrations should be run. When `nil` the app may have been /// deleted between runs so should clear data in the shared container and keychain. - @UserSetting(key: UserDefaultsKeys.lastVersionLaunched.rawValue, defaultValue: nil, storage: store) + @UserSetting(key: UserDefaultsKeys.lastVersionLaunched.rawValue, defaultValue: nil, persistIn: store) var lastVersionLaunched: String? /// The default homeserver address used. This is intentionally a string without a scheme @@ -123,27 +124,30 @@ final class AppSettings: ObservableObject { } /// `true` when the user has opted in to send analytics. - @UserSetting(key: UserDefaultsKeys.enableAnalytics.rawValue, defaultValue: false, storage: store) + @UserSetting(key: UserDefaultsKeys.enableAnalytics.rawValue, defaultValue: false, persistIn: store) var enableAnalytics /// Indicates if the device has already called identify for this session to PostHog. /// This is separate to `enableAnalytics` as logging out leaves analytics /// enabled, but requires the next account to be identified separately. - @UserSetting(key: UserDefaultsKeys.isIdentifiedForAnalytics.rawValue, defaultValue: false, storage: store) + @UserSetting(key: UserDefaultsKeys.isIdentifiedForAnalytics.rawValue, defaultValue: false, persistIn: store) var isIdentifiedForAnalytics // MARK: - Room Screen - @UserSettingRawRepresentable(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: TimelineStyle.bubbles, storage: store) + @UserSettingRawRepresentable(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: TimelineStyle.bubbles, persistIn: store) var timelineStyle + + @UserSetting(key: UserDefaultsKeys.shouldCollapseRoomStateEvents.rawValue, defaultValue: true, persistIn: nil) + var shouldCollapseRoomStateEvents // MARK: - Notifications - @UserSetting(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: true, storage: store) + @UserSetting(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: true, persistIn: store) var enableInAppNotifications /// Tag describing which set of device specific rules a pusher executes. - @UserSetting(key: UserDefaultsKeys.pusherProfileTag.rawValue, defaultValue: nil, storage: store) + @UserSetting(key: UserDefaultsKeys.pusherProfileTag.rawValue, defaultValue: nil, persistIn: store) var pusherProfileTag: String? // MARK: - Other diff --git a/ElementX/Sources/Generated/Strings+Untranslated.swift b/ElementX/Sources/Generated/Strings+Untranslated.swift index 7833f6234..198ad7430 100644 --- a/ElementX/Sources/Generated/Strings+Untranslated.swift +++ b/ElementX/Sources/Generated/Strings+Untranslated.swift @@ -120,6 +120,10 @@ extension ElementL10n { public static func roomTimelineReplyingTo(_ p1: Any) -> String { return ElementL10n.tr("Untranslated", "room_timeline_replying_to", String(describing: p1)) } + /// %d room changes + public static func roomTimelineStateChanges(_ p1: Int) -> String { + return ElementL10n.tr("Untranslated", "room_timeline_state_changes", p1) + } /// Bubbles public static let roomTimelineStyleBubbledLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_bubbled_long_description") /// Modern diff --git a/ElementX/Sources/Other/Extensions/Array.swift b/ElementX/Sources/Other/Extensions/Array.swift new file mode 100644 index 000000000..a4a2e654e --- /dev/null +++ b/ElementX/Sources/Other/Extensions/Array.swift @@ -0,0 +1,69 @@ +// +// 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 + +extension Array { + func groupBy(_ isGroupable: (Element) -> Bool) -> [[Element]] { + var newItems = [[Element]]() + + // Cache groupable states to avoid recomputing them later + let groupableStateByIndex = map(isGroupable) + + var itemsToBeGrouped = [Element]() + for (index, currentItem) in enumerated() { + let previousItem = self[safe: index - 1] + let nextItem = self[safe: index + 1] + + if groupableStateByIndex[safe: index] == true { + // At the begining of a groupable slice, very first message + if previousItem == nil, groupableStateByIndex[safe: index + 1] == true { + itemsToBeGrouped.append(currentItem) + } + // Still at the begining of a groupable slice + else if groupableStateByIndex[safe: index - 1] == false, groupableStateByIndex[safe: index + 1] == true { + itemsToBeGrouped.append(currentItem) + } + // In the middle of a groupable slice + else if !itemsToBeGrouped.isEmpty { + itemsToBeGrouped.append(currentItem) + } + // Solitary groupable item + else { + newItems.append([currentItem]) + } + + // Last item in the list. Try finishing the groupable slice + if nextItem == nil, !itemsToBeGrouped.isEmpty { + newItems.append(itemsToBeGrouped) + itemsToBeGrouped.removeAll() + } + + } else { + // Finished a groupable slice + if !itemsToBeGrouped.isEmpty { + newItems.append(itemsToBeGrouped) + itemsToBeGrouped.removeAll() + } + + // Append the current element too + newItems.append([currentItem]) + } + } + + return newItems + } +} diff --git a/ElementX/Sources/Other/UserSettingPropertyWrapper.swift b/ElementX/Sources/Other/UserSettingPropertyWrapper.swift index d5483a786..e6dba5765 100644 --- a/ElementX/Sources/Other/UserSettingPropertyWrapper.swift +++ b/ElementX/Sources/Other/UserSettingPropertyWrapper.swift @@ -26,25 +26,31 @@ import Foundation @propertyWrapper struct UserSetting { private let key: String - private let defaultValue: Value - private let storage: UserDefaults + private var defaultValue: Value + private let storage: UserDefaults? private let publisher: CurrentValueSubject - init(key: String, defaultValue: Value, storage: UserDefaults = .standard) { + init(key: String, defaultValue: Value, persistIn storage: UserDefaults?) { self.key = key self.defaultValue = defaultValue self.storage = storage - let value = storage.value(forKey: key) as? Value ?? defaultValue + let value = storage?.value(forKey: key) as? Value ?? defaultValue publisher = CurrentValueSubject(value) } var wrappedValue: Value { get { - let value = storage.value(forKey: key) as? Value + let value = storage?.value(forKey: key) as? Value return value ?? defaultValue } set { + guard let storage else { + defaultValue = newValue + publisher.send(defaultValue) + return + } + if let optional = newValue as? AnyOptional, optional.isNil { storage.removeObject(forKey: key) publisher.send(defaultValue) @@ -61,8 +67,8 @@ struct UserSetting { } extension UserSetting where Value: ExpressibleByNilLiteral { - init(key: String, storage: UserDefaults = .standard) { - self.init(key: key, defaultValue: nil, storage: storage) + init(key: String, persistIn storage: UserDefaults?) { + self.init(key: key, defaultValue: nil, persistIn: storage) } } @@ -74,28 +80,34 @@ extension UserSetting where Value: ExpressibleByNilLiteral { @propertyWrapper struct UserSettingRawRepresentable { private let key: String - private let defaultValue: Value - private let storage: UserDefaults + private var defaultValue: Value + private let storage: UserDefaults? private let publisher: CurrentValueSubject - init(key: String, defaultValue: Value, storage: UserDefaults = .standard) { + init(key: String, defaultValue: Value, persistIn storage: UserDefaults?) { self.key = key self.defaultValue = defaultValue self.storage = storage - let value = (storage.value(forKey: key) as? Value.RawValue).flatMap { Value(rawValue: $0) } ?? defaultValue + let value = (storage?.value(forKey: key) as? Value.RawValue).flatMap { Value(rawValue: $0) } ?? defaultValue publisher = CurrentValueSubject(value) } var wrappedValue: Value { get { - guard let value = storage.value(forKey: key) as? Value.RawValue else { + guard let value = storage?.value(forKey: key) as? Value.RawValue else { return defaultValue } return Value(rawValue: value) ?? defaultValue } set { + guard let storage else { + defaultValue = newValue + publisher.send(defaultValue) + return + } + if let optional = newValue as? AnyOptional, optional.isNil { storage.removeObject(forKey: key) publisher.send(newValue) @@ -112,8 +124,8 @@ struct UserSettingRawRepresentable { } extension UserSettingRawRepresentable where Value: ExpressibleByNilLiteral { - init(key: String, storage: UserDefaults = .standard) { - self.init(key: key, defaultValue: nil, storage: storage) + init(key: String, persistIn storage: UserDefaults?) { + self.init(key: key, defaultValue: nil, persistIn: storage) } } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift index 42fafe250..a23f3d155 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenModels.swift @@ -22,6 +22,10 @@ struct DeveloperOptionsScreenViewState: BindableState { var bindings: DeveloperOptionsScreenViewStateBindings } -struct DeveloperOptionsScreenViewStateBindings { } +struct DeveloperOptionsScreenViewStateBindings { + var shouldCollapseRoomStateEvents: Bool +} -enum DeveloperOptionsScreenViewAction { } +enum DeveloperOptionsScreenViewAction { + case changedShouldCollapseRoomStateEvents +} diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift index 72c8dbf71..a6e4c45ac 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/DeveloperOptionsScreenViewModel.swift @@ -20,8 +20,19 @@ typealias DeveloperOptionsScreenViewModelType = StateStoreViewModel Void)? - + init() { - super.init(initialViewState: DeveloperOptionsScreenViewState(bindings: DeveloperOptionsScreenViewStateBindings())) + super.init(initialViewState: DeveloperOptionsScreenViewState(bindings: DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: ServiceLocator.shared.settings.shouldCollapseRoomStateEvents))) + + ServiceLocator.shared.settings.$shouldCollapseRoomStateEvents + .weakAssign(to: \.state.bindings.shouldCollapseRoomStateEvents, on: self) + .store(in: &cancellables) + } + + override func process(viewAction: DeveloperOptionsScreenViewAction) async { + switch viewAction { + case .changedShouldCollapseRoomStateEvents: + ServiceLocator.shared.settings.shouldCollapseRoomStateEvents = state.bindings.shouldCollapseRoomStateEvents + } } } diff --git a/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreenScreen.swift b/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreenScreen.swift index 237beaf9c..75750e936 100644 --- a/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreenScreen.swift +++ b/ElementX/Sources/Screens/DeveloperOptionsScreen/View/DeveloperOptionsScreenScreen.swift @@ -22,6 +22,15 @@ struct DeveloperOptionsScreenScreen: View { var body: some View { Form { + Section { + Toggle(isOn: $context.shouldCollapseRoomStateEvents) { + Text("Collapse room state events") + } + .onChange(of: context.shouldCollapseRoomStateEvents) { _ in + context.send(viewAction: .changedShouldCollapseRoomStateEvents) + } + } + Section { Button { showConfetti = true diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift index 7ed0c9148..adf9f7211 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenCoordinator.swift @@ -36,7 +36,6 @@ final class RoomScreenCoordinator: CoordinatorProtocol { self.parameters = parameters viewModel = RoomScreenViewModel(timelineController: parameters.timelineController, - timelineViewFactory: RoomTimelineViewFactory(), mediaProvider: parameters.mediaProvider, roomName: parameters.roomProxy.displayName ?? parameters.roomProxy.name, roomAvatarUrl: parameters.roomProxy.avatarURL) diff --git a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift index 05ac88d01..ec4f77349 100644 --- a/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift +++ b/ElementX/Sources/Screens/RoomScreen/RoomScreenViewModel.swift @@ -14,6 +14,7 @@ // limitations under the License. // +import Algorithms import Combine import SwiftUI @@ -27,15 +28,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } private let timelineController: RoomTimelineControllerProtocol - private let timelineViewFactory: RoomTimelineViewFactoryProtocol init(timelineController: RoomTimelineControllerProtocol, - timelineViewFactory: RoomTimelineViewFactoryProtocol, mediaProvider: MediaProviderProtocol, roomName: String?, roomAvatarUrl: URL? = nil) { self.timelineController = timelineController - self.timelineViewFactory = timelineViewFactory super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomID, roomTitle: roomName ?? "Unknown room 💥", @@ -52,13 +50,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol switch callback { case .updatedTimelineItems: self.buildTimelineViews() - case .updatedTimelineItem(let itemId): - guard let timelineItem = self.timelineController.timelineItems.first(where: { $0.id == itemId }), - let viewIndex = self.state.items.firstIndex(where: { $0.id == itemId }) else { - return - } - - self.state.items[viewIndex] = timelineViewFactory.buildTimelineViewFor(timelineItem: timelineItem) case .canBackPaginate(let canBackPaginate): if self.state.canBackPaginate != canBackPaginate { self.state.canBackPaginate = canBackPaginate @@ -161,13 +152,57 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol } private func buildTimelineViews() { - let stateItems = timelineController.timelineItems.map { item in - timelineViewFactory.buildTimelineViewFor(timelineItem: item) + var timelineViews = [RoomTimelineViewProvider]() + + let itemsGroupedByTimelineDisplayStyle = timelineController.timelineItems.chunked { current, next in + canGroupItem(timelineItem: current, with: next) } - state.items = stateItems + for itemGroup in itemsGroupedByTimelineDisplayStyle { + guard !itemGroup.isEmpty else { + MXLog.error("Found empty item group") + continue + } + + if itemGroup.count == 1 { + if let firstItem = itemGroup.first { + timelineViews.append(RoomTimelineViewProvider(timelineItem: firstItem, grouping: .single)) + } + } else { + for (index, item) in itemGroup.enumerated() { + if index == 0 { + timelineViews.append(RoomTimelineViewProvider(timelineItem: item, grouping: .first)) + } else if index == itemGroup.count - 1 { + timelineViews.append(RoomTimelineViewProvider(timelineItem: item, grouping: .last)) + } else { + timelineViews.append(RoomTimelineViewProvider(timelineItem: item, grouping: .middle)) + } + } + } + } + + state.items = timelineViews } - + + private func canGroupItem(timelineItem: RoomTimelineItemProtocol, with otherTimelineItem: RoomTimelineItemProtocol) -> Bool { + if timelineItem is CollapsibleTimelineItem || otherTimelineItem is CollapsibleTimelineItem { + return false + } + + guard let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol, + let otherEventTimelineItem = otherTimelineItem as? EventBasedTimelineItemProtocol else { + return false + } + + // State events aren't rendered as messages so shouldn't be grouped. + if eventTimelineItem is StateRoomTimelineItem || otherEventTimelineItem is StateRoomTimelineItem { + return false + } + + // can be improved by adding a date threshold + return otherEventTimelineItem.properties.reactions.isEmpty && eventTimelineItem.sender == otherEventTimelineItem.sender + } + private func sendCurrentMessage() async { guard !state.bindings.composerText.isEmpty else { fatalError("This message should never be empty") @@ -298,7 +333,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol extension RoomScreenViewModel { static let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(), - timelineViewFactory: RoomTimelineViewFactory(), mediaProvider: MockMediaProvider(), roomName: "Preview room") } diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift index 6a7d10dc2..7782df301 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomHeaderView.swift @@ -58,7 +58,6 @@ struct RoomHeaderView_Previews: PreviewProvider { @ViewBuilder static var bodyPlain: some View { let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), - timelineViewFactory: RoomTimelineViewFactory(), mediaProvider: MockMediaProvider(), roomName: "Some Room name", roomAvatarUrl: URL.picturesDirectory) @@ -67,11 +66,10 @@ struct RoomHeaderView_Previews: PreviewProvider { .previewLayout(.sizeThatFits) .padding() } - + @ViewBuilder static var bodyEncrypted: some View { let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), - timelineViewFactory: RoomTimelineViewFactory(), mediaProvider: MockMediaProvider(), roomName: "Some Room name", roomAvatarUrl: nil) diff --git a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift index b249c81ee..920eb5437 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift @@ -125,7 +125,6 @@ struct RoomScreen: View { struct RoomScreen_Previews: PreviewProvider { static var previews: some View { let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), - timelineViewFactory: RoomTimelineViewFactory(), mediaProvider: MockMediaProvider(), roomName: "Preview room") diff --git a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift index 46bfac1eb..0e8f0d27d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Style/TimelineItemBubbledStylerView.swift @@ -19,11 +19,11 @@ import SwiftUI struct TimelineItemBubbledStylerView: View { @EnvironmentObject private var context: RoomScreenViewModel.Context + @Environment(\.timelineGroupStyle) private var timelineStyleGrouping let timelineItem: EventBasedTimelineItemProtocol @ViewBuilder let content: () -> Content - @Environment(\.colorScheme) private var colorScheme @ScaledMetric private var senderNameVerticalPadding = 3 private let cornerRadius: CGFloat = 12 @@ -54,7 +54,7 @@ struct TimelineItemBubbledStylerView: View { @ViewBuilder private var header: some View { - if timelineItem.shouldShowSenderDetails { + if shouldShowSenderDetails { VStack { Spacer() .frame(height: 8) @@ -90,7 +90,7 @@ struct TimelineItemBubbledStylerView: View { var messageBubble: some View { styledContent - .contentShape(.contextMenuPreview, RoundedCornerShape(radius: cornerRadius, corners: timelineItem.roundedCorners)) // Rounded corners for the context menu animation. + .contentShape(.contextMenuPreview, RoundedCornerShape(radius: cornerRadius, corners: roundedCorners)) // Rounded corners for the context menu animation. .contextMenu { context.viewState.contextMenuActionProvider?(timelineItem.id).map { actions in TimelineItemContextMenu(itemID: timelineItem.id, contextMenuActions: actions) @@ -105,7 +105,7 @@ struct TimelineItemBubbledStylerView: View { content() .bubbleStyle(inset: false, cornerRadius: cornerRadius, - corners: timelineItem.roundedCorners) + corners: roundedCorners) } else { VStack(alignment: .trailing, spacing: 4) { content() @@ -119,13 +119,13 @@ struct TimelineItemBubbledStylerView: View { .bubbleStyle(inset: true, color: timelineItem.isOutgoing ? .element.bubblesYou : .element.bubblesNotYou, cornerRadius: cornerRadius, - corners: timelineItem.roundedCorners) + corners: roundedCorners) } } private var messageBubbleTopPadding: CGFloat { guard timelineItem.isOutgoing else { return 0 } - return timelineItem.groupState == .single || timelineItem.groupState == .beginning ? 8 : 0 + return timelineStyleGrouping == .single || timelineStyleGrouping == .first ? 8 : 0 } private var shouldAvoidBubbling: Bool { @@ -135,6 +135,31 @@ struct TimelineItemBubbledStylerView: View { private var alignment: HorizontalAlignment { timelineItem.isOutgoing ? .trailing : .leading } + + private var roundedCorners: UIRectCorner { + switch timelineStyleGrouping { + case .single: + return .allCorners + case .first: + if timelineItem.isOutgoing { + return [.topLeft, .topRight, .bottomLeft] + } else { + return [.topLeft, .topRight, .bottomRight] + } + case .middle: + return timelineItem.isOutgoing ? [.topLeft, .bottomLeft] : [.topRight, .bottomRight] + case .last: + if timelineItem.isOutgoing { + return [.topLeft, .bottomLeft, .bottomRight] + } else { + return [.topRight, .bottomLeft, .bottomRight] + } + } + } + + private var shouldShowSenderDetails: Bool { + timelineStyleGrouping.shouldShowSenderDetails + } } private extension View { @@ -152,7 +177,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider { VStack(alignment: .leading, spacing: 0) { ForEach(1..: View { @EnvironmentObject private var context: RoomScreenViewModel.Context + @Environment(\.timelineGroupStyle) private var timelineStyleGrouping let timelineItem: EventBasedTimelineItemProtocol @ViewBuilder let content: () -> Content @@ -44,7 +45,7 @@ struct TimelineItemPlainStylerView: View { @ViewBuilder private var header: some View { - if timelineItem.shouldShowSenderDetails { + if shouldShowSenderDetails { HStack { TimelineSenderAvatarView(timelineItem: timelineItem) Text(timelineItem.sender.displayName ?? timelineItem.sender.id) @@ -78,6 +79,10 @@ struct TimelineItemPlainStylerView: View { } } } + + private var shouldShowSenderDetails: Bool { + timelineStyleGrouping.shouldShowSenderDetails + } } struct TimelineItemPlainStylerView_Previews: PreviewProvider { @@ -87,7 +92,7 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider { VStack(alignment: .leading, spacing: 0) { ForEach(1.. some View { environment(\.timelineStyle, style) } + + func timelineStyleGrouping(_ grouping: TimelineGroupStyle) -> some View { + environment(\.timelineGroupStyle, grouping) + } } diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift index 81cd08759..df6b9c2d6 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/AudioRoomTimelineView.swift @@ -45,7 +45,6 @@ struct AudioRoomTimelineView_Previews: PreviewProvider { AudioRoomTimelineView(timelineItem: AudioRoomTimelineItem(id: UUID().uuidString, body: "audio.ogg", timestamp: "Now", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/CollapsibleRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/CollapsibleRoomTimelineView.swift new file mode 100644 index 000000000..712a5d59d --- /dev/null +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/CollapsibleRoomTimelineView.swift @@ -0,0 +1,77 @@ +// +// 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 CollapsibleRoomTimelineView: View { + private let timelineItem: CollapsibleTimelineItem + private let timelineViews: [RoomTimelineViewProvider] + + @State private var isExpanded = false + + init(timelineItem: CollapsibleTimelineItem) { + self.timelineItem = timelineItem + timelineViews = timelineItem.items.map { RoomTimelineViewProvider(timelineItem: $0, grouping: .single) } + } + + var body: some View { + DisclosureGroup(ElementL10n.roomTimelineStateChanges(timelineItem.items.count), + isExpanded: $isExpanded) { + Group { + ForEach(timelineViews) { timelineView in + timelineView.body + } + }.transition(.opacity.animation(.elementDefault)) + } + .disclosureGroupStyle(CollapsibleRoomTimelineItemDisclosureGroupStyle()) + .transaction { transaction in + transaction.animation = .noAnimation // Fixes weird animations on the disclosure indicator + } + } +} + +struct CollapsibleRoomTimelineView_Previews: PreviewProvider { + static var previews: some View { + let item = CollapsibleTimelineItem(items: [SeparatorRoomTimelineItem(text: "This is a separator"), + SeparatorRoomTimelineItem(text: "This is another separator")]) + CollapsibleRoomTimelineView(timelineItem: item) + } +} + +private struct CollapsibleRoomTimelineItemDisclosureGroupStyle: DisclosureGroupStyle { + func makeBody(configuration: Configuration) -> some View { + VStack { + HStack(alignment: .center) { + configuration.label + Text(Image(systemName: "chevron.forward")) + .rotationEffect(.degrees(configuration.isExpanded ? 90 : 0)) + .animation(.elementDefault, value: configuration.isExpanded) + } + .frame(maxWidth: .infinity) + .font(.element.footnote) + .foregroundColor(.element.secondaryContent) + .padding(.horizontal, 36) + .padding(.vertical, 8) + .onTapGesture { + configuration.isExpanded.toggle() + } + + if configuration.isExpanded { + configuration.content + } + } + } +} diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift index 9b555310e..7528dc78d 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EmoteRoomTimelineView.swift @@ -55,7 +55,6 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider { EmoteRoomTimelineItem(id: UUID().uuidString, body: text, timestamp: timestamp, - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: senderId)) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EncryptedRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EncryptedRoomTimelineView.swift index 5d9599b9d..8a459659c 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/EncryptedRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/EncryptedRoomTimelineView.swift @@ -69,7 +69,6 @@ struct EncryptedRoomTimelineView_Previews: PreviewProvider { body: text, encryptionType: .unknown, timestamp: timestamp, - groupState: .single, isOutgoing: isOutgoing, isEditable: false, sender: .init(id: senderId)) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FileRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FileRoomTimelineView.swift index c64ca2968..8276d499a 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/FileRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/FileRoomTimelineView.swift @@ -46,7 +46,6 @@ struct FileRoomTimelineView_Previews: PreviewProvider { FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString, body: "document.pdf", timestamp: "Now", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), @@ -56,7 +55,6 @@ struct FileRoomTimelineView_Previews: PreviewProvider { FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString, body: "document.docx", timestamp: "Now", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), @@ -66,7 +64,6 @@ struct FileRoomTimelineView_Previews: PreviewProvider { FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString, body: "document.txt", timestamp: "Now", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift index fa1d2af7a..875b265a4 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ImageRoomTimelineView.swift @@ -74,7 +74,6 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, body: "Some image", timestamp: "Now", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), @@ -83,7 +82,6 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, body: "Some other image", timestamp: "Now", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), @@ -92,7 +90,6 @@ struct ImageRoomTimelineView_Previews: PreviewProvider { ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, body: "Blurhashed image", timestamp: "Now", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift index e7bb59089..2d8e5f9e0 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/NoticeRoomTimelineView.swift @@ -66,7 +66,6 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider { NoticeRoomTimelineItem(id: UUID().uuidString, body: text, timestamp: timestamp, - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: senderId)) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift index c8c16b442..1f756e8ac 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/ReadMarkerRoomTimelineView.swift @@ -41,25 +41,23 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider { static let item = ReadMarkerRoomTimelineItem() static var previews: some View { VStack(alignment: .leading, spacing: 0) { - RoomTimelineViewProvider.separator(.init(text: "Today")) + RoomTimelineViewProvider.separator(.init(text: "Today"), .single) RoomTimelineViewProvider.text(.init(id: "", body: "This is another message", timestamp: "", - groupState: .single, isOutgoing: true, isEditable: false, - sender: .init(id: "1", displayName: "Bob"))) + sender: .init(id: "1", displayName: "Bob")), .single) ReadMarkerRoomTimelineView(timelineItem: item) - RoomTimelineViewProvider.separator(.init(text: "Today")) + RoomTimelineViewProvider.separator(.init(text: "Today"), .single) RoomTimelineViewProvider.text(.init(id: "", body: "This is a message", timestamp: "", - groupState: .single, isOutgoing: false, isEditable: false, - sender: .init(id: "", displayName: "Alice"))) + sender: .init(id: "", displayName: "Alice")), .single) } .padding(.horizontal, 8) .environmentObject(viewModel.context) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift index 79b60a926..c59bf30a7 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/RedactedRoomTimelineView.swift @@ -45,7 +45,6 @@ struct RedactedRoomTimelineView_Previews: PreviewProvider { RedactedRoomTimelineItem(id: UUID().uuidString, body: text, timestamp: timestamp, - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: senderId)) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StateRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StateRoomTimelineView.swift index a02e4d6e3..aa7f61591 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StateRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StateRoomTimelineView.swift @@ -43,7 +43,6 @@ struct StateRoomTimelineView_Previews: PreviewProvider { static let item = StateRoomTimelineItem(id: UUID().uuidString, body: "Alice joined", timestamp: "Now", - groupState: .beginning, isOutgoing: false, isEditable: false, sender: .init(id: "")) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift index 224899ce7..b24560eec 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/StickerRoomTimelineView.swift @@ -59,7 +59,6 @@ struct StickerRoomTimelineView_Previews: PreviewProvider { StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString, body: "Some image", timestamp: "Now", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), @@ -68,7 +67,6 @@ struct StickerRoomTimelineView_Previews: PreviewProvider { StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString, body: "Some other image", timestamp: "Now", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), @@ -77,7 +75,6 @@ struct StickerRoomTimelineView_Previews: PreviewProvider { StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString, body: "Blurhashed image", timestamp: "Now", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift index 97ec154fa..c53a7fc78 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/TextRoomTimelineView.swift @@ -67,7 +67,6 @@ struct TextRoomTimelineView_Previews: PreviewProvider { TextRoomTimelineItem(id: UUID().uuidString, body: text, timestamp: timestamp, - groupState: .single, isOutgoing: isOutgoing, isEditable: isOutgoing, sender: .init(id: senderId)) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/UnsupportedRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UnsupportedRoomTimelineView.swift index c2d60e400..ced134622 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/UnsupportedRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/UnsupportedRoomTimelineView.swift @@ -66,7 +66,6 @@ struct UnsupportedRoomTimelineView_Previews: PreviewProvider { eventType: "Some Event Type", error: "Something went wrong", timestamp: timestamp, - groupState: .single, isOutgoing: isOutgoing, isEditable: false, sender: .init(id: senderId)) diff --git a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift index 5c6463989..84dc3bcb2 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/Timeline/VideoRoomTimelineView.swift @@ -69,7 +69,6 @@ struct VideoRoomTimelineView_Previews: PreviewProvider { VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString, body: "Some video", timestamp: "Now", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), @@ -80,7 +79,6 @@ struct VideoRoomTimelineView_Previews: PreviewProvider { VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString, body: "Some other video", timestamp: "Now", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), @@ -91,7 +89,6 @@ struct VideoRoomTimelineView_Previews: PreviewProvider { VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString, body: "Blurhashed video", timestamp: "Now", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "Bob"), diff --git a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift index 886cacfcc..5fede34a8 100644 --- a/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift +++ b/ElementX/Sources/Screens/RoomScreen/View/TimelineView.swift @@ -83,7 +83,6 @@ struct TimelineView: UIViewControllerRepresentable { struct TimelineTableView_Previews: PreviewProvider { static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), - timelineViewFactory: RoomTimelineViewFactory(), mediaProvider: MockMediaProvider(), roomName: "Preview room") diff --git a/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift b/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift index 01dbdae62..ab64331a0 100644 --- a/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift +++ b/ElementX/Sources/Services/Timeline/Fixtures/RoomTimelineItemFixtures.swift @@ -23,7 +23,6 @@ enum RoomTimelineItemFixtures { TextRoomTimelineItem(id: UUID().uuidString, body: "That looks so good!", timestamp: "10:10 AM", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "", displayName: "Jacob"), @@ -31,7 +30,6 @@ enum RoomTimelineItemFixtures { TextRoomTimelineItem(id: UUID().uuidString, body: "Let’s get lunch soon! New salad place opened up 🥗. When are y’all free? 🤗", timestamp: "10:11 AM", - groupState: .beginning, isOutgoing: false, isEditable: false, sender: .init(id: "", displayName: "Helena"), @@ -41,7 +39,6 @@ enum RoomTimelineItemFixtures { TextRoomTimelineItem(id: UUID().uuidString, body: "I can be around on Wednesday. How about some 🌮 instead? Like https://www.tortilla.co.uk/", timestamp: "10:11 AM", - groupState: .end, isOutgoing: false, isEditable: false, sender: .init(id: "", displayName: "Helena"), @@ -53,21 +50,18 @@ enum RoomTimelineItemFixtures { TextRoomTimelineItem(id: UUID().uuidString, body: "Wow, cool. Ok, lets go the usual place tomorrow?! Is that too soon? Here’s the menu, let me know what you want it’s on me!", timestamp: "5 PM", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "", displayName: "Helena")), TextRoomTimelineItem(id: UUID().uuidString, body: "And John's speech was amazing!", timestamp: "5 PM", - groupState: .beginning, isOutgoing: true, isEditable: true, sender: .init(id: "", displayName: "Bob")), TextRoomTimelineItem(id: UUID().uuidString, body: "New home office set up!", timestamp: "5 PM", - groupState: .end, isOutgoing: true, isEditable: true, sender: .init(id: "", displayName: "Bob"), @@ -79,7 +73,6 @@ enum RoomTimelineItemFixtures { body: "", formattedBody: AttributedStringBuilder().fromHTML("Hol' up
New home office set up!
That's amazing! Congrats 🥳"), timestamp: "5 PM", - groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "", displayName: "Helena")) @@ -88,24 +81,20 @@ enum RoomTimelineItemFixtures { /// A small chunk of events, containing 2 text items. static var smallChunk: [RoomTimelineItemProtocol] { [TextRoomTimelineItem(text: "Hey there 👋", - groupState: .beginning, senderDisplayName: "Alice"), TextRoomTimelineItem(text: "How are you?", - groupState: .end, senderDisplayName: "Alice")] } /// A chunk of events that contains a single text item. static var singleMessageChunk: [RoomTimelineItemProtocol] { [TextRoomTimelineItem(text: "Tap tap tap 🎙️. Is this thing on?", - groupState: .single, senderDisplayName: "Helena")] } /// A single text item. static var incomingMessage: RoomTimelineItemProtocol { TextRoomTimelineItem(text: "Hello, World!", - groupState: .single, senderDisplayName: "Bob") } @@ -196,11 +185,10 @@ enum RoomTimelineItemFixtures { } private extension TextRoomTimelineItem { - init(text: String, groupState: TimelineItemGroupState = .single, senderDisplayName: String) { + init(text: String, senderDisplayName: String) { self.init(id: UUID().uuidString, body: text, timestamp: "10:47 am", - groupState: groupState, isOutgoing: senderDisplayName == "Alice", isEditable: false, sender: .init(id: "", displayName: senderDisplayName)) diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift index 6f81dce99..43ba88b57 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineController.swift @@ -230,61 +230,30 @@ class RoomTimelineController: RoomTimelineControllerProtocol { updateTimelineItems() } - // swiftlint:disable:next cyclomatic_complexity private func updateTimelineItems() { var newTimelineItems = [RoomTimelineItemProtocol]() var canBackPaginate = true var isBackPaginating = false var createdIdentifiers = [String: Bool]() - - for (index, itemProxy) in timelineProvider.itemsPublisher.value.enumerated() { - if Task.isCancelled { - return - } - - let previousItemProxy = timelineProvider.itemsPublisher.value[safe: index - 1] - let nextItemProxy = timelineProvider.itemsPublisher.value[safe: index + 1] - - let groupState = computeGroupState(for: itemProxy, previousItemProxy: previousItemProxy, nextItemProxy: nextItemProxy) - - switch itemProxy { - case .event(let eventItemProxy): - if let timelineItem = timelineItemFactory.buildTimelineItemFor(eventItemProxy: eventItemProxy, groupState: groupState) { - #warning("This works around duplicated items coming out of the SDK, remove once fixed") - if createdIdentifiers[timelineItem.id] == nil { - newTimelineItems.append(timelineItem) - createdIdentifiers[timelineItem.id] = true - } else { - MXLog.error("Found duplicated timeline item, ignoring") - } - } - case .virtual(let virtualItem): - switch virtualItem { - case .dayDivider(let timestamp): - // These components will be replaced by a timestamp in upcoming releases - let date = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000)) - let dateString = date.formatted(date: .complete, time: .omitted) - newTimelineItems.append(SeparatorRoomTimelineItem(text: dateString)) - case .readMarker: - // Don't show the read marker if it's the last item in the timeline - if index != timelineProvider.itemsPublisher.value.indices.last { - newTimelineItems.append(ReadMarkerRoomTimelineItem()) - } - case .loadingIndicator: - newTimelineItems.append(PaginationIndicatorRoomTimelineItem()) - isBackPaginating = true - case .timelineStart: - newTimelineItems.append(TimelineStartRoomTimelineItem(name: roomProxy.displayName ?? roomProxy.name)) - canBackPaginate = false - } - default: - break - } - } - if Task.isCancelled { - return + let collapsibleChunks = timelineProvider.itemsPublisher.value.groupBy { isItemCollapsible($0) } + + for (index, itemGroup) in collapsibleChunks.enumerated() { + if itemGroup.count == 1, let itemProxy = itemGroup.first { + let isLastItem = index == collapsibleChunks.indices.last + if let item = buildTimelineItemFor(itemProxy: itemProxy, isLastItem: isLastItem, createdIdentifiers: &createdIdentifiers, isBackPaginating: &isBackPaginating, canBackPaginate: &canBackPaginate) { + newTimelineItems.append(item) + } + } else { + let items = itemGroup.compactMap { itemProxy in + buildTimelineItemFor(itemProxy: itemProxy, isLastItem: false, createdIdentifiers: &createdIdentifiers, isBackPaginating: &isBackPaginating, canBackPaginate: &canBackPaginate) + } + + if !items.isEmpty { + newTimelineItems.append(CollapsibleTimelineItem(items: items)) + } + } } timelineItems = newTimelineItems @@ -294,49 +263,63 @@ class RoomTimelineController: RoomTimelineControllerProtocol { callbacks.send(.isBackPaginating(isBackPaginating)) } - private func computeGroupState(for itemProxy: TimelineItemProxy, - previousItemProxy: TimelineItemProxy?, - nextItemProxy: TimelineItemProxy?) -> TimelineItemGroupState { - guard let previousItem = previousItemProxy else { - // no previous item, check next item - guard let nextItem = nextItemProxy else { - // no next item neither, this is single - return .single + private func buildTimelineItemFor(itemProxy: TimelineItemProxy, + isLastItem: Bool, + createdIdentifiers: inout [String: Bool], + isBackPaginating: inout Bool, + canBackPaginate: inout Bool) -> RoomTimelineItemProtocol? { + switch itemProxy { + case .event(let eventItemProxy): + if let timelineItem = timelineItemFactory.buildTimelineItemFor(eventItemProxy: eventItemProxy) { + #warning("This works around duplicated items coming out of the SDK, remove once fixed") + if createdIdentifiers[timelineItem.id] == nil { + createdIdentifiers[timelineItem.id] = true + return timelineItem + } else { + MXLog.error("Found duplicated timeline item, ignoring") + } } - guard nextItem.canBeGrouped(with: itemProxy) else { - // there is a next item but can't be grouped, this is single - return .single + case .virtual(let virtualItem): + switch virtualItem { + case .dayDivider(let timestamp): + // These components will be replaced by a timestamp in upcoming releases + let date = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000)) + let dateString = date.formatted(date: .complete, time: .omitted) + return SeparatorRoomTimelineItem(text: dateString) + case .readMarker: + // Don't show the read marker if it's the last item in the timeline + if !isLastItem { + return ReadMarkerRoomTimelineItem() + } + case .loadingIndicator: + isBackPaginating = true + return PaginationIndicatorRoomTimelineItem() + case .timelineStart: + canBackPaginate = false + return TimelineStartRoomTimelineItem(name: roomProxy.displayName ?? roomProxy.name) } - // next will be grouped with this one, this is the start - return .beginning + case .unknown: + return nil } - - guard let nextItem = nextItemProxy else { - // no next item - guard itemProxy.canBeGrouped(with: previousItem) else { - // there is a previous item but can't be grouped, this is single - return .single + + return nil + } + + private func isItemCollapsible(_ item: TimelineItemProxy) -> Bool { + if !ServiceLocator.shared.settings.shouldCollapseRoomStateEvents { + return false + } + + if case let .event(eventItem) = item { + switch eventItem.content.kind() { + case .profileChange, .roomMembership, .state: + return true + default: + return false } - // will be grouped with previous, this is the end - return .end } - - guard itemProxy.canBeGrouped(with: previousItem) else { - guard nextItem.canBeGrouped(with: itemProxy) else { - // there is a next item but can't be grouped, this is single - return .single - } - // next will be grouped with this one, this is the start - return .beginning - } - - guard nextItem.canBeGrouped(with: itemProxy) else { - // there is a next item but can't be grouped, this is the end - return .end - } - - // next will be grouped with this one, this is the start - return .middle + + return false } private func loadVideoForTimelineItem(_ timelineItem: VideoRoomTimelineItem) async { diff --git a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift index 7ffe4ff66..64165bf5b 100644 --- a/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineController/RoomTimelineControllerProtocol.swift @@ -20,7 +20,6 @@ import UIKit enum RoomTimelineControllerCallback { case updatedTimelineItems - case updatedTimelineItem(_ itemId: String) case canBackPaginate(Bool) case isBackPaginating(Bool) } diff --git a/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift b/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift index ca44cfb05..55f509053 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItemProxy.swift @@ -32,20 +32,6 @@ enum TimelineItemProxy { self = .unknown(item) } } - - func canBeGrouped(with previousItemProxy: TimelineItemProxy) -> Bool { - guard case let .event(selfEventItemProxy) = self, case let .event(previousEventItemProxy) = previousItemProxy else { - return false - } - - // State events aren't rendered as messages so shouldn't be grouped. - if selfEventItemProxy.isRoomState || previousEventItemProxy.isRoomState { - return false - } - - // can be improved by adding a date threshold - return previousEventItemProxy.reactions.isEmpty && selfEventItemProxy.sender == previousEventItemProxy.sender - } } /// The delivery status for the item. diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift index b9423f2d6..2851b4333 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/EventBasedTimelineItemProtocol.swift @@ -17,27 +17,9 @@ import Foundation import UIKit -enum TimelineItemGroupState: Hashable { - case single - case beginning - case middle - case end - - var shouldShowSenderDetails: Bool { - switch self { - case .single, .beginning: - return true - default: - return false - } - } -} - protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol, CustomStringConvertible { var body: String { get } var timestamp: String { get } - var shouldShowSenderDetails: Bool { get } - var groupState: TimelineItemGroupState { get } var isOutgoing: Bool { get } var isEditable: Bool { get } @@ -47,31 +29,6 @@ protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol, CustomStringC } extension EventBasedTimelineItemProtocol { - var shouldShowSenderDetails: Bool { - groupState.shouldShowSenderDetails - } - - var roundedCorners: UIRectCorner { - switch groupState { - case .single: - return .allCorners - case .beginning: - if isOutgoing { - return [.topLeft, .topRight, .bottomLeft] - } else { - return [.topLeft, .topRight, .bottomRight] - } - case .middle: - return isOutgoing ? [.topLeft, .bottomLeft] : [.topRight, .bottomRight] - case .end: - if isOutgoing { - return [.topLeft, .bottomLeft, .bottomRight] - } else { - return [.topRight, .bottomLeft, .bottomRight] - } - } - } - var description: String { "\(String(describing: Self.self)): id: \(id), timestamp: \(timestamp), isOutgoing: \(isOutgoing), properties: \(properties)" } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift index 7f4244ddc..1a7aac7bf 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/AudioRoomTimelineItem.swift @@ -20,7 +20,6 @@ struct AudioRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash let id: String let body: String let timestamp: String - let groupState: TimelineItemGroupState let isOutgoing: Bool let isEditable: Bool let sender: TimelineItemSender diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift index cf5974890..205e32214 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/EmoteRoomTimelineItem.swift @@ -21,7 +21,6 @@ struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash let body: String var formattedBody: AttributedString? let timestamp: String - let groupState: TimelineItemGroupState let isOutgoing: Bool let isEditable: Bool diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift index 4abf3d0f3..139dd56ac 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/FileRoomTimelineItem.swift @@ -20,7 +20,6 @@ struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha let id: String let body: String let timestamp: String - let groupState: TimelineItemGroupState let isOutgoing: Bool let isEditable: Bool diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift index 7d57060ca..75bd0c573 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/ImageRoomTimelineItem.swift @@ -21,7 +21,6 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash let id: String let body: String let timestamp: String - let groupState: TimelineItemGroupState let isOutgoing: Bool let isEditable: Bool diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift index f73a56926..3993c0255 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/NoticeRoomTimelineItem.swift @@ -21,7 +21,6 @@ struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Has let body: String var formattedBody: AttributedString? let timestamp: String - let groupState: TimelineItemGroupState let isOutgoing: Bool let isEditable: Bool diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift index 21491d8f2..9c46fe8a5 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/TextRoomTimelineItem.swift @@ -21,7 +21,6 @@ struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha let body: String var formattedBody: AttributedString? let timestamp: String - let groupState: TimelineItemGroupState let isOutgoing: Bool let isEditable: Bool diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift index 7b2886172..d7abedc2b 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Messages/VideoRoomTimelineItem.swift @@ -20,7 +20,6 @@ struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash let id: String let body: String let timestamp: String - let groupState: TimelineItemGroupState let isOutgoing: Bool let isEditable: Bool diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/CollapsibleTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/CollapsibleTimelineItem.swift new file mode 100644 index 000000000..da375f279 --- /dev/null +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/CollapsibleTimelineItem.swift @@ -0,0 +1,43 @@ +// +// 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 CollapsibleTimelineItem: RoomTimelineItemProtocol, Identifiable, Hashable { + let id: String + let items: [RoomTimelineItemProtocol] + + init(items: [RoomTimelineItemProtocol]) { + guard let firstItem = items.first else { + fatalError() + } + + self.items = items + id = firstItem.id + } + + // MARK: - Equatable + + static func == (lhs: CollapsibleTimelineItem, rhs: CollapsibleTimelineItem) -> Bool { + lhs.id == rhs.id + } + + // MARK: - Hashable + + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } +} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/EncryptedRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/EncryptedRoomTimelineItem.swift index 012f58d59..388481c23 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/EncryptedRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/EncryptedRoomTimelineItem.swift @@ -27,7 +27,6 @@ struct EncryptedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, let body: String let encryptionType: EncryptionType let timestamp: String - let groupState: TimelineItemGroupState let isOutgoing: Bool let isEditable: Bool diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/RedactedRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/RedactedRoomTimelineItem.swift index 16758292c..299b9c30a 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/RedactedRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/RedactedRoomTimelineItem.swift @@ -21,7 +21,6 @@ struct RedactedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, H let id: String let body: String let timestamp: String - let groupState: TimelineItemGroupState let isOutgoing: Bool let isEditable: Bool diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StateRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StateRoomTimelineItem.swift index 9db011532..ee58c488c 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StateRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StateRoomTimelineItem.swift @@ -20,7 +20,6 @@ struct StateRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash let id: String let body: String let timestamp: String - let groupState: TimelineItemGroupState let isOutgoing: Bool let isEditable: Bool diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StickerRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StickerRoomTimelineItem.swift index 59d1c632c..e4ea33b0e 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StickerRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/StickerRoomTimelineItem.swift @@ -20,7 +20,6 @@ struct StickerRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Ha let id: String let body: String let timestamp: String - let groupState: TimelineItemGroupState let isOutgoing: Bool let isEditable: Bool diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/UnsupportedRoomTimelineItem.swift b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/UnsupportedRoomTimelineItem.swift index fc86f032c..a844d13c4 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/UnsupportedRoomTimelineItem.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/Items/Other/UnsupportedRoomTimelineItem.swift @@ -24,7 +24,6 @@ struct UnsupportedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable let error: String let timestamp: String - let groupState: TimelineItemGroupState let isOutgoing: Bool let isEditable: Bool diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift index 0acac1dce..94aadc325 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactory.swift @@ -36,53 +36,52 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { } // swiftlint:disable:next cyclomatic_complexity - func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy, - groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol? { + func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy) -> RoomTimelineItemProtocol? { let isOutgoing = eventItemProxy.isOwn switch eventItemProxy.content.kind() { case .unableToDecrypt(let encryptedMessage): - return buildEncryptedTimelineItem(eventItemProxy, encryptedMessage, isOutgoing, groupState) + return buildEncryptedTimelineItem(eventItemProxy, encryptedMessage, isOutgoing) case .redactedMessage: - return buildRedactedTimelineItem(eventItemProxy, isOutgoing, groupState) + return buildRedactedTimelineItem(eventItemProxy, isOutgoing) case .sticker(let body, let imageInfo, let urlString): guard let url = URL(string: urlString) else { MXLog.error("Invalid sticker url string: \(urlString)") - return buildUnsupportedTimelineItem(eventItemProxy, "m.sticker", "Invalid Sticker URL", isOutgoing, groupState) + return buildUnsupportedTimelineItem(eventItemProxy, "m.sticker", "Invalid Sticker URL", isOutgoing) } - return buildStickerTimelineItem(eventItemProxy, body, imageInfo, url, isOutgoing, groupState) + return buildStickerTimelineItem(eventItemProxy, body, imageInfo, url, isOutgoing) case .failedToParseMessageLike(let eventType, let error): - return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing, groupState) + return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing) case .failedToParseState(let eventType, _, let error): - return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing, groupState) + return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing) case .message: guard let messageContent = eventItemProxy.content.asMessage() else { fatalError("Invalid message timeline item: \(eventItemProxy)") } switch messageContent.msgtype() { case .text(content: let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildTextTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) + return buildTextTimelineItemFromMessage(eventItemProxy, message, isOutgoing) case .image(content: let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildImageTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) + return buildImageTimelineItemFromMessage(eventItemProxy, message, isOutgoing) case .video(let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildVideoTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) + return buildVideoTimelineItemFromMessage(eventItemProxy, message, isOutgoing) case .file(let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildFileTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) - case .notice(let content): + return buildFileTimelineItemFromMessage(eventItemProxy, message, isOutgoing) + case .notice(content: let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildNoticeTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) - case .emote(let content): + return buildNoticeTimelineItemFromMessage(eventItemProxy, message, isOutgoing) + case .emote(content: let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildEmoteTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) + return buildEmoteTimelineItemFromMessage(eventItemProxy, message, isOutgoing) case .audio(let content): let message = MessageTimelineItem(item: eventItemProxy.item, content: content) - return buildAudioTimelineItem(eventItemProxy, message, isOutgoing, groupState) + return buildAudioTimelineItem(eventItemProxy, message, isOutgoing) case .none: - return buildFallbackTimelineItem(eventItemProxy, isOutgoing, groupState) + return buildFallbackTimelineItem(eventItemProxy, isOutgoing) } case .state(let stateKey, let content): return buildStateTimelineItemFor(eventItemProxy: eventItemProxy, state: content, stateKey: stateKey, isOutgoing: isOutgoing) @@ -98,27 +97,23 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildUnsupportedTimelineItem(_ eventItemProxy: EventTimelineItemProxy, _ eventType: String, _ error: String, - _ isOutgoing: Bool, - _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { UnsupportedRoomTimelineItem(id: eventItemProxy.id, body: ElementL10n.roomTimelineItemUnsupported, eventType: eventType, error: error, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), - groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, sender: eventItemProxy.sender, properties: RoomTimelineItemProperties()) } - // swiftlint:disable:next function_parameter_count private func buildStickerTimelineItem(_ eventItemProxy: EventTimelineItemProxy, _ body: String, _ imageInfo: ImageInfo, _ imageURL: URL, - _ isOutgoing: Bool, - _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { var aspectRatio: CGFloat? let width = imageInfo.width.map(CGFloat.init) let height = imageInfo.height.map(CGFloat.init) @@ -129,7 +124,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { return StickerRoomTimelineItem(id: eventItemProxy.id, body: body, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), - groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, sender: eventItemProxy.sender, @@ -144,8 +138,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildEncryptedTimelineItem(_ eventItemProxy: EventTimelineItemProxy, _ encryptedMessage: EncryptedMessage, - _ isOutgoing: Bool, - _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { var encryptionType = EncryptedRoomTimelineItem.EncryptionType.unknown switch encryptedMessage { case .megolmV1AesSha2(let sessionId): @@ -160,7 +153,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { body: ElementL10n.roomTimelineUnableToDecrypt, encryptionType: encryptionType, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), - groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, sender: eventItemProxy.sender, @@ -168,12 +160,10 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { } private func buildRedactedTimelineItem(_ eventItemProxy: EventTimelineItemProxy, - _ isOutgoing: Bool, - _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { RedactedRoomTimelineItem(id: eventItemProxy.id, body: ElementL10n.eventRedacted, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), - groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, sender: eventItemProxy.sender, @@ -181,15 +171,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { } private func buildFallbackTimelineItem(_ eventItemProxy: EventTimelineItemProxy, - _ isOutgoing: Bool, - _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { let formattedBody = attributedStringBuilder.fromPlain(eventItemProxy.body) return TextRoomTimelineItem(id: eventItemProxy.id, body: eventItemProxy.body ?? "", formattedBody: formattedBody, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), - groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, sender: eventItemProxy.sender, @@ -199,15 +187,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildTextTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, _ message: MessageTimelineItem, - _ isOutgoing: Bool, - _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { let formattedBody = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body)) return TextRoomTimelineItem(id: message.id, body: message.body, formattedBody: formattedBody, timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), - groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, sender: eventItemProxy.sender, @@ -218,8 +204,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildImageTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, _ message: MessageTimelineItem, - _ isOutgoing: Bool, - _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { var aspectRatio: CGFloat? if let width = message.width, let height = message.height { @@ -229,7 +214,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { return ImageRoomTimelineItem(id: message.id, body: message.body, timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), - groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, sender: eventItemProxy.sender, @@ -246,8 +230,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildVideoTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, _ message: MessageTimelineItem, - _ isOutgoing: Bool, - _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { var aspectRatio: CGFloat? if let width = message.width, let height = message.height { @@ -257,7 +240,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { return VideoRoomTimelineItem(id: message.id, body: message.body, timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), - groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, sender: eventItemProxy.sender, @@ -275,12 +257,10 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildAudioTimelineItem(_ eventItemProxy: EventTimelineItemProxy, _ message: MessageTimelineItem, - _ isOutgoing: Bool, - _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { AudioRoomTimelineItem(id: eventItemProxy.id, body: message.body, timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), - groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, sender: eventItemProxy.sender, @@ -290,12 +270,10 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildFileTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, _ message: MessageTimelineItem, - _ isOutgoing: Bool, - _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { FileRoomTimelineItem(id: message.id, body: message.body, timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), - groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, sender: eventItemProxy.sender, @@ -308,15 +286,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildNoticeTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, _ message: MessageTimelineItem, - _ isOutgoing: Bool, - _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { let formattedBody = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body)) return NoticeRoomTimelineItem(id: message.id, body: message.body, formattedBody: formattedBody, timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), - groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, sender: eventItemProxy.sender, @@ -327,8 +303,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { private func buildEmoteTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, _ message: MessageTimelineItem, - _ isOutgoing: Bool, - _ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol { + _ isOutgoing: Bool) -> RoomTimelineItemProtocol { let name = eventItemProxy.sender.displayName ?? eventItemProxy.sender.id var formattedBody: AttributedString? @@ -342,7 +317,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { body: message.body, formattedBody: formattedBody, timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), - groupState: groupState, isOutgoing: isOutgoing, isEditable: eventItemProxy.isEditable, sender: eventItemProxy.sender, @@ -396,7 +370,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol { StateRoomTimelineItem(id: eventItemProxy.id, body: text, timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), - groupState: .single, isOutgoing: isOutgoing, isEditable: false, sender: eventItemProxy.sender) diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift index 1aaac253d..e07c08da2 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineItemFactoryProtocol.swift @@ -18,6 +18,5 @@ import Foundation @MainActor protocol RoomTimelineItemFactoryProtocol { - func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy, - groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol? + func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy) -> RoomTimelineItemProtocol? } diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactory.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactory.swift deleted file mode 100644 index 703d5a805..000000000 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactory.swift +++ /dev/null @@ -1,59 +0,0 @@ -// -// Copyright 2022 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation - -struct RoomTimelineViewFactory: RoomTimelineViewFactoryProtocol { - // swiftlint:disable:next cyclomatic_complexity - func buildTimelineViewFor(timelineItem: RoomTimelineItemProtocol) -> RoomTimelineViewProvider { - switch timelineItem { - case let item as TextRoomTimelineItem: - return .text(item) - case let item as ImageRoomTimelineItem: - return .image(item) - case let item as VideoRoomTimelineItem: - return .video(item) - case let item as AudioRoomTimelineItem: - return .audio(item) - case let item as FileRoomTimelineItem: - return .file(item) - case let item as SeparatorRoomTimelineItem: - return .separator(item) - case let item as NoticeRoomTimelineItem: - return .notice(item) - case let item as EmoteRoomTimelineItem: - return .emote(item) - case let item as RedactedRoomTimelineItem: - return .redacted(item) - case let item as EncryptedRoomTimelineItem: - return .encrypted(item) - case let item as ReadMarkerRoomTimelineItem: - return .readMarker(item) - case let item as PaginationIndicatorRoomTimelineItem: - return .paginationIndicator(item) - case let item as StickerRoomTimelineItem: - return .sticker(item) - case let item as UnsupportedRoomTimelineItem: - return .unsupported(item) - case let item as TimelineStartRoomTimelineItem: - return .timelineStart(item) - case let item as StateRoomTimelineItem: - return .state(item) - default: - fatalError("Unknown timeline item") - } - } -} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactoryProtocol.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactoryProtocol.swift deleted file mode 100644 index 92ecc2856..000000000 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewFactoryProtocol.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Copyright 2022 New Vector Ltd -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// - -import Foundation - -@MainActor -protocol RoomTimelineViewFactoryProtocol { - func buildTimelineViewFor(timelineItem: RoomTimelineItemProtocol) -> RoomTimelineViewProvider -} diff --git a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift index 2e4d73a3b..22ecd5e66 100644 --- a/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift +++ b/ElementX/Sources/Services/Timeline/TimelineItems/RoomTimelineViewProvider.swift @@ -18,56 +18,85 @@ import Foundation import SwiftUI enum RoomTimelineViewProvider: Identifiable, Hashable { - case text(TextRoomTimelineItem) - case separator(SeparatorRoomTimelineItem) - case image(ImageRoomTimelineItem) - case video(VideoRoomTimelineItem) - case audio(AudioRoomTimelineItem) - case file(FileRoomTimelineItem) - case emote(EmoteRoomTimelineItem) - case notice(NoticeRoomTimelineItem) - case redacted(RedactedRoomTimelineItem) - case encrypted(EncryptedRoomTimelineItem) - case readMarker(ReadMarkerRoomTimelineItem) - case paginationIndicator(PaginationIndicatorRoomTimelineItem) - case sticker(StickerRoomTimelineItem) - case unsupported(UnsupportedRoomTimelineItem) - case timelineStart(TimelineStartRoomTimelineItem) - case state(StateRoomTimelineItem) + case text(TextRoomTimelineItem, TimelineGroupStyle) + case separator(SeparatorRoomTimelineItem, TimelineGroupStyle) + case image(ImageRoomTimelineItem, TimelineGroupStyle) + case video(VideoRoomTimelineItem, TimelineGroupStyle) + case audio(AudioRoomTimelineItem, TimelineGroupStyle) + case file(FileRoomTimelineItem, TimelineGroupStyle) + case emote(EmoteRoomTimelineItem, TimelineGroupStyle) + case notice(NoticeRoomTimelineItem, TimelineGroupStyle) + case redacted(RedactedRoomTimelineItem, TimelineGroupStyle) + case encrypted(EncryptedRoomTimelineItem, TimelineGroupStyle) + case readMarker(ReadMarkerRoomTimelineItem, TimelineGroupStyle) + case paginationIndicator(PaginationIndicatorRoomTimelineItem, TimelineGroupStyle) + case sticker(StickerRoomTimelineItem, TimelineGroupStyle) + case unsupported(UnsupportedRoomTimelineItem, TimelineGroupStyle) + case timelineStart(TimelineStartRoomTimelineItem, TimelineGroupStyle) + case state(StateRoomTimelineItem, TimelineGroupStyle) + case group(CollapsibleTimelineItem, TimelineGroupStyle) + + // swiftlint:disable:next cyclomatic_complexity + init(timelineItem: RoomTimelineItemProtocol, grouping: TimelineGroupStyle) { + switch timelineItem { + case let item as TextRoomTimelineItem: + self = .text(item, grouping) + case let item as ImageRoomTimelineItem: + self = .image(item, grouping) + case let item as VideoRoomTimelineItem: + self = .video(item, grouping) + case let item as AudioRoomTimelineItem: + self = .audio(item, grouping) + case let item as FileRoomTimelineItem: + self = .file(item, grouping) + case let item as SeparatorRoomTimelineItem: + self = .separator(item, grouping) + case let item as NoticeRoomTimelineItem: + self = .notice(item, grouping) + case let item as EmoteRoomTimelineItem: + self = .emote(item, grouping) + case let item as RedactedRoomTimelineItem: + self = .redacted(item, grouping) + case let item as EncryptedRoomTimelineItem: + self = .encrypted(item, grouping) + case let item as ReadMarkerRoomTimelineItem: + self = .readMarker(item, grouping) + case let item as PaginationIndicatorRoomTimelineItem: + self = .paginationIndicator(item, grouping) + case let item as StickerRoomTimelineItem: + self = .sticker(item, grouping) + case let item as UnsupportedRoomTimelineItem: + self = .unsupported(item, grouping) + case let item as TimelineStartRoomTimelineItem: + self = .timelineStart(item, grouping) + case let item as StateRoomTimelineItem: + self = .state(item, grouping) + case let item as CollapsibleTimelineItem: + self = .group(item, grouping) + default: + fatalError("Unknown timeline item") + } + } var id: String { switch self { - case .text(let item): - return item.id - case .separator(let item): - return item.id - case .image(let item): - return item.id - case .video(let item): - return item.id - case .audio(let item): - return item.id - case .file(let item): - return item.id - case .emote(let item): - return item.id - case .notice(let item): - return item.id - case .redacted(let item): - return item.id - case .encrypted(let item): - return item.id - case .readMarker(let item): - return item.id - case .paginationIndicator(let item): - return item.id - case .sticker(let item): - return item.id - case .unsupported(let item): - return item.id - case .timelineStart(let item): - return item.id - case .state(let item): + case .text(let item as RoomTimelineItemProtocol, _), + .separator(let item as RoomTimelineItemProtocol, _), + .image(let item as RoomTimelineItemProtocol, _), + .video(let item as RoomTimelineItemProtocol, _), + .audio(let item as RoomTimelineItemProtocol, _), + .file(let item as RoomTimelineItemProtocol, _), + .emote(let item as RoomTimelineItemProtocol, _), + .notice(let item as RoomTimelineItemProtocol, _), + .redacted(let item as RoomTimelineItemProtocol, _), + .encrypted(let item as RoomTimelineItemProtocol, _), + .readMarker(let item as RoomTimelineItemProtocol, _), + .paginationIndicator(let item as RoomTimelineItemProtocol, _), + .sticker(let item as RoomTimelineItemProtocol, _), + .unsupported(let item as RoomTimelineItemProtocol, _), + .timelineStart(let item as RoomTimelineItemProtocol, _), + .state(let item as RoomTimelineItemProtocol, _), + .group(let item as RoomTimelineItemProtocol, _): return item.id } } @@ -81,45 +110,77 @@ enum RoomTimelineViewProvider: Identifiable, Hashable { return false case .timelineStart, .separator, .readMarker, .paginationIndicator: // Virtual items are never reactable return false + case .group: + return false } } } extension RoomTimelineViewProvider: View { - @ViewBuilder var body: some View { + var body: some View { + timelineView + .environment(\.timelineGroupStyle, timelineStyleGrouping) + } + + @ViewBuilder private var timelineView: some View { switch self { - case .text(let item): + case .text(let item, _): TextRoomTimelineView(timelineItem: item) - case .separator(let item): + case .separator(let item, _): SeparatorRoomTimelineView(timelineItem: item) - case .image(let item): + case .image(let item, _): ImageRoomTimelineView(timelineItem: item) - case .video(let item): + case .video(let item, _): VideoRoomTimelineView(timelineItem: item) - case .audio(let item): + case .audio(let item, _): AudioRoomTimelineView(timelineItem: item) - case .file(let item): + case .file(let item, _): FileRoomTimelineView(timelineItem: item) - case .emote(let item): + case .emote(let item, _): EmoteRoomTimelineView(timelineItem: item) - case .notice(let item): + case .notice(let item, _): NoticeRoomTimelineView(timelineItem: item) - case .redacted(let item): + case .redacted(let item, _): RedactedRoomTimelineView(timelineItem: item) - case .encrypted(let item): + case .encrypted(let item, _): EncryptedRoomTimelineView(timelineItem: item) - case .readMarker(let item): + case .readMarker(let item, _): ReadMarkerRoomTimelineView(timelineItem: item) - case .paginationIndicator(let item): + case .paginationIndicator(let item, _): PaginationIndicatorRoomTimelineView(timelineItem: item) - case .sticker(let item): + case .sticker(let item, _): StickerRoomTimelineView(timelineItem: item) - case .unsupported(let item): + case .unsupported(let item, _): UnsupportedRoomTimelineView(timelineItem: item) - case .timelineStart(let item): + case .timelineStart(let item, _): TimelineStartRoomTimelineView(timelineItem: item) - case .state(let item): + case .state(let item, _): StateRoomTimelineView(timelineItem: item) + case .group(let item, _): + CollapsibleRoomTimelineView(timelineItem: item) + } + } + + private var timelineStyleGrouping: TimelineGroupStyle { + switch self { + case .text(_, let grouping), + .separator(_, let grouping), + .image(_, let grouping), + .video(_, let grouping), + .audio(_, let grouping), + .file(_, let grouping), + .emote(_, let grouping), + .notice(_, let grouping), + .redacted(_, let grouping), + .encrypted(_, let grouping), + .readMarker(_, let grouping), + .paginationIndicator(_, let grouping), + .sticker(_, let grouping), + .unsupported(_, let grouping), + .timelineStart(_, let grouping), + .state(_, let grouping), + .group(_, let grouping): + return grouping } } } diff --git a/ElementX/SupportingFiles/target.yml b/ElementX/SupportingFiles/target.yml index 27455dc4f..6c6359890 100644 --- a/ElementX/SupportingFiles/target.yml +++ b/ElementX/SupportingFiles/target.yml @@ -116,6 +116,7 @@ targets: - target: NSE - package: MatrixRustSDK - package: DesignKit + - package: Algorithms - package: AnalyticsEvents - package: AppAuth - package: Collections diff --git a/UnitTests/Sources/ArrayTests.swift b/UnitTests/Sources/ArrayTests.swift new file mode 100644 index 000000000..4d05f5d6d --- /dev/null +++ b/UnitTests/Sources/ArrayTests.swift @@ -0,0 +1,45 @@ +// +// 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 + +import XCTest + +@testable import ElementX + +class ArrayTests: XCTestCase { + func testGrouping() { + XCTAssertEqual([].groupBy { $0 == 0 }, []) + + XCTAssertEqual([0].groupBy { $0 == 0 }, [[0]]) + + XCTAssertEqual([1].groupBy { $0 == 0 }, [[1]]) + + XCTAssertEqual([0, 0, 0].groupBy { $0 == 0 }, [[0, 0, 0]]) + + XCTAssertEqual([1, 1, 1].groupBy { $0 == 0 }, [[1], [1], [1]]) + + XCTAssertEqual([1, 0, 0, 1].groupBy { $0 == 0 }, [[1], [0, 0], [1]]) + + XCTAssertEqual([0, 0, 1, 0].groupBy { $0 == 0 }, [[0, 0], [1], [0]]) + + XCTAssertEqual([0, 0, 0, 1, 2, 3, 0].groupBy { $0 == 0 }, [[0, 0, 0], [1], [2], [3], [0]]) + + XCTAssertEqual([0, 0, 0, 1, 2, 3, 0, 0].groupBy { $0 == 0 }, [[0, 0, 0], [1], [2], [3], [0, 0]]) + + XCTAssertEqual([0, 0, 0, 1, 0, 2, 3, 0, 0].groupBy { $0 == 0 }, [[0, 0, 0], [1], [0], [2], [3], [0, 0]]) + } +} diff --git a/UnitTests/Sources/LoggingTests.swift b/UnitTests/Sources/LoggingTests.swift index 20fac0238..f57b73143 100644 --- a/UnitTests/Sources/LoggingTests.swift +++ b/UnitTests/Sources/LoggingTests.swift @@ -212,23 +212,23 @@ class LoggingTests: XCTestCase { let textAttributedString = "TextAttributed" let textMessage = TextRoomTimelineItem(id: "mytextmessage", body: "TextString", formattedBody: AttributedString(textAttributedString), - timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender")) + timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender")) let noticeAttributedString = "NoticeAttributed" let noticeMessage = NoticeRoomTimelineItem(id: "mynoticemessage", body: "NoticeString", formattedBody: AttributedString(noticeAttributedString), - timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender")) + timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender")) let emoteAttributedString = "EmoteAttributed" let emoteMessage = EmoteRoomTimelineItem(id: "myemotemessage", body: "EmoteString", formattedBody: AttributedString(emoteAttributedString), - timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender")) + timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender")) let imageMessage = ImageRoomTimelineItem(id: "myimagemessage", body: "ImageString", - timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, + timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender"), source: nil) let videoMessage = VideoRoomTimelineItem(id: "myvideomessage", body: "VideoString", - timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, + timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender"), duration: 0, source: nil, thumbnailSource: nil) let fileMessage = FileRoomTimelineItem(id: "myfilemessage", body: "FileString", - timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, + timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender"), source: nil, thumbnailSource: nil) // When logging that value diff --git a/project.yml b/project.yml index 2606df459..e6a9440f2 100644 --- a/project.yml +++ b/project.yml @@ -44,6 +44,9 @@ packages: # path: ../matrix-rust-sdk DesignKit: path: DesignKit + Algorithms: + url: https://github.com/apple/swift-algorithms + majorVersion: 1.0.0 AnalyticsEvents: url: https://github.com/matrix-org/matrix-analytics-events exactVersion: 0.4.0