Files
letro-ios/ElementX/Sources/Screens/RoomScreen/View/RoomScreen.swift
Doug a7aa116beb Require acknowledgement to send to verified users who have unsigned devices or have changed their identity. (#3215)
* Refactor HeroImage style.

* Add a screen to resolve (crypto-related) timeline item send failures.

* Refactor send failures.

* Update the SDK.
2024-09-10 09:56:11 +01:00

233 lines
11 KiB
Swift

//
// Copyright 2022-2024 New Vector Ltd.
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Compound
import SwiftUI
import WysiwygComposer
struct RoomScreen: View {
@ObservedObject var roomContext: RoomScreenViewModel.Context
@ObservedObject var timelineContext: TimelineViewModel.Context
@ObservedObject private var composerToolbarContext: ComposerToolbarViewModel.Context
@State private var dragOver = false
let composerToolbar: ComposerToolbar
init(roomViewModel: RoomScreenViewModelProtocol,
timelineViewModel: TimelineViewModelProtocol,
composerToolbar: ComposerToolbar) {
roomContext = roomViewModel.context
timelineContext = timelineViewModel.context
self.composerToolbar = composerToolbar
composerToolbarContext = composerToolbar.context
}
var body: some View {
timeline
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.safeAreaInset(edge: .bottom, spacing: 0) {
composerToolbar
.padding(.bottom, composerToolbarContext.composerFormattingEnabled ? 8 : 12)
.background {
if composerToolbarContext.composerFormattingEnabled {
RoundedRectangle(cornerRadius: 20)
.stroke(Color.compound.borderInteractiveSecondary, lineWidth: 0.5)
.ignoresSafeArea()
}
}
.padding(.top, 8)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.environmentObject(timelineContext)
.environment(\.timelineContext, timelineContext)
}
.overlay(alignment: .top) {
Group {
if roomContext.viewState.shouldShowPinnedEventsBanner {
pinnedItemsBanner
}
}
.animation(.elementDefault, value: roomContext.viewState.shouldShowPinnedEventsBanner)
}
.navigationTitle(L10n.screenRoomTitle) // Hidden but used for back button text.
.navigationBarTitleDisplayMode(.inline)
.navigationBarHidden(isNavigationBarHidden)
.toolbar { toolbar }
.toolbarBackground(.visible, for: .navigationBar) // Fix the toolbar's background.
.overlay { loadingIndicator }
.alert(item: $timelineContext.alertInfo)
.sheet(item: $timelineContext.debugInfo) { TimelineItemDebugView(info: $0) }
.sheet(item: $timelineContext.actionMenuInfo) { info in
let actions = TimelineItemMenuActionProvider(timelineItem: info.item,
canCurrentUserRedactSelf: timelineContext.viewState.canCurrentUserRedactSelf,
canCurrentUserRedactOthers: timelineContext.viewState.canCurrentUserRedactOthers,
canCurrentUserPin: timelineContext.viewState.canCurrentUserPin,
pinnedEventIDs: timelineContext.viewState.pinnedEventIDs,
isDM: timelineContext.viewState.isEncryptedOneToOneRoom,
isViewSourceEnabled: timelineContext.viewState.isViewSourceEnabled,
isPinnedEventsTimeline: timelineContext.viewState.isPinnedEventsTimeline)
.makeActions()
if let actions {
TimelineItemMenu(item: info.item, actions: actions)
.environmentObject(timelineContext)
}
}
.sheet(item: $timelineContext.reactionSummaryInfo) {
ReactionsSummaryView(reactions: $0.reactions,
members: timelineContext.viewState.members,
mediaProvider: timelineContext.mediaProvider,
selectedReactionKey: $0.selectedKey)
.edgesIgnoringSafeArea([.bottom])
}
.sheet(item: $timelineContext.readReceiptsSummaryInfo) {
ReadReceiptsSummaryView(orderedReadReceipts: $0.orderedReceipts)
.environmentObject(timelineContext)
}
.interactiveQuickLook(item: $timelineContext.mediaPreviewItem)
.track(screen: .Room)
.onDrop(of: ["public.item", "public.file-url"], isTargeted: $dragOver) { providers -> Bool in
guard let provider = providers.first,
provider.isSupportedForPasteOrDrop else {
return false
}
timelineContext.send(viewAction: .handlePasteOrDrop(provider: provider))
return true
}
.sentryTrace("\(Self.self)")
}
private var timeline: some View {
TimelineView()
.id(timelineContext.viewState.roomID)
.environmentObject(timelineContext)
.environment(\.focussedEventID, timelineContext.viewState.timelineViewState.focussedEvent?.eventID)
.overlay(alignment: .bottomTrailing) {
scrollToBottomButton
}
}
private var pinnedItemsBanner: some View {
PinnedItemsBannerView(state: roomContext.viewState.pinnedEventsBannerState,
onMainButtonTap: { roomContext.send(viewAction: .tappedPinnedEventsBanner) },
onViewAllButtonTap: { roomContext.send(viewAction: .viewAllPins) })
.transition(.move(edge: .top))
}
private var scrollToBottomButton: some View {
Button { timelineContext.send(viewAction: .scrollToBottom) } label: {
Image(systemName: "chevron.down")
.font(.compound.bodyLG)
.fontWeight(.semibold)
.foregroundColor(.compound.iconSecondary)
.padding(13)
.offset(y: 1)
.background {
Circle()
.fill(Color.compound.iconOnSolidPrimary)
// Intentionally using system primary colour to get white/black.
.shadow(color: .primary.opacity(0.33), radius: 2.0)
}
.padding()
}
.opacity(isAtBottomAndLive ? 0.0 : 1.0)
.accessibilityHidden(isAtBottomAndLive)
.animation(.elementDefault, value: isAtBottomAndLive)
.accessibilityIdentifier(A11yIdentifiers.roomScreen.scrollToBottom)
}
private var isAtBottomAndLive: Bool {
timelineContext.isScrolledToBottom && timelineContext.viewState.timelineViewState.isLive
}
@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: roomContext.viewState.roomTitle,
roomAvatar: roomContext.viewState.roomAvatar,
mediaProvider: roomContext.mediaProvider)
// Using a button stops it from getting truncated in the navigation bar
.contentShape(.rect)
.onTapGesture {
roomContext.send(viewAction: .displayRoomDetails)
}
}
if !ProcessInfo.processInfo.isiOSAppOnMac {
ToolbarItem(placement: .primaryAction) {
if roomContext.viewState.shouldShowCallButton {
callButton
.disabled(!roomContext.viewState.canJoinCall)
}
}
}
}
@ViewBuilder
private var callButton: some View {
if roomContext.viewState.hasOngoingCall {
Button {
roomContext.send(viewAction: .displayCall)
} label: {
Label(L10n.actionJoin, icon: \.videoCallSolid)
.labelStyle(.titleAndIcon)
}
.buttonStyle(ElementCallButtonStyle())
.accessibilityIdentifier(A11yIdentifiers.roomScreen.joinCall)
} else {
Button {
roomContext.send(viewAction: .displayCall)
} label: {
CompoundIcon(\.videoCallSolid)
}
.accessibilityIdentifier(A11yIdentifiers.roomScreen.joinCall)
}
}
private var isNavigationBarHidden: Bool {
composerToolbarContext.composerFormattingEnabled && composerToolbarContext.composerExpanded && UIDevice.current.userInterfaceIdiom == .pad
}
}
// MARK: - Previews
struct RoomScreen_Previews: PreviewProvider, TestablePreview {
static let roomProxyMock = JoinedRoomProxyMock(.init(id: "stable_id",
name: "Preview room",
hasOngoingCall: true))
static let roomViewModel = RoomScreenViewModel.mock(roomProxyMock: roomProxyMock)
static let timelineViewModel = TimelineViewModel(roomProxy: roomProxyMock,
timelineController: MockRoomTimelineController(),
mediaProvider: MockMediaProvider(),
mediaPlayerProvider: MediaPlayerProviderMock(),
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
userIndicatorController: ServiceLocator.shared.userIndicatorController,
appMediator: AppMediatorMock.default,
appSettings: ServiceLocator.shared.settings,
analyticsService: ServiceLocator.shared.analytics)
static var previews: some View {
NavigationStack {
RoomScreen(roomViewModel: roomViewModel,
timelineViewModel: timelineViewModel,
composerToolbar: ComposerToolbar.mock())
}
}
}