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:
committed by
Stefan Ceriu
parent
5a759d777d
commit
bf19e3e00b
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
69
ElementX/Sources/Other/Extensions/Array.swift
Normal file
69
ElementX/Sources/Other/Extensions/Array.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,10 @@ struct DeveloperOptionsScreenViewState: BindableState {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
body: text,
|
||||
timestamp: timestamp,
|
||||
groupState: .single,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: senderId))
|
||||
|
||||
@@ -69,7 +69,6 @@ struct EncryptedRoomTimelineView_Previews: PreviewProvider {
|
||||
body: text,
|
||||
encryptionType: .unknown,
|
||||
timestamp: timestamp,
|
||||
groupState: .single,
|
||||
isOutgoing: isOutgoing,
|
||||
isEditable: false,
|
||||
sender: .init(id: senderId))
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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: ""))
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -83,7 +83,6 @@ struct TimelineView: UIViewControllerRepresentable {
|
||||
|
||||
struct TimelineTableView_Previews: PreviewProvider {
|
||||
static let viewModel = RoomScreenViewModel(timelineController: MockRoomTimelineController(),
|
||||
timelineViewFactory: RoomTimelineViewFactory(),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
roomName: "Preview room")
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ enum RoomTimelineItemFixtures {
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "That looks so good!",
|
||||
timestamp: "10:10 AM",
|
||||
groupState: .single,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "", displayName: "Jacob"),
|
||||
@@ -31,7 +30,6 @@ enum RoomTimelineItemFixtures {
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "Let’s get lunch soon! New salad place opened up 🥗. When are y’all free? 🤗",
|
||||
timestamp: "10:11 AM",
|
||||
groupState: .beginning,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "", displayName: "Helena"),
|
||||
@@ -41,7 +39,6 @@ enum RoomTimelineItemFixtures {
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "I can be around on Wednesday. How about some 🌮 instead? Like https://www.tortilla.co.uk/",
|
||||
timestamp: "10:11 AM",
|
||||
groupState: .end,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "", displayName: "Helena"),
|
||||
@@ -53,21 +50,18 @@ enum RoomTimelineItemFixtures {
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "Wow, cool. Ok, lets go the usual place tomorrow?! Is that too soon? Here’s the menu, let me know what you want it’s on me!",
|
||||
timestamp: "5 PM",
|
||||
groupState: .single,
|
||||
isOutgoing: false,
|
||||
isEditable: false,
|
||||
sender: .init(id: "", displayName: "Helena")),
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "And John's speech was amazing!",
|
||||
timestamp: "5 PM",
|
||||
groupState: .beginning,
|
||||
isOutgoing: true,
|
||||
isEditable: true,
|
||||
sender: .init(id: "", displayName: "Bob")),
|
||||
TextRoomTimelineItem(id: UUID().uuidString,
|
||||
body: "New home office set up!",
|
||||
timestamp: "5 PM",
|
||||
groupState: .end,
|
||||
isOutgoing: true,
|
||||
isEditable: true,
|
||||
sender: .init(id: "", displayName: "Bob"),
|
||||
@@ -79,7 +73,6 @@ enum RoomTimelineItemFixtures {
|
||||
body: "",
|
||||
formattedBody: AttributedStringBuilder().fromHTML("Hol' up <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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -20,7 +20,6 @@ import UIKit
|
||||
|
||||
enum RoomTimelineControllerCallback {
|
||||
case updatedTimelineItems
|
||||
case updatedTimelineItem(_ itemId: String)
|
||||
case canBackPaginate(Bool)
|
||||
case isBackPaginating(Bool)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 encryptionType: EncryptionType
|
||||
let timestamp: String
|
||||
let groupState: TimelineItemGroupState
|
||||
let isOutgoing: Bool
|
||||
let isEditable: Bool
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ struct UnsupportedRoomTimelineItem: EventBasedTimelineItemProtocol, Identifiable
|
||||
let error: String
|
||||
|
||||
let timestamp: String
|
||||
let groupState: TimelineItemGroupState
|
||||
let isOutgoing: Bool
|
||||
let isEditable: Bool
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -18,6 +18,5 @@ import Foundation
|
||||
|
||||
@MainActor
|
||||
protocol RoomTimelineItemFactoryProtocol {
|
||||
func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy,
|
||||
groupState: TimelineItemGroupState) -> RoomTimelineItemProtocol?
|
||||
func buildTimelineItemFor(eventItemProxy: EventTimelineItemProxy) -> 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
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ targets:
|
||||
- target: NSE
|
||||
- package: MatrixRustSDK
|
||||
- package: DesignKit
|
||||
- package: Algorithms
|
||||
- package: AnalyticsEvents
|
||||
- package: AppAuth
|
||||
- package: Collections
|
||||
|
||||
45
UnitTests/Sources/ArrayTests.swift
Normal file
45
UnitTests/Sources/ArrayTests.swift
Normal 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]])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user