Files
element-x-ios/ElementX/Sources/Screens/HomeScreen/View/HomeScreenRoomCell.swift
T
Copilot c41a38159b Only offer joined rooms in the forward and share pickers (#6255)
* Initial plan

* Offer only joined rooms in destination pickers

Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com>

* Clean whitespace in share picker test

Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com>

* Restrict the shared picker room list to joined rooms

The alternate room summary provider, which backs the forward, share and
Add rooms pickers and global search, now always includes the SDK's
joined filter in its base filter. This replaces the per-call joinedOnly
flags, so Add rooms no longer offers invited rooms or spaces and no
screen can forget the flag.

A filter set before the provider has its room list is now applied once
the list is set, instead of being reset to the default. The share picker
hit this on a cold launch from the share extension.

The forward and share pickers also hide upgraded rooms, matching the
Suggestions section. The SDK has no filter for these.

Also registers the share picker's test file in the project, fixes the
blank-line indentation SwiftFormat flagged, and re-records the picker
snapshots without the upgraded room.

* Check room membership for forwarding suggestions and search breadcrumbs

RoomSummary now has isJoined, built from the room's membership. The
forwarding suggestions and the search breadcrumbs used a missing
joinRequestType to mean joined, which is also the case for rooms that
have been left or banned from. It only worked because the room list's
base filter already drops those.

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Velin92 <34335419+Velin92@users.noreply.github.com>
Co-authored-by: Mauro Romito <mauro.romito@element.io>
2026-10-02 12:44:33 +00:00

296 lines
12 KiB
Swift

//
// 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
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(.compound.bodyLGSemibold)
.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 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 {
if room.isHighlighted, room.badges.notificationCount > 0 {
Text(formattedNotificationCount)
.font(.compound.bodySMSemibold)
.foregroundColor(.compound.textOnSolidPrimary)
.lineLimit(1)
.padding(.horizontal, 6)
.frame(minWidth: 20, minHeight: 20)
.background(.compound.iconAccentPrimary, in: .capsule)
.accessibilityLabel(L10n.a11yNotificationsNewMessages)
} else {
Circle()
.frame(width: 12, height: 12)
.accessibilityLabel(L10n.a11yNotificationsNewMessages)
}
}
}
.foregroundColor(room.isHighlighted ? .compound.iconAccentPrimary : .compound.iconQuaternary)
}
}
private var mentionIcon: some View {
CompoundIcon(\.mention, size: .custom(15), relativeTo: .compound.bodyMD)
.accessibilityLabel(L10n.a11yNotificationsNewMentions)
}
private var formattedNotificationCount: String {
room.badges.notificationCount > 99 ? "99+" : "\(room.badges.notificationCount)"
}
@ViewBuilder
private var lastMessage: some View {
if let displayedLastMessage = room.displayedLastMessage {
Text(displayedLastMessage)
.font(.compound.bodyMD)
.lastMessageFormatting(hasFailed: room.lastMessageState == .failed)
}
}
}
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,
isJoined: true,
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)
}
}