Implement rich replies

https://spec.matrix.org/v1.4/client-server-api/#rich-replies

Change-Id: I65ea1fd3e42414fc0e5311ad7abf7035bf723a30
This commit is contained in:
SpiritCroc 2022-11-18 16:45:03 +01:00
parent e37d378cda
commit 6c4c35158b
16 changed files with 722 additions and 4 deletions

View file

@ -203,4 +203,7 @@
<string name="home_layout_preferences_sort_unread">Unread</string>
<string name="in_reply_to_loading">Loading replied-to message…</string>
<string name="in_reply_to_error">Failed to load replied-to message</string>
</resources>

View file

@ -40,6 +40,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
import org.matrix.android.sdk.api.session.room.sender.SenderInfo
import org.matrix.android.sdk.api.util.ContentUtils
import org.matrix.android.sdk.api.util.ContentUtils.extractUsefulTextFromHtmlReply
import org.matrix.android.sdk.api.util.ContentUtils.extractUsefulTextFromReply
/**
@ -199,11 +200,11 @@ fun TimelineEvent.getTextEditableContent(formatted: Boolean): String {
/**
* Get the latest displayable content.
* Will take care to hide spoiler text
* Will take care to hide spoiler text and removing mx-reply tags
*/
fun MessageContent.getTextDisplayableContent(imageFallback: String = ""): String {
return newContent?.toModel<MessageTextContent>()?.matrixFormattedBody?.let { ContentUtils.formatSpoilerTextFromHtml(it, imageFallback = imageFallback) }
return newContent?.toModel<MessageTextContent>()?.matrixFormattedBody?.let { ContentUtils.formatSpoilerTextFromHtml(extractUsefulTextFromHtmlReply(it), imageFallback = imageFallback) }
?: newContent?.toModel<MessageContent>()?.body
?: (this as MessageTextContent?)?.matrixFormattedBody?.let { ContentUtils.formatSpoilerTextFromHtml(it, imageFallback = imageFallback) }
?: (this as MessageTextContent?)?.matrixFormattedBody?.let { ContentUtils.formatSpoilerTextFromHtml(extractUsefulTextFromHtmlReply(it), imageFallback = imageFallback) }
?: body
}

View file

