Browse Source

Use RTE `TextView` for timeline text messages, add mention pills to messages (#1990)

* Add `formattedBody` to `TimelineItemTextBasedContent`.

This is pre-computed when timeline events are being mapped from the Rust SDK.

* Update `HtmlConverterProvider` styles.

* Improve `MentionSpan` to add missing `@` or `#` if needed

* Replace `HtmlDocument` with the `TextView` based component

* Improve extra padding calculation for timestamp by rounding the float offset result instead of truncating it.

* Remove composer line height workaround

* Use `ElementRichTextEditorStyle` instead of `RichTextEditorDefaults` for the theming

* Use slightly different styles for composer and messages (top/bottom line height discrepancies, mostly).

* Add `formattedBody` to notice and emote events.

---------

Co-authored-by: ElementBot <benoitm+elementbot@element.io>
pull/2017/head
Jorge Martin Espinosa 9 months ago committed by GitHub
parent
commit
1e86d8279b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      changelog.d/1433.feature
  2. 2
      features/messages/api/build.gradle.kts
  3. 29
      features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt
  4. 3
      features/messages/impl/build.gradle.kts
  5. 4
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
  6. 80
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt
  7. 26
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
  8. 4
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt
  9. 3
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ExtraPadding.kt
  10. 9
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt
  11. 62
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt
  12. 56
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/DocumentProvider.kt
  13. 635
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt
  14. 53
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
  15. 1
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt
  16. 23
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
  17. 1
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt
  18. 1
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt
  19. 3
      features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt
  20. 2
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt
  21. 14
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt
  22. 2
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/aMessageEvent.kt
  23. 2
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/timelineItemsFactory.kt
  24. 57
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt
  25. 89
      features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt
  26. 3
      features/messages/test/build.gradle.kts
  27. 38
      features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt
  28. 1
      gradle/libs.versions.toml
  29. 44
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ElementRichTextEditorStyle.kt
  30. 4
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt
  31. 47
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt
  32. 22
      libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt
  33. 11
      libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt
  34. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Day-42_42_null_0,NEXUS_5,1.0,en].png
  35. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Day-42_42_null_1,NEXUS_5,1.0,en].png
  36. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Day-42_42_null_2,NEXUS_5,1.0,en].png
  37. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Day-42_42_null_3,NEXUS_5,1.0,en].png
  38. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Day-42_42_null_4,NEXUS_5,1.0,en].png
  39. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Day-42_42_null_5,NEXUS_5,1.0,en].png
  40. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Night-42_43_null_0,NEXUS_5,1.0,en].png
  41. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Night-42_43_null_1,NEXUS_5,1.0,en].png
  42. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Night-42_43_null_2,NEXUS_5,1.0,en].png
  43. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Night-42_43_null_3,NEXUS_5,1.0,en].png
  44. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Night-42_43_null_4,NEXUS_5,1.0,en].png
  45. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Night-42_43_null_5,NEXUS_5,1.0,en].png
  46. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_0,NEXUS_5,1.0,en].png
  47. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_1,NEXUS_5,1.0,en].png
  48. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_10,NEXUS_5,1.0,en].png
  49. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_11,NEXUS_5,1.0,en].png
  50. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_12,NEXUS_5,1.0,en].png
  51. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_13,NEXUS_5,1.0,en].png
  52. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_14,NEXUS_5,1.0,en].png
  53. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_15,NEXUS_5,1.0,en].png
  54. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_16,NEXUS_5,1.0,en].png
  55. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_17,NEXUS_5,1.0,en].png
  56. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_18,NEXUS_5,1.0,en].png
  57. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_19,NEXUS_5,1.0,en].png
  58. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_2,NEXUS_5,1.0,en].png
  59. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_20,NEXUS_5,1.0,en].png
  60. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_21,NEXUS_5,1.0,en].png
  61. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_3,NEXUS_5,1.0,en].png
  62. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_4,NEXUS_5,1.0,en].png
  63. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_5,NEXUS_5,1.0,en].png
  64. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_6,NEXUS_5,1.0,en].png
  65. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_7,NEXUS_5,1.0,en].png
  66. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_8,NEXUS_5,1.0,en].png
  67. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_9,NEXUS_5,1.0,en].png
  68. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_0,NEXUS_5,1.0,en].png
  69. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_1,NEXUS_5,1.0,en].png
  70. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_10,NEXUS_5,1.0,en].png
  71. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_11,NEXUS_5,1.0,en].png
  72. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_12,NEXUS_5,1.0,en].png
  73. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_13,NEXUS_5,1.0,en].png
  74. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_14,NEXUS_5,1.0,en].png
  75. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_15,NEXUS_5,1.0,en].png
  76. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_16,NEXUS_5,1.0,en].png
  77. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_17,NEXUS_5,1.0,en].png
  78. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_18,NEXUS_5,1.0,en].png
  79. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_19,NEXUS_5,1.0,en].png
  80. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_2,NEXUS_5,1.0,en].png
  81. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_20,NEXUS_5,1.0,en].png
  82. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_21,NEXUS_5,1.0,en].png
  83. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_3,NEXUS_5,1.0,en].png
  84. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_4,NEXUS_5,1.0,en].png
  85. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_5,NEXUS_5,1.0,en].png
  86. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_6,NEXUS_5,1.0,en].png
  87. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_7,NEXUS_5,1.0,en].png
  88. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_8,NEXUS_5,1.0,en].png
  89. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_9,NEXUS_5,1.0,en].png
  90. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.reactionsummary_SheetContent_null_SheetContent-Day-49_49_null_0,NEXUS_5,1.0,en].png
  91. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.reactionsummary_SheetContent_null_SheetContent-Night-49_50_null_0,NEXUS_5,1.0,en].png
  92. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-51_51_null_0,NEXUS_5,1.0,en].png
  93. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-51_51_null_1,NEXUS_5,1.0,en].png
  94. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-51_51_null_2,NEXUS_5,1.0,en].png
  95. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-51_51_null_3,NEXUS_5,1.0,en].png
  96. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-51_51_null_4,NEXUS_5,1.0,en].png
  97. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-51_51_null_5,NEXUS_5,1.0,en].png
  98. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Night-51_52_null_0,NEXUS_5,1.0,en].png
  99. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Night-51_52_null_1,NEXUS_5,1.0,en].png
  100. 0
      tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Night-51_52_null_2,NEXUS_5,1.0,en].png
  101. Some files were not shown because too many files have changed in this diff Show More

3
changelog.d/1433.feature

@ -0,0 +1,3 @@ @@ -0,0 +1,3 @@
Use the RTE library `TextView` to render text events in the timeline.
Add support for mention pills - with no interaction yet.

2
features/messages/api/build.gradle.kts

