Skip to content

Commit def67b2

Browse files
authored
Integrate WYSIWYG editor (#7288)
* Add WYSIWYG lib dependency * Replace EditText with RichTextEditor * Add bold button, fix sending formatting messages issues * Add missing inline formatting buttons, make scrollview horizontal * Disable autocomplete for rich text editor * Add formatted text to messages sent, replies, quotes and edited messages. * Several fixes * Add changelog * Try to fix lint issues * Address review comments. * Exclude Epoxy KSP generated files from ktlint checks
1 parent 2fe636e commit def67b2

36 files changed

+1320
-236
lines changed

build.gradle

+3
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,9 @@ allprojects {
148148
// To have XML report for Danger
149149
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
150150
}
151+
filter {
152+
exclude { element -> element.file.path.contains("$buildDir/generated/") }
153+
}
151154
disabledRules = [
152155
// TODO Re-enable these 4 rules after reformatting project
153156
"indent",

changelog.d/7288.feature

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add WYSIWYG editor.

changelog.d/7288.sdk

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Add `formattedText` or similar optional parameters in several methods:
2+
3+
* RelationService:
4+
* editTextMessage
5+
* editReply
6+
* replyToMessage
7+
* SendService:
8+
* sendQuotedTextMessage
9+
10+
This allows us to send any HTML formatted text message without needing to rely on automatic Markdown > HTML translation. All these new parameters have a `null` value by default, so previous calls to these API methods remain compatible.

dependencies.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ ext.libs = [
102102
],
103103
element : [
104104
'opusencoder' : "io.element.android:opusencoder:1.1.0",
105+
'wysiwyg' : "io.element.android:wysiwyg:0.1.0"
105106
],
106107
squareup : [
107108
'moshi' : "com.squareup.moshi:moshi:$moshi",

dependencies_groups.gradle

+1
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ ext.groups = [
178178
'org.apache.httpcomponents',
179179
'org.apache.sanselan',
180180
'org.bouncycastle',
181+
'org.ccil.cowan.tagsoup',
181182
'org.checkerframework',
182183
'org.codehaus',
183184
'org.codehaus.groovy',

library/ui-strings/src/main/res/values/strings.xml

+3
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,9 @@
446446
<string name="labs_enable_deferred_dm_title">Enable deferred DMs</string>
447447
<string name="labs_enable_deferred_dm_summary">Create DM only on first message</string>
448448

449+
<string name="labs_enable_rich_text_editor_title">Enable rich text editor</string>
450+
<string name="labs_enable_rich_text_editor_summary">Use a rich text editor to send formatted messages</string>
451+
449452
<!-- Home fragment -->
450453
<string name="invitations_header">Invites</string>
451454
<string name="low_priority_header">Low priority</string>

matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationService.kt

+8-2
Original file line numberDiff line numberDiff line change
@@ -91,14 +91,16 @@ interface RelationService {
9191
* Edit a text message body. Limited to "m.text" contentType.
9292
* @param targetEvent The event to edit
9393
* @param msgType the message type
94-
* @param newBodyText The edited body
94+
* @param newBodyText The edited body in plain text
95+
* @param newFormattedBodyText The edited body with format
9596
* @param newBodyAutoMarkdown true to parse markdown on the new body
9697
* @param compatibilityBodyText The text that will appear on clients that don't support yet edition
9798
*/
9899
fun editTextMessage(
99100
targetEvent: TimelineEvent,
100101
msgType: String,
101102
newBodyText: CharSequence,
103+
newFormattedBodyText: CharSequence? = null,
102104
newBodyAutoMarkdown: Boolean,
103105
compatibilityBodyText: String = "* $newBodyText"
104106
): Cancelable
@@ -108,13 +110,15 @@ interface RelationService {
108110
* This method will take the new body (stripped from fallbacks) and re-add them before sending.
109111
* @param replyToEdit The event to edit
110112
* @param originalTimelineEvent the message that this reply (being edited) is relating to
111-
* @param newBodyText The edited body (stripped from in reply to content)
113+
* @param newBodyText The plain text edited body (stripped from in reply to content)
114+
* @param newFormattedBodyText The formatted edited body (stripped from in reply to content)
112115
* @param compatibilityBodyText The text that will appear on clients that don't support yet edition
113116
*/
114117
fun editReply(
115118
replyToEdit: TimelineEvent,
116119
originalTimelineEvent: TimelineEvent,
117120
newBodyText: String,
121+
newFormattedBodyText: String? = null,
118122
compatibilityBodyText: String = "* $newBodyText"
119123
): Cancelable
120124

@@ -133,13 +137,15 @@ interface RelationService {
133137
* by the sdk into pills.
134138
* @param eventReplied the event referenced by the reply
135139
* @param replyText the reply text
140+
* @param replyFormattedText the reply text, formatted
136141
* @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
137142
* @param showInThread If true, relation will be added to the reply in order to be visible from within threads
138143
* @param rootThreadEventId If show in thread is true then we need the rootThreadEventId to generate the relation
139144
*/
140145
fun replyToMessage(
141146
eventReplied: TimelineEvent,
142147
replyText: CharSequence,
148+
replyFormattedText: CharSequence? = null,
143149
autoMarkdown: Boolean = false,
144150
showInThread: Boolean = false,
145151
rootThreadEventId: String? = null

matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt

+9-2
Original file line numberDiff line numberDiff line change
@@ -60,12 +60,19 @@ interface SendService {
6060
/**
6161
* Method to quote an events content.
6262
* @param quotedEvent The event to which we will quote it's content.
63-
* @param text the text message to send
63+
* @param text the plain text message to send
64+
* @param formattedText the formatted text message to send
6465
* @param autoMarkdown If true, the SDK will generate a formatted HTML message from the body text if markdown syntax is present
6566
* @param rootThreadEventId when this param is not null, the message will be sent in this specific thread
6667
* @return a [Cancelable]
6768
*/
68-
fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String? = null): Cancelable
69+
fun sendQuotedTextMessage(
70+
quotedEvent: TimelineEvent,
71+
text: String,
72+
formattedText: String? = null,
73+
autoMarkdown: Boolean,
74+
rootThreadEventId: String? = null
75+
): Cancelable
6976

7077
/**
7178
* Method to send a media asynchronously.

matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt

+11-1
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import org.matrix.android.sdk.api.session.room.model.ReadReceipt
3333
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent
3434
import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
3535
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
36+
import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody
3637
import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
3738
import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
3839
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
@@ -181,7 +182,8 @@ fun TimelineEvent.isRootThread(): Boolean {
181182
* Get the latest message body, after a possible edition, stripping the reply prefix if necessary.
182183
*/
183184
fun TimelineEvent.getTextEditableContent(): String {
184-
val lastContentBody = getLastMessageContent()?.body ?: return ""
185+
val lastMessageContent = getLastMessageContent()
186+
val lastContentBody = lastMessageContent.getFormattedBody() ?: return ""
185187
return if (isReply()) {
186188
extractUsefulTextFromReply(lastContentBody)
187189
} else {
@@ -199,3 +201,11 @@ fun MessageContent.getTextDisplayableContent(): String {
199201
?: (this as MessageTextContent?)?.matrixFormattedBody?.let { ContentUtils.formatSpoilerTextFromHtml(it) }
200202
?: body
201203
}
204+
205+
fun MessageContent?.getFormattedBody(): String? {
206+
return if (this is MessageContentWithFormattedBody) {
207+
formattedBody
208+
} else {
209+
this?.body
210+
}
211+
}

matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/DefaultRelationService.kt

+7-2
Original file line numberDiff line numberDiff line change
@@ -105,19 +105,21 @@ internal class DefaultRelationService @AssistedInject constructor(
105105
targetEvent: TimelineEvent,
106106
msgType: String,
107107
newBodyText: CharSequence,
108+
newFormattedBodyText: CharSequence?,
108109
newBodyAutoMarkdown: Boolean,
109110
compatibilityBodyText: String
110111
): Cancelable {
111-
return eventEditor.editTextMessage(targetEvent, msgType, newBodyText, newBodyAutoMarkdown, compatibilityBodyText)
112+
return eventEditor.editTextMessage(targetEvent, msgType, newBodyText, newFormattedBodyText, newBodyAutoMarkdown, compatibilityBodyText)
112113
}
113114

114115
override fun editReply(
115116
replyToEdit: TimelineEvent,
116117
originalTimelineEvent: TimelineEvent,
117118
newBodyText: String,
119+
newFormattedBodyText: String?,
118120
compatibilityBodyText: String
119121
): Cancelable {
120-
return eventEditor.editReply(replyToEdit, originalTimelineEvent, newBodyText, compatibilityBodyText)
122+
return eventEditor.editReply(replyToEdit, originalTimelineEvent, newBodyText, newFormattedBodyText, compatibilityBodyText)
121123
}
122124

123125
override suspend fun fetchEditHistory(eventId: String): List<Event> {
@@ -127,6 +129,7 @@ internal class DefaultRelationService @AssistedInject constructor(
127129
override fun replyToMessage(
128130
eventReplied: TimelineEvent,
129131
replyText: CharSequence,
132+
replyFormattedText: CharSequence?,
130133
autoMarkdown: Boolean,
131134
showInThread: Boolean,
132135
rootThreadEventId: String?
@@ -135,6 +138,7 @@ internal class DefaultRelationService @AssistedInject constructor(
135138
roomId = roomId,
136139
eventReplied = eventReplied,
137140
replyText = replyText,
141+
replyTextFormatted = replyFormattedText,
138142
autoMarkdown = autoMarkdown,
139143
rootThreadEventId = rootThreadEventId,
140144
showInThread = showInThread
@@ -178,6 +182,7 @@ internal class DefaultRelationService @AssistedInject constructor(
178182
roomId = roomId,
179183
eventReplied = eventReplied,
180184
replyText = replyInThreadText,
185+
replyTextFormatted = formattedText,
181186
autoMarkdown = autoMarkdown,
182187
rootThreadEventId = rootThreadEventId,
183188
showInThread = false

matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/EventEditor.kt

+11-2
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import org.matrix.android.sdk.api.session.room.send.SendState
2323
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
2424
import org.matrix.android.sdk.api.util.Cancelable
2525
import org.matrix.android.sdk.api.util.NoOpCancellable
26+
import org.matrix.android.sdk.api.util.TextContent
2627
import org.matrix.android.sdk.internal.database.mapper.toEntity
2728
import org.matrix.android.sdk.internal.session.room.send.LocalEchoEventFactory
2829
import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository
@@ -42,19 +43,25 @@ internal class EventEditor @Inject constructor(
4243
targetEvent: TimelineEvent,
4344
msgType: String,
4445
newBodyText: CharSequence,
46+
newBodyFormattedText: CharSequence?,
4547
newBodyAutoMarkdown: Boolean,
4648
compatibilityBodyText: String
4749
): Cancelable {
4850
val roomId = targetEvent.roomId
4951
if (targetEvent.root.sendState.hasFailed()) {
5052
// We create a new in memory event for the EventSenderProcessor but we keep the eventId of the failed event.
51-
val editedEvent = eventFactory.createTextEvent(roomId, msgType, newBodyText, newBodyAutoMarkdown).copy(
53+
val editedEvent = if (newBodyFormattedText != null) {
54+
val content = TextContent(newBodyText.toString(), newBodyFormattedText.toString())
55+
eventFactory.createFormattedTextEvent(roomId, content, msgType)
56+
} else {
57+
eventFactory.createTextEvent(roomId, msgType, newBodyText, newBodyAutoMarkdown)
58+
}.copy(
5259
eventId = targetEvent.eventId
5360
)
5461
return sendFailedEvent(targetEvent, editedEvent)
5562
} else if (targetEvent.root.sendState.isSent()) {
5663
val event = eventFactory
57-
.createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyAutoMarkdown, msgType, compatibilityBodyText)
64+
.createReplaceTextEvent(roomId, targetEvent.eventId, newBodyText, newBodyFormattedText, newBodyAutoMarkdown, msgType, compatibilityBodyText)
5865
return sendReplaceEvent(event)
5966
} else {
6067
// Should we throw?
@@ -100,6 +107,7 @@ internal class EventEditor @Inject constructor(
100107
replyToEdit: TimelineEvent,
101108
originalTimelineEvent: TimelineEvent,
102109
newBodyText: String,
110+
newBodyFormattedText: String?,
103111
compatibilityBodyText: String
104112
): Cancelable {
105113
val roomId = replyToEdit.roomId
@@ -109,6 +117,7 @@ internal class EventEditor @Inject constructor(
109117
roomId = roomId,
110118
eventReplied = originalTimelineEvent,
111119
replyText = newBodyText,
120+
replyTextFormatted = newBodyFormattedText,
112121
autoMarkdown = false,
113122
showInThread = false
114123
)?.copy(

matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt

+8-1
Original file line numberDiff line numberDiff line change
@@ -99,11 +99,18 @@ internal class DefaultSendService @AssistedInject constructor(
9999
.let { sendEvent(it) }
100100
}
101101

102-
override fun sendQuotedTextMessage(quotedEvent: TimelineEvent, text: String, autoMarkdown: Boolean, rootThreadEventId: String?): Cancelable {
102+
override fun sendQuotedTextMessage(
103+
quotedEvent: TimelineEvent,
104+
text: String,
105+
formattedText: String?,
106+
autoMarkdown: Boolean,
107+
rootThreadEventId: String?
108+
): Cancelable {
103109
return localEchoEventFactory.createQuotedTextEvent(
104110
roomId = roomId,
105111
quotedEvent = quotedEvent,
106112
text = text,
113+
formattedText = formattedText,
107114
autoMarkdown = autoMarkdown,
108115
rootThreadEventId = rootThreadEventId
109116
)

matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt

+15-8
Original file line numberDiff line numberDiff line change
@@ -124,19 +124,23 @@ internal class LocalEchoEventFactory @Inject constructor(
124124
roomId: String,
125125
targetEventId: String,
126126
newBodyText: CharSequence,
127+
newBodyFormattedText: CharSequence?,
127128
newBodyAutoMarkdown: Boolean,
128129
msgType: String,
129130
compatibilityText: String
130131
): Event {
132+
val content = if (newBodyFormattedText != null) {
133+
TextContent(newBodyText.toString(), newBodyFormattedText.toString()).toMessageTextContent(msgType)
134+
} else {
135+
createTextContent(newBodyText, newBodyAutoMarkdown).toMessageTextContent(msgType)
136+
}.toContent()
131137
return createMessageEvent(
132138
roomId,
133139
MessageTextContent(
134140
msgType = msgType,
135141
body = compatibilityText,
136142
relatesTo = RelationDefaultContent(RelationType.REPLACE, targetEventId),
137-
newContent = createTextContent(newBodyText, newBodyAutoMarkdown)
138-
.toMessageTextContent(msgType)
139-
.toContent()
143+
newContent = content,
140144
)
141145
)
142146
}
@@ -581,6 +585,7 @@ internal class LocalEchoEventFactory @Inject constructor(
581585
roomId: String,
582586
eventReplied: TimelineEvent,
583587
replyText: CharSequence,
588+
replyTextFormatted: CharSequence?,
584589
autoMarkdown: Boolean,
585590
rootThreadEventId: String? = null,
586591
showInThread: Boolean
@@ -594,15 +599,15 @@ internal class LocalEchoEventFactory @Inject constructor(
594599
val body = bodyForReply(eventReplied.getLastMessageContent(), eventReplied.isReply())
595600

596601
// As we always supply formatted body for replies we should force the MarkdownParser to produce html.
597-
val replyTextFormatted = markdownParser.parse(replyText, force = true, advanced = autoMarkdown).takeFormatted()
602+
val finalReplyTextFormatted = replyTextFormatted?.toString() ?: markdownParser.parse(replyText, force = true, advanced = autoMarkdown).takeFormatted()
598603
// Body of the original message may not have formatted version, so may also have to convert to html.
599604
val bodyFormatted = body.formattedText ?: markdownParser.parse(body.text, force = true, advanced = autoMarkdown).takeFormatted()
600605
val replyFormatted = buildFormattedReply(
601606
permalink,
602607
userLink,
603608
userId,
604609
bodyFormatted,
605-
replyTextFormatted
610+
finalReplyTextFormatted
606611
)
607612
//
608613
// > <@alice:example.org> This is the original body
@@ -765,18 +770,20 @@ internal class LocalEchoEventFactory @Inject constructor(
765770
roomId: String,
766771
quotedEvent: TimelineEvent,
767772
text: String,
773+
formattedText: String?,
768774
autoMarkdown: Boolean,
769775
rootThreadEventId: String?
770776
): Event {
771777
val messageContent = quotedEvent.getLastMessageContent()
772-
val textMsg = messageContent?.body
778+
val textMsg = if (messageContent is MessageContentWithFormattedBody) { messageContent.formattedBody } else { messageContent?.body }
773779
val quoteText = legacyRiotQuoteText(textMsg, text)
780+
val quoteFormattedText = "<blockquote>$textMsg</blockquote>$formattedText"
774781

775782
return if (rootThreadEventId != null) {
776783
createMessageEvent(
777784
roomId,
778785
markdownParser
779-
.parse(quoteText, force = true, advanced = autoMarkdown)
786+
.parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText)
780787
.toThreadTextContent(
781788
rootThreadEventId = rootThreadEventId,
782789
latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId),
@@ -786,7 +793,7 @@ internal class LocalEchoEventFactory @Inject constructor(
786793
} else {
787794
createFormattedTextEvent(
788795
roomId,
789-
markdownParser.parse(quoteText, force = true, advanced = autoMarkdown),
796+
markdownParser.parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText),
790797
MessageType.MSGTYPE_TEXT
791798
)
792799
}

vector-config/src/main/res/values/config-settings.xml

+2
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@
4343
<bool name="settings_labs_new_app_layout_default">true</bool>
4444
<bool name="settings_timeline_show_live_sender_info_visible">true</bool>
4545
<bool name="settings_timeline_show_live_sender_info_default">false</bool>
46+
<bool name="settings_labs_rich_text_editor_visible">true</bool>
47+
<bool name="settings_labs_rich_text_editor_default">false</bool>
4648
<!-- Level 1: Advanced settings -->
4749

4850
<!-- Level 1: Help and about -->

vector/build.gradle

+4
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,7 @@ android {
104104
}
105105
}
106106
dependencies {
107+
107108
implementation project(":vector-config")
108109
api project(":matrix-sdk-android")
109110
implementation project(":matrix-sdk-android-flow")
@@ -143,6 +144,9 @@ dependencies {
143144
// Opus Encoder
144145
implementation libs.element.opusencoder
145146

147+
// WYSIWYG Editor
148+
implementation libs.element.wysiwyg
149+
146150
// Log
147151
api libs.jakewharton.timber
148152

0 commit comments

Comments
 (0)