mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
08d02001bf
* Use our standard XYZMock(.init()) pattern everywhere instead of XYZMock.default. * Remove the configuration parameter label on mock convenience inits. * Update the enterprise submodule.
386 lines
17 KiB
Swift
386 lines
17 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
|
|
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
|
|
|
|
enum MarkAsReadSource {
|
|
case up
|
|
case down
|
|
}
|
|
|
|
/// Which scroll button (if any) currently has the "Mark as read" pill displayed alongside it.
|
|
/// Set when the user long-presses one of the scroll buttons; the pill anchors to that button.
|
|
@State private var markAsReadSource: MarkAsReadSource?
|
|
|
|
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 {
|
|
// Sits below the bottom-trailing overlay in z-order, so taps on the pill or
|
|
// buttons still go to them; taps anywhere else dismiss the pill.
|
|
if markAsReadSource != nil {
|
|
Color.clear
|
|
.contentShape(Rectangle())
|
|
.onTapGesture { dismissMarkAsReadPill() }
|
|
}
|
|
}
|
|
.overlay(alignment: .bottomTrailing) {
|
|
VStack(alignment: .trailing, spacing: 16) {
|
|
HStack(spacing: 8) {
|
|
if markAsReadSource == .up {
|
|
markAsReadPill
|
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
|
}
|
|
jumpToReadMarkerButton
|
|
}
|
|
HStack(spacing: 8) {
|
|
if markAsReadSource == .down {
|
|
markAsReadPill
|
|
.transition(.move(edge: .trailing).combined(with: .opacity))
|
|
}
|
|
TimelineScrollButton(isHidden: !timelineContext.viewState.shouldShowScrollToBottomButton,
|
|
showsBadge: scrollToBottomShowsBadge,
|
|
onLongPress: scrollToBottomShowsBadge ? { revealMarkAsReadPill(source: .down) } : nil) {
|
|
dismissMarkAsReadPill()
|
|
timelineContext.send(viewAction: .scrollToBottom)
|
|
}
|
|
.accessibilityIdentifier(A11yIdentifiers.roomScreen.scrollToBottom)
|
|
}
|
|
}
|
|
.padding()
|
|
.animation(.elementDefault, value: markAsReadSource)
|
|
}
|
|
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
|
.topBanners([
|
|
TopBannerLayer(verticalBanners: [
|
|
TopBannerItem(pinnedItemsBanner, isVisible: context.viewState.shouldShowPinnedEventsBanner && !isVoiceOverEnabled),
|
|
TopBannerItem(liveLocationBanner, isVisible: context.viewState.isSharingLiveLocation && !isVoiceOverEnabled)
|
|
]),
|
|
// This can overlay on top of the stacked banners
|
|
TopBannerLayer(knockRequestsBanner, isVisible: context.viewState.shouldSeeKnockRequests)
|
|
], footer: dateBadge)
|
|
.safeAreaInset(edge: .top) {
|
|
// When VoiceOver is enabled, the table view isn't reversed and the scroll gestures
|
|
// don't trigger meaning the banner never hides itself and so the .overlay layout
|
|
// above permanently obscures the top of the timeline. So whenever VoiceOver is
|
|
// enabled we use a safe area inset to vertically stack it above the timeline.
|
|
if context.viewState.shouldShowPinnedEventsBanner || context.viewState.isSharingLiveLocation, isVoiceOverEnabled {
|
|
VStack(spacing: 0) {
|
|
if context.viewState.shouldShowPinnedEventsBanner {
|
|
pinnedItemsBanner
|
|
}
|
|
if context.viewState.isSharingLiveLocation {
|
|
liveLocationBanner
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.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)
|
|
}
|
|
}
|
|
.toolbarRole(RoomHeaderView.toolbarRole)
|
|
.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 }
|
|
.alert(item: $context.alertInfo)
|
|
.timelineMediaPreview(viewModel: $context.mediaPreviewViewModel)
|
|
.onChange(of: pillSourceButtonIsVisible) { _, isVisible in
|
|
if !isVisible { dismissMarkAsReadPill() }
|
|
}
|
|
.track(screen: .Room)
|
|
.sentryTrace("\(Self.self)")
|
|
}
|
|
|
|
private var liveLocationBanner: some View {
|
|
LiveLocationSharingBannerView {
|
|
context.send(viewAction: .tappedOpenLiveLocation)
|
|
} onStop: {
|
|
context.send(viewAction: .tappedStopLiveLocation)
|
|
}
|
|
}
|
|
|
|
private var pinnedItemsBanner: some View {
|
|
PinnedItemsBannerView(state: context.viewState.pinnedEventsBannerState,
|
|
onMainButtonTap: { context.send(viewAction: .tappedPinnedEventsBanner) },
|
|
onViewAllButtonTap: { context.send(viewAction: .viewAllPins) })
|
|
}
|
|
|
|
private var knockRequestsBanner: some View {
|
|
KnockRequestsBannerView(requests: context.viewState.displayedKnockRequests,
|
|
onDismiss: dismissKnockRequestsBanner,
|
|
onAccept: context.viewState.canAcceptKnocks ? acceptKnockRequest : nil,
|
|
onViewAll: onViewAllKnockRequests,
|
|
mediaProvider: context.mediaProvider)
|
|
.padding(.top, 16)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var dateBadge: some View {
|
|
if !isVoiceOverEnabled {
|
|
FloatingDateBadge(dateText: timelineContext.floatingDate?.formattedDateSeparator()) {
|
|
timelineContext.send(viewAction: .scrollToFirstItemForCurrentDate)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var jumpToReadMarkerButton: some View {
|
|
if timelineContext.viewState.shouldShowJumpToReadMarker {
|
|
TimelineScrollButton(direction: .up,
|
|
showsBadge: true) {
|
|
revealMarkAsReadPill(source: .up)
|
|
} callback: {
|
|
dismissMarkAsReadPill()
|
|
timelineContext.send(viewAction: .scrollToReadMarker)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var markAsReadPill: some View {
|
|
Button {
|
|
timelineContext.send(viewAction: .markAllAsRead)
|
|
dismissMarkAsReadPill()
|
|
} label: {
|
|
markAsReadPillLabel
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var markAsReadPillLabel: some View {
|
|
// Font scales with Dynamic Type via the Compound token; padding is a fixed point value
|
|
// so the pill grows with the text instead of growing twice over.
|
|
let label = Label {
|
|
Text(L10n.screenRoomlistMarkAsRead)
|
|
} icon: {
|
|
CompoundIcon(\.markAsRead, size: .medium, relativeTo: .compound.bodyLG)
|
|
}
|
|
.font(.compound.bodyLG)
|
|
.foregroundStyle(.compound.textPrimary)
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 20)
|
|
if #available(iOS 26, *) {
|
|
label.glassEffect(.regular.interactive(), in: Capsule())
|
|
} else {
|
|
label.background(.regularMaterial, in: Capsule())
|
|
}
|
|
}
|
|
|
|
private func revealMarkAsReadPill(source: MarkAsReadSource) {
|
|
markAsReadSource = source
|
|
}
|
|
|
|
private func dismissMarkAsReadPill() {
|
|
markAsReadSource = nil
|
|
}
|
|
|
|
/// Whether the scroll button that the pill is anchored to is still being rendered.
|
|
/// Used to dismiss an orphaned pill when the source button gets hidden — without
|
|
/// this, the pill can render alongside an invisible button. Returns `true` when no
|
|
/// pill is shown so the `onChange` doesn't fire spuriously when the source clears.
|
|
private var pillSourceButtonIsVisible: Bool {
|
|
switch markAsReadSource {
|
|
case .up: timelineContext.viewState.shouldShowJumpToReadMarker
|
|
case .down: timelineContext.viewState.shouldShowScrollToBottomButton
|
|
case .none: true
|
|
}
|
|
}
|
|
|
|
/// Hide the new-messages dot when the jump-to-read-marker feature is disabled.
|
|
private var scrollToBottomShowsBadge: Bool {
|
|
timelineContext.viewState.jumpToReadMarkerEnabled
|
|
&& timelineContext.viewState.bindings.hasNewMessagesAtBottom
|
|
}
|
|
|
|
@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)
|
|
}
|
|
|
|
@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,
|
|
roomHistorySharingState: context.viewState.roomHistorySharingState,
|
|
mediaProvider: context.mediaProvider) {
|
|
context.send(viewAction: .displayRoomDetails)
|
|
}
|
|
}
|
|
|
|
if !ProcessInfo.processInfo.isiOSAppOnMac {
|
|
if context.viewState.shouldShowCallButton {
|
|
RoomCallControlsToolbar(viewState: context.viewState) { isVoiceCall in
|
|
context.send(viewAction: .displayCall(isVoiceCall: isVoiceCall))
|
|
}
|
|
}
|
|
}
|
|
|
|
if context.viewState.roomThreadListEnabled {
|
|
if #available(iOS 26, *) {
|
|
ToolbarSpacer(.fixed, placement: .primaryAction)
|
|
}
|
|
|
|
ToolbarItem(placement: .primaryAction) {
|
|
Button {
|
|
context.send(viewAction: .displayThreadList)
|
|
} label: {
|
|
CompoundIcon(\.threads)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
struct RoomScreen_Previews: PreviewProvider, TestablePreview {
|
|
static let viewModels = makeViewModels()
|
|
static let readOnlyViewModels = makeViewModels(canSendMessage: false)
|
|
static let tombstonedViewModels = makeViewModels(hasSuccessor: true)
|
|
static let composerViewModel = ComposerToolbarViewModel.mock()
|
|
|
|
static var previews: some View {
|
|
ElementNavigationStack {
|
|
RoomScreen(context: viewModels.room.context,
|
|
timelineContext: viewModels.timeline.context,
|
|
composerToolbar: ComposerToolbar(context: composerViewModel.context))
|
|
}
|
|
.previewDisplayName("Normal")
|
|
|
|
ElementNavigationStack {
|
|
RoomScreen(context: readOnlyViewModels.room.context,
|
|
timelineContext: readOnlyViewModels.timeline.context,
|
|
composerToolbar: ComposerToolbar(context: composerViewModel.context))
|
|
}
|
|
.previewDisplayName("Read-only")
|
|
.snapshotPreferences(expect: readOnlyViewModels.room.context.$viewState.map { !$0.canSendMessage })
|
|
|
|
ElementNavigationStack {
|
|
RoomScreen(context: tombstonedViewModels.room.context,
|
|
timelineContext: tombstonedViewModels.timeline.context,
|
|
composerToolbar: ComposerToolbar(context: composerViewModel.context))
|
|
}
|
|
.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 appSettings = AppSettings.volatile()
|
|
let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
|
|
timelineController: MockTimelineController(),
|
|
userSession: UserSessionMock(.init()),
|
|
mediaPlayerProvider: MediaPlayerProviderMock(),
|
|
userIndicatorController: UserIndicatorControllerMock(),
|
|
appMediator: AppMediatorMock(.init()),
|
|
appSettings: appSettings,
|
|
analyticsService: AnalyticsServiceMock(.init()),
|
|
emojiProvider: EmojiProvider(appSettings: appSettings),
|
|
linkMetadataProvider: LinkMetadataProvider(),
|
|
timelineControllerFactory: TimelineControllerFactoryMock(.init()))
|
|
|
|
return .init(room: roomViewModel, timeline: timelineViewModel)
|
|
}
|
|
|
|
struct ViewModels {
|
|
let room: RoomScreenViewModelProtocol
|
|
let timeline: TimelineViewModelProtocol
|
|
}
|
|
}
|