mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
424 lines
17 KiB
Swift
424 lines
17 KiB
Swift
//
|
|
// Copyright 2025 Element Creations Ltd.
|
|
// Copyright 2023-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 MatrixRustSDK
|
|
import SwiftUI
|
|
import WysiwygComposer
|
|
|
|
struct ComposerToolbar: View {
|
|
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
|
|
|
@ObservedObject var context: ComposerToolbarViewModel.Context
|
|
|
|
@FocusState private var composerFocused: Bool
|
|
@State private var frame: CGRect = .zero
|
|
|
|
/// - When Liquid Glass is available, the buttons and composer are all 44pt x 44pt.
|
|
/// - On iOS 18 and below, the main buttons are 30pt x 30pt and the composer is 42pt high, so some
|
|
/// additional padding is required to centre the buttons vertically when there's a single line of text,
|
|
/// but preserve their position (using bottom alignment) when there's 2 or more lines of text.
|
|
private var buttonVerticalPadding: CGFloat {
|
|
Compound.supportsGlass ? 0 : 6
|
|
}
|
|
|
|
/// - When Liquid Glass is available, the buttons and composer are all 44pt x 44pt.
|
|
/// - On iOS 18 and below, the trailing button is 36pt x 36pt and the composer is 42pt high, so some
|
|
/// additional padding is required to centre the button (and maintain alignment as described above).
|
|
private var trailingButtonVerticalPadding: CGFloat {
|
|
Compound.supportsGlass ? 0 : 3
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 8) {
|
|
topBar
|
|
|
|
if context.composerFormattingEnabled {
|
|
if verticalSizeClass != .compact,
|
|
context.composerExpanded {
|
|
suggestionView
|
|
.padding(.leading, -5)
|
|
.padding(.trailing, -8)
|
|
}
|
|
bottomBar
|
|
}
|
|
}
|
|
.padding(.leading, 12)
|
|
.padding(.trailing, 12)
|
|
.padding(.bottom, context.composerFormattingEnabled ? 8 : 12)
|
|
.background {
|
|
if context.composerFormattingEnabled {
|
|
RoundedRectangle(cornerRadius: 20)
|
|
.stroke(Color.compound.borderInteractiveSecondary, lineWidth: 0.5)
|
|
.ignoresSafeArea()
|
|
}
|
|
}
|
|
.readFrame($frame)
|
|
.safeAreaInset(edge: .top) {
|
|
if !context.viewState.isRoomEncrypted {
|
|
Label {
|
|
Text(L10n.commonNotEncrypted)
|
|
.font(.compound.bodySM)
|
|
.foregroundStyle(.compound.textSecondary)
|
|
} icon: {
|
|
CompoundIcon(\.lockOff, size: .xSmall, relativeTo: .compound.bodyMD)
|
|
.foregroundStyle(.compound.iconInfoPrimary)
|
|
}
|
|
.padding(4.0)
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
ZStack {
|
|
if verticalSizeClass != .compact, !context.composerExpanded {
|
|
suggestionView
|
|
.offset(y: -frame.height)
|
|
}
|
|
}
|
|
}
|
|
.disabled(!context.viewState.canSend)
|
|
.alert(item: $context.alertInfo)
|
|
}
|
|
|
|
private var suggestionView: some View {
|
|
CompletionSuggestionView(mediaProvider: context.mediaProvider,
|
|
items: context.viewState.suggestions,
|
|
showBackgroundShadow: !context.composerExpanded) { suggestion in
|
|
context.send(viewAction: .selectedSuggestion(suggestion))
|
|
}
|
|
}
|
|
|
|
private var topBar: some View {
|
|
topBarLayout {
|
|
mainTopBarContent
|
|
|
|
if !context.composerFormattingEnabled {
|
|
if context.viewState.isUploading {
|
|
ProgressView()
|
|
.scaledFrame(size: Compound.supportsGlass ? 44 : 36, relativeTo: .compound.headingLG)
|
|
.scaledPadding(.vertical, trailingButtonVerticalPadding, relativeTo: .compound.headingLG)
|
|
} else if context.viewState.showSendButton {
|
|
sendButton
|
|
.scaledPadding(.vertical, trailingButtonVerticalPadding, relativeTo: .compound.headingLG)
|
|
} else {
|
|
voiceMessageRecordingButton(mode: context.viewState.isVoiceMessageModeActivated ? .recording : .idle)
|
|
.scaledPadding(.vertical, trailingButtonVerticalPadding, relativeTo: .compound.headingLG)
|
|
}
|
|
}
|
|
}
|
|
.animation(.linear(duration: 0.15), value: context.viewState.composerMode)
|
|
}
|
|
|
|
private var bottomBar: some View {
|
|
HStack(alignment: .center, spacing: 4) {
|
|
closeRTEButton
|
|
|
|
FormattingToolbar(formatItems: context.formatItems) { action in
|
|
context.send(viewAction: .composerAction(action: action.composerAction))
|
|
}
|
|
.padding(.horizontal, 5)
|
|
|
|
sendButton
|
|
}
|
|
}
|
|
|
|
private var topBarLayout: some Layout {
|
|
HStackLayout(alignment: .bottom, spacing: 12)
|
|
}
|
|
|
|
private var mainTopBarContent: some View {
|
|
ZStack(alignment: .bottom) {
|
|
topBarLayout {
|
|
if !context.composerFormattingEnabled {
|
|
RoomAttachmentPicker(context: context)
|
|
.scaledPadding(.vertical, buttonVerticalPadding, relativeTo: .compound.headingLG)
|
|
}
|
|
messageComposer
|
|
}
|
|
.opacity(context.viewState.isVoiceMessageModeActivated ? 0 : 1)
|
|
|
|
if context.viewState.isVoiceMessageModeActivated {
|
|
voiceMessageContent
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var closeRTEButton: some View {
|
|
Button {
|
|
context.composerFormattingEnabled = false
|
|
context.composerExpanded = false
|
|
} label: {
|
|
CompoundIcon(\.close,
|
|
size: Compound.supportsGlass ? .medium : .small,
|
|
relativeTo: .compound.headingLG)
|
|
}
|
|
.buttonStyle(ComposerToolbarButtonStyle())
|
|
.accessibilityLabel(L10n.richTextEditorCloseFormattingOptions)
|
|
.accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.closeFormattingOptions)
|
|
}
|
|
|
|
private var sendButton: some View {
|
|
SendButton(mode: context.viewState.sendButtonMode, action: sendMessage)
|
|
.accessibilityLabel(context.viewState.sendButtonAccessibilityLabel)
|
|
.disabled(context.viewState.sendButtonDisabled)
|
|
.animation(.linear(duration: 0.1).disabledDuringTests(), value: context.viewState.sendButtonDisabled)
|
|
.keyboardShortcut(.return, modifiers: [.command])
|
|
.accessibilityIdentifier(A11yIdentifiers.roomScreen.sendButton)
|
|
}
|
|
|
|
private var messageComposer: some View {
|
|
MessageComposer(plainComposerText: $context.plainComposerText,
|
|
presendCallback: $context.presendCallback,
|
|
selectedRange: $context.selectedRange,
|
|
composerView: composerView,
|
|
mode: context.viewState.composerMode,
|
|
placeholder: placeholder,
|
|
composerFormattingEnabled: context.composerFormattingEnabled,
|
|
showResizeGrabber: context.composerFormattingEnabled,
|
|
isExpanded: $context.composerExpanded) {
|
|
sendMessage()
|
|
} editAction: {
|
|
context.send(viewAction: .editLastMessage)
|
|
} pasteAction: { providers in
|
|
context.send(viewAction: .handlePasteOrDrop(providers: providers))
|
|
} cancellationAction: {
|
|
switch context.viewState.composerMode {
|
|
case .edit:
|
|
context.send(viewAction: .cancelEdit)
|
|
case .reply:
|
|
context.send(viewAction: .cancelReply)
|
|
default:
|
|
break
|
|
}
|
|
} onAppearAction: {
|
|
context.send(viewAction: .composerAppeared)
|
|
}
|
|
.onDisappear {
|
|
context.send(viewAction: .composerDisappeared)
|
|
}
|
|
.environmentObject(context)
|
|
.focused($composerFocused)
|
|
.accessibilityIdentifier(A11yIdentifiers.roomScreen.messageComposer)
|
|
.onTapGesture {
|
|
guard !composerFocused else { return }
|
|
composerFocused = true
|
|
}
|
|
.onChange(of: context.composerFocused) { _, newValue in
|
|
guard composerFocused != newValue else { return }
|
|
|
|
composerFocused = newValue
|
|
}
|
|
.onChange(of: composerFocused) { _, newValue in
|
|
context.composerFocused = newValue
|
|
}
|
|
.onChange(of: context.plainComposerText) {
|
|
context.send(viewAction: .plainComposerTextChanged)
|
|
}
|
|
.onChange(of: context.composerFormattingEnabled) {
|
|
context.send(viewAction: .didToggleFormattingOptions)
|
|
}
|
|
.onChange(of: context.selectedRange) {
|
|
context.send(viewAction: .selectedTextChanged)
|
|
}
|
|
.onAppear {
|
|
composerFocused = context.composerFocused
|
|
}
|
|
}
|
|
|
|
private func sendMessage() {
|
|
// Allow the inner TextField do apply any final processing before
|
|
// sending e.g. accepting current autocorrection.
|
|
// Fixes https://github.com/element-hq/element-x-ios/issues/3216
|
|
context.presendCallback?()
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
|
context.send(viewAction: .sendMessage)
|
|
}
|
|
}
|
|
|
|
private var placeholder: String {
|
|
switch context.viewState.composerMode {
|
|
case .reply(_, _, let isThread):
|
|
return isThread ? L10n.actionReplyInThread : composerPlaceholder
|
|
default:
|
|
return composerPlaceholder
|
|
}
|
|
}
|
|
|
|
private var composerPlaceholder: String {
|
|
L10n.richTextEditorComposerPlaceholder
|
|
}
|
|
|
|
private var composerView: WysiwygComposerView {
|
|
WysiwygComposerView(placeholder: placeholder,
|
|
placeholderColor: .compound.textSecondary,
|
|
viewModel: context.viewState.wysiwygViewModel,
|
|
itemProviderHelper: ItemProviderHelper(),
|
|
keyCommands: context.viewState.keyCommands) { provider in
|
|
context.send(viewAction: .handlePasteOrDrop(providers: [provider]))
|
|
}
|
|
}
|
|
|
|
private class ItemProviderHelper: WysiwygItemProviderHelper {
|
|
func isPasteSupported(for itemProvider: NSItemProvider) -> Bool {
|
|
itemProvider.isSupportedForPasteOrDrop
|
|
}
|
|
}
|
|
|
|
// MARK: - Voice message
|
|
|
|
@ViewBuilder
|
|
private var voiceMessageContent: some View {
|
|
// Display the voice message composer above to keep the focus and keep the keyboard open if it's already open.
|
|
switch context.viewState.composerMode {
|
|
case .recordVoiceMessage(let state):
|
|
topBarLayout {
|
|
voiceMessageTrashButton
|
|
.scaledPadding(.vertical, buttonVerticalPadding, relativeTo: .compound.headingLG)
|
|
VoiceMessageRecordingComposer(recorderState: state)
|
|
}
|
|
case .previewVoiceMessage(let state, let waveform, let isUploading):
|
|
topBarLayout {
|
|
voiceMessageTrashButton
|
|
.scaledPadding(.vertical, buttonVerticalPadding, relativeTo: .compound.headingLG)
|
|
voiceMessagePreviewComposer(audioPlayerState: state, waveform: waveform)
|
|
}
|
|
.disabled(isUploading)
|
|
default:
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
private func voiceMessageRecordingButton(mode: VoiceMessageRecordingButtonMode) -> some View {
|
|
VoiceMessageRecordingButton(mode: mode) {
|
|
context.send(viewAction: .voiceMessage(.startRecording))
|
|
} stopRecording: {
|
|
context.send(viewAction: .voiceMessage(.stopRecording))
|
|
}
|
|
}
|
|
|
|
private var voiceMessageTrashButton: some View {
|
|
VoiceMessageTrashButton {
|
|
context.send(viewAction: .voiceMessage(.deleteRecording))
|
|
}
|
|
.accessibilityLabel(L10n.a11yDelete)
|
|
}
|
|
|
|
private func voiceMessagePreviewComposer(audioPlayerState: AudioPlayerState, waveform: WaveformSource) -> some View {
|
|
VoiceMessagePreviewComposer(playerState: audioPlayerState, waveform: waveform) {
|
|
context.send(viewAction: .voiceMessage(.startPlayback))
|
|
} onPause: {
|
|
context.send(viewAction: .voiceMessage(.pausePlayback))
|
|
} onSeek: { progress in
|
|
context.send(viewAction: .voiceMessage(.seekPlayback(progress: progress)))
|
|
} onScrubbing: { isScrubbing in
|
|
context.send(viewAction: .voiceMessage(.scrubPlayback(scrubbing: isScrubbing)))
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ComposerToolbarButtonStyle: ButtonStyle {
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
if #available(iOS 26, *) {
|
|
configuration.label
|
|
.modifier(GlassStyle())
|
|
} else {
|
|
configuration.label
|
|
.modifier(FlatStyle(isPressed: configuration.isPressed))
|
|
}
|
|
}
|
|
|
|
@available(iOS 26, *)
|
|
private struct GlassStyle: ViewModifier {
|
|
@Environment(\.isEnabled) private var isEnabled
|
|
|
|
func body(content: Content) -> some View {
|
|
if isEnabled {
|
|
label(content: content)
|
|
.snapshotableGlassEffect(.regular.interactive(),
|
|
snapshotBackground: .compound.bgSubtleSecondary,
|
|
in: .circle)
|
|
} else {
|
|
label(content: content)
|
|
.background(.compound.bgSubtlePrimary, in: .circle)
|
|
}
|
|
}
|
|
|
|
func label(content: Content) -> some View {
|
|
content
|
|
.foregroundStyle(isEnabled ? .compound.iconPrimary : .compound.iconDisabled)
|
|
.scaledPadding(10, relativeTo: .compound.headingLG)
|
|
}
|
|
}
|
|
|
|
private struct FlatStyle: ViewModifier {
|
|
@Environment(\.isEnabled) private var isEnabled
|
|
|
|
let isPressed: Bool
|
|
|
|
func body(content: Content) -> some View {
|
|
content
|
|
.foregroundStyle(.compound.iconOnSolidPrimary)
|
|
.scaledPadding(5, relativeTo: .compound.headingLG)
|
|
.background(backgroundColor(isPressed: isPressed), in: .circle)
|
|
}
|
|
|
|
private func backgroundColor(isPressed: Bool) -> Color {
|
|
guard isEnabled else { return .compound.bgActionPrimaryDisabled }
|
|
return isPressed ? .compound.bgActionPrimaryPressed : .compound.bgActionPrimaryRest
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Previews
|
|
|
|
struct ComposerToolbar_Previews: PreviewProvider, TestablePreview {
|
|
static let timelineViewModel = TimelineViewModel.mock
|
|
|
|
static let viewModel = ComposerToolbarViewModel.mock()
|
|
static let editingViewModel = ComposerToolbarViewModel.mock(message: "Hello, Wrold!", mockMode: .editing)
|
|
static let multiLineViewModel = ComposerToolbarViewModel.mock(message: "Hello, World! This is a loooong message that wraps onto multiple lines.")
|
|
static let voiceMessageRecordingViewModel = ComposerToolbarViewModel.mock(mockMode: .recordVoiceMessage)
|
|
static let voiceMessagePreviewViewModel = ComposerToolbarViewModel.mock(mockMode: .previewVoiceMessage(isUploading: false))
|
|
static let voiceMessageUploadingViewModel = ComposerToolbarViewModel.mock(mockMode: .previewVoiceMessage(isUploading: true))
|
|
static let replyLoadingViewModel = ComposerToolbarViewModel.mock(mockMode: .reply(isLoading: true))
|
|
static let replyLoadedViewModel = ComposerToolbarViewModel.mock(mockMode: .reply(isLoading: false))
|
|
static let suggestionsViewModel = ComposerToolbarViewModel.mock(hasSuggestions: true)
|
|
static let disabledViewModel = ComposerToolbarViewModel.mock(canSend: false)
|
|
|
|
static var previews: some View {
|
|
VStack(spacing: 8) {
|
|
ComposerToolbar(context: viewModel.context)
|
|
ComposerToolbar(context: editingViewModel.context)
|
|
ComposerToolbar(context: multiLineViewModel.context)
|
|
.padding(.bottom)
|
|
|
|
ComposerToolbar(context: voiceMessageRecordingViewModel.context)
|
|
ComposerToolbar(context: voiceMessagePreviewViewModel.context)
|
|
ComposerToolbar(context: voiceMessageUploadingViewModel.context)
|
|
.padding(.bottom)
|
|
|
|
ComposerToolbar(context: disabledViewModel.context)
|
|
}
|
|
|
|
// Putting them in a VStack allows the completion suggestion preview to work properly in tests
|
|
VStack(spacing: 8) {
|
|
ComposerToolbar(context: suggestionsViewModel.context)
|
|
}
|
|
.previewDisplayName("With Suggestions")
|
|
|
|
VStack(spacing: 8) {
|
|
ComposerToolbar(context: replyLoadingViewModel.context)
|
|
ComposerToolbar(context: replyLoadedViewModel.context)
|
|
}
|
|
.environmentObject(timelineViewModel.context)
|
|
.previewDisplayName("Reply")
|
|
}
|
|
}
|