tombstoned and upgraded rooms implementation

added tests

tombstoned room avatar
This commit is contained in:
Mauro Romito
2025-06-03 12:52:31 +02:00
committed by Mauro
parent b628f5d2c2
commit 155b47e849
76 changed files with 438 additions and 171 deletions
@@ -515,6 +515,11 @@
"screen_room_details_pinned_events_row_title" = "Pinned messages";
"screen_room_details_profile_row_title" = "Profile";
"screen_room_details_requests_to_join_title" = "Requests to join";
"screen_room_timeline_tombstoned_room_action" = "Jump to new room";
"screen_room_timeline_tombstoned_room_message" = "This room has been replaced and is no longer active";
"screen_room_timeline_upgraded_room_action" = "See old messages";
"screen_room_timeline_upgraded_room_message" = "This room is a continuation of another room";
"screen_roomlist_tombstoned_room_description" = "This room has been upgraded";
"screen_security_and_privacy_add_room_address_action" = "Add room address";
"screen_security_and_privacy_ask_to_join_option_description" = "Anyone can ask to join the room but an administrator or moderator will have to accept the request.";
"screen_security_and_privacy_enable_encryption_alert_confirm_button_title" = "Yes, enable encryption";
@@ -549,6 +549,8 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
stateMachine.tryEvent(.presentKnockRequestsListScreen)
case .presentThread(let itemID):
stateMachine.tryEvent(.presentThread(itemID: itemID))
case .presentRoom(roomID: let roomID):
stateMachine.tryEvent(.startChildFlow(roomID: roomID, via: [], entryPoint: .room))
}
}
.store(in: &cancellables)
+10
View File
@@ -2312,6 +2312,14 @@ internal enum L10n {
internal static func screenRoomTimelineStateChanges(_ p1: Int) -> String {
return L10n.tr("Localizable", "screen_room_timeline_state_changes", p1)
}
/// Jump to new room
internal static var screenRoomTimelineTombstonedRoomAction: String { return L10n.tr("Localizable", "screen_room_timeline_tombstoned_room_action") }
/// This room has been replaced and is no longer active
internal static var screenRoomTimelineTombstonedRoomMessage: String { return L10n.tr("Localizable", "screen_room_timeline_tombstoned_room_message") }
/// See old messages
internal static var screenRoomTimelineUpgradedRoomAction: String { return L10n.tr("Localizable", "screen_room_timeline_upgraded_room_action") }
/// This room is a continuation of another room
internal static var screenRoomTimelineUpgradedRoomMessage: String { return L10n.tr("Localizable", "screen_room_timeline_upgraded_room_message") }
/// Chat
internal static var screenRoomTitle: String { return L10n.tr("Localizable", "screen_room_title") }
/// Plural format key: "%#@COUNT@"
@@ -2380,6 +2388,8 @@ internal enum L10n {
internal static var screenRoomlistMarkAsRead: String { return L10n.tr("Localizable", "screen_roomlist_mark_as_read") }
/// Mark as unread
internal static var screenRoomlistMarkAsUnread: String { return L10n.tr("Localizable", "screen_roomlist_mark_as_unread") }
/// This room has been upgraded
internal static var screenRoomlistTombstonedRoomDescription: String { return L10n.tr("Localizable", "screen_roomlist_tombstoned_room_description") }
/// Add room address
internal static var screenSecurityAndPrivacyAddRoomAddressAction: String { return L10n.tr("Localizable", "screen_security_and_privacy_add_room_address_action") }
/// Anyone can ask to join the room but an administrator or moderator will have to accept the request.
@@ -6484,6 +6484,7 @@ class JoinedRoomProxyMock: JoinedRoomProxyProtocol, @unchecked Sendable {
set(value) { underlyingTimeline = value }
}
var underlyingTimeline: TimelineProxyProtocol!
var predecessorRoom: PredecessorRoom?
var id: String {
get { return underlyingId }
set(value) { underlyingId = value }
@@ -48,6 +48,8 @@ struct JoinedRoomProxyMockConfiguration {
var membership: Membership = .joined
var isVisibleInPublicDirectory = false
var predecessor: PredecessorRoom?
var successor: SuccessorRoom?
}
extension JoinedRoomProxyMock {
@@ -137,6 +139,8 @@ extension JoinedRoomProxyMock {
clearDraftReturnValue = .success(())
sendTypingNotificationIsTypingReturnValue = .success(())
isVisibleInRoomDirectoryReturnValue = .success(configuration.isVisibleInPublicDirectory)
predecessorRoom = configuration.predecessor
}
}
@@ -152,7 +156,7 @@ extension RoomInfo {
isDirect: configuration.isDirect,
isPublic: configuration.isPublic,
isSpace: configuration.isSpace,
successorRoom: nil,
successorRoom: configuration.successor,
isFavourite: false,
canonicalAlias: configuration.canonicalAlias,
alternativeAliases: configuration.alternativeAliases,
@@ -89,7 +89,8 @@ extension RoomSummary {
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false)
isFavourite: false,
isTombstoned: false)
}
}
@@ -113,7 +114,8 @@ extension Array where Element == RoomSummary {
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false),
isFavourite: false,
isTombstoned: false),
RoomSummary(room: RoomSDKMock(),
id: "2",
joinRequestType: nil,
@@ -132,7 +134,8 @@ extension Array where Element == RoomSummary {
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false),
isFavourite: false,
isTombstoned: false),
RoomSummary(room: RoomSDKMock(),
id: "3",
joinRequestType: nil,
@@ -151,7 +154,8 @@ extension Array where Element == RoomSummary {
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false),
isFavourite: false,
isTombstoned: false),
RoomSummary(room: RoomSDKMock(),
id: "4",
joinRequestType: nil,
@@ -170,7 +174,8 @@ extension Array where Element == RoomSummary {
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false),
isFavourite: false,
isTombstoned: false),
RoomSummary(room: RoomSDKMock(),
id: "5",
joinRequestType: nil,
@@ -189,7 +194,8 @@ extension Array where Element == RoomSummary {
alternativeAliases: [],
hasOngoingCall: true,
isMarkedUnread: false,
isFavourite: false),
isFavourite: false,
isTombstoned: false),
RoomSummary(room: RoomSDKMock(),
id: "6",
joinRequestType: nil,
@@ -208,7 +214,28 @@ extension Array where Element == RoomSummary {
alternativeAliases: [],
hasOngoingCall: true,
isMarkedUnread: false,
isFavourite: false),
isFavourite: false,
isTombstoned: false),
RoomSummary(room: RoomSDKMock(),
id: "7",
joinRequestType: nil,
name: "Tombstoned",
isDirect: false,
avatarURL: nil,
heroes: [],
activeMembersCount: 0,
lastMessage: nil,
lastMessageDate: .mock,
unreadMessagesCount: 1,
unreadMentionsCount: 0,
unreadNotificationsCount: 1,
notificationMode: .allMessages,
canonicalAlias: nil,
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false,
isTombstoned: true),
RoomSummary(room: RoomSDKMock(),
id: "0",
joinRequestType: nil,
@@ -227,7 +254,8 @@ extension Array where Element == RoomSummary {
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false)
isFavourite: false,
isTombstoned: false)
]
static let mockRoomsWithNotificationsState: [Element] = {
@@ -279,7 +307,8 @@ extension Array where Element == RoomSummary {
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false),
isFavourite: false,
isTombstoned: false),
RoomSummary(room: RoomSDKMock(),
id: "someAwesomeRoomId2",
joinRequestType: .invite(inviter: RoomMemberProxyMock.mockCharlie),
@@ -298,6 +327,7 @@ extension Array where Element == RoomSummary {
alternativeAliases: [],
hasOngoingCall: false,
isMarkedUnread: false,
isFavourite: false)
isFavourite: false,
isTombstoned: false)
]
}
@@ -0,0 +1,35 @@
//
// Copyright 2025 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
// Please see LICENSE files in the repository root for full details.
//
import Compound
import SwiftUI
struct Highlight: ViewModifier {
let borderColor: Color
let primaryColor: Color
let secondaryColor: Color
func body(content: Content) -> some View {
ZStack(alignment: .top) {
VStack(spacing: 0) {
borderColor
.frame(height: 1)
LinearGradient(colors: [primaryColor, secondaryColor],
startPoint: .top,
endPoint: .bottom)
}
content
.layoutPriority(1)
}
}
}
extension View {
func highlight(borderColor: Color, primaryColor: Color, secondaryColor: Color) -> some View {
modifier(Highlight(borderColor: borderColor, primaryColor: primaryColor, secondaryColor: secondaryColor))
}
}
@@ -12,6 +12,7 @@ struct PlaceholderAvatarImage: View {
@Environment(\.redactionReasons) private var redactionReasons
private let textForImage: String
/// When `nil` the tombstoned colors will be used
private let contentID: String?
var body: some View {
@@ -22,7 +23,9 @@ struct PlaceholderAvatarImage: View {
// This text's frame doesn't look right when redacted
if redactionReasons != .placeholder {
Text(textForImage)
.foregroundColor(avatarColor?.text ?? .white)
.foregroundColor(avatarColor?.text ??
// tombstoned room foreground
.compound.iconTertiary)
.font(.system(size: geometry.size.width * 0.5625, weight: .semibold))
.minimumScaleFactor(0.001)
.frame(alignment: .center)
@@ -43,7 +46,9 @@ struct PlaceholderAvatarImage: View {
return Color(.systemGray4) // matches the default text redaction
}
return avatarColor?.background ?? .compound.iconPrimary
return avatarColor?.background ??
// tombstoned room background
.compound.bgSubtlePrimary
}
private var avatarColor: DecorativeColor? {
@@ -70,6 +75,10 @@ struct PlaceholderAvatarImage_Previews: PreviewProvider, TestablePreview {
.clipShape(Circle())
.frame(width: 150, height: 100)
PlaceholderAvatarImage(name: "!", contentID: nil)
.clipShape(Circle())
.frame(width: 100, height: 100)
PlaceholderAvatarImage(name: nil, contentID: "@fooserid:matrix.org")
.clipShape(Circle())
.frame(width: 30, height: 30)
@@ -13,13 +13,17 @@ enum RoomAvatar: Equatable {
case room(id: String, name: String?, avatarURL: URL?)
/// An avatar generated from the room's heroes.
case heroes([UserProfileProxy])
/// A static avatar for a tombstoned room.
case tombstoned
var removingAvatar: RoomAvatar {
switch self {
case let .room(id, name, _):
return .room(id: id, name: name, avatarURL: nil)
.room(id: id, name: name, avatarURL: nil)
case let .heroes(users):
return .heroes(users.map { .init(userID: $0.userID, displayName: $0.displayName, avatarURL: nil) })
.heroes(users.map { .init(userID: $0.userID, displayName: $0.displayName, avatarURL: nil) })
case .tombstoned:
.tombstoned
}
}
}
@@ -91,6 +95,12 @@ struct RoomAvatarImage: View {
mediaProvider: mediaProvider,
onTap: onAvatarTap)
}
case .tombstoned:
LoadableAvatarImage(url: nil,
name: "!",
contentID: nil, avatarSize: avatarSize,
mediaProvider: mediaProvider,
onTap: onAvatarTap)
}
}
}
@@ -126,6 +136,8 @@ struct RoomAvatarImage_Previews: PreviewProvider, TestablePreview {
.init(userID: "@bob:server.net", displayName: "Bob", avatarURL: nil)]),
avatarSize: .room(on: .home),
mediaProvider: MediaProviderMock(configuration: .init()))
RoomAvatarImage(avatar: .tombstoned, avatarSize: .room(on: .home), mediaProvider: MediaProviderMock(configuration: .init()))
}
}
}
@@ -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()
}
@@ -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
}
}
@@ -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()),
@@ -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")
}
}
@@ -34,6 +34,8 @@ class JoinedRoomProxy: JoinedRoomProxyProtocol {
var ownUserID: String { room.ownUserId() }
lazy var predecessorRoom: PredecessorRoom? = room.predecessorRoom()
let timeline: TimelineProxyProtocol
private let infoSubject: CurrentValueSubject<RoomInfoProxy, Never>
@@ -33,6 +33,10 @@ struct RoomInfoProxy: BaseRoomInfoProxyProtocol {
var avatarURL: URL? { roomInfo.avatarUrl.flatMap(URL.init) }
/// The room's avatar info for use in a ``RoomAvatarImage``.
var avatar: RoomAvatar {
guard successor == nil else {
return .tombstoned
}
if isDirect, avatarURL == nil, heroes.count == 1 {
return .heroes(heroes.map(UserProfileProxy.init))
}
@@ -61,7 +65,7 @@ struct RoomInfoProxy: BaseRoomInfoProxyProtocol {
}
var isSpace: Bool { roomInfo.isSpace }
var tombstoneInfo: SuccessorRoom? { roomInfo.successorRoom }
var successor: SuccessorRoom? { roomInfo.successorRoom }
var isFavourite: Bool { roomInfo.isFavourite }
var canonicalAlias: String? { roomInfo.canonicalAlias }
var alternativeAliases: [String] { roomInfo.alternativeAliases }
@@ -76,6 +76,8 @@ protocol JoinedRoomProxyProtocol: RoomProxyProtocol {
var timeline: TimelineProxyProtocol { get }
var predecessorRoom: PredecessorRoom? { get }
func subscribeForUpdates() async
func subscribeToRoomInfoUpdates()
@@ -56,6 +56,8 @@ struct RoomSummary {
let isMarkedUnread: Bool
let isFavourite: Bool
let isTombstoned: Bool
var hasUnreadMessages: Bool { unreadMessagesCount > 0 }
var hasUnreadMentions: Bool { unreadMentionsCount > 0 }
var hasUnreadNotifications: Bool { unreadNotificationsCount > 0 }
@@ -124,14 +126,19 @@ extension RoomSummary {
joinRequestType = nil
isMarkedUnread = false
isFavourite = false
isTombstoned = false
}
// This doesn't have to work properly for DM invites, the heroes are always empty
var avatar: RoomAvatar {
guard !isTombstoned else {
return .tombstoned
}
if isDirect, avatarURL == nil, heroes.count == 1 {
.heroes(heroes)
return .heroes(heroes)
} else {
.room(id: id, name: name, avatarURL: avatarURL)
return .room(id: id, name: name, avatarURL: avatarURL)
}
}
}
@@ -281,7 +281,8 @@ class RoomSummaryProvider: RoomSummaryProviderProtocol {
alternativeAliases: .init(roomInfo.alternativeAliases),
hasOngoingCall: roomInfo.hasRoomCall,
isMarkedUnread: roomInfo.isMarkedUnread,
isFavourite: roomInfo.isFavourite)
isFavourite: roomInfo.isFavourite,
isTombstoned: roomInfo.successorRoom != nil)
}
private func buildDiff(from diff: RoomListEntriesUpdate, on rooms: [RoomSummary]) -> CollectionDifference<RoomSummary>? {
@@ -185,11 +185,13 @@ class TimelineController: TimelineControllerProtocol {
sender: nil,
attachments: nil)
let avatarURL = switch roomProxy.details.avatar {
let avatarURL: URL? = switch roomProxy.details.avatar {
case .room(_, _, let avatarURL):
avatarURL
case .heroes(let userProfiles):
userProfiles.first?.avatarURL
case .tombstoned:
nil
}
func addPlacehoder() {
@@ -389,6 +391,7 @@ class TimelineController: TimelineControllerProtocol {
let isDM = roomProxy.isDirectOneToOneRoom
let displayName = roomProxy.infoPublisher.value.displayName
let hasPredecessor = roomProxy.predecessorRoom != nil
var newTimelineItems = await Task.detached { [timelineItemFactory, activeTimeline] in
var newTimelineItems = [RoomTimelineItemProtocol]()
@@ -401,6 +404,7 @@ class TimelineController: TimelineControllerProtocol {
let items = collapsibleChunk.compactMap { itemProxy in
let timelineItem = self.buildTimelineItem(for: itemProxy,
isDM: isDM,
hasPredecessor: hasPredecessor,
roomDisplayName: displayName,
timelineItemFactory: timelineItemFactory,
activeTimeline: activeTimeline)
@@ -451,6 +455,7 @@ class TimelineController: TimelineControllerProtocol {
private nonisolated func buildTimelineItem(for itemProxy: TimelineItemProxy,
isDM: Bool,
hasPredecessor: Bool,
roomDisplayName: String?,
timelineItemFactory: RoomTimelineItemFactoryProtocol,
activeTimeline: TimelineProxyProtocol) -> RoomTimelineItemProtocol? {
@@ -475,7 +480,11 @@ class TimelineController: TimelineControllerProtocol {
case .readMarker:
return ReadMarkerRoomTimelineItem(id: .virtual(uniqueID: uniqueID))
case .timelineStart:
return isDM ? nil : TimelineStartRoomTimelineItem(name: roomDisplayName)
if hasPredecessor {
return TimelineStartRoomTimelineItem(name: roomDisplayName)
} else {
return isDM ? nil : TimelineStartRoomTimelineItem(name: roomDisplayName)
}
}
case .unknown:
return nil
@@ -8,6 +8,6 @@
import Foundation
struct TimelineStartRoomTimelineItem: DecorationTimelineItemProtocol, Equatable {
let id: TimelineItemIdentifier = .virtual(uniqueID: .init(UUID().uuidString))
let id: TimelineItemIdentifier = .virtual(uniqueID: .init("TimelineStart"))
let name: String?
}