@ -15,7 +15,7 @@ @@ -15,7 +15,7 @@
*/
plugins {
id("io.element.android-library")
id("io.element.android-compose-library")
}
android {

29
features/messages/api/src/main/kotlin/io/element/android/features/messages/api/timeline/HtmlConverterProvider.kt

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.api.timeline
import androidx.compose.runtime.Composable
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.wysiwyg.utils.HtmlConverter
interface HtmlConverterProvider {
@Composable
fun Update(currentUserId: UserId)
fun provide(): HtmlConverter
}

3
features/messages/impl/build.gradle.kts

@ -73,6 +73,7 @@ dependencies { @@ -73,6 +73,7 @@ dependencies {
implementation(libs.vanniktech.blurhash)
implementation(libs.telephoto.zoomableimage)
implementation(libs.matrix.emojibase.bindings)
api(libs.matrix.richtexteditor.compose)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
@ -97,6 +98,8 @@ dependencies { @@ -97,6 +98,8 @@ dependencies {
testImplementation(libs.test.mockk)
testImplementation(libs.test.junitext)
testImplementation(libs.test.robolectric)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.showkase.processor)
}

4
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt

@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue @@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListPresenter
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
@ -107,6 +108,7 @@ class MessagesPresenter @AssistedInject constructor( @@ -107,6 +108,7 @@ class MessagesPresenter @AssistedInject constructor(
private val clipboardHelper: ClipboardHelper,
private val preferencesStore: PreferencesStore,
private val featureFlagsService: FeatureFlagService,
private val htmlConverterProvider: HtmlConverterProvider,
@Assisted private val navigator: MessagesNavigator,
private val buildMeta: BuildMeta,
private val currentSessionIdHolder: CurrentSessionIdHolder,
@ -121,6 +123,8 @@ class MessagesPresenter @AssistedInject constructor( @@ -121,6 +123,8 @@ class MessagesPresenter @AssistedInject constructor(
@Composable
override fun present(): MessagesState {
htmlConverterProvider.Update(currentUserId = currentSessionIdHolder.current)
val roomInfo by room.roomInfoFlow.collectAsState(null)
val localCoroutineScope = rememberCoroutineScope()
val composerState = composerPresenter.present()

80
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProvider.kt

@ -0,0 +1,80 @@ @@ -0,0 +1,80 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalInspectionMode
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider
import io.element.android.wysiwyg.compose.StyledHtmlConverter
import io.element.android.wysiwyg.display.MentionDisplayHandler
import io.element.android.wysiwyg.display.TextDisplay
import io.element.android.wysiwyg.utils.HtmlConverter
import uniffi.wysiwyg_composer.newMentionDetector
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
@SingleIn(SessionScope::class)
class DefaultHtmlConverterProvider @Inject constructor(): HtmlConverterProvider {
private val htmlConverter: MutableState<HtmlConverter?> = mutableStateOf(null)
@Composable
override fun Update(currentUserId: UserId) {
val isInEditMode = LocalInspectionMode.current
val mentionDetector = remember(isInEditMode) {
if (isInEditMode) { null } else { newMentionDetector() }
}
val editorStyle = ElementRichTextEditorStyle.textStyle()
val mentionSpanProvider = rememberMentionSpanProvider(currentUserId = currentUserId)
val context = LocalContext.current
htmlConverter.value = remember(editorStyle, mentionSpanProvider) {
StyledHtmlConverter(
context = context,
mentionDisplayHandler = object : MentionDisplayHandler {
override fun resolveAtRoomMentionDisplay(): TextDisplay {
return TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text = "@room", url = "#"))
}
override fun resolveMentionDisplay(text: String, url: String): TextDisplay {
return TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url))
}
},
isMention = { _, url -> mentionDetector?.isMention(url).orFalse() }
).apply {
configureWith(editorStyle)
}
}
}
override fun provide(): HtmlConverter {
return htmlConverter.value ?: error("HtmlConverter wasn't instantiated. Make sure to call HtmlConverterProvider.Update() first.")
}
}

26
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt

@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.timeline.components
import android.annotation.SuppressLint
import android.net.Uri
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
@ -48,6 +49,7 @@ import androidx.compose.ui.Modifier @@ -48,6 +49,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalViewConfiguration
import androidx.compose.ui.platform.ViewConfiguration
import androidx.compose.ui.res.stringResource
@ -79,6 +81,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt @@ -79,6 +81,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.metadata
import io.element.android.libraries.androidutils.system.openUrlInExternalApp
import io.element.android.libraries.designsystem.colors.AvatarColorsProvider
import io.element.android.libraries.designsystem.components.EqualWidthColumn
import io.element.android.libraries.designsystem.components.avatar.Avatar
@ -93,9 +96,12 @@ import io.element.android.libraries.designsystem.theme.components.Text @@ -93,9 +96,12 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.math.abs
import kotlin.math.roundToInt
@ -305,8 +311,6 @@ private fun TimelineItemEventRowContent( @@ -305,8 +311,6 @@ private fun TimelineItemEventRowContent(
) {
MessageEventBubbleContent(
event = event,
interactionSource = interactionSource,
onMessageClick = onClick,
onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClicked,
onTimestampClicked = {
@ -380,8 +384,6 @@ private fun MessageSenderInformation( @@ -380,8 +384,6 @@ private fun MessageSenderInformation(
@Composable
private fun MessageEventBubbleContent(
event: TimelineItem.Event,
interactionSource: MutableInteractionSource,
onMessageClick: () -> Unit,
onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit,
@ -473,6 +475,7 @@ private fun MessageEventBubbleContent( @@ -473,6 +475,7 @@ private fun MessageEventBubbleContent(
inReplyToDetails: InReplyToDetails?,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val timestampLayoutModifier: Modifier
val contentModifier: Modifier
when {
@ -508,9 +511,18 @@ private fun MessageEventBubbleContent( @@ -508,9 +511,18 @@ private fun MessageEventBubbleContent(
content = event.content,
isMine = event.isMine,
isEditable = event.isEditable,
interactionSource = interactionSource,
onClick = onMessageClick,
onLongClick = onMessageLongClick,
onLinkClicked = { url ->
Timber.d("Clicked on: $url")
when (PermalinkParser.parse(Uri.parse(url))) {
is PermalinkData.UserLink -> {
// TODO open member details
}
is PermalinkData.FallbackLink -> {
context.openUrlInExternalApp(url)
}
else -> Unit // TODO handle other types of links, as room ones
}
},
extraPadding = event.toExtraPadding(),
eventSink = eventSink,
modifier = contentModifier,

4
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt

@ -82,9 +82,7 @@ fun TimelineItemStateEventRow( @@ -82,9 +82,7 @@ fun TimelineItemStateEventRow(
content = event.content,
isMine = event.isMine,
isEditable = event.isEditable,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
onLinkClicked = {},
extraPadding = noExtraPadding,
eventSink = eventSink,
modifier = Modifier.defaultTimelineContentPadding()

3
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/ExtraPadding.kt

@ -29,6 +29,7 @@ import io.element.android.libraries.designsystem.text.toDp @@ -29,6 +29,7 @@ import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.math.roundToInt
// Allow to not overlap the timestamp with the text, in the message bubble.
// Compute the size of the worst case.
@ -69,7 +70,7 @@ fun TimelineItem.Event.toExtraPadding(): ExtraPadding { @@ -69,7 +70,7 @@ fun TimelineItem.Event.toExtraPadding(): ExtraPadding {
fun ExtraPadding.getStr(fontSize: TextUnit): String {
if (nbChars == 0) return ""
val timestampFontSize = ElementTheme.typography.fontBodyXsRegular.fontSize // 11.sp
val nbOfSpaces = (timestampFontSize.value / fontSize.value * nbChars).toInt() + 1
val nbOfSpaces = (timestampFontSize.value / fontSize.value * nbChars).roundToInt() + 1
// A space and some unbreakable spaces
return " " + "\u00A0".repeat(nbOfSpaces)
}

9
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt

@ -16,7 +16,6 @@ @@ -16,7 +16,6 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.TimelineEvents
@ -43,10 +42,8 @@ fun TimelineItemEventContentView( @@ -43,10 +42,8 @@ fun TimelineItemEventContentView(
content: TimelineItemEventContent,
isMine: Boolean,
isEditable: Boolean,
interactionSource: MutableInteractionSource,
extraPadding: ExtraPadding,
onClick: () -> Unit,
onLongClick: () -> Unit,
onLinkClicked: (url: String) -> Unit,
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier
) {
@ -65,10 +62,8 @@ fun TimelineItemEventContentView( @@ -65,10 +62,8 @@ fun TimelineItemEventContentView(
is TimelineItemTextBasedContent -> TimelineItemTextView(
content = content,
extraPadding = extraPadding,
interactionSource = interactionSource,
modifier = modifier,
onTextClicked = onClick,
onTextLongClicked = onLongClick
onLinkClicked = onLinkClicked,
)
is TimelineItemUnknownContent -> TimelineItemUnknownView(
content = content,

62
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt

@ -16,56 +16,52 @@ @@ -16,56 +16,52 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.interaction.MutableInteractionSource
import android.text.SpannableString
import androidx.compose.foundation.layout.Box
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.components.html.HtmlDocument
import androidx.core.text.buildSpannedString
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContentProvider
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.text.toAnnotatedString
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle
import io.element.android.wysiwyg.compose.EditorStyledText
@Composable
fun TimelineItemTextView(
content: TimelineItemTextBasedContent,
interactionSource: MutableInteractionSource,
extraPadding: ExtraPadding,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
onLinkClicked: (String) -> Unit,
modifier: Modifier = Modifier,
) {
CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textPrimary) {
val htmlDocument = content.htmlDocument
if (htmlDocument != null) {
HtmlDocument(
document = htmlDocument,
extraPadding = extraPadding,
modifier = modifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
} else {
Box(modifier) {
val textWithPadding = remember(content.body) {
content.body + extraPadding.getStr(16.sp).toAnnotatedString()
CompositionLocalProvider(
LocalContentColor provides ElementTheme.colors.textPrimary,
LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular
) {
val fontSize = LocalTextStyle.current.fontSize
val formattedBody = content.formattedBody
val body = SpannableString(formattedBody ?: content.body)
Box(modifier) {
val textWithPadding = remember(body, fontSize) {
buildSpannedString {
append(body)
append(extraPadding.getStr(fontSize))
}
ClickableLinkText(
text = textWithPadding,
onClick = onTextClicked,
onLongClick = onTextLongClicked,
interactionSource = interactionSource
)
}
EditorStyledText(
text = textWithPadding,
onLinkClickedListener = onLinkClicked,
style = ElementRichTextEditorStyle.textStyle(),
)
}
}
}
@ -77,9 +73,7 @@ internal fun TimelineItemTextViewPreview( @@ -77,9 +73,7 @@ internal fun TimelineItemTextViewPreview(
) = ElementPreview {
TimelineItemTextView(
content = content,
interactionSource = remember { MutableInteractionSource() },
extraPadding = ExtraPadding(nbChars = 8),
onTextClicked = {},
onTextLongClicked = {},
onLinkClicked = {},
)
}

56
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/DocumentProvider.kt

@ -1,56 +0,0 @@ @@ -1,56 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline.components.html
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import org.jsoup.Jsoup
import org.jsoup.nodes.Document
open class DocumentProvider : PreviewParameterProvider<Document> {
override val values: Sequence<Document>
get() = sequenceOf(
"text",
"<strong>Strong</strong>",
"<b>Bold</b>",
"<i>Italic</i>",
// FIXME This does not work
"<b><i>Bold then italic</i></b>",
// FIXME This does not work
"<i><b>Italic then bold</b></i>",
"<em>em</em>",
"<unknown>unknown</unknown>",
// FIXME `br` is not rendered correctly in the Preview.
"Line 1<br/>Line 2",
"<code>code</code>",
"<del>del</del>",
"<h1>Heading 1</h1><h2>Heading 2</h2><h3>Heading 3</h3><h4>Heading 4</h4><h5>Heading 5</h5><h6>Heading 6</h6><h7>Heading 7</h7>",
"<a href=\"https://matrix.org\">link</a>",
"<p>paragraph</p>",
"<p>paragraph 1</p><p>paragraph 2</p>",
"<ol><li>ol item 1</li><li>ol item 2</li></ol>",
"<ol><li><i>ol item 1 italic</i></li><li><b>ol item 2 bold</b></li></ol>",
"<ul><li>ul item 1</li><li>ul item 2</li></ul>",
"<blockquote>blockquote</blockquote>",
// TODO Find a way to make is work with `pre`. For now there is an error with
// jsoup: java.lang.NoSuchMethodError: 'org.jsoup.nodes.Element org.jsoup.nodes.Element.firstElementChild()'
// "<pre>pre</pre>",
"<mx-reply><blockquote><a href=\\\"https://matrix.to/#/!roomId/\$eventId?via=matrix.org\\\">In reply to</a> " +
"<a href=\\\"https://matrix.to/#/@alice:matrix.org\\\">@alice:matrix.org</a><br>original message</blockquote></mx-reply>reply",
"<ol><li>Testing <a href='#'>link</a> item.</li><li>And <a href='#'>another</a> item.</li></ol>",
"<ul><li>Testing <a href='#'>link</a> item.</li><li>And <a href='#'>another</a> item.</li></ul>",
).map { Jsoup.parse(it) }
}

635
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/html/HtmlDocument.kt

@ -1,635 +0,0 @@ @@ -1,635 +0,0 @@
/*
* Copyright (c) 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.
*/
@file:OptIn(ExperimentalLayoutApi::class)
package io.element.android.features.messages.impl.timeline.components.html
import androidx.compose.foundation.background
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.features.messages.impl.timeline.components.event.ExtraPadding
import io.element.android.features.messages.impl.timeline.components.event.getDpSize
import io.element.android.features.messages.impl.timeline.components.event.noExtraPadding
import io.element.android.libraries.designsystem.components.ClickableLinkText
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Surface
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.theme.LinkColor
import kotlinx.collections.immutable.persistentMapOf
import org.jsoup.nodes.Document
import org.jsoup.nodes.Element
import org.jsoup.nodes.Node
import org.jsoup.nodes.TextNode
private const val CHIP_ID = "chip"
@Composable
fun HtmlDocument(
document: Document,
extraPadding: ExtraPadding,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
FlowRow(
modifier = modifier,
) {
HtmlBody(
body = document.body(),
interactionSource = interactionSource,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
)
Spacer(
modifier = Modifier.size(
width = extraPadding.getDpSize(),
height = ElementTheme.typography.fontBodyXsRegular.fontSize.toDp() * 1.25f
)
)
}
}
@Composable
private fun HtmlBody(
body: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
@Composable
fun NodesFlowRode(
nodes: Iterator<Node>,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
) = FlowRow(
horizontalArrangement = Arrangement.spacedBy(2.dp, Alignment.Start),
verticalArrangement = Arrangement.spacedBy(8.dp, Alignment.Top),
) {
var sameRow = true
while (sameRow && nodes.hasNext()) {
when (val node = nodes.next()) {
is TextNode -> {
if (!node.isBlank) {
ClickableLinkText(
text = node.text(),
interactionSource = interactionSource,
onClick = onTextClicked,
onLongClick = onTextLongClicked,
)
}
}
is Element -> {
if (node.isInline()) {
HtmlInline(
node,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
} else {
HtmlBlock(
element = node,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
sameRow = false
}
}
else -> continue
}
}
}
Column(modifier = modifier) {
val nodesIterator = body.childNodes().iterator()
while (nodesIterator.hasNext()) {
NodesFlowRode(
nodes = nodesIterator,
interactionSource = interactionSource,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
)
}
}
}
private fun Element.isInline(): Boolean {
return when (tagName().lowercase()) {
"del" -> true
"mx-reply" -> false
else -> !isBlock
}
}
@Composable
private fun HtmlBlock(
element: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val blockModifier = modifier
.padding(top = 4.dp)
when (element.tagName().lowercase()) {
"p" -> HtmlParagraph(
paragraph = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
"h1", "h2", "h3", "h4", "h5", "h6" -> HtmlHeading(
heading = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
"ol" -> HtmlOrderedList(
orderedList = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
"ul" -> HtmlUnorderedList(
unorderedList = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
"blockquote" -> HtmlBlockquote(
blockquote = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
"pre" -> HtmlPreformatted(element, blockModifier)
"mx-reply" -> HtmlMxReply(
mxReply = element,
modifier = blockModifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
else -> return
}
}
@Composable
private fun HtmlInline(
element: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier) {
val styledText = buildAnnotatedString {
appendInlineElement(element, MaterialTheme.colorScheme)
}
HtmlText(
text = styledText,
onClick = onTextClicked,
onLongClick = onTextLongClicked,
interactionSource = interactionSource
)
}
}
@Composable
private fun HtmlPreformatted(
pre: Element,
modifier: Modifier = Modifier
) {
val isCode = pre.firstElementChild()?.tagName()?.lowercase() == "code"
val backgroundColor =
if (isCode) MaterialTheme.colorScheme.codeBackground() else Color.Unspecified
Box(
modifier
.background(color = backgroundColor)
.padding(horizontal = 8.dp)
) {
Text(
text = pre.wholeText(),
style = TextStyle(fontFamily = FontFamily.Monospace),
color = MaterialTheme.colorScheme.primary,
)
}
}
@Composable
private fun HtmlParagraph(
paragraph: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(modifier) {
val styledText = buildAnnotatedString {
appendInlineChildrenElements(paragraph.childNodes(), MaterialTheme.colorScheme)
}
HtmlText(
text = styledText, onClick = onTextClicked,
onLongClick = onTextLongClicked, interactionSource = interactionSource
)
}
}
@Composable
private fun HtmlBlockquote(
blockquote: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val color = MaterialTheme.colorScheme.onBackground
Box(
modifier = modifier
.drawBehind {
drawLine(
color = color,
strokeWidth = 2f,
start = Offset(12.dp.value, 0f),
end = Offset(12.dp.value, size.height)
)
}
.padding(start = 8.dp, top = 4.dp, bottom = 4.dp)
) {
val text = buildAnnotatedString {
withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) {
appendInlineChildrenElements(blockquote.childNodes(), MaterialTheme.colorScheme)
}
}
HtmlText(
text = text, onClick = onTextClicked,
onLongClick = onTextLongClicked, interactionSource = interactionSource
)
}
}
@Composable
private fun HtmlHeading(
heading: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val style = when (heading.tagName().lowercase()) {
"h1" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 30.sp)
"h2" -> MaterialTheme.typography.headlineLarge.copy(fontSize = 26.sp)
"h3" -> MaterialTheme.typography.headlineMedium.copy(fontSize = 22.sp)
"h4" -> MaterialTheme.typography.headlineMedium.copy(fontSize = 18.sp)
"h5" -> MaterialTheme.typography.headlineSmall.copy(fontSize = 14.sp)
"h6" -> MaterialTheme.typography.headlineSmall.copy(fontSize = 12.sp)
else -> {
return
}
}
Box(modifier) {
val text = buildAnnotatedString {
appendInlineChildrenElements(heading.childNodes(), MaterialTheme.colorScheme)
}
HtmlText(
text = text,
style = style,
onClick = onTextClicked,
onLongClick = onTextLongClicked,
interactionSource = interactionSource
)
}
}
@Composable
private fun HtmlMxReply(
mxReply: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val blockquote = mxReply.childNodes().firstOrNull() ?: return
val shape = RoundedCornerShape(12.dp)
Surface(
modifier = modifier
.padding(bottom = 4.dp)
.offset(x = (-8).dp),
color = MaterialTheme.colorScheme.background,
shape = shape,
) {
val text = buildAnnotatedString {
for (blockquoteNode in blockquote.childNodes()) {
when (blockquoteNode) {
is TextNode -> {
withStyle(
style = SpanStyle(
fontSize = 12.sp,
color = MaterialTheme.colorScheme.secondary
)
) {
append(blockquoteNode.text())
}
}
is Element -> {
when (blockquoteNode.tagName().lowercase()) {
"br" -> {
append('\n')
}
"a" -> {
append(blockquoteNode.ownText())
}
}
}
}
}
}
HtmlText(
text = text,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
onClick = onTextClicked,
onLongClick = onTextLongClicked,
interactionSource = interactionSource
)
}
}
@Composable
private fun HtmlOrderedList(
orderedList: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val delimiter = "."
HtmlListItems(
list = orderedList,
marker = { index -> "$index$delimiter" },
modifier = modifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
}
@Composable
private fun HtmlUnorderedList(
unorderedList: Element,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
val marker = ""
HtmlListItems(
list = unorderedList,
marker = { marker },
modifier = modifier,
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
}
@Composable
private fun HtmlListItems(
list: Element,
marker: (Int) -> String,
interactionSource: MutableInteractionSource,
onTextClicked: () -> Unit,
onTextLongClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(modifier = modifier) {
for ((index, node) in list.children().withIndex()) {
val areAllChildrenInline = node.childNodes().all { it is TextNode || it is Element && it.isInline() }
if (areAllChildrenInline) {
val text = buildAnnotatedString {
append("${marker(index + 1)} ")
appendInlineChildrenElements(node.childNodes(), MaterialTheme.colorScheme)
}
HtmlText(
text = text,
interactionSource = remember { MutableInteractionSource() },
onClick = onTextClicked,
onLongClick = onTextLongClicked,
)
} else {
for (innerNode in node.childNodes()) {
when (innerNode) {
is TextNode -> {
if (!innerNode.isBlank) {
val text = buildAnnotatedString {
append("${marker(index + 1)} ")
}
HtmlText(
text = text,
onClick = onTextClicked,
onLongClick = onTextLongClicked,
interactionSource = interactionSource
)
}
}
is Element -> HtmlBlock(
element = innerNode,
modifier = Modifier.padding(start = 4.dp),
onTextClicked = onTextClicked,
onTextLongClicked = onTextLongClicked,
interactionSource = interactionSource
)
}
}
}
}
}
}
private fun ColorScheme.codeBackground(): Color {
return background.copy(alpha = 0.3f)
}
private fun AnnotatedString.Builder.appendInlineChildrenElements(
childNodes: List<Node>,
colors: ColorScheme
) {
for (node in childNodes) {
when (node) {
is TextNode -> {
append(node.text())
}
is Element -> {
appendInlineElement(node, colors)
}
}
}
}
private fun AnnotatedString.Builder.appendInlineElement(element: Element, colors: ColorScheme) {
when (element.tagName().lowercase()) {
"br" -> {
append('\n')
}
"code" -> {
withStyle(
style = TextStyle(
fontFamily = FontFamily.Monospace,
background = colors.codeBackground()
).toSpanStyle()
) {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
"del" -> {
withStyle(style = SpanStyle(textDecoration = TextDecoration.LineThrough)) {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
"i",
"em" -> {
withStyle(style = SpanStyle(fontStyle = FontStyle.Italic)) {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
"strong" -> {
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
"a" -> {
appendLink(element)
}
else -> {
appendInlineChildrenElements(element.childNodes(), colors)
}
}
}
private fun AnnotatedString.Builder.appendLink(link: Element) {
val uriString = link.attr("href")
val permalinkData = PermalinkParser.parse(uriString)
when (permalinkData) {
is PermalinkData.FallbackLink -> {
pushStringAnnotation(tag = "URL", annotation = permalinkData.uri.toString())
withStyle(
style = SpanStyle(color = LinkColor)
) {
append(link.ownText())
}
pop()
}
is PermalinkData.RoomEmailInviteLink -> {
safeAppendInlineContent(CHIP_ID, link.ownText())
}
is PermalinkData.RoomLink -> {
safeAppendInlineContent(CHIP_ID, link.ownText())
}
is PermalinkData.UserLink -> {
safeAppendInlineContent(CHIP_ID, link.ownText())
}
}
}
fun AnnotatedString.Builder.safeAppendInlineContent(chipId: String, ownText: String) {
if (ownText.isEmpty()) {
// alternateText cannot be empty and default parameter value is private,
// so just omit the second param here.
appendInlineContent(chipId)
} else {
appendInlineContent(chipId, ownText)
}
}
@Composable
private fun HtmlText(
text: AnnotatedString,
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
style: TextStyle = LocalTextStyle.current,
) {
val inlineContentMap = persistentMapOf<String, InlineTextContent>()
ClickableLinkText(
annotatedString = text,
style = style,
modifier = modifier,
inlineContent = inlineContentMap,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick
)
}
@PreviewsDayNight
@Composable
internal fun HtmlDocumentPreview(@PreviewParameter(DocumentProvider::class) document: Document) = ElementPreview {
HtmlDocument(
document = document,
extraPadding = noExtraPadding,
interactionSource = remember { MutableInteractionSource() },
onTextClicked = {},
onTextLongClicked = {},
)
}

53
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt

@ -16,7 +16,14 @@ @@ -16,7 +16,14 @@
package io.element.android.features.messages.impl.timeline.factories.event
import android.text.Spannable
import android.text.style.URLSpan
import android.text.util.Linkify
import androidx.core.text.buildSpannedString
import androidx.core.text.getSpans
import androidx.core.text.util.LinkifyCompat
import io.element.android.features.location.api.Location
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEmoteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@ -35,9 +42,11 @@ import io.element.android.libraries.matrix.api.core.EventId @@ -35,9 +42,11 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
@ -52,8 +61,9 @@ import kotlin.time.Duration @@ -52,8 +61,9 @@ import kotlin.time.Duration
class TimelineItemContentMessageFactory @Inject constructor(
private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor,
private val fileExtensionExtractor: FileExtensionExtractor,
private val featureFlagService: FeatureFlagService,
private val htmlConverterProvider: HtmlConverterProvider,
) {
suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
@ -61,6 +71,7 @@ class TimelineItemContentMessageFactory @Inject constructor( @@ -61,6 +71,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
is EmoteMessageType -> TimelineItemEmoteContent(
body = "* $senderDisplayName ${messageType.body}",
htmlDocument = messageType.formatted?.toHtmlDocument(prefix = "* $senderDisplayName"),
formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisplayName"),
isEdited = content.isEdited,
)
is ImageMessageType -> {
@ -85,6 +96,7 @@ class TimelineItemContentMessageFactory @Inject constructor( @@ -85,6 +96,7 @@ class TimelineItemContentMessageFactory @Inject constructor(
body = messageType.body,
htmlDocument = null,
plainText = messageType.body,
formattedBody = null,
isEdited = content.isEdited,
)
} else {
@ -159,18 +171,21 @@ class TimelineItemContentMessageFactory @Inject constructor( @@ -159,18 +171,21 @@ class TimelineItemContentMessageFactory @Inject constructor(
is NoticeMessageType -> TimelineItemNoticeContent(
body = messageType.body,
htmlDocument = messageType.formatted?.toHtmlDocument(),
formattedBody = parseHtml(messageType.formatted),
isEdited = content.isEdited,
)
is TextMessageType -> {
TimelineItemTextContent(
body = messageType.body,
htmlDocument = messageType.formatted?.toHtmlDocument(),
formattedBody = parseHtml(messageType.formatted),
isEdited = content.isEdited,
)
}
is OtherMessageType -> TimelineItemTextContent(
body = messageType.body,
htmlDocument = null,
formattedBody = null,
isEdited = content.isEdited,
)
}
@ -185,4 +200,40 @@ class TimelineItemContentMessageFactory @Inject constructor( @@ -185,4 +200,40 @@ class TimelineItemContentMessageFactory @Inject constructor(
return result?.takeIf { it.isFinite() }
}
private fun parseHtml(formattedBody: FormattedBody?, prefix: String? = null): CharSequence? {
if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null
val result = htmlConverterProvider.provide()
.fromHtmlToSpans(formattedBody.body)
.withFixedURLSpans()
return if (prefix != null) {
buildSpannedString {
append(prefix)
append(" ")
append(result)
}
} else {
result
}
}
private fun CharSequence.withFixedURLSpans(): CharSequence {
if (this !is Spannable) return this
// Get all URL spans, as they will be removed by LinkifyCompat.addLinks
val oldURLSpans = getSpans<URLSpan>(0, length).associateWith {
val start = getSpanStart(it)
val end = getSpanEnd(it)
Pair(start, end)
}
// Find and set as URLSpans any links present in the text
LinkifyCompat.addLinks(this, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS)
// Restore old spans if they don't conflict with the new ones
for ((urlSpan, location) in oldURLSpans) {
val (start, end) = location
if (getSpans<URLSpan>(start, end).isEmpty()) {
setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
return this
}
}

1
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEmoteContent.kt

@ -23,6 +23,7 @@ data class TimelineItemEmoteContent( @@ -23,6 +23,7 @@ data class TimelineItemEmoteContent(
override val body: String,
override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val formattedBody: CharSequence?,
override val isEdited: Boolean,
) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemEmoteContent"

23
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt

@ -16,9 +16,12 @@ @@ -16,9 +16,12 @@
package io.element.android.features.messages.impl.timeline.model.event
import android.graphics.Typeface
import android.text.style.StyleSpan
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.core.text.buildSpannedString
import androidx.core.text.inSpans
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import org.jsoup.Jsoup
class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEventContent> {
override val values = sequenceOf(
@ -43,19 +46,29 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv @@ -43,19 +46,29 @@ class TimelineItemEventContentProvider : PreviewParameterProvider<TimelineItemEv
}
class TimelineItemTextBasedContentProvider : PreviewParameterProvider<TimelineItemTextBasedContent> {
private fun buildSpanned(text: String) = buildSpannedString {
inSpans(StyleSpan(Typeface.BOLD)) {
append("Rich Text")
}
append(" ")
append(text)
}
override val values = sequenceOf(
aTimelineItemEmoteContent(),
aTimelineItemEmoteContent().copy(htmlDocument = Jsoup.parse("Emote Document")),
aTimelineItemEmoteContent().copy(formattedBody = buildSpanned("Emote")),
aTimelineItemNoticeContent(),
aTimelineItemNoticeContent().copy(htmlDocument = Jsoup.parse("Notice Document")),
aTimelineItemNoticeContent().copy(formattedBody = buildSpanned("Notice")),
aTimelineItemTextContent(),
aTimelineItemTextContent().copy(htmlDocument = Jsoup.parse("Text Document")),
aTimelineItemTextContent().copy(formattedBody = buildSpanned("Text")),
)
}
fun aTimelineItemEmoteContent() = TimelineItemEmoteContent(
body = "Emote",
htmlDocument = null,
formattedBody = null,
isEdited = false,
)
@ -66,6 +79,7 @@ fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent( @@ -66,6 +79,7 @@ fun aTimelineItemEncryptedContent() = TimelineItemEncryptedContent(
fun aTimelineItemNoticeContent() = TimelineItemNoticeContent(
body = "Notice",
htmlDocument = null,
formattedBody = null,
isEdited = false,
)
@ -74,6 +88,7 @@ fun aTimelineItemRedactedContent() = TimelineItemRedactedContent @@ -74,6 +88,7 @@ fun aTimelineItemRedactedContent() = TimelineItemRedactedContent
fun aTimelineItemTextContent() = TimelineItemTextContent(
body = "Text",
htmlDocument = null,
formattedBody = null,
isEdited = false,
)

1
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemNoticeContent.kt

@ -23,6 +23,7 @@ data class TimelineItemNoticeContent( @@ -23,6 +23,7 @@ data class TimelineItemNoticeContent(
override val body: String,
override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val formattedBody: CharSequence?,
override val isEdited: Boolean,
) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemNoticeContent"

1
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextBasedContent.kt

@ -23,6 +23,7 @@ import org.jsoup.nodes.Document @@ -23,6 +23,7 @@ import org.jsoup.nodes.Document
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
val body: String
val htmlDocument: Document?
val formattedBody: CharSequence?
val plainText: String
val isEdited: Boolean
val htmlBody: String?

3
features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemTextContent.kt

@ -23,7 +23,8 @@ data class TimelineItemTextContent( @@ -23,7 +23,8 @@ data class TimelineItemTextContent(
override val body: String,
override val htmlDocument: Document?,
override val plainText: String = htmlDocument?.toPlainText() ?: body,
override val formattedBody: CharSequence?,
override val isEdited: Boolean,
) : TimelineItemTextBasedContent{
) : TimelineItemTextBasedContent {
override val type: String = "TimelineItemTextContent"
}

2
features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt

@ -46,6 +46,7 @@ import io.element.android.features.messages.impl.voicemessages.composer.VoiceMes @@ -46,6 +46,7 @@ import io.element.android.features.messages.impl.voicemessages.composer.VoiceMes
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.messages.impl.voicemessages.timeline.FakeRedactedVoiceMessageManager
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.androidutils.clipboard.FakeClipboardHelper
import io.element.android.libraries.architecture.Async
@ -742,6 +743,7 @@ class MessagesPresenterTest { @@ -742,6 +743,7 @@ class MessagesPresenterTest {
buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers,
currentSessionIdHolder = currentSessionIdHolder,
htmlConverterProvider = FakeHtmlConverterProvider(),
)
}
}

14
features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenterTest.kt

@ -113,7 +113,7 @@ class ActionListPresenterTest { @@ -113,7 +113,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
@ -145,7 +145,7 @@ class ActionListPresenterTest { @@ -145,7 +145,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = false))
// val loadingState = awaitItem()
@ -176,7 +176,7 @@ class ActionListPresenterTest { @@ -176,7 +176,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = false,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = true, canSendMessage = true))
val successState = awaitItem()
@ -207,7 +207,7 @@ class ActionListPresenterTest { @@ -207,7 +207,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
@ -328,7 +328,7 @@ class ActionListPresenterTest { @@ -328,7 +328,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
// val loadingState = awaitItem()
@ -360,7 +360,7 @@ class ActionListPresenterTest { @@ -360,7 +360,7 @@ class ActionListPresenterTest {
val initialState = awaitItem()
val messageEvent = aMessageEvent(
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false)
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null)
)
val redactedEvent = aMessageEvent(
isMine = true,
@ -388,7 +388,7 @@ class ActionListPresenterTest { @@ -388,7 +388,7 @@ class ActionListPresenterTest {
val messageEvent = aMessageEvent(
eventId = null, // No event id, so it's not sent yet
isMine = true,
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
content = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false, formattedBody = null),
)
initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))

2
features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/aMessageEvent.kt

@ -39,7 +39,7 @@ internal fun aMessageEvent( @@ -39,7 +39,7 @@ internal fun aMessageEvent(
eventId: EventId? = AN_EVENT_ID,
isMine: Boolean = true,
isEditable: Boolean = true,
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, isEdited = false),
content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = null, isEdited = false),
inReplyTo: InReplyToDetails? = null,
isThreaded: Boolean = false,
debugInfo: TimelineItemDebugInfo = aTimelineItemDebugInfo(),

2
features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/timelineItemsFactory.kt

@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli @@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.timeline.factories.event.Timeli
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemDaySeparatorFactory
import io.element.android.features.messages.impl.timeline.factories.virtual.TimelineItemVirtualFactory
import io.element.android.features.messages.impl.timeline.groups.TimelineItemGrouper
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.dateformatter.test.FakeDaySeparatorFormatter
import io.element.android.libraries.dateformatter.test.FakeLastMessageTimestampFormatter
@ -54,6 +55,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory { @@ -54,6 +55,7 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = FakeFeatureFlagService(),
htmlConverterProvider = FakeHtmlConverterProvider(),
),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(),

57
features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/DefaultHtmlConverterProviderTest.kt

@ -0,0 +1,57 @@ @@ -0,0 +1,57 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.test.junit4.createComposeRule
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_USER_ID
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultHtmlConverterProviderTest {
@get:Rule val composeTestRule = createComposeRule()
@Test
fun `calling provide without calling Update first should throw an exception`() {
val provider = DefaultHtmlConverterProvider()
val exception = runCatching { provider.provide() }.exceptionOrNull()
assertThat(exception).isInstanceOf(IllegalStateException::class.java)
}
@Test
fun `calling provide after calling Update first should return an HtmlConverter`() {
val provider = DefaultHtmlConverterProvider()
composeTestRule.setContent {
CompositionLocalProvider(LocalInspectionMode provides true) {
provider.Update(currentUserId = A_USER_ID)
}
}
val htmlConverter = runCatching { provider.provide() }.getOrNull()
assertThat(htmlConverter).isNotNull()
}
}

89
features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt

@ -16,6 +16,10 @@ @@ -16,6 +16,10 @@
package io.element.android.features.messages.impl.timeline.factories.event
import android.text.SpannableString
import android.text.SpannableStringBuilder
import android.text.style.URLSpan
import androidx.core.text.inSpans
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.Location
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
@ -27,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt @@ -27,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.test.timeline.FakeHtmlConverterProvider
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -42,10 +47,12 @@ import io.element.android.libraries.matrix.api.media.VideoInfo @@ -42,10 +47,12 @@ import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.InReplyTo
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat
import io.element.android.libraries.matrix.api.timeline.item.event.MessageType
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
@ -59,9 +66,12 @@ import kotlinx.collections.immutable.persistentListOf @@ -59,9 +66,12 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.time.Duration
import kotlin.time.Duration.Companion.minutes
@RunWith(RobolectricTestRunner::class)
class TimelineItemContentMessageFactoryTest {
@Test
@ -77,6 +87,7 @@ class TimelineItemContentMessageFactoryTest { @@ -77,6 +87,7 @@ class TimelineItemContentMessageFactoryTest {
htmlDocument = null,
plainText = "body",
isEdited = false,
formattedBody = null,
)
assertThat(result).isEqualTo(expected)
}
@ -110,6 +121,7 @@ class TimelineItemContentMessageFactoryTest { @@ -110,6 +121,7 @@ class TimelineItemContentMessageFactoryTest {
htmlDocument = null,
plainText = "body",
isEdited = false,
formattedBody = null,
)
assertThat(result).isEqualTo(expected)
}
@ -127,10 +139,53 @@ class TimelineItemContentMessageFactoryTest { @@ -127,10 +139,53 @@ class TimelineItemContentMessageFactoryTest {
htmlDocument = null,
plainText = "body",
isEdited = false,
formattedBody = null,
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `test create TextMessageType with HTML formatted body`() = runTest {
val expected = SpannableStringBuilder().apply {
append("link to ")
inSpans(URLSpan("https://matrix.org")) {
append("https://matrix.org")
}
append(" ")
inSpans(URLSpan("https://matrix.org")) {
append("and manually added link")
}
}
val sut = createTimelineItemContentMessageFactory(
htmlConverterTransform = { expected }
)
val result = sut.create(
content = createMessageContent(type = TextMessageType(
body = "body",
formatted = FormattedBody(MessageFormat.HTML, expected.toString())
)),
senderDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expected)
}
@Test
fun `test create TextMessageType with unknown formatted body does nothing`() = runTest {
val sut = createTimelineItemContentMessageFactory(
htmlConverterTransform = { it }
)
val result = sut.create(
content = createMessageContent(type = TextMessageType(
body = "body",
formatted = FormattedBody(MessageFormat.UNKNOWN, "formatted")
)),
senderDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemTextContent).formattedBody).isNull()
}
@Test
fun `test create VideoMessageType`() = runTest {
val sut = createTimelineItemContentMessageFactory()
@ -455,11 +510,26 @@ class TimelineItemContentMessageFactoryTest { @@ -455,11 +510,26 @@ class TimelineItemContentMessageFactoryTest {
body = "body",
htmlDocument = null,
plainText = "body",
formattedBody = null,
isEdited = false,
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `test create NoticeMessageType with HTML formatted body`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = NoticeMessageType(
body = "body",
formatted = FormattedBody(MessageFormat.HTML, "formatted")
)),
senderDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemNoticeContent).formattedBody).isEqualTo("formatted")
}
@Test
fun `test create EmoteMessageType`() = runTest {
val sut = createTimelineItemContentMessageFactory()
@ -472,11 +542,26 @@ class TimelineItemContentMessageFactoryTest { @@ -472,11 +542,26 @@ class TimelineItemContentMessageFactoryTest {
body = "* Bob body",
htmlDocument = null,
plainText = "* Bob body",
formattedBody = null,
isEdited = false,
)
assertThat(result).isEqualTo(expected)
}
@Test
fun `test create EmoteMessageType with HTML formatted body`() = runTest {
val sut = createTimelineItemContentMessageFactory()
val result = sut.create(
content = createMessageContent(type = EmoteMessageType(
body = "body",
formatted = FormattedBody(MessageFormat.HTML, "formatted")
)),
senderDisplayName = "Bob",
eventId = AN_EVENT_ID,
)
assertThat((result as TimelineItemEmoteContent).formattedBody).isEqualTo(SpannableString("* Bob formatted"))
}
private fun createMessageContent(
body: String = "Body",
inReplyTo: InReplyTo? = null,
@ -494,10 +579,12 @@ class TimelineItemContentMessageFactoryTest { @@ -494,10 +579,12 @@ class TimelineItemContentMessageFactoryTest {
}
private fun createTimelineItemContentMessageFactory(
featureFlagService: FeatureFlagService = FakeFeatureFlagService()
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
htmlConverterTransform: (String) -> CharSequence = { it },
) = TimelineItemContentMessageFactory(
fileSizeFormatter = FakeFileSizeFormatter(),
fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
featureFlagService = featureFlagService,
htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform),
)
}

3
features/messages/test/build.gradle.kts

@ -15,7 +15,7 @@ @@ -15,7 +15,7 @@
*/
plugins {
id("io.element.android-library")
id("io.element.android-compose-library")
}
android {
@ -24,4 +24,5 @@ android { @@ -24,4 +24,5 @@ android {
dependencies {
api(projects.features.messages.api)
implementation(projects.libraries.matrix.api)
}

38
features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt

@ -0,0 +1,38 @@ @@ -0,0 +1,38 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.messages.test.timeline
import androidx.compose.runtime.Composable
import io.element.android.features.messages.api.timeline.HtmlConverterProvider
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.wysiwyg.utils.HtmlConverter
class FakeHtmlConverterProvider(
private val transform: (String) -> CharSequence = { it },
): HtmlConverterProvider {
@Composable
override fun Update(currentUserId: UserId) = Unit
override fun provide(): HtmlConverter {
return object : HtmlConverter {
override fun fromHtmlToSpans(html: String): CharSequence {
return transform(html)
}
}
}
}

1
gradle/libs.versions.toml

@ -96,6 +96,7 @@ androidx_compose_ui = { module = "androidx.compose.ui:ui" } @@ -96,6 +96,7 @@ androidx_compose_ui = { module = "androidx.compose.ui:ui" }
androidx_compose_ui_tooling = { module = "androidx.compose.ui:ui-tooling" }
androidx_compose_ui_tooling_preview = { module = "androidx.compose.ui:ui-tooling-preview" }
androidx_compose_ui_test_manifest = { module = "androidx.compose.ui:ui-test-manifest" }
androidx_compose_ui_test_junit = { module = "androidx.compose.ui:ui-test-junit4-android" }
androidx_compose_material = { module = "androidx.compose.material:material" }
androidx_compose_material_icons = { module = "androidx.compose.material:material-icons-extended" }

44
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ElementRichTextEditorStyle.kt

@ -16,32 +16,52 @@ @@ -16,32 +16,52 @@
package io.element.android.libraries.textcomposer
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.LocalTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.isSpecified
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.theme.bgSubtleTertiary
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.designsystem.theme.bgSubtleTertiary
import io.element.android.wysiwyg.compose.RichTextEditorDefaults
import io.element.android.wysiwyg.compose.RichTextEditorStyle
internal object ElementRichTextEditorStyle {
object ElementRichTextEditorStyle {
@Composable
fun create(
fun composerStyle(
hasFocus: Boolean,
) : RichTextEditorStyle {
val baseStyle = common()
return baseStyle.copy(
text = baseStyle.text.copy(
color = if (hasFocus) {
ElementTheme.materialColors.primary
} else {
ElementTheme.materialColors.secondary
},
lineHeight = TextUnit.Unspecified,
includeFontPadding = true,
)
)
}
@Composable
fun textStyle(): RichTextEditorStyle {
return common()
}
@Composable
private fun common(): RichTextEditorStyle {
val colors = ElementTheme.colors
val m3colors = MaterialTheme.colorScheme
val codeCornerRadius = 4.dp
val codeBorderWidth = 1.dp
return RichTextEditorDefaults.style(
text = RichTextEditorDefaults.textStyle(
color = if (hasFocus) {
m3colors.primary
} else {
m3colors.secondary
},
lineHeight = 16.25.sp,
color = LocalTextStyle.current.color.takeIf { it.isSpecified } ?: LocalContentColor.current,
fontStyle = LocalTextStyle.current.fontStyle,
lineHeight = LocalTextStyle.current.lineHeight,
includeFontPadding = false,
),
cursor = RichTextEditorDefaults.cursorStyle(
color = colors.iconAccentTertiary,

4
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt

@ -441,9 +441,7 @@ private fun TextInput( @@ -441,9 +441,7 @@ private fun TextInput(
modifier = Modifier
.padding(top = 6.dp, bottom = 6.dp)
.fillMaxWidth(),
style = ElementRichTextEditorStyle.create(
hasFocus = state.hasFocus
),
style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus),
resolveMentionDisplay = resolveMentionDisplay,
resolveRoomMentionDisplay = resolveRoomMentionDisplay,
onError = onError

47
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt

@ -23,20 +23,59 @@ import android.text.style.ReplacementSpan @@ -23,20 +23,59 @@ import android.text.style.ReplacementSpan
import kotlin.math.roundToInt
class MentionSpan(
val type: Type,
val backgroundColor: Int,
val textColor: Int,
) : ReplacementSpan() {
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
return paint.measureText(text, start, end).roundToInt() + 40
val mentionText = getActualText(text, start)
var actualEnd = end
if (mentionText != text.toString()) {
actualEnd = end + 1
}
return paint.measureText(mentionText, start, actualEnd).roundToInt() + 40
}
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
val textSize = paint.measureText(text, start, end)
val rect = RectF(x, top.toFloat(), x + textSize + 40, bottom.toFloat())
val mentionText = getActualText(text, start)
var actualEnd = end
if (mentionText != text.toString()) {
actualEnd = end + 1
}
val textWidth = paint.measureText(mentionText, start, actualEnd)
// Extra vertical space to add below the baseline (y). This helps us center the span vertically
val extraVerticalSpace = y + paint.ascent() + paint.descent() - top
val rect = RectF(x, top.toFloat(), x + textWidth + 40, y.toFloat() + extraVerticalSpace)
paint.color = backgroundColor
canvas.drawRoundRect(rect, rect.height() / 2, rect.height() / 2, paint)
paint.color = textColor
canvas.drawText(text!!, start, end, x + 20, y.toFloat(), paint)
canvas.drawText(mentionText, start, actualEnd, x + 20, y.toFloat(), paint)
}
private fun getActualText(text: CharSequence?, start: Int): String {
return when (type) {
Type.USER -> {
val mentionText = text.toString()
if (start in mentionText.indices && mentionText[start] != '@') {
mentionText.replaceRange(start, start, "@")
} else {
mentionText
}
}
Type.ROOM -> {
val mentionText = text.toString()
if (start in mentionText.indices && mentionText[start] != '#') {
mentionText.replaceRange(start, start, "#")
} else {
mentionText
}
}
}
}
enum class Type {
USER,
ROOM,
}
}

22
libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt

@ -60,18 +60,21 @@ class MentionSpanProvider( @@ -60,18 +60,21 @@ class MentionSpanProvider(
permalinkData is PermalinkData.UserLink -> {
val isCurrentUser = permalinkData.userId == currentSessionId.value
MentionSpan(
type = MentionSpan.Type.USER,
backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor,
textColor = if (isCurrentUser) currentUserTextColor else otherTextColor,
)
}
text == "@room" && permalinkData is PermalinkData.FallbackLink -> {
MentionSpan(
type = MentionSpan.Type.USER,
backgroundColor = otherBackgroundColor,
textColor = otherTextColor,
)
}
else -> {
MentionSpan(
type = MentionSpan.Type.ROOM,
backgroundColor = otherBackgroundColor,
textColor = otherTextColor,
)
@ -97,17 +100,26 @@ internal fun MentionSpanPreview() { @@ -97,17 +100,26 @@ internal fun MentionSpanPreview() {
provider.setup()
val textColor = ElementTheme.colors.textPrimary.toArgb()
val mentionSpan = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org")
val mentionSpan2 = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
fun mentionSpanMe() = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org")
fun mentionSpanOther() = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
fun mentionSpanRoom() = provider.getMentionSpanFor("room", "https://matrix.to/#/#room:matrix.org")
AndroidView(factory = { context ->
TextView(context).apply {
includeFontPadding = false
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT)
text = buildSpannedString {
append("This is a ")
append("@mention", mentionSpan, 0)
append("@mention", mentionSpanMe(), 0)
append(" to the current user and this is a ")
append("@mention", mentionSpan2, 0)
append(" to other user")
append("@mention", mentionSpanOther(), 0)
append(" to other user. This one is for a room: ")
append("#room:matrix.org", mentionSpanRoom(), 0)
append("\n\n")
append("This ")
append("mention", mentionSpanMe(), 0)
append(" didn't have an '@' and it was automatically added, same as this ")
append("room:matrix.org", mentionSpanRoom(), 0)
append(" one, which had no leading '#'.")
}
setTextColor(textColor)
}

11
libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt

@ -46,14 +46,21 @@ class MentionSpanProviderTest { @@ -46,14 +46,21 @@ class MentionSpanProviderTest {
@Test
fun `getting mention span for current user should return a MentionSpan with custom colors`() {
val mentionSpan = mentionSpanProvider.getMentionSpanFor("me", "https://matrix.to/#/${currentUserId.value}")
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@me:matrix.org", "https://matrix.to/#/${currentUserId.value}")
assertThat(mentionSpan.backgroundColor).isEqualTo(myUserColor)
assertThat(mentionSpan.textColor).isEqualTo(myUserColor)
}
@Test
fun `getting mention span for other user should return a MentionSpan with normal colors`() {
val mentionSpan = mentionSpanProvider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org")
val mentionSpan = mentionSpanProvider.getMentionSpanFor("@other:matrix.org", "https://matrix.to/#/@other:matrix.org")
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
assertThat(mentionSpan.textColor).isEqualTo(otherColor)
}
@Test
fun `getting mention span for a room should return a MentionSpan with normal colors`() {
val mentionSpan = mentionSpanProvider.getMentionSpanFor("#room:matrix.org", "https://matrix.to/#/#room:matrix.org")
assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor)
assertThat(mentionSpan.textColor).isEqualTo(otherColor)
}

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Day-42_42_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Day-42_42_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Day-42_42_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Day-42_42_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Day-42_42_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Day-42_42_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Night-42_43_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Night-42_43_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Night-42_43_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Night-42_43_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Night-42_43_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.event_TimelineItemTextView_null_TimelineItemTextView-Night-42_43_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_10,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_11,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_12,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_13,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_14,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_15,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_16,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_17,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_18,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_19,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_20,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_21,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_6,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_7,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_8,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Day-49_49_null_9,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_10,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_11,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_12,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_13,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_14,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_15,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_16,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_17,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_18,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_19,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_20,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_21,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_6,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_7,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_8,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.html_HtmlDocument_null_HtmlDocument-Night-49_50_null_9,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.reactionsummary_SheetContent_null_SheetContent-Day-50_50_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.reactionsummary_SheetContent_null_SheetContent-Day-49_49_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.reactionsummary_SheetContent_null_SheetContent-Night-50_51_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.reactionsummary_SheetContent_null_SheetContent-Night-49_50_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-52_52_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-51_51_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-52_52_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-51_51_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-52_52_null_2,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-51_51_null_2,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-52_52_null_3,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-51_51_null_3,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-52_52_null_4,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-51_51_null_4,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-52_52_null_5,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Day-51_51_null_5,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Night-52_53_null_0,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Night-51_52_null_0,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Night-52_53_null_1,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Night-51_52_null_1,NEXUS_5,1.0,en].png

0
tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Night-52_53_null_2,NEXUS_5,1.0,en].png → tests/uitests/src/test/snapshots/images/ui_S_t[f.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_null_ReadReceiptBottomSheet-Night-51_52_null_2,NEXUS_5,1.0,en].png

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save