mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 03:06:37 +00:00
c41a38159b
* 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>
296 lines
12 KiB
Swift
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)
|
|
}
|
|
}
|