mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-05 11:58:32 +00:00
Initial integration of RTE (#1464)
* Initial integration of RTE * Fix `clipped`, `focused` and composer view type * Remove horizontal padding * Add `ComposerToolbar` mock * Restore `composerFocusedSubject` * Allow using HTML from RTE on message sent * Fix new message content API * Add feature flag for Rich Text Editor
This commit is contained in:
@@ -17,7 +17,8 @@
|
||||
import UIKit
|
||||
|
||||
enum ComposerToolbarViewModelAction {
|
||||
case sendMessage(message: String, mode: RoomScreenComposerMode)
|
||||
case sendMessage(plain: String, html: String, mode: RoomScreenComposerMode)
|
||||
case sendPlainTextMessage(message: String, mode: RoomScreenComposerMode)
|
||||
|
||||
case displayCameraPicker
|
||||
case displayMediaPicker
|
||||
@@ -27,11 +28,12 @@ enum ComposerToolbarViewModelAction {
|
||||
case handlePasteOrDrop(provider: NSItemProvider)
|
||||
|
||||
case composerModeChanged(mode: RoomScreenComposerMode)
|
||||
case focusedChanged(isFocused: Bool)
|
||||
case composerFocusedChanged(isFocused: Bool)
|
||||
}
|
||||
|
||||
enum ComposerToolbarViewAction {
|
||||
case sendMessage(message: String, mode: RoomScreenComposerMode)
|
||||
case composerAppeared
|
||||
case sendMessage
|
||||
case cancelReply
|
||||
case cancelEdit
|
||||
case displayCameraPicker
|
||||
@@ -43,21 +45,28 @@ enum ComposerToolbarViewAction {
|
||||
|
||||
struct ComposerToolbarViewState: BindableState {
|
||||
var composerMode: RoomScreenComposerMode = .default
|
||||
var composerEmpty = true
|
||||
|
||||
var bindings: ComposerToolbarViewStateBindings
|
||||
|
||||
var sendButtonDisabled: Bool {
|
||||
bindings.composerText.count == 0
|
||||
if ServiceLocator.shared.settings.richTextEditorEnabled {
|
||||
return composerEmpty
|
||||
} else {
|
||||
return bindings.composerPlainText.isEmpty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposerToolbarViewStateBindings {
|
||||
var composerText: String
|
||||
var composerPlainText: String
|
||||
var composerFocused: Bool
|
||||
|
||||
var showAttachmentPopover = false {
|
||||
didSet {
|
||||
composerFocused = false
|
||||
if showAttachmentPopover {
|
||||
composerFocused = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,17 +15,21 @@
|
||||
//
|
||||
|
||||
import Combine
|
||||
import WysiwygComposer
|
||||
|
||||
typealias ComposerToolbarViewModelType = StateStoreViewModel<ComposerToolbarViewState, ComposerToolbarViewAction>
|
||||
|
||||
final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerToolbarViewModelProtocol {
|
||||
private let wysiwygViewModel: WysiwygComposerViewModel
|
||||
private let actionsSubject: PassthroughSubject<ComposerToolbarViewModelAction, Never> = .init()
|
||||
var actions: AnyPublisher<ComposerToolbarViewModelAction, Never> {
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
init() {
|
||||
super.init(initialViewState: ComposerToolbarViewState(bindings: .init(composerText: "", composerFocused: false)))
|
||||
init(wysiwygViewModel: WysiwygComposerViewModel) {
|
||||
self.wysiwygViewModel = wysiwygViewModel
|
||||
|
||||
super.init(initialViewState: ComposerToolbarViewState(bindings: .init(composerPlainText: "", composerFocused: false)))
|
||||
|
||||
context.$viewState
|
||||
.map(\.composerMode)
|
||||
@@ -36,7 +40,11 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
context.$viewState
|
||||
.map(\.bindings.composerFocused)
|
||||
.removeDuplicates()
|
||||
.sink { [weak self] in self?.actionsSubject.send(.focusedChanged(isFocused: $0)) }
|
||||
.sink { [weak self] in self?.actionsSubject.send(.composerFocusedChanged(isFocused: $0)) }
|
||||
.store(in: &cancellables)
|
||||
|
||||
wysiwygViewModel.$isContentEmpty
|
||||
.weakAssign(to: \.state.composerEmpty, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
@@ -44,8 +52,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
|
||||
override func process(viewAction: ComposerToolbarViewAction) {
|
||||
switch viewAction {
|
||||
case .sendMessage(let message, let mode):
|
||||
actionsSubject.send(.sendMessage(message: message, mode: mode))
|
||||
case .composerAppeared:
|
||||
wysiwygViewModel.setup()
|
||||
case .sendMessage:
|
||||
guard !state.sendButtonDisabled else { return }
|
||||
|
||||
if ServiceLocator.shared.settings.richTextEditorEnabled {
|
||||
actionsSubject.send(.sendMessage(plain: wysiwygViewModel.content.markdown,
|
||||
html: wysiwygViewModel.content.html,
|
||||
mode: state.composerMode))
|
||||
} else {
|
||||
actionsSubject.send(.sendPlainTextMessage(message: context.composerPlainText,
|
||||
mode: state.composerMode))
|
||||
}
|
||||
case .cancelReply:
|
||||
set(mode: .default)
|
||||
case .cancelEdit:
|
||||
@@ -78,6 +97,16 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
}
|
||||
}
|
||||
|
||||
func handleKeyCommand(_ keyCommand: WysiwygKeyCommand) -> Bool {
|
||||
switch keyCommand {
|
||||
case .enter:
|
||||
process(viewAction: .sendMessage)
|
||||
return true
|
||||
case .shiftEnter:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func set(mode: RoomScreenComposerMode) {
|
||||
@@ -91,6 +120,10 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
}
|
||||
|
||||
private func set(text: String) {
|
||||
state.bindings.composerText = text
|
||||
if ServiceLocator.shared.settings.richTextEditorEnabled {
|
||||
wysiwygViewModel.setMarkdownContent(text)
|
||||
} else {
|
||||
state.bindings.composerPlainText = text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,13 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
struct ComposerToolbar: View {
|
||||
@ObservedObject var context: ComposerToolbarViewModel.Context
|
||||
let wysiwygViewModel: WysiwygComposerViewModel
|
||||
let keyCommandHandler: KeyCommandHandler
|
||||
|
||||
@FocusState private var composerFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
@@ -27,7 +31,27 @@ struct ComposerToolbar: View {
|
||||
messageComposer
|
||||
.environmentObject(context)
|
||||
}
|
||||
}
|
||||
|
||||
private var messageComposer: some View {
|
||||
MessageComposer(plainText: $context.composerPlainText,
|
||||
composerView: composerView,
|
||||
sendingDisabled: context.viewState.sendButtonDisabled,
|
||||
mode: context.viewState.composerMode) {
|
||||
context.send(viewAction: .sendMessage)
|
||||
} pasteAction: { provider in
|
||||
context.send(viewAction: .handlePasteOrDrop(provider: provider))
|
||||
} replyCancellationAction: {
|
||||
context.send(viewAction: .cancelReply)
|
||||
} editCancellationAction: {
|
||||
context.send(viewAction: .cancelEdit)
|
||||
} onAppearAction: {
|
||||
context.send(viewAction: .composerAppeared)
|
||||
}
|
||||
.focused($composerFocused)
|
||||
.onChange(of: context.composerFocused) { newValue in
|
||||
guard composerFocused != newValue else { return }
|
||||
|
||||
composerFocused = newValue
|
||||
}
|
||||
.onChange(of: composerFocused) { newValue in
|
||||
@@ -35,23 +59,30 @@ struct ComposerToolbar: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var messageComposer: some View {
|
||||
MessageComposer(text: $context.composerText,
|
||||
focused: $composerFocused,
|
||||
sendingDisabled: context.viewState.sendButtonDisabled,
|
||||
mode: context.viewState.composerMode) {
|
||||
sendMessage()
|
||||
} pasteAction: { provider in
|
||||
private var composerView: WysiwygComposerView {
|
||||
WysiwygComposerView(placeholder: L10n.richTextEditorComposerPlaceholder,
|
||||
viewModel: wysiwygViewModel,
|
||||
itemProviderHelper: ItemProviderHelper(),
|
||||
keyCommandHandler: keyCommandHandler) { provider in
|
||||
context.send(viewAction: .handlePasteOrDrop(provider: provider))
|
||||
} replyCancellationAction: {
|
||||
context.send(viewAction: .cancelReply)
|
||||
} editCancellationAction: {
|
||||
context.send(viewAction: .cancelEdit)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendMessage() {
|
||||
guard !context.viewState.sendButtonDisabled else { return }
|
||||
context.send(viewAction: .sendMessage(message: context.composerText, mode: context.viewState.composerMode))
|
||||
private class ItemProviderHelper: WysiwygItemProviderHelper {
|
||||
func isPasteSupported(for itemProvider: NSItemProvider) -> Bool {
|
||||
itemProvider.isSupportedForPasteOrDrop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock
|
||||
|
||||
extension ComposerToolbar {
|
||||
static func mock() -> ComposerToolbar {
|
||||
let wysiwygViewModel = WysiwygComposerViewModel()
|
||||
let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel)
|
||||
return ComposerToolbar(context: composerViewModel.context,
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
keyCommandHandler: { _ in false })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,18 +15,23 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
typealias EnterKeyHandler = () -> Void
|
||||
typealias PasteHandler = (NSItemProvider) -> Void
|
||||
|
||||
struct MessageComposer: View {
|
||||
@Binding var text: String
|
||||
var focused: FocusState<Bool>.Binding
|
||||
@Binding var plainText: String
|
||||
let composerView: WysiwygComposerView
|
||||
let sendingDisabled: Bool
|
||||
let mode: RoomScreenComposerMode
|
||||
|
||||
let sendAction: EnterKeyHandler
|
||||
let pasteAction: PasteHandler
|
||||
let replyCancellationAction: () -> Void
|
||||
let editCancellationAction: () -> Void
|
||||
|
||||
let onAppearAction: () -> Void
|
||||
@FocusState private var focused: Bool
|
||||
|
||||
@State private var isMultiline = false
|
||||
@ScaledMetric private var sendButtonIconSize = 16
|
||||
|
||||
@@ -35,15 +40,25 @@ struct MessageComposer: View {
|
||||
VStack(alignment: .leading, spacing: -6) {
|
||||
header
|
||||
HStack(alignment: .bottom) {
|
||||
MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder,
|
||||
text: $text,
|
||||
focused: focused,
|
||||
isMultiline: $isMultiline,
|
||||
maxHeight: 300,
|
||||
enterKeyHandler: sendAction,
|
||||
pasteHandler: pasteAction)
|
||||
.tint(.compound.iconAccentTertiary)
|
||||
.padding(.vertical, 10)
|
||||
if ServiceLocator.shared.settings.richTextEditorEnabled {
|
||||
composerView
|
||||
.tint(.compound.iconAccentTertiary)
|
||||
.padding(.vertical, 10)
|
||||
.focused($focused)
|
||||
.onAppear {
|
||||
onAppearAction()
|
||||
}
|
||||
} else {
|
||||
MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder,
|
||||
text: $plainText,
|
||||
isMultiline: $isMultiline,
|
||||
maxHeight: 300,
|
||||
enterKeyHandler: sendAction,
|
||||
pasteHandler: pasteAction)
|
||||
.tint(.compound.iconAccentTertiary)
|
||||
.padding(.vertical, 10)
|
||||
.focused($focused)
|
||||
}
|
||||
|
||||
Button {
|
||||
sendAction()
|
||||
@@ -64,13 +79,14 @@ struct MessageComposer: View {
|
||||
}
|
||||
}
|
||||
.padding(.leading, 12.0)
|
||||
.clipped()
|
||||
.background {
|
||||
ZStack {
|
||||
roundedRectangle
|
||||
.fill(Color.compound.bgSubtleSecondary)
|
||||
roundedRectangle
|
||||
.stroke(Color.compound._borderTextFieldFocused, lineWidth: 1)
|
||||
.opacity(focused.wrappedValue ? 1 : 0)
|
||||
.opacity(focused ? 1 : 0)
|
||||
}
|
||||
}
|
||||
// Explicitly disable all animations to fix weirdness with the header immediately
|
||||
@@ -174,63 +190,40 @@ private struct MessageComposerHeaderLabelStyle: LabelStyle {
|
||||
struct MessageComposer_Previews: PreviewProvider {
|
||||
static let viewModel = RoomScreenViewModel.mock
|
||||
|
||||
static func messageComposer(_ content: String = "",
|
||||
sendingDisabled: Bool = false,
|
||||
mode: RoomScreenComposerMode = .default) -> MessageComposer {
|
||||
let viewModel = WysiwygComposerViewModel(minHeight: 22,
|
||||
maxExpandedHeight: 250)
|
||||
viewModel.setMarkdownContent(content)
|
||||
|
||||
let composerView = WysiwygComposerView(placeholder: L10n.richTextEditorComposerPlaceholder,
|
||||
viewModel: viewModel,
|
||||
itemProviderHelper: nil,
|
||||
keyCommandHandler: nil,
|
||||
pasteHandler: nil)
|
||||
|
||||
return MessageComposer(plainText: .constant(content),
|
||||
composerView: composerView,
|
||||
sendingDisabled: sendingDisabled,
|
||||
mode: mode,
|
||||
sendAction: { },
|
||||
pasteAction: { _ in },
|
||||
replyCancellationAction: { },
|
||||
editCancellationAction: { },
|
||||
onAppearAction: { viewModel.setup() })
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
VStack {
|
||||
MessageComposer(text: .constant(""),
|
||||
focused: FocusState<Bool>().projectedValue,
|
||||
sendingDisabled: true,
|
||||
mode: .default,
|
||||
sendAction: { },
|
||||
pasteAction: { _ in },
|
||||
replyCancellationAction: { },
|
||||
editCancellationAction: { })
|
||||
messageComposer(sendingDisabled: true)
|
||||
|
||||
MessageComposer(text: .constant("This is a short message."),
|
||||
focused: FocusState<Bool>().projectedValue,
|
||||
sendingDisabled: false,
|
||||
mode: .default,
|
||||
sendAction: { },
|
||||
pasteAction: { _ in },
|
||||
replyCancellationAction: { },
|
||||
editCancellationAction: { })
|
||||
messageComposer("Some message",
|
||||
mode: .edit(originalItemId: .random))
|
||||
|
||||
MessageComposer(text: .constant("This is a very long message that will wrap to 2 lines on an iPhone 14."),
|
||||
focused: FocusState<Bool>().projectedValue,
|
||||
sendingDisabled: false,
|
||||
mode: .default,
|
||||
sendAction: { },
|
||||
pasteAction: { _ in },
|
||||
replyCancellationAction: { },
|
||||
editCancellationAction: { })
|
||||
|
||||
MessageComposer(text: .constant("This is an even longer message that will wrap to 3 lines on an iPhone 14, just to see the difference it makes."),
|
||||
focused: FocusState<Bool>().projectedValue,
|
||||
sendingDisabled: false,
|
||||
mode: .default,
|
||||
sendAction: { },
|
||||
pasteAction: { _ in },
|
||||
replyCancellationAction: { },
|
||||
editCancellationAction: { })
|
||||
|
||||
MessageComposer(text: .constant("Some message"),
|
||||
focused: FocusState<Bool>().projectedValue,
|
||||
sendingDisabled: false,
|
||||
mode: .edit(originalItemId: .random),
|
||||
sendAction: { },
|
||||
pasteAction: { _ in },
|
||||
replyCancellationAction: { },
|
||||
editCancellationAction: { })
|
||||
|
||||
MessageComposer(text: .constant(""),
|
||||
focused: FocusState<Bool>().projectedValue,
|
||||
sendingDisabled: false,
|
||||
mode: .reply(itemID: .random,
|
||||
messageComposer(mode: .reply(itemID: .random,
|
||||
replyDetails: .loaded(sender: .init(id: "Kirk"),
|
||||
contentType: .text(.init(body: "Text: Where the wild things are")))),
|
||||
sendAction: { },
|
||||
pasteAction: { _ in },
|
||||
replyCancellationAction: { },
|
||||
editCancellationAction: { })
|
||||
contentType: .text(.init(body: "Text: Where the wild things are")))))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
@@ -253,15 +246,8 @@ struct MessageComposer_Previews: PreviewProvider {
|
||||
]
|
||||
|
||||
ForEach(replyTypes, id: \.self) { replyDetails in
|
||||
MessageComposer(text: .constant(""),
|
||||
focused: FocusState<Bool>().projectedValue,
|
||||
sendingDisabled: false,
|
||||
mode: .reply(itemID: .random,
|
||||
replyDetails: replyDetails),
|
||||
sendAction: { },
|
||||
pasteAction: { _ in },
|
||||
replyCancellationAction: { },
|
||||
editCancellationAction: { })
|
||||
messageComposer(mode: .reply(itemID: .random,
|
||||
replyDetails: replyDetails))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,22 +13,17 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
typealias EnterKeyHandler = () -> Void
|
||||
typealias PasteHandler = (NSItemProvider) -> Void
|
||||
|
||||
struct MessageComposerTextField: View {
|
||||
let placeholder: String
|
||||
@Binding var text: String
|
||||
var focused: FocusState<Bool>.Binding
|
||||
@Binding var isMultiline: Bool
|
||||
|
||||
|
||||
let maxHeight: CGFloat
|
||||
let enterKeyHandler: EnterKeyHandler
|
||||
let pasteHandler: PasteHandler
|
||||
|
||||
|
||||
var body: some View {
|
||||
UITextViewWrapper(text: $text,
|
||||
isMultiline: $isMultiline,
|
||||
@@ -37,9 +32,8 @@ struct MessageComposerTextField: View {
|
||||
pasteHandler: pasteHandler)
|
||||
.accessibilityLabel(placeholder)
|
||||
.background(placeholderView, alignment: .topLeading)
|
||||
.focused(focused)
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
private var placeholderView: some View {
|
||||
if text.isEmpty {
|
||||
@@ -55,14 +49,14 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
||||
|
||||
@Binding var text: String
|
||||
@Binding var isMultiline: Bool
|
||||
|
||||
|
||||
let maxHeight: CGFloat
|
||||
|
||||
let enterKeyHandler: EnterKeyHandler
|
||||
let pasteHandler: PasteHandler
|
||||
|
||||
|
||||
private let font = UIFont.preferredFont(forTextStyle: .body)
|
||||
|
||||
|
||||
func makeUIView(context: UIViewRepresentableContext<UITextViewWrapper>) -> UITextView {
|
||||
let textView = ElementTextView()
|
||||
textView.isMultiline = $isMultiline
|
||||
@@ -83,14 +77,14 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
||||
|
||||
return textView
|
||||
}
|
||||
|
||||
|
||||
func sizeThatFits(_ proposal: ProposedViewSize, uiView: UITextView, context: Context) -> CGSize? {
|
||||
// Note: Coalescing a width of zero here returns a size for the view with 1 line of text visible.
|
||||
let newSize = uiView.sizeThatFits(CGSize(width: proposal.width ?? .zero,
|
||||
height: CGFloat.greatestFiniteMagnitude))
|
||||
let width = proposal.width ?? newSize.width
|
||||
let height = min(maxHeight, newSize.height)
|
||||
|
||||
|
||||
return CGSize(width: width, height: height)
|
||||
}
|
||||
|
||||
@@ -117,15 +111,15 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
||||
enterKeyHandler: enterKeyHandler,
|
||||
pasteHandler: pasteHandler)
|
||||
}
|
||||
|
||||
|
||||
final class Coordinator: NSObject, UITextViewDelegate, ElementTextViewDelegate {
|
||||
private var text: Binding<String>
|
||||
|
||||
|
||||
private let maxHeight: CGFloat
|
||||
|
||||
|
||||
private let enterKeyHandler: EnterKeyHandler
|
||||
private let pasteHandler: PasteHandler
|
||||
|
||||
|
||||
init(text: Binding<String>,
|
||||
maxHeight: CGFloat,
|
||||
enterKeyHandler: @escaping EnterKeyHandler,
|
||||
@@ -135,19 +129,19 @@ private struct UITextViewWrapper: UIViewRepresentable {
|
||||
self.enterKeyHandler = enterKeyHandler
|
||||
self.pasteHandler = pasteHandler
|
||||
}
|
||||
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
text.wrappedValue = textView.text
|
||||
}
|
||||
|
||||
|
||||
func textViewDidReceiveEnterKeyPress(_ textView: UITextView) {
|
||||
enterKeyHandler()
|
||||
}
|
||||
|
||||
|
||||
func textViewDidReceiveShiftEnterKeyPress(_ textView: UITextView) {
|
||||
textView.insertText("\n")
|
||||
}
|
||||
|
||||
|
||||
func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider) {
|
||||
pasteHandler(provider)
|
||||
}
|
||||
@@ -162,27 +156,27 @@ private protocol ElementTextViewDelegate: AnyObject {
|
||||
|
||||
private class ElementTextView: UITextView {
|
||||
weak var elementDelegate: ElementTextViewDelegate?
|
||||
|
||||
|
||||
var isMultiline: Binding<Bool>?
|
||||
|
||||
|
||||
override var keyCommands: [UIKeyCommand]? {
|
||||
[UIKeyCommand(input: "\r", modifierFlags: .shift, action: #selector(shiftEnterKeyPressed)),
|
||||
UIKeyCommand(input: "\r", modifierFlags: [], action: #selector(enterKeyPressed))]
|
||||
}
|
||||
|
||||
|
||||
@objc func shiftEnterKeyPressed(sender: UIKeyCommand) {
|
||||
elementDelegate?.textViewDidReceiveShiftEnterKeyPress(self)
|
||||
}
|
||||
|
||||
|
||||
@objc func enterKeyPressed(sender: UIKeyCommand) {
|
||||
elementDelegate?.textViewDidReceiveEnterKeyPress(self)
|
||||
}
|
||||
|
||||
|
||||
override func layoutSubviews() {
|
||||
super.layoutSubviews()
|
||||
|
||||
|
||||
guard let isMultiline, let font else { return }
|
||||
|
||||
|
||||
let numberOfLines = frame.height / font.lineHeight
|
||||
if numberOfLines > 1.5 {
|
||||
if !isMultiline.wrappedValue {
|
||||
@@ -194,21 +188,21 @@ private class ElementTextView: UITextView {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Pasting support
|
||||
|
||||
|
||||
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
|
||||
if super.canPerformAction(action, withSender: sender) {
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
guard action == #selector(paste(_:)) else {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
return UIPasteboard.general.itemProviders.first?.isSupportedForPasteOrDrop ?? false
|
||||
}
|
||||
|
||||
|
||||
override func paste(_ sender: Any?) {
|
||||
guard let provider = UIPasteboard.general.itemProviders.first,
|
||||
provider.isSupportedForPasteOrDrop else {
|
||||
@@ -217,7 +211,7 @@ private class ElementTextView: UITextView {
|
||||
super.paste(sender)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
elementDelegate?.textView(self, didReceivePasteWith: provider)
|
||||
}
|
||||
}
|
||||
@@ -230,20 +224,19 @@ struct MessageComposerTextField_Previews: PreviewProvider {
|
||||
PreviewWrapper(text: "A really long message that will wrap to multiple lines on a phone in portrait.")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct PreviewWrapper: View {
|
||||
@State var text: String
|
||||
@State var isMultiline: Bool
|
||||
|
||||
|
||||
init(text: String) {
|
||||
_text = .init(initialValue: text)
|
||||
_isMultiline = .init(initialValue: false)
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
MessageComposerTextField(placeholder: "Placeholder",
|
||||
text: $text,
|
||||
focused: FocusState().projectedValue,
|
||||
isMultiline: $isMultiline,
|
||||
maxHeight: 300,
|
||||
enterKeyHandler: { },
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
struct RoomAttachmentPicker: View {
|
||||
@ObservedObject var context: ComposerToolbarViewModel.Context
|
||||
@@ -106,7 +107,7 @@ struct RoomAttachmentPicker: View {
|
||||
}
|
||||
|
||||
struct RoomAttachmentPicker_Previews: PreviewProvider {
|
||||
static let viewModel = ComposerToolbarViewModel()
|
||||
static let viewModel = ComposerToolbarViewModel(wysiwygViewModel: WysiwygComposerViewModel())
|
||||
|
||||
static var previews: some View {
|
||||
RoomAttachmentPicker(context: viewModel.context)
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
struct RoomScreenCoordinatorParameters {
|
||||
let roomProxy: RoomProxyProtocol
|
||||
@@ -40,6 +41,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
private var parameters: RoomScreenCoordinatorParameters
|
||||
private var viewModel: RoomScreenViewModelProtocol
|
||||
private var composerViewModel: ComposerToolbarViewModel
|
||||
private var wysiwygViewModel: WysiwygComposerViewModel
|
||||
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
|
||||
@@ -58,7 +60,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController)
|
||||
|
||||
composerViewModel = ComposerToolbarViewModel()
|
||||
wysiwygViewModel = WysiwygComposerViewModel(minHeight: 22, maxExpandedHeight: 250)
|
||||
composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -111,6 +114,10 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
}
|
||||
|
||||
func toPresentable() -> AnyView {
|
||||
AnyView(RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar(context: composerViewModel.context)))
|
||||
let composerToolbar = ComposerToolbar(context: composerViewModel.context,
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
keyCommandHandler: composerViewModel.handleKeyCommand)
|
||||
|
||||
return AnyView(RoomScreen(context: viewModel.context, composerToolbar: composerToolbar))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,8 +146,10 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
|
||||
func process(composerAction: ComposerToolbarViewModelAction) {
|
||||
switch composerAction {
|
||||
case .sendMessage(let message, let mode):
|
||||
Task { await sendCurrentMessage(message, mode: mode) }
|
||||
case .sendMessage(let message, let html, let mode):
|
||||
Task { await sendCurrentMessage(message, html: html, mode: mode) }
|
||||
case .sendPlainTextMessage(let message, let mode):
|
||||
Task { await sendCurrentMessage(message, html: nil, mode: mode) }
|
||||
case .displayCameraPicker:
|
||||
actionsSubject.send(.displayCameraPicker)
|
||||
case .displayMediaPicker:
|
||||
@@ -160,7 +162,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
handlePasteOrDrop(provider)
|
||||
case .composerModeChanged(mode: let mode):
|
||||
trackComposerMode(mode)
|
||||
case .focusedChanged(isFocused: let isFocused):
|
||||
case .composerFocusedChanged(isFocused: let isFocused):
|
||||
composerFocusedSubject.send(isFocused)
|
||||
}
|
||||
}
|
||||
@@ -427,8 +429,8 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
return eventTimelineItem.properties.reactions.isEmpty && eventTimelineItem.sender == otherEventTimelineItem.sender
|
||||
}
|
||||
|
||||
private func sendCurrentMessage(_ currentMessage: String, mode: RoomScreenComposerMode) async {
|
||||
guard !currentMessage.isEmpty else {
|
||||
private func sendCurrentMessage(_ message: String, html: String?, mode: RoomScreenComposerMode) async {
|
||||
guard !message.isEmpty else {
|
||||
fatalError("This message should never be empty")
|
||||
}
|
||||
|
||||
@@ -436,11 +438,11 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
|
||||
switch mode {
|
||||
case .reply(let itemId, _):
|
||||
await timelineController.sendMessage(currentMessage, inReplyTo: itemId)
|
||||
await timelineController.sendMessage(message, html: html, inReplyTo: itemId)
|
||||
case .edit(let originalItemId):
|
||||
await timelineController.editMessage(currentMessage, original: originalItemId)
|
||||
await timelineController.editMessage(message, html: html, original: originalItemId)
|
||||
default:
|
||||
await timelineController.sendMessage(currentMessage)
|
||||
await timelineController.sendMessage(message, html: html)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
struct RoomScreen: View {
|
||||
@ObservedObject var context: RoomScreenViewModel.Context
|
||||
@@ -22,7 +23,7 @@ struct RoomScreen: View {
|
||||
let composerToolbar: ComposerToolbar
|
||||
|
||||
private let attachmentButtonPadding = 10.0
|
||||
|
||||
|
||||
var body: some View {
|
||||
timeline
|
||||
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
|
||||
@@ -152,12 +153,10 @@ struct RoomScreen_Previews: PreviewProvider {
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController)
|
||||
|
||||
static let composerViewModel = ComposerToolbarViewModel()
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar(context: composerViewModel.context))
|
||||
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
/// A table view wrapper that displays the timeline of a room.
|
||||
struct UITimelineView: UIViewControllerRepresentable {
|
||||
@@ -86,11 +87,9 @@ struct UITimelineView_Previews: PreviewProvider {
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController)
|
||||
|
||||
static let composerViewModel = ComposerToolbarViewModel()
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar(context: composerViewModel.context))
|
||||
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import Combine
|
||||
import SwiftUI
|
||||
|
||||
import SwiftUIIntrospect
|
||||
import WysiwygComposer
|
||||
|
||||
struct TimelineView: View {
|
||||
let viewState: TimelineViewState
|
||||
@@ -174,11 +175,9 @@ struct TimelineView_Previews: PreviewProvider {
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
userIndicatorController: ServiceLocator.shared.userIndicatorController)
|
||||
|
||||
static let composerViewModel = ComposerToolbarViewModel()
|
||||
|
||||
static var previews: some View {
|
||||
NavigationStack {
|
||||
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar(context: composerViewModel.context))
|
||||
RoomScreen(context: viewModel.context, composerToolbar: ComposerToolbar.mock())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -51,6 +51,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
|
||||
var notificationSettingsEnabled: Bool { get set }
|
||||
var swiftUITimelineEnabled: Bool { get set }
|
||||
var pollsInTimelineEnabled: Bool { get set }
|
||||
var richTextEditorEnabled: Bool { get set }
|
||||
}
|
||||
|
||||
extension AppSettings: DeveloperOptionsProtocol { }
|
||||
|
||||
+6
@@ -72,6 +72,12 @@ struct DeveloperOptionsScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
Section("Rich Text Editor") {
|
||||
Toggle(isOn: $context.richTextEditorEnabled) {
|
||||
Text("Use the Rich Text Editor")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
showConfetti = true
|
||||
|
||||
Reference in New Issue
Block a user