@ -43,6 +43,12 @@ interface TimelineService {
*/
fun getTimelineEvent(eventId: String): TimelineEvent?
/**
* Like getTimelineEvent(), but try to fetch (and persist) it from the server if not found.
* @param eventId the eventId to get the TimelineEvent
*/
fun getOrFetchAndPersistTimelineEventBlocking(eventId: String): TimelineEvent?
/**
* Creates a LiveData of Optional TimelineEvent event with eventId.
* If the eventId is a local echo eventId, it will make the LiveData be updated with the synced TimelineEvent when coming through the sync.

View file

@ -21,6 +21,7 @@ import com.zhuinden.monarchy.Monarchy
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import kotlinx.coroutines.runBlocking
import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
import org.matrix.android.sdk.api.session.room.timeline.Timeline
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
@ -89,6 +90,17 @@ internal class DefaultTimelineService @AssistedInject constructor(
return timelineEventDataSource.getTimelineEvent(roomId, eventId)
}
override fun getOrFetchAndPersistTimelineEventBlocking(eventId: String): TimelineEvent? {
// Try to fetch it from storage first
getTimelineEvent(eventId)?.let { return it }
// Fetch it from the server
val params = GetContextOfEventTask.Params(roomId, eventId)
runBlocking {
contextOfEventTask.execute(params)
}
return getTimelineEvent(eventId)
}
override fun getTimelineEventLive(eventId: String): LiveData<Optional<TimelineEvent>> {
return timelineEventDataSource.getTimelineEventLive(roomId, eventId)
}

View file

@ -42,6 +42,7 @@ internal class DefaultGetContextOfEventTask @Inject constructor(
val filter = filterRepository.getRoomFilter()
val response = executeRequest(globalErrorReceiver) {
// We are limiting the response to the event with eventId to be sure we don't have any issue with potential merging process.
// In case we change this in the future, we want to make sure that the ReplyPreviewRetriever still only fetches the necessary event.
roomAPI.getContextOfEvent(params.roomId, params.eventId, 0, filter)
}
return tokenChunkEventPersistor.insertInDb(response, params.roomId, PaginationDirection.FORWARDS)

View file

@ -170,6 +170,7 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceItem
import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData
import im.vector.app.features.home.room.detail.timeline.item.TimelineReadMarkerItem
import im.vector.app.features.home.room.detail.timeline.reactions.ViewReactionsBottomSheet
import im.vector.app.features.home.room.detail.timeline.reply.ReplyPreviewRetriever
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet
import im.vector.app.features.home.room.detail.views.RoomDetailLazyLoadedViews
@ -1757,6 +1758,10 @@ class TimelineFragment :
return true
}
override fun onRepliedToEventClicked(eventId: String) {
timelineViewModel.handle(RoomDetailAction.NavigateToEvent(eventId, true))
}
override fun onEventVisible(event: TimelineEvent) {
timelineViewModel.handle(RoomDetailAction.TimelineEventTurnsVisible(event))
}
@ -1912,6 +1917,10 @@ class TimelineFragment :
return timelineViewModel.previewUrlRetriever
}
override fun getReplyPreviewRetriever(): ReplyPreviewRetriever {
return timelineViewModel.replyPreviewRetriever
}
override fun onRoomCreateLinkClicked(url: String) {
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
permalinkHandler

View file

@ -25,6 +25,7 @@ import com.airbnb.mvrx.Loading
import com.airbnb.mvrx.MavericksViewModelFactory
import com.airbnb.mvrx.Success
import com.airbnb.mvrx.Uninitialized
import com.airbnb.mvrx.withState
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
@ -56,9 +57,17 @@ import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAc
import im.vector.app.features.home.room.detail.error.RoomNotFound
import im.vector.app.features.home.room.detail.location.RedactLiveLocationShareEventUseCase
import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler
import im.vector.app.features.home.room.detail.timeline.MessageColorProvider
import im.vector.app.features.home.room.detail.timeline.factory.TimelineFactory
import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter
import im.vector.app.features.home.room.detail.timeline.render.EventTextRenderer
import im.vector.app.features.home.room.detail.timeline.reply.ReplyPreviewRetriever
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.home.room.typing.TypingHelper
import im.vector.app.features.html.EventHtmlRenderer
import im.vector.app.features.html.PillsPostProcessor
import im.vector.app.features.html.SpanUtils
import im.vector.app.features.html.VectorHtmlCompressor
import im.vector.app.features.location.live.StopLiveLocationShareUseCase
import im.vector.app.features.location.live.tracking.LocationSharingServiceConnection
import im.vector.app.features.notifications.NotificationDrawerManager
@ -156,8 +165,16 @@ class TimelineViewModel @AssistedInject constructor(
timelineFactory: TimelineFactory,
private val spaceStateHandler: SpaceStateHandler,
private val voiceBroadcastHelper: VoiceBroadcastHelper,
displayableEventFormatter: DisplayableEventFormatter,
pillsPostProcessorFactory: PillsPostProcessor.Factory,
textRendererFactory: EventTextRenderer.Factory,
messageColorProvider: MessageColorProvider,
htmlCompressor: VectorHtmlCompressor,
htmlRenderer: EventHtmlRenderer,
spanUtils: SpanUtils,
) : VectorViewModel<RoomDetailViewState, RoomDetailAction, RoomDetailViewEvents>(initialState),
Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener, LocationSharingServiceConnection.Callback {
Timeline.Listener, ChatEffectManager.Delegate, CallProtocolsChecker.Listener, LocationSharingServiceConnection.Callback,
ReplyPreviewRetriever.PowerLevelProvider {
private val room = session.getRoom(initialState.roomId)
private val eventId = initialState.eventId ?: if (loadRoomAtFirstUnread() && initialState.rootThreadEventId == null) room?.roomSummary()?.readMarkerId else null
@ -170,6 +187,19 @@ class TimelineViewModel @AssistedInject constructor(
// Same lifecycle than the ViewModel (survive to screen rotation)
val previewUrlRetriever = PreviewUrlRetriever(session, viewModelScope, buildMeta)
val replyPreviewRetriever = ReplyPreviewRetriever(
initialState.roomId,
session,
viewModelScope,
displayableEventFormatter,
pillsPostProcessorFactory,
textRendererFactory,
messageColorProvider,
this,
htmlCompressor,
htmlRenderer,
spanUtils
)
// Slot to keep a pending action during permission request
var pendingAction: RoomDetailAction? = null
@ -236,6 +266,7 @@ class TimelineViewModel @AssistedInject constructor(
observeActiveRoomWidgets()
observePowerLevel()
setupPreviewUrlObservers()
setupInReplyToObserver()
viewModelScope.launch(Dispatchers.IO) {
if (loadRoomAtFirstUnread()) {
if (vectorPreferences.readReceiptFollowsReadMarker()) {
@ -422,6 +453,19 @@ class TimelineViewModel @AssistedInject constructor(
.launchIn(viewModelScope)
}
private fun setupInReplyToObserver() {
timelineEvents.onEach { snapshot ->
withContext(Dispatchers.Default) {
// First, invalidate edited and removed events
replyPreviewRetriever.invalidateEventsFromSnapshot(snapshot)
// Then update replied-to fields
snapshot.forEach {
replyPreviewRetriever.getReplyTo(it)
}
}
}.launchIn(viewModelScope)
}
/**
* Mark the thread as read, while the user navigated within the thread.
* This is a local implementation has nothing to do with APIs.
@ -1553,4 +1597,8 @@ class TimelineViewModel @AssistedInject constructor(
}
return tryOrNull { operation() }
}
override fun getPowerLevelsHelper(): PowerLevelsHelper? = withState(this) { state ->
state.powerLevelsHelper
}
}

View file

@ -61,6 +61,7 @@ import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryEve
import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData
import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem
import im.vector.app.features.home.room.detail.timeline.item.TypingItem_
import im.vector.app.features.home.room.detail.timeline.reply.ReplyPreviewRetriever
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.media.AttachmentData
import im.vector.app.features.media.ImageContentRenderer
@ -132,6 +133,7 @@ class TimelineEventController @Inject constructor(
AvatarCallback,
ThreadCallback,
UrlClickCallback,
InReplyToClickCallback,
ReadReceiptsCallback,
PreviewUrlCallback {
fun onLoadMore(direction: Timeline.Direction)
@ -157,6 +159,7 @@ class TimelineEventController @Inject constructor(
// Introduce ViewModel scoped component (or Hilt?)
fun getPreviewUrlRetriever(): PreviewUrlRetriever
fun getReplyPreviewRetriever(): ReplyPreviewRetriever
fun onVoiceControlButtonClicked(eventId: String, messageAudioContent: MessageAudioContent)
fun onVoiceWaveformTouchedUp(eventId: String, duration: Int, percentage: Float)
@ -196,6 +199,10 @@ class TimelineEventController @Inject constructor(
fun onUrlLongClicked(url: String): Boolean
}
interface InReplyToClickCallback {
fun onRepliedToEventClicked(eventId: String)
}
interface PreviewUrlCallback {
fun onPreviewUrlClicked(url: String)
fun onPreviewUrlCloseClicked(eventId: String, url: String)

View file

@ -604,6 +604,8 @@ class MessageItemFactory @Inject constructor(
.markwonPlugins(htmlRenderer.get().plugins)
.searchForPills(isFormatted)
.previewUrlRetriever(callback?.getPreviewUrlRetriever())
.replyPreviewRetriever(callback?.getReplyPreviewRetriever())
.inReplyToClickCallback(callback)
.imageContentRenderer(imageContentRenderer)
.previewUrlCallback(callback)
.leftGuideline(avatarSizeProvider.leftGuideline)
@ -703,6 +705,8 @@ class MessageItemFactory @Inject constructor(
return MessageTextItem_()
.leftGuideline(avatarSizeProvider.leftGuideline)
.previewUrlRetriever(callback?.getPreviewUrlRetriever())
.replyPreviewRetriever(callback?.getReplyPreviewRetriever())
.inReplyToClickCallback(callback)
.imageContentRenderer(imageContentRenderer)
.previewUrlCallback(callback)
.attributes(attributes)
@ -736,6 +740,8 @@ class MessageItemFactory @Inject constructor(
.bindingOptions(bindingOptions)
.leftGuideline(avatarSizeProvider.leftGuideline)
.previewUrlRetriever(callback?.getPreviewUrlRetriever())
.replyPreviewRetriever(callback?.getReplyPreviewRetriever())
.inReplyToClickCallback(callback)
.imageContentRenderer(imageContentRenderer)
.previewUrlCallback(callback)
.attributes(attributes)

View file

@ -20,6 +20,7 @@ import android.text.Spanned
import android.text.method.MovementMethod
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.text.PrecomputedTextCompat
import androidx.core.view.isVisible
import androidx.core.widget.TextViewCompat
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
@ -28,7 +29,10 @@ import im.vector.app.core.epoxy.onClick
import im.vector.app.core.epoxy.onLongClickIgnoringLinks
import im.vector.app.core.ui.views.FooteredTextView
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.reply.InReplyToView
import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayout
import im.vector.app.features.home.room.detail.timeline.reply.PreviewReplyUiState
import im.vector.app.features.home.room.detail.timeline.reply.ReplyPreviewRetriever
import im.vector.app.features.home.room.detail.timeline.tools.findPillsAndProcess
import im.vector.app.features.home.room.detail.timeline.url.AbstractPreviewUrlView
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
@ -56,6 +60,12 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
@EpoxyAttribute
var useBigFont: Boolean = false
@EpoxyAttribute
var replyPreviewRetriever: ReplyPreviewRetriever? = null
@EpoxyAttribute
var inReplyToClickCallback: TimelineEventController.InReplyToClickCallback? = null
@EpoxyAttribute
var previewUrlRetriever: PreviewUrlRetriever? = null
@ -72,6 +82,7 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
var markwonPlugins: (List<MarkwonPlugin>)? = null
private val previewUrlViewUpdater = PreviewUrlViewUpdater()
private val replyViewUpdater = ReplyViewUpdater()
// Remember footer measures for URL updates
private var footerWidth: Int = 0
@ -96,6 +107,15 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
holder.previewUrlView.delegate = previewUrlCallback
holder.previewUrlView.renderMessageLayout(attributes.informationData.messageLayout)
replyViewUpdater.replyView = holder.replyToView
val safeReplyPreviewRetriever = replyPreviewRetriever
if (safeReplyPreviewRetriever == null) {
holder.replyToView.isVisible = false
} else {
safeReplyPreviewRetriever.addListener(attributes.informationData.eventId, replyViewUpdater)
}
holder.replyToView.delegate = inReplyToClickCallback
if (useBigFont) {
holder.messageView.textSize = 44F
} else {
@ -135,6 +155,7 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
previewUrlViewUpdater.previewUrlView = null
previewUrlViewUpdater.imageContentRenderer = null
previewUrlRetriever?.removeListener(attributes.informationData.eventId, previewUrlViewUpdater)
replyPreviewRetriever?.removeListener(attributes.informationData.eventId, replyViewUpdater)
}
override fun getViewStubId() = STUB_ID
@ -143,6 +164,7 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
val messageView by bind<FooteredTextView>(R.id.messageTextView)
val previewUrlViewElement by bind<PreviewUrlView>(R.id.messageUrlPreviewElement)
val previewUrlViewSc by bind<PreviewUrlViewSc>(R.id.messageUrlPreviewSc)
val replyToView by bind<InReplyToView>(R.id.inReplyToContainer)
lateinit var previewUrlView: AbstractPreviewUrlView // set to either previewUrlViewElement or previewUrlViewSc by layout
}
@ -175,6 +197,17 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
}
}
inner class ReplyViewUpdater : ReplyPreviewRetriever.PreviewReplyRetrieverListener {
var replyView: InReplyToView? = null
override fun onStateUpdated(state: PreviewReplyUiState) {
timber.log.Timber.i("REPLY STATE UPDATE $replyPreviewRetriever $replyView")
replyPreviewRetriever?.let {
replyView?.render(state, it, attributes.informationData, movementMethod, coroutineScope)
}
}
}
override fun allowFooterOverlay(holder: Holder, bubbleWrapView: ScMessageBubbleWrapView): Boolean {
return true

View file

@ -0,0 +1,210 @@
/*
* Copyright (c) 2020 New Vector Ltd
* Copyright (c) 2022 SpiritCroc
*
* 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 im.vector.app.features.home.room.detail.timeline.reply
import android.content.Context
import android.text.Spanned
import android.text.method.MovementMethod
import android.util.AttributeSet
import android.view.View
import androidx.appcompat.widget.AppCompatTextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.text.PrecomputedTextCompat
import androidx.core.view.isVisible
import androidx.core.widget.TextViewCompat
import im.vector.app.R
import im.vector.app.databinding.ViewInReplyToBinding
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.item.BindingOptions
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.tools.findPillsAndProcess
import kotlinx.coroutines.CoroutineScope
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
import timber.log.Timber
/**
* A View to render a replied-to event
*/
class InReplyToView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), View.OnClickListener {
private lateinit var views: ViewInReplyToBinding
var delegate: TimelineEventController.InReplyToClickCallback? = null
init {
setupView()
}
private var state: PreviewReplyUiState = PreviewReplyUiState.NoReply
/**
* This methods is responsible for rendering the view according to the newState
*
* @param newState the newState representing the view
*/
fun render(newState: PreviewReplyUiState,
retriever: ReplyPreviewRetriever,
roomInformationData: MessageInformationData,
movementMethod: MovementMethod?,
coroutineScope: CoroutineScope,
force: Boolean = false) {
if (newState == state && !force) {
return
}
state = newState
when (newState) {
PreviewReplyUiState.NoReply -> renderHidden()
is PreviewReplyUiState.ReplyLoading -> renderLoading()
is PreviewReplyUiState.Error -> renderError(newState)
is PreviewReplyUiState.InReplyTo -> renderReplyTo(newState, retriever, roomInformationData, movementMethod, coroutineScope)
}
}
override fun onClick(v: View?) {
state.repliedToEventId?.let { delegate?.onRepliedToEventClicked(it) }
}
// PRIVATE METHODS ****************************************************************************************************************************************
private fun setupView() {
inflate(context, R.layout.view_in_reply_to, this)
views = ViewInReplyToBinding.bind(this)
setOnClickListener(this)
// Somehow this one needs it additionally?
views.replyTextView.setOnClickListener(this)
}
private fun hideViews() {
views.replyMemberNameView.isVisible = false
views.replyTextView.isVisible = false
}
private fun renderHidden() {
isVisible = false
}
private fun renderLoading() {
hideViews()
isVisible = true
views.replyTextView.setText(R.string.in_reply_to_loading)
}
private fun renderError(state: PreviewReplyUiState.Error) {
hideViews()
isVisible = true
Timber.w(state.throwable, "Error rendering reply")
views.replyTextView.setText(R.string.in_reply_to_error)
}
private fun renderReplyTo(
state: PreviewReplyUiState.InReplyTo,
retriever: ReplyPreviewRetriever,
roomInformationData: MessageInformationData,
movementMethod: MovementMethod?,
coroutineScope: CoroutineScope
) {
hideViews()
isVisible = true
views.replyMemberNameView.isVisible = true
views.replyMemberNameView.text = state.event.senderInfo.disambiguatedDisplayName
val senderColor = retriever.getMemberNameColor(state.event, roomInformationData)
views.replyMemberNameView.setTextColor(senderColor)
views.inReplyToBar.setBackgroundColor(senderColor)
if (state.event.root.isRedacted()) {
renderRedacted()
} else {
when (val content = state.event.getLastMessageContent()) {
is MessageTextContent -> renderTextContent(content, retriever, movementMethod, coroutineScope)
else -> renderFallback(state.event, retriever)
}
}
}
private fun renderRedacted() {
views.replyTextView.isVisible = true
views.replyTextView.setText(R.string.event_redacted)
}
private fun renderTextContent(
content: MessageTextContent,
retriever: ReplyPreviewRetriever,
movementMethod: MovementMethod?,
coroutineScope: CoroutineScope
) {
views.replyTextView.isVisible = true
val formattedBody = content.formattedBody
val bindingOptions: BindingOptions?
val text = if (formattedBody != null) {
val compressed = retriever.htmlCompressor.compress(formattedBody)
val renderedFormattedBody = retriever.htmlRenderer.render(compressed, retriever.pillsPostProcessor) as Spanned
val renderedBody = retriever.textRenderer.render(renderedFormattedBody)
bindingOptions = retriever.spanUtils.getBindingOptions(renderedBody)
// To be re-enabled if we want clickable urls in reply previews, which would conflict with going to the original event on clicking
//val linkifiedBody = renderedBody.linkify(callback)
//linkifiedBody
renderedBody
} else {
bindingOptions = null
content.body
}
val markwonPlugins = retriever.htmlRenderer.plugins
if (formattedBody != null) {
text.findPillsAndProcess(coroutineScope) { pillImageSpan ->
pillImageSpan.bind(views.replyTextView)
}
}
text.let { charSequence ->
if (charSequence is Spanned) {
markwonPlugins.forEach { plugin -> plugin.beforeSetText(views.replyTextView, charSequence) }
}
}
views.replyTextView.movementMethod = movementMethod
views.replyTextView.setTextWithEmojiSupport(text, bindingOptions)
markwonPlugins.forEach { plugin -> plugin.afterSetText(views.replyTextView) }
}
private fun renderFallback(event: TimelineEvent, retriever: ReplyPreviewRetriever) {
views.replyTextView.isVisible = true
views.replyTextView.text = retriever.formatFallbackReply(event)
}
private fun AppCompatTextView.setTextWithEmojiSupport(message: CharSequence?, bindingOptions: BindingOptions?) {
if (bindingOptions?.canUseTextFuture.orFalse() && message != null) {
val textFuture = PrecomputedTextCompat.getTextFuture(message, TextViewCompat.getTextMetricsParams(this), null)
setTextFuture(textFuture)
} else {
setTextFuture(null)
text = message
}
}
}

