Files
2026-05-27 17:11:42 +01:00

375 lines
15 KiB
Swift
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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<Header: View>: 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")
}
}