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 5a759d777d
commit bf19e3e00b
58 changed files with 679 additions and 458 deletions

View File

@@ -117,6 +117,15 @@
"version" : "7.30.2"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-algorithms",
"state" : {
"revision" : "b14b7f4c528c942f121c8b860b9410b2bf57825e",
"version" : "1.0.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
@@ -126,6 +135,15 @@
"version" : "1.0.4"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics",
"state" : {
"revision" : "0a5bc04095a675662cf24757cc0640aa2204253b",
"version" : "1.0.2"
}
},
{
"identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl",

View File

@@ -33,6 +33,8 @@
"room_timeline_image_gif" = "GIF";
"room_timeline_read_marker_title" = "New";
"room_timeline_state_changes" = "%d room changes";
"noticeRoomInviteAccepted" = "%1$@ accepted the invite";
"noticeRoomInviteAcceptedByYou" = "You accepted the invite";
"noticeRoomKnock" = "%1$@ requested to join";

View File

@@ -26,6 +26,7 @@ final class AppSettings: ObservableObject {
case isIdentifiedForAnalytics
case enableInAppNotifications
case pusherProfileTag
case shouldCollapseRoomStateEvents
}
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
@@ -61,7 +62,7 @@ final class AppSettings: ObservableObject {
/// The last known version of the app that was launched on this device, which is
/// used to detect when migrations should be run. When `nil` the app may have been
/// deleted between runs so should clear data in the shared container and keychain.
@UserSetting(key: UserDefaultsKeys.lastVersionLaunched.rawValue, defaultValue: nil, storage: store)
@UserSetting(key: UserDefaultsKeys.lastVersionLaunched.rawValue, defaultValue: nil, persistIn: store)
var lastVersionLaunched: String?
/// The default homeserver address used. This is intentionally a string without a scheme
@@ -123,27 +124,30 @@ final class AppSettings: ObservableObject {
}
/// `true` when the user has opted in to send analytics.
@UserSetting(key: UserDefaultsKeys.enableAnalytics.rawValue, defaultValue: false, storage: store)
@UserSetting(key: UserDefaultsKeys.enableAnalytics.rawValue, defaultValue: false, persistIn: store)
var enableAnalytics
/// Indicates if the device has already called identify for this session to PostHog.
/// This is separate to `enableAnalytics` as logging out leaves analytics
/// enabled, but requires the next account to be identified separately.
@UserSetting(key: UserDefaultsKeys.isIdentifiedForAnalytics.rawValue, defaultValue: false, storage: store)
@UserSetting(key: UserDefaultsKeys.isIdentifiedForAnalytics.rawValue, defaultValue: false, persistIn: store)
var isIdentifiedForAnalytics
// MARK: - Room Screen
@UserSettingRawRepresentable(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: TimelineStyle.bubbles, storage: store)
@UserSettingRawRepresentable(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: TimelineStyle.bubbles, persistIn: store)
var timelineStyle
@UserSetting(key: UserDefaultsKeys.shouldCollapseRoomStateEvents.rawValue, defaultValue: true, persistIn: nil)
var shouldCollapseRoomStateEvents
// MARK: - Notifications
@UserSetting(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: true, storage: store)
@UserSetting(key: UserDefaultsKeys.timelineStyle.rawValue, defaultValue: true, persistIn: store)
var enableInAppNotifications
/// Tag describing which set of device specific rules a pusher executes.
@UserSetting(key: UserDefaultsKeys.pusherProfileTag.rawValue, defaultValue: nil, storage: store)
@UserSetting(key: UserDefaultsKeys.pusherProfileTag.rawValue, defaultValue: nil, persistIn: store)
var pusherProfileTag: String?
// MARK: - Other

View File

@@ -120,6 +120,10 @@ extension ElementL10n {
public static func roomTimelineReplyingTo(_ p1: Any) -> String {
return ElementL10n.tr("Untranslated", "room_timeline_replying_to", String(describing: p1))
}
/// %d room changes
public static func roomTimelineStateChanges(_ p1: Int) -> String {
return ElementL10n.tr("Untranslated", "room_timeline_state_changes", p1)
}
/// Bubbles
public static let roomTimelineStyleBubbledLongDescription = ElementL10n.tr("Untranslated", "room_timeline_style_bubbled_long_description")
/// Modern

View File

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

View File

@@ -26,25 +26,31 @@ import Foundation
@propertyWrapper
struct UserSetting<Value: Equatable> {
private let key: String
private let defaultValue: Value
private let storage: UserDefaults
private var defaultValue: Value
private let storage: UserDefaults?
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.defaultValue = defaultValue
self.storage = storage
let value = storage.value(forKey: key) as? Value ?? defaultValue
let value = storage?.value(forKey: key) as? Value ?? defaultValue
publisher = CurrentValueSubject<Value, Never>(value)
}
var wrappedValue: Value {
get {
let value = storage.value(forKey: key) as? Value
let value = storage?.value(forKey: key) as? Value
return value ?? defaultValue
}
set {
guard let storage else {
defaultValue = newValue
publisher.send(defaultValue)
return
}
if let optional = newValue as? AnyOptional, optional.isNil {
storage.removeObject(forKey: key)
publisher.send(defaultValue)
@@ -61,8 +67,8 @@ struct UserSetting<Value: Equatable> {
}
extension UserSetting where Value: ExpressibleByNilLiteral {
init(key: String, storage: UserDefaults = .standard) {
self.init(key: key, defaultValue: nil, storage: storage)
init(key: String, persistIn storage: UserDefaults?) {
self.init(key: key, defaultValue: nil, persistIn: storage)
}
}
@@ -74,28 +80,34 @@ extension UserSetting where Value: ExpressibleByNilLiteral {
@propertyWrapper
struct UserSettingRawRepresentable<Value: RawRepresentable & Equatable> {
private let key: String
private let defaultValue: Value
private let storage: UserDefaults
private var defaultValue: Value
private let storage: UserDefaults?
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.defaultValue = defaultValue
self.storage = storage
let value = (storage.value(forKey: key) as? Value.RawValue).flatMap { Value(rawValue: $0) } ?? defaultValue
let value = (storage?.value(forKey: key) as? Value.RawValue).flatMap { Value(rawValue: $0) } ?? defaultValue
publisher = CurrentValueSubject<Value, Never>(value)
}
var wrappedValue: Value {
get {
guard let value = storage.value(forKey: key) as? Value.RawValue else {
guard let value = storage?.value(forKey: key) as? Value.RawValue else {
return defaultValue
}
return Value(rawValue: value) ?? defaultValue
}
set {
guard let storage else {
defaultValue = newValue
publisher.send(defaultValue)
return
}
if let optional = newValue as? AnyOptional, optional.isNil {
storage.removeObject(forKey: key)
publisher.send(newValue)
@@ -112,8 +124,8 @@ struct UserSettingRawRepresentable<Value: RawRepresentable & Equatable> {
}
extension UserSettingRawRepresentable where Value: ExpressibleByNilLiteral {
init(key: String, storage: UserDefaults = .standard) {
self.init(key: key, defaultValue: nil, storage: storage)
init(key: String, persistIn storage: UserDefaults?) {
self.init(key: key, defaultValue: nil, persistIn: storage)
}
}

View File

@@ -22,6 +22,10 @@ struct DeveloperOptionsScreenViewState: BindableState {
var bindings: DeveloperOptionsScreenViewStateBindings
}
struct DeveloperOptionsScreenViewStateBindings { }
struct DeveloperOptionsScreenViewStateBindings {
var shouldCollapseRoomStateEvents: Bool
}
enum DeveloperOptionsScreenViewAction { }
enum DeveloperOptionsScreenViewAction {
case changedShouldCollapseRoomStateEvents
}

View File

@@ -20,8 +20,19 @@ typealias DeveloperOptionsScreenViewModelType = StateStoreViewModel<DeveloperOpt
class DeveloperOptionsScreenViewModel: DeveloperOptionsScreenViewModelType, DeveloperOptionsScreenViewModelProtocol {
var callback: ((DeveloperOptionsScreenViewModelAction) -> Void)?
init() {
super.init(initialViewState: DeveloperOptionsScreenViewState(bindings: DeveloperOptionsScreenViewStateBindings()))
super.init(initialViewState: DeveloperOptionsScreenViewState(bindings: DeveloperOptionsScreenViewStateBindings(shouldCollapseRoomStateEvents: ServiceLocator.shared.settings.shouldCollapseRoomStateEvents)))
ServiceLocator.shared.settings.$shouldCollapseRoomStateEvents
.weakAssign(to: \.state.bindings.shouldCollapseRoomStateEvents, on: self)
.store(in: &cancellables)
}
override func process(viewAction: DeveloperOptionsScreenViewAction) async {
switch viewAction {
case .changedShouldCollapseRoomStateEvents:
ServiceLocator.shared.settings.shouldCollapseRoomStateEvents = state.bindings.shouldCollapseRoomStateEvents
}
}
}

View File

@@ -22,6 +22,15 @@ struct DeveloperOptionsScreenScreen: View {
var body: some View {
Form {
Section {
Toggle(isOn: $context.shouldCollapseRoomStateEvents) {
Text("Collapse room state events")
}
.onChange(of: context.shouldCollapseRoomStateEvents) { _ in
context.send(viewAction: .changedShouldCollapseRoomStateEvents)
}
}
Section {
Button {
showConfetti = true

View File

@@ -36,7 +36,6 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
self.parameters = parameters
viewModel = RoomScreenViewModel(timelineController: parameters.timelineController,
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: parameters.mediaProvider,
roomName: parameters.roomProxy.displayName ?? parameters.roomProxy.name,
roomAvatarUrl: parameters.roomProxy.avatarURL)

View File

@@ -14,6 +14,7 @@
// limitations under the License.
//
import Algorithms
import Combine
import SwiftUI
@@ -27,15 +28,12 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
private let timelineController: RoomTimelineControllerProtocol
private let timelineViewFactory: RoomTimelineViewFactoryProtocol
init(timelineController: RoomTimelineControllerProtocol,
timelineViewFactory: RoomTimelineViewFactoryProtocol,
mediaProvider: MediaProviderProtocol,
roomName: String?,
roomAvatarUrl: URL? = nil) {
self.timelineController = timelineController
self.timelineViewFactory = timelineViewFactory
super.init(initialViewState: RoomScreenViewState(roomId: timelineController.roomID,
roomTitle: roomName ?? "Unknown room 💥",
@@ -52,13 +50,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
switch callback {
case .updatedTimelineItems:
self.buildTimelineViews()
case .updatedTimelineItem(let itemId):
guard let timelineItem = self.timelineController.timelineItems.first(where: { $0.id == itemId }),
let viewIndex = self.state.items.firstIndex(where: { $0.id == itemId }) else {
return
}
self.state.items[viewIndex] = timelineViewFactory.buildTimelineViewFor(timelineItem: timelineItem)
case .canBackPaginate(let canBackPaginate):
if self.state.canBackPaginate != canBackPaginate {
self.state.canBackPaginate = canBackPaginate
@@ -161,13 +152,57 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
}
private func buildTimelineViews() {
let stateItems = timelineController.timelineItems.map { item in
timelineViewFactory.buildTimelineViewFor(timelineItem: item)
var timelineViews = [RoomTimelineViewProvider]()
let itemsGroupedByTimelineDisplayStyle = timelineController.timelineItems.chunked { current, next in
canGroupItem(timelineItem: current, with: next)
}
state.items = stateItems
for itemGroup in itemsGroupedByTimelineDisplayStyle {
guard !itemGroup.isEmpty else {
MXLog.error("Found empty item group")
continue
}
if itemGroup.count == 1 {
if let firstItem = itemGroup.first {
timelineViews.append(RoomTimelineViewProvider(timelineItem: firstItem, grouping: .single))
}
} else {
for (index, item) in itemGroup.enumerated() {
if index == 0 {
timelineViews.append(RoomTimelineViewProvider(timelineItem: item, grouping: .first))
} else if index == itemGroup.count - 1 {
timelineViews.append(RoomTimelineViewProvider(timelineItem: item, grouping: .last))
} else {
timelineViews.append(RoomTimelineViewProvider(timelineItem: item, grouping: .middle))
}
}
}
}
state.items = timelineViews
}
private func canGroupItem(timelineItem: RoomTimelineItemProtocol, with otherTimelineItem: RoomTimelineItemProtocol) -> Bool {
if timelineItem is CollapsibleTimelineItem || otherTimelineItem is CollapsibleTimelineItem {
return false
}
guard let eventTimelineItem = timelineItem as? EventBasedTimelineItemProtocol,
let otherEventTimelineItem = otherTimelineItem as? EventBasedTimelineItemProtocol else {
return false
}
// State events aren't rendered as messages so shouldn't be grouped.
if eventTimelineItem is StateRoomTimelineItem || otherEventTimelineItem is StateRoomTimelineItem {
return false
}
// can be improved by adding a date threshold
return otherEventTimelineItem.properties.reactions.isEmpty && eventTimelineItem.sender == otherEventTimelineItem.sender
}
private func sendCurrentMessage() async {
guard !state.bindings.composerText.isEmpty else {
fatalError("This message should never be empty")
@@ -298,7 +333,6 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
extension RoomScreenViewModel {
static let mock = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: MockMediaProvider(),
roomName: "Preview room")
}

View File

@@ -58,7 +58,6 @@ struct RoomHeaderView_Previews: PreviewProvider {
@ViewBuilder
static var bodyPlain: some View {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: MockMediaProvider(),
roomName: "Some Room name",
roomAvatarUrl: URL.picturesDirectory)
@@ -67,11 +66,10 @@ struct RoomHeaderView_Previews: PreviewProvider {
.previewLayout(.sizeThatFits)
.padding()
}
@ViewBuilder
static var bodyEncrypted: some View {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: MockMediaProvider(),
roomName: "Some Room name",
roomAvatarUrl: nil)

View File

@@ -125,7 +125,6 @@ struct RoomScreen: View {
struct RoomScreen_Previews: PreviewProvider {
static var previews: some View {
let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: MockMediaProvider(),
roomName: "Preview room")

View File

@@ -19,11 +19,11 @@ import SwiftUI
struct TimelineItemBubbledStylerView<Content: View>: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@Environment(\.timelineGroupStyle) private var timelineStyleGrouping
let timelineItem: EventBasedTimelineItemProtocol
@ViewBuilder let content: () -> Content
@Environment(\.colorScheme) private var colorScheme
@ScaledMetric private var senderNameVerticalPadding = 3
private let cornerRadius: CGFloat = 12
@@ -54,7 +54,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
@ViewBuilder
private var header: some View {
if timelineItem.shouldShowSenderDetails {
if shouldShowSenderDetails {
VStack {
Spacer()
.frame(height: 8)
@@ -90,7 +90,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
var messageBubble: some View {
styledContent
.contentShape(.contextMenuPreview, RoundedCornerShape(radius: cornerRadius, corners: timelineItem.roundedCorners)) // Rounded corners for the context menu animation.
.contentShape(.contextMenuPreview, RoundedCornerShape(radius: cornerRadius, corners: roundedCorners)) // Rounded corners for the context menu animation.
.contextMenu {
context.viewState.contextMenuActionProvider?(timelineItem.id).map { actions in
TimelineItemContextMenu(itemID: timelineItem.id, contextMenuActions: actions)
@@ -105,7 +105,7 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
content()
.bubbleStyle(inset: false,
cornerRadius: cornerRadius,
corners: timelineItem.roundedCorners)
corners: roundedCorners)
} else {
VStack(alignment: .trailing, spacing: 4) {
content()
@@ -119,13 +119,13 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
.bubbleStyle(inset: true,
color: timelineItem.isOutgoing ? .element.bubblesYou : .element.bubblesNotYou,
cornerRadius: cornerRadius,
corners: timelineItem.roundedCorners)
corners: roundedCorners)
}
}
private var messageBubbleTopPadding: CGFloat {
guard timelineItem.isOutgoing else { return 0 }
return timelineItem.groupState == .single || timelineItem.groupState == .beginning ? 8 : 0
return timelineStyleGrouping == .single || timelineStyleGrouping == .first ? 8 : 0
}
private var shouldAvoidBubbling: Bool {
@@ -135,6 +135,31 @@ struct TimelineItemBubbledStylerView<Content: View>: View {
private var alignment: HorizontalAlignment {
timelineItem.isOutgoing ? .trailing : .leading
}
private var roundedCorners: UIRectCorner {
switch timelineStyleGrouping {
case .single:
return .allCorners
case .first:
if timelineItem.isOutgoing {
return [.topLeft, .topRight, .bottomLeft]
} else {
return [.topLeft, .topRight, .bottomRight]
}
case .middle:
return timelineItem.isOutgoing ? [.topLeft, .bottomLeft] : [.topRight, .bottomRight]
case .last:
if timelineItem.isOutgoing {
return [.topLeft, .bottomLeft, .bottomRight]
} else {
return [.topRight, .bottomLeft, .bottomRight]
}
}
}
private var shouldShowSenderDetails: Bool {
timelineStyleGrouping.shouldShowSenderDetails
}
}
private extension View {
@@ -152,7 +177,7 @@ struct TimelineItemBubbledStylerView_Previews: PreviewProvider {
VStack(alignment: .leading, spacing: 0) {
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
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
}
}

View File

@@ -19,6 +19,7 @@ import SwiftUI
struct TimelineItemPlainStylerView<Content: View>: View {
@EnvironmentObject private var context: RoomScreenViewModel.Context
@Environment(\.timelineGroupStyle) private var timelineStyleGrouping
let timelineItem: EventBasedTimelineItemProtocol
@ViewBuilder let content: () -> Content
@@ -44,7 +45,7 @@ struct TimelineItemPlainStylerView<Content: View>: View {
@ViewBuilder
private var header: some View {
if timelineItem.shouldShowSenderDetails {
if shouldShowSenderDetails {
HStack {
TimelineSenderAvatarView(timelineItem: timelineItem)
Text(timelineItem.sender.displayName ?? timelineItem.sender.id)
@@ -78,6 +79,10 @@ struct TimelineItemPlainStylerView<Content: View>: View {
}
}
}
private var shouldShowSenderDetails: Bool {
timelineStyleGrouping.shouldShowSenderDetails
}
}
struct TimelineItemPlainStylerView_Previews: PreviewProvider {
@@ -87,7 +92,7 @@ struct TimelineItemPlainStylerView_Previews: PreviewProvider {
VStack(alignment: .leading, spacing: 0) {
ForEach(1..<MockRoomTimelineController().timelineItems.count, id: \.self) { index in
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
}
}

View File

@@ -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
private struct TimelineStyleKey: EnvironmentKey {
static let defaultValue = TimelineStyle.bubbles
}
private struct TimelineGroupStyleKey: EnvironmentKey {
static let defaultValue = TimelineGroupStyle.single
}
extension EnvironmentValues {
var timelineStyle: TimelineStyle {
get { self[TimelineStyleKey.self] }
set { self[TimelineStyleKey.self] = newValue }
}
var timelineGroupStyle: TimelineGroupStyle {
get { self[TimelineGroupStyleKey.self] }
set { self[TimelineGroupStyleKey.self] = newValue }
}
}
extension View {
func timelineStyle(_ style: TimelineStyle) -> some View {
environment(\.timelineStyle, style)
}
func timelineStyleGrouping(_ grouping: TimelineGroupStyle) -> some View {
environment(\.timelineGroupStyle, grouping)
}
}

View File

@@ -45,7 +45,6 @@ struct AudioRoomTimelineView_Previews: PreviewProvider {
AudioRoomTimelineView(timelineItem: AudioRoomTimelineItem(id: UUID().uuidString,
body: "audio.ogg",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),

View File

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

View File

@@ -55,7 +55,6 @@ struct EmoteRoomTimelineView_Previews: PreviewProvider {
EmoteRoomTimelineItem(id: UUID().uuidString,
body: text,
timestamp: timestamp,
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: senderId))

View File

@@ -69,7 +69,6 @@ struct EncryptedRoomTimelineView_Previews: PreviewProvider {
body: text,
encryptionType: .unknown,
timestamp: timestamp,
groupState: .single,
isOutgoing: isOutgoing,
isEditable: false,
sender: .init(id: senderId))

View File

@@ -46,7 +46,6 @@ struct FileRoomTimelineView_Previews: PreviewProvider {
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
body: "document.pdf",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
@@ -56,7 +55,6 @@ struct FileRoomTimelineView_Previews: PreviewProvider {
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
body: "document.docx",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
@@ -66,7 +64,6 @@ struct FileRoomTimelineView_Previews: PreviewProvider {
FileRoomTimelineView(timelineItem: FileRoomTimelineItem(id: UUID().uuidString,
body: "document.txt",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),

View File

@@ -74,7 +74,6 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
body: "Some image",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
@@ -83,7 +82,6 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
body: "Some other image",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
@@ -92,7 +90,6 @@ struct ImageRoomTimelineView_Previews: PreviewProvider {
ImageRoomTimelineView(timelineItem: ImageRoomTimelineItem(id: UUID().uuidString,
body: "Blurhashed image",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),

View File

@@ -66,7 +66,6 @@ struct NoticeRoomTimelineView_Previews: PreviewProvider {
NoticeRoomTimelineItem(id: UUID().uuidString,
body: text,
timestamp: timestamp,
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: senderId))

View File

@@ -41,25 +41,23 @@ struct ReadMarkerRoomTimelineView_Previews: PreviewProvider {
static let item = ReadMarkerRoomTimelineItem()
static var previews: some View {
VStack(alignment: .leading, spacing: 0) {
RoomTimelineViewProvider.separator(.init(text: "Today"))
RoomTimelineViewProvider.separator(.init(text: "Today"), .single)
RoomTimelineViewProvider.text(.init(id: "",
body: "This is another message",
timestamp: "",
groupState: .single,
isOutgoing: true,
isEditable: false,
sender: .init(id: "1", displayName: "Bob")))
sender: .init(id: "1", displayName: "Bob")), .single)
ReadMarkerRoomTimelineView(timelineItem: item)
RoomTimelineViewProvider.separator(.init(text: "Today"))
RoomTimelineViewProvider.separator(.init(text: "Today"), .single)
RoomTimelineViewProvider.text(.init(id: "",
body: "This is a message",
timestamp: "",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "", displayName: "Alice")))
sender: .init(id: "", displayName: "Alice")), .single)
}
.padding(.horizontal, 8)
.environmentObject(viewModel.context)

View File

@@ -45,7 +45,6 @@ struct RedactedRoomTimelineView_Previews: PreviewProvider {
RedactedRoomTimelineItem(id: UUID().uuidString,
body: text,
timestamp: timestamp,
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: senderId))

View File

@@ -43,7 +43,6 @@ struct StateRoomTimelineView_Previews: PreviewProvider {
static let item = StateRoomTimelineItem(id: UUID().uuidString,
body: "Alice joined",
timestamp: "Now",
groupState: .beginning,
isOutgoing: false,
isEditable: false,
sender: .init(id: ""))

View File

@@ -59,7 +59,6 @@ struct StickerRoomTimelineView_Previews: PreviewProvider {
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString,
body: "Some image",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
@@ -68,7 +67,6 @@ struct StickerRoomTimelineView_Previews: PreviewProvider {
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString,
body: "Some other image",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
@@ -77,7 +75,6 @@ struct StickerRoomTimelineView_Previews: PreviewProvider {
StickerRoomTimelineView(timelineItem: StickerRoomTimelineItem(id: UUID().uuidString,
body: "Blurhashed image",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),

View File

@@ -67,7 +67,6 @@ struct TextRoomTimelineView_Previews: PreviewProvider {
TextRoomTimelineItem(id: UUID().uuidString,
body: text,
timestamp: timestamp,
groupState: .single,
isOutgoing: isOutgoing,
isEditable: isOutgoing,
sender: .init(id: senderId))

View File

@@ -66,7 +66,6 @@ struct UnsupportedRoomTimelineView_Previews: PreviewProvider {
eventType: "Some Event Type",
error: "Something went wrong",
timestamp: timestamp,
groupState: .single,
isOutgoing: isOutgoing,
isEditable: false,
sender: .init(id: senderId))

View File

@@ -69,7 +69,6 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
body: "Some video",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
@@ -80,7 +79,6 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
body: "Some other video",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),
@@ -91,7 +89,6 @@ struct VideoRoomTimelineView_Previews: PreviewProvider {
VideoRoomTimelineView(timelineItem: VideoRoomTimelineItem(id: UUID().uuidString,
body: "Blurhashed video",
timestamp: "Now",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "Bob"),

View File

@@ -83,7 +83,6 @@ struct TimelineView: UIViewControllerRepresentable {
struct TimelineTableView_Previews: PreviewProvider {
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
timelineViewFactory: RoomTimelineViewFactory(),
mediaProvider: MockMediaProvider(),
roomName: "Preview room")

View File

@@ -23,7 +23,6 @@ enum RoomTimelineItemFixtures {
TextRoomTimelineItem(id: UUID().uuidString,
body: "That looks so good!",
timestamp: "10:10 AM",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "", displayName: "Jacob"),
@@ -31,7 +30,6 @@ enum RoomTimelineItemFixtures {
TextRoomTimelineItem(id: UUID().uuidString,
body: "Lets get lunch soon! New salad place opened up 🥗. When are yall free? 🤗",
timestamp: "10:11 AM",
groupState: .beginning,
isOutgoing: false,
isEditable: false,
sender: .init(id: "", displayName: "Helena"),
@@ -41,7 +39,6 @@ enum RoomTimelineItemFixtures {
TextRoomTimelineItem(id: UUID().uuidString,
body: "I can be around on Wednesday. How about some 🌮 instead? Like https://www.tortilla.co.uk/",
timestamp: "10:11 AM",
groupState: .end,
isOutgoing: false,
isEditable: false,
sender: .init(id: "", displayName: "Helena"),
@@ -53,21 +50,18 @@ enum RoomTimelineItemFixtures {
TextRoomTimelineItem(id: UUID().uuidString,
body: "Wow, cool. Ok, lets go the usual place tomorrow?! Is that too soon? Heres the menu, let me know what you want its on me!",
timestamp: "5 PM",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "", displayName: "Helena")),
TextRoomTimelineItem(id: UUID().uuidString,
body: "And John's speech was amazing!",
timestamp: "5 PM",
groupState: .beginning,
isOutgoing: true,
isEditable: true,
sender: .init(id: "", displayName: "Bob")),
TextRoomTimelineItem(id: UUID().uuidString,
body: "New home office set up!",
timestamp: "5 PM",
groupState: .end,
isOutgoing: true,
isEditable: true,
sender: .init(id: "", displayName: "Bob"),
@@ -79,7 +73,6 @@ enum RoomTimelineItemFixtures {
body: "",
formattedBody: AttributedStringBuilder().fromHTML("Hol' up <blockquote>New home office set up!</blockquote>That's amazing! Congrats 🥳"),
timestamp: "5 PM",
groupState: .single,
isOutgoing: false,
isEditable: false,
sender: .init(id: "", displayName: "Helena"))
@@ -88,24 +81,20 @@ enum RoomTimelineItemFixtures {
/// A small chunk of events, containing 2 text items.
static var smallChunk: [RoomTimelineItemProtocol] {
[TextRoomTimelineItem(text: "Hey there 👋",
groupState: .beginning,
senderDisplayName: "Alice"),
TextRoomTimelineItem(text: "How are you?",
groupState: .end,
senderDisplayName: "Alice")]
}
/// A chunk of events that contains a single text item.
static var singleMessageChunk: [RoomTimelineItemProtocol] {
[TextRoomTimelineItem(text: "Tap tap tap 🎙️. Is this thing on?",
groupState: .single,
senderDisplayName: "Helena")]
}
/// A single text item.
static var incomingMessage: RoomTimelineItemProtocol {
TextRoomTimelineItem(text: "Hello, World!",
groupState: .single,
senderDisplayName: "Bob")
}
@@ -196,11 +185,10 @@ enum RoomTimelineItemFixtures {
}
private extension TextRoomTimelineItem {
init(text: String, groupState: TimelineItemGroupState = .single, senderDisplayName: String) {
init(text: String, senderDisplayName: String) {
self.init(id: UUID().uuidString,
body: text,
timestamp: "10:47 am",
groupState: groupState,
isOutgoing: senderDisplayName == "Alice",
isEditable: false,
sender: .init(id: "", displayName: senderDisplayName))

View File

@@ -230,61 +230,30 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
updateTimelineItems()
}
// swiftlint:disable:next cyclomatic_complexity
private func updateTimelineItems() {
var newTimelineItems = [RoomTimelineItemProtocol]()
var canBackPaginate = true
var isBackPaginating = false
var createdIdentifiers = [String: Bool]()
for (index, itemProxy) in timelineProvider.itemsPublisher.value.enumerated() {
if Task.isCancelled {
return
}
let previousItemProxy = timelineProvider.itemsPublisher.value[safe: index - 1]
let nextItemProxy = timelineProvider.itemsPublisher.value[safe: index + 1]
let groupState = computeGroupState(for: itemProxy, previousItemProxy: previousItemProxy, nextItemProxy: nextItemProxy)
switch itemProxy {
case .event(let eventItemProxy):
if let timelineItem = timelineItemFactory.buildTimelineItemFor(eventItemProxy: eventItemProxy, groupState: groupState) {
#warning("This works around duplicated items coming out of the SDK, remove once fixed")
if createdIdentifiers[timelineItem.id] == nil {
newTimelineItems.append(timelineItem)
createdIdentifiers[timelineItem.id] = true
} else {
MXLog.error("Found duplicated timeline item, ignoring")
}
}
case .virtual(let virtualItem):
switch virtualItem {
case .dayDivider(let timestamp):
// These components will be replaced by a timestamp in upcoming releases
let date = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000))
let dateString = date.formatted(date: .complete, time: .omitted)
newTimelineItems.append(SeparatorRoomTimelineItem(text: dateString))
case .readMarker:
// Don't show the read marker if it's the last item in the timeline
if index != timelineProvider.itemsPublisher.value.indices.last {
newTimelineItems.append(ReadMarkerRoomTimelineItem())
}
case .loadingIndicator:
newTimelineItems.append(PaginationIndicatorRoomTimelineItem())
isBackPaginating = true
case .timelineStart:
newTimelineItems.append(TimelineStartRoomTimelineItem(name: roomProxy.displayName ?? roomProxy.name))
canBackPaginate = false
}
default:
break
}
}
if Task.isCancelled {
return
let collapsibleChunks = timelineProvider.itemsPublisher.value.groupBy { isItemCollapsible($0) }
for (index, itemGroup) in collapsibleChunks.enumerated() {
if itemGroup.count == 1, let itemProxy = itemGroup.first {
let isLastItem = index == collapsibleChunks.indices.last
if let item = buildTimelineItemFor(itemProxy: itemProxy, isLastItem: isLastItem, createdIdentifiers: &createdIdentifiers, isBackPaginating: &isBackPaginating, canBackPaginate: &canBackPaginate) {
newTimelineItems.append(item)
}
} else {
let items = itemGroup.compactMap { itemProxy in
buildTimelineItemFor(itemProxy: itemProxy, isLastItem: false, createdIdentifiers: &createdIdentifiers, isBackPaginating: &isBackPaginating, canBackPaginate: &canBackPaginate)
}
if !items.isEmpty {
newTimelineItems.append(CollapsibleTimelineItem(items: items))
}
}
}
timelineItems = newTimelineItems
@@ -294,49 +263,63 @@ class RoomTimelineController: RoomTimelineControllerProtocol {
callbacks.send(.isBackPaginating(isBackPaginating))
}
private func computeGroupState(for itemProxy: TimelineItemProxy,
previousItemProxy: TimelineItemProxy?,
nextItemProxy: TimelineItemProxy?) -> TimelineItemGroupState {
guard let previousItem = previousItemProxy else {
// no previous item, check next item
guard let nextItem = nextItemProxy else {
// no next item neither, this is single
return .single
private func buildTimelineItemFor(itemProxy: TimelineItemProxy,
isLastItem: Bool,
createdIdentifiers: inout [String: Bool],
isBackPaginating: inout Bool,
canBackPaginate: inout Bool) -> RoomTimelineItemProtocol? {
switch itemProxy {
case .event(let eventItemProxy):
if let timelineItem = timelineItemFactory.buildTimelineItemFor(eventItemProxy: eventItemProxy) {
#warning("This works around duplicated items coming out of the SDK, remove once fixed")
if createdIdentifiers[timelineItem.id] == nil {
createdIdentifiers[timelineItem.id] = true
return timelineItem
} else {
MXLog.error("Found duplicated timeline item, ignoring")
}
}
guard nextItem.canBeGrouped(with: itemProxy) else {
// there is a next item but can't be grouped, this is single
return .single
case .virtual(let virtualItem):
switch virtualItem {
case .dayDivider(let timestamp):
// These components will be replaced by a timestamp in upcoming releases
let date = Date(timeIntervalSince1970: TimeInterval(timestamp / 1000))
let dateString = date.formatted(date: .complete, time: .omitted)
return SeparatorRoomTimelineItem(text: dateString)
case .readMarker:
// Don't show the read marker if it's the last item in the timeline
if !isLastItem {
return ReadMarkerRoomTimelineItem()
}
case .loadingIndicator:
isBackPaginating = true
return PaginationIndicatorRoomTimelineItem()
case .timelineStart:
canBackPaginate = false
return TimelineStartRoomTimelineItem(name: roomProxy.displayName ?? roomProxy.name)
}
// next will be grouped with this one, this is the start
return .beginning
case .unknown:
return nil
}
guard let nextItem = nextItemProxy else {
// no next item
guard itemProxy.canBeGrouped(with: previousItem) else {
// there is a previous item but can't be grouped, this is single
return .single
return nil
}
private func isItemCollapsible(_ item: TimelineItemProxy) -> Bool {
if !ServiceLocator.shared.settings.shouldCollapseRoomStateEvents {
return false
}
if case let .event(eventItem) = item {
switch eventItem.content.kind() {
case .profileChange, .roomMembership, .state:
return true
default:
return false
}
// will be grouped with previous, this is the end
return .end
}
guard itemProxy.canBeGrouped(with: previousItem) else {
guard nextItem.canBeGrouped(with: itemProxy) else {
// there is a next item but can't be grouped, this is single
return .single
}
// next will be grouped with this one, this is the start
return .beginning
}
guard nextItem.canBeGrouped(with: itemProxy) else {
// there is a next item but can't be grouped, this is the end
return .end
}
// next will be grouped with this one, this is the start
return .middle
return false
}
private func loadVideoForTimelineItem(_ timelineItem: VideoRoomTimelineItem) async {

View File

@@ -20,7 +20,6 @@ import UIKit
enum RoomTimelineControllerCallback {
case updatedTimelineItems
case updatedTimelineItem(_ itemId: String)
case canBackPaginate(Bool)
case isBackPaginating(Bool)
}

View File

@@ -32,20 +32,6 @@ enum TimelineItemProxy {
self = .unknown(item)
}
}
func canBeGrouped(with previousItemProxy: TimelineItemProxy) -> Bool {
guard case let .event(selfEventItemProxy) = self, case let .event(previousEventItemProxy) = previousItemProxy else {
return false
}
// State events aren't rendered as messages so shouldn't be grouped.
if selfEventItemProxy.isRoomState || previousEventItemProxy.isRoomState {
return false
}
// can be improved by adding a date threshold
return previousEventItemProxy.reactions.isEmpty && selfEventItemProxy.sender == previousEventItemProxy.sender
}
}
/// The delivery status for the item.

View File

@@ -17,27 +17,9 @@
import Foundation
import UIKit
enum TimelineItemGroupState: Hashable {
case single
case beginning
case middle
case end
var shouldShowSenderDetails: Bool {
switch self {
case .single, .beginning:
return true
default:
return false
}
}
}
protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol, CustomStringConvertible {
var body: String { get }
var timestamp: String { get }
var shouldShowSenderDetails: Bool { get }
var groupState: TimelineItemGroupState { get }
var isOutgoing: Bool { get }
var isEditable: Bool { get }
@@ -47,31 +29,6 @@ protocol EventBasedTimelineItemProtocol: RoomTimelineItemProtocol, CustomStringC
}
extension EventBasedTimelineItemProtocol {
var shouldShowSenderDetails: Bool {
groupState.shouldShowSenderDetails
}
var roundedCorners: UIRectCorner {
switch groupState {
case .single:
return .allCorners
case .beginning:
if isOutgoing {
return [.topLeft, .topRight, .bottomLeft]
} else {
return [.topLeft, .topRight, .bottomRight]
}
case .middle:
return isOutgoing ? [.topLeft, .bottomLeft] : [.topRight, .bottomRight]
case .end:
if isOutgoing {
return [.topLeft, .bottomLeft, .bottomRight]
} else {
return [.topRight, .bottomLeft, .bottomRight]
}
}
}
var description: String {
"\(String(describing: Self.self)): id: \(id), timestamp: \(timestamp), isOutgoing: \(isOutgoing), properties: \(properties)"
}

View File

@@ -20,7 +20,6 @@ struct AudioRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let id: String
let body: String
let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool
let isEditable: Bool
let sender: TimelineItemSender

View File

@@ -21,7 +21,6 @@ struct EmoteRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let body: String
var formattedBody: AttributedString?
let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool
let isEditable: Bool

View File

@@ -20,7 +20,6 @@ struct FileRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha
let id: String
let body: String
let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool
let isEditable: Bool

View File

@@ -21,7 +21,6 @@ struct ImageRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let id: String
let body: String
let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool
let isEditable: Bool

View File

@@ -21,7 +21,6 @@ struct NoticeRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Has
let body: String
var formattedBody: AttributedString?
let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool
let isEditable: Bool

View File

@@ -21,7 +21,6 @@ struct TextRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hasha
let body: String
var formattedBody: AttributedString?
let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool
let isEditable: Bool

View File

@@ -20,7 +20,6 @@ struct VideoRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let id: String
let body: String
let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool
let isEditable: Bool

View File

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

View File

@@ -27,7 +27,6 @@ struct EncryptedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable,
let body: String
let encryptionType: EncryptionType
let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool
let isEditable: Bool

View File

@@ -21,7 +21,6 @@ struct RedactedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, H
let id: String
let body: String
let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool
let isEditable: Bool

View File

@@ -20,7 +20,6 @@ struct StateRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Hash
let id: String
let body: String
let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool
let isEditable: Bool

View File

@@ -20,7 +20,6 @@ struct StickerRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable, Ha
let id: String
let body: String
let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool
let isEditable: Bool

View File

@@ -24,7 +24,6 @@ struct UnsupportedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable
let error: String
let timestamp: String
let groupState: TimelineItemGroupState
let isOutgoing: Bool
let isEditable: Bool

View File

@@ -36,53 +36,52 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
}
// swiftlint:disable:next cyclomatic_complexity
func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy,
groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol? {
func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy) -> RoomTimelineItemProtocol? {
let isOutgoing = eventItemProxy.isOwn
switch eventItemProxy.content.kind() {
case .unableToDecrypt(let encryptedMessage):
return buildEncryptedTimelineItem(eventItemProxy, encryptedMessage, isOutgoing, groupState)
return buildEncryptedTimelineItem(eventItemProxy, encryptedMessage, isOutgoing)
case .redactedMessage:
return buildRedactedTimelineItem(eventItemProxy, isOutgoing, groupState)
return buildRedactedTimelineItem(eventItemProxy, isOutgoing)
case .sticker(let body, let imageInfo, let urlString):
guard let url = URL(string: urlString) else {
MXLog.error("Invalid sticker url string: \(urlString)")
return buildUnsupportedTimelineItem(eventItemProxy, "m.sticker", "Invalid Sticker URL", isOutgoing, groupState)
return buildUnsupportedTimelineItem(eventItemProxy, "m.sticker", "Invalid Sticker URL", isOutgoing)
}
return buildStickerTimelineItem(eventItemProxy, body, imageInfo, url, isOutgoing, groupState)
return buildStickerTimelineItem(eventItemProxy, body, imageInfo, url, isOutgoing)
case .failedToParseMessageLike(let eventType, let error):
return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing, groupState)
return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing)
case .failedToParseState(let eventType, _, let error):
return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing, groupState)
return buildUnsupportedTimelineItem(eventItemProxy, eventType, error, isOutgoing)
case .message:
guard let messageContent = eventItemProxy.content.asMessage() else { fatalError("Invalid message timeline item: \(eventItemProxy)") }
switch messageContent.msgtype() {
case .text(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildTextTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState)
return buildTextTimelineItemFromMessage(eventItemProxy, message, isOutgoing)
case .image(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildImageTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState)
return buildImageTimelineItemFromMessage(eventItemProxy, message, isOutgoing)
case .video(let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildVideoTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState)
return buildVideoTimelineItemFromMessage(eventItemProxy, message, isOutgoing)
case .file(let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildFileTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState)
case .notice(let content):
return buildFileTimelineItemFromMessage(eventItemProxy, message, isOutgoing)
case .notice(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildNoticeTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState)
case .emote(let content):
return buildNoticeTimelineItemFromMessage(eventItemProxy, message, isOutgoing)
case .emote(content: let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildEmoteTimelineItemFromMessage(eventItemProxy, message, isOutgoing, groupState)
return buildEmoteTimelineItemFromMessage(eventItemProxy, message, isOutgoing)
case .audio(let content):
let message = MessageTimelineItem(item: eventItemProxy.item, content: content)
return buildAudioTimelineItem(eventItemProxy, message, isOutgoing, groupState)
return buildAudioTimelineItem(eventItemProxy, message, isOutgoing)
case .none:
return buildFallbackTimelineItem(eventItemProxy, isOutgoing, groupState)
return buildFallbackTimelineItem(eventItemProxy, isOutgoing)
}
case .state(let stateKey, let content):
return buildStateTimelineItemFor(eventItemProxy: eventItemProxy, state: content, stateKey: stateKey, isOutgoing: isOutgoing)
@@ -98,27 +97,23 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildUnsupportedTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ eventType: String,
_ error: String,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
UnsupportedRoomTimelineItem(id: eventItemProxy.id,
body: ElementL10n.roomTimelineItemUnsupported,
eventType: eventType,
error: error,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender,
properties: RoomTimelineItemProperties())
}
// swiftlint:disable:next function_parameter_count
private func buildStickerTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ body: String,
_ imageInfo: ImageInfo,
_ imageURL: URL,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
var aspectRatio: CGFloat?
let width = imageInfo.width.map(CGFloat.init)
let height = imageInfo.height.map(CGFloat.init)
@@ -129,7 +124,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
return StickerRoomTimelineItem(id: eventItemProxy.id,
body: body,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender,
@@ -144,8 +138,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildEncryptedTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ encryptedMessage: EncryptedMessage,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
var encryptionType = EncryptedRoomTimelineItem.EncryptionType.unknown
switch encryptedMessage {
case .megolmV1AesSha2(let sessionId):
@@ -160,7 +153,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
body: ElementL10n.roomTimelineUnableToDecrypt,
encryptionType: encryptionType,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender,
@@ -168,12 +160,10 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
}
private func buildRedactedTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
RedactedRoomTimelineItem(id: eventItemProxy.id,
body: ElementL10n.eventRedacted,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender,
@@ -181,15 +171,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
}
private func buildFallbackTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
let formattedBody = attributedStringBuilder.fromPlain(eventItemProxy.body)
return TextRoomTimelineItem(id: eventItemProxy.id,
body: eventItemProxy.body ?? "",
formattedBody: formattedBody,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender,
@@ -199,15 +187,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildTextTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<TextMessageContent>,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
let formattedBody = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
return TextRoomTimelineItem(id: message.id,
body: message.body,
formattedBody: formattedBody,
timestamp: message.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender,
@@ -218,8 +204,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildImageTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<ImageMessageContent>,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
var aspectRatio: CGFloat?
if let width = message.width,
let height = message.height {
@@ -229,7 +214,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
return ImageRoomTimelineItem(id: message.id,
body: message.body,
timestamp: message.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender,
@@ -246,8 +230,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildVideoTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<VideoMessageContent>,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
var aspectRatio: CGFloat?
if let width = message.width,
let height = message.height {
@@ -257,7 +240,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
return VideoRoomTimelineItem(id: message.id,
body: message.body,
timestamp: message.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender,
@@ -275,12 +257,10 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildAudioTimelineItem(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<AudioMessageContent>,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
AudioRoomTimelineItem(id: eventItemProxy.id,
body: message.body,
timestamp: message.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender,
@@ -290,12 +270,10 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildFileTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<FileMessageContent>,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
FileRoomTimelineItem(id: message.id,
body: message.body,
timestamp: message.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender,
@@ -308,15 +286,13 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildNoticeTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<NoticeMessageContent>,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
let formattedBody = (message.htmlBody != nil ? attributedStringBuilder.fromHTML(message.htmlBody) : attributedStringBuilder.fromPlain(message.body))
return NoticeRoomTimelineItem(id: message.id,
body: message.body,
formattedBody: formattedBody,
timestamp: message.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender,
@@ -327,8 +303,7 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
private func buildEmoteTimelineItemFromMessage(_ eventItemProxy: EventTimelineItemProxy,
_ message: MessageTimelineItem<EmoteMessageContent>,
_ isOutgoing: Bool,
_ groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol {
_ isOutgoing: Bool) -> RoomTimelineItemProtocol {
let name = eventItemProxy.sender.displayName ?? eventItemProxy.sender.id
var formattedBody: AttributedString?
@@ -342,7 +317,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
body: message.body,
formattedBody: formattedBody,
timestamp: message.timestamp.formatted(date: .omitted, time: .shortened),
groupState: groupState,
isOutgoing: isOutgoing,
isEditable: eventItemProxy.isEditable,
sender: eventItemProxy.sender,
@@ -396,7 +370,6 @@ struct RoomTimelineItemFactory: RoomTimelineItemFactoryProtocol {
StateRoomTimelineItem(id: eventItemProxy.id,
body: text,
timestamp: eventItemProxy.timestamp.formatted(date: .omitted, time: .shortened),
groupState: .single,
isOutgoing: isOutgoing,
isEditable: false,
sender: eventItemProxy.sender)

View File

@@ -18,6 +18,5 @@ import Foundation
@MainActor
protocol RoomTimelineItemFactoryProtocol {
func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy,
groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol?
func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy) -> RoomTimelineItemProtocol?
}

View File

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

View File

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

View File

@@ -18,56 +18,85 @@ import Foundation
import SwiftUI
enum RoomTimelineViewProvider: Identifiable, Hashable {
case text(TextRoomTimelineItem)
case separator(SeparatorRoomTimelineItem)
case image(ImageRoomTimelineItem)
case video(VideoRoomTimelineItem)
case audio(AudioRoomTimelineItem)
case file(FileRoomTimelineItem)
case emote(EmoteRoomTimelineItem)
case notice(NoticeRoomTimelineItem)
case redacted(RedactedRoomTimelineItem)
case encrypted(EncryptedRoomTimelineItem)
case readMarker(ReadMarkerRoomTimelineItem)
case paginationIndicator(PaginationIndicatorRoomTimelineItem)
case sticker(StickerRoomTimelineItem)
case unsupported(UnsupportedRoomTimelineItem)
case timelineStart(TimelineStartRoomTimelineItem)
case state(StateRoomTimelineItem)
case text(TextRoomTimelineItem, TimelineGroupStyle)
case separator(SeparatorRoomTimelineItem, TimelineGroupStyle)
case image(ImageRoomTimelineItem, TimelineGroupStyle)
case video(VideoRoomTimelineItem, TimelineGroupStyle)
case audio(AudioRoomTimelineItem, TimelineGroupStyle)
case file(FileRoomTimelineItem, TimelineGroupStyle)
case emote(EmoteRoomTimelineItem, TimelineGroupStyle)
case notice(NoticeRoomTimelineItem, TimelineGroupStyle)
case redacted(RedactedRoomTimelineItem, TimelineGroupStyle)
case encrypted(EncryptedRoomTimelineItem, TimelineGroupStyle)
case readMarker(ReadMarkerRoomTimelineItem, TimelineGroupStyle)
case paginationIndicator(PaginationIndicatorRoomTimelineItem, TimelineGroupStyle)
case sticker(StickerRoomTimelineItem, TimelineGroupStyle)
case unsupported(UnsupportedRoomTimelineItem, TimelineGroupStyle)
case timelineStart(TimelineStartRoomTimelineItem, TimelineGroupStyle)
case state(StateRoomTimelineItem, TimelineGroupStyle)
case group(CollapsibleTimelineItem, TimelineGroupStyle)
// swiftlint:disable:next cyclomatic_complexity
init(timelineItem: RoomTimelineItemProtocol, grouping: TimelineGroupStyle) {
switch timelineItem {
case let item as TextRoomTimelineItem:
self = .text(item, grouping)
case let item as ImageRoomTimelineItem:
self = .image(item, grouping)
case let item as VideoRoomTimelineItem:
self = .video(item, grouping)
case let item as AudioRoomTimelineItem:
self = .audio(item, grouping)
case let item as FileRoomTimelineItem:
self = .file(item, grouping)
case let item as SeparatorRoomTimelineItem:
self = .separator(item, grouping)
case let item as NoticeRoomTimelineItem:
self = .notice(item, grouping)
case let item as EmoteRoomTimelineItem:
self = .emote(item, grouping)
case let item as RedactedRoomTimelineItem:
self = .redacted(item, grouping)
case let item as EncryptedRoomTimelineItem:
self = .encrypted(item, grouping)
case let item as ReadMarkerRoomTimelineItem:
self = .readMarker(item, grouping)
case let item as PaginationIndicatorRoomTimelineItem:
self = .paginationIndicator(item, grouping)
case let item as StickerRoomTimelineItem:
self = .sticker(item, grouping)
case let item as UnsupportedRoomTimelineItem:
self = .unsupported(item, grouping)
case let item as TimelineStartRoomTimelineItem:
self = .timelineStart(item, grouping)
case let item as StateRoomTimelineItem:
self = .state(item, grouping)
case let item as CollapsibleTimelineItem:
self = .group(item, grouping)
default:
fatalError("Unknown timeline item")
}
}
var id: String {
switch self {
case .text(let item):
return item.id
case .separator(let item):
return item.id
case .image(let item):
return item.id
case .video(let item):
return item.id
case .audio(let item):
return item.id
case .file(let item):
return item.id
case .emote(let item):
return item.id
case .notice(let item):
return item.id
case .redacted(let item):
return item.id
case .encrypted(let item):
return item.id
case .readMarker(let item):
return item.id
case .paginationIndicator(let item):
return item.id
case .sticker(let item):
return item.id
case .unsupported(let item):
return item.id
case .timelineStart(let item):
return item.id
case .state(let item):
case .text(let item as RoomTimelineItemProtocol, _),
.separator(let item as RoomTimelineItemProtocol, _),
.image(let item as RoomTimelineItemProtocol, _),
.video(let item as RoomTimelineItemProtocol, _),
.audio(let item as RoomTimelineItemProtocol, _),
.file(let item as RoomTimelineItemProtocol, _),
.emote(let item as RoomTimelineItemProtocol, _),
.notice(let item as RoomTimelineItemProtocol, _),
.redacted(let item as RoomTimelineItemProtocol, _),
.encrypted(let item as RoomTimelineItemProtocol, _),
.readMarker(let item as RoomTimelineItemProtocol, _),
.paginationIndicator(let item as RoomTimelineItemProtocol, _),
.sticker(let item as RoomTimelineItemProtocol, _),
.unsupported(let item as RoomTimelineItemProtocol, _),
.timelineStart(let item as RoomTimelineItemProtocol, _),
.state(let item as RoomTimelineItemProtocol, _),
.group(let item as RoomTimelineItemProtocol, _):
return item.id
}
}
@@ -81,45 +110,77 @@ enum RoomTimelineViewProvider: Identifiable, Hashable {
return false
case .timelineStart, .separator, .readMarker, .paginationIndicator: // Virtual items are never reactable
return false
case .group:
return false
}
}
}
extension RoomTimelineViewProvider: View {
@ViewBuilder var body: some View {
var body: some View {
timelineView
.environment(\.timelineGroupStyle, timelineStyleGrouping)
}
@ViewBuilder private var timelineView: some View {
switch self {
case .text(let item):
case .text(let item, _):
TextRoomTimelineView(timelineItem: item)
case .separator(let item):
case .separator(let item, _):
SeparatorRoomTimelineView(timelineItem: item)
case .image(let item):
case .image(let item, _):
ImageRoomTimelineView(timelineItem: item)
case .video(let item):
case .video(let item, _):
VideoRoomTimelineView(timelineItem: item)
case .audio(let item):
case .audio(let item, _):
AudioRoomTimelineView(timelineItem: item)
case .file(let item):
case .file(let item, _):
FileRoomTimelineView(timelineItem: item)
case .emote(let item):
case .emote(let item, _):
EmoteRoomTimelineView(timelineItem: item)
case .notice(let item):
case .notice(let item, _):
NoticeRoomTimelineView(timelineItem: item)
case .redacted(let item):
case .redacted(let item, _):
RedactedRoomTimelineView(timelineItem: item)
case .encrypted(let item):
case .encrypted(let item, _):
EncryptedRoomTimelineView(timelineItem: item)
case .readMarker(let item):
case .readMarker(let item, _):
ReadMarkerRoomTimelineView(timelineItem: item)
case .paginationIndicator(let item):
case .paginationIndicator(let item, _):
PaginationIndicatorRoomTimelineView(timelineItem: item)
case .sticker(let item):
case .sticker(let item, _):
StickerRoomTimelineView(timelineItem: item)
case .unsupported(let item):
case .unsupported(let item, _):
UnsupportedRoomTimelineView(timelineItem: item)
case .timelineStart(let item):
case .timelineStart(let item, _):
TimelineStartRoomTimelineView(timelineItem: item)
case .state(let item):
case .state(let item, _):
StateRoomTimelineView(timelineItem: item)
case .group(let item, _):
CollapsibleRoomTimelineView(timelineItem: item)
}
}
private var timelineStyleGrouping: TimelineGroupStyle {
switch self {
case .text(_, let grouping),
.separator(_, let grouping),
.image(_, let grouping),
.video(_, let grouping),
.audio(_, let grouping),
.file(_, let grouping),
.emote(_, let grouping),
.notice(_, let grouping),
.redacted(_, let grouping),
.encrypted(_, let grouping),
.readMarker(_, let grouping),
.paginationIndicator(_, let grouping),
.sticker(_, let grouping),
.unsupported(_, let grouping),
.timelineStart(_, let grouping),
.state(_, let grouping),
.group(_, let grouping):
return grouping
}
}
}

View File

@@ -116,6 +116,7 @@ targets:
- target: NSE
- package: MatrixRustSDK
- package: DesignKit
- package: Algorithms
- package: AnalyticsEvents
- package: AppAuth
- package: Collections

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

View File

@@ -212,23 +212,23 @@ class LoggingTests: XCTestCase {
let textAttributedString = "TextAttributed"
let textMessage = TextRoomTimelineItem(id: "mytextmessage", body: "TextString",
formattedBody: AttributedString(textAttributedString),
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
let noticeAttributedString = "NoticeAttributed"
let noticeMessage = NoticeRoomTimelineItem(id: "mynoticemessage", body: "NoticeString",
formattedBody: AttributedString(noticeAttributedString),
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
let emoteAttributedString = "EmoteAttributed"
let emoteMessage = EmoteRoomTimelineItem(id: "myemotemessage", body: "EmoteString",
formattedBody: AttributedString(emoteAttributedString),
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
timestamp: "", isOutgoing: false, isEditable: false, sender: .init(id: "sender"))
let imageMessage = ImageRoomTimelineItem(id: "myimagemessage", body: "ImageString",
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false,
timestamp: "", isOutgoing: false, isEditable: false,
sender: .init(id: "sender"), source: nil)
let videoMessage = VideoRoomTimelineItem(id: "myvideomessage", body: "VideoString",
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false,
timestamp: "", isOutgoing: false, isEditable: false,
sender: .init(id: "sender"), duration: 0, source: nil, thumbnailSource: nil)
let fileMessage = FileRoomTimelineItem(id: "myfilemessage", body: "FileString",
timestamp: "", groupState: .single, isOutgoing: false, isEditable: false,
timestamp: "", isOutgoing: false, isEditable: false,
sender: .init(id: "sender"), source: nil, thumbnailSource: nil)
// When logging that value

View File

@@ -44,6 +44,9 @@ packages:
# path: ../matrix-rust-sdk
DesignKit:
path: DesignKit
Algorithms:
url: https://github.com/apple/swift-algorithms
majorVersion: 1.0.0
AnalyticsEvents:
url: https://github.com/matrix-org/matrix-analytics-events
exactVersion: 0.4.0