Files
letro-ios/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift
Mauro a18eff9201 Accessibiliy Tests part 2 (#4325)
* running all the tests

* setting up CI

* fixed the workflow

* workflow on pull request, just to make it appear

* removed the test to run var

* fix archived tests name

* improved the tests, by filtering out some noise

* pr suggestions and added an improvement to the filtering

* improved the interrupt handler

* improved the UI interruption monitor handler

* some more refinement to handle the interruptor + false positive for non human readable labels

* reverted wrong commit

* ready for review, removed the on pull request check

* pr suggestions
2025-07-18 10:33:45 +02:00

279 lines
12 KiB
Swift

//
// Copyright 2022-2024 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
import WysiwygComposer
struct RoomScreen: View {
@ObservedObject private var context: RoomScreenViewModelType.Context
@ObservedObject private var timelineContext: TimelineViewModelType.Context
let composerToolbar: ComposerToolbar
@Environment(\.accessibilityVoiceOverEnabled) private var isVoiceOverEnabled
init(context: RoomScreenViewModelType.Context,
timelineContext: TimelineViewModelType.Context,
composerToolbar: ComposerToolbar) {
self.context = context
self.timelineContext = timelineContext
self.composerToolbar = composerToolbar
}
var body: some View {
TimelineView(timelineContext: timelineContext)
.overlay(alignment: .bottomTrailing) {
TimelineScrollToBottomButton(isVisible: isAtBottomAndLive) {
timelineContext.send(viewAction: .scrollToBottom)
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.scrollToBottom)
}
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.overlay(alignment: .top) {
if !isVoiceOverEnabled {
pinnedItemsBanner
}
}
// This can overlay on top of the pinnedItemsBanner
.overlay(alignment: .top) {
knockRequestsBanner
}
.safeAreaInset(edge: .top) {
// When voice over is on the table view is not reversed
// and the scroll gestures are not intercepted
// so we render the pinned banner on top.
if isVoiceOverEnabled {
pinnedItemsBanner
}
}
.safeAreaInset(edge: .bottom, spacing: 0) {
VStack(spacing: 0) {
RoomScreenFooterView(details: context.viewState.footerDetails,
mediaProvider: context.mediaProvider) { action in
context.send(viewAction: .footerViewAction(action))
}
composer
.padding(.top, 8)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.environmentObject(timelineContext)
.environment(\.timelineContext, timelineContext)
// Make sure the reply header honours the hideTimelineMedia setting too.
.environment(\.shouldAutomaticallyLoadImages, !timelineContext.viewState.hideTimelineMedia)
}
}
.navigationTitle(L10n.screenRoomTitle) // Hidden but used for back button text.
.navigationBarTitleDisplayMode(.inline)
.toolbar { toolbar }
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
.overlay { loadingIndicator }
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
.track(screen: .Room)
.sentryTrace("\(Self.self)")
}
@ViewBuilder
private var pinnedItemsBanner: some View {
Group {
if context.viewState.shouldShowPinnedEventsBanner {
PinnedItemsBannerView(state: context.viewState.pinnedEventsBannerState,
onMainButtonTap: { context.send(viewAction: .tappedPinnedEventsBanner) },
onViewAllButtonTap: { context.send(viewAction: .viewAllPins) })
.transition(.move(edge: .top))
}
}
.animation(.elementDefault, value: context.viewState.shouldShowPinnedEventsBanner)
}
@ViewBuilder
private var knockRequestsBanner: some View {
Group {
if context.viewState.shouldSeeKnockRequests {
KnockRequestsBannerView(requests: context.viewState.displayedKnockRequests,
onDismiss: dismissKnockRequestsBanner,
onAccept: context.viewState.canAcceptKnocks ? acceptKnockRequest : nil,
onViewAll: onViewAllKnockRequests,
mediaProvider: context.mediaProvider)
.padding(.top, 16)
.transition(.move(edge: .top))
}
}
.animation(.elementDefault, value: context.viewState.shouldSeeKnockRequests)
}
private func dismissKnockRequestsBanner() {
context.send(viewAction: .dismissKnockRequests)
}
private func acceptKnockRequest(eventID: String) {
context.send(viewAction: .acceptKnock(eventID: eventID))
}
private func onViewAllKnockRequests() {
context.send(viewAction: .viewKnockRequests)
}
private var isAtBottomAndLive: Bool {
timelineContext.isScrolledToBottom && timelineContext.viewState.timelineState.isLive
}
@ViewBuilder
private var composer: some View {
if context.viewState.hasSuccessor {
tombstonedDialogue
} else if context.viewState.canSendMessage, !ProcessInfo.isRunningAccessibilityTests {
// We are not sure why but when wrapped in the room screen the composer toolbar breaks the accessibility tests
composerToolbar
} else {
ComposerDisabledView()
}
}
private var tombstonedDialogue: some View {
VStack(spacing: 16) {
Text(L10n.screenRoomTimelineTombstonedRoomMessage)
.font(.compound.bodyMD)
.foregroundStyle(.compound.textPrimary)
Button {
context.send(viewAction: .displaySuccessorRoom)
} label: {
Text(L10n.screenRoomTimelineTombstonedRoomAction)
.frame(maxWidth: .infinity)
}
.buttonStyle(.compound(.primary, size: .medium))
}
.padding(.top, 16)
.padding(.horizontal, 16)
.padding(.bottom, 12)
.highlight(gradient: .compound.info,
borderColor: .compound.borderInfoSubtle,
backgroundColor: .compound.bgCanvasDefault)
}
@ViewBuilder
private var loadingIndicator: some View {
if timelineContext.viewState.showLoading {
ProgressView()
.progressViewStyle(.circular)
.tint(.compound.textPrimary)
.padding(16)
.background(.ultraThickMaterial)
.cornerRadius(8)
}
}
@ToolbarContentBuilder
private var toolbar: some ToolbarContent {
// .principal + .primaryAction works better than .navigation leading + trailing
// as the latter disables interaction in the action button for rooms with long names
ToolbarItem(placement: .principal) {
RoomHeaderView(roomName: context.viewState.roomTitle,
roomAvatar: context.viewState.roomAvatar,
dmRecipientVerificationState: context.viewState.dmRecipientVerificationState,
mediaProvider: context.mediaProvider)
// Using a button stops it from getting truncated in the navigation bar
.contentShape(.rect)
.onTapGesture {
context.send(viewAction: .displayRoomDetails)
}
}
if !ProcessInfo.processInfo.isiOSAppOnMac {
ToolbarItem(placement: .primaryAction) {
if context.viewState.shouldShowCallButton {
callButton
.disabled(!context.viewState.canJoinCall)
}
}
}
}
@ViewBuilder
private var callButton: some View {
if context.viewState.hasOngoingCall {
Button {
context.send(viewAction: .displayCall)
} label: {
Label(L10n.actionJoin, icon: \.videoCallSolid)
.labelStyle(.titleAndIcon)
}
.buttonStyle(ElementCallButtonStyle())
.accessibilityLabel(L10n.a11yJoinCall)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.joinCall)
} else {
Button {
context.send(viewAction: .displayCall)
} label: {
CompoundIcon(\.videoCallSolid)
}
.accessibilityLabel(L10n.a11yStartCall)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.joinCall)
}
}
}
// MARK: - Previews
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 {
RoomScreen(context: viewModels.room.context,
timelineContext: viewModels.timeline.context,
composerToolbar: ComposerToolbar.mock())
}
.previewDisplayName("Normal")
NavigationStack {
RoomScreen(context: readOnlyViewModels.room.context,
timelineContext: readOnlyViewModels.timeline.context,
composerToolbar: ComposerToolbar.mock())
}
.previewDisplayName("Read-only")
.snapshotPreferences(expect: readOnlyViewModels.room.context.$viewState.map { !$0.canSendMessage })
NavigationStack {
RoomScreen(context: tombstonedViewModels.room.context,
timelineContext: tombstonedViewModels.timeline.context,
composerToolbar: ComposerToolbar.mock())
}
.previewDisplayName("Tombstoned")
.snapshotPreferences(expect: tombstonedViewModels.room.context.$viewState.map(\.hasSuccessor))
}
static func makeViewModels(canSendMessage: Bool = true, hasSuccessor: Bool = false) -> ViewModels {
let roomProxyMock = JoinedRoomProxyMock(.init(id: "stable_id",
name: "Preview room",
hasOngoingCall: true,
successor: hasSuccessor ? .init(roomId: UUID().uuidString, reason: nil) : nil,
powerLevelsConfiguration: .init(canUserSendMessage: canSendMessage)))
let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock)
let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
timelineController: MockTimelineController(),
mediaProvider: MediaProviderMock(configuration: .init()),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics,
emojiProvider: EmojiProvider(appSettings: ServiceLocator.shared.settings),
timelineControllerFactory: TimelineControllerFactoryMock(.init()),
clientProxy: ClientProxyMock(.init()))
return .init(room: roomViewModel, timeline: timelineViewModel)
}
struct ViewModels {
let room: RoomScreenViewModelProtocol
let timeline: TimelineViewModelProtocol
}
}