mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2025-03-18 12:18:48 +03:00
Implement rich replies
https://spec.matrix.org/v1.4/client-server-api/#rich-replies Change-Id: I65ea1fd3e42414fc0e5311ad7abf7035bf723a30
This commit is contained in:
parent
e37d378cda
commit
6c4c35158b
16 changed files with 722 additions and 4 deletions
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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())
|
||||
|
|
|
@ -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,
|
||||
|
|
61
vector/src/main/res/layout/view_in_reply_to.xml
Normal file
61
vector/src/main/res/layout/view_in_reply_to.xml
Normal 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>
|
Loading…
Add table
Reference in a new issue