Skip to content

Commit

Permalink
Use RTE TextView for timeline text messages, add mention pills to m…
Browse files Browse the repository at this point in the history
…essages (#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 <[email protected]>
  • Loading branch information
jmartinesp and ElementBot authored Dec 13, 2023
1 parent e4a07df commit 1e86d82
Show file tree
Hide file tree
Showing 232 changed files with 754 additions and 1,124 deletions.
3 changes: 3 additions & 0 deletions changelog.d/1433.feature
Original file line number Diff line number Diff line change
@@ -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 changes: 1 addition & 1 deletion features/messages/api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/

plugins {
id("io.element.android-library")
id("io.element.android-compose-library")
}

android {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 changes: 3 additions & 0 deletions features/messages/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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.")
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -305,8 +311,6 @@ private fun TimelineItemEventRowContent(
) {
MessageEventBubbleContent(
event = event,
interactionSource = interactionSource,
onMessageClick = onClick,
onMessageLongClick = onLongClick,
inReplyToClick = inReplyToClicked,
onTimestampClicked = {
Expand Down Expand Up @@ -380,8 +384,6 @@ private fun MessageSenderInformation(
@Composable
private fun MessageEventBubbleContent(
event: TimelineItem.Event,
interactionSource: MutableInteractionSource,
onMessageClick: () -> Unit,
onMessageLongClick: () -> Unit,
inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit,
Expand Down Expand Up @@ -473,6 +475,7 @@ private fun MessageEventBubbleContent(
inReplyToDetails: InReplyToDetails?,
modifier: Modifier = Modifier
) {
val context = LocalContext.current
val timestampLayoutModifier: Modifier
val contentModifier: Modifier
when {
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
) {
Expand All @@ -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,
Expand Down
Loading

0 comments on commit 1e86d82

Please sign in to comment.