Merge pull request #6831 from vector-im/langleyd/6830_wysiwyg_core_formatting

Wysiwyg: Core Formatting
This commit is contained in:
David Langley 2022-10-13 16:03:27 +01:00 committed by GitHub
commit 1f5830821c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
100 changed files with 2342 additions and 164 deletions

View file

@ -18,6 +18,15 @@
"version" : "5.12.2"
}
},
{
"identity" : "matrix-wysiwyg-composer-swift",
"kind" : "remoteSourceControl",
"location" : "https://github.com/matrix-org/matrix-wysiwyg-composer-swift",
"state" : {
"branch" : "main",
"revision" : "532183124d973b8432694f29bce3619d184fe1a7"
}
},
{
"identity" : "ogg-swift",
"kind" : "remoteSourceControl",

Binary file not shown.

After

Width:  |  Height:  |  Size: 470 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 737 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "Bold.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Bold@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Bold@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 894 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "Code.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Code@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Code@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "Indent increase.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Indent increase@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Indent increase@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 B

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "Italic.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Italic@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Italic@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 660 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 940 B

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "Link.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Link@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Link@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 682 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "Numbered list.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Numbered list@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Numbered list@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 287 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 465 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 B

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "Quote.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Quote@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Quote@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 474 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 848 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "Strikethrough.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Strikethrough@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Strikethrough@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 585 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 962 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "Underlined.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Underlined@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Underlined@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 662 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 586 B

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "Bullet list.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Bullet list@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Bullet list@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "Indent decrease.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Indent decrease@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Indent decrease@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 449 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 597 B

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "maximise_composer.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "maximise_composer@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "maximise_composer@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 553 B

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "minimise_composer.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "minimise_composer@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "minimise_composer@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 488 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 B

View file

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "start_compose_module.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "start_compose_module@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "start_compose_module@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

View file

@ -797,6 +797,7 @@ Tap the + to start adding people.";
"settings_labs_enable_new_session_manager" = "New session manager";
"settings_labs_enable_new_client_info_feature" = "Record the client name, version, and url to recognise sessions more easily in session manager";
"settings_labs_enable_new_app_layout" = "New Application Layout";
"settings_labs_enable_wysiwyg_composer" = "Try out the rich text editor (plain text mode coming soon)";
"settings_version" = "Version %@";
"settings_olm_version" = "Olm Version %@";
@ -2489,6 +2490,19 @@ To enable access, tap Settings> Location and select Always";
"user_session_overview_current_session_title" = "Current session";
"user_session_overview_session_title" = "Session";
"user_session_overview_session_details_button_title" = "Session details";
// Mark: - WYSIWYG Composer
//Send Media Actions
"wysiwyg_composer_start_action_media_picker" = "Photo Library";
"wysiwyg_composer_start_action_stickers" = "Stickers";
"wysiwyg_composer_start_action_attachments" = "Attachments";
"wysiwyg_composer_start_action_polls" = "Polls";
"wysiwyg_composer_start_action_location" = "Location";
"wysiwyg_composer_start_action_camera" = "Camera";
"wysiwyg_composer_start_action_text_formatting" = "Text Formatting";
// MARK: - MatrixKit

View file

@ -100,6 +100,20 @@ internal class Asset: NSObject {
internal static let touchidIcon = ImageAsset(name: "touchid_icon")
internal static let addGroupParticipant = ImageAsset(name: "add_group_participant")
internal static let removeIconBlue = ImageAsset(name: "remove_icon_blue")
internal static let indentIncrease = ImageAsset(name: "Indent_increase")
internal static let bold = ImageAsset(name: "bold")
internal static let bulletList = ImageAsset(name: "bullet_list")
internal static let code = ImageAsset(name: "code")
internal static let indentDecrease = ImageAsset(name: "indent_decrease")
internal static let italic = ImageAsset(name: "italic")
internal static let link = ImageAsset(name: "link")
internal static let maximiseComposer = ImageAsset(name: "maximise_composer")
internal static let minimiseComposer = ImageAsset(name: "minimise_composer")
internal static let numberedList = ImageAsset(name: "numbered list")
internal static let quote = ImageAsset(name: "quote")
internal static let startComposeModule = ImageAsset(name: "start_compose_module")
internal static let strikethrough = ImageAsset(name: "strikethrough")
internal static let underlined = ImageAsset(name: "underlined")
internal static let findYourContactsFacepile = ImageAsset(name: "find_your_contacts_facepile")
internal static let captureAvatar = ImageAsset(name: "capture_avatar")
internal static let deleteAvatar = ImageAsset(name: "delete_avatar")

View file

@ -7535,6 +7535,10 @@ public class VectorL10n: NSObject {
public static var settingsLabsEnableThreads: String {
return VectorL10n.tr("Vector", "settings_labs_enable_threads")
}
/// Try out the rich text editor (plain text mode coming soon)
public static var settingsLabsEnableWysiwygComposer: String {
return VectorL10n.tr("Vector", "settings_labs_enable_wysiwyg_composer")
}
/// Polls
public static var settingsLabsEnabledPolls: String {
return VectorL10n.tr("Vector", "settings_labs_enabled_polls")
@ -9155,6 +9159,34 @@ public class VectorL10n: NSObject {
public static var widgetStickerPickerNoStickerpacksAlertAddNow: String {
return VectorL10n.tr("Vector", "widget_sticker_picker_no_stickerpacks_alert_add_now")
}
/// Attachments
public static var wysiwygComposerStartActionAttachments: String {
return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_attachments")
}
/// Camera
public static var wysiwygComposerStartActionCamera: String {
return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_camera")
}
/// Location
public static var wysiwygComposerStartActionLocation: String {
return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_location")
}
/// Photo Library
public static var wysiwygComposerStartActionMediaPicker: String {
return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_media_picker")
}
/// Polls
public static var wysiwygComposerStartActionPolls: String {
return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_polls")
}
/// Stickers
public static var wysiwygComposerStartActionStickers: String {
return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_stickers")
}
/// Text Formatting
public static var wysiwygComposerStartActionTextFormatting: String {
return VectorL10n.tr("Vector", "wysiwyg_composer_start_action_text_formatting")
}
/// Yes
public static var yes: String {
return VectorL10n.tr("Vector", "yes")

View file

@ -172,6 +172,10 @@ final class RiotSettings: NSObject {
@UserDefault(key: "enableClientInformationFeature", defaultValue: false, storage: defaults)
var enableClientInformationFeature
/// Flag indicating if the wysiwyg composer feature is enabled
@UserDefault(key: "enableWysiwygComposer", defaultValue: false, storage: defaults)
var enableWysiwygComposer
// MARK: Calls
/// Indicate if `allowStunServerFallback` settings has been set once.

View file

@ -16,6 +16,7 @@
import Foundation
import SwiftUI
import Combine
/**
UIHostingController that applies some app-level specific configuration
@ -25,7 +26,9 @@ class VectorHostingController: UIHostingController<AnyView> {
// MARK: Private
private let forceZeroSafeAreaInsets: Bool
private var theme: Theme
private var heightSubject = CurrentValueSubject<CGFloat, Never>(0)
// MARK: Public
@ -40,8 +43,12 @@ class VectorHostingController: UIHostingController<AnyView> {
var enableNavigationBarScrollEdgeAppearance = false
/// When non-nil, the style will be applied to the status bar.
var statusBarStyle: UIStatusBarStyle?
private let forceZeroSafeAreaInsets: Bool
/// Whether or not to publish when the height of the view changes.
var publishHeightChanges: Bool = false
/// The publisher to subscribe to if `publishHeightChanges` is enabled.
var heightPublisher: AnyPublisher<CGFloat, Never> {
return heightSubject.eraseToAnyPublisher()
}
override var preferredStatusBarStyle: UIStatusBarStyle {
statusBarStyle ?? super.preferredStatusBarStyle
@ -104,6 +111,10 @@ class VectorHostingController: UIHostingController<AnyView> {
if #available(iOS 15.0, *) {
self.view.invalidateIntrinsicContentSize()
}
if publishHeightChanges {
let height = sizeThatFits(in: CGSize(width: self.view.frame.width, height: UIView.layoutFittingExpandedSize.height)).height
heightSubject.send(height)
}
}
override func viewSafeAreaInsetsDidChange() {

View file

@ -92,6 +92,22 @@ typedef enum : NSUInteger
*/
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendTextMessage:(NSString*)textMessage;
/**
Tells the delegate that the user wants to send a formatted text message.
@param toolbarView the room input toolbar view.
@param formattedTextMessage the formatted message to send.
@param rawText the raw message to send.
*/
- (void)roomInputToolbarView:(MXKRoomInputToolbarView *)toolbarView sendFormattedTextMessage:(NSString *)formattedTextMessage withRawText:(NSString *)rawText;
/**
Tells the delegate that the user wants to display the send media actions.
@param toolbarView the room input toolbar view.
*/
- (void)roomInputToolbarViewShowSendMediaActions:(MXKRoomInputToolbarView *)toolbarView;
/**
Tells the delegate that the user wants to send an image.
@ -222,7 +238,7 @@ typedef enum : NSUInteger
@discussion This is the designated initializer for programmatic instantiation.
@return An initialized `MXKRoomInputToolbarView-inherited` object if successful, `nil` otherwise.
*/
+ (instancetype)roomInputToolbarView;
+ (MXKRoomInputToolbarView *)instantiateRoomInputToolbarView;
/**
The delegate notified when inputs are ready.

View file

@ -69,7 +69,7 @@
bundle:[NSBundle bundleForClass:[MXKRoomInputToolbarView class]]];
}
+ (instancetype)roomInputToolbarView
+ (MXKRoomInputToolbarView *)instantiateRoomInputToolbarView
{
if ([[self class] nib])
{

View file

@ -21,7 +21,7 @@ extension RoomDataSource {
private enum Constants {
static let emoteMessageSlashCommandPrefix = String(format: "%@ ", kMXKSlashCmdEmote)
}
// MARK: - NSAttributedString Sending
/// Send a text message to the room.
/// While sending, a fake event will be echoed in the messages list.
@ -33,7 +33,7 @@ extension RoomDataSource {
func sendAttributedTextMessage(_ attributedText: NSAttributedString,
completion: @escaping (MXResponse<String?>) -> Void) {
var localEcho: MXEvent?
let isEmote = isAttributedTextMessageAnEmote(attributedText)
let sanitized = sanitizedAttributedMessageText(attributedText)
let rawText: String
@ -43,7 +43,7 @@ extension RoomDataSource {
} else {
rawText = sanitized.string
}
if isEmote {
room.sendEmote(rawText,
formattedText: html,
@ -57,13 +57,38 @@ extension RoomDataSource {
localEcho: &localEcho,
completion: completion)
}
if localEcho != nil {
self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards)
self.processQueuedEvents(nil)
}
}
// MARK: - NSAttributedString Sending
/// Send a text message to the room.
/// While sending, a fake event will be echoed in the messages list.
/// Once complete, this local echo will be replaced by the event saved by the homeserver.
///
/// - Parameters:
/// - rawText: the raw text to send
/// - html: the formatted html to send
/// - completion: http operation completion block
func sendFormattedTextMessage(_ rawText: String,
html: String,
completion: @escaping (MXResponse<String?>) -> Void) {
var localEcho: MXEvent?
room.sendTextMessage(rawText,
formattedText: html,
threadId: self.threadId,
localEcho: &localEcho,
completion: completion)
if localEcho != nil {
self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards)
self.processQueuedEvents(nil)
}
}
/// Send a reply to an event with text message to the room.
///
/// While sending, a fake event will be echoed in the messages list.
@ -76,8 +101,6 @@ extension RoomDataSource {
func sendReply(to eventToReply: MXEvent,
withAttributedTextMessage attributedText: NSAttributedString,
completion: @escaping (MXResponse<String?>) -> Void) {
var localEcho: MXEvent?
let sanitized = sanitizedAttributedMessageText(attributedText)
let rawText: String
let html: String? = htmlMessageFromSanitizedAttributedText(sanitized)
@ -86,23 +109,29 @@ extension RoomDataSource {
} else {
rawText = sanitized.string
}
let stringLocalizer: MXSendReplyEventStringLocalizerProtocol = MXKSendReplyEventStringLocalizer()
room.sendReply(to: eventToReply,
textMessage: rawText,
formattedTextMessage: html,
stringLocalizer: stringLocalizer,
threadId: self.threadId,
localEcho: &localEcho,
completion: completion)
if localEcho != nil {
self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards)
self.processQueuedEvents(nil)
}
handleFormattedSendReply(to: eventToReply, rawText: rawText, html: html, completion: completion)
}
/// Send a reply to an event with a html formatted text message to the room.
///
/// While sending, a fake event will be echoed in the messages list.
/// Once complete, this local echo will be replaced by the event saved by the homeserver.
///
/// - Parameters:
/// - eventToReply: the event to reply
/// - rawText: the raw text to send
/// - htmlText: the html text to send
/// - completion: http operation completion block
func sendReply(to eventToReply: MXEvent,
rawText: String,
htmlText: String,
completion: @escaping (MXResponse<String?>) -> Void) {
handleFormattedSendReply(to: eventToReply, rawText: rawText, html: htmlText, completion: completion)
}
/// Replace a text in an event.
///
/// - Parameters:
@ -122,29 +151,24 @@ extension RoomDataSource {
} else {
rawText = sanitized.string
}
let eventBody = event.content[kMXMessageBodyKey] as? String
let eventFormattedBody = event.content["formatted_body"] as? String
if rawText != eventBody && (eventFormattedBody == nil || html != eventFormattedBody) {
self.mxSession.aggregations.replaceTextMessageEvent(
event,
withTextMessage: rawText,
formattedText: html,
localEcho: { localEcho in
// Apply the local echo to the timeline
self.updateEvent(withReplace: localEcho)
// Integrate the replace local event into the timeline like when sending a message
// This also allows to manage read receipt on this replace event
self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards)
self.processQueuedEvents(nil)
},
success: success,
failure: failure)
} else {
failure(nil)
}
handleReplaceFormattedMessage(for: event, rawText: rawText, html: html, success: success, failure: failure)
}
/// Replace a formatted html text in an event
///
/// - Parameters:
/// - event: The event to replace
/// - rawText: The new rawText
/// - html: The new html text
/// - success: A block object called when the operation succeeds. It returns the event id of the event generated on the homeserver
/// - failure: A block object called when the operation fails
func replaceFormattedTextMessage( for event: MXEvent,
rawText: String,
html: String,
success: @escaping ((String?) -> Void),
failure: @escaping ((Error?) -> Void)) {
handleReplaceFormattedMessage(for: event, rawText: rawText, html: html, success: success, failure: failure)
}
/// Retrieve editable attributed text message from an event.
@ -197,6 +221,10 @@ extension RoomDataSource {
return editableTextMessage
}
@objc func editableHtmlTextMessage(for event: MXEvent) -> String {
event.content["formatted_body"] as? String ?? event.content["body"] as? String ?? ""
}
}
// MARK: - Private Helpers
@ -230,4 +258,54 @@ private extension RoomDataSource {
func isAttributedTextMessageAnEmote(_ attributedText: NSAttributedString) -> Bool {
return attributedText.string.starts(with: Constants.emoteMessageSlashCommandPrefix)
}
func handleReplaceFormattedMessage(for event: MXEvent,
rawText: String,
html: String?,
success: @escaping ((String?) -> Void),
failure: @escaping ((Error?) -> Void)) {
let eventBody = event.content[kMXMessageBodyKey] as? String
let eventFormattedBody = event.content["formatted_body"] as? String
if rawText != eventBody && (eventFormattedBody == nil || html != eventFormattedBody) {
self.mxSession.aggregations.replaceTextMessageEvent(
event,
withTextMessage: rawText,
formattedText: html,
localEcho: { localEcho in
// Apply the local echo to the timeline
self.updateEvent(withReplace: localEcho)
// Integrate the replace local event into the timeline like when sending a message
// This also allows to manage read receipt on this replace event
self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards)
self.processQueuedEvents(nil)
},
success: success,
failure: failure)
} else {
failure(nil)
}
}
func handleFormattedSendReply(to eventToReply: MXEvent,
rawText: String,
html: String?,
completion: @escaping (MXResponse<String?>) -> Void) {
var localEcho: MXEvent?
let stringLocalizer: MXSendReplyEventStringLocalizerProtocol = MXKSendReplyEventStringLocalizer()
room.sendReply(to: eventToReply,
textMessage: rawText,
formattedTextMessage: html,
stringLocalizer: stringLocalizer,
threadId: self.threadId,
localEcho: &localEcho,
completion: completion)
if localEcho != nil {
self.queueEvent(forProcessing: localEcho, with: self.roomState, direction: .forwards)
self.processQueuedEvents(nil)
}
}
}

View file

@ -1116,7 +1116,7 @@
MXLogDebug(@"[MXKRoomVC] setRoomInputToolbarViewClass: Set inputToolbarView to class %@", roomInputToolbarViewClass);
id inputToolbarView = [roomInputToolbarViewClass roomInputToolbarView];
id inputToolbarView = [roomInputToolbarViewClass instantiateRoomInputToolbarView];
self->inputToolbarView = inputToolbarView;
self->inputToolbarView.delegate = self;
@ -3359,32 +3359,34 @@
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView heightDidChanged:(CGFloat)height completion:(void (^)(BOOL finished))completion
{
_roomInputToolbarContainerHeightConstraint.constant = height;
// Update layout with animation
[UIView animateWithDuration:self.resizeComposerAnimationDuration delay:0 options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn
animations:^{
// We will scroll to bottom if the bottom of the table is currently visible
BOOL shouldScrollToBottom = [self isBubblesTableScrollViewAtTheBottom];
CGFloat bubblesTableViewBottomConst = self->_roomInputToolbarContainerBottomConstraint.constant + self->_roomInputToolbarContainerHeightConstraint.constant + self->_roomActivitiesContainerHeightConstraint.constant;
self->_bubblesTableViewBottomConstraint.constant = bubblesTableViewBottomConst;
// Force to render the view
[self.view layoutIfNeeded];
if (shouldScrollToBottom)
{
[self scrollBubblesTableViewToBottomAnimated:NO];
}
}
completion:^(BOOL finished){
if (completion)
{
completion(finished);
}
}];
// This dispatch fixes a simultaneous accesses crash if this gets called twice quickly in succession
dispatch_async(dispatch_get_main_queue(), ^{
// Update layout with animation
[UIView animateWithDuration:self.resizeComposerAnimationDuration delay:0 options:UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionCurveEaseIn
animations:^{
// We will scroll to bottom if the bottom of the table is currently visible
BOOL shouldScrollToBottom = [self isBubblesTableScrollViewAtTheBottom];
self->_roomInputToolbarContainerHeightConstraint.constant = height;
CGFloat bubblesTableViewBottomConst = self->_roomInputToolbarContainerBottomConstraint.constant + self->_roomInputToolbarContainerHeightConstraint.constant + self->_roomActivitiesContainerHeightConstraint.constant;
self->_bubblesTableViewBottomConstraint.constant = bubblesTableViewBottomConst;
// Force to render the view
[self.view layoutIfNeeded];
if (shouldScrollToBottom)
{
[self scrollBubblesTableViewToBottomAnimated:NO];
}
}
completion:^(BOOL finished){
if (completion)
{
completion(finished);
}
}];
});
}
- (void)roomInputToolbarView:(MXKRoomInputToolbarView*)toolbarView sendTextMessage:(NSString*)textMessage

View file

@ -1,10 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="11762" systemVersion="16C67" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait">
<adaptation id="fullscreen"/>
</device>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="11757"/>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
@ -59,6 +58,7 @@
<constraint firstAttribute="bottom" secondItem="nLd-BP-JAE" secondAttribute="bottom" id="kQ6-Cg-FMi"/>
</constraints>
<nil key="simulatedStatusBarMetrics"/>
<point key="canvasLocation" x="130" y="132"/>
</view>
</objects>
</document>

View file

@ -97,7 +97,7 @@ static CGSize kThreadListBarButtonItemImageSize;
@interface RoomViewController () <UISearchBarDelegate, UIGestureRecognizerDelegate, UIScrollViewAccessibilityDelegate, RoomTitleViewTapGestureDelegate, MXKRoomMemberDetailsViewControllerDelegate, ContactsTableViewControllerDelegate, MXServerNoticesDelegate, RoomContextualMenuViewControllerDelegate,
ReactionsMenuViewModelCoordinatorDelegate, EditHistoryCoordinatorBridgePresenterDelegate, MXKDocumentPickerPresenterDelegate, EmojiPickerCoordinatorBridgePresenterDelegate,
ReactionHistoryCoordinatorBridgePresenterDelegate, CameraPresenterDelegate, MediaPickerCoordinatorBridgePresenterDelegate,
RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate, SpaceDetailPresenterDelegate, UserSuggestionCoordinatorBridgeDelegate, ThreadsCoordinatorBridgePresenterDelegate, ThreadsBetaCoordinatorBridgePresenterDelegate, MXThreadingServiceDelegate, RoomParticipantsInviteCoordinatorBridgePresenterDelegate>
RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate, SpaceDetailPresenterDelegate, UserSuggestionCoordinatorBridgeDelegate, ThreadsCoordinatorBridgePresenterDelegate, ThreadsBetaCoordinatorBridgePresenterDelegate, MXThreadingServiceDelegate, RoomParticipantsInviteCoordinatorBridgePresenterDelegate, RoomInputToolbarViewDelegate, ComposerCreateActionListBridgePresenterDelegate>
{
// The preview header
@ -195,6 +195,7 @@ static CGSize kThreadListBarButtonItemImageSize;
@property (nonatomic, strong) RoomContextualMenuPresenter *roomContextualMenuPresenter;
@property (nonatomic, strong) MXKErrorAlertPresentation *errorPresenter;
@property (nonatomic, strong) NSAttributedString *textMessageBeforeEditing;
@property (nonatomic, strong) NSString *htmlTextBeforeEditing;
@property (nonatomic, strong) EditHistoryCoordinatorBridgePresenter *editHistoryPresenter;
@property (nonatomic, strong) MXKDocumentPickerPresenter *documentPickerPresenter;
@property (nonatomic, strong) EmojiPickerCoordinatorBridgePresenter *emojiPickerCoordinatorBridgePresenter;
@ -209,6 +210,7 @@ static CGSize kThreadListBarButtonItemImageSize;
@property (nonatomic, strong) ThreadsCoordinatorBridgePresenter *threadsBridgePresenter;
@property (nonatomic, strong) ThreadsBetaCoordinatorBridgePresenter *threadsBetaBridgePresenter;
@property (nonatomic, strong) SlidingModalPresenter *threadsNoticeModalPresenter;
@property (nonatomic, strong) ComposerCreateActionListBridgePresenter *composerCreateActionListBridgePresenter;
@property (nonatomic, getter=isActivitiesViewExpanded) BOOL activitiesViewExpanded;
@property (nonatomic, getter=isScrollToBottomHidden) BOOL scrollToBottomHidden;
@property (nonatomic, getter=isMissedDiscussionsBadgeHidden) BOOL missedDiscussionsBadgeHidden;
@ -672,9 +674,7 @@ static CGSize kThreadListBarButtonItemImageSize;
{
// Retrieve the potential message partially typed during last room display.
// Note: We have to wait for viewDidAppear before updating growingTextView (viewWillAppear is too early)
RoomInputToolbarView *inputToolbar = (RoomInputToolbarView *)self.inputToolbarView;
inputToolbar.attributedTextMessage = self.roomDataSource.partialAttributedTextMessage;
self.inputToolbarView.attributedTextMessage = self.roomDataSource.partialAttributedTextMessage;
}
}
@ -1152,10 +1152,23 @@ static CGSize kThreadListBarButtonItemImageSize;
[self notifyDelegateOnLeaveRoomIfNecessary];
}
+ (Class) mainToolbarClass
{
if (RiotSettings.shared.enableWysiwygComposer)
{
return WysiwygInputToolbarView.class;
}
else
{
return RoomInputToolbarView.class;
}
}
// Set the input toolbar according to the current display
- (void)updateRoomInputToolbarViewClassIfNeeded
{
Class roomInputToolbarViewClass = RoomInputToolbarView.class;
Class roomInputToolbarViewClass = [RoomViewController mainToolbarClass];
BOOL shouldDismissContextualMenu = NO;
@ -1198,10 +1211,10 @@ static CGSize kThreadListBarButtonItemImageSize;
{
[super setRoomInputToolbarViewClass:roomInputToolbarViewClass];
// The voice message toolbar cannot be set on DisabledInputToolbarView and on new direct chat.
if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class] && !self.isNewDirectChat)
{
[(RoomInputToolbarView *)self.inputToolbarView setVoiceMessageToolbarView:self.voiceMessageController.voiceMessageToolbarView];
if ([self.inputToolbarView.class conformsToProtocol:@protocol(RoomInputToolbarViewProtocol)]) {
id<RoomInputToolbarViewProtocol> inputToolbar = (id<RoomInputToolbarViewProtocol>)self.inputToolbarView;
[inputToolbar setVoiceMessageToolbarView:self.voiceMessageController.voiceMessageToolbarView];
}
[self updateInputToolBarViewHeight];
@ -1214,9 +1227,9 @@ static CGSize kThreadListBarButtonItemImageSize;
{
CGFloat height = 0;
if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class])
{
height = ((RoomInputToolbarView*)self.inputToolbarView).mainToolbarHeightConstraint.constant;
if ([self.inputToolbarView.class conformsToProtocol:@protocol(RoomInputToolbarViewProtocol)]) {
id<RoomInputToolbarViewProtocol> inputToolbar = (id<RoomInputToolbarViewProtocol>)self.inputToolbarView;
height = inputToolbar.toolbarHeight;
}
else if ([self.inputToolbarView isKindOfClass:DisabledRoomInputToolbarView.class])
{
@ -2029,9 +2042,9 @@ static CGSize kThreadListBarButtonItemImageSize;
- (void)setInputToolBarSendMode:(RoomInputToolbarViewSendMode)sendMode forEventWithId:(NSString *)eventId
{
if (self.inputToolbarView && [self.inputToolbarView isKindOfClass:[RoomInputToolbarView class]])
if (self.inputToolbarView && [self inputToolbarConformsToToolbarViewProtocol])
{
RoomInputToolbarView *roomInputToolbarView = (RoomInputToolbarView*)self.inputToolbarView;
MXKRoomInputToolbarView <RoomInputToolbarViewProtocol> *roomInputToolbarView = (MXKRoomInputToolbarView <RoomInputToolbarViewProtocol> *) self.inputToolbarView;
if (eventId)
{
MXEvent *event = [self.roomDataSource eventWithEventId:eventId];
@ -2165,11 +2178,9 @@ static CGSize kThreadListBarButtonItemImageSize;
UIView *sourceView;
RoomInputToolbarView *roomInputToolbarView = [self inputToolbarViewAsRoomInputToolbarView];
if (roomInputToolbarView)
if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class])
{
sourceView = roomInputToolbarView.attachMediaButton;
sourceView = ((RoomInputToolbarView*)self.inputToolbarView).attachMediaButton;
}
else
{
@ -2241,6 +2252,7 @@ static CGSize kThreadListBarButtonItemImageSize;
}
- (void)setupActions {
if (![self.inputToolbarView isKindOfClass:RoomInputToolbarView.class]) {
return;
}
@ -2432,8 +2444,7 @@ static CGSize kThreadListBarButtonItemImageSize;
*/
- (void)sendVideoAsset:(AVAsset *)videoAsset isPhotoLibraryAsset:(BOOL)isPhotoLibraryAsset
{
RoomInputToolbarView *roomInputToolbarView = [self inputToolbarViewAsRoomInputToolbarView];
if (!roomInputToolbarView)
if (![self inputToolbarConformsToToolbarViewProtocol])
{
return;
}
@ -2454,15 +2465,27 @@ static CGSize kThreadListBarButtonItemImageSize;
// Create before sending the message in case of a discussion (direct chat)
[self createDiscussionIfNeeded:^(BOOL readyToSend) {
if (readyToSend)
if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol])
{
[[self inputToolbarViewAsRoomInputToolbarView] sendSelectedVideoAsset:videoAsset isPhotoLibraryAsset:isPhotoLibraryAsset];
[self.inputToolbarView sendSelectedVideoAsset:videoAsset isPhotoLibraryAsset:isPhotoLibraryAsset];
}
// Errors are handled at the request level. This should be improved in case of code rewriting.
}];
}];
compressionPrompt.popoverPresentationController.sourceView = roomInputToolbarView.attachMediaButton;
compressionPrompt.popoverPresentationController.sourceRect = roomInputToolbarView.attachMediaButton.bounds;
UIView *sourceView;
if ([self.inputToolbarView isKindOfClass:RoomInputToolbarView.class])
{
sourceView = ((RoomInputToolbarView*)self.inputToolbarView).attachMediaButton;
}
else
{
sourceView = self.inputToolbarView;
}
compressionPrompt.popoverPresentationController.sourceView = sourceView;
compressionPrompt.popoverPresentationController.sourceRect = sourceView.bounds;
[self presentViewController:compressionPrompt animated:YES completion:nil];
}
@ -2473,9 +2496,9 @@ static CGSize kThreadListBarButtonItemImageSize;
// Create before sending the message in case of a discussion (direct chat)
[self createDiscussionIfNeeded:^(BOOL readyToSend) {
if (readyToSend)
if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol])
{
[[self inputToolbarViewAsRoomInputToolbarView] sendSelectedVideoAsset:videoAsset isPhotoLibraryAsset:isPhotoLibraryAsset];
[self.inputToolbarView sendSelectedVideoAsset:videoAsset isPhotoLibraryAsset:isPhotoLibraryAsset];
}
// Errors are handled at the request level. This should be improved in case of code rewriting.
}];
@ -4608,12 +4631,16 @@ static CGSize kThreadListBarButtonItemImageSize;
{
MXEvent *event = [self.roomDataSource eventWithEventId:eventId];
RoomInputToolbarView *roomInputToolbarView = [self inputToolbarViewAsRoomInputToolbarView];
if (roomInputToolbarView)
if ([self inputToolbarConformsToHtmlToolbarViewProtocol])
{
self.textMessageBeforeEditing = roomInputToolbarView.attributedTextMessage;
roomInputToolbarView.attributedTextMessage = [self.customizedRoomDataSource editableAttributedTextMessageFor:event];
MXKRoomInputToolbarView <HtmlRoomInputToolbarViewProtocol> *htmlInputToolBarView = (MXKRoomInputToolbarView <HtmlRoomInputToolbarViewProtocol> *) self.inputToolbarView;
self.htmlTextBeforeEditing = htmlInputToolBarView.htmlContent;
htmlInputToolBarView.htmlContent = [self.customizedRoomDataSource editableHtmlTextMessageFor:event];
}
else if ([self inputToolbarConformsToToolbarViewProtocol])
{
self.textMessageBeforeEditing = self.inputToolbarView.attributedTextMessage;
self.inputToolbarView.attributedTextMessage = [self.customizedRoomDataSource editableAttributedTextMessageFor:event];
}
[self selectEventWithId:eventId inputToolBarSendMode:RoomInputToolbarViewSendModeEdit showTimestamp:YES];
@ -4621,26 +4648,30 @@ static CGSize kThreadListBarButtonItemImageSize;
- (void)restoreTextMessageBeforeEditing
{
RoomInputToolbarView *roomInputToolbarView = [self inputToolbarViewAsRoomInputToolbarView];
if (self.textMessageBeforeEditing)
if (self.htmlTextBeforeEditing && [self inputToolbarConformsToHtmlToolbarViewProtocol])
{
roomInputToolbarView.attributedTextMessage = self.textMessageBeforeEditing;
MXKRoomInputToolbarView <HtmlRoomInputToolbarViewProtocol> *htmlInputToolBarView = (MXKRoomInputToolbarView <HtmlRoomInputToolbarViewProtocol> *) self.inputToolbarView;
htmlInputToolBarView.htmlContent = self.htmlTextBeforeEditing;
}
else if (self.textMessageBeforeEditing && [self inputToolbarConformsToToolbarViewProtocol])
{
self.inputToolbarView.attributedTextMessage = self.textMessageBeforeEditing;
}
self.textMessageBeforeEditing = nil;
self.htmlTextBeforeEditing = nil;
}
- (RoomInputToolbarView*)inputToolbarViewAsRoomInputToolbarView
- (BOOL)inputToolbarConformsToHtmlToolbarViewProtocol
{
RoomInputToolbarView *roomInputToolbarView;
if (self.inputToolbarView && [self.inputToolbarView isKindOfClass:[RoomInputToolbarView class]])
{
roomInputToolbarView = (RoomInputToolbarView*)self.inputToolbarView;
}
return roomInputToolbarView;
return [self.inputToolbarView conformsToProtocol:@protocol(HtmlRoomInputToolbarViewProtocol)];
}
- (BOOL)inputToolbarConformsToToolbarViewProtocol
{
return [self.inputToolbarView conformsToProtocol:@protocol(RoomInputToolbarViewProtocol)];
}
- (void)showDifferentURLsAlertFor:(NSURL *)url visibleURLString:(NSString *)visibleURLString
@ -4933,7 +4964,7 @@ static CGSize kThreadListBarButtonItemImageSize;
{
if (self.roomInputToolbarContainerHeightConstraint.constant != height)
{
// Hide temporarily the placeholder to prevent its distorsion during height animation
// Hide temporarily the placeholder to prevent its distortion during height animation
if (!savedInputToolbarPlaceholder)
{
savedInputToolbarPlaceholder = toolbarView.placeholder.length ? toolbarView.placeholder : @"";
@ -4958,7 +4989,7 @@ static CGSize kThreadListBarButtonItemImageSize;
}
}
- (void)roomInputToolbarViewDidTapCancel:(RoomInputToolbarView*)toolbarView
- (void)roomInputToolbarViewDidTapCancel:(MXKRoomInputToolbarView<RoomInputToolbarViewProtocol>*)toolbarView
{
[self cancelEventSelection];
}
@ -4977,6 +5008,53 @@ static CGSize kThreadListBarButtonItemImageSize;
}
}
- (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendFormattedTextMessage:(NSString *)formattedTextMessage withRawText:(NSString *)rawText
{
// Create before sending the message in case of a discussion (direct chat)
MXWeakify(self);
[self createDiscussionIfNeeded:^(BOOL readyToSend) {
MXStrongifyAndReturnIfNil(self);
if (readyToSend) {
[self sendFormattedTextMessage:rawText htmlMsg:formattedTextMessage];
}
// Errors are handled at the request level. This should be improved in case of code rewriting.
}];
}
- (void)roomInputToolbarViewShowSendMediaActions:(MXKRoomInputToolbarView *)toolbarView
{
NSMutableArray *actionItems = [NSMutableArray new];
if (RiotSettings.shared.roomScreenAllowMediaLibraryAction)
{
[actionItems addObject:@(ComposerCreateActionPhotoLibrary)];
}
if (RiotSettings.shared.roomScreenAllowStickerAction && !self.isNewDirectChat)
{
[actionItems addObject:@(ComposerCreateActionStickers)];
}
if (RiotSettings.shared.roomScreenAllowFilesAction)
{
[actionItems addObject:@(ComposerCreateActionAttachments)];
}
if (BuildSettings.pollsEnabled && self.displayConfiguration.sendingPollsEnabled && !self.isNewDirectChat)
{
[actionItems addObject:@(ComposerCreateActionPolls)];
}
if (BuildSettings.locationSharingEnabled && !self.isNewDirectChat)
{
[actionItems addObject:@(ComposerCreateActionLocation)];
}
if (RiotSettings.shared.roomScreenAllowCameraAction)
{
[actionItems addObject:@(ComposerCreateActionCamera)];
}
self.composerCreateActionListBridgePresenter = [[ComposerCreateActionListBridgePresenter alloc] initWithActions:actionItems];
self.composerCreateActionListBridgePresenter.delegate = self;
[self.composerCreateActionListBridgePresenter presentFrom:self animated:YES];
}
- (void)roomInputToolbarView:(RoomInputToolbarView *)toolbarView sendAttributedTextMessage:(NSAttributedString *)attributedTextMessage
{
// Create before sending the message in case of a discussion (direct chat)
@ -5323,7 +5401,7 @@ static CGSize kThreadListBarButtonItemImageSize;
else
{
// Enable back the text input
[self setRoomInputToolbarViewClass:RoomInputToolbarView.class];
[self setRoomInputToolbarViewClass:[RoomViewController mainToolbarClass]];
[self updateInputToolBarViewHeight];
// And the extra area
@ -7575,9 +7653,9 @@ static CGSize kThreadListBarButtonItemImageSize;
// Create before sending the message in case of a discussion (direct chat)
[self createDiscussionIfNeeded:^(BOOL readyToSend) {
if (readyToSend)
if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol])
{
[[self inputToolbarViewAsRoomInputToolbarView] sendSelectedImage:imageData
[self.inputToolbarView sendSelectedImage:imageData
withMimeType:MXKUTI.jpeg.mimeType
andCompressionMode:MediaCompressionHelper.defaultCompressionMode
isPhotoLibraryAsset:NO];
@ -7610,9 +7688,9 @@ static CGSize kThreadListBarButtonItemImageSize;
// Create before sending the message in case of a discussion (direct chat)
[self createDiscussionIfNeeded:^(BOOL readyToSend) {
if (readyToSend)
if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol])
{
[[self inputToolbarViewAsRoomInputToolbarView] sendSelectedImage:imageData
[self.inputToolbarView sendSelectedImage:imageData
withMimeType:uti.mimeType
andCompressionMode:MediaCompressionHelper.defaultCompressionMode
isPhotoLibraryAsset:YES];
@ -7639,9 +7717,9 @@ static CGSize kThreadListBarButtonItemImageSize;
// Create before sending the message in case of a discussion (direct chat)
[self createDiscussionIfNeeded:^(BOOL readyToSend) {
if (readyToSend)
if (readyToSend && [self inputToolbarConformsToToolbarViewProtocol])
{
[[self inputToolbarViewAsRoomInputToolbarView] sendSelectedAssets:assets withCompressionMode:MediaCompressionHelper.defaultCompressionMode];
[self.inputToolbarView sendSelectedAssets:assets withCompressionMode:MediaCompressionHelper.defaultCompressionMode];
}
// Errors are handled at the request level. This should be improved in case of code rewriting.
}];
@ -7880,4 +7958,39 @@ static CGSize kThreadListBarButtonItemImageSize;
}
}
#pragma mark - ComposerCreateActionListBridgePresenter
- (void)composerCreateActionListBridgePresenterDelegateDidComplete:(ComposerCreateActionListBridgePresenter *)coordinatorBridgePresenter action:(enum ComposerCreateAction)action
{
[coordinatorBridgePresenter dismissWithAnimated:true completion:^{
switch (action) {
case ComposerCreateActionPhotoLibrary:
[self showMediaPickerAnimated:YES];
break;
case ComposerCreateActionStickers:
[self roomInputToolbarViewPresentStickerPicker];
break;
case ComposerCreateActionAttachments:
[self roomInputToolbarViewDidTapFileUpload];
break;
case ComposerCreateActionPolls:
[self.delegate roomViewControllerDidRequestPollCreationFormPresentation:self];
break;
case ComposerCreateActionLocation:
[self.delegate roomViewControllerDidRequestLocationSharingFormPresentation:self];
break;
case ComposerCreateActionCamera:
[self showCameraControllerAnimated:YES];
break;
}
self.composerCreateActionListBridgePresenter = nil;
}];
}
- (void)composerCreateActionListBridgePresenterDidDismissInteractively:(ComposerCreateActionListBridgePresenter *)coordinatorBridgePresenter
{
self.composerCreateActionListBridgePresenter = nil;
}
@end

View file

@ -52,6 +52,55 @@ extension RoomViewController {
}
/// Send the formatted text message and its raw counterpat to the room
///
/// - Parameter rawTextMsg: the raw text message
/// - Parameter htmlMsg: the html text message
@objc func sendFormattedTextMessage(_ rawTextMsg: String, htmlMsg: String) {
let eventModified = self.roomDataSource.event(withEventId: customizedRoomDataSource?.selectedEventId)
self.setupRoomDataSource { roomDataSource in
guard let roomDataSource = roomDataSource as? RoomDataSource else { return }
if self.wysiwygInputToolbar?.sendMode == .reply, let eventModified = eventModified {
roomDataSource.sendReply(to: eventModified, rawText: rawTextMsg, htmlText: htmlMsg) { response in
switch response {
case .success:
break
case .failure:
MXLog.error("[RoomViewController] sendFormattedTextMessage failed while updating event", context: [
"event_id": eventModified.eventId
])
}
}
} else if self.wysiwygInputToolbar?.sendMode == .edit, let eventModified = eventModified {
roomDataSource.replaceFormattedTextMessage(
for: eventModified,
rawText: rawTextMsg,
html: htmlMsg,
success: { _ in
//
},
failure: { _ in
MXLog.error("[RoomViewController] sendFormattedTextMessage failed while updating event", context: [
"event_id": eventModified.eventId
])
})
} else {
roomDataSource.sendFormattedTextMessage(rawTextMsg, html: htmlMsg) { response in
switch response {
case .success:
break
case .failure:
MXLog.error("[RoomViewController] sendFormattedTextMessage failed")
}
}
}
if self.customizedRoomDataSource?.selectedEventId != nil {
self.cancelEventSelection()
}
}
}
/// Send given attributed text message to the room
///
/// - Parameter attributedTextMsg: the attributed text message
@ -107,4 +156,8 @@ private extension RoomViewController {
var inputToolbar: RoomInputToolbarView? {
return self.inputToolbarView as? RoomInputToolbarView
}
var wysiwygInputToolbar: WysiwygInputToolbarView? {
return self.inputToolbarView as? WysiwygInputToolbarView
}
}

View file

@ -27,7 +27,7 @@
bundle:[NSBundle bundleForClass:[DisabledRoomInputToolbarView class]]];
}
+ (instancetype)roomInputToolbarView
+ (MXKRoomInputToolbarView *)instantiateRoomInputToolbarView
{
if ([[self class] nib])
{

View file

@ -33,6 +33,16 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode)
};
@protocol RoomInputToolbarViewProtocol
@property (nonatomic, strong) NSString *eventSenderDisplayName;
@property (nonatomic, assign) RoomInputToolbarViewSendMode sendMode;
- (void)setVoiceMessageToolbarView:(UIView *)voiceMessageToolbarView;
- (CGFloat)toolbarHeight;
@end
@protocol RoomInputToolbarViewDelegate <MXKRoomInputToolbarViewDelegate>
/**
@ -40,7 +50,7 @@ typedef NS_ENUM(NSUInteger, RoomInputToolbarViewSendMode)
@param toolbarView the room input toolbar view
*/
- (void)roomInputToolbarViewDidTapCancel:(RoomInputToolbarView*)toolbarView;
- (void)roomInputToolbarViewDidTapCancel:(MXKRoomInputToolbarView<RoomInputToolbarViewProtocol>*)toolbarView;
/**
Inform the delegate that the text message has changed.

View file

@ -30,7 +30,7 @@ static const NSTimeInterval kActionMenuAttachButtonAnimationDuration = .4;
static const NSTimeInterval kActionMenuContentAlphaAnimationDuration = .2;
static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
@interface RoomInputToolbarView() <UITextViewDelegate, RoomInputToolbarTextViewDelegate>
@interface RoomInputToolbarView() <UITextViewDelegate, RoomInputToolbarTextViewDelegate, RoomInputToolbarViewProtocol>
@property (nonatomic, weak) IBOutlet UIView *mainToolbarView;
@ -59,7 +59,7 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
@implementation RoomInputToolbarView
@dynamic delegate;
+ (instancetype)roomInputToolbarView
+ (MXKRoomInputToolbarView *)instantiateRoomInputToolbarView
{
UINib *nib = [UINib nibWithNibName:NSStringFromClass([RoomInputToolbarView class]) bundle:nil];
return [nib instantiateWithOwner:nil options:nil].firstObject;
@ -85,25 +85,6 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
self.textView.inputAccessoryView = inputAccessoryViewForKeyboard;
}
- (void)setVoiceMessageToolbarView:(UIView *)voiceMessageToolbarView
{
if (voiceMessageToolbarView) {
_voiceMessageToolbarView = voiceMessageToolbarView;
self.voiceMessageToolbarView.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:self.voiceMessageToolbarView];
[NSLayoutConstraint activateConstraints:@[[self.mainToolbarView.topAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.topAnchor],
[self.mainToolbarView.leftAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.leftAnchor],
[self.mainToolbarView.bottomAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.bottomAnchor],
[self.mainToolbarView.rightAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.rightAnchor]]];
}
else
{
[self.voiceMessageToolbarView removeFromSuperview];
_voiceMessageToolbarView = nil;
}
}
#pragma mark - Override MXKView
-(void)customizeViewRendering
@ -543,4 +524,28 @@ static const NSTimeInterval kActionMenuComposerHeightAnimationDuration = .3;
}];
}
#pragma mark - RoomInputToolbarViewProtocol
- (CGFloat)toolbarHeight {
return self.mainToolbarHeightConstraint.constant;
}
- (void)setVoiceMessageToolbarView:(UIView *)voiceMessageToolbarView
{
if (voiceMessageToolbarView) {
_voiceMessageToolbarView = voiceMessageToolbarView;
self.voiceMessageToolbarView.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:self.voiceMessageToolbarView];
[NSLayoutConstraint activateConstraints:@[[self.mainToolbarView.topAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.topAnchor],
[self.mainToolbarView.leftAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.leftAnchor],
[self.mainToolbarView.bottomAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.bottomAnchor],
[self.mainToolbarView.rightAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.rightAnchor]]];
}
else
{
[self.voiceMessageToolbarView removeFromSuperview];
_voiceMessageToolbarView = nil;
}
}
@end

View file

@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="18122" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES">
<device id="retina4_7" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="18093"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
@ -27,7 +27,7 @@
<action selector="onTouchUpInside:" destination="iN0-l3-epB" eventType="touchUpInside" id="WbU-WH-gwL"/>
</connections>
</button>
<scrollView hidden="YES" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="ESv-9w-KJF" customClass="RoomActionsBar" customModule="Riot" customModuleProvider="target">
<scrollView hidden="YES" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="scaleToFill" ambiguous="YES" translatesAutoresizingMaskIntoConstraints="NO" id="ESv-9w-KJF" customClass="RoomActionsBar" customModule="Element" customModuleProvider="target">
<rect key="frame" x="60" y="8" width="540" height="38"/>
<constraints>
<constraint firstAttribute="height" constant="38" id="i6C-gL-ADZ"/>
@ -77,7 +77,7 @@
<constraint firstItem="48y-kn-7b5" firstAttribute="centerY" secondItem="dVr-ZM-kkX" secondAttribute="centerY" id="z5v-Vy-6tc"/>
</constraints>
</view>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wgb-ON-N29" customClass="RoomInputToolbarTextView" customModule="Riot" customModuleProvider="target">
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="wgb-ON-N29" customClass="RoomInputToolbarTextView" customModule="Element" customModuleProvider="target">
<rect key="frame" x="5" y="33" width="474" height="4"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<accessibility key="accessibilityConfiguration" identifier="GrowingTextView"/>

View file

@ -0,0 +1,200 @@
//
// 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 Foundation
import Reusable
import WysiwygComposer
import SwiftUI
import Combine
import UIKit
import CoreGraphics
@objc protocol HtmlRoomInputToolbarViewProtocol: RoomInputToolbarViewProtocol {
@objc var htmlContent: String { get set }
}
// The toolbar for editing with rich text
class WysiwygInputToolbarView: MXKRoomInputToolbarView, NibLoadable, HtmlRoomInputToolbarViewProtocol {
// MARK: - Properties
// MARK: Private
private var cancellables = Set<AnyCancellable>()
private var heightConstraint: NSLayoutConstraint!
private var hostingViewController: VectorHostingController!
private var wysiwygViewModel = WysiwygComposerViewModel()
private var viewModel: ComposerViewModelProtocol! = ComposerViewModel(initialViewState: ComposerViewState())
// MARK: Public
/// The current html content of the composer
var htmlContent: String {
get {
wysiwygViewModel.content.html
}
set {
wysiwygViewModel.setHtmlContent(newValue)
}
}
/// The display name to show when in edit/reply
var eventSenderDisplayName: String! {
get {
viewModel.eventSenderDisplayName
}
set {
viewModel.eventSenderDisplayName = newValue
}
}
/// Whether the composer is in send, reply or edit mode.
var sendMode: RoomInputToolbarViewSendMode {
get {
viewModel.sendMode.legacySendMode
}
set {
viewModel.sendMode = ComposerSendMode(from: newValue)
}
}
// MARK: - Setup
override class func instantiate() -> MXKRoomInputToolbarView! {
return loadFromNib()
}
private weak var toolbarViewDelegate: RoomInputToolbarViewDelegate? {
return (delegate as? RoomInputToolbarViewDelegate) ?? nil
}
override func awakeFromNib() {
super.awakeFromNib()
viewModel.callback = { [weak self] result in
guard let self = self else { return }
switch result {
case .cancel:
self.toolbarViewDelegate?.roomInputToolbarViewDidTapCancel(self)
}
}
let composer = Composer(viewModel: viewModel.context,
wysiwygViewModel: wysiwygViewModel,
sendMessageAction: { [weak self] content in
guard let self = self else { return }
self.sendWysiwygMessage(content: content)
}, showSendMediaActions: { [weak self] in
guard let self = self else { return }
self.showSendMediaActions()
})
hostingViewController = VectorHostingController(rootView: composer)
hostingViewController.publishHeightChanges = true
let height = hostingViewController.sizeThatFits(in: CGSize(width: self.frame.width, height: UIView.layoutFittingExpandedSize.height)).height
let subView: UIView = hostingViewController.view
self.addSubview(subView)
hostingViewController.view.translatesAutoresizingMaskIntoConstraints = false
subView.translatesAutoresizingMaskIntoConstraints = false
heightConstraint = subView.heightAnchor.constraint(equalToConstant: height)
NSLayoutConstraint.activate([
heightConstraint,
subView.leadingAnchor.constraint(equalTo: self.leadingAnchor),
subView.trailingAnchor.constraint(equalTo: self.trailingAnchor),
subView.bottomAnchor.constraint(equalTo: self.bottomAnchor)
])
cancellables = [
hostingViewController.heightPublisher
.removeDuplicates()
.sink(receiveValue: { [weak self] idealHeight in
guard let self = self else { return }
self.updateToolbarHeight(wysiwygHeight: idealHeight)
})
]
update(theme: ThemeService.shared().theme)
registerThemeServiceDidChangeThemeNotification()
}
override func customizeRendering() {
super.customizeRendering()
self.backgroundColor = .clear
}
// MARK: - Private
private func updateToolbarHeight(wysiwygHeight: CGFloat) {
self.heightConstraint.constant = wysiwygHeight
toolbarViewDelegate?.roomInputToolbarView?(self, heightDidChanged: wysiwygHeight, completion: nil)
}
private func sendWysiwygMessage(content: WysiwygComposerContent) {
delegate?.roomInputToolbarView?(self, sendFormattedTextMessage: content.html, withRawText: content.plainText)
}
private func showSendMediaActions() {
delegate?.roomInputToolbarViewShowSendMediaActions?(self)
}
private func registerThemeServiceDidChangeThemeNotification() {
NotificationCenter.default.addObserver(self, selector: #selector(themeDidChange), name: .themeServiceDidChangeTheme, object: nil)
}
@objc private func themeDidChange() {
self.update(theme: ThemeService.shared().theme)
}
private func update(theme: Theme) {
hostingViewController.view.backgroundColor = theme.colors.background
}
// MARK: - RoomInputToolbarViewProtocol
/// Add the voice message toolbar to the composer
/// - Parameter voiceMessageToolbarView: the voice message toolbar UIView
func setVoiceMessageToolbarView(_ voiceMessageToolbarView: UIView!) {
// TODO embed the voice messages UI
}
func toolbarHeight() -> CGFloat {
return heightConstraint.constant
}
}
// MARK: - LegacySendModeAdapter
fileprivate extension ComposerSendMode {
init(from sendMode: RoomInputToolbarViewSendMode) {
switch sendMode {
case .reply: self = .reply
case .edit: self = .edit
case .createDM: self = .createDM
default: self = .send
}
}
var legacySendMode: RoomInputToolbarViewSendMode {
switch self {
case .createDM: return .createDM
case .reply: return .reply
case .edit: return .edit
case .send: return .send
}
}
}

View file

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.XIB" version="3.0" toolsVersion="20037" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="20020"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<placeholder placeholderIdentifier="IBFilesOwner" id="-1" userLabel="File's Owner"/>
<placeholder placeholderIdentifier="IBFirstResponder" id="-2" customClass="UIResponder"/>
<view autoresizesSubviews="NO" contentMode="scaleToFill" id="iN0-l3-epB" customClass="WysiwygInputToolbarView" customModule="Element" customModuleProvider="target">
<rect key="frame" x="0.0" y="0.0" width="600" height="80"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<viewLayoutGuide key="safeArea" id="vUN-kp-3ea"/>
<freeformSimulatedSizeMetrics key="simulatedDestinationMetrics"/>
<point key="canvasLocation" x="139" y="101"/>
</view>
</objects>
</document>

View file

@ -174,7 +174,8 @@ typedef NS_ENUM(NSUInteger, LABS_ENABLE)
LABS_ENABLE_AUTO_REPORT_DECRYPTION_ERRORS,
LABS_ENABLE_LIVE_LOCATION_SHARING,
LABS_ENABLE_NEW_SESSION_MANAGER,
LABS_ENABLE_NEW_CLIENT_INFO_FEATURE
LABS_ENABLE_NEW_CLIENT_INFO_FEATURE,
LABS_ENABLE_WYSIWYG_COMPOSER
};
typedef NS_ENUM(NSUInteger, SECURITY)
@ -594,6 +595,10 @@ ChangePasswordCoordinatorBridgePresenterDelegate>
}
[sectionLabs addRowWithTag:LABS_ENABLE_NEW_SESSION_MANAGER];
[sectionLabs addRowWithTag:LABS_ENABLE_NEW_CLIENT_INFO_FEATURE];
if (@available(iOS 15.0, *))
{
[sectionLabs addRowWithTag:LABS_ENABLE_WYSIWYG_COMPOSER];
}
sectionLabs.headerTitle = [VectorL10n settingsLabs];
if (sectionLabs.hasAnyRows)
{
@ -2549,6 +2554,18 @@ ChangePasswordCoordinatorBridgePresenterDelegate>
[labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableNewClientInfoFeature:) forControlEvents:UIControlEventTouchUpInside];
cell = labelAndSwitchCell;
}
else if (row == LABS_ENABLE_WYSIWYG_COMPOSER)
{
MXKTableViewCellWithLabelAndSwitch *labelAndSwitchCell = [self getLabelAndSwitchCell:tableView forIndexPath:indexPath];
labelAndSwitchCell.mxkLabel.text = [VectorL10n settingsLabsEnableWysiwygComposer];
labelAndSwitchCell.mxkSwitch.on = RiotSettings.shared.enableWysiwygComposer;
labelAndSwitchCell.mxkSwitch.onTintColor = ThemeService.shared.theme.tintColor;
[labelAndSwitchCell.mxkSwitch addTarget:self action:@selector(toggleEnableWysiwygComposerFeature:) forControlEvents:UIControlEventTouchUpInside];
cell = labelAndSwitchCell;
}
}
@ -3311,6 +3328,11 @@ ChangePasswordCoordinatorBridgePresenterDelegate>
MXSDKOptions.sharedInstance.enableNewClientInformationFeature = isEnabled;
}
- (void)toggleEnableWysiwygComposerFeature:(UISwitch *)sender
{
RiotSettings.shared.enableWysiwygComposer = sender.isOn;
}
- (void)togglePinRoomsWithMissedNotif:(UISwitch *)sender
{
RiotSettings.shared.pinRoomsWithMissedNotificationsOnHome = sender.isOn;

View file

@ -42,6 +42,7 @@ targets:
- package: Mapbox
- package: OrderedCollections
- package: SwiftOGG
- package: WysiwygComposer
- package: DeviceKit
configFiles:

View file

@ -68,6 +68,8 @@ enum MockAppScreens {
MockTemplateUserProfileScreenState.self,
MockTemplateRoomListScreenState.self,
MockTemplateRoomChatScreenState.self,
MockSpaceSelectorScreenState.self
MockSpaceSelectorScreenState.self,
MockComposerScreenState.self,
MockComposerCreateActionListScreenState.self
]
}

View file

@ -0,0 +1,90 @@
/*
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 Foundation
@objc protocol ComposerCreateActionListBridgePresenterDelegate {
func composerCreateActionListBridgePresenterDelegateDidComplete(_ coordinatorBridgePresenter: ComposerCreateActionListBridgePresenter, action: ComposerCreateAction)
func composerCreateActionListBridgePresenterDidDismissInteractively(_ coordinatorBridgePresenter: ComposerCreateActionListBridgePresenter)
}
/// ComposerCreateActionListBridgePresenter enables to start ComposerCreateActionList from a view controller.
/// This bridge is used while waiting for global usage of coordinator pattern.
/// **WARNING**: This class breaks the Coordinator abstraction and it has been introduced for **Objective-C compatibility only**
/// (mainly for integration in legacy view controllers). Each bridge should be removed once the underlying Coordinator has been integrated by another Coordinator.
@objcMembers
final class ComposerCreateActionListBridgePresenter: NSObject {
// MARK: - Constants
// MARK: - Properties
// MARK: Private
private let actions: [ComposerCreateAction]
private var coordinator: ComposerCreateActionListCoordinator?
// MARK: Public
weak var delegate: ComposerCreateActionListBridgePresenterDelegate?
// MARK: - Setup
init(actions: [Int]) {
self.actions = actions.compactMap {
ComposerCreateAction(rawValue: $0)
}
super.init()
}
// MARK: - Public
// NOTE: Default value feature is not compatible with Objective-C.
// func present(from viewController: UIViewController, animated: Bool) {
// self.present(from: viewController, animated: animated)
// }
func present(from viewController: UIViewController, animated: Bool) {
let composerCreateActionListCoordinator = ComposerCreateActionListCoordinator(actions: actions)
composerCreateActionListCoordinator.callback = { [weak self] action in
guard let self = self else { return }
switch action {
case .done(let composeAction):
self.delegate?.composerCreateActionListBridgePresenterDelegateDidComplete(self, action: composeAction)
case .cancel:
self.delegate?.composerCreateActionListBridgePresenterDidDismissInteractively(self)
}
}
let presentable = composerCreateActionListCoordinator.toPresentable()
viewController.present(presentable, animated: animated, completion: nil)
composerCreateActionListCoordinator.start()
coordinator = composerCreateActionListCoordinator
}
func dismiss(animated: Bool, completion: (() -> Void)?) {
guard let coordinator = coordinator else {
return
}
// Dismiss modal
coordinator.toPresentable().dismiss(animated: animated) {
self.coordinator = nil
if let completion = completion {
completion()
}
}
}
}

View file

@ -0,0 +1,71 @@
//
// 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
/// Actions returned by the coordinator callback
enum ComposerCreateActionListCoordinatorAction {
case done(ComposerCreateAction)
case cancel
}
final class ComposerCreateActionListCoordinator: NSObject, Coordinator, Presentable, UISheetPresentationControllerDelegate {
// MARK: - Properties
// MARK: Private
private let hostingController: UIViewController
private var view: ComposerCreateActionList
private var viewModel: ComposerCreateActionListViewModel
// MARK: Public
// Must be used only internally
var childCoordinators: [Coordinator] = []
var callback: ((ComposerCreateActionListCoordinatorAction) -> Void)?
// MARK: - Setup
init(actions: [ComposerCreateAction]) {
viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState(actions: actions))
view = ComposerCreateActionList(viewModel: viewModel.context)
let hostingVC = VectorHostingController(rootView: view)
hostingVC.bottomSheetPreferences = VectorHostingBottomSheetPreferences(detents: [.medium])
hostingController = hostingVC
super.init()
hostingVC.presentationController?.delegate = self
}
// MARK: - Public
func start() {
MXLog.debug("[ComposerCreateActionListCoordinator] did start.")
viewModel.callback = { result in
switch result {
case .done(let action):
self.callback?(.done(action))
}
}
}
func toPresentable() -> UIViewController {
hostingController
}
func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
callback?(.cancel)
}
}

View file

@ -0,0 +1,43 @@
//
// 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 Foundation
import SwiftUI
enum MockComposerCreateActionListScreenState: MockScreenState, CaseIterable {
case partialList
case fullList
var screenType: Any.Type {
ComposerCreateActionList.self
}
var screenView: ([Any], AnyView) {
let actions: [ComposerCreateAction]
switch self {
case .partialList:
actions = [.photoLibrary, .polls]
case .fullList:
actions = ComposerCreateAction.allCases
}
let viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState(actions: actions))
return (
[viewModel],
AnyView(ComposerCreateActionList(viewModel: viewModel.context))
)
}
}

View file

@ -0,0 +1,108 @@
//
// 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 Foundation
// MARK: View model
enum ComposerCreateActionListViewAction {
// The user selected an action
case selectAction(ComposerCreateAction)
}
enum ComposerCreateActionListViewModelResult: Equatable {
// The user selected an action and is done with the screen
case done(ComposerCreateAction)
}
// MARK: View
struct ComposerCreateActionListViewState: BindableState {
/// The list of composer create actions to display to the user
let actions: [ComposerCreateAction]
}
@objc enum ComposerCreateAction: Int {
/// Upload a photo/video from the media library
case photoLibrary
/// Add a sticker
case stickers
/// Upload an attachment
case attachments
/// Create a Poll
case polls
/// Add a location
case location
/// Upload a photo or video from the camera
case camera
}
extension ComposerCreateAction: Equatable, CaseIterable, Identifiable {
var id: Self { self }
}
extension ComposerCreateAction {
var title: String {
switch self {
case .photoLibrary:
return VectorL10n.wysiwygComposerStartActionMediaPicker
case .stickers:
return VectorL10n.wysiwygComposerStartActionStickers
case .attachments:
return VectorL10n.wysiwygComposerStartActionAttachments
case .polls:
return VectorL10n.wysiwygComposerStartActionPolls
case .location:
return VectorL10n.wysiwygComposerStartActionLocation
case .camera:
return VectorL10n.wysiwygComposerStartActionCamera
}
}
var accessibilityIdentifier: String {
switch self {
case .photoLibrary:
return "photoLibraryAction"
case .stickers:
return "stickersAction"
case .attachments:
return "attachmentsAction"
case .polls:
return "pollsAction"
case .location:
return "locationAction"
case .camera:
return "cameraAction"
}
}
var icon: String {
switch self {
case .photoLibrary:
return Asset.Images.actionMediaLibrary.name
case .stickers:
return Asset.Images.actionSticker.name
case .attachments:
return Asset.Images.actionFile.name
case .polls:
return Asset.Images.actionPoll.name
case .location:
return Asset.Images.actionLocation.name
case .camera:
return Asset.Images.actionCamera.name
}
}
}

View file

@ -0,0 +1,34 @@
//
// 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 RiotSwiftUI
import XCTest
class ComposerCreateActionListUITests: MockScreenTestCase {
func testFullList() throws {
app.goToScreenWithIdentifier(MockComposerCreateActionListScreenState.fullList.title)
XCTAssert(app.staticTexts[ComposerCreateAction.photoLibrary.accessibilityIdentifier].exists)
XCTAssert(app.staticTexts[ComposerCreateAction.location.accessibilityIdentifier].exists)
}
func testPartialList() throws {
app.goToScreenWithIdentifier(MockComposerCreateActionListScreenState.partialList.title)
XCTAssert(app.staticTexts[ComposerCreateAction.photoLibrary.accessibilityIdentifier].exists)
XCTAssertFalse(app.staticTexts[ComposerCreateAction.location.accessibilityIdentifier].exists)
}
}

View file

@ -0,0 +1,41 @@
//
// 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.
//
@testable import RiotSwiftUI
import SwiftUI
import XCTest
class ComposerCreateActionListTests: XCTestCase {
var viewModel: ComposerCreateActionListViewModel!
var context: ComposerCreateActionListViewModel.Context!
override func setUpWithError() throws {
viewModel = ComposerCreateActionListViewModel(initialViewState: ComposerCreateActionListViewState(actions: ComposerCreateAction.allCases))
context = viewModel.context
}
func testSelection() throws {
let actionToSelect: ComposerCreateAction = .attachments
var result: ComposerCreateActionListViewModelResult?
viewModel.callback = { callbackResult in
result = callbackResult
}
viewModel.context.send(viewAction: .selectAction(actionToSelect))
XCTAssertEqual(result, .done(actionToSelect))
}
}

View file

@ -0,0 +1,65 @@
//
// 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 ComposerCreateActionList: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
// MARK: Public
@ObservedObject var viewModel: ComposerCreateActionListViewModel.Context
var body: some View {
VStack {
VStack(alignment: .leading) {
ForEach(viewModel.viewState.actions) { action in
HStack(spacing: 16) {
Image(action.icon)
.renderingMode(.template)
.foregroundColor(theme.colors.accent)
Text(action.title)
.foregroundColor(theme.colors.primaryContent)
.font(theme.fonts.body)
.accessibilityIdentifier(action.accessibilityIdentifier)
Spacer()
}
.contentShape(Rectangle())
.onTapGesture {
viewModel.send(viewAction: .selectAction(action))
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
}
}
.padding(.top, 16)
Spacer()
}.background(theme.colors.background.ignoresSafeArea())
}
}
// MARK: - Previews
struct ComposerCreateActionList_Previews: PreviewProvider {
static let stateRenderer = MockComposerCreateActionListScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
}
}

View file

@ -0,0 +1,22 @@
//
// 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 Foundation
protocol ComposerCreateActionListViewModelProtocol {
var callback: ((ComposerCreateActionListViewModelResult) -> Void)? { get set }
var context: ComposerCreateActionListViewModelType.Context { get }
}

View file

@ -0,0 +1,40 @@
//
// 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
typealias ComposerCreateActionListViewModelType = StateStoreViewModel<ComposerCreateActionListViewState, ComposerCreateActionListViewAction>
class ComposerCreateActionListViewModel: ComposerCreateActionListViewModelType, ComposerCreateActionListViewModelProtocol {
// MARK: - Properties
// MARK: Private
// MARK: Public
var callback: ((ComposerCreateActionListViewModelResult) -> Void)?
// MARK: - Setup
// MARK: - Public
override func process(viewAction: ComposerCreateActionListViewAction) {
switch viewAction {
case .selectAction(let action):
callback?(.done(action))
}
}
}

View file

@ -0,0 +1,65 @@
//
// 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 Foundation
import SwiftUI
import WysiwygComposer
enum MockComposerScreenState: MockScreenState, CaseIterable {
case send
case edit
case reply
var screenType: Any.Type {
Composer.self
}
var screenView: ([Any], AnyView) {
let viewModel: ComposerViewModel
switch self {
case .send: viewModel = ComposerViewModel(initialViewState: ComposerViewState())
case .edit: viewModel = ComposerViewModel(initialViewState: ComposerViewState(sendMode: .edit))
case .reply: viewModel = ComposerViewModel(initialViewState: ComposerViewState(eventSenderDisplayName: "TestUser", sendMode: .reply))
}
let wysiwygviewModel = WysiwygComposerViewModel(minHeight: 20, maxHeight: 360)
viewModel.callback = { [weak viewModel, weak wysiwygviewModel] result in
guard let viewModel = viewModel else { return }
if viewModel.sendMode == .edit {
wysiwygviewModel?.setHtmlContent("")
}
switch result {
case .cancel: viewModel.sendMode = .send
}
}
return (
[viewModel, wysiwygviewModel],
AnyView(VStack {
Spacer()
Composer(viewModel: viewModel.context, wysiwygViewModel: wysiwygviewModel, sendMessageAction: { _ in }, showSendMediaActions: { })
}.frame(
minWidth: 0,
maxWidth: .infinity,
minHeight: 0,
maxHeight: .infinity,
alignment: .topLeading
))
)
}
}

View file

@ -0,0 +1,125 @@
//
// 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 Foundation
import SwiftUI
import WysiwygComposer
// MARK: View
/// An item in the toolbar
struct FormatItem {
/// The type of the item
let type: FormatType
/// Whether it is active(highlighted)
let active: Bool
/// Whether it is disabled or enabled
let disabled: Bool
}
/// The types of formatting actions
enum FormatType {
case bold
case italic
case strikethrough
case underline
}
extension FormatType: CaseIterable, Identifiable {
var id: Self { self }
}
extension FormatItem: Identifiable {
var id: FormatType { type }
}
extension FormatItem {
/// The icon for the item
var icon: String {
switch type {
case .bold:
return Asset.Images.bold.name
case .italic:
return Asset.Images.italic.name
case .strikethrough:
return Asset.Images.strikethrough.name
case .underline:
return Asset.Images.underlined.name
}
}
var accessibilityIdentifier: String {
switch type {
case .bold:
return "boldButton"
case .italic:
return "italicButton"
case .strikethrough:
return "strikethroughButton"
case .underline:
return "underlineButton"
}
}
}
extension FormatType {
/// Convenience method to map it to the external ViewModel action
var action: WysiwygAction {
switch self {
case .bold:
return .bold
case .italic:
return .italic
case .strikethrough:
return .strikeThrough
case .underline:
return .underline
}
}
// TODO: We probably don't need to expose this, clean up.
/// Convenience method to map it to the external rust binging action
var composerAction: ComposerAction {
switch self {
case .bold:
return .bold
case .italic:
return .italic
case .strikethrough:
return .strikeThrough
case .underline:
return .underline
}
}
}
enum ComposerSendMode: Equatable {
case send
case edit
case reply
case createDM
}
enum ComposerViewAction {
case cancel
}
enum ComposerViewModelResult {
case cancel
}

View file

@ -0,0 +1,46 @@
//
// 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 Foundation
struct ComposerViewState: BindableState {
var eventSenderDisplayName: String?
var sendMode: ComposerSendMode = .send
}
extension ComposerViewState {
var shouldDisplayContext: Bool {
return sendMode == .edit || sendMode == .reply
}
var contextDescription: String? {
switch sendMode {
case .reply:
guard let eventSenderDisplayName = eventSenderDisplayName else { return nil }
return VectorL10n.roomMessageReplyingTo(eventSenderDisplayName)
case .edit: return VectorL10n.roomMessageEditing
default: return nil
}
}
var contextImageName: String? {
switch sendMode {
case .edit: return Asset.Images.inputEditIcon.name
case .reply: return Asset.Images.inputReplyIcon.name
default: return nil
}
}
}

View file

@ -0,0 +1,86 @@
//
// 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 RiotSwiftUI
import XCTest
final class ComposerUITests: MockScreenTestCase {
func testSendMode() throws {
app.goToScreenWithIdentifier(MockComposerScreenState.send.title)
XCTAssertFalse(app.buttons["cancelButton"].exists)
let wysiwygTextView = app.textViews.allElementsBoundByIndex[0]
XCTAssertTrue(wysiwygTextView.exists)
let sendButton = app.buttons["sendButton"]
XCTAssertFalse(sendButton.exists)
wysiwygTextView.tap()
wysiwygTextView.typeText("test")
XCTAssertTrue(sendButton.exists)
XCTAssertFalse(app.buttons["editButton"].exists)
}
func testReplyMode() throws {
app.goToScreenWithIdentifier(MockComposerScreenState.reply.title)
let wysiwygTextView = app.textViews.allElementsBoundByIndex[0]
XCTAssertTrue(wysiwygTextView.exists)
let sendButton = app.buttons["sendButton"]
XCTAssertFalse(sendButton.exists)
let cancelButton = app.buttons["cancelButton"]
XCTAssertTrue(cancelButton.exists)
let contextDescription = app.staticTexts["contextDescription"]
XCTAssertTrue(contextDescription.exists)
XCTAssert(contextDescription.label == VectorL10n.roomMessageReplyingTo("TestUser"))
wysiwygTextView.tap()
wysiwygTextView.typeText("test")
XCTAssertTrue(sendButton.exists)
XCTAssertFalse(app.buttons["editButton"].exists)
cancelButton.tap()
let textViewContent = wysiwygTextView.value as! String
XCTAssertFalse(textViewContent.isEmpty)
XCTAssertFalse(cancelButton.exists)
}
func testEditMode() throws {
app.goToScreenWithIdentifier(MockComposerScreenState.edit.title)
let wysiwygTextView = app.textViews.allElementsBoundByIndex[0]
XCTAssertTrue(wysiwygTextView.exists)
let editButton = app.buttons["editButton"]
XCTAssert(!editButton.exists)
let cancelButton = app.buttons["cancelButton"]
XCTAssertTrue(cancelButton.exists)
let contextDescription = app.staticTexts["contextDescription"]
XCTAssertTrue(contextDescription.exists)
XCTAssert(contextDescription.label == VectorL10n.roomMessageEditing)
wysiwygTextView.tap()
wysiwygTextView.typeText("test")
XCTAssertTrue(editButton.exists)
XCTAssertFalse(app.buttons["sendButton"].exists)
cancelButton.tap()
let textViewContent = wysiwygTextView.value as! String
XCTAssertTrue(textViewContent.isEmpty)
XCTAssertFalse(cancelButton.exists)
}
}

View file

@ -0,0 +1,66 @@
//
// 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.
//
@testable import RiotSwiftUI
import SwiftUI
import XCTest
final class ComposerViewModelTests: XCTestCase {
var viewModel: ComposerViewModel!
var context: ComposerViewModel.Context!
override func setUpWithError() throws {
viewModel = ComposerViewModel(initialViewState: ComposerViewState())
context = viewModel.context
}
func testSendState() {
viewModel.sendMode = .send
XCTAssert(context.viewState.sendMode == .send)
XCTAssert(context.viewState.shouldDisplayContext == false)
XCTAssert(context.viewState.eventSenderDisplayName == nil)
XCTAssert(context.viewState.contextImageName == nil)
XCTAssert(context.viewState.contextDescription == nil)
}
func testEditState() {
viewModel.sendMode = .edit
XCTAssert(context.viewState.sendMode == .edit)
XCTAssert(context.viewState.shouldDisplayContext == true)
XCTAssert(context.viewState.eventSenderDisplayName == nil)
XCTAssert(context.viewState.contextImageName == Asset.Images.inputEditIcon.name)
XCTAssert(context.viewState.contextDescription == VectorL10n.roomMessageEditing)
}
func testReplyState() {
viewModel.eventSenderDisplayName = "TestUser"
viewModel.sendMode = .reply
XCTAssert(context.viewState.sendMode == .reply)
XCTAssert(context.viewState.shouldDisplayContext == true)
XCTAssert(context.viewState.eventSenderDisplayName == "TestUser")
XCTAssert(context.viewState.contextImageName == Asset.Images.inputReplyIcon.name)
XCTAssert(context.viewState.contextDescription == VectorL10n.roomMessageReplyingTo("TestUser"))
}
func testCancelTapped() {
var result: ComposerViewModelResult!
viewModel.callback = { value in
result = value
}
context.send(viewAction: .cancel)
XCTAssert(result == .cancel)
}
}

View file

@ -0,0 +1,192 @@
//
// 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 DSBottomSheet
import SwiftUI
import WysiwygComposer
struct Composer: View {
// MARK: - Properties
// MARK: Private
@Environment(\.theme) private var theme: ThemeSwiftUI
@State private var focused = false
@State private var showSendButton = false
private let horizontalPadding: CGFloat = 12
private let borderHeight: CGFloat = 44
private let minTextViewHeight: CGFloat = 20
private var verticalPadding: CGFloat {
(borderHeight - minTextViewHeight) / 2
}
private var topPadding: CGFloat {
viewModel.viewState.shouldDisplayContext ? 0 : verticalPadding
}
private var cornerRadius: CGFloat {
viewModel.viewState.shouldDisplayContext ? 14 : borderHeight / 2
}
private var actionButtonAccessibilityIdentifier: String {
viewModel.viewState.sendMode == .edit ? "editButton" : "sendButton"
}
private var formatItems: [FormatItem] {
FormatType.allCases.map { type in
FormatItem(
type: type,
active: wysiwygViewModel.reversedActions.contains(type.composerAction),
disabled: wysiwygViewModel.disabledActions.contains(type.composerAction)
)
}
}
// MARK: Public
@ObservedObject var viewModel: ComposerViewModelType.Context
@ObservedObject var wysiwygViewModel: WysiwygComposerViewModel
let sendMessageAction: (WysiwygComposerContent) -> Void
let showSendMediaActions: () -> Void
var body: some View {
VStack {
let rect = RoundedRectangle(cornerRadius: cornerRadius)
// TODO: Fix maximise animation bugs before re-enabling
// ZStack(alignment: .topTrailing) {
VStack(spacing: 12) {
if viewModel.viewState.shouldDisplayContext {
HStack {
if let imageName = viewModel.viewState.contextImageName {
Image(imageName)
.foregroundColor(theme.colors.tertiaryContent)
}
if let contextDescription = viewModel.viewState.contextDescription {
Text(contextDescription)
.accessibilityIdentifier("contextDescription")
.font(.system(size: 12, weight: .medium))
.foregroundColor(theme.colors.secondaryContent)
}
Spacer()
Button {
viewModel.send(viewAction: .cancel)
} label: {
Image(Asset.Images.inputCloseIcon.name)
.foregroundColor(theme.colors.tertiaryContent)
}
.accessibilityIdentifier("cancelButton")
}
.padding(.top, 8)
.padding(.horizontal, horizontalPadding)
}
WysiwygComposerView(
content: wysiwygViewModel.content,
replaceText: wysiwygViewModel.replaceText,
select: wysiwygViewModel.select,
didUpdateText: wysiwygViewModel.didUpdateText
)
.textColor(theme.colors.primaryContent)
.frame(height: wysiwygViewModel.idealHeight)
.padding(.horizontal, horizontalPadding)
.onAppear {
wysiwygViewModel.setup()
}
// Button {
// withAnimation(.easeInOut(duration: 0.25)) {
// viewModel.maximised.toggle()
// }
// } label: {
// Image(viewModel.maximised ? Asset.Images.minimiseComposer.name : Asset.Images.maximiseComposer.name)
// .foregroundColor(theme.colors.tertiaryContent)
// }
// .padding(.top, 4)
// .padding(.trailing, 12)
// }
.padding(.top, topPadding)
.padding(.bottom, verticalPadding)
}
.clipShape(rect)
.overlay(rect.stroke(theme.colors.quinaryContent, lineWidth: 2))
.padding(.horizontal, horizontalPadding)
.padding(.top, 8)
.padding(.bottom, 4)
.onTapGesture {
if !focused {
focused = true
}
}
HStack {
Button {
showSendMediaActions()
} label: {
Image(Asset.Images.startComposeModule.name)
.foregroundColor(theme.colors.tertiaryContent)
.padding(11)
.background(Circle().fill(theme.colors.system))
}
FormattingToolbar(formatItems: formatItems) { type in
wysiwygViewModel.apply(type.action)
}
Spacer()
ZStack {
// TODO: Add support for voice messages
// Button {
//
// } label: {
// Image(Asset.Images.voiceMessageRecordButtonDefault.name)
// .foregroundColor(theme.colors.tertiaryContent)
// }
// .isHidden(showSendButton)
// .isHidden(true)
Button {
sendMessageAction(wysiwygViewModel.content)
wysiwygViewModel.clearContent()
} label: {
if viewModel.viewState.sendMode == .edit {
Image(Asset.Images.saveIcon.name)
.foregroundColor(theme.colors.tertiaryContent)
} else {
Image(Asset.Images.sendIcon.name)
.foregroundColor(theme.colors.tertiaryContent)
}
}
.accessibilityIdentifier(actionButtonAccessibilityIdentifier)
.isHidden(!showSendButton)
}
.onChange(of: wysiwygViewModel.isContentEmpty) { empty in
withAnimation(.easeInOut(duration: 0.25)) {
showSendButton = !empty
}
}
}
.padding(.horizontal, 16)
.padding(.bottom, 4)
.animation(.none)
}
}
}
// MARK: Previews
struct Composer_Previews: PreviewProvider {
static let stateRenderer = MockComposerScreenState.stateRenderer
static var previews: some View {
stateRenderer.screenGroup()
}
}

View file

@ -0,0 +1,64 @@
//
// 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
import WysiwygComposer
struct FormattingToolbar: View {
// MARK: - Properties
// MARK: Private
// MARK: Public
@Environment(\.theme) private var theme: ThemeSwiftUI
/// 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 {
HStack {
ForEach(formatItems) { item in
Button {
formatAction(item.type)
} label: {
Image(item.icon)
.renderingMode(.template)
.foregroundColor(item.active ? theme.colors.accent : theme.colors.tertiaryContent)
}
.disabled(item.disabled)
.background(item.active ? theme.colors.accent.opacity(0.1) : theme.colors.background)
.cornerRadius(8)
.accessibilityIdentifier(item.accessibilityIdentifier)
}
}
}
}
// MARK: - Previews
struct FormattingToolbar_Previews: PreviewProvider {
static var previews: some View {
FormattingToolbar(formatItems: [
FormatItem(type: .bold, active: true, disabled: false),
FormatItem(type: .italic, active: false, disabled: false),
FormatItem(type: .strikethrough, active: true, disabled: false),
FormatItem(type: .underline, active: false, disabled: true)
], formatAction: { _ in })
}
}

View file

@ -0,0 +1,56 @@
//
// 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
typealias ComposerViewModelType = StateStoreViewModel<ComposerViewState, ComposerViewAction>
final class ComposerViewModel: ComposerViewModelType, ComposerViewModelProtocol {
// MARK: - Properties
// MARK: Private
// MARK: Public
var callback: ((ComposerViewModelResult) -> Void)?
var sendMode: ComposerSendMode {
get {
state.sendMode
}
set {
state.sendMode = newValue
}
}
var eventSenderDisplayName: String? {
get {
state.eventSenderDisplayName
}
set {
state.eventSenderDisplayName = newValue
}
}
// MARK: - Public
override func process(viewAction: ComposerViewAction) {
switch viewAction {
case .cancel:
callback?(.cancel)
}
}
}

View file

@ -0,0 +1,24 @@
//
// 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 Foundation
protocol ComposerViewModelProtocol {
var context: ComposerViewModelType.Context { get }
var callback: ((ComposerViewModelResult) -> Void)? { get set }
var sendMode: ComposerSendMode { get set }
var eventSenderDisplayName: String? { get set }
}

View file

@ -35,6 +35,7 @@ targets:
dependencies:
- target: DesignKit
- package: Mapbox
- package: WysiwygComposer
sources:
- path: .
excludes:

View file

@ -35,7 +35,8 @@ targets:
dependencies:
- target: RiotSwiftUI
- package: WysiwygComposer
settings:
base:
TEST_TARGET_NAME: RiotSwiftUI

View file

@ -51,6 +51,9 @@ packages:
SwiftOGG:
url: https://github.com/vector-im/swift-ogg
branch: main
WysiwygComposer:
url: https://github.com/matrix-org/matrix-wysiwyg-composer-swift
branch: main
DeviceKit:
url: https://github.com/devicekit/DeviceKit
majorVersion: 4.7.0