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
This commit is contained in:
Stefan Ceriu
2023-02-15 17:14:50 +02:00
committed by Stefan Ceriu
parent 64902190d4
commit a3c7c47a69
58 changed files with 679 additions and 458 deletions
@@ -117,6 +117,15 @@
"version" : "7.30.2" "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", "identity" : "swift-collections",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -126,6 +135,15 @@
"version" : "1.0.4" "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", "identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl", "kind" : "remoteSourceControl",
@@ -33,6 +33,8 @@
"room_timeline_image_gif" = "GIF"; "room_timeline_image_gif" = "GIF";
"room_timeline_read_marker_title" = "New"; "room_timeline_read_marker_title" = "New";
"room_timeline_state_changes" = "%d room changes";
"noticeRoomInviteAccepted" = "%1$@ accepted the invite"; "noticeRoomInviteAccepted" = "%1$@ accepted the invite";
"noticeRoomInviteAcceptedByYou" = "You accepted the invite"; "noticeRoomInviteAcceptedByYou" = "You accepted the invite";
"noticeRoomKnock" = "%1$@ requested to join"; "noticeRoomKnock" = "%1$@ requested to join";
+10 -6
View File
@@ -26,6 +26,7 @@ final class AppSettings: ObservableObject {
case isIdentifiedForAnalytics case isIdentifiedForAnalytics
case enableInAppNotifications case enableInAppNotifications
case pusherProfileTag case pusherProfileTag
case shouldCollapseRoomStateEvents
} }
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier 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 /// 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 /// 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. /// 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? var lastVersionLaunched: String?
/// The default homeserver address used. This is intentionally a string without a scheme /// 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. /// `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 var enableAnalytics
/// Indicates if the device has already called identify for this session to PostHog. /// Indicates if the device has already called identify for this session to PostHog.
/// This is separate to `enableAnalytics` as logging out leaves analytics /// This is separate to `enableAnalytics` as logging out leaves analytics
/// enabled, but requires the next account to be identified separately. /// 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 var isIdentifiedForAnalytics
// MARK: - Room Screen // 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 var timelineStyle
@UserSetting(key: UserDefaultsKeys.shouldCollapseRoomStateEvents.rawValue, defaultValue: true, persistIn: nil)
var shouldCollapseRoomStateEvents
// MARK: - Notifications // MARK: - Notifications
@UserSetting(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: true, storage: store) @UserSetting(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: true, persistIn: store)
var enableInAppNotifications var enableInAppNotifications
/// Tag describing which set of device specific rules a pusher executes. /// 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? var pusherProfileTag: String?
// MARK: - Other // MARK: - Other
@@ -120,6 +120,10 @@ extension ElementL10n {
public static func roomTimelineReplyingTo(_ p1: Any) -> String { public static func roomTimelineReplyingTo(_ p1: Any) -> String {
return ElementL10n.tr("Untranslated", "room_timeline_replying_to", String(describing: p1)) 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 /// Bubbles
public static let roomTimelineStyleBubbledLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_bubbled_long_description") public static let roomTimelineStyleBubbledLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_bubbled_long_description")
/// Modern /// Modern
@@ -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
}
}
@@ -26,25 +26,31 @@ import Foundation
@propertyWrapper @propertyWrapper
struct UserSetting<Value: Equatable> { struct UserSetting<Value: Equatable> {
private let key: String private let key: String
private let defaultValue: Value private var defaultValue: Value
private let storage: UserDefaults private let storage: UserDefaults?
private let publisher: CurrentValueSubject<Value, Never> private let publisher: CurrentValueSubject<Value, Never>
init(key: String, defaultValue: Value, storage: UserDefaults = .standard) { init(key: String, defaultValue: Value, persistIn storage: UserDefaults?) {
self.key = key self.key = key
self.defaultValue = defaultValue self.defaultValue = defaultValue
self.storage = storage self.storage = storage
let value = storage.value(forKey: key) as? Value ?? defaultValue let value = storage?.value(forKey: key) as? Value ?? defaultValue
publisher = CurrentValueSubject<Value, Never>(value) publisher = CurrentValueSubject<Value, Never>(value)
} }
var wrappedValue: Value { var wrappedValue: Value {
get { get {
let value = storage.value(forKey: key) as? Value let value = storage?.value(forKey: key) as? Value
return value ?? defaultValue return value ?? defaultValue
} }
set { set {
guard let storage else {
defaultValue = newValue
publisher.send(defaultValue)
return
}
if let optional = newValue as? AnyOptional, optional.isNil { if let optional = newValue as? AnyOptional, optional.isNil {
storage.removeObject(forKey: key) storage.removeObject(forKey: key)
publisher.send(defaultValue) publisher.send(defaultValue)
@@ -61,8 +67,8 @@ struct UserSetting<Value: Equatable> {
} }
extension UserSetting where Value: ExpressibleByNilLiteral { extension UserSetting where Value: ExpressibleByNilLiteral {
init(key: String, storage: UserDefaults = .standard) { init(key: String, persistIn storage: UserDefaults?) {
self.init(key: key, defaultValue: nil, storage: storage) self.init(key: key, defaultValue: nil, persistIn: storage)
} }
} }
@@ -74,28 +80,34 @@ extension UserSetting where Value: ExpressibleByNilLiteral {
@propertyWrapper @propertyWrapper
struct UserSettingRawRepresentable<Value: RawRepresentable & Equatable> { struct UserSettingRawRepresentable<Value: RawRepresentable & Equatable> {
private let key: String private let key: String
private let defaultValue: Value private var defaultValue: Value
private let storage: UserDefaults private let storage: UserDefaults?
private let publisher: CurrentValueSubject<Value, Never> private let publisher: CurrentValueSubject<Value, Never>
init(key: String, defaultValue: Value, storage: UserDefaults = .standard) { init(key: String, defaultValue: Value, persistIn storage: UserDefaults?) {
self.key = key self.key = key
self.defaultValue = defaultValue self.defaultValue = defaultValue
self.storage = storage 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, Never>(value) publisher = CurrentValueSubject<Value, Never>(value)
} }
var wrappedValue: Value { var wrappedValue: Value {
get { 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 defaultValue
} }
return Value(rawValue: value) ?? defaultValue return Value(rawValue: value) ?? defaultValue
} }
set { set {
guard let storage else {
defaultValue = newValue
publisher.send(defaultValue)
return
}
if let optional = newValue as? AnyOptional, optional.isNil { if let optional = newValue as? AnyOptional, optional.isNil {
storage.removeObject(forKey: key) storage.removeObject(forKey: key)
publisher.send(newValue) publisher.send(newValue)
@@ -112,8 +124,8 @@ struct UserSettingRawRepresentable<Value: RawRepresentable & Equatable> {
} }
extension UserSettingRawRepresentable where Value: ExpressibleByNilLiteral { extension UserSettingRawRepresentable where Value: ExpressibleByNilLiteral {
init(key: String, storage: UserDefaults = .standard) { init(key: String, persistIn storage: UserDefaults?) {
self.init(key: key, defaultValue: nil, storage: storage) self.init(key: key, defaultValue: nil, persistIn: storage)
} }
} }
@@ -22,6 +22,10 @@ struct DeveloperOptionsScreenViewState: BindableState {
var bindings: DeveloperOptionsScreenViewStateBindings var bindings: DeveloperOptionsScreenViewStateBindings
} }
struct DeveloperOptionsScreenViewStateBindings { } struct DeveloperOptionsScreenViewStateBindings {
var shouldCollapseRoomStateEvents: Bool
}
enum DeveloperOptionsScreenViewAction { } enum DeveloperOptionsScreenViewAction {
case changedShouldCollapseRoomStateEvents
}
@@ -20,8 +20,19 @@ typealias DeveloperOptionsScreenViewModelType = StateStoreViewModel<DeveloperOpt
class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, DeveloperOptionsScreenViewModelProtocol { class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, DeveloperOptionsScreenViewModelProtocol {
var callback: ((DeveloperOptionsScreenViewModelAction) -> Void)? var callback: ((DeveloperOptionsScreenViewModelAction) -> Void)?
init() { 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
}
} }
} }
@@ -22,6 +22,15 @@ struct DeveloperOptionsScreenScreen: View {
var body: some View { var body: some View {
Form { Form {
Section {
Toggle(isOn: $context.shouldCollapseRoomStateEvents) {
Text("Collapse room state events")
}
.onChange(of: context.shouldCollapseRoomStateEvents) { _ in
context.send(viewAction: .changedShouldCollapseRoomStateEvents)
}
}
Section { Section {
Button { Button {
showConfetti = true showConfetti = true
@@ -36,7 +36,6 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
self.parameters = parameters self.parameters = parameters
viewModel = RoomScreenViewModel(timelineController: parameters.timelineController, viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: parameters.mediaProvider, mediaProvider: parameters.mediaProvider,
roomName: parameters.roomProxy.displayName ?? parameters.roomProxy.name, roomName: parameters.roomProxy.displayName ?? parameters.roomProxy.name,
roomAvatarUrl: parameters.roomProxy.avatarURL) roomAvatarUrl: parameters.roomProxy.avatarURL)
@@ -14,6 +14,7 @@
// limitations under the License. // limitations under the License.
// //
import Algorithms
import Combine import Combine
import SwiftUI import SwiftUI
@@ -27,15 +28,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
private let timelineController: RoomTimelineControllerProtocol private let timelineController: RoomTimelineControllerProtocol
private let timelineViewFactory: RoomTimelineViewFactoryProtocol
init(timelineController: RoomTimelineControllerProtocol, init(timelineController: RoomTimelineControllerProtocol,
timelineViewFactory: RoomTimelineViewFactoryProtocol,
mediaProvider: MediaProviderProtocol, mediaProvider: MediaProviderProtocol,
roomName: String?, roomName: String?,
roomAvatarUrl: URL? = nil) { roomAvatarUrl: URL? = nil) {
self.timelineController = timelineController self.timelineController = timelineController
self.timelineViewFactory = timelineViewFactory
super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomID, super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomID,
roomTitle: roomName ?? "Unknown room 💥", roomTitle: roomName ?? "Unknown room 💥",
@@ -52,13 +50,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
switch callback { switch callback {
case .updatedTimelineItems: case .updatedTimelineItems:
self.buildTimelineViews() 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): case .canBackPaginate(let canBackPaginate):
if self.state.canBackPaginate != canBackPaginate { if self.state.canBackPaginate != canBackPaginate {
self.state.canBackPaginate = canBackPaginate self.state.canBackPaginate = canBackPaginate
@@ -161,13 +152,57 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
} }
private func buildTimelineViews() { private func buildTimelineViews() {
let stateItems = timelineController.timelineItems.map { item in var timelineViews = [RoomTimelineViewProvider]()
timelineViewFactory.buildTimelineViewFor(timelineItem: item)
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 { private func sendCurrentMessage() async {
guard !state.bindings.composerText.isEmpty else { guard !state.bindings.composerText.isEmpty else {
fatalError("This message should never be empty") fatalError("This message should never be empty")
@@ -298,7 +333,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
extension RoomScreenViewModel { extension RoomScreenViewModel {
static let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(), static let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
roomName: "Preview room") roomName: "Preview room")
} }
@@ -58,7 +58,6 @@ struct RoomHeaderView_Previews: PreviewProvider {
@ViewBuilder @ViewBuilder
static var bodyPlain: some View { static var bodyPlain: some View {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
roomName: "Some Room name", roomName: "Some Room name",
roomAvatarUrl: URL.picturesDirectory) roomAvatarUrl: URL.picturesDirectory)
@@ -67,11 +66,10 @@ struct RoomHeaderView_Previews: PreviewProvider {
.previewLayout(.sizeThatFits) .previewLayout(.sizeThatFits)
.padding() .padding()
} }
@ViewBuilder @ViewBuilder
static var bodyEncrypted: some View { static var bodyEncrypted: some View {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
roomName: "Some Room name", roomName: "Some Room name",
roomAvatarUrl: nil) roomAvatarUrl: nil)
@@ -125,7 +125,6 @@ struct RoomScreen: View {
struct RoomScreen_Previews: PreviewProvider { struct RoomScreen_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
roomName: "Preview room") roomName: "Preview room")
@@ -19,11 +19,11 @@ import SwiftUI
struct TimelineItemBubbledStylerView<Content: View>: View { struct TimelineItemBubbledStylerView<Content: View>: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context @EnvironmentObject private var context: RoomScreenViewModel.Context
@Environment(\.timelineGroupStyle) private var timelineStyleGrouping
let timelineItem: EventBasedTimelineItemProtocol let timelineItem: EventBasedTimelineItemProtocol
@ViewBuilder let content: () -> Content @ViewBuilder let content: () -> Content
@Environment(\.colorScheme) private var colorScheme
@ScaledMetric private var senderNameVerticalPadding = 3 @ScaledMetric private var senderNameVerticalPadding = 3
private let cornerRadius: CGFloat = 12 private let cornerRadius: CGFloat = 12
@@ -54,7 +54,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ViewBuilder @ViewBuilder
private var header: some View { private var header: some View {
if timelineItem.shouldShowSenderDetails { if shouldShowSenderDetails {
VStack { VStack {
Spacer() Spacer()
.frame(height: 8) .frame(height: 8)
@@ -90,7 +90,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
var messageBubble: some View { var messageBubble: some View {
styledContent 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 { .contextMenu {
context.viewState.contextMenuActionProvider?(timelineItem.id).map { actions in context.viewState.contextMenuActionProvider?(timelineItem.id).map { actions in
TimelineItemContextMenu(itemID: timelineItem.id, contextMenuActions: actions) TimelineItemContextMenu(itemID: timelineItem.id, contextMenuActions: actions)
@@ -105,7 +105,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
content() content()
.bubbleStyle(inset: false, .bubbleStyle(inset: false,
cornerRadius: cornerRadius, cornerRadius: cornerRadius,
corners: timelineItem.roundedCorners) corners: roundedCorners)
} else { } else {
VStack(alignment: .trailing, spacing: 4) { VStack(alignment: .trailing, spacing: 4) {
content() content()
@@ -119,13 +119,13 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
.bubbleStyle(inset: true, .bubbleStyle(inset: true,
color: timelineItem.isOutgoing ? .element.bubblesYou : .element.bubblesNotYou, color: timelineItem.isOutgoing ? .element.bubblesYou : .element.bubblesNotYou,
cornerRadius: cornerRadius, cornerRadius: cornerRadius,
corners: timelineItem.roundedCorners) corners: roundedCorners)
} }
} }
private var messageBubbleTopPadding: CGFloat { private var messageBubbleTopPadding: CGFloat {
guard timelineItem.isOutgoing else { return 0 } 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 { private var shouldAvoidBubbling: Bool {
@@ -135,6 +135,31 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
private var alignment: HorizontalAlignment { private var alignment: HorizontalAlignment {
timelineItem.isOutgoing ? .trailing : .leading 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 { private extension View {
@@ -152,7 +177,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
let item = MockRoomTimelineController().timelineItems[index] let item = MockRoomTimelineController().timelineItems[index]
RoomTimelineViewFactory().buildTimelineViewFor(timelineItem: item) RoomTimelineViewProvider(timelineItem: item, grouping: .single)
.padding(TimelineStyle.bubbles.rowInsets) // Insets added in the table view cells .padding(TimelineStyle.bubbles.rowInsets) // Insets added in the table view cells
} }
} }
@@ -19,6 +19,7 @@ import SwiftUI
struct TimelineItemPlainStylerView<Content: View>: View { struct TimelineItemPlainStylerView<Content: View>: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context @EnvironmentObject private var context: RoomScreenViewModel.Context
@Environment(\.timelineGroupStyle) private var timelineStyleGrouping
let timelineItem: EventBasedTimelineItemProtocol let timelineItem: EventBasedTimelineItemProtocol
@ViewBuilder let content: () -> Content @ViewBuilder let content: () -> Content
@@ -44,7 +45,7 @@ struct TimelineItemPlainStylerView<Content: View>: View {
@ViewBuilder @ViewBuilder
private var header: some View { private var header: some View {
if timelineItem.shouldShowSenderDetails { if shouldShowSenderDetails {
HStack { HStack {
TimelineSenderAvatarView(timelineItem: timelineItem) TimelineSenderAvatarView(timelineItem: timelineItem)
Text(timelineItem.sender.displayName ?? timelineItem.sender.id) Text(timelineItem.sender.displayName ?? timelineItem.sender.id)
@@ -78,6 +79,10 @@ struct TimelineItemPlainStylerView<Content: View>: View {
} }
} }
} }
private var shouldShowSenderDetails: Bool {
timelineStyleGrouping.shouldShowSenderDetails
}
} }
struct TimelineItemPlainStylerView_Previews: PreviewProvider { struct TimelineItemPlainStylerView_Previews: PreviewProvider {
@@ -87,7 +92,7 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
let item = MockRoomTimelineController().timelineItems[index] let item = MockRoomTimelineController().timelineItems[index]
RoomTimelineViewFactory().buildTimelineViewFor(timelineItem: item) RoomTimelineViewProvider(timelineItem: item, grouping: .single)
.padding(TimelineStyle.plain.rowInsets) // Insets added in the table view cells .padding(TimelineStyle.plain.rowInsets) // Insets added in the table view cells
} }
} }
@@ -32,21 +32,50 @@ enum TimelineStyle: String, CaseIterable {
} }
} }
enum TimelineGroupStyle: Hashable {
case single
case first
case middle
case last
var shouldShowSenderDetails: Bool {
switch self {
case .single, .first:
return true
default:
return false
}
}
}
// MARK: - Environment // MARK: - Environment
private struct TimelineStyleKey: EnvironmentKey { private struct TimelineStyleKey: EnvironmentKey {
static let defaultValue = TimelineStyle.bubbles static let defaultValue = TimelineStyle.bubbles
} }
private struct TimelineGroupStyleKey: EnvironmentKey {
static let defaultValue = TimelineGroupStyle.single
}
extension EnvironmentValues { extension EnvironmentValues {
var timelineStyle: TimelineStyle { var timelineStyle: TimelineStyle {
get { self[TimelineStyleKey.self] } get { self[TimelineStyleKey.self] }
set { self[TimelineStyleKey.self] = newValue } set { self[TimelineStyleKey.self] = newValue }
} }
var timelineGroupStyle: TimelineGroupStyle {
get { self[TimelineGroupStyleKey.self] }
set { self[TimelineGroupStyleKey.self] = newValue }
}
} }
extension View { extension View {
func timelineStyle(_ style: TimelineStyle) -> some View { func timelineStyle(_ style: TimelineStyle) -> some View {
environment(\.timelineStyle, style) environment(\.timelineStyle, style)
} }
func timelineStyleGrouping(_ grouping: TimelineGroupStyle) -> some View {
environment(\.timelineGroupStyle, grouping)
}
} }
@@ -45,7 +45,6 @@ struct AudioRoomTimelineView_Previews: PreviewProvider {
AudioRoomTimelineView(timelineItem: AudioRoomTimelineItem(id: UUID().uuidString, AudioRoomTimelineView(timelineItem: AudioRoomTimelineItem(id: UUID().uuidString,
body: "audio.ogg", body: "audio.ogg",
timestamp: "Now", timestamp: "Now",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
@@ -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
}
}
}
}
@@ -55,7 +55,6 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider {
EmoteRoomTimelineItem(id: UUID().uuidString, EmoteRoomTimelineItem(id: UUID().uuidString,
body: text, body: text,
timestamp: timestamp, timestamp: timestamp,
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: senderId)) sender: .init(id: senderId))
@@ -69,7 +69,6 @@ struct EncryptedRoomTimelineView_Previews: PreviewProvider {
body: text, body: text,
encryptionType: .unknown, encryptionType: .unknown,
timestamp: timestamp, timestamp: timestamp,
groupState: .single,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: false, isEditable: false,
sender: .init(id: senderId)) sender: .init(id: senderId))
@@ -46,7 +46,6 @@ struct FileRoomTimelineView_Previews: PreviewProvider {
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString, FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
body: "document.pdf", body: "document.pdf",
timestamp: "Now", timestamp: "Now",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
@@ -56,7 +55,6 @@ struct FileRoomTimelineView_Previews: PreviewProvider {
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString, FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
body: "document.docx", body: "document.docx",
timestamp: "Now", timestamp: "Now",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
@@ -66,7 +64,6 @@ struct FileRoomTimelineView_Previews: PreviewProvider {
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString, FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
body: "document.txt", body: "document.txt",
timestamp: "Now", timestamp: "Now",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
@@ -74,7 +74,6 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
body: "Some image", body: "Some image",
timestamp: "Now", timestamp: "Now",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
@@ -83,7 +82,6 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
body: "Some other image", body: "Some other image",
timestamp: "Now", timestamp: "Now",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
@@ -92,7 +90,6 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString, ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
body: "Blurhashed image", body: "Blurhashed image",
timestamp: "Now", timestamp: "Now",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
@@ -66,7 +66,6 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider {
NoticeRoomTimelineItem(id: UUID().uuidString, NoticeRoomTimelineItem(id: UUID().uuidString,
body: text, body: text,
timestamp: timestamp, timestamp: timestamp,
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: senderId)) sender: .init(id: senderId))
@@ -41,25 +41,23 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
static let item = ReadMarkerRoomTimelineItem() static let item = ReadMarkerRoomTimelineItem()
static var previews: some View { static var previews: some View {
VStack(alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 0) {
RoomTimelineViewProvider.separator(.init(text: "Today")) RoomTimelineViewProvider.separator(.init(text: "Today"), .single)
RoomTimelineViewProvider.text(.init(id: "", RoomTimelineViewProvider.text(.init(id: "",
body: "This is another message", body: "This is another message",
timestamp: "", timestamp: "",
groupState: .single,
isOutgoing: true, isOutgoing: true,
isEditable: false, isEditable: false,
sender: .init(id: "1", displayName: "Bob"))) sender: .init(id: "1", displayName: "Bob")), .single)
ReadMarkerRoomTimelineView(timelineItem: item) ReadMarkerRoomTimelineView(timelineItem: item)
RoomTimelineViewProvider.separator(.init(text: "Today")) RoomTimelineViewProvider.separator(.init(text: "Today"), .single)
RoomTimelineViewProvider.text(.init(id: "", RoomTimelineViewProvider.text(.init(id: "",
body: "This is a message", body: "This is a message",
timestamp: "", timestamp: "",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "", displayName: "Alice"))) sender: .init(id: "", displayName: "Alice")), .single)
} }
.padding(.horizontal, 8) .padding(.horizontal, 8)
.environmentObject(viewModel.context) .environmentObject(viewModel.context)
@@ -45,7 +45,6 @@ struct RedactedRoomTimelineView_Previews: PreviewProvider {
RedactedRoomTimelineItem(id: UUID().uuidString, RedactedRoomTimelineItem(id: UUID().uuidString,
body: text, body: text,
timestamp: timestamp, timestamp: timestamp,
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: senderId)) sender: .init(id: senderId))
@@ -43,7 +43,6 @@ struct StateRoomTimelineView_Previews: PreviewProvider {
static let item = StateRoomTimelineItem(id: UUID().uuidString, static let item = StateRoomTimelineItem(id: UUID().uuidString,
body: "Alice joined", body: "Alice joined",
timestamp: "Now", timestamp: "Now",
groupState: .beginning,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "")) sender: .init(id: ""))
@@ -59,7 +59,6 @@ struct StickerRoomTimelineView_Previews: PreviewProvider {
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString, StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString,
body: "Some image", body: "Some image",
timestamp: "Now", timestamp: "Now",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
@@ -68,7 +67,6 @@ struct StickerRoomTimelineView_Previews: PreviewProvider {
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString, StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString,
body: "Some other image", body: "Some other image",
timestamp: "Now", timestamp: "Now",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
@@ -77,7 +75,6 @@ struct StickerRoomTimelineView_Previews: PreviewProvider {
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString, StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString,
body: "Blurhashed image", body: "Blurhashed image",
timestamp: "Now", timestamp: "Now",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
@@ -67,7 +67,6 @@ struct TextRoomTimelineView_Previews: PreviewProvider {
TextRoomTimelineItem(id: UUID().uuidString, TextRoomTimelineItem(id: UUID().uuidString,
body: text, body: text,
timestamp: timestamp, timestamp: timestamp,
groupState: .single,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: isOutgoing, isEditable: isOutgoing,
sender: .init(id: senderId)) sender: .init(id: senderId))
@@ -66,7 +66,6 @@ struct UnsupportedRoomTimelineView_Previews: PreviewProvider {
eventType: "Some Event Type", eventType: "Some Event Type",
error: "Something went wrong", error: "Something went wrong",
timestamp: timestamp, timestamp: timestamp,
groupState: .single,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: false, isEditable: false,
sender: .init(id: senderId)) sender: .init(id: senderId))
@@ -69,7 +69,6 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString, VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
body: "Some video", body: "Some video",
timestamp: "Now", timestamp: "Now",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
@@ -80,7 +79,6 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString, VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
body: "Some other video", body: "Some other video",
timestamp: "Now", timestamp: "Now",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
@@ -91,7 +89,6 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString, VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
body: "Blurhashed video", body: "Blurhashed video",
timestamp: "Now", timestamp: "Now",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "Bob"), sender: .init(id: "Bob"),
@@ -83,7 +83,6 @@ struct TimelineView: UIViewControllerRepresentable {
struct TimelineTableView_Previews: PreviewProvider { struct TimelineTableView_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(), static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: MockMediaProvider(), mediaProvider: MockMediaProvider(),
roomName: "Preview room") roomName: "Preview room")
@@ -23,7 +23,6 @@ enum RoomTimelineItemFixtures {
TextRoomTimelineItem(id: UUID().uuidString, TextRoomTimelineItem(id: UUID().uuidString,
body: "That looks so good!", body: "That looks so good!",
timestamp: "10:10 AM", timestamp: "10:10 AM",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "", displayName: "Jacob"), sender: .init(id: "", displayName: "Jacob"),
@@ -31,7 +30,6 @@ enum RoomTimelineItemFixtures {
TextRoomTimelineItem(id: UUID().uuidString, TextRoomTimelineItem(id: UUID().uuidString,
body: "Let’s get lunch soon! New salad place opened up 🥗. When are y’all free? 🤗", body: "Let’s get lunch soon! New salad place opened up 🥗. When are y’all free? 🤗",
timestamp: "10:11 AM", timestamp: "10:11 AM",
groupState: .beginning,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "", displayName: "Helena"), sender: .init(id: "", displayName: "Helena"),
@@ -41,7 +39,6 @@ enum RoomTimelineItemFixtures {
TextRoomTimelineItem(id: UUID().uuidString, TextRoomTimelineItem(id: UUID().uuidString,
body: "I can be around on Wednesday. How about some 🌮 instead? Like https://www.tortilla.co.uk/", body: "I can be around on Wednesday. How about some 🌮 instead? Like https://www.tortilla.co.uk/",
timestamp: "10:11 AM", timestamp: "10:11 AM",
groupState: .end,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "", displayName: "Helena"), sender: .init(id: "", displayName: "Helena"),
@@ -53,21 +50,18 @@ enum RoomTimelineItemFixtures {
TextRoomTimelineItem(id: UUID().uuidString, 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!", 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", timestamp: "5 PM",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "", displayName: "Helena")), sender: .init(id: "", displayName: "Helena")),
TextRoomTimelineItem(id: UUID().uuidString, TextRoomTimelineItem(id: UUID().uuidString,
body: "And John's speech was amazing!", body: "And John's speech was amazing!",
timestamp: "5 PM", timestamp: "5 PM",
groupState: .beginning,
isOutgoing: true, isOutgoing: true,
isEditable: true, isEditable: true,
sender: .init(id: "", displayName: "Bob")), sender: .init(id: "", displayName: "Bob")),
TextRoomTimelineItem(id: UUID().uuidString, TextRoomTimelineItem(id: UUID().uuidString,
body: "New home office set up!", body: "New home office set up!",
timestamp: "5 PM", timestamp: "5 PM",
groupState: .end,
isOutgoing: true, isOutgoing: true,
isEditable: true, isEditable: true,
sender: .init(id: "", displayName: "Bob"), sender: .init(id: "", displayName: "Bob"),
@@ -79,7 +73,6 @@ enum RoomTimelineItemFixtures {
body: "", body: "",
formattedBody: AttributedStringBuilder().fromHTML("Hol' up <blockquote>New home office set up!</blockquote>That's amazing! Congrats 🥳"), formattedBody: AttributedStringBuilder().fromHTML("Hol' up <blockquote>New home office set up!</blockquote>That's amazing! Congrats 🥳"),
timestamp: "5 PM", timestamp: "5 PM",
groupState: .single,
isOutgoing: false, isOutgoing: false,
isEditable: false, isEditable: false,
sender: .init(id: "", displayName: "Helena")) sender: .init(id: "", displayName: "Helena"))
@@ -88,24 +81,20 @@ enum RoomTimelineItemFixtures {
/// A small chunk of events, containing 2 text items. /// A small chunk of events, containing 2 text items.
static var smallChunk: [RoomTimelineItemProtocol] { static var smallChunk: [RoomTimelineItemProtocol] {
[TextRoomTimelineItem(text: "Hey there 👋", [TextRoomTimelineItem(text: "Hey there 👋",
groupState: .beginning,
senderDisplayName: "Alice"), senderDisplayName: "Alice"),
TextRoomTimelineItem(text: "How are you?", TextRoomTimelineItem(text: "How are you?",
groupState: .end,
senderDisplayName: "Alice")] senderDisplayName: "Alice")]
} }
/// A chunk of events that contains a single text item. /// A chunk of events that contains a single text item.
static var singleMessageChunk: [RoomTimelineItemProtocol] { static var singleMessageChunk: [RoomTimelineItemProtocol] {
[TextRoomTimelineItem(text: "Tap tap tap 🎙️. Is this thing on?", [TextRoomTimelineItem(text: "Tap tap tap 🎙️. Is this thing on?",
groupState: .single,
senderDisplayName: "Helena")] senderDisplayName: "Helena")]
} }
/// A single text item. /// A single text item.
static var incomingMessage: RoomTimelineItemProtocol { static var incomingMessage: RoomTimelineItemProtocol {
TextRoomTimelineItem(text: "Hello, World!", TextRoomTimelineItem(text: "Hello, World!",
groupState: .single,
senderDisplayName: "Bob") senderDisplayName: "Bob")
} }
@@ -196,11 +185,10 @@ enum RoomTimelineItemFixtures {
} }
private extension TextRoomTimelineItem { private extension TextRoomTimelineItem {
init(text: String, groupState: TimelineItemGroupState = .single, senderDisplayName: String) { init(text: String, senderDisplayName: String) {
self.init(id: UUID().uuidString, self.init(id: UUID().uuidString,
body: text, body: text,
timestamp: "10:47 am", timestamp: "10:47 am",
groupState: groupState,
isOutgoing: senderDisplayName == "Alice", isOutgoing: senderDisplayName == "Alice",
isEditable: false, isEditable: false,
sender: .init(id: "", displayName: senderDisplayName)) sender: .init(id: "", displayName: senderDisplayName))
@@ -230,61 +230,30 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
updateTimelineItems() updateTimelineItems()
} }
// swiftlint:disable:next cyclomatic_complexity
private func updateTimelineItems() { private func updateTimelineItems() {
var newTimelineItems = [RoomTimelineItemProtocol]() var newTimelineItems = [RoomTimelineItemProtocol]()
var canBackPaginate = true var canBackPaginate = true
var isBackPaginating = false var isBackPaginating = false
var createdIdentifiers = [String: Bool]() 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 { let collapsibleChunks = timelineProvider.itemsPublisher.value.groupBy { isItemCollapsible($0) }
return
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 timelineItems = newTimelineItems
@@ -294,49 +263,63 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
callbacks.send(.isBackPaginating(isBackPaginating)) callbacks.send(.isBackPaginating(isBackPaginating))
} }
private func computeGroupState(for itemProxy: TimelineItemProxy, private func buildTimelineItemFor(itemProxy: TimelineItemProxy,
previousItemProxy: TimelineItemProxy?, isLastItem: Bool,
nextItemProxy: TimelineItemProxy?) -> TimelineItemGroupState { createdIdentifiers: inout [String: Bool],
guard let previousItem = previousItemProxy else { isBackPaginating: inout Bool,
// no previous item, check next item canBackPaginate: inout Bool) -> RoomTimelineItemProtocol? {
guard let nextItem = nextItemProxy else { switch itemProxy {
// no next item neither, this is single case .event(let eventItemProxy):
return .single 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 { case .virtual(let virtualItem):
// there is a next item but can't be grouped, this is single switch virtualItem {
return .single 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 case .unknown:
return .beginning return nil
} }
guard let nextItem = nextItemProxy else { return nil
// no next item }
guard itemProxy.canBeGrouped(with: previousItem) else {
// there is a previous item but can't be grouped, this is single private func isItemCollapsible(_ item: TimelineItemProxy) -> Bool {
return .single 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 { return false
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
} }
private func loadVideoForTimelineItem(_ timelineItem: VideoRoomTimelineItem) async { private func loadVideoForTimelineItem(_ timelineItem: VideoRoomTimelineItem) async {
@@ -20,7 +20,6 @@ import UIKit
enum RoomTimelineControllerCallback { enum RoomTimelineControllerCallback {
case updatedTimelineItems case updatedTimelineItems
case updatedTimelineItem(_ itemId: String)
case canBackPaginate(Bool) case canBackPaginate(Bool)
case isBackPaginating(Bool) case isBackPaginating(Bool)
} }
@@ -32,20 +32,6 @@ enum TimelineItemProxy {
self = .unknown(item) 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. /// The delivery status for the item.
@@ -17,27 +17,9 @@
import Foundation import Foundation
import UIKit 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 { protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol, CustomStringConvertible {
var body: String { get } var body: String { get }
var timestamp: String { get } var timestamp: String { get }
var shouldShowSenderDetails: Bool { get }
var groupState: TimelineItemGroupState { get }
var isOutgoing: Bool { get } var isOutgoing: Bool { get }
var isEditable: Bool { get } var isEditable: Bool { get }
@@ -47,31 +29,6 @@ protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol, CustomStringC
} }
extension EventBasedTimelineItemProtocol { 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 { var description: String {
"\(String(describing: Self.self)): id: \(id), timestamp: \(timestamp), isOutgoing: \(isOutgoing), properties: \(properties)" "\(String(describing: Self.self)): id: \(id), timestamp: \(timestamp), isOutgoing: \(isOutgoing), properties: \(properties)"
} }
@@ -20,7 +20,6 @@ struct AudioRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let id: String let id: String
let body: String let body: String
let timestamp: String let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
let sender: TimelineItemSender let sender: TimelineItemSender
@@ -21,7 +21,6 @@ struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let body: String let body: String
var formattedBody: AttributedString? var formattedBody: AttributedString?
let timestamp: String let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
@@ -20,7 +20,6 @@ struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha
let id: String let id: String
let body: String let body: String
let timestamp: String let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
@@ -21,7 +21,6 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let id: String let id: String
let body: String let body: String
let timestamp: String let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
@@ -21,7 +21,6 @@ struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Has
let body: String let body: String
var formattedBody: AttributedString? var formattedBody: AttributedString?
let timestamp: String let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
@@ -21,7 +21,6 @@ struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha
let body: String let body: String
var formattedBody: AttributedString? var formattedBody: AttributedString?
let timestamp: String let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
@@ -20,7 +20,6 @@ struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let id: String let id: String
let body: String let body: String
let timestamp: String let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
@@ -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)
}
}
@@ -27,7 +27,6 @@ struct EncryptedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable,
let body: String let body: String
let encryptionType: EncryptionType let encryptionType: EncryptionType
let timestamp: String let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
@@ -21,7 +21,6 @@ struct RedactedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, H
let id: String let id: String
let body: String let body: String
let timestamp: String let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
@@ -20,7 +20,6 @@ struct StateRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let id: String let id: String
let body: String let body: String
let timestamp: String let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
@@ -20,7 +20,6 @@ struct StickerRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Ha
let id: String let id: String
let body: String let body: String
let timestamp: String let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
@@ -24,7 +24,6 @@ struct UnsupportedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable
let error: String let error: String
let timestamp: String let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool let isOutgoing: Bool
let isEditable: Bool let isEditable: Bool
@@ -36,53 +36,52 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
} }
// swiftlint:disable:next cyclomatic_complexity // swiftlint:disable:next cyclomatic_complexity
func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy, func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy) -> RoomTimelineItemProtocol? {
groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol? {
let isOutgoing = eventItemProxy.isOwn let isOutgoing = eventItemProxy.isOwn
switch eventItemProxy.content.kind() { switch eventItemProxy.content.kind() {
case .unableToDecrypt(let encryptedMessage): case .unableToDecrypt(let encryptedMessage):
return buildEncryptedTimelineItem(eventItemProxy, encryptedMessage, isOutgoing, groupState) return buildEncryptedTimelineItem(eventItemProxy, encryptedMessage, isOutgoing)
case .redactedMessage: case .redactedMessage:
return buildRedactedTimelineItem(eventItemProxy, isOutgoing, groupState) return buildRedactedTimelineItem(eventItemProxy, isOutgoing)
case .sticker(let body, let imageInfo, let urlString): case .sticker(let body, let imageInfo, let urlString):
guard let url = URL(string: urlString) else { guard let url = URL(string: urlString) else {
MXLog.error("Invalid sticker url string: \(urlString)") 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): 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): case .failedToParseState(let eventType, _, let error):
return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing, groupState) return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing)
case .message: case .message:
guard let messageContent = eventItemProxy.content.asMessage() else { fatalError("Invalid message timeline item: \(eventItemProxy)") } guard let messageContent = eventItemProxy.content.asMessage() else { fatalError("Invalid message timeline item: \(eventItemProxy)") }
switch messageContent.msgtype() { switch messageContent.msgtype() {
case .text(content: let content): case .text(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: 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): case .image(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content) let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildImageTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) return buildImageTimelineItemFromMessage(eventItemProxy, message, isOutgoing)
case .video(let content): case .video(let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content) let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildVideoTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) return buildVideoTimelineItemFromMessage(eventItemProxy, message, isOutgoing)
case .file(let content): case .file(let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content) let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildFileTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) return buildFileTimelineItemFromMessage(eventItemProxy, message, isOutgoing)
case .notice(let content): case .notice(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content) let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildNoticeTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) return buildNoticeTimelineItemFromMessage(eventItemProxy, message, isOutgoing)
case .emote(let content): case .emote(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content) let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildEmoteTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState) return buildEmoteTimelineItemFromMessage(eventItemProxy, message, isOutgoing)
case .audio(let content): case .audio(let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content) let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildAudioTimelineItem(eventItemProxy, message, isOutgoing, groupState) return buildAudioTimelineItem(eventItemProxy, message, isOutgoing)
case .none: case .none:
return buildFallbackTimelineItem(eventItemProxy, isOutgoing, groupState) return buildFallbackTimelineItem(eventItemProxy, isOutgoing)
} }
case .state(let stateKey, let content): case .state(let stateKey, let content):
return buildStateTimelineItemFor(eventItemProxy: eventItemProxy, state: content, stateKey: stateKey, isOutgoing: isOutgoing) return buildStateTimelineItemFor(eventItemProxy: eventItemProxy, state: content, stateKey: stateKey, isOutgoing: isOutgoing)
@@ -98,27 +97,23 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildUnsupportedTimelineItem(_ eventItemProxy: EventTimelineItemProxy, private func buildUnsupportedTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ eventType: String, _ eventType: String,
_ error: String, _ error: String,
_ isOutgoing: Bool, _ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
UnsupportedRoomTimelineItem(id: eventItemProxy.id, UnsupportedRoomTimelineItem(id: eventItemProxy.id,
body: ElementL10n.roomTimelineItemUnsupported, body: ElementL10n.roomTimelineItemUnsupported,
eventType: eventType, eventType: eventType,
error: error, error: error,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable, isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
properties: RoomTimelineItemProperties()) properties: RoomTimelineItemProperties())
} }
// swiftlint:disable:next function_parameter_count
private func buildStickerTimelineItem(_ eventItemProxy: EventTimelineItemProxy, private func buildStickerTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ body: String, _ body: String,
_ imageInfo: ImageInfo, _ imageInfo: ImageInfo,
_ imageURL: URL, _ imageURL: URL,
_ isOutgoing: Bool, _ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
var aspectRatio: CGFloat? var aspectRatio: CGFloat?
let width = imageInfo.width.map(CGFloat.init) let width = imageInfo.width.map(CGFloat.init)
let height = imageInfo.height.map(CGFloat.init) let height = imageInfo.height.map(CGFloat.init)
@@ -129,7 +124,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
return StickerRoomTimelineItem(id: eventItemProxy.id, return StickerRoomTimelineItem(id: eventItemProxy.id,
body: body, body: body,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable, isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
@@ -144,8 +138,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildEncryptedTimelineItem(_ eventItemProxy: EventTimelineItemProxy, private func buildEncryptedTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ encryptedMessage: EncryptedMessage, _ encryptedMessage: EncryptedMessage,
_ isOutgoing: Bool, _ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
var encryptionType = EncryptedRoomTimelineItem.EncryptionType.unknown var encryptionType = EncryptedRoomTimelineItem.EncryptionType.unknown
switch encryptedMessage { switch encryptedMessage {
case .megolmV1AesSha2(let sessionId): case .megolmV1AesSha2(let sessionId):
@@ -160,7 +153,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
body: ElementL10n.roomTimelineUnableToDecrypt, body: ElementL10n.roomTimelineUnableToDecrypt,
encryptionType: encryptionType, encryptionType: encryptionType,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable, isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
@@ -168,12 +160,10 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
} }
private func buildRedactedTimelineItem(_ eventItemProxy: EventTimelineItemProxy, private func buildRedactedTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ isOutgoing: Bool, _ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
RedactedRoomTimelineItem(id: eventItemProxy.id, RedactedRoomTimelineItem(id: eventItemProxy.id,
body: ElementL10n.eventRedacted, body: ElementL10n.eventRedacted,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable, isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
@@ -181,15 +171,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
} }
private func buildFallbackTimelineItem(_ eventItemProxy: EventTimelineItemProxy, private func buildFallbackTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ isOutgoing: Bool, _ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
let formattedBody = attributedStringBuilder.fromPlain(eventItemProxy.body) let formattedBody = attributedStringBuilder.fromPlain(eventItemProxy.body)
return TextRoomTimelineItem(id: eventItemProxy.id, return TextRoomTimelineItem(id: eventItemProxy.id,
body: eventItemProxy.body ?? "", body: eventItemProxy.body ?? "",
formattedBody: formattedBody, formattedBody: formattedBody,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable, isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
@@ -199,15 +187,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildTextTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, private func buildTextTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<TextMessageContent>, _ message: MessageTimelineItem<TextMessageContent>,
_ isOutgoing: Bool, _ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
let formattedBody = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body)) let formattedBody = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
return TextRoomTimelineItem(id: message.id, return TextRoomTimelineItem(id: message.id,
body: message.body, body: message.body,
formattedBody: formattedBody, formattedBody: formattedBody,
timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), timestamp: message.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable, isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
@@ -218,8 +204,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildImageTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, private func buildImageTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<ImageMessageContent>, _ message: MessageTimelineItem<ImageMessageContent>,
_ isOutgoing: Bool, _ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
var aspectRatio: CGFloat? var aspectRatio: CGFloat?
if let width = message.width, if let width = message.width,
let height = message.height { let height = message.height {
@@ -229,7 +214,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
return ImageRoomTimelineItem(id: message.id, return ImageRoomTimelineItem(id: message.id,
body: message.body, body: message.body,
timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), timestamp: message.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable, isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
@@ -246,8 +230,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildVideoTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, private func buildVideoTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<VideoMessageContent>, _ message: MessageTimelineItem<VideoMessageContent>,
_ isOutgoing: Bool, _ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
var aspectRatio: CGFloat? var aspectRatio: CGFloat?
if let width = message.width, if let width = message.width,
let height = message.height { let height = message.height {
@@ -257,7 +240,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
return VideoRoomTimelineItem(id: message.id, return VideoRoomTimelineItem(id: message.id,
body: message.body, body: message.body,
timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), timestamp: message.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable, isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
@@ -275,12 +257,10 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildAudioTimelineItem(_ eventItemProxy: EventTimelineItemProxy, private func buildAudioTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<AudioMessageContent>, _ message: MessageTimelineItem<AudioMessageContent>,
_ isOutgoing: Bool, _ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
AudioRoomTimelineItem(id: eventItemProxy.id, AudioRoomTimelineItem(id: eventItemProxy.id,
body: message.body, body: message.body,
timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), timestamp: message.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable, isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
@@ -290,12 +270,10 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildFileTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, private func buildFileTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<FileMessageContent>, _ message: MessageTimelineItem<FileMessageContent>,
_ isOutgoing: Bool, _ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
FileRoomTimelineItem(id: message.id, FileRoomTimelineItem(id: message.id,
body: message.body, body: message.body,
timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), timestamp: message.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable, isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
@@ -308,15 +286,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildNoticeTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, private func buildNoticeTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<NoticeMessageContent>, _ message: MessageTimelineItem<NoticeMessageContent>,
_ isOutgoing: Bool, _ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
let formattedBody = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body)) let formattedBody = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
return NoticeRoomTimelineItem(id: message.id, return NoticeRoomTimelineItem(id: message.id,
body: message.body, body: message.body,
formattedBody: formattedBody, formattedBody: formattedBody,
timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), timestamp: message.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable, isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
@@ -327,8 +303,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildEmoteTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy, private func buildEmoteTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<EmoteMessageContent>, _ message: MessageTimelineItem<EmoteMessageContent>,
_ isOutgoing: Bool, _ isOutgoing: Bool) -> RoomTimelineItemProtocol {
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
let name = eventItemProxy.sender.displayName ?? eventItemProxy.sender.id let name = eventItemProxy.sender.displayName ?? eventItemProxy.sender.id
var formattedBody: AttributedString? var formattedBody: AttributedString?
@@ -342,7 +317,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
body: message.body, body: message.body,
formattedBody: formattedBody, formattedBody: formattedBody,
timestamp: message.timestamp.formatted(date: .omitted, time: .shortened), timestamp: message.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable, isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender, sender: eventItemProxy.sender,
@@ -396,7 +370,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
StateRoomTimelineItem(id: eventItemProxy.id, StateRoomTimelineItem(id: eventItemProxy.id,
body: text, body: text,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened), timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
groupState: .single,
isOutgoing: isOutgoing, isOutgoing: isOutgoing,
isEditable: false, isEditable: false,
sender: eventItemProxy.sender) sender: eventItemProxy.sender)
@@ -18,6 +18,5 @@ import Foundation
@MainActor @MainActor
protocol RoomTimelineItemFactoryProtocol { protocol RoomTimelineItemFactoryProtocol {
func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy, func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy) -> RoomTimelineItemProtocol?
groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol?
} }
@@ -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")
}
}
}
@@ -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
}
@@ -18,56 +18,85 @@ import Foundation
import SwiftUI import SwiftUI
enum RoomTimelineViewProvider: Identifiable, Hashable { enum RoomTimelineViewProvider: Identifiable, Hashable {
case text(TextRoomTimelineItem) case text(TextRoomTimelineItem, TimelineGroupStyle)
case separator(SeparatorRoomTimelineItem) case separator(SeparatorRoomTimelineItem, TimelineGroupStyle)
case image(ImageRoomTimelineItem) case image(ImageRoomTimelineItem, TimelineGroupStyle)
case video(VideoRoomTimelineItem) case video(VideoRoomTimelineItem, TimelineGroupStyle)
case audio(AudioRoomTimelineItem) case audio(AudioRoomTimelineItem, TimelineGroupStyle)
case file(FileRoomTimelineItem) case file(FileRoomTimelineItem, TimelineGroupStyle)
case emote(EmoteRoomTimelineItem) case emote(EmoteRoomTimelineItem, TimelineGroupStyle)
case notice(NoticeRoomTimelineItem) case notice(NoticeRoomTimelineItem, TimelineGroupStyle)
case redacted(RedactedRoomTimelineItem) case redacted(RedactedRoomTimelineItem, TimelineGroupStyle)
case encrypted(EncryptedRoomTimelineItem) case encrypted(EncryptedRoomTimelineItem, TimelineGroupStyle)
case readMarker(ReadMarkerRoomTimelineItem) case readMarker(ReadMarkerRoomTimelineItem, TimelineGroupStyle)
case paginationIndicator(PaginationIndicatorRoomTimelineItem) case paginationIndicator(PaginationIndicatorRoomTimelineItem, TimelineGroupStyle)
case sticker(StickerRoomTimelineItem) case sticker(StickerRoomTimelineItem, TimelineGroupStyle)
case unsupported(UnsupportedRoomTimelineItem) case unsupported(UnsupportedRoomTimelineItem, TimelineGroupStyle)
case timelineStart(TimelineStartRoomTimelineItem) case timelineStart(TimelineStartRoomTimelineItem, TimelineGroupStyle)
case state(StateRoomTimelineItem) 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 { var id: String {
switch self { switch self {
case .text(let item): case .text(let item as RoomTimelineItemProtocol, _),
return item.id .separator(let item as RoomTimelineItemProtocol, _),
case .separator(let item): .image(let item as RoomTimelineItemProtocol, _),
return item.id .video(let item as RoomTimelineItemProtocol, _),
case .image(let item): .audio(let item as RoomTimelineItemProtocol, _),
return item.id .file(let item as RoomTimelineItemProtocol, _),
case .video(let item): .emote(let item as RoomTimelineItemProtocol, _),
return item.id .notice(let item as RoomTimelineItemProtocol, _),
case .audio(let item): .redacted(let item as RoomTimelineItemProtocol, _),
return item.id .encrypted(let item as RoomTimelineItemProtocol, _),
case .file(let item): .readMarker(let item as RoomTimelineItemProtocol, _),
return item.id .paginationIndicator(let item as RoomTimelineItemProtocol, _),
case .emote(let item): .sticker(let item as RoomTimelineItemProtocol, _),
return item.id .unsupported(let item as RoomTimelineItemProtocol, _),
case .notice(let item): .timelineStart(let item as RoomTimelineItemProtocol, _),
return item.id .state(let item as RoomTimelineItemProtocol, _),
case .redacted(let item): .group(let item as RoomTimelineItemProtocol, _):
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):
return item.id return item.id
} }
} }
@@ -81,45 +110,77 @@ enum RoomTimelineViewProvider: Identifiable, Hashable {
return false return false
case .timelineStart, .separator, .readMarker, .paginationIndicator: // Virtual items are never reactable case .timelineStart, .separator, .readMarker, .paginationIndicator: // Virtual items are never reactable
return false return false
case .group:
return false
} }
} }
} }
extension RoomTimelineViewProvider: View { extension RoomTimelineViewProvider: View {
@ViewBuilder var body: some View { var body: some View {
timelineView
.environment(\.timelineGroupStyle, timelineStyleGrouping)
}
@ViewBuilder private var timelineView: some View {
switch self { switch self {
case .text(let item): case .text(let item, _):
TextRoomTimelineView(timelineItem: item) TextRoomTimelineView(timelineItem: item)
case .separator(let item): case .separator(let item, _):
SeparatorRoomTimelineView(timelineItem: item) SeparatorRoomTimelineView(timelineItem: item)
case .image(let item): case .image(let item, _):
ImageRoomTimelineView(timelineItem: item) ImageRoomTimelineView(timelineItem: item)
case .video(let item): case .video(let item, _):
VideoRoomTimelineView(timelineItem: item) VideoRoomTimelineView(timelineItem: item)
case .audio(let item): case .audio(let item, _):
AudioRoomTimelineView(timelineItem: item) AudioRoomTimelineView(timelineItem: item)
case .file(let item): case .file(let item, _):
FileRoomTimelineView(timelineItem: item) FileRoomTimelineView(timelineItem: item)
case .emote(let item): case .emote(let item, _):
EmoteRoomTimelineView(timelineItem: item) EmoteRoomTimelineView(timelineItem: item)
case .notice(let item): case .notice(let item, _):
NoticeRoomTimelineView(timelineItem: item) NoticeRoomTimelineView(timelineItem: item)
case .redacted(let item): case .redacted(let item, _):
RedactedRoomTimelineView(timelineItem: item) RedactedRoomTimelineView(timelineItem: item)
case .encrypted(let item): case .encrypted(let item, _):
EncryptedRoomTimelineView(timelineItem: item) EncryptedRoomTimelineView(timelineItem: item)
case .readMarker(let item): case .readMarker(let item, _):
ReadMarkerRoomTimelineView(timelineItem: item) ReadMarkerRoomTimelineView(timelineItem: item)
case .paginationIndicator(let item): case .paginationIndicator(let item, _):
PaginationIndicatorRoomTimelineView(timelineItem: item) PaginationIndicatorRoomTimelineView(timelineItem: item)
case .sticker(let item): case .sticker(let item, _):
StickerRoomTimelineView(timelineItem: item) StickerRoomTimelineView(timelineItem: item)
case .unsupported(let item): case .unsupported(let item, _):
UnsupportedRoomTimelineView(timelineItem: item) UnsupportedRoomTimelineView(timelineItem: item)
case .timelineStart(let item): case .timelineStart(let item, _):
TimelineStartRoomTimelineView(timelineItem: item) TimelineStartRoomTimelineView(timelineItem: item)
case .state(let item): case .state(let item, _):
StateRoomTimelineView(timelineItem: 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
} }
} }
} }
+1
View File
@@ -116,6 +116,7 @@ targets:
- target: NSE - target: NSE
- package: MatrixRustSDK - package: MatrixRustSDK
- package: DesignKit - package: DesignKit
- package: Algorithms
- package: AnalyticsEvents - package: AnalyticsEvents
- package: AppAuth - package: AppAuth
- package: Collections - package: Collections
+45
View File
@@ -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]])
}
}
+6 -6
View File
@@ -212,23 +212,23 @@ class LoggingTests: XCTestCase {
let textAttributedString = "TextAttributed" let textAttributedString = "TextAttributed"
let textMessage = TextRoomTimelineItem(id: "mytextmessage", body: "TextString", let textMessage = TextRoomTimelineItem(id: "mytextmessage", body: "TextString",
formattedBody: AttributedString(textAttributedString), 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 noticeAttributedString = "NoticeAttributed"
let noticeMessage = NoticeRoomTimelineItem(id: "mynoticemessage", body: "NoticeString", let noticeMessage = NoticeRoomTimelineItem(id: "mynoticemessage", body: "NoticeString",
formattedBody: AttributedString(noticeAttributedString), 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 emoteAttributedString = "EmoteAttributed"
let emoteMessage = EmoteRoomTimelineItem(id: "myemotemessage", body: "EmoteString", let emoteMessage = EmoteRoomTimelineItem(id: "myemotemessage", body: "EmoteString",
formattedBody: AttributedString(emoteAttributedString), 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", 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) sender: .init(id: "sender"), source: nil)
let videoMessage = VideoRoomTimelineItem(id: "myvideomessage", body: "VideoString", 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) sender: .init(id: "sender"), duration: 0, source: nil, thumbnailSource: nil)
let fileMessage = FileRoomTimelineItem(id: "myfilemessage", body: "FileString", 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) sender: .init(id: "sender"), source: nil, thumbnailSource: nil)
// When logging that value // When logging that value
+3
View File
@@ -44,6 +44,9 @@ packages:
# path: ../matrix-rust-sdk # path: ../matrix-rust-sdk
DesignKit: DesignKit:
path: DesignKit path: DesignKit
Algorithms:
url: https://github.com/apple/swift-algorithms
majorVersion: 1.0.0
AnalyticsEvents: AnalyticsEvents:
url: https://github.com/matrix-org/matrix-analytics-events url: https://github.com/matrix-org/matrix-analytics-events
exactVersion: 0.4.0 exactVersion: 0.4.0