mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 16:10:20 +00:00
Plain composer suggestions and pills (#2751)
* Move ComposerToolbar files to withing the RoomScreen folder (as it's not a screen on its own) * Switch the plain composer to NSAttributedStrings * Enable suggestions on the plain composer * Introduce a new `MatrixUserDisplayName` attributed string attributed and use it for mention building * Implement mention pill rendering and sending on the plain message composer * Fix plain composer snapshot tests * Fix broken formatting options layout * Add clarifying comment.
This commit is contained in:
+136
@@ -0,0 +1,136 @@
|
||||
//
|
||||
// Copyright 2021 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct CompletionSuggestionView: View {
|
||||
let imageProvider: ImageProviderProtocol?
|
||||
let items: [SuggestionItem]
|
||||
var showBackgroundShadow = true
|
||||
let onTap: (SuggestionItem) -> Void
|
||||
|
||||
private enum Constants {
|
||||
static let topPadding: CGFloat = 8.0
|
||||
static let listItemPadding: CGFloat = 6.0
|
||||
// added by the list itself when presenting the divider
|
||||
static let listItemSpacing: CGFloat = 4.0
|
||||
static let leadingPadding: CGFloat = 16.0
|
||||
// To make the scrolling more apparent we show a factional amount
|
||||
static let maxVisibleRows: CGFloat = 4.5
|
||||
}
|
||||
|
||||
// MARK: Public
|
||||
|
||||
@State private var prototypeListItemFrame: CGRect = .zero
|
||||
|
||||
var body: some View {
|
||||
if items.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
ZStack {
|
||||
MentionSuggestionItemView(imageProvider: nil, item: .init(id: "", displayName: nil, avatarURL: nil, range: .init()))
|
||||
.readFrame($prototypeListItemFrame)
|
||||
.hidden()
|
||||
if showBackgroundShadow {
|
||||
BackgroundView {
|
||||
list()
|
||||
}
|
||||
} else {
|
||||
list()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func list() -> some View {
|
||||
List(items) { item in
|
||||
Button {
|
||||
onTap(item)
|
||||
} label: {
|
||||
switch item {
|
||||
case .user(let mention), .allUsers(let mention):
|
||||
MentionSuggestionItemView(imageProvider: imageProvider, item: mention)
|
||||
}
|
||||
}
|
||||
.modifier(ListItemPaddingModifier(isFirst: items.first?.id == item.id))
|
||||
.listRowInsets(.init(top: 0, leading: Constants.leadingPadding, bottom: 0, trailing: 0))
|
||||
}
|
||||
.listStyle(PlainListStyle())
|
||||
.frame(height: contentHeightForRowCount(min(CGFloat(items.count), Constants.maxVisibleRows)))
|
||||
.background(Color.compound.bgCanvasDefault)
|
||||
}
|
||||
|
||||
private func contentHeightForRowCount(_ count: CGFloat) -> CGFloat {
|
||||
(prototypeListItemFrame.height + Constants.listItemPadding * 2 + Constants.listItemSpacing) * count - Constants.listItemSpacing / 2 + Constants.topPadding - Constants.listItemPadding
|
||||
}
|
||||
|
||||
private struct ListItemPaddingModifier: ViewModifier {
|
||||
private let isFirst: Bool
|
||||
|
||||
init(isFirst: Bool) {
|
||||
self.isFirst = isFirst
|
||||
}
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
let topPadding: CGFloat = isFirst ? Constants.topPadding : Constants.listItemPadding
|
||||
let bottomPadding: CGFloat = Constants.listItemPadding
|
||||
|
||||
return content
|
||||
.padding(.top, topPadding)
|
||||
.padding(.bottom, bottomPadding)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct BackgroundView<Content: View>: View {
|
||||
var content: () -> Content
|
||||
|
||||
private let shadowRadius: CGFloat = 20.0
|
||||
|
||||
init(@ViewBuilder content: @escaping () -> Content) {
|
||||
self.content = content
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
content()
|
||||
.background(Color.compound.bgSubtlePrimary)
|
||||
.clipShape(RoundedCornerShape(radius: shadowRadius, corners: [.topLeft, .topRight]))
|
||||
.shadow(color: .black.opacity(0.20), radius: 20.0, x: 0.0, y: 3.0)
|
||||
.mask(Rectangle().padding(.init(top: -(shadowRadius * 2), leading: 0.0, bottom: 0.0, trailing: 0.0)))
|
||||
.edgesIgnoringSafeArea(.all)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
struct CompletionSuggestion_Previews: PreviewProvider, TestablePreview {
|
||||
static let multipleItems: [SuggestionItem] = (0...10).map { index in
|
||||
SuggestionItem.user(item: MentionSuggestionItem(id: "\(index)", displayName: "\(index)", avatarURL: nil, range: .init()))
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
// Putting them is VStack allows the preview to work properly in tests
|
||||
VStack(spacing: 8) {
|
||||
CompletionSuggestionView(imageProvider: MockMediaProvider(),
|
||||
items: [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, range: .init())),
|
||||
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory, range: .init()))]) { _ in }
|
||||
}
|
||||
VStack(spacing: 8) {
|
||||
CompletionSuggestionView(imageProvider: MockMediaProvider(),
|
||||
items: multipleItems) { _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
struct ComposerToolbar: View {
|
||||
@ObservedObject var context: ComposerToolbarViewModel.Context
|
||||
|
||||
// Needs to be observable or the placeholder and the dictation state are not managed correctly.
|
||||
@ObservedObject var wysiwygViewModel: WysiwygComposerViewModel
|
||||
|
||||
let keyCommands: [WysiwygKeyCommand]
|
||||
|
||||
@FocusState private var composerFocused: Bool
|
||||
@State private var frame: CGRect = .zero
|
||||
@Environment(\.verticalSizeClass) private var verticalSizeClass
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
topBar
|
||||
|
||||
if context.composerActionsEnabled {
|
||||
if verticalSizeClass != .compact,
|
||||
context.composerExpanded {
|
||||
suggestionView
|
||||
.padding(.leading, -5)
|
||||
.padding(.trailing, -8)
|
||||
}
|
||||
bottomBar
|
||||
}
|
||||
}
|
||||
.padding(.leading, 5)
|
||||
.padding(.trailing, 8)
|
||||
.readFrame($frame)
|
||||
.overlay(alignment: .bottom) {
|
||||
if verticalSizeClass != .compact, !context.composerExpanded {
|
||||
suggestionView
|
||||
.offset(y: -frame.height)
|
||||
}
|
||||
}
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
|
||||
private var suggestionView: some View {
|
||||
CompletionSuggestionView(imageProvider: context.imageProvider,
|
||||
items: context.viewState.suggestions,
|
||||
showBackgroundShadow: !context.composerExpanded) { suggestion in
|
||||
context.send(viewAction: .selectedSuggestion(suggestion))
|
||||
}
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
topBarLayout {
|
||||
mainTopBarContent
|
||||
|
||||
if !context.composerActionsEnabled {
|
||||
if context.viewState.isUploading {
|
||||
ProgressView()
|
||||
.scaledFrame(size: 44, relativeTo: .title)
|
||||
.padding(.leading, 3)
|
||||
} else if context.viewState.showSendButton {
|
||||
sendButton
|
||||
.padding(.leading, 3)
|
||||
} else {
|
||||
voiceMessageRecordingButton(mode: context.viewState.isVoiceMessageModeActivated ? .recording : .idle)
|
||||
.padding(.leading, 3)
|
||||
}
|
||||
}
|
||||
}
|
||||
.animation(.linear(duration: 0.15), value: context.viewState.composerMode)
|
||||
}
|
||||
|
||||
private var bottomBar: some View {
|
||||
HStack(alignment: .center, spacing: 9) {
|
||||
closeRTEButton
|
||||
|
||||
FormattingToolbar(formatItems: context.formatItems) { action in
|
||||
context.send(viewAction: .composerAction(action: action.composerAction))
|
||||
}
|
||||
|
||||
sendButton
|
||||
.padding(.leading, 7)
|
||||
}
|
||||
}
|
||||
|
||||
private var topBarLayout: some Layout {
|
||||
HStackLayout(alignment: .bottom, spacing: 5)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var mainTopBarContent: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
topBarLayout {
|
||||
if !context.composerActionsEnabled {
|
||||
RoomAttachmentPicker(context: context)
|
||||
}
|
||||
messageComposer
|
||||
.environmentObject(context)
|
||||
.onTapGesture {
|
||||
guard !composerFocused else { return }
|
||||
composerFocused = true
|
||||
}
|
||||
.padding(.leading, context.composerActionsEnabled ? 7 : 0)
|
||||
.padding(.trailing, context.composerActionsEnabled ? 4 : 0)
|
||||
}
|
||||
.opacity(context.viewState.isVoiceMessageModeActivated ? 0 : 1)
|
||||
|
||||
if context.viewState.isVoiceMessageModeActivated {
|
||||
voiceMessageContent
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var closeRTEButton: some View {
|
||||
Button {
|
||||
context.composerActionsEnabled = false
|
||||
context.composerExpanded = false
|
||||
} label: {
|
||||
Image(Asset.Images.closeRte.name)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.scaledFrame(size: 30, relativeTo: .title)
|
||||
.scaledPadding(7, relativeTo: .title)
|
||||
}
|
||||
.accessibilityLabel(L10n.actionClose)
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.closeFormattingOptions)
|
||||
}
|
||||
|
||||
private var sendButton: some View {
|
||||
Button {
|
||||
context.send(viewAction: .sendMessage)
|
||||
} label: {
|
||||
CompoundIcon(context.viewState.composerMode.isEdit ? \.check : \.sendSolid)
|
||||
.scaledPadding(6, relativeTo: .title)
|
||||
.accessibilityLabel(context.viewState.composerMode.isEdit ? L10n.actionConfirm : L10n.actionSend)
|
||||
.foregroundColor(context.viewState.sendButtonDisabled ? .compound.iconDisabled : .white)
|
||||
.background {
|
||||
Circle()
|
||||
.foregroundColor(context.viewState.sendButtonDisabled ? .clear : .compound.iconAccentTertiary)
|
||||
}
|
||||
.scaledPadding(4, relativeTo: .title)
|
||||
}
|
||||
.disabled(context.viewState.sendButtonDisabled)
|
||||
.animation(.linear(duration: 0.1).disabledDuringTests(), value: context.viewState.sendButtonDisabled)
|
||||
.keyboardShortcut(.return, modifiers: [.command])
|
||||
}
|
||||
|
||||
private var messageComposer: some View {
|
||||
MessageComposer(plainComposerText: $context.plainComposerText,
|
||||
composerView: composerView,
|
||||
mode: context.viewState.composerMode,
|
||||
showResizeGrabber: context.viewState.bindings.composerActionsEnabled,
|
||||
isExpanded: $context.composerExpanded) {
|
||||
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
|
||||
context.composerFocused = newValue
|
||||
}
|
||||
.onChange(of: context.plainComposerText) { _ in
|
||||
context.send(viewAction: .plainComposerTextChanged)
|
||||
}
|
||||
.onAppear {
|
||||
composerFocused = context.composerFocused
|
||||
}
|
||||
}
|
||||
|
||||
private var placeholder: String {
|
||||
switch context.viewState.composerMode {
|
||||
case .reply(_, _, let isThread):
|
||||
return isThread ? L10n.actionReplyInThread : L10n.richTextEditorComposerPlaceholder
|
||||
default:
|
||||
return L10n.richTextEditorComposerPlaceholder
|
||||
}
|
||||
}
|
||||
|
||||
private var composerView: WysiwygComposerView {
|
||||
WysiwygComposerView(placeholder: placeholder,
|
||||
placeholderColor: .compound.textSecondary,
|
||||
viewModel: wysiwygViewModel,
|
||||
itemProviderHelper: ItemProviderHelper(),
|
||||
keyCommands: keyCommands) { provider in
|
||||
context.send(viewAction: .handlePasteOrDrop(provider: 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
|
||||
VoiceMessageRecordingComposer(recorderState: state)
|
||||
}
|
||||
case .previewVoiceMessage(let state, let waveform, let isUploading):
|
||||
topBarLayout {
|
||||
voiceMessageTrashButton
|
||||
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 {
|
||||
Button(role: .destructive) {
|
||||
context.send(viewAction: .voiceMessage(.deleteRecording))
|
||||
} label: {
|
||||
CompoundIcon(\.delete)
|
||||
.scaledToFit()
|
||||
.scaledFrame(size: 30, relativeTo: .title)
|
||||
.scaledPadding(7, relativeTo: .title)
|
||||
}
|
||||
.buttonStyle(.compound(.plain))
|
||||
.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 ComposerToolbar_Previews: PreviewProvider, TestablePreview {
|
||||
static let wysiwygViewModel = WysiwygComposerViewModel()
|
||||
static let composerViewModel = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init(suggestions: suggestions)),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
|
||||
static let suggestions: [SuggestionItem] = [.user(item: MentionSuggestionItem(id: "@user_mention_1:matrix.org", displayName: "User 1", avatarURL: nil, range: .init())),
|
||||
.user(item: MentionSuggestionItem(id: "@user_mention_2:matrix.org", displayName: "User 2", avatarURL: URL.documentsDirectory, range: .init()))]
|
||||
|
||||
static var previews: some View {
|
||||
ComposerToolbar.mock(focused: true)
|
||||
|
||||
// Putting them is VStack allows the completion suggestion preview to work properly in tests
|
||||
VStack(spacing: 8) {
|
||||
// The mock functon can't be used in this context because it does not hold a reference to the view model, losing the combine subscriptions
|
||||
ComposerToolbar(context: composerViewModel.context,
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
keyCommands: [])
|
||||
}
|
||||
.previewDisplayName("With Suggestions")
|
||||
|
||||
VStack(spacing: 8) {
|
||||
ComposerToolbar.textWithVoiceMessage(focused: false)
|
||||
ComposerToolbar.textWithVoiceMessage(focused: true)
|
||||
ComposerToolbar.voiceMessageRecordingMock()
|
||||
ComposerToolbar.voiceMessagePreviewMock(uploading: false)
|
||||
}
|
||||
.previewDisplayName("Voice Message")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock
|
||||
|
||||
extension ComposerToolbar {
|
||||
static func mock(focused: Bool = true) -> ComposerToolbar {
|
||||
let wysiwygViewModel = WysiwygComposerViewModel()
|
||||
var composerViewModel: ComposerToolbarViewModel {
|
||||
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
|
||||
model.state.composerEmpty = focused
|
||||
return model
|
||||
}
|
||||
return ComposerToolbar(context: composerViewModel.context,
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
keyCommands: [])
|
||||
}
|
||||
|
||||
static func textWithVoiceMessage(focused: Bool = true) -> ComposerToolbar {
|
||||
let wysiwygViewModel = WysiwygComposerViewModel()
|
||||
var composerViewModel: ComposerToolbarViewModel {
|
||||
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
|
||||
model.state.composerEmpty = focused
|
||||
return model
|
||||
}
|
||||
return ComposerToolbar(context: composerViewModel.context,
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
keyCommands: [])
|
||||
}
|
||||
|
||||
static func voiceMessageRecordingMock() -> ComposerToolbar {
|
||||
let wysiwygViewModel = WysiwygComposerViewModel()
|
||||
var composerViewModel: ComposerToolbarViewModel {
|
||||
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
|
||||
model.state.composerMode = .recordVoiceMessage(state: AudioRecorderState())
|
||||
return model
|
||||
}
|
||||
return ComposerToolbar(context: composerViewModel.context,
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
keyCommands: [])
|
||||
}
|
||||
|
||||
static func voiceMessagePreviewMock(uploading: Bool) -> ComposerToolbar {
|
||||
let wysiwygViewModel = WysiwygComposerViewModel()
|
||||
let waveformData: [Float] = Array(repeating: 1.0, count: 1000)
|
||||
var composerViewModel: ComposerToolbarViewModel {
|
||||
let model = ComposerToolbarViewModel(wysiwygViewModel: wysiwygViewModel,
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
|
||||
model.state.composerMode = .previewVoiceMessage(state: AudioPlayerState(id: .recorderPreview, duration: 10.0), waveform: .data(waveformData), isUploading: uploading)
|
||||
return model
|
||||
}
|
||||
return ComposerToolbar(context: composerViewModel.context,
|
||||
wysiwygViewModel: wysiwygViewModel,
|
||||
keyCommands: [])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
struct FormattingToolbar: View {
|
||||
/// The list of items to render in the toolbar
|
||||
var formatItems: [FormatItem]
|
||||
/// The action when an item is selected
|
||||
var formatAction: (FormatType) -> Void
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal) {
|
||||
HStack(spacing: 4) {
|
||||
ForEach(formatItems) { item in
|
||||
Button {
|
||||
formatAction(item.type)
|
||||
} label: {
|
||||
CompoundIcon(item.icon, size: .small, relativeTo: .compound.headingLG)
|
||||
.foregroundColor(item.foregroundColor)
|
||||
.padding(8)
|
||||
.background(item.backgroundColor)
|
||||
.cornerRadius(8)
|
||||
.padding(4)
|
||||
}
|
||||
.disabled(item.state == .disabled)
|
||||
.accessibilityIdentifier(item.accessibilityIdentifier)
|
||||
.accessibilityLabel(item.accessibilityLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension FormatItem {
|
||||
var foregroundColor: Color {
|
||||
switch state {
|
||||
case .reversed:
|
||||
return .compound.iconSuccessPrimary
|
||||
case .enabled:
|
||||
return .compound.iconSecondary
|
||||
case .disabled:
|
||||
return .compound.iconDisabled
|
||||
}
|
||||
}
|
||||
|
||||
var backgroundColor: Color {
|
||||
switch state {
|
||||
case .reversed:
|
||||
return .compound._bgAccentSelected
|
||||
case .enabled, .disabled:
|
||||
return .compound.bgCanvasDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FormattingToolbar_Previews: PreviewProvider, TestablePreview {
|
||||
static let items = FormatType.allCases.map { FormatItem(type: $0, state: .enabled) }
|
||||
static let disabledItems = FormatType.allCases.map { FormatItem(type: $0, state: .disabled) }
|
||||
|
||||
static var previews: some View {
|
||||
VStack(spacing: 16.0) {
|
||||
FormattingToolbar(formatItems: items) { _ in }
|
||||
FormattingToolbar(formatItems: disabledItems) { _ in }
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MentionSuggestionItemView: View {
|
||||
let imageProvider: ImageProviderProtocol?
|
||||
let item: MentionSuggestionItem
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 16) {
|
||||
LoadableAvatarImage(url: item.avatarURL,
|
||||
name: item.displayName,
|
||||
contentID: item.id,
|
||||
avatarSize: .user(on: .suggestions),
|
||||
imageProvider: imageProvider)
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
Text(item.displayName ?? item.id)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundColor(.compound.textPrimary)
|
||||
.lineLimit(1)
|
||||
if item.displayName != nil {
|
||||
Text(item.id)
|
||||
.font(.compound.bodySM)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MentionSuggestionItemView_Previews: PreviewProvider, TestablePreview {
|
||||
static let mockMediaProvider = MockMediaProvider()
|
||||
|
||||
static var previews: some View {
|
||||
MentionSuggestionItemView(imageProvider: mockMediaProvider, item: .init(id: "test", displayName: "Test", avatarURL: URL.documentsDirectory, range: .init()))
|
||||
MentionSuggestionItemView(imageProvider: mockMediaProvider, item: .init(id: "test2", displayName: nil, avatarURL: nil, range: .init()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
typealias EnterKeyHandler = () -> Void
|
||||
typealias PasteHandler = (NSItemProvider) -> Void
|
||||
|
||||
struct MessageComposer: View {
|
||||
@Binding var plainComposerText: NSAttributedString
|
||||
let composerView: WysiwygComposerView
|
||||
let mode: RoomScreenComposerMode
|
||||
let showResizeGrabber: Bool
|
||||
@Binding var isExpanded: Bool
|
||||
let sendAction: EnterKeyHandler
|
||||
let pasteAction: PasteHandler
|
||||
let replyCancellationAction: () -> Void
|
||||
let editCancellationAction: () -> Void
|
||||
let onAppearAction: () -> Void
|
||||
|
||||
@State private var isMultiline = false
|
||||
@State private var composerTranslation: CGFloat = 0
|
||||
private let composerShape = RoundedRectangle(cornerRadius: 21, style: .circular)
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
if showResizeGrabber {
|
||||
resizeGrabber
|
||||
}
|
||||
|
||||
mainContent
|
||||
.padding(.horizontal, 12.0)
|
||||
.clipShape(composerShape)
|
||||
.background {
|
||||
ZStack {
|
||||
composerShape
|
||||
.fill(Color.compound.bgSubtleSecondary)
|
||||
composerShape
|
||||
.stroke(Color.compound._borderTextFieldFocused, lineWidth: 0.5)
|
||||
}
|
||||
}
|
||||
// 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
|
||||
|
||||
private var mainContent: some View {
|
||||
VStack(alignment: .leading, spacing: -6) {
|
||||
header
|
||||
|
||||
if ServiceLocator.shared.settings.richTextEditorEnabled {
|
||||
Color.clear
|
||||
.overlay(alignment: .top) {
|
||||
composerView
|
||||
.clipped()
|
||||
.readFrame($composerFrame)
|
||||
}
|
||||
.frame(minHeight: ComposerConstant.minHeight, maxHeight: max(composerHeight, composerFrame.height),
|
||||
alignment: .top)
|
||||
.tint(.compound.iconAccentTertiary)
|
||||
.padding(.vertical, 10)
|
||||
.onAppear {
|
||||
onAppearAction()
|
||||
}
|
||||
} else {
|
||||
MessageComposerTextField(placeholder: L10n.richTextEditorComposerPlaceholder,
|
||||
text: $plainComposerText,
|
||||
isMultiline: $isMultiline,
|
||||
maxHeight: 300,
|
||||
enterKeyHandler: sendAction,
|
||||
pasteHandler: pasteAction)
|
||||
.tint(.compound.iconAccentTertiary)
|
||||
.padding(.vertical, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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: replyCancellationAction)
|
||||
case .edit:
|
||||
MessageComposerEditHeader(action: editCancellationAction)
|
||||
case .recordVoiceMessage, .previewVoiceMessage, .default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var resizeGrabber: some View {
|
||||
Capsule()
|
||||
.foregroundColor(Asset.Colors.grabber.swiftUIColor)
|
||||
.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 struct MessageComposerReplyHeader: View {
|
||||
let replyDetails: TimelineItemReplyDetails
|
||||
let action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
TimelineReplyView(placement: .composer, timelineItemReplyDetails: replyDetails)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(4.0)
|
||||
.background(.compound.bgCanvasDefault, in: RoundedRectangle(cornerRadius: 13, style: .circular))
|
||||
.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 action: () -> Void
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Label(L10n.commonEditing,
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageComposer_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = RoomScreenViewModel.mock
|
||||
|
||||
static let replyTypes: [TimelineItemReplyDetails] = [
|
||||
.loaded(sender: .init(id: "Dave"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.audio(.init(body: "Audio: Ride the lightning", duration: 100, waveform: nil, source: 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(body: "File: Crash course in brain surgery.pdf", source: nil, thumbnailSource: nil, contentType: nil)))),
|
||||
.loaded(sender: .init(id: "Cliff"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.image(.init(body: "Image: Pushead",
|
||||
source: .init(url: .picturesDirectory, mimeType: nil),
|
||||
thumbnailSource: .init(url: .picturesDirectory, mimeType: nil))))),
|
||||
.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(body: "Video: Through the never",
|
||||
duration: 100,
|
||||
source: nil,
|
||||
thumbnailSource: .init(url: .picturesDirectory, mimeType: nil))))),
|
||||
.loading(eventID: "")
|
||||
]
|
||||
|
||||
static func messageComposer(_ content: NSAttributedString = .init(string: ""),
|
||||
mode: RoomScreenComposerMode = .default) -> 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),
|
||||
composerView: composerView,
|
||||
mode: mode,
|
||||
showResizeGrabber: false,
|
||||
isExpanded: .constant(false),
|
||||
sendAction: { },
|
||||
pasteAction: { _ in },
|
||||
replyCancellationAction: { },
|
||||
editCancellationAction: { },
|
||||
onAppearAction: { viewModel.setup() })
|
||||
}
|
||||
|
||||
static var previews: some View {
|
||||
VStack(spacing: 8) {
|
||||
messageComposer()
|
||||
|
||||
messageComposer(.init(string: "Some message"),
|
||||
mode: .edit(originalItemId: .random))
|
||||
|
||||
messageComposer(mode: .reply(itemID: .random,
|
||||
replyDetails: .loaded(sender: .init(id: "Kirk"),
|
||||
eventID: "123",
|
||||
eventContent: .message(.text(.init(body: "Text: Where the wild things are")))),
|
||||
isThread: false))
|
||||
}
|
||||
.padding(.horizontal)
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(replyTypes, id: \.self) { replyDetails in
|
||||
messageComposer(mode: .reply(itemID: .random,
|
||||
replyDetails: replyDetails, isThread: false))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Replying")
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 8) {
|
||||
ForEach(replyTypes, id: \.self) { replyDetails in
|
||||
messageComposer(mode: .reply(itemID: .random,
|
||||
replyDetails: replyDetails, isThread: true))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.environmentObject(viewModel.context)
|
||||
.previewDisplayName("Replying in thread")
|
||||
}
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
//
|
||||
// Copyright 2022 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
import SwiftUI
|
||||
|
||||
struct MessageComposerTextField: View {
|
||||
let placeholder: String
|
||||
@Binding var text: NSAttributedString
|
||||
@Binding var isMultiline: Bool
|
||||
|
||||
let maxHeight: CGFloat
|
||||
let enterKeyHandler: EnterKeyHandler
|
||||
let pasteHandler: PasteHandler
|
||||
|
||||
var body: some View {
|
||||
UITextViewWrapper(text: $text,
|
||||
isMultiline: $isMultiline,
|
||||
maxHeight: maxHeight,
|
||||
enterKeyHandler: enterKeyHandler,
|
||||
pasteHandler: pasteHandler)
|
||||
.accessibilityLabel(placeholder)
|
||||
.background(placeholderView, alignment: .topLeading)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var placeholderView: some View {
|
||||
if text.string.isEmpty {
|
||||
Text(placeholder)
|
||||
.foregroundColor(.compound.textPlaceholder)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct UITextViewWrapper: UIViewRepresentable {
|
||||
@Environment(\.roomContext) private var roomContext
|
||||
|
||||
@Binding var text: NSAttributedString
|
||||
@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 {
|
||||
// Need to use TextKit 1 for mentions
|
||||
let textView = ElementTextView(usingTextLayoutManager: false)
|
||||
textView.roomContext = roomContext
|
||||
|
||||
textView.isMultiline = $isMultiline
|
||||
textView.delegate = context.coordinator
|
||||
textView.elementDelegate = context.coordinator
|
||||
textView.textColor = .compound.textPrimary
|
||||
textView.isEditable = true
|
||||
textView.font = font
|
||||
textView.isSelectable = true
|
||||
textView.isUserInteractionEnabled = true
|
||||
textView.backgroundColor = UIColor.clear
|
||||
textView.returnKeyType = .default
|
||||
textView.textContainer.lineFragmentPadding = 0.0
|
||||
textView.textContainerInset = .zero
|
||||
textView.keyboardType = .default
|
||||
|
||||
textView.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func updateUIView(_ textView: UITextView, context: UIViewRepresentableContext<UITextViewWrapper>) {
|
||||
if textView.attributedText != text {
|
||||
textView.attributedText = text
|
||||
|
||||
// Prevent the textView from randomly using the tint color
|
||||
textView.typingAttributes = [.font: font,
|
||||
.foregroundColor: UIColor(.compound.textPrimary)]
|
||||
|
||||
if text.string.isEmpty {
|
||||
// text cleared, probably because the written text is sent
|
||||
// reload keyboard type
|
||||
if textView.isFirstResponder {
|
||||
textView.keyboardType = .twitter
|
||||
textView.reloadInputViews()
|
||||
textView.keyboardType = .default
|
||||
textView.reloadInputViews()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(text: $text,
|
||||
maxHeight: maxHeight,
|
||||
enterKeyHandler: enterKeyHandler,
|
||||
pasteHandler: pasteHandler)
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, UITextViewDelegate, ElementTextViewDelegate {
|
||||
private var text: Binding<NSAttributedString>
|
||||
|
||||
private let maxHeight: CGFloat
|
||||
|
||||
private let enterKeyHandler: EnterKeyHandler
|
||||
private let pasteHandler: PasteHandler
|
||||
|
||||
init(text: Binding<NSAttributedString>,
|
||||
maxHeight: CGFloat,
|
||||
enterKeyHandler: @escaping EnterKeyHandler,
|
||||
pasteHandler: @escaping PasteHandler) {
|
||||
self.text = text
|
||||
self.maxHeight = maxHeight
|
||||
self.enterKeyHandler = enterKeyHandler
|
||||
self.pasteHandler = pasteHandler
|
||||
}
|
||||
|
||||
func textViewDidChange(_ textView: UITextView) {
|
||||
text.wrappedValue = textView.attributedText
|
||||
}
|
||||
|
||||
func textViewDidReceiveEnterKeyPress(_ textView: UITextView) {
|
||||
enterKeyHandler()
|
||||
}
|
||||
|
||||
func textViewDidReceiveShiftEnterKeyPress(_ textView: UITextView) {
|
||||
textView.insertText("\n")
|
||||
}
|
||||
|
||||
func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider) {
|
||||
pasteHandler(provider)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private protocol ElementTextViewDelegate: AnyObject {
|
||||
func textViewDidReceiveShiftEnterKeyPress(_ textView: UITextView)
|
||||
func textViewDidReceiveEnterKeyPress(_ textView: UITextView)
|
||||
func textView(_ textView: UITextView, didReceivePasteWith provider: NSItemProvider)
|
||||
}
|
||||
|
||||
private class ElementTextView: UITextView, PillAttachmentViewProviderDelegate {
|
||||
var roomContext: RoomScreenViewModel.Context?
|
||||
var isMultiline: Binding<Bool>?
|
||||
|
||||
weak var elementDelegate: ElementTextViewDelegate?
|
||||
|
||||
private var pillViews = NSHashTable<UIView>.weakObjects()
|
||||
|
||||
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 {
|
||||
isMultiline.wrappedValue = true
|
||||
}
|
||||
} else {
|
||||
if isMultiline.wrappedValue {
|
||||
isMultiline.wrappedValue = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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 {
|
||||
// If the item is not supported for media upload then
|
||||
// just try pasting its contents into the textfield
|
||||
super.paste(sender)
|
||||
return
|
||||
}
|
||||
|
||||
elementDelegate?.textView(self, didReceivePasteWith: provider)
|
||||
}
|
||||
|
||||
// MARK: PillAttachmentViewProviderDelegate
|
||||
|
||||
func invalidateTextAttachmentsDisplay() {
|
||||
attributedText.enumerateAttribute(.attachment,
|
||||
in: NSRange(location: 0, length: attributedText.length),
|
||||
options: []) { value, range, _ in
|
||||
guard value != nil else {
|
||||
return
|
||||
}
|
||||
self.layoutManager.invalidateDisplay(forCharacterRange: range)
|
||||
}
|
||||
}
|
||||
|
||||
func registerPillView(_ pillView: UIView) {
|
||||
pillViews.add(pillView)
|
||||
}
|
||||
|
||||
func flushPills() {
|
||||
for view in pillViews.allObjects {
|
||||
view.alpha = 0.0
|
||||
view.removeFromSuperview()
|
||||
}
|
||||
pillViews.removeAllObjects()
|
||||
}
|
||||
}
|
||||
|
||||
struct MessageComposerTextField_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
VStack(spacing: 16) {
|
||||
PreviewWrapper(text: "123")
|
||||
PreviewWrapper(text: "")
|
||||
PreviewWrapper(text: "A really long message that will wrap to multiple lines on a phone in portrait.")
|
||||
}
|
||||
}
|
||||
|
||||
struct PreviewWrapper: View {
|
||||
@State var text: NSAttributedString
|
||||
@State var isMultiline: Bool
|
||||
|
||||
init(text: String) {
|
||||
_text = .init(initialValue: .init(string: text, attributes: [.font: UIFont.preferredFont(forTextStyle: .body),
|
||||
.foregroundColor: UIColor(.compound.textPrimary)]))
|
||||
_isMultiline = .init(initialValue: false)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
MessageComposerTextField(placeholder: "Placeholder",
|
||||
text: $text,
|
||||
isMultiline: $isMultiline,
|
||||
maxHeight: 300,
|
||||
enterKeyHandler: { },
|
||||
pasteHandler: { _ in })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
struct RoomAttachmentPicker: View {
|
||||
@ObservedObject var context: ComposerToolbarViewModel.Context
|
||||
|
||||
var body: some View {
|
||||
// Use a menu instead of the popover/sheet shown in Figma because overriding the colour scheme
|
||||
// results in a rendering bug on 17.1: https://github.com/element-hq/element-x-ios/issues/2157
|
||||
Menu {
|
||||
menuContent
|
||||
} label: {
|
||||
CompoundIcon(asset: Asset.Images.composerAttachment, size: .custom(30), relativeTo: .title)
|
||||
.scaledPadding(7, relativeTo: .title)
|
||||
}
|
||||
.buttonStyle(RoomAttachmentPickerButtonStyle())
|
||||
.accessibilityLabel(L10n.actionAddToTimeline)
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.openComposeOptions)
|
||||
}
|
||||
|
||||
var menuContent: some View {
|
||||
VStack(alignment: .leading, spacing: 0.0) {
|
||||
Button {
|
||||
context.send(viewAction: .attach(.photoLibrary))
|
||||
} label: {
|
||||
Label(L10n.screenRoomAttachmentSourceGallery, icon: \.image)
|
||||
.labelStyle(.menuSheet)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerPhotoLibrary)
|
||||
|
||||
Button {
|
||||
context.send(viewAction: .attach(.file))
|
||||
} label: {
|
||||
Label(L10n.screenRoomAttachmentSourceFiles, icon: \.attachment)
|
||||
.labelStyle(.menuSheet)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerDocuments)
|
||||
|
||||
Button {
|
||||
context.send(viewAction: .attach(.camera))
|
||||
} label: {
|
||||
Label(L10n.screenRoomAttachmentSourceCamera, icon: \.takePhoto)
|
||||
.labelStyle(.menuSheet)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerCamera)
|
||||
|
||||
Button {
|
||||
context.send(viewAction: .attach(.location))
|
||||
} label: {
|
||||
Label(L10n.screenRoomAttachmentSourceLocation, icon: \.locationPin)
|
||||
.labelStyle(.menuSheet)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerLocation)
|
||||
|
||||
Button {
|
||||
context.send(viewAction: .attach(.poll))
|
||||
} label: {
|
||||
Label(L10n.screenRoomAttachmentSourcePoll, icon: \.polls)
|
||||
.labelStyle(.menuSheet)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerPoll)
|
||||
|
||||
if ServiceLocator.shared.settings.richTextEditorEnabled {
|
||||
Button {
|
||||
context.send(viewAction: .enableTextFormatting)
|
||||
} label: {
|
||||
Label(L10n.screenRoomAttachmentTextFormatting, icon: \.textFormatting)
|
||||
.labelStyle(.menuSheet)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerTextFormatting)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct RoomAttachmentPickerButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.foregroundStyle(configuration.isPressed ? .compound.bgActionPrimaryPressed : .compound.bgActionPrimaryRest)
|
||||
}
|
||||
}
|
||||
|
||||
struct RoomAttachmentPicker_Previews: PreviewProvider, TestablePreview {
|
||||
static let viewModel = ComposerToolbarViewModel(wysiwygViewModel: WysiwygComposerViewModel(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
mediaProvider: MockMediaProvider(),
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
mentionDisplayHelper: ComposerMentionDisplayHelper.mock)
|
||||
|
||||
static var previews: some View {
|
||||
RoomAttachmentPicker(context: viewModel.context)
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import DSWaveformImage
|
||||
import DSWaveformImageViews
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceMessagePreviewComposer: View {
|
||||
@ObservedObject var playerState: AudioPlayerState
|
||||
let waveform: WaveformSource
|
||||
@ScaledMetric private var waveformLineWidth = 2.0
|
||||
@ScaledMetric private var waveformLinePadding = 2.0
|
||||
@GestureState var isDragging = false
|
||||
|
||||
let onPlay: () -> Void
|
||||
let onPause: () -> Void
|
||||
let onSeek: (Double) -> Void
|
||||
let onScrubbing: (Bool) -> Void
|
||||
|
||||
var timeLabelContent: String {
|
||||
// Display the duration if progress is 0.0
|
||||
let percent = playerState.progress > 0.0 ? playerState.progress : 1.0
|
||||
// If the duration is greater or equal 10 minutes, use the long format
|
||||
let elapsed = Date(timeIntervalSinceReferenceDate: playerState.duration * percent)
|
||||
return DateFormatter.elapsedTimeFormatter.string(from: elapsed)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
VoiceMessageButton(state: .init(playerState.playerButtonPlaybackState),
|
||||
size: .small,
|
||||
action: onPlayPause)
|
||||
Text(timeLabelContent)
|
||||
.lineLimit(1)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.monospacedDigit()
|
||||
.fixedSize(horizontal: true, vertical: true)
|
||||
|
||||
waveformView
|
||||
.waveformInteraction(isDragging: $isDragging,
|
||||
progress: playerState.progress,
|
||||
showCursor: playerState.showProgressIndicator,
|
||||
onSeek: onSeek)
|
||||
}
|
||||
.onChange(of: isDragging) { isDragging in
|
||||
onScrubbing(isDragging)
|
||||
}
|
||||
.padding(.vertical, 4.0)
|
||||
.padding(.horizontal, 6.0)
|
||||
.background {
|
||||
let roundedRectangle = RoundedRectangle(cornerRadius: 12)
|
||||
ZStack {
|
||||
roundedRectangle
|
||||
.fill(Color.compound.bgSubtleSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var waveformView: some View {
|
||||
let configuration: Waveform.Configuration = .init(style: .striped(.init(color: .black, width: waveformLineWidth, spacing: waveformLinePadding)),
|
||||
verticalScalingFactor: 1.0)
|
||||
switch waveform {
|
||||
case .url(let url):
|
||||
WaveformView(audioURL: url,
|
||||
configuration: configuration)
|
||||
.progressMask(progress: playerState.progress)
|
||||
case .data(let array):
|
||||
WaveformLiveCanvas(samples: array,
|
||||
configuration: configuration)
|
||||
.progressMask(progress: playerState.progress)
|
||||
}
|
||||
}
|
||||
|
||||
private func onPlayPause() {
|
||||
if playerState.playbackState == .playing {
|
||||
onPause()
|
||||
} else {
|
||||
onPlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension DateFormatter {
|
||||
static let elapsedTimeFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "mm:ss"
|
||||
return dateFormatter
|
||||
}()
|
||||
}
|
||||
|
||||
struct VoiceMessagePreviewComposer_Previews: PreviewProvider, TestablePreview {
|
||||
static let playerState = AudioPlayerState(id: .recorderPreview,
|
||||
duration: 10.0,
|
||||
waveform: EstimatedWaveform.mockWaveform,
|
||||
progress: 0.4)
|
||||
|
||||
static let waveformData: [Float] = Array(repeating: 1.0, count: 1000)
|
||||
|
||||
static var previews: some View {
|
||||
VoiceMessagePreviewComposer(playerState: playerState, waveform: .data(waveformData), onPlay: { }, onPause: { }, onSeek: { _ in }, onScrubbing: { _ in })
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import SwiftUI
|
||||
|
||||
enum VoiceMessageRecordingButtonMode {
|
||||
case idle
|
||||
case recording
|
||||
}
|
||||
|
||||
struct VoiceMessageRecordingButton: View {
|
||||
let mode: VoiceMessageRecordingButtonMode
|
||||
var startRecording: (() -> Void)?
|
||||
var stopRecording: (() -> Void)?
|
||||
|
||||
private let impactFeedbackGenerator = UIImpactFeedbackGenerator()
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
impactFeedbackGenerator.impactOccurred()
|
||||
switch mode {
|
||||
case .idle:
|
||||
startRecording?()
|
||||
case .recording:
|
||||
stopRecording?()
|
||||
}
|
||||
} label: {
|
||||
switch mode {
|
||||
case .idle:
|
||||
CompoundIcon(\.micOn, size: .medium, relativeTo: .title)
|
||||
.foregroundColor(.compound.iconSecondary)
|
||||
.scaledPadding(10, relativeTo: .title)
|
||||
case .recording:
|
||||
CompoundIcon(asset: Asset.Images.stopRecording, size: .medium, relativeTo: .title)
|
||||
.foregroundColor(.compound.iconOnSolidPrimary)
|
||||
.scaledPadding(6, relativeTo: .title)
|
||||
.background(
|
||||
Circle()
|
||||
.foregroundColor(.compound.bgActionPrimaryRest)
|
||||
)
|
||||
.scaledPadding(4, relativeTo: .title)
|
||||
}
|
||||
}
|
||||
.buttonStyle(VoiceMessageRecordingButtonStyle())
|
||||
.accessibilityLabel(mode == .idle ? L10n.a11yVoiceMessageRecord : L10n.a11yVoiceMessageStopRecording)
|
||||
}
|
||||
}
|
||||
|
||||
private struct VoiceMessageRecordingButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.opacity(configuration.isPressed ? 0.6 : 1)
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceMessageRecordingButton_Previews: PreviewProvider, TestablePreview {
|
||||
static var previews: some View {
|
||||
HStack(spacing: 8) {
|
||||
VoiceMessageRecordingButton(mode: .idle)
|
||||
|
||||
VoiceMessageRecordingButton(mode: .recording)
|
||||
}
|
||||
}
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceMessageRecordingComposer: View {
|
||||
@ObservedObject var recorderState: AudioRecorderState
|
||||
|
||||
var body: some View {
|
||||
VoiceMessageRecordingView(recorderState: recorderState)
|
||||
.padding(.vertical, 8.0)
|
||||
.padding(.horizontal, 12.0)
|
||||
.background {
|
||||
let roundedRectangle = RoundedRectangle(cornerRadius: 12)
|
||||
ZStack {
|
||||
roundedRectangle
|
||||
.fill(Color.compound.bgSubtleSecondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceMessageRecordingComposer_Previews: PreviewProvider, TestablePreview {
|
||||
static let recorderState = AudioRecorderState()
|
||||
|
||||
static var previews: some View {
|
||||
VoiceMessageRecordingComposer(recorderState: recorderState)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
//
|
||||
// Copyright 2023 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import Compound
|
||||
import DSWaveformImage
|
||||
import DSWaveformImageViews
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct VoiceMessageRecordingView: View {
|
||||
@ObservedObject var recorderState: AudioRecorderState
|
||||
@ScaledMetric private var waveformLineWidth = 2.0
|
||||
@ScaledMetric private var waveformLinePadding = 2.0
|
||||
@ScaledMetric private var recordingIndicatorSize = 8
|
||||
|
||||
private static let elapsedTimeFormatter: DateFormatter = {
|
||||
let dateFormatter = DateFormatter()
|
||||
dateFormatter.dateFormat = "mm:ss"
|
||||
return dateFormatter
|
||||
}()
|
||||
|
||||
private var timeLabelContent: String {
|
||||
Self.elapsedTimeFormatter.string(from: Date(timeIntervalSinceReferenceDate: recorderState.duration))
|
||||
}
|
||||
|
||||
private var configuration: Waveform.Configuration {
|
||||
.init(style: .striped(.init(color: .compound.iconSecondary, width: waveformLineWidth, spacing: waveformLinePadding)),
|
||||
verticalScalingFactor: 1.0)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
VoiceMessageRecordingBadge()
|
||||
.frame(width: recordingIndicatorSize, height: recordingIndicatorSize)
|
||||
|
||||
Text(timeLabelContent)
|
||||
.lineLimit(1)
|
||||
.font(.compound.bodySMSemibold)
|
||||
.foregroundColor(.compound.textSecondary)
|
||||
.monospacedDigit()
|
||||
.fixedSize()
|
||||
|
||||
WaveformLiveCanvas(samples: recorderState.waveformSamples,
|
||||
configuration: configuration)
|
||||
}
|
||||
.padding(.leading, 2)
|
||||
.padding(.trailing, 8)
|
||||
}
|
||||
}
|
||||
|
||||
private struct VoiceMessageRecordingBadge: View {
|
||||
@State private var opacity: CGFloat = 0
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.foregroundColor(.red)
|
||||
.opacity(opacity)
|
||||
.onAppear {
|
||||
withElementAnimation(.easeOut(duration: 1).repeatForever(autoreverses: true)) {
|
||||
opacity = 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct VoiceMessageRecordingView_Previews: PreviewProvider, TestablePreview {
|
||||
static let recorderState = AudioRecorderState()
|
||||
|
||||
static var previews: some View {
|
||||
VoiceMessageRecordingView(recorderState: recorderState)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user