mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-04 10:33:45 +00:00
tombstoned and upgraded rooms implementation
added tests tombstoned room avatar
This commit is contained in:
@@ -197,6 +197,8 @@ struct HomeScreenRoom: Identifiable, Equatable {
|
||||
|
||||
let canonicalAlias: String?
|
||||
|
||||
let isTombstoned: Bool
|
||||
|
||||
static func placeholder() -> HomeScreenRoom {
|
||||
HomeScreenRoom(id: UUID().uuidString,
|
||||
roomID: nil,
|
||||
@@ -209,7 +211,8 @@ struct HomeScreenRoom: Identifiable, Equatable {
|
||||
timestamp: "Now",
|
||||
lastMessage: placeholderLastMessage,
|
||||
avatar: .room(id: "", name: "", avatarURL: nil),
|
||||
canonicalAlias: nil)
|
||||
canonicalAlias: nil,
|
||||
isTombstoned: false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,6 +249,7 @@ extension HomeScreenRoom {
|
||||
timestamp: summary.lastMessageDate?.formattedMinimal(),
|
||||
lastMessage: summary.lastMessage,
|
||||
avatar: summary.avatar,
|
||||
canonicalAlias: summary.canonicalAlias)
|
||||
canonicalAlias: summary.canonicalAlias,
|
||||
isTombstoned: summary.isTombstoned)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +217,8 @@ private extension HomeScreenRoom {
|
||||
alternativeAliases: [],
|
||||
hasOngoingCall: false,
|
||||
isMarkedUnread: false,
|
||||
isFavourite: false)
|
||||
isFavourite: false,
|
||||
isTombstoned: false)
|
||||
|
||||
return .init(summary: summary, hideUnreadMessagesBadge: false)
|
||||
}
|
||||
@@ -246,7 +247,8 @@ private extension HomeScreenRoom {
|
||||
alternativeAliases: [],
|
||||
hasOngoingCall: false,
|
||||
isMarkedUnread: false,
|
||||
isFavourite: false)
|
||||
isFavourite: false,
|
||||
isTombstoned: false)
|
||||
|
||||
return .init(summary: summary, hideUnreadMessagesBadge: false)
|
||||
}
|
||||
|
||||
@@ -162,7 +162,8 @@ private extension HomeScreenRoom {
|
||||
alternativeAliases: [],
|
||||
hasOngoingCall: false,
|
||||
isMarkedUnread: false,
|
||||
isFavourite: false)
|
||||
isFavourite: false,
|
||||
isTombstoned: false)
|
||||
|
||||
return .init(summary: summary, hideUnreadMessagesBadge: false)
|
||||
}
|
||||
@@ -191,7 +192,8 @@ private extension HomeScreenRoom {
|
||||
alternativeAliases: [],
|
||||
hasOngoingCall: false,
|
||||
isMarkedUnread: false,
|
||||
isFavourite: false)
|
||||
isFavourite: false,
|
||||
isTombstoned: false)
|
||||
|
||||
return .init(summary: summary, hideUnreadMessagesBadge: false)
|
||||
}
|
||||
|
||||
@@ -135,7 +135,11 @@ struct HomeScreenRoomCell: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var lastMessage: some View {
|
||||
if let lastMessage = room.lastMessage {
|
||||
// If the room is tombstoned, show a specific message, regardless of any last message.
|
||||
if room.isTombstoned {
|
||||
Text(L10n.screenRoomlistTombstonedRoomDescription)
|
||||
.lastMessageFormatting()
|
||||
} else if let lastMessage = room.lastMessage {
|
||||
Text(lastMessage)
|
||||
.lastMessageFormatting()
|
||||
}
|
||||
|
||||
+2
-2
@@ -81,7 +81,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
|
||||
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
|
||||
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
|
||||
.displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
|
||||
.displayThread, .composer, .hasScrolled, .viewInRoomTimeline:
|
||||
.displayThread, .composer, .hasScrolled, .viewInRoomTimeline, .displayRoom:
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -103,7 +103,7 @@ class MediaEventsTimelineScreenViewModel: MediaEventsTimelineScreenViewModelType
|
||||
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
|
||||
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
|
||||
.displaySenderDetails, .displayMessageForwarding, .displayLocation, .displayResolveSendFailure,
|
||||
.displayThread, .composer, .hasScrolled, .viewInRoomTimeline:
|
||||
.displayThread, .composer, .hasScrolled, .viewInRoomTimeline, .displayRoom:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -91,7 +91,7 @@ final class PinnedEventsTimelineScreenCoordinator: CoordinatorProtocol {
|
||||
// These other actions will not be handled in this view
|
||||
case .displayEmojiPicker, .displayReportContent, .displayCameraPicker, .displayMediaPicker,
|
||||
.displayDocumentPicker, .displayLocationPicker, .displayPollForm, .displayMediaUploadPreviewScreen,
|
||||
.displayResolveSendFailure, .displayThread, .composer, .hasScrolled:
|
||||
.displayResolveSendFailure, .displayThread, .composer, .hasScrolled, .displayRoom:
|
||||
// These actions are not handled in this coordinator
|
||||
break
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ enum RoomScreenCoordinatorAction {
|
||||
case presentResolveSendFailure(failure: TimelineItemSendFailure.VerifiedUser, sendHandle: SendHandleProxy)
|
||||
case presentKnockRequestsList
|
||||
case presentThread(itemID: TimelineItemIdentifier)
|
||||
case presentRoom(roomID: String)
|
||||
}
|
||||
|
||||
final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
@@ -151,6 +152,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
composerViewModel.process(timelineAction: action)
|
||||
case .hasScrolled(direction: let direction):
|
||||
roomViewModel.timelineHasScrolled(direction: direction)
|
||||
case .displayRoom(let roomID):
|
||||
actionsSubject.send(.presentRoom(roomID: roomID))
|
||||
case .viewInRoomTimeline:
|
||||
fatalError("The action: \(action) should not be sent to this coordinator")
|
||||
}
|
||||
@@ -182,6 +185,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
composerViewModel.process(timelineAction: .removeFocus)
|
||||
case .displayKnockRequests:
|
||||
actionsSubject.send(.presentKnockRequestsList)
|
||||
case .displayRoom(let roomID):
|
||||
actionsSubject.send(.presentRoom(roomID: roomID))
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
@@ -15,6 +15,7 @@ enum RoomScreenViewModelAction: Equatable {
|
||||
case displayCall
|
||||
case removeComposerFocus
|
||||
case displayKnockRequests
|
||||
case displayRoom(roomID: String)
|
||||
}
|
||||
|
||||
enum RoomScreenViewAction {
|
||||
@@ -26,6 +27,7 @@ enum RoomScreenViewAction {
|
||||
case acceptKnock(eventID: String)
|
||||
case dismissKnockRequests
|
||||
case viewKnockRequests
|
||||
case displaySuccessorRoom
|
||||
}
|
||||
|
||||
struct RoomScreenViewState: BindableState {
|
||||
@@ -53,6 +55,8 @@ struct RoomScreenViewState: BindableState {
|
||||
var unseenKnockRequests: [KnockRequestInfo] = []
|
||||
var handledEventIDs: Set<String> = []
|
||||
|
||||
var hasSuccessor: Bool
|
||||
|
||||
var displayedKnockRequests: [KnockRequestInfo] {
|
||||
unseenKnockRequests.filter { !handledEventIDs.contains($0.eventID) }
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
super.init(initialViewState: .init(roomTitle: roomProxy.infoPublisher.value.displayName ?? roomProxy.id,
|
||||
roomAvatar: roomProxy.infoPublisher.value.avatar,
|
||||
hasOngoingCall: roomProxy.infoPublisher.value.hasRoomCall,
|
||||
hasSuccessor: roomProxy.infoPublisher.value.successor != nil,
|
||||
bindings: .init()),
|
||||
mediaProvider: mediaProvider)
|
||||
|
||||
@@ -114,6 +115,9 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
Task { await markAllKnocksAsSeen() }
|
||||
case .viewKnockRequests:
|
||||
actionsSubject.send(.displayKnockRequests)
|
||||
case .displaySuccessorRoom:
|
||||
guard let successorID = roomProxy.infoPublisher.value.successor?.roomId else { return }
|
||||
actionsSubject.send(.displayRoom(roomID: successorID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,6 +330,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
}
|
||||
|
||||
private func handleRoomInfoUpdate(_ roomInfo: RoomInfoProxy) async {
|
||||
state.hasSuccessor = roomInfo.successor != nil
|
||||
let pinnedEventIDs = roomInfo.pinnedEventIDs
|
||||
// Only update the loading state of the banner
|
||||
if state.pinnedEventsBannerState.isLoading {
|
||||
|
||||
@@ -151,7 +151,9 @@ struct RoomScreen: View {
|
||||
|
||||
@ViewBuilder
|
||||
private var composer: some View {
|
||||
if roomContext.viewState.canSendMessage {
|
||||
if roomContext.viewState.hasSuccessor {
|
||||
tombstonedDialogue
|
||||
} else if roomContext.viewState.canSendMessage {
|
||||
composerToolbar
|
||||
} else {
|
||||
Text(L10n.screenRoomTimelineNoPermissionToPost)
|
||||
@@ -162,6 +164,28 @@ struct RoomScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var tombstonedDialogue: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text(L10n.screenRoomTimelineTombstonedRoomMessage)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundStyle(.compound.textPrimary)
|
||||
|
||||
Button {
|
||||
roomContext.send(viewAction: .displaySuccessorRoom)
|
||||
} label: {
|
||||
Text(L10n.screenRoomTimelineTombstonedRoomAction)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.compound(.primary, size: .medium))
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.bottom, 8)
|
||||
.highlight(borderColor: .compound.borderInfoSubtle,
|
||||
primaryColor: .compound.bgInfoSubtle,
|
||||
secondaryColor: .compound.bgCanvasDefault)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var loadingIndicator: some View {
|
||||
if timelineContext.viewState.showLoading {
|
||||
@@ -233,6 +257,7 @@ struct RoomScreen: View {
|
||||
struct RoomScreen_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModels = makeViewModels()
|
||||
static let readOnlyViewModels = makeViewModels(canSendMessage: false)
|
||||
static let tombstonedViewModels = makeViewModels(hasSuccessor: true)
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
@@ -249,13 +274,22 @@ struct RoomScreen_Previews: PreviewProvider, TestablePreview {
|
||||
}
|
||||
.previewDisplayName("Read-only")
|
||||
.snapshotPreferences(expect: readOnlyViewModels.room.context.$viewState.map { !$0.canSendMessage })
|
||||
|
||||
NavigationStack {
|
||||
RoomScreen(roomViewModel: tombstonedViewModels.room,
|
||||
timelineViewModel: tombstonedViewModels.timeline,
|
||||
composerToolbar: ComposerToolbar.mock())
|
||||
}
|
||||
.previewDisplayName("Tombstoned")
|
||||
.snapshotPreferences(expect: tombstonedViewModels.room.context.$viewState.map(\.hasSuccessor))
|
||||
}
|
||||
|
||||
static func makeViewModels(canSendMessage: Bool = true) -> ViewModels {
|
||||
static func makeViewModels(canSendMessage: Bool = true, hasSuccessor: Bool = false) -> ViewModels {
|
||||
let roomProxyMock = JoinedRoomProxyMock(.init(id: "stable_id",
|
||||
name: "Preview room",
|
||||
hasOngoingCall: true,
|
||||
canUserSendMessage: canSendMessage))
|
||||
canUserSendMessage: canSendMessage,
|
||||
successor: hasSuccessor ? .init(roomId: UUID().uuidString, reason: nil) : nil))
|
||||
let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock)
|
||||
let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
|
||||
timelineController: MockTimelineController(),
|
||||
|
||||
@@ -12,32 +12,44 @@ struct RoomScreenFooterView: View {
|
||||
let mediaProvider: MediaProviderProtocol?
|
||||
let callback: (RoomScreenFooterViewAction) -> Void
|
||||
|
||||
private var borderColor: Color {
|
||||
switch details {
|
||||
case .pinViolation:
|
||||
.compound.borderInfoSubtle
|
||||
case .verificationViolation:
|
||||
.compound.borderCriticalSubtle
|
||||
case .none:
|
||||
Color.compound.bgCanvasDefault
|
||||
}
|
||||
}
|
||||
|
||||
private var gradientColor: Color {
|
||||
switch details {
|
||||
case .pinViolation:
|
||||
.compound.bgInfoSubtle
|
||||
case .verificationViolation:
|
||||
.compound.bgCriticalSubtle
|
||||
case .none:
|
||||
Color.compound.bgCanvasDefault
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
if let details {
|
||||
ZStack(alignment: .top) {
|
||||
switch details {
|
||||
case .pinViolation(let member, let learnMoreURL):
|
||||
VStack(spacing: 0) {
|
||||
Color.compound.borderInfoSubtle
|
||||
.frame(height: 1)
|
||||
LinearGradient(colors: [.compound.bgInfoSubtle, .compound.bgCanvasDefault],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
}
|
||||
pinViolation(member: member, learnMoreURL: learnMoreURL)
|
||||
case .verificationViolation(member: let member, learnMoreURL: let learnMoreURL):
|
||||
VStack(spacing: 0) {
|
||||
Color.compound.borderCriticalSubtle
|
||||
.frame(height: 1)
|
||||
LinearGradient(colors: [.compound.bgCriticalSubtle, .compound.bgCanvasDefault],
|
||||
startPoint: .top,
|
||||
endPoint: .bottom)
|
||||
}
|
||||
verificationViolation(member: member, learnMoreURL: learnMoreURL)
|
||||
}
|
||||
}
|
||||
.padding(.top, 8)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
detailsView(details)
|
||||
.highlight(borderColor: borderColor, primaryColor: gradientColor, secondaryColor: .compound.bgCanvasDefault)
|
||||
.padding(.top, 8)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func detailsView(_ details: RoomScreenFooterViewDetails) -> some View {
|
||||
switch details {
|
||||
case .pinViolation(let member, let learnMoreURL):
|
||||
pinViolation(member: member, learnMoreURL: learnMoreURL)
|
||||
case .verificationViolation(member: let member, learnMoreURL: let learnMoreURL):
|
||||
verificationViolation(member: member, learnMoreURL: learnMoreURL)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,7 +96,7 @@ final class ThreadTimelineScreenCoordinator: CoordinatorProtocol {
|
||||
actionsSubject.send(.presentLocationViewer(body: body, geoURI: geoURI, description: description))
|
||||
case .displayResolveSendFailure(let failure, let sendHandle):
|
||||
actionsSubject.send(.presentResolveSendFailure(failure: failure, sendHandle: sendHandle))
|
||||
case .displayThread, .composer, .hasScrolled:
|
||||
case .displayThread, .composer, .hasScrolled, .displayRoom:
|
||||
break
|
||||
case .viewInRoomTimeline:
|
||||
fatalError("The action: \(action) should not be sent to this coordinator")
|
||||
|
||||
@@ -28,6 +28,7 @@ enum TimelineViewModelAction {
|
||||
case composer(action: TimelineComposerAction)
|
||||
case hasScrolled(direction: ScrollDirection)
|
||||
case viewInRoomTimeline(eventID: String)
|
||||
case displayRoom(roomID: String)
|
||||
}
|
||||
|
||||
enum TimelineViewPollAction {
|
||||
@@ -77,6 +78,8 @@ enum TimelineViewAction {
|
||||
|
||||
case hasScrolled(direction: ScrollDirection)
|
||||
case setOpenURLAction(OpenURLAction)
|
||||
|
||||
case seePredecessorTapped
|
||||
}
|
||||
|
||||
enum TimelineComposerAction {
|
||||
@@ -106,6 +109,8 @@ struct TimelineViewState: BindableState {
|
||||
var isViewSourceEnabled: Bool
|
||||
var areThreadsEnabled: Bool
|
||||
var hideTimelineMedia: Bool
|
||||
|
||||
let hasPredecessor: Bool
|
||||
|
||||
// The `pinnedEventIDs` are used only to determine if an item is already pinned or not.
|
||||
// It's updated from the room info, so it's faster than using the timeline
|
||||
|
||||
@@ -104,6 +104,7 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
isViewSourceEnabled: appSettings.viewSourceEnabled,
|
||||
areThreadsEnabled: appSettings.threadsEnabled,
|
||||
hideTimelineMedia: hideTimelineMedia,
|
||||
hasPredecessor: roomProxy.predecessorRoom != nil,
|
||||
pinnedEventIDs: roomProxy.infoPublisher.value.pinnedEventIDs,
|
||||
emojiProvider: emojiProvider,
|
||||
mapTilerConfiguration: appSettings.mapTilerConfiguration,
|
||||
@@ -211,6 +212,9 @@ class TimelineViewModel: TimelineViewModelType, TimelineViewModelProtocol {
|
||||
actionsSubject.send(.hasScrolled(direction: direction))
|
||||
case .setOpenURLAction(let action):
|
||||
state.openURL = action
|
||||
case .seePredecessorTapped:
|
||||
guard let predecessorID = roomProxy.predecessorRoom?.roomId else { return }
|
||||
actionsSubject.send(.displayRoom(roomID: predecessorID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1018,11 +1022,12 @@ private extension RoomInfoProxy {
|
||||
extension TimelineViewModel {
|
||||
static let mock = mock(timelineKind: .live)
|
||||
|
||||
static func mock(timelineKind: TimelineKind = .live, timelineController: MockTimelineController? = nil) -> TimelineViewModel {
|
||||
static func mock(timelineKind: TimelineKind = .live, timelineController: MockTimelineController? = nil, hasPredecessor: Bool = false) -> TimelineViewModel {
|
||||
let clientProxyMock = ClientProxyMock(.init())
|
||||
clientProxyMock.roomSummaryForAliasReturnValue = .mock(id: "!room:matrix.org", name: "Room")
|
||||
clientProxyMock.roomSummaryForIdentifierReturnValue = .mock(id: "!room:matrix.org", name: "Room", canonicalAlias: "#room:matrix.org")
|
||||
return TimelineViewModel(roomProxy: JoinedRoomProxyMock(.init(name: "Preview room")),
|
||||
let roomProxy = JoinedRoomProxyMock(.init(name: "Preview room", predecessor: hasPredecessor ? .init(roomId: UUID().uuidString, lastEventId: UUID().uuidString) : nil))
|
||||
return TimelineViewModel(roomProxy: roomProxy,
|
||||
focussedEventID: nil,
|
||||
timelineController: timelineController ?? MockTimelineController(timelineKind: timelineKind),
|
||||
mediaProvider: MediaProviderMock(configuration: .init()),
|
||||
|
||||
+43
-6
@@ -5,20 +5,50 @@
|
||||
// Please see LICENSE files in the repository root for full details.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct TimelineStartRoomTimelineView: View {
|
||||
let timelineItem: TimelineStartRoomTimelineItem
|
||||
@Environment(\.timelineContext) private var context
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.padding(.vertical, 24)
|
||||
.frame(maxWidth: .infinity)
|
||||
VStack(spacing: 14) {
|
||||
if context?.viewState.hasPredecessor == true {
|
||||
upgrade
|
||||
}
|
||||
if context?.viewState.isDirectOneToOneRoom != true {
|
||||
Text(title)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.padding(.bottom, 24)
|
||||
.padding(.top, context?.viewState.hasPredecessor == true ? 0 : 24)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var title: String {
|
||||
private var upgrade: some View {
|
||||
VStack(spacing: 16) {
|
||||
Text(L10n.screenRoomTimelineUpgradedRoomMessage)
|
||||
.font(.compound.bodyMD)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
Button {
|
||||
context?.send(viewAction: .seePredecessorTapped)
|
||||
} label: {
|
||||
Text(L10n.screenRoomTimelineUpgradedRoomAction)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.compound(.primary, size: .medium))
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.padding(.top, 16)
|
||||
.padding(.bottom, 8)
|
||||
.padding(.horizontal, 16)
|
||||
.highlight(borderColor: .compound.borderInfoSubtle, primaryColor: .compound.bgInfoSubtle, secondaryColor: Color.clear)
|
||||
}
|
||||
|
||||
private var title: String {
|
||||
var text = L10n.screenRoomTimelineBeginningOfRoomNoName
|
||||
if let name = timelineItem.name {
|
||||
text = L10n.screenRoomTimelineBeginningOfRoom(name)
|
||||
@@ -28,8 +58,15 @@ struct TimelineStartRoomTimelineView: View {
|
||||
}
|
||||
|
||||
struct TimelineStartRoomTimelineView_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = TimelineViewModel.mock(hasPredecessor: true)
|
||||
|
||||
static var previews: some View {
|
||||
let item = TimelineStartRoomTimelineItem(name: "Alice and Bob")
|
||||
TimelineStartRoomTimelineView(timelineItem: item)
|
||||
.previewLayout(.sizeThatFits)
|
||||
TimelineStartRoomTimelineView(timelineItem: item)
|
||||
.environment(\.timelineContext, viewModel.context)
|
||||
.previewLayout(.sizeThatFits)
|
||||
.previewDisplayName("with predecessor")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user