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:
Stefan Ceriu
2024-05-03 12:06:16 +03:00
committed by GitHub
parent d067e844da
commit 38416dbb5c
27 changed files with 418 additions and 232 deletions
@@ -0,0 +1,130 @@
//
// 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 Combine
import Foundation
private enum SuggestionTriggerPattern: Character {
case at = "@"
}
final class CompletionSuggestionService: CompletionSuggestionServiceProtocol {
private let roomProxy: RoomProxyProtocol
private var canMentionAllUsers = false
private(set) var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> = Empty().eraseToAnyPublisher()
private let suggestionTriggerSubject = CurrentValueSubject<SuggestionTrigger?, Never>(nil)
init(roomProxy: RoomProxyProtocol) {
self.roomProxy = roomProxy
suggestionsPublisher = suggestionTriggerSubject
.combineLatest(roomProxy.membersPublisher)
.map { [weak self, ownUserID = roomProxy.ownUserID] suggestionTrigger, members -> [SuggestionItem] in
guard let self,
let suggestionTrigger else {
return []
}
switch suggestionTrigger.type {
case .user:
var membersSuggestion = members
.compactMap { member -> SuggestionItem? in
guard member.userID != ownUserID,
member.membership == .join,
Self.shouldIncludeMember(userID: member.userID, displayName: member.displayName, searchText: suggestionTrigger.text) else {
return nil
}
return SuggestionItem.user(item: .init(id: member.userID,
displayName: member.displayName,
avatarURL: member.avatarURL,
range: suggestionTrigger.range))
}
if self.canMentionAllUsers,
!self.roomProxy.isEncryptedOneToOneRoom,
Self.shouldIncludeMember(userID: PillConstants.atRoom, displayName: PillConstants.everyone, searchText: suggestionTrigger.text) {
membersSuggestion
.insert(SuggestionItem.allUsers(item: .init(id: PillConstants.atRoom,
displayName: PillConstants.everyone,
avatarURL: self.roomProxy.avatarURL,
range: suggestionTrigger.range)), at: 0)
}
return membersSuggestion
}
}
// We only debounce if the suggestion is nil
.debounceAndRemoveDuplicates(on: DispatchQueue.main) { [weak self] _ in
self?.suggestionTriggerSubject.value != nil ? .milliseconds(500) : .milliseconds(0)
}
Task {
switch await roomProxy.canUserTriggerRoomNotification(userID: roomProxy.ownUserID) {
case .success(let value):
canMentionAllUsers = value
case .failure:
canMentionAllUsers = false
}
}
}
func processTextMessage(_ textMessage: String?) {
setSuggestionTrigger(detectTriggerInText(textMessage))
}
func setSuggestionTrigger(_ suggestionTrigger: SuggestionTrigger?) {
suggestionTriggerSubject.value = suggestionTrigger
}
// MARK: - Private
private func detectTriggerInText(_ text: String?) -> SuggestionTrigger? {
guard let text else {
return nil
}
let components = text.components(separatedBy: .whitespaces)
guard var lastComponent = components.last,
let range = text.range(of: lastComponent, options: .backwards),
lastComponent.count > 0,
let suggestionKey = SuggestionTriggerPattern(rawValue: lastComponent.removeFirst()),
// If a second character exists and is the same as the key it shouldn't trigger.
lastComponent.first != suggestionKey.rawValue else {
return nil
}
return .init(type: .user, text: lastComponent, range: NSRange(range, in: text))
}
private static func shouldIncludeMember(userID: String, displayName: String?, searchText: String) -> Bool {
// If the search text is empty give back all the results
guard !searchText.isEmpty else {
return true
}
let containedInUserID = userID.localizedStandardContains(searchText.lowercased())
let containedInDisplayName: Bool
if let displayName {
containedInDisplayName = displayName.localizedStandardContains(searchText.lowercased())
} else {
containedInDisplayName = false
}
return containedInUserID || containedInDisplayName
}
}
@@ -0,0 +1,88 @@
//
// 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 Combine
import Foundation
import WysiwygComposer
struct SuggestionTrigger: Equatable {
enum SuggestionType: Equatable {
case user
}
let type: SuggestionType
let text: String
let range: NSRange
}
enum SuggestionItem: Identifiable, Equatable {
case user(item: MentionSuggestionItem)
case allUsers(item: MentionSuggestionItem)
var id: String {
switch self {
case .user(let user):
return user.id
case .allUsers:
return PillConstants.atRoom
}
}
var range: NSRange {
switch self {
case .user(let item), .allUsers(let item):
return item.range
}
}
}
struct MentionSuggestionItem: Identifiable, Equatable {
let id: String
let displayName: String?
let avatarURL: URL?
let range: NSRange
}
// sourcery: AutoMockable
protocol CompletionSuggestionServiceProtocol {
var suggestionsPublisher: AnyPublisher<[SuggestionItem], Never> { get }
func processTextMessage(_ textMessage: String?)
func setSuggestionTrigger(_ suggestionTrigger: SuggestionTrigger?)
}
extension WysiwygComposer.SuggestionPattern {
var toElementPattern: SuggestionTrigger? {
switch key {
case .at:
return SuggestionTrigger(type: .user, text: text, range: .init(location: Int(start), length: Int(end)))
default:
return nil
}
}
}
extension CompletionSuggestionServiceMock {
struct CompletionSuggestionServiceMockConfiguration {
var suggestions: [SuggestionItem] = []
}
convenience init(configuration: CompletionSuggestionServiceMockConfiguration) {
self.init()
underlyingSuggestionsPublisher = Just(configuration.suggestions).eraseToAnyPublisher()
}
}
@@ -0,0 +1,287 @@
//
// 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
enum ComposerToolbarVoiceMessageAction {
case startRecording
case stopRecording
case cancelRecording
case deleteRecording
case startPlayback
case pausePlayback
case scrubPlayback(scrubbing: Bool)
case seekPlayback(progress: Double)
case send
}
enum ComposerToolbarViewModelAction {
case sendMessage(plain: String, html: String?, mode: RoomScreenComposerMode, intentionalMentions: IntentionalMentions)
case attach(ComposerAttachmentType)
case handlePasteOrDrop(provider: NSItemProvider)
case composerModeChanged(mode: RoomScreenComposerMode)
case composerFocusedChanged(isFocused: Bool)
case voiceMessage(ComposerToolbarVoiceMessageAction)
case contentChanged(isEmpty: Bool)
}
enum ComposerToolbarViewAction {
case composerAppeared
case sendMessage
case cancelReply
case cancelEdit
case attach(ComposerAttachmentType)
case handlePasteOrDrop(provider: NSItemProvider)
case enableTextFormatting
case composerAction(action: ComposerAction)
case selectedSuggestion(_ suggestion: SuggestionItem)
case voiceMessage(ComposerToolbarVoiceMessageAction)
case plainComposerTextChanged
}
enum ComposerAttachmentType {
case camera
case photoLibrary
case file
case location
case poll
}
struct ComposerToolbarViewState: BindableState {
var composerMode: RoomScreenComposerMode = .default
var composerEmpty = true
var suggestions: [SuggestionItem] = []
var audioPlayerState: AudioPlayerState
var audioRecorderState: AudioRecorderState
var bindings: ComposerToolbarViewStateBindings
var isUploading: Bool {
switch composerMode {
case .previewVoiceMessage(_, _, let isUploading):
return isUploading
default:
return false
}
}
var showSendButton: Bool {
switch composerMode {
case .recordVoiceMessage:
return false
case .previewVoiceMessage:
return true
default:
if ServiceLocator.shared.settings.richTextEditorEnabled {
return !composerEmpty
} else {
return !bindings.plainComposerText.string.isEmpty
}
}
}
var sendButtonDisabled: Bool {
if case .previewVoiceMessage = composerMode {
return false
}
if ServiceLocator.shared.settings.richTextEditorEnabled {
return composerEmpty
} else {
return bindings.plainComposerText.string.isEmpty
}
}
var isVoiceMessageModeActivated: Bool {
switch composerMode {
case .recordVoiceMessage, .previewVoiceMessage:
return true
default:
return false
}
}
}
struct ComposerToolbarViewStateBindings {
var plainComposerText: NSAttributedString = .init(string: "")
var composerFocused = false
var composerActionsEnabled = false
var composerExpanded = false
var formatItems: [FormatItem] = .init()
var alertInfo: AlertInfo<UUID>?
}
/// An item in the toolbar
struct FormatItem {
/// The type of the item
let type: FormatType
/// The state of the item
let state: ActionState
}
/// The types of formatting actions
enum FormatType {
case bold
case italic
case underline
case strikeThrough
case link
case unorderedList
case orderedList
case indent
case unindent
case inlineCode
case codeBlock
case quote
}
extension FormatType: CaseIterable, Identifiable {
var id: Self { self }
}
extension FormatItem: Identifiable {
var id: FormatType { type }
}
extension FormatItem {
/// The icon to display in the formatting toolbar.
var icon: KeyPath<CompoundIcons, Image> {
switch type {
case .bold:
return \.bold
case .italic:
return \.italic
case .underline:
return \.underline
case .strikeThrough:
return \.strikethrough
case .unorderedList:
return \.listBulleted
case .orderedList:
return \.listNumbered
case .indent:
return \.indentIncrease
case .unindent:
return \.indentDecrease
case .inlineCode:
return \.inlineCode
case .codeBlock:
return \.code
case .quote:
return \.quote
case .link:
return \.link
}
}
var accessibilityIdentifier: String {
switch type {
case .bold:
return A11yIdentifiers.roomScreen.composerToolbar.bold
case .italic:
return A11yIdentifiers.roomScreen.composerToolbar.italic
case .underline:
return A11yIdentifiers.roomScreen.composerToolbar.underline
case .strikeThrough:
return A11yIdentifiers.roomScreen.composerToolbar.strikethrough
case .unorderedList:
return A11yIdentifiers.roomScreen.composerToolbar.unorderedList
case .orderedList:
return A11yIdentifiers.roomScreen.composerToolbar.orderedList
case .indent:
return A11yIdentifiers.roomScreen.composerToolbar.indent
case .unindent:
return A11yIdentifiers.roomScreen.composerToolbar.unindent
case .inlineCode:
return A11yIdentifiers.roomScreen.composerToolbar.inlineCode
case .codeBlock:
return A11yIdentifiers.roomScreen.composerToolbar.codeBlock
case .quote:
return A11yIdentifiers.roomScreen.composerToolbar.quote
case .link:
return A11yIdentifiers.roomScreen.composerToolbar.link
}
}
var accessibilityLabel: String {
switch type {
case .bold:
return L10n.richTextEditorFormatBold
case .italic:
return L10n.richTextEditorFormatItalic
case .underline:
return L10n.richTextEditorFormatUnderline
case .strikeThrough:
return L10n.richTextEditorFormatStrikethrough
case .unorderedList:
return L10n.richTextEditorBulletList
case .orderedList:
return L10n.richTextEditorNumberedList
case .indent:
return L10n.richTextEditorIndent
case .unindent:
return L10n.richTextEditorUnindent
case .inlineCode:
return L10n.richTextEditorInlineCode
case .codeBlock:
return L10n.richTextEditorCodeBlock
case .quote:
return L10n.richTextEditorQuote
case .link:
return L10n.richTextEditorLink
}
}
}
extension FormatType {
/// The associated library composer action.
var composerAction: ComposerAction {
switch self {
case .bold:
return .bold
case .italic:
return .italic
case .underline:
return .underline
case .strikeThrough:
return .strikeThrough
case .unorderedList:
return .unorderedList
case .orderedList:
return .orderedList
case .indent:
return .indent
case .unindent:
return .unindent
case .inlineCode:
return .inlineCode
case .codeBlock:
return .codeBlock
case .quote:
return .quote
case .link:
return .link
}
}
}
@@ -0,0 +1,495 @@
//
// 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 Combine
import Foundation
import GameKit
import MatrixRustSDK
import SwiftUI
import WysiwygComposer
typealias ComposerToolbarViewModelType = StateStoreViewModel<ComposerToolbarViewState, ComposerToolbarViewAction>
final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerToolbarViewModelProtocol {
private let wysiwygViewModel: WysiwygComposerViewModel
private let completionSuggestionService: CompletionSuggestionServiceProtocol
private let appSettings: AppSettings
private let mentionBuilder: MentionBuilderProtocol
private let attributedStringBuilder: AttributedStringBuilderProtocol
private var hasAppeard = false
private let actionsSubject: PassthroughSubject<ComposerToolbarViewModelAction, Never> = .init()
var actions: AnyPublisher<ComposerToolbarViewModelAction, Never> {
actionsSubject.eraseToAnyPublisher()
}
private struct WysiwygLinkData {
let action: LinkAction
let range: NSRange
var url: String
var text: String
}
private var currentLinkData: WysiwygLinkData?
init(wysiwygViewModel: WysiwygComposerViewModel, completionSuggestionService: CompletionSuggestionServiceProtocol, mediaProvider: MediaProviderProtocol, appSettings: AppSettings, mentionDisplayHelper: MentionDisplayHelper) {
self.wysiwygViewModel = wysiwygViewModel
self.completionSuggestionService = completionSuggestionService
self.appSettings = appSettings
mentionBuilder = MentionBuilder()
attributedStringBuilder = AttributedStringBuilder(cacheKey: "Composer", mentionBuilder: mentionBuilder)
super.init(initialViewState: ComposerToolbarViewState(audioPlayerState: .init(id: .recorderPreview, duration: 0),
audioRecorderState: .init(),
bindings: .init()),
imageProvider: mediaProvider)
context.$viewState
.map(\.composerMode)
.removeDuplicates()
.sink { [weak self] in
self?.wysiwygViewModel.shouldReplaceText = $0.isTextEditingEnabled
self?.actionsSubject.send(.composerModeChanged(mode: $0))
}
.store(in: &cancellables)
context.$viewState
.map(\.bindings.composerFocused)
.removeDuplicates()
.sink { [weak self] in self?.actionsSubject.send(.composerFocusedChanged(isFocused: $0)) }
.store(in: &cancellables)
wysiwygViewModel.$isContentEmpty
.removeDuplicates()
.sink { [weak self] isEmpty in
self?.state.composerEmpty = isEmpty
self?.actionsSubject.send(.contentChanged(isEmpty: isEmpty))
}
.store(in: &cancellables)
wysiwygViewModel.$actionStates
.map { actions in
FormatType
.allCases
.map { type in
FormatItem(type: type,
state: actions[type.composerAction] ?? .disabled)
}
}
.weakAssign(to: \.state.bindings.formatItems, on: self)
.store(in: &cancellables)
wysiwygViewModel.$suggestionPattern
.sink { [weak self] suggestionPattern in
self?.completionSuggestionService.setSuggestionTrigger(suggestionPattern?.toElementPattern)
}
.store(in: &cancellables)
completionSuggestionService.suggestionsPublisher
.weakAssign(to: \.state.suggestions, on: self)
.store(in: &cancellables)
setupMentionsHandling(mentionDisplayHelper: mentionDisplayHelper)
focusComposerIfHardwareKeyboardConnected()
}
// MARK: - Public
override func process(viewAction: ComposerToolbarViewAction) {
switch viewAction {
case .composerAppeared:
if !hasAppeard {
hasAppeard = true
wysiwygViewModel.setup()
}
case .sendMessage:
guard !state.sendButtonDisabled else { return }
switch state.composerMode {
case .previewVoiceMessage:
actionsSubject.send(.voiceMessage(.send))
default:
if ServiceLocator.shared.settings.richTextEditorEnabled {
let sendHTML = appSettings.richTextEditorEnabled
actionsSubject.send(.sendMessage(plain: wysiwygViewModel.content.markdown,
html: sendHTML ? wysiwygViewModel.content.html : nil,
mode: state.composerMode,
intentionalMentions: wysiwygViewModel.getMentionsState().toIntentionalMentions()))
} else {
sendPlainComposerText()
}
}
case .cancelReply:
set(mode: .default)
case .cancelEdit:
set(mode: .default)
set(text: "")
case .attach(let attachment):
state.bindings.composerFocused = false
actionsSubject.send(.attach(attachment))
case .handlePasteOrDrop(let provider):
actionsSubject.send(.handlePasteOrDrop(provider: provider))
case .enableTextFormatting:
state.bindings.composerActionsEnabled = true
state.bindings.composerFocused = true
case .composerAction(let action):
if action == .link {
createLinkAlert()
} else {
wysiwygViewModel.apply(action)
}
case .selectedSuggestion(let suggestion):
handleSuggestion(suggestion)
case .voiceMessage(let voiceMessageAction):
processVoiceMessageAction(voiceMessageAction)
case .plainComposerTextChanged:
completionSuggestionService.processTextMessage(state.bindings.plainComposerText.string)
}
}
func process(roomAction: RoomScreenComposerAction) {
switch roomAction {
case .setMode(mode: let mode):
set(mode: mode)
case .setText(text: let text):
set(text: text)
case .removeFocus:
state.bindings.composerFocused = false
case .clear:
set(mode: .default)
set(text: "")
}
}
var keyCommands: [WysiwygKeyCommand] {
[
.enter { [weak self] in
self?.process(viewAction: .sendMessage)
}
]
}
// MARK: - Private
private func sendPlainComposerText() {
let attributedString = NSMutableAttributedString(attributedString: context.plainComposerText)
var shouldMakeAnotherPass = false
var userIDs = Set<String>()
var containsAtRoom = false
repeat { // Don't enumerate and mutate at the same time, big no no
shouldMakeAnotherPass = false
attributedString.enumerateAttribute(.link, in: .init(location: 0, length: attributedString.length), options: []) { value, range, stop in
guard let value else { return }
shouldMakeAnotherPass = true
// Remove the attribute so it doesn't get inherited by the new string
attributedString.removeAttribute(.link, range: range)
guard let userID = attributedString.attribute(.MatrixUserID, at: range.location, effectiveRange: nil) as? String else {
return
}
let displayName = attributedString.attribute(.MatrixUserDisplayName, at: range.location, effectiveRange: nil)
attributedString.replaceCharacters(in: range, with: "[\(displayName ?? userID)](\(value))")
userIDs.insert(userID)
stop.pointee = true
}
} while shouldMakeAnotherPass
repeat {
shouldMakeAnotherPass = false
attributedString.enumerateAttribute(.MatrixAllUsersMention, in: .init(location: 0, length: attributedString.length), options: []) { value, range, stop in
guard value != nil else { return }
shouldMakeAnotherPass = true
// Remove the attribute so it doesn't get inherited by the new string
attributedString.removeAttribute(.MatrixAllUsersMention, range: range)
attributedString.replaceCharacters(in: range, with: PillConstants.atRoom)
containsAtRoom = true
stop.pointee = true
}
} while shouldMakeAnotherPass
actionsSubject.send(.sendMessage(plain: attributedString.string, html: nil,
mode: state.composerMode,
intentionalMentions: .init(userIDs: userIDs, atRoom: containsAtRoom)))
}
private func processVoiceMessageAction(_ action: ComposerToolbarVoiceMessageAction) {
switch action {
case .startRecording:
state.bindings.composerActionsEnabled = false
actionsSubject.send(.voiceMessage(.startRecording))
case .stopRecording:
actionsSubject.send(.voiceMessage(.stopRecording))
case .cancelRecording:
actionsSubject.send(.voiceMessage(.cancelRecording))
case .deleteRecording:
actionsSubject.send(.voiceMessage(.deleteRecording))
case .startPlayback:
actionsSubject.send(.voiceMessage(.startPlayback))
case .pausePlayback:
actionsSubject.send(.voiceMessage(.pausePlayback))
case .scrubPlayback(let scrubbing):
actionsSubject.send(.voiceMessage(.scrubPlayback(scrubbing: scrubbing)))
case .seekPlayback(let progress):
actionsSubject.send(.voiceMessage(.seekPlayback(progress: progress)))
case .send:
break
}
}
private func setupMentionsHandling(mentionDisplayHelper: MentionDisplayHelper) {
wysiwygViewModel.mentionDisplayHelper = mentionDisplayHelper
wysiwygViewModel.mentionReplacer = ComposerMentionReplacer { [weak self] urlString, string in
guard let self else {
return NSMutableAttributedString(string: string)
}
let attributedString: NSMutableAttributedString
// This is the all room mention special case
if urlString == PillConstants.composerAtRoomURLString {
attributedString = NSMutableAttributedString(string: string, attributes: [.MatrixAllUsersMention: true])
} else {
attributedString = NSMutableAttributedString(string: string, attributes: [.link: URL(string: urlString) as Any])
}
attributedStringBuilder.detectPermalinks(attributedString)
// In RTE mentions don't need to be handled as links
attributedString.removeAttribute(.link, range: NSRange(location: 0, length: attributedString.length))
return attributedString
}
}
private func handleSuggestion(_ suggestion: SuggestionItem) {
switch suggestion {
case let .user(item):
guard let url = try? URL(string: matrixToUserPermalink(userId: item.id)) else {
MXLog.error("Could not build user permalink")
return
}
if appSettings.richTextEditorEnabled {
wysiwygViewModel.setMention(url: url.absoluteString, name: item.displayName ?? item.id, mentionType: .user)
} else {
let attributedString = NSMutableAttributedString(attributedString: state.bindings.plainComposerText)
mentionBuilder.handleUserMention(for: attributedString, in: suggestion.range, url: url, userID: item.id, userDisplayName: item.displayName)
state.bindings.plainComposerText = attributedString
}
case .allUsers:
if appSettings.richTextEditorEnabled {
wysiwygViewModel.setAtRoomMention()
} else {
let attributedString = NSMutableAttributedString(attributedString: state.bindings.plainComposerText)
mentionBuilder.handleAllUsersMention(for: attributedString, in: suggestion.range)
state.bindings.plainComposerText = attributedString
}
}
}
private func set(mode: RoomScreenComposerMode) {
guard mode != state.composerMode else { return }
state.composerMode = mode
switch mode {
case .default:
break
case .recordVoiceMessage(let audioRecorderState):
state.audioRecorderState = audioRecorderState
case .previewVoiceMessage(let audioPlayerState, _, _):
state.audioPlayerState = audioPlayerState
case .edit, .reply:
// Focus composer when switching to reply/edit
state.bindings.composerFocused = true
}
}
private func set(text: String) {
if ServiceLocator.shared.settings.richTextEditorEnabled {
wysiwygViewModel.textView.flushPills()
wysiwygViewModel.setHtmlContent(text)
} else {
state.bindings.plainComposerText = .init(string: text)
}
}
private func createLinkAlert() {
let linkAction = wysiwygViewModel.getLinkAction()
currentLinkData = WysiwygLinkData(action: linkAction,
range: wysiwygViewModel.attributedContent.selection,
url: linkAction.url ?? "",
text: "")
let urlBinding: Binding<String> = .init { [weak self] in
self?.currentLinkData?.url ?? ""
} set: { [weak self] value in
self?.currentLinkData?.url = value
}
let textBinding: Binding<String> = .init { [weak self] in
self?.currentLinkData?.text ?? ""
} set: { [weak self] value in
self?.currentLinkData?.text = value
}
switch linkAction {
case .createWithText:
state.bindings.alertInfo = makeCreateWithTextAlertInfo(urlBinding: urlBinding, textBinding: textBinding)
case .create:
state.bindings.alertInfo = makeSetUrlAlertInfo(urlBinding: urlBinding, isEdit: false)
case .edit:
state.bindings.alertInfo = makeEditChoiceAlertInfo(urlBinding: urlBinding)
case .disabled:
break
}
}
private func makeCreateWithTextAlertInfo(urlBinding: Binding<String>, textBinding: Binding<String>) -> AlertInfo<UUID> {
AlertInfo(id: UUID(),
title: L10n.richTextEditorCreateLink,
primaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionCancel, action: {
self.restoreComposerSelectedRange()
}),
secondaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionSave, action: {
self.restoreComposerSelectedRange()
self.createLinkWithText()
}),
textFields: [AlertInfo<UUID>.AlertTextField(placeholder: L10n.commonText,
text: textBinding,
autoCapitalization: .never,
autoCorrectionDisabled: false),
AlertInfo<UUID>.AlertTextField(placeholder: L10n.richTextEditorUrlPlaceholder,
text: urlBinding,
autoCapitalization: .never,
autoCorrectionDisabled: true)])
}
private func makeSetUrlAlertInfo(urlBinding: Binding<String>, isEdit: Bool) -> AlertInfo<UUID> {
AlertInfo(id: UUID(),
title: isEdit ? L10n.richTextEditorEditLink : L10n.richTextEditorCreateLink,
primaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionCancel, action: {
self.restoreComposerSelectedRange()
}),
secondaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionSave, action: {
self.restoreComposerSelectedRange()
self.setLink()
}),
textFields: [AlertInfo<UUID>.AlertTextField(placeholder: L10n.richTextEditorUrlPlaceholder,
text: urlBinding,
autoCapitalization: .never,
autoCorrectionDisabled: true)])
}
private func makeEditChoiceAlertInfo(urlBinding: Binding<String>) -> AlertInfo<UUID> {
AlertInfo(id: UUID(),
title: L10n.richTextEditorEditLink,
primaryButton: AlertInfo<UUID>.AlertButton(title: L10n.actionRemove, role: .destructive, action: {
self.restoreComposerSelectedRange()
self.removeLinks()
}),
verticalButtons: [AlertInfo<UUID>.AlertButton(title: L10n.actionEdit, action: {
self.state.bindings.alertInfo = nil
DispatchQueue.main.async {
self.state.bindings.alertInfo = self.makeSetUrlAlertInfo(urlBinding: urlBinding, isEdit: true)
}
})])
}
private func restoreComposerSelectedRange() {
guard let currentLinkData else { return }
wysiwygViewModel.select(range: currentLinkData.range)
}
private func setLink() {
guard let currentLinkData else { return }
wysiwygViewModel.applyLinkOperation(.setLink(urlString: currentLinkData.url))
}
private func createLinkWithText() {
guard let currentLinkData else { return }
wysiwygViewModel.applyLinkOperation(.createLink(urlString: currentLinkData.url,
text: currentLinkData.text))
}
private func removeLinks() {
wysiwygViewModel.applyLinkOperation(.removeLinks)
}
private func focusComposerIfHardwareKeyboardConnected() {
// The simulator always detects the hardware keyboard as connected
#if !targetEnvironment(simulator)
if GCKeyboard.coalesced != nil {
MXLog.info("Hardware keyboard is connected")
state.bindings.composerFocused = true
}
NotificationCenter.default.addObserver(self, selector: #selector(hardwareKeyboardDidConnect), name: .GCKeyboardDidConnect, object: nil)
#endif
}
// periphery:ignore:parameters notification
@objc private func hardwareKeyboardDidConnect(_ notification: Notification) {
MXLog.info("Did connect hardware keyboard")
state.bindings.composerFocused = true
}
}
private extension LinkAction {
var url: String? {
guard case .edit(let url) = self else {
return nil
}
return url
}
}
private final class ComposerMentionReplacer: MentionReplacer {
let replacementForMentionClosure: (_ urlString: String, _ text: String) -> (NSAttributedString?)
init(replacementForMentionClosure: @escaping (String, String) -> (NSAttributedString?)) {
self.replacementForMentionClosure = replacementForMentionClosure
}
// There is no internal Markdown to RTE switch implemented yet in the room so this one is never called
func postProcessMarkdown(in attributedString: NSAttributedString) -> NSAttributedString {
attributedString
}
// There is no internal RTE to Markdown switch implemented yet in the room so this one is never called
func restoreMarkdown(in attributedString: NSAttributedString) -> String {
attributedString.string
}
func replacementForMention(_ url: String, text: String) -> NSAttributedString? {
replacementForMentionClosure(url, text)
}
}
@@ -0,0 +1,24 @@
//
// 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 Combine
// periphery: ignore - markdown protocol
protocol ComposerToolbarViewModelProtocol {
var actions: AnyPublisher<ComposerToolbarViewModelAction, Never> { get }
var context: ComposerToolbarViewModelType.Context { get }
func process(roomAction: RoomScreenComposerAction)
}
@@ -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 }
}
}
}
@@ -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")
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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)
}
}
@@ -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)
}
}