// // 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 Compound import SwiftUI import WysiwygComposer typealias GenericKeyHandler = (_ key: UIKeyboardHIDUsage) -> Void typealias PasteHandler = ([NSItemProvider]) -> Void struct MessageComposer: View { @Binding var plainComposerText: NSAttributedString @Binding var presendCallback: (() -> Void)? @Binding var selectedRange: NSRange let composerView: WysiwygComposerView let mode: ComposerMode let placeholder: String let composerFormattingEnabled: Bool let showResizeGrabber: Bool @Binding var isExpanded: Bool let sendAction: () -> Void let editAction: () -> Void let pasteAction: PasteHandler let cancellationAction: () -> Void let onAppearAction: () -> Void @State private var composerTranslation: CGFloat = 0 private let composerShape = RoundedRectangle(cornerRadius: 21, style: .circular) var body: some View { VStack(spacing: 0) { if showResizeGrabber { resizeGrabber } composerTextField .messageComposerStyle(header: header) // Explicitly disable all animations to fix weirdness with the header immediately // appearing whilst the text field and keyboard are still animating up to it. .animation(.noAnimation, value: mode) } .gesture(showResizeGrabber ? dragGesture : nil) } // MARK: - Private @State private var composerFrame = CGRect.zero @ViewBuilder private var composerTextField: some View { if composerFormattingEnabled { Color.clear .overlay(alignment: .top) { composerView .clipped() .readFrame($composerFrame) } .frame(minHeight: ComposerConstant.minHeight, maxHeight: max(composerHeight, composerFrame.height), alignment: .top) .onAppear { onAppearAction() } } else { MessageComposerTextField(placeholder: placeholder, text: $plainComposerText, presendCallback: $presendCallback, selectedRange: $selectedRange, maxHeight: ComposerConstant.maxHeight, keyHandler: { handleKeyPress($0) }, pasteHandler: pasteAction) } } private var composerHeight: CGFloat { let baseHeight = isExpanded ? ComposerConstant.maxHeight : ComposerConstant.minHeight return (baseHeight - composerTranslation).clamped(to: ComposerConstant.allowedHeightRange) } @ViewBuilder private var header: some View { switch mode { case .reply(_, let replyDetails, _): MessageComposerReplyHeader(replyDetails: replyDetails, action: cancellationAction) case .edit(_, let editType): MessageComposerEditHeader(editType: editType, action: cancellationAction) case .recordVoiceMessage, .previewVoiceMessage, .default: EmptyView() } } private var resizeGrabber: some View { Capsule() .foregroundStyle(.tertiary) .frame(width: 36, height: 5) .padding(.vertical, 8) .frame(maxWidth: .infinity) } private var dragGesture: some Gesture { DragGesture() .onChanged { value in composerTranslation += value.translation.height } .onEnded { _ in withElementAnimation(.easeIn(duration: 0.3)) { if composerTranslation > ComposerConstant.translationThreshold { isExpanded = false } else if composerTranslation < -ComposerConstant.translationThreshold { isExpanded = true } composerTranslation = 0 } } } private func handleKeyPress(_ key: UIKeyboardHIDUsage) { switch key { case .keyboardReturnOrEnter: sendAction() case .keyboardUpArrow: editAction() case .keyboardEscape: cancellationAction() default: break } } } private struct MessageComposerReplyHeader: View { let replyDetails: TimelineItemReplyDetails let action: () -> Void var body: some View { TimelineReplyView(placement: .composer, timelineItemReplyDetails: replyDetails, maxWidth: .infinity) .overlay(alignment: .topTrailing) { Button(action: action) { CompoundIcon(\.close, size: .small, relativeTo: .compound.bodySMSemibold) .foregroundColor(.compound.iconTertiary) .padding(4.0) } .accessibilityLabel(L10n.actionClose) } .padding(.vertical, 8.0) .padding(.horizontal, -4.0) } } private struct MessageComposerEditHeader: View { let editType: ComposerMode.EditType let action: () -> Void private var title: String { switch editType { case .default: L10n.commonEditing case .addCaption: L10n.commonAddingCaption case .editCaption: L10n.commonEditingCaption } } var body: some View { HStack(alignment: .center, spacing: 8) { Label(title, icon: \.editSolid, iconSize: .xSmall, relativeTo: .compound.bodySMSemibold) .labelStyle(MessageComposerHeaderLabelStyle()) Spacer() Button(action: action) { CompoundIcon(\.close, size: .small, relativeTo: .compound.bodySMSemibold) .foregroundColor(.compound.iconTertiary) .padding([.leading, .vertical], 6.0) } .accessibilityLabel(L10n.actionClose) } } } private struct MessageComposerHeaderLabelStyle: LabelStyle { func makeBody(configuration: Configuration) -> some View { HStack(alignment: .center, spacing: 4) { configuration.icon configuration.title } .font(.compound.bodySMSemibold) .foregroundColor(.compound.textSecondary) .lineLimit(1) } } // MARK: - Style extension View { func messageComposerStyle(header: some View = EmptyView()) -> some View { modifier(MessageComposerStyleModifier(header: header)) } } private struct MessageComposerStyleModifier: ViewModifier { @Environment(\.isEnabled) private var isEnabled let header: Header private let composerShape = RoundedRectangle(cornerRadius: 21, style: .circular) func body(content: Content) -> some View { if #available(iOS 26, *) { if isEnabled { mainContent(content: content) .snapshotableGlassEffect(.regular.interactive(), // Doesn't need to be interactive but Apple does it 🤷‍♂️ snapshotBackground: .compound.bgSubtleSecondary, in: composerShape) } else { mainContent(content: content) .background(.compound.bgSubtlePrimary, in: composerShape) } } else { mainContent(content: content) .background { ZStack { composerShape .fill(Color.compound.bgSubtleSecondary) composerShape .stroke(Color.compound.borderInteractiveSecondary, lineWidth: 0.5) } } } } func mainContent(content: Content) -> some View { VStack(alignment: .leading, spacing: -6) { header content .tint(.compound.iconAccentTertiary) .padding(.vertical, Compound.supportsGlass ? 11 : 10) } .padding(.horizontal, Compound.supportsGlass ? 16 : 12) .clipShape(composerShape) } } // MARK: - Previews struct MessageComposer_Previews: PreviewProvider, TestablePreview { static let viewModel = TimelineViewModel.mock static let replyTypes: [TimelineItemReplyDetails] = [ .loaded(sender: .init(id: "Dave"), eventID: "123", eventContent: .message(.audio(.init(filename: "lightning.mp3", caption: "Audio: Ride the lightning", duration: 100, waveform: nil, source: nil, fileSize: nil, contentType: nil)))), .loaded(sender: .init(id: "James"), eventID: "123", eventContent: .message(.emote(.init(body: "Emote: James thinks he's the phantom lord")))), .loaded(sender: .init(id: "Robert"), eventID: "123", eventContent: .message(.file(.init(filename: "brain-surgery.pdf", caption: "File: Crash course in brain surgery", source: nil, fileSize: nil, thumbnailSource: nil, contentType: nil)))), .loaded(sender: .init(id: "Cliff"), eventID: "123", eventContent: .message(.image(.init(filename: "head.png", caption: "Image: Pushead", imageInfo: .mockImage, thumbnailInfo: .mockThumbnail)))), .loaded(sender: .init(id: "Jason"), eventID: "123", eventContent: .message(.notice(.init(body: "Notice: Too far gone?")))), .loaded(sender: .init(id: "Kirk"), eventID: "123", eventContent: .message(.text(.init(body: "Text: Where the wild things are")))), .loaded(sender: .init(id: "Lars"), eventID: "123", eventContent: .message(.video(.init(filename: "never.mov", caption: "Video: Through the never", videoInfo: .mockVideo, thumbnailInfo: .mockVideoThumbnail)))), .loading(eventID: "") ] static func messageComposer(_ content: NSAttributedString = .init(string: ""), mode: ComposerMode = .default, placeholder: String = L10n.richTextEditorComposerPlaceholder) -> MessageComposer { let viewModel = WysiwygComposerViewModel(minHeight: 22, maxExpandedHeight: 250) viewModel.setMarkdownContent(content.string) let composerView = WysiwygComposerView(placeholder: L10n.richTextEditorComposerPlaceholder, viewModel: viewModel, itemProviderHelper: nil, keyCommands: nil, pasteHandler: nil) return MessageComposer(plainComposerText: .constant(content), presendCallback: .constant(nil), selectedRange: .constant(NSRange(location: 0, length: 0)), composerView: composerView, mode: mode, placeholder: placeholder, composerFormattingEnabled: false, showResizeGrabber: false, isExpanded: .constant(false), sendAction: { }, editAction: { }, pasteAction: { _ in }, cancellationAction: { }, onAppearAction: { viewModel.setup() }) } static var previews: some View { VStack(spacing: 8) { messageComposer() messageComposer(.init(string: "Some message"), mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .default)) let longMessage = "Short loin ground round tongue hamburger, fatback salami shoulder. Beef turkey sausage kielbasa strip steak. Alcatra capicola pig tail pancetta chislic." messageComposer(.init(string: longMessage), mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .default)) messageComposer(mode: .reply(eventID: UUID().uuidString, replyDetails: .loaded(sender: .init(id: "Kirk"), eventID: "123", eventContent: .message(.text(.init(body: "Text: Where the wild things are")))), isThread: false)) messageComposer(.init(string: "Some new caption"), mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .addCaption)) messageComposer(.init(string: "Some updated caption"), mode: .edit(originalEventOrTransactionID: .eventID(UUID().uuidString), type: .editCaption)) } .padding(.horizontal) .environmentObject(viewModel.context) ScrollView { VStack(spacing: 8) { ForEach(replyTypes, id: \.self) { replyDetails in messageComposer(mode: .reply(eventID: UUID().uuidString, replyDetails: replyDetails, isThread: false)) } } } .padding(.horizontal) .environmentObject(viewModel.context) .previewDisplayName("Replying") ScrollView { VStack(spacing: 8) { ForEach(replyTypes, id: \.self) { replyDetails in messageComposer(mode: .reply(eventID: UUID().uuidString, replyDetails: replyDetails, isThread: true), placeholder: L10n.actionReplyInThread) } } } .padding(.horizontal) .environmentObject(viewModel.context) .previewDisplayName("Replying in thread") } }