View file

@ -0,0 +1,44 @@
/*
* Copyright (c) 2020 New Vector Ltd
* Copyright (c) 2022 Beeper Inc.
*
* 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 im.vector.app.features.home.room.detail.timeline.reply
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
/**
* The state representing a reply preview UI state for an Event.
*/
sealed class PreviewReplyUiState {
abstract val repliedToEventId: String?
// This is not a reply
object NoReply : PreviewReplyUiState() {
override val repliedToEventId: String? = null
}
// Error
data class Error(val throwable: Throwable, override val repliedToEventId: String?) : PreviewReplyUiState()
data class ReplyLoading(override val repliedToEventId: String?) : PreviewReplyUiState()
// Is a reply
data class InReplyTo(
override val repliedToEventId: String,
val event: TimelineEvent
) : PreviewReplyUiState()
}

View file

@ -0,0 +1,261 @@
/*
* Copyright (c) 2020 New Vector Ltd
* Copyright (c) 2022 Beeper Inc.
*
* 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 im.vector.app.features.home.room.detail.timeline.reply
import im.vector.app.BuildConfig
import im.vector.app.features.home.room.detail.timeline.MessageColorProvider
import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactory
import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter
import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider
import im.vector.app.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.app.features.home.room.detail.timeline.render.EventTextRenderer
import im.vector.app.features.html.EventHtmlRenderer
import im.vector.app.features.html.PillsPostProcessor
import im.vector.app.features.html.SpanUtils
import im.vector.app.features.html.VectorHtmlCompressor
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.crypto.MXCryptoError
import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult
import org.matrix.android.sdk.api.session.events.model.getRelationContent
import org.matrix.android.sdk.api.session.getRoom
import org.matrix.android.sdk.api.session.room.getTimelineEvent
import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.session.room.timeline.getLatestEventId
import org.matrix.android.sdk.api.util.MatrixItem
import org.matrix.android.sdk.api.util.toMatrixItem
import timber.log.Timber
import java.util.UUID
class ReplyPreviewRetriever(
private val roomId: String,
private val session: Session,
private val coroutineScope: CoroutineScope,
private val displayableEventFormatter: DisplayableEventFormatter,
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
private val textRendererFactory: EventTextRenderer.Factory,
val messageColorProvider: MessageColorProvider,
val powerLevelProvider: PowerLevelProvider,
val htmlCompressor: VectorHtmlCompressor,
val htmlRenderer: EventHtmlRenderer,
val spanUtils: SpanUtils,
) {
private data class ReplyPreviewUiState(
// Id of the latest event in the case of an edited event, or the eventId for an event which has not been edited
//val latestEventId: String,
// Id of the latest replied-to event in the case of an edited event, or the eventId for an replied-to event which has not been edited
val latestRepliedToEventId: String?,
val previewReplyUiState: PreviewReplyUiState
)
companion object {
// Delay between attempts to fetch the replied-to event from the server, if it failed.
private const val RETRY_SERVER_LOOKUP_INTERVAL_MS = 1000 * 30
private val DEBUG = BuildConfig.DEBUG // TODO: false
}
private fun TimelineEvent.getCacheId(): String {
return if (root.isRedacted()) {
"REDACTED"
} else {
"L:${getLatestEventId()}"
}
}
// Keys are the main eventId
private val data = mutableMapOf<String, ReplyPreviewUiState>()
private val listeners = mutableMapOf<String, MutableSet<PreviewReplyRetrieverListener>>()
// Cache which replied-to events we already looked up successfully: key is main eventId, value is the getCacheId() value, which wraps the latest eventId.
// To be synchronized with the data field's lock.
private val lookedUpEvents = mutableMapOf<String, String>()
// Timestamps of allowed server requests for individual events, to not spam server with the same request
private val serverRequests = mutableMapOf<String, Long>()
fun invalidateEventsFromSnapshot(snapshot: List<TimelineEvent>) {
val snapshotEvents = snapshot.associateBy { it.eventId }
synchronized(data) {
// Invalidate all events that have been updated in the snapshot, or are not included in it (in which case we don't know if they updated)
for (eventId in lookedUpEvents.keys.toList()) {
val cacheId = snapshotEvents[eventId]?.getCacheId()
if (lookedUpEvents[eventId] != cacheId) {
if (DEBUG) Timber.i("Reply retriever: invalidate $eventId: ${lookedUpEvents[eventId]} -> $cacheId")
lookedUpEvents.remove(eventId)
}
}
}
}
val pillsPostProcessor by lazy {
pillsPostProcessorFactory.create(roomId)
}
val textRenderer by lazy {
textRendererFactory.create(roomId)
}
fun getReplyTo(event: TimelineEvent) {
val eventId = event.root.eventId ?: return
val now = System.currentTimeMillis()
synchronized(data) {
val current = data[eventId]
val repliedToEventId = event.root.getRelationContent()?.inReplyTo?.eventId
if (current == null || repliedToEventId != current.latestRepliedToEventId) {
// We have not rendered this yet, or the replied-to event has updated
if (repliedToEventId?.isNotEmpty().orFalse()) {
updateState(eventId, repliedToEventId, PreviewReplyUiState.ReplyLoading(repliedToEventId))
repliedToEventId
} else {
updateState(eventId, repliedToEventId, PreviewReplyUiState.NoReply)
null
}
} else {
// Nothing changed, we have rendered this before... but the replied-to event might have been edited or decrypted in the meantime
if (repliedToEventId in lookedUpEvents) {
// We have looked this event up before and haven't removed it from lookedUpEvents yet, so no need to re-render
null
} else {
repliedToEventId
}
}
}?.let { eventIdToRetrieve ->
coroutineScope.launch(Dispatchers.IO) {
runCatching {
// Don't spam the server too often if it doesn't know the event
val mayAskServerForEvent = synchronized(serverRequests) {
val lastAttempt = serverRequests[eventIdToRetrieve]
if (lastAttempt == null || lastAttempt < now - RETRY_SERVER_LOOKUP_INTERVAL_MS) {
serverRequests[eventIdToRetrieve] = now
true
} else {
false
}
}
if (DEBUG) Timber.i("REPLY HANDLING AFTER ${System.currentTimeMillis() - now} for $eventId / $eventIdToRetrieve, may ask: $mayAskServerForEvent")// TODO remove
if (mayAskServerForEvent) {
session.getRoom(roomId)?.timelineService()?.getOrFetchAndPersistTimelineEventBlocking(eventIdToRetrieve)
} else {
session.getRoom(roomId)?.getTimelineEvent(eventIdToRetrieve)
}?.apply {
// We need to check encryption
val repliedToEvent = root // TODO what if rendered event is not root, i.e. root.eventId != getLatestEventId()? (we currently just use the initial event in this case, better than nothing)
if (repliedToEvent.isEncrypted() && repliedToEvent.mxDecryptionResult == null) {
// for now decrypt sync
try {
val result = session.cryptoService().decryptEvent(root, root.roomId + UUID.randomUUID().toString())
repliedToEvent.mxDecryptionResult = OlmDecryptionResult(
payload = result.clearEvent,
senderKey = result.senderCurve25519Key,
keysClaimed = result.claimedEd25519Key?.let { k -> mapOf("ed25519" to k) },
forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain,
isSafe = result.isSafe
)
} catch (e: MXCryptoError) {
Timber.w("Failed to decrypt event in reply")
}
}
}
}.fold(
{
// We should render a reply
synchronized(data) {
updateState(eventId, eventIdToRetrieve,
if (it == null) PreviewReplyUiState.Error(Exception("Event not found"), eventIdToRetrieve) // TODO proper exception or sth.
else PreviewReplyUiState.InReplyTo(eventIdToRetrieve, it)
)
}
},
{
synchronized(data) {
updateState(eventId, eventIdToRetrieve, PreviewReplyUiState.Error(it, eventIdToRetrieve))
}
}
)
}
}
}
private fun updateState(eventId: String, latestRepliedToEventId: String?, state: PreviewReplyUiState) {
data[eventId] = ReplyPreviewUiState(latestRepliedToEventId, state)
if (state is PreviewReplyUiState.InReplyTo) {
if (state.event.isEncrypted() && state.event.root.mxDecryptionResult == null) {
if (DEBUG) Timber.i("Reply retriever: not caching $eventId / $latestRepliedToEventId")
// Do not cache encrypted events, so we try again on next update
lookedUpEvents.remove(state.repliedToEventId)
} else {
if (DEBUG) Timber.i("Reply retriever: caching $eventId / $latestRepliedToEventId")
lookedUpEvents[state.repliedToEventId] = state.event.getCacheId()
}
}
// Notify the listener
coroutineScope.launch(Dispatchers.Main) {
listeners[eventId].orEmpty().forEach {
it.onStateUpdated(state)
}
}
}
// Called by the Epoxy item during binding
fun addListener(key: String, listener: PreviewReplyRetrieverListener) {
listeners.getOrPut(key) { mutableSetOf() }.add(listener)
// Give the current state if any
synchronized(data) {
listener.onStateUpdated(data[key]?.previewReplyUiState ?: PreviewReplyUiState.NoReply)
}
}
// Called by the Epoxy item during unbinding
fun removeListener(key: String, listener: PreviewReplyRetrieverListener) {
listeners[key]?.remove(listener)
}
interface PreviewReplyRetrieverListener {
fun onStateUpdated(state: PreviewReplyUiState)
}
interface PowerLevelProvider {
fun getPowerLevelsHelper(): PowerLevelsHelper?
}
fun getMemberNameColor(event: TimelineEvent, roomInformationData: MessageInformationData): Int {
val matrixItem = event.senderInfo.toMatrixItem()
return messageColorProvider.getMemberNameTextColor(
matrixItem,
MatrixItemColorProvider.UserInRoomInformation(
roomInformationData.isDirect,
roomInformationData.isPublic,
powerLevelProvider.getPowerLevelsHelper()?.getUserPowerLevelValue(event.senderInfo.userId)
)
)
}
fun formatFallbackReply(event: TimelineEvent): CharSequence {
return displayableEventFormatter.format(event,
// This is not a preview in the traditional sense, as sender information is rendered outside either way.
// So we want to omit these information from the text.
isDm = false,
appendAuthor = false,
)
}
}

View file

@ -61,6 +61,7 @@ import org.commonmark.node.Node
import org.commonmark.parser.Parser
import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.util.ContentUtils.extractUsefulTextFromHtmlReply
import timber.log.Timber
import java.security.MessageDigest
import javax.inject.Inject
@ -174,6 +175,11 @@ class EventHtmlRenderer @Inject constructor(
.blockQuoteColor(quoteBarColor)
}
},
object: AbstractMarkwonPlugin() { // Remove fallback mx-replies
override fun processMarkdown(markdown: String): String {
return extractUsefulTextFromHtmlReply(markdown)
}
},
object : AbstractMarkwonPlugin() { // Overwrite height for data-mx-emoticon, to ensure emoji-like height
override fun processMarkdown(markdown: String): String {
return markdown
@ -321,6 +327,7 @@ class MatrixHtmlPluginConfigure @Inject constructor(private val colorProvider: C
.addHandler(ListHandlerWithInitialStart())
.addHandler(FontTagHandler())
.addHandler(ParagraphHandler(DimensionConverter(resources)))
// Note: only for fallback replies, which we should have removed by now
.addHandler(MxReplyTagHandler())
.addHandler(CodePreTagHandler())
.addHandler(CodeTagHandler())

View file

@ -8,6 +8,15 @@
android:orientation="vertical"
tools:viewBindingIgnore="true">
<im.vector.app.features.home.room.detail.timeline.reply.InReplyToView
android:id="@+id/inReplyToContainer"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginBottom="8dp"
android:visibility="gone"
tools:visibility="visible" />
<!-- Layout gravity left: fixes long display name pills moving text out:
https://github.com/SchildiChat/SchildiChat-android/issues/66
Interestingly, this not only fixes it for LTR texts for RTL locales,

View file

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/inReplyToContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<LinearLayout
android:id="@+id/inReplyToHolder"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_marginStart="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<TextView
android:id="@+id/replyMemberNameView"
style="@style/Widget.Vector.TextView.Subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAlignment="viewStart"
android:textColor="?vctr_content_primary"
android:textStyle="bold"
android:textSize="12sp"
tools:text="@sample/users.json/data/displayName" />
<!-- Layout gravity left: fixes long display name pills moving text out:
https://github.com/SchildiChat/SchildiChat-android/issues/66
Interestingly, this not only fixes it for LTR texts for RTL locales,
but it also makes it align better for RTL texts in RTL locales - at least
until the user pill is touched...
-->
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/replyTextView"
style="@style/Widget.Vector.TextView.Body"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="0dp"
android:textColor="?vctr_content_primary"
android:layout_gravity="left"
tools:text="@sample/messages.json/data/message"
tools:ignore="RtlHardcoded" />
</LinearLayout>
<View
android:id="@+id/inReplyToBar"
android:layout_width="2dp"
android:layout_height="0dp"
android:background="?vctr_content_secondary"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="@id/inReplyToHolder"
app:layout_constraintTop_toTopOf="@id/inReplyToHolder" />
</merge>