// // Copyright 2025 Element Creations Ltd. // Copyright 2022-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 Combine import Compound import SwiftUI struct HomeScreenRoomCell: View { @Environment(\.dynamicTypeSize) var dynamicTypeSize @Environment(\.redactionReasons) private var redactionReasons let room: HomeScreenRoom var roomListActivityVisibility: RoomListActivityVisibility = .current let isSelected: Bool let mediaProvider: MediaProviderProtocol! let action: (HomeScreenViewAction) -> Void private let verticalInsets = 12.0 private let horizontalInsets = 16.0 var body: some View { Button { if let roomID = room.roomID { action(.selectRoom(roomIdentifier: roomID)) } } label: { HStack(spacing: 16.0) { avatar content .padding(.vertical, verticalInsets) .rowDivider(horizontalInsets: horizontalInsets) } .padding(.horizontal, horizontalInsets) .accessibilityElement(children: .combine) } .buttonStyle(HomeScreenRoomCellButtonStyle(isSelected: isSelected)) .accessibilityIdentifier(A11yIdentifiers.homeScreen.roomName(room.name)) .accessibilityHidden(redactionReasons.contains(.placeholder) ? true : false) } @ViewBuilder private var avatar: some View { if dynamicTypeSize < .accessibility3 { RoomAvatarImage(avatar: room.avatar, avatarSize: .room(on: .chats), mediaProvider: mediaProvider) .dynamicTypeSize(dynamicTypeSize < .accessibility1 ? dynamicTypeSize : .accessibility1) .accessibilityHidden(true) } } private var content: some View { VStack(alignment: .leading, spacing: 2) { header footer } // Hide the normal content for Skeletons and overlay centre aligned placeholders. .opacity(redactionReasons.contains(.placeholder) ? 0 : 1) .overlay { if redactionReasons.contains(.placeholder) { VStack(alignment: .leading, spacing: 2) { header lastMessage } } } } private var header: some View { HStack(alignment: .top, spacing: 16) { HStack(spacing: 4) { Text(room.name) .lineLimit(1) if let statusEmoji = room.statusEmoji { Text(String(statusEmoji)) } } .font(headerFont) .foregroundColor(.compound.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) if let timestamp = room.timestamp { Text(timestamp) .font(room.isHighlighted ? .compound.bodySMSemibold : .compound.bodySM) .foregroundColor(room.isHighlighted ? .compound.textActionAccent : .compound.textSecondary) } } } private var headerFont: Font { switch roomListActivityVisibility { case .current: .compound.bodyLGSemibold case .show: room.hasUnreads ? .compound.bodyLGSemibold : .compound.bodyLG case .hide: room.isHighlighted ? .compound.bodyLGSemibold : .compound.bodyLG } } private var footer: some View { HStack(alignment: .firstTextBaseline, spacing: 0) { ZStack(alignment: .topLeading) { // Hidden text with 2 lines to maintain consistent height, scaling with dynamic text. Text(" \n ") .lastMessageFormatting(hasFailed: false) .hidden() .environment(\.redactionReasons, []) // Always maintain consistent height HStack(alignment: .top, spacing: 4.0) { switch room.lastMessageState { case .sending: CompoundIcon(\.time, size: .small, relativeTo: .compound.bodyMD) .foregroundStyle(.compound.iconTertiary) .offset(y: -1) .accessibilityLabel(L10n.commonSending) case .failed: CompoundIcon(\.errorSolid, size: .small, relativeTo: .compound.bodyMD) .foregroundStyle(.compound.iconCriticalPrimary) .offset(y: -1) .accessibilityHidden(true) // The last message contains the error. case .none: EmptyView() } lastMessage } } Spacer() HStack(spacing: 8) { if room.badges.callBadgeType == .voice { CompoundIcon(\.voiceCallSolid, size: .xSmall, relativeTo: .compound.bodySM) .accessibilityLabel(L10n.a11yNotificationsOngoingCall) } if room.badges.callBadgeType == .video { CompoundIcon(\.videoCallSolid, size: .xSmall, relativeTo: .compound.bodySM) .accessibilityLabel(L10n.a11yNotificationsOngoingCall) } if room.badges.isMuteShown { CompoundIcon(\.notificationsOffSolid, size: .custom(15), relativeTo: .compound.bodyMD) .accessibilityLabel(L10n.a11yNotificationsMuted) } if room.badges.isMentionShown { mentionIcon } if room.badges.isDotShown { Circle() .frame(width: 12, height: 12) .accessibilityLabel(L10n.a11yNotificationsNewMessages) } } .foregroundColor(room.isHighlighted ? .compound.iconAccentTertiary : .compound.iconQuaternary) } } private var mentionIcon: some View { CompoundIcon(\.mention, size: .custom(15), relativeTo: .compound.bodyMD) .accessibilityLabel(L10n.a11yNotificationsNewMentions) } @ViewBuilder private var lastMessage: some View { if let displayedLastMessage = room.displayedLastMessage { Text(displayedLastMessage) .font(lastMessageFont) .lastMessageFormatting(hasFailed: room.lastMessageState == .failed) } } private var lastMessageFont: Font { switch roomListActivityVisibility { case .current: .compound.bodyMD case .show: room.hasUnreads ? .compound.bodyMDSemibold : .compound.bodyMD case .hide: room.isHighlighted ? .compound.bodyMDSemibold : .compound.bodyMD } } } struct HomeScreenRoomCellButtonStyle: ButtonStyle { let isSelected: Bool func makeBody(configuration: Configuration) -> some View { configuration.label .background(isSelected ? Color.compound.bgSubtleSecondary : Color.compound.bgCanvasDefault) .contentShape(Rectangle()) .animation(isSelected ? .none : .easeOut(duration: 0.1).disabledDuringTests(), value: isSelected) } } private extension View { func lastMessageFormatting(hasFailed: Bool) -> some View { foregroundColor(hasFailed ? .compound.textCriticalPrimary : .compound.textSecondary) .lineLimit(2) .multilineTextAlignment(.leading) } } // MARK: - Previews import MatrixRustSDKMocks struct HomeScreenRoomCell_Previews: PreviewProvider, TestablePreview { static let summaryProviderGeneric = RoomSummaryProviderMock(.init(state: .loaded(.mockRooms))) static let genericRooms = summaryProviderGeneric.roomListPublisher.value.compactMap { mockRoom(summary: $0) } static let summaryProviderForNotificationsState = RoomSummaryProviderMock(.init(state: .loaded(.mockRoomsWithNotificationsState))) static let notificationsStateRooms = summaryProviderForNotificationsState.roomListPublisher.value.compactMap { mockRoom(summary: $0) } static let lastMessageStateRooms = [makeRoom(lastMessageState: .sending), makeRoom(lastMessageState: .failed)] static let roomHeroRooms = [makeRoom(heroes: [.mockDan]), makeRoom(heroes: [.mockErin])] static var previews: some View { VStack(spacing: 0) { ForEach(genericRooms) { room in HomeScreenRoomCell(room: room, isSelected: false, mediaProvider: MediaProviderMock(.init())) { _ in } } HomeScreenRoomCell(room: .placeholder(), isSelected: false, mediaProvider: MediaProviderMock(.init())) { _ in } .redacted(reason: .placeholder) } .previewDisplayName("Generic") VStack(spacing: 0) { ForEach(notificationsStateRooms) { room in HomeScreenRoomCell(room: room, isSelected: false, mediaProvider: MediaProviderMock(.init())) { _ in } } } .previewLayout(.sizeThatFits) .previewDisplayName("Notifications State") VStack(spacing: 0) { ForEach(lastMessageStateRooms) { room in HomeScreenRoomCell(room: room, isSelected: false, mediaProvider: MediaProviderMock(.init())) { _ in } } } .previewLayout(.sizeThatFits) .previewDisplayName("Last Message State") VStack(spacing: 0) { ForEach(roomHeroRooms) { room in HomeScreenRoomCell(room: room, isSelected: false, mediaProvider: MediaProviderMock(.init())) { _ in } } } .previewLayout(.sizeThatFits) .previewDisplayName("Room Heroes") } static func mockRoom(summary: RoomSummary) -> HomeScreenRoom? { HomeScreenRoom(summary: summary) } static func makeRoom(lastMessageState: RoomSummary.LastMessageState? = nil, heroes: [UserProfile] = []) -> HomeScreenRoom { let name = if heroes.count == 1 { heroes[0].displayName ?? heroes[0].id } else { "Foundation and Empire" } let summary = RoomSummary(room: RoomSDKMock(), id: UUID().uuidString, joinRequestType: nil, name: name, isDirect: heroes.count == 1, isSpace: false, avatarURL: heroes.count == 1 ? nil : .mockMXCAvatar, heroes: heroes, activeMembersCount: 0, lastMessage: AttributedString("How do you see the Emperor then? You think he keeps office hours?"), lastMessageDate: .mock, lastMessageState: lastMessageState, unreadMessagesCount: 2, unreadMentionsCount: 0, unreadNotificationsCount: 2, notificationMode: .mute, canonicalAlias: "#foundation-and-empire:matrix.org", alternativeAliases: [], hasOngoingCall: false, activeCallIntent: nil, isMarkedUnread: false, isFavourite: false, isTombstoned: false) return .init(summary: summary) } }