PreviewUrl: Application part - WIP

This commit is contained in:
Benoit Marty 2020-12-04 07:46:09 +01:00
parent fcd9fe7d5a
commit 48354c7793
11 changed files with 382 additions and 21 deletions

View File

@ -21,6 +21,7 @@ import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.EventType
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import javax.inject.Inject
internal class UrlsExtractor @Inject constructor() {
@ -30,6 +31,7 @@ internal class UrlsExtractor @Inject constructor() {
return event.takeIf { it.getClearType() == EventType.MESSAGE }
?.getClearContent()
?.toModel<MessageContent>()
?.takeIf { it.msgType == MessageType.MSGTYPE_TEXT || it.msgType == MessageType.MSGTYPE_EMOTE }
?.body
?.let { urlRegex.findAll(it) }
?.map { it.value }

View File

@ -0,0 +1,147 @@
/*
* Copyright (c) 2020 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 im.vector.app.core.ui.views
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import butterknife.BindView
import butterknife.ButterKnife
import im.vector.app.R
import im.vector.app.core.extensions.setTextOrHide
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlUiState
import im.vector.app.features.media.ImageContentRenderer
import org.matrix.android.sdk.api.session.media.PreviewUrlData
import timber.log.Timber
/**
* A View to display a PreviewUrl and some other state
*/
class PreviewUrlView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), View.OnClickListener {
@BindView(R.id.url_preview_title)
lateinit var titleView: TextView
@BindView(R.id.url_preview_image)
lateinit var imageView: ImageView
@BindView(R.id.url_preview_description)
lateinit var descriptionView: TextView
@BindView(R.id.url_preview_site)
lateinit var siteView: TextView
var delegate: Delegate? = null
init {
setupView()
}
private var state: PreviewUrlUiState = PreviewUrlUiState.Unknown
/**
* This methods is responsible for rendering the view according to the newState
*
* @param newState the newState representing the view
*/
fun render(newState: PreviewUrlUiState,
imageContentRenderer: ImageContentRenderer,
force: Boolean = false) {
if (newState == state && !force) {
Timber.v("State unchanged")
return
}
Timber.v("Rendering $newState")
state = newState
hideAll()
when (newState) {
PreviewUrlUiState.Unknown,
PreviewUrlUiState.NoUrl -> renderHidden()
PreviewUrlUiState.Loading -> renderLoading()
is PreviewUrlUiState.Error -> renderHidden()
is PreviewUrlUiState.Data -> renderData(newState.previewUrlData, imageContentRenderer)
}
}
override fun onClick(v: View?) {
when (val finalState = state) {
is PreviewUrlUiState.Data -> delegate?.onUrlClicked(finalState.previewUrlData.url)
else -> Unit
}
}
// PRIVATE METHODS ****************************************************************************************************************************************
private fun setupView() {
inflate(context, R.layout.url_preview, this)
ButterKnife.bind(this)
setOnClickListener(this)
}
private fun renderHidden() {
isVisible = false
}
private fun renderLoading() {
// TODO
isVisible = false
}
private fun renderData(previewUrlData: PreviewUrlData, imageContentRenderer: ImageContentRenderer) {
isVisible = true
titleView.setTextOrHide(previewUrlData.title)
val mxcUrl = previewUrlData.mxcUrl
imageView.isVisible = mxcUrl != null
if (mxcUrl != null) {
imageContentRenderer.render(mxcUrl, imageView)
}
descriptionView.setTextOrHide(previewUrlData.description)
siteView.setTextOrHide(previewUrlData.siteName)
}
/**
* Hide all views that are not visible in all state
*/
private fun hideAll() {
titleView.isVisible = false
imageView.isVisible = false
descriptionView.isVisible = false
siteView.isVisible = false
}
/**
* An interface to delegate some actions to another object
*/
interface Delegate {
// TODO
fun onUrlClicked(url: String)
// TODO
// fun close()
}
}

View File

@ -40,6 +40,7 @@ import im.vector.app.features.home.room.detail.composer.rainbow.RainbowGenerator
import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler
import im.vector.app.features.home.room.detail.timeline.helper.RoomSummaryHolder
import im.vector.app.features.home.room.detail.timeline.helper.TimelineSettingsFactory
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.powerlevel.PowerLevelsObservableFactory
import im.vector.app.features.raw.wellknown.getElementWellknown
@ -112,6 +113,7 @@ class RoomDetailViewModel @AssistedInject constructor(
private val rainbowGenerator: RainbowGenerator,
private val session: Session,
private val rawService: RawService,
private val previewUrlRetriever: PreviewUrlRetriever,
private val supportedVerificationMethodsProvider: SupportedVerificationMethodsProvider,
private val stickerPickerActionHandler: StickerPickerActionHandler,
private val roomSummaryHolder: RoomSummaryHolder,
@ -1350,6 +1352,12 @@ class RoomDetailViewModel @AssistedInject constructor(
override fun onTimelineUpdated(snapshot: List<TimelineEvent>) {
timelineEvents.accept(snapshot)
// PreviewUrl
// TODO Check if URL preview is enable, check if encrypted room, etc.
snapshot.forEach {
previewUrlRetriever.getPreviewUrl(it.root, viewModelScope)
}
}
override fun onTimelineFailure(throwable: Throwable) {

View File

@ -58,6 +58,7 @@ import im.vector.app.features.home.room.detail.timeline.item.VerificationRequest
import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem_
import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod
import im.vector.app.features.home.room.detail.timeline.tools.linkify
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.html.CodeVisitor
import im.vector.app.features.html.EventHtmlRenderer
import im.vector.app.features.html.PillsPostProcessor
@ -107,6 +108,7 @@ class MessageItemFactory @Inject constructor(
private val defaultItemFactory: DefaultItemFactory,
private val noticeItemFactory: NoticeItemFactory,
private val avatarSizeProvider: AvatarSizeProvider,
private val previewUrlRetriever: PreviewUrlRetriever,
private val pillsPostProcessorFactory: PillsPostProcessor.Factory,
private val session: Session) {
@ -424,6 +426,8 @@ class MessageItemFactory @Inject constructor(
}
.useBigFont(linkifiedBody.length <= MAX_NUMBER_OF_EMOJI_FOR_BIG_FONT * 2 && containsOnlyEmojis(linkifiedBody.toString()))
.searchForPills(isFormatted)
.previewUrlRetriever(previewUrlRetriever)
.imageContentRenderer(imageContentRenderer)
.leftGuideline(avatarSizeProvider.leftGuideline)
.attributes(attributes)
.highlighted(highlight)
@ -529,6 +533,8 @@ class MessageItemFactory @Inject constructor(
}
}
.leftGuideline(avatarSizeProvider.leftGuideline)
.previewUrlRetriever(previewUrlRetriever)
.imageContentRenderer(imageContentRenderer)
.attributes(attributes)
.highlighted(highlight)
.movementMethod(createLinkMovementMethod(callback))

View File

@ -23,7 +23,11 @@ import androidx.core.widget.TextViewCompat
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.app.R
import im.vector.app.core.ui.views.PreviewUrlView
import im.vector.app.features.home.room.detail.timeline.tools.findPillsAndProcess
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlUiState
import im.vector.app.features.media.ImageContentRenderer
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
@ -37,10 +41,22 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
@EpoxyAttribute
var useBigFont: Boolean = false
@EpoxyAttribute
var previewUrlRetriever: PreviewUrlRetriever? = null
@EpoxyAttribute
var imageContentRenderer: ImageContentRenderer? = null
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var movementMethod: MovementMethod? = null
private val previewUrlViewUpdater = PreviewUrlViewUpdater()
override fun bind(holder: Holder) {
previewUrlViewUpdater.previewUrlView = holder.previewUrlView
previewUrlViewUpdater.imageContentRenderer = imageContentRenderer
previewUrlRetriever?.addListener(attributes.informationData.eventId, previewUrlViewUpdater)
if (useBigFont) {
holder.messageView.textSize = 44F
} else {
@ -65,12 +81,27 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
holder.messageView.setTextFuture(textFuture)
}
override fun unbind(holder: Holder) {
super.unbind(holder)
previewUrlRetriever?.removeListener(attributes.informationData.eventId, previewUrlViewUpdater)
}
override fun getViewType() = STUB_ID
class Holder : AbsMessageItem.Holder(STUB_ID) {
val messageView by bind<AppCompatTextView>(R.id.messageTextView)
val previewUrlView by bind<PreviewUrlView>(R.id.messageUrlPreview)
}
inner class PreviewUrlViewUpdater : PreviewUrlRetriever.PreviewUrlRetrieverListener {
var previewUrlView: PreviewUrlView? = null
var imageContentRenderer: ImageContentRenderer? = null
override fun onStateUpdated(state: PreviewUrlUiState) {
val safeImageContentRenderer = imageContentRenderer ?: return
previewUrlView?.render(state, safeImageContentRenderer)
}
}
companion object {
private const val STUB_ID = R.id.messageContentTextStub
}

View File

@ -0,0 +1,108 @@
/*
* Copyright (c) 2020 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 im.vector.app.features.home.room.detail.timeline.url
import im.vector.app.core.di.ScreenScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import org.matrix.android.sdk.api.cache.CacheStrategy
import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.events.model.Event
import javax.inject.Inject
@ScreenScope
class PreviewUrlRetriever @Inject constructor(
private val session: Session
) {
private val data = mutableMapOf<String, PreviewUrlUiState>()
private val listeners = mutableMapOf<String, MutableSet<PreviewUrlRetrieverListener>>()
fun getPreviewUrl(event: Event, coroutineScope: CoroutineScope) {
val eventId = event.eventId ?: return
val urlToRetrieve = synchronized(data) {
if (data[eventId] == null) {
// Keep only the first URL for the moment
val url = session.mediaService().extractUrls(event).firstOrNull()
if (url == null) {
updateState(eventId, PreviewUrlUiState.NoUrl)
} else {
updateState(eventId, PreviewUrlUiState.Loading)
}
url
} else {
// Already handled
null
}
}
urlToRetrieve?.let { urlToRetrieve ->
coroutineScope.launch {
runCatching {
session.mediaService().getPreviewUrl(
url = urlToRetrieve,
timestamp = null,
cacheStrategy = CacheStrategy.TtlCache(CACHE_VALIDITY, false)
)
}.fold(
{
synchronized(data) {
updateState(eventId, PreviewUrlUiState.Data(it))
}
},
{
synchronized(data) {
updateState(eventId, PreviewUrlUiState.Error(it))
}
}
)
}
}
}
private fun updateState(eventId: String, state: PreviewUrlUiState) {
data[eventId] = state
// Notify the listener
listeners[eventId].orEmpty().forEach {
it.onStateUpdated(state)
}
}
// Called by the Epoxy item during binding
fun addListener(key: String, listener: PreviewUrlRetrieverListener) {
listeners.getOrPut(key) { mutableSetOf() }.add(listener)
// Give the current state if any
synchronized(data) {
listener.onStateUpdated(data[key] ?: PreviewUrlUiState.Unknown)
}
}
// Called by the Epoxy item during unbinding
fun removeListener(key: String, listener: PreviewUrlRetrieverListener) {
listeners.getOrPut(key) { mutableSetOf() }.remove(listener)
}
interface PreviewUrlRetrieverListener {
fun onStateUpdated(state: PreviewUrlUiState)
}
companion object {
// One week in millis
private const val CACHE_VALIDITY: Long = 7 * 24 * 3_600 * 1_000
}
}

View File

@ -0,0 +1,39 @@
/*
* Copyright (c) 2020 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 im.vector.app.features.home.room.detail.timeline.url
import org.matrix.android.sdk.api.session.media.PreviewUrlData
/**
* The state representing a preview url UI state for an Event
*/
sealed class PreviewUrlUiState {
// No info
object Unknown : PreviewUrlUiState()
// The event does not contain any URLs
object NoUrl : PreviewUrlUiState()
// Loading
object Loading : PreviewUrlUiState()
// Error
data class Error(val throwable: Throwable) : PreviewUrlUiState()
// PreviewUrl data
data class Data(val previewUrlData: PreviewUrlData) : PreviewUrlUiState()
}

View File

@ -83,6 +83,19 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
STICKER
}
/**
* For url preview
*/
fun render(mxcUrl: String, imageView: ImageView) {
val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver()
val imageUrl = contentUrlResolver.resolveFullSize(mxcUrl) ?: return
GlideApp.with(imageView)
.load(imageUrl)
.placeholder(R.drawable.ic_image)
.into(imageView)
}
/**
* For gallery
*/
@ -227,7 +240,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver()
val resolvedUrl = when (mode) {
Mode.FULL_SIZE,
Mode.STICKER -> resolveUrl(data)
Mode.STICKER -> resolveUrl(data)
Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, size.width, size.height, ContentUrlResolver.ThumbnailMethod.SCALE)
}
// Fallback to base url
@ -295,7 +308,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
finalHeight = min(maxImageWidth * height / width, maxImageHeight)
finalWidth = finalHeight * width / height
}
Mode.STICKER -> {
Mode.STICKER -> {
// limit on width
val maxWidthDp = min(dimensionConverter.dpToPx(120), maxImageWidth / 2)
finalWidth = min(dimensionConverter.dpToPx(width), maxWidthDp)

View File

@ -87,7 +87,6 @@
android:id="@+id/messageContentTextStub"
style="@style/TimelineContentStubBaseParams"
android:layout_height="wrap_content"
android:inflatedId="@id/messageTextView"
android:layout="@layout/item_timeline_event_text_message_stub"
tools:visibility="visible" />
@ -143,16 +142,6 @@
android:addStatesFromChildren="true"
android:orientation="vertical">
<include
android:id="@+id/informationUrlPreview"
layout="@layout/url_preview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginBottom="4dp"
android:visibility="gone"
tools:visibility="visible" />
<com.google.android.flexbox.FlexboxLayout
android:id="@+id/reactionsContainer"
android:layout_width="match_parent"

View File

@ -1,9 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/messageTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="?riotx_text_primary"
android:textSize="14sp"
tools:text="@sample/matrix.json/data/message" />
android:orientation="vertical">
<TextView
android:id="@+id/messageTextView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="?riotx_text_primary"
android:textSize="14sp"
tools:text="@sample/matrix.json/data/message" />
<im.vector.app.core.ui.views.PreviewUrlView
android:id="@+id/messageUrlPreview"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:layout_marginBottom="4dp"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>

View File

@ -1,10 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<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/informationUrlPreviewContainer"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="wrap_content"
tools:parentTag="androidx.constraintlayout.widget.ConstraintLayout">
<View
android:id="@+id/url_preview_left_border"
@ -34,6 +35,7 @@
android:layout_width="wrap_content"
android:layout_height="157dp"
android:layout_marginTop="16dp"
android:importantForAccessibility="no"
android:scaleType="centerCrop"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="@+id/url_preview_title"
@ -69,4 +71,4 @@
app:layout_constraintTop_toBottomOf="@+id/url_preview_description"
tools:text="BBC News" />
</androidx.constraintlayout.widget.ConstraintLayout>
</merge>