mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +00:00
Add RTE formatting buttons (#1614)
* Add RTE formatting buttons * Update UUID as stored properties * Disable autocorrection on URL fields * Alert ids to let * Add ComposerToolbar_Previews * Cleanup * Cleanup * Refactor FormatItem colors * Fix composer layout issue * Fix ui tests * Nest ComposerToolbar A11y ids under RoomScreen * Add composer reply ui tests * Add UTs --------- Co-authored-by: Alfonso Grillo <alfogrillo@gmail.com>
This commit is contained in:
@@ -32,6 +32,19 @@ internal enum Asset {
|
||||
internal static let appLogo = ImageAsset(name: "images/app-logo")
|
||||
internal static let serverSelectionIcon = ImageAsset(name: "images/server-selection-icon")
|
||||
internal static let closeCircle = ImageAsset(name: "images/close-circle")
|
||||
internal static let bold = ImageAsset(name: "images/bold")
|
||||
internal static let bulletList = ImageAsset(name: "images/bullet-list")
|
||||
internal static let codeBlock = ImageAsset(name: "images/code-block")
|
||||
internal static let indent = ImageAsset(name: "images/indent")
|
||||
internal static let inlineCode = ImageAsset(name: "images/inline-code")
|
||||
internal static let italic = ImageAsset(name: "images/italic")
|
||||
internal static let link = ImageAsset(name: "images/link")
|
||||
internal static let numberedList = ImageAsset(name: "images/numbered-list")
|
||||
internal static let quote = ImageAsset(name: "images/quote")
|
||||
internal static let strikethrough = ImageAsset(name: "images/strikethrough")
|
||||
internal static let textFormat = ImageAsset(name: "images/text-format")
|
||||
internal static let underline = ImageAsset(name: "images/underline")
|
||||
internal static let unindent = ImageAsset(name: "images/unindent")
|
||||
internal static let encryptionNormal = ImageAsset(name: "images/encryption-normal")
|
||||
internal static let encryptionTrusted = ImageAsset(name: "images/encryption-trusted")
|
||||
internal static let encryptionWarning = ImageAsset(name: "images/encryption-warning")
|
||||
|
||||
@@ -34,6 +34,7 @@ struct RoomProxyMockConfiguration {
|
||||
var members: [RoomMemberProxyProtocol]?
|
||||
var inviter: RoomMemberProxyMock?
|
||||
var memberForID: RoomMemberProxyMock = .mockMe
|
||||
var ownUserID = "@alice:somewhere.org"
|
||||
|
||||
var invitedMembersCount = 100
|
||||
var joinedMembersCount = 50
|
||||
@@ -61,6 +62,7 @@ extension RoomProxyMock {
|
||||
invitedMembersCount = configuration.invitedMembersCount
|
||||
joinedMembersCount = configuration.joinedMembersCount
|
||||
activeMembersCount = configuration.activeMembersCount
|
||||
ownUserID = configuration.ownUserID
|
||||
|
||||
if let members = configuration.members {
|
||||
membersPublisher = Just(members).eraseToAnyPublisher()
|
||||
@@ -78,5 +80,6 @@ extension RoomProxyMock {
|
||||
setNameClosure = { _ in .success(()) }
|
||||
setTopicClosure = { _ in .success(()) }
|
||||
getMemberUserIDReturnValue = .success(configuration.memberForID)
|
||||
canUserRedactUserIDReturnValue = .success(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,12 +106,32 @@ struct A11yIdentifiers {
|
||||
struct RoomScreen {
|
||||
let name = "room-name"
|
||||
let avatar = "room-avatar"
|
||||
let attachmentPicker = "room-attachment_picker"
|
||||
let attachmentPickerPhotoLibrary = "room-attachment_picker_photo_library"
|
||||
let attachmentPickerDocuments = "room-attachment_picker_documents"
|
||||
let attachmentPickerCamera = "room-attachment_picker_camera"
|
||||
let attachmentPickerLocation = "room-attachment_picker_location"
|
||||
let attachmentPickerPoll = "room-attachment_picker_poll"
|
||||
let attachmentPickerTextFormatting = "room-attachment_picker_text_formatting"
|
||||
let timelineItemActionMenu = "room-timeline_item_action_menu"
|
||||
|
||||
let composerToolbar = ComposerToolbar()
|
||||
|
||||
struct ComposerToolbar {
|
||||
let bold = "composer_toolbar-bold"
|
||||
let italic = "composer_toolbar-italic"
|
||||
let underline = "composer_toolbar-underline"
|
||||
let strikethrough = "composer_toolbar-strikethrough"
|
||||
let unorderedList = "composer_toolbar-unordered_list"
|
||||
let orderedList = "composer_toolbar-ordered_list"
|
||||
let indent = "composer_toolbar-indent"
|
||||
let unindent = "composer_toolbar-unindent"
|
||||
let inlineCode = "composer_toolbar-inline_code"
|
||||
let codeBlock = "composer_toolbar-code_block"
|
||||
let quote = "composer_toolbar-quote"
|
||||
let link = "composer_toolbar-link"
|
||||
let openComposeOptions = "composer_toolbar-open_compose_options"
|
||||
let closeFormattingOptions = "composer_toolbar-close-formatting-options"
|
||||
}
|
||||
}
|
||||
|
||||
struct RoomDetailsScreen {
|
||||
|
||||
@@ -52,12 +52,21 @@ extension View {
|
||||
/// .alert(item: $context.alertInfo)
|
||||
/// ```
|
||||
struct AlertInfo<T: Hashable>: Identifiable, AlertProtocol {
|
||||
struct AlertButton {
|
||||
struct AlertButton: Identifiable {
|
||||
let id = UUID()
|
||||
let title: String
|
||||
var role: ButtonRole?
|
||||
let action: (() -> Void)?
|
||||
}
|
||||
|
||||
struct AlertTextField: Identifiable {
|
||||
let id = UUID()
|
||||
let placeholder: String
|
||||
let text: Binding<String>
|
||||
let autoCapitalization: TextInputAutocapitalization
|
||||
let autoCorrectionDisabled: Bool
|
||||
}
|
||||
|
||||
/// An identifier that can be used to distinguish one error from another.
|
||||
let id: T
|
||||
/// The alert's title.
|
||||
@@ -68,6 +77,10 @@ struct AlertInfo<T: Hashable>: Identifiable, AlertProtocol {
|
||||
var primaryButton = AlertButton(title: L10n.actionOk, action: nil)
|
||||
/// The alert's secondary button title and action.
|
||||
var secondaryButton: AlertButton?
|
||||
/// The alert's displayed text fields.
|
||||
var textFields: [AlertTextField]?
|
||||
/// The alert's additional buttons displayed vertically above the primary button.
|
||||
var verticalButtons: [AlertButton]?
|
||||
}
|
||||
|
||||
extension AlertInfo {
|
||||
@@ -95,9 +108,28 @@ extension AlertInfo {
|
||||
extension View {
|
||||
func alert<T: Hashable>(item: Binding<AlertInfo<T>?>) -> some View {
|
||||
alert(item: item) { item in
|
||||
if let verticalButtons = item.verticalButtons {
|
||||
ForEach(verticalButtons) { button in
|
||||
Button(button.title, role: button.role) {
|
||||
button.action?()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let textFields = item.textFields {
|
||||
VStack(spacing: 24) {
|
||||
ForEach(textFields) { textField in
|
||||
TextField(textField.placeholder, text: textField.text)
|
||||
.textInputAutocapitalization(textField.autoCapitalization)
|
||||
.autocorrectionDisabled(textField.autoCorrectionDisabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(item.primaryButton.title, role: item.primaryButton.role) {
|
||||
item.primaryButton.action?()
|
||||
}
|
||||
|
||||
if let secondaryButton = item.secondaryButton {
|
||||
Button(secondaryButton.title, role: secondaryButton.role) {
|
||||
secondaryButton.action?()
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import UIKit
|
||||
import WysiwygComposer
|
||||
|
||||
enum ComposerToolbarViewModelAction {
|
||||
case sendMessage(plain: String, html: String, mode: RoomScreenComposerMode)
|
||||
@@ -43,6 +45,8 @@ enum ComposerToolbarViewAction {
|
||||
case displayLocationPicker
|
||||
case displayPollForm
|
||||
case handlePasteOrDrop(provider: NSItemProvider)
|
||||
case enableTextFormatting
|
||||
case composerAction(action: ComposerAction)
|
||||
}
|
||||
|
||||
struct ComposerToolbarViewState: BindableState {
|
||||
@@ -61,8 +65,11 @@ struct ComposerToolbarViewState: BindableState {
|
||||
}
|
||||
|
||||
struct ComposerToolbarViewStateBindings {
|
||||
var composerPlainText: String
|
||||
var composerFocused: Bool
|
||||
var composerPlainText = ""
|
||||
var composerFocused = false
|
||||
var composerActionsEnabled = false
|
||||
var formatItems: [FormatItem] = .init()
|
||||
var alertInfo: AlertInfo<UUID>?
|
||||
|
||||
var showAttachmentPopover = false {
|
||||
didSet {
|
||||
@@ -72,3 +79,167 @@ struct ComposerToolbarViewStateBindings {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 unorderedList
|
||||
case orderedList
|
||||
case indent
|
||||
case unindent
|
||||
case inlineCode
|
||||
case codeBlock
|
||||
case quote
|
||||
case link
|
||||
}
|
||||
|
||||
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: Image {
|
||||
switch type {
|
||||
case .bold:
|
||||
return Image(asset: Asset.Images.bold)
|
||||
case .italic:
|
||||
return Image(asset: Asset.Images.italic)
|
||||
case .underline:
|
||||
return Image(asset: Asset.Images.underline)
|
||||
case .strikeThrough:
|
||||
return Image(asset: Asset.Images.strikethrough)
|
||||
case .unorderedList:
|
||||
return Image(asset: Asset.Images.bulletList)
|
||||
case .orderedList:
|
||||
return Image(asset: Asset.Images.numberedList)
|
||||
case .indent:
|
||||
return Image(asset: Asset.Images.indent)
|
||||
case .unindent:
|
||||
return Image(asset: Asset.Images.unindent)
|
||||
case .inlineCode:
|
||||
return Image(asset: Asset.Images.inlineCode)
|
||||
case .codeBlock:
|
||||
return Image(asset: Asset.Images.codeBlock)
|
||||
case .quote:
|
||||
return Image(asset: Asset.Images.quote)
|
||||
case .link:
|
||||
return Image(asset: Asset.Images.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
|
||||
}
|
||||
}
|
||||
|
||||
/// Return true if the format type is an indentation action.
|
||||
var isIndentType: Bool {
|
||||
switch self {
|
||||
case .indent, .unindent:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
//
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import WysiwygComposer
|
||||
|
||||
typealias ComposerToolbarViewModelType = StateStoreViewModel<ComposerToolbarViewState, ComposerToolbarViewAction>
|
||||
@@ -26,10 +28,19 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
actionsSubject.eraseToAnyPublisher()
|
||||
}
|
||||
|
||||
private struct WysiwygLinkData {
|
||||
let action: LinkAction
|
||||
let range: NSRange
|
||||
var url: String
|
||||
var text: String
|
||||
}
|
||||
|
||||
private var currentLinkData: WysiwygLinkData?
|
||||
|
||||
init(wysiwygViewModel: WysiwygComposerViewModel) {
|
||||
self.wysiwygViewModel = wysiwygViewModel
|
||||
|
||||
super.init(initialViewState: ComposerToolbarViewState(bindings: .init(composerPlainText: "", composerFocused: false)))
|
||||
super.init(initialViewState: ComposerToolbarViewState(bindings: .init()))
|
||||
|
||||
context.$viewState
|
||||
.map(\.composerMode)
|
||||
@@ -46,6 +57,20 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
wysiwygViewModel.$isContentEmpty
|
||||
.weakAssign(to: \.state.composerEmpty, on: self)
|
||||
.store(in: &cancellables)
|
||||
|
||||
wysiwygViewModel.$actionStates
|
||||
.map { actions in
|
||||
FormatType
|
||||
.allCases
|
||||
// Exclude indent type outside of lists.
|
||||
.filter { wysiwygViewModel.isInList || !$0.isIndentType }
|
||||
.map { type in
|
||||
FormatItem(type: type,
|
||||
state: actions[type.composerAction] ?? .disabled)
|
||||
}
|
||||
}
|
||||
.weakAssign(to: \.state.bindings.formatItems, on: self)
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
@@ -65,6 +90,7 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
actionsSubject.send(.sendPlainTextMessage(message: context.composerPlainText,
|
||||
mode: state.composerMode))
|
||||
}
|
||||
state.bindings.composerActionsEnabled = false
|
||||
case .cancelReply:
|
||||
set(mode: .default)
|
||||
case .cancelEdit:
|
||||
@@ -82,6 +108,15 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
actionsSubject.send(.displayPollForm)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,4 +163,124 @@ final class ComposerToolbarViewModel: ComposerToolbarViewModelType, ComposerTool
|
||||
state.bindings.composerPlainText = 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 extension WysiwygComposerViewModel {
|
||||
/// Return true if the selection of the composer is currently located in a list.
|
||||
var isInList: Bool {
|
||||
actionStates[.orderedList] == .reversed || actionStates[.unorderedList] == .reversed
|
||||
}
|
||||
}
|
||||
|
||||
private extension LinkAction {
|
||||
var url: String? {
|
||||
guard case .edit(let url) = self else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,29 +26,65 @@ struct ComposerToolbar: View {
|
||||
@ScaledMetric private var sendButtonIconSize = 16
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
topBar
|
||||
if context.composerActionsEnabled {
|
||||
bottomBar
|
||||
}
|
||||
}
|
||||
.alert(item: $context.alertInfo)
|
||||
}
|
||||
|
||||
private var topBar: some View {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
RoomAttachmentPicker(context: context)
|
||||
.padding(.bottom, 5) // centre align with the send button
|
||||
if !context.composerActionsEnabled {
|
||||
RoomAttachmentPicker(context: context)
|
||||
.padding(.bottom, 5) // centre align with the send button
|
||||
}
|
||||
messageComposer
|
||||
.environmentObject(context)
|
||||
Button {
|
||||
context.send(viewAction: .sendMessage)
|
||||
} label: {
|
||||
submitButtonImage
|
||||
.symbolVariant(.fill)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundColor(context.viewState.sendButtonDisabled ? .compound.iconDisabled : .global.white)
|
||||
.background {
|
||||
Circle()
|
||||
.foregroundColor(context.viewState.sendButtonDisabled ? .clear : .compound.iconAccentTertiary)
|
||||
}
|
||||
if !context.composerActionsEnabled {
|
||||
sendButton
|
||||
}
|
||||
.disabled(context.viewState.sendButtonDisabled)
|
||||
.animation(.linear(duration: 0.1), value: context.viewState.sendButtonDisabled)
|
||||
.keyboardShortcut(.return, modifiers: [.command])
|
||||
.padding([.vertical, .trailing], 6)
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomBar: some View {
|
||||
HStack(alignment: .bottom, spacing: 10) {
|
||||
Button {
|
||||
context.composerActionsEnabled = false
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.compound.headingLG)
|
||||
.foregroundColor(.compound.textActionPrimary)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.closeFormattingOptions)
|
||||
.padding(.bottom, 5) // centre align with the send button
|
||||
FormattingToolbar(formatItems: context.formatItems) { action in
|
||||
context.send(viewAction: .composerAction(action: action.composerAction))
|
||||
}
|
||||
sendButton
|
||||
}
|
||||
}
|
||||
|
||||
private var sendButton: some View {
|
||||
Button {
|
||||
context.send(viewAction: .sendMessage)
|
||||
} label: {
|
||||
submitButtonImage
|
||||
.symbolVariant(.fill)
|
||||
.font(.compound.bodyLG)
|
||||
.foregroundColor(context.viewState.sendButtonDisabled ? .compound.iconDisabled : .global.white)
|
||||
.background {
|
||||
Circle()
|
||||
.foregroundColor(context.viewState.sendButtonDisabled ? .clear : .compound.iconAccentTertiary)
|
||||
}
|
||||
}
|
||||
.disabled(context.viewState.sendButtonDisabled)
|
||||
.animation(.linear(duration: 0.1), value: context.viewState.sendButtonDisabled)
|
||||
.keyboardShortcut(.return, modifiers: [.command])
|
||||
.padding([.vertical, .trailing], 6)
|
||||
}
|
||||
|
||||
private var messageComposer: some View {
|
||||
MessageComposer(plainText: $context.composerPlainText,
|
||||
@@ -109,6 +145,12 @@ struct ComposerToolbar: View {
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposerToolbar_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
ComposerToolbar.mock()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mock
|
||||
|
||||
extension ComposerToolbar {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
//
|
||||
// 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 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: {
|
||||
item.icon
|
||||
.renderingMode(.template)
|
||||
.foregroundColor(item.foregroundColor)
|
||||
}
|
||||
.disabled(item.state == .disabled)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(item.backgroundColor)
|
||||
.cornerRadius(8)
|
||||
.accessibilityIdentifier(item.accessibilityIdentifier)
|
||||
.accessibilityLabel(item.accessibilityLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private extension FormatItem {
|
||||
var foregroundColor: Color {
|
||||
switch state {
|
||||
case .reversed:
|
||||
return .compound.iconOnSolidPrimary
|
||||
case .enabled:
|
||||
return .compound.iconSecondary
|
||||
case .disabled:
|
||||
return .compound.iconDisabled
|
||||
}
|
||||
}
|
||||
|
||||
var backgroundColor: Color {
|
||||
switch state {
|
||||
case .reversed:
|
||||
return .compound.bgActionPrimaryRest
|
||||
default:
|
||||
return .compound.bgCanvasDefault
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,7 +59,7 @@ struct MessageComposer: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding([.leading, .trailing], 12.0)
|
||||
.padding(.horizontal, 12.0)
|
||||
.clipped()
|
||||
.background {
|
||||
ZStack {
|
||||
@@ -107,16 +107,16 @@ private struct MessageComposerReplyHeader: View {
|
||||
.padding(4.0)
|
||||
.background(Color.compound.bgCanvasDefault)
|
||||
.cornerRadius(13.0)
|
||||
.padding([.trailing, .vertical], 8.0)
|
||||
.padding([.leading], -4.0)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
Button(action: action) {
|
||||
Image(systemName: "xmark")
|
||||
.font(.compound.bodySM.weight(.medium))
|
||||
.foregroundColor(.compound.iconTertiary)
|
||||
.padding(16.0)
|
||||
.padding(8.0)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8.0)
|
||||
.padding(.horizontal, -4.0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ struct RoomAttachmentPicker: View {
|
||||
.font(.compound.headingLG)
|
||||
.foregroundColor(.compound.textActionPrimary)
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPicker)
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.composerToolbar.openComposeOptions)
|
||||
.popover(isPresented: $context.showAttachmentPopover) {
|
||||
VStack(alignment: .leading, spacing: 0.0) {
|
||||
Button {
|
||||
@@ -72,6 +72,17 @@ struct RoomAttachmentPicker: View {
|
||||
} label: {
|
||||
PickerLabel(title: L10n.screenRoomAttachmentSourcePoll, icon: Image(asset: Asset.Images.timelinePollAttachment))
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerPoll)
|
||||
|
||||
if ServiceLocator.shared.settings.richTextEditorEnabled {
|
||||
Button {
|
||||
context.showAttachmentPopover = false
|
||||
context.send(viewAction: .enableTextFormatting)
|
||||
} label: {
|
||||
PickerLabel(title: L10n.screenRoomAttachmentTextFormatting, icon: Image(asset: Asset.Images.textFormat))
|
||||
}
|
||||
.accessibilityIdentifier(A11yIdentifiers.roomScreen.attachmentPickerTextFormatting)
|
||||
}
|
||||
}
|
||||
.padding(.top, isPresented ? 20 : 0)
|
||||
.background {
|
||||
|
||||
@@ -368,7 +368,7 @@ class MockScreen: Identifiable {
|
||||
var sessionVerificationControllerProxy = SessionVerificationControllerProxyMock.configureMock(requestDelay: .seconds(5))
|
||||
let parameters = SessionVerificationScreenCoordinatorParameters(sessionVerificationControllerProxy: sessionVerificationControllerProxy)
|
||||
return SessionVerificationScreenCoordinator(parameters: parameters)
|
||||
case .userSessionScreen:
|
||||
case .userSessionScreen, .userSessionScreenReply:
|
||||
let navigationSplitCoordinator = NavigationSplitCoordinator(placeholderCoordinator: PlaceholderScreenCoordinator())
|
||||
|
||||
let clientProxy = MockClientProxy(userID: "@mock:client.com", roomSummaryProvider: MockRoomSummaryProvider(state: .loaded(.mockRooms)))
|
||||
|
||||
@@ -50,6 +50,7 @@ enum UITestsScreenIdentifier: String {
|
||||
case roomWithUndisclosedPolls
|
||||
case sessionVerification
|
||||
case userSessionScreen
|
||||
case userSessionScreenReply
|
||||
case roomDetailsScreen
|
||||
case roomDetailsScreenWithRoomAvatar
|
||||
case roomDetailsScreenWithEmptyTopic
|
||||
|
||||
Reference in New Issue
Block a user