mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2025-03-18 20:29:10 +03:00
Render image reactions (MSC3746)
Some notes: - Doesn't re-parse reactions already in the db to add the url field - so may need an initial sync for those. - Since some clients don't really follow MSC3746, as in: they don't use the url field, but instead only write and check the key if it is an mxc-url, support those as well. - Accordingly, initial sync is likely not required for those reactions I've seen in the wild so far, as it's common to use the mxc url also as key. Change-Id: Ib1c50315425494986fa2e794d165658220a4f342
This commit is contained in:
parent
88556658a3
commit
85a26ae8be
19 changed files with 160 additions and 7 deletions
|
@ -30,6 +30,7 @@ Here you can find some extra features and changes compared to Element Android (w
|
|||
- Setting to re-alert for new messages even if there's still an old notification for that room
|
||||
- Setting to hide start call buttons from the room's toolbar
|
||||
- Render inline images / custom emojis in the timeline
|
||||
- Render image reactions
|
||||
|
||||
- Branding (name, app icon, links)
|
||||
- Show a toast instead of a snackbar after copying text, in order to not block the input area right after copying
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
package de.spiritcroc.android.sdk.internal.database.migration
|
||||
|
||||
import de.spiritcroc.android.sdk.internal.util.database.ScRealmMigrator
|
||||
import io.realm.DynamicRealm
|
||||
import org.matrix.android.sdk.internal.database.model.ReactionAggregatedSummaryEntityFields
|
||||
|
||||
internal class MigrateScSessionTo005(realm: DynamicRealm) : ScRealmMigrator(realm, 5) {
|
||||
|
||||
override fun doMigrate(realm: DynamicRealm) {
|
||||
realm.schema.get("ReactionAggregatedSummaryEntity")
|
||||
?.addField(ReactionAggregatedSummaryEntityFields.URL, String::class.java)
|
||||
}
|
||||
}
|
|
@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.session.room.model
|
|||
|
||||
data class ReactionAggregatedSummary(
|
||||
val key: String, // "👍"
|
||||
val url: String?, // mxc://...
|
||||
val count: Int, // 8
|
||||
val addedByMe: Boolean, // true
|
||||
val firstTimestamp: Long, // unix timestamp
|
||||
|
|
|
@ -21,5 +21,6 @@ import com.squareup.moshi.JsonClass
|
|||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class ReactionContent(
|
||||
@Json(name = "m.relates_to") val relatesTo: ReactionInfo? = null
|
||||
@Json(name = "m.relates_to") val relatesTo: ReactionInfo? = null,
|
||||
@Json(name = "url") val url: String? = null,
|
||||
)
|
||||
|
|
|
@ -20,6 +20,7 @@ import de.spiritcroc.android.sdk.internal.database.migration.MigrateScSessionTo0
|
|||
import de.spiritcroc.android.sdk.internal.database.migration.MigrateScSessionTo002
|
||||
import de.spiritcroc.android.sdk.internal.database.migration.MigrateScSessionTo003
|
||||
import de.spiritcroc.android.sdk.internal.database.migration.MigrateScSessionTo004
|
||||
import de.spiritcroc.android.sdk.internal.database.migration.MigrateScSessionTo005
|
||||
import io.realm.DynamicRealm
|
||||
import io.realm.RealmMigration
|
||||
import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo001
|
||||
|
@ -64,7 +65,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
|
|||
override fun hashCode() = 1000
|
||||
|
||||
// SC-specific DB changes on top of Element
|
||||
private val scSchemaVersion = 4L
|
||||
private val scSchemaVersion = 5L
|
||||
private val scSchemaVersionOffset = (1L shl 12)
|
||||
|
||||
val schemaVersion = 27L +
|
||||
|
@ -108,5 +109,6 @@ internal class RealmSessionStoreMigration @Inject constructor(
|
|||
if (oldScVersion <= 1) MigrateScSessionTo002(realm).perform()
|
||||
if (oldScVersion <= 2) MigrateScSessionTo003(realm).perform()
|
||||
if (oldScVersion <= 3) MigrateScSessionTo004(realm).perform()
|
||||
if (oldScVersion <= 4) MigrateScSessionTo005(realm).perform()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -28,6 +28,7 @@ internal object EventAnnotationsSummaryMapper {
|
|||
reactionsSummary = annotationsSummary.reactionsSummary.toList().map {
|
||||
ReactionAggregatedSummary(
|
||||
it.key,
|
||||
it.url,
|
||||
it.count,
|
||||
it.addedByMe,
|
||||
it.firstTimestamp,
|
||||
|
|
|
@ -25,6 +25,8 @@ import io.realm.RealmObject
|
|||
internal open class ReactionAggregatedSummaryEntity(
|
||||
// The reaction String 😀
|
||||
var key: String = "",
|
||||
// mxc url
|
||||
var url: String? = null,
|
||||
// Number of time this reaction was selected
|
||||
var count: Int = 0,
|
||||
// Did the current user sent this reaction
|
||||
|
|
|
@ -597,12 +597,13 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
|
|||
// rel_type must be m.annotation
|
||||
if (RelationType.ANNOTATION == content.relatesTo?.type) {
|
||||
val reaction = content.relatesTo.key
|
||||
val url = content.url
|
||||
val relatedEventID = content.relatesTo.eventId
|
||||
val reactionEventId = event.eventId
|
||||
Timber.v("Reaction $reactionEventId relates to $relatedEventID")
|
||||
val eventSummary = EventAnnotationsSummaryEntity.getOrCreate(realm, roomId, relatedEventID)
|
||||
|
||||
var sum = eventSummary.reactionsSummary.find { it.key == reaction }
|
||||
var sum = eventSummary.reactionsSummary.find { it.key == reaction && it.url == url }
|
||||
val txId = event.unsignedData?.transactionId
|
||||
if (isLocalEcho && txId.isNullOrBlank()) {
|
||||
Timber.w("Received a local echo with no transaction ID")
|
||||
|
@ -610,6 +611,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
|
|||
if (sum == null) {
|
||||
sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java)
|
||||
sum.key = reaction
|
||||
sum.url = url
|
||||
sum.firstTimestamp = event.originServerTs ?: 0
|
||||
if (isLocalEcho) {
|
||||
Timber.v("Adding local echo reaction")
|
||||
|
|
|
@ -111,6 +111,7 @@ internal class UIEchoManager(
|
|||
// just add the new key
|
||||
ReactionAggregatedSummary(
|
||||
key = uiEchoReaction.reaction,
|
||||
url = null,
|
||||
count = 1,
|
||||
addedByMe = true,
|
||||
firstTimestamp = clock.epochMillis(),
|
||||
|
@ -125,6 +126,7 @@ internal class UIEchoManager(
|
|||
// only update if echo is not yet there
|
||||
ReactionAggregatedSummary(
|
||||
key = existing.key,
|
||||
url = existing.url,
|
||||
count = existing.count + 1,
|
||||
addedByMe = true,
|
||||
firstTimestamp = existing.firstTimestamp,
|
||||
|
|
|
@ -0,0 +1,63 @@
|
|||
package im.vector.app.core.glide
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import com.bumptech.glide.load.DataSource
|
||||
import com.bumptech.glide.load.engine.GlideException
|
||||
import com.bumptech.glide.request.RequestListener
|
||||
import com.bumptech.glide.request.target.Target
|
||||
import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl
|
||||
import org.matrix.android.sdk.api.extensions.orFalse
|
||||
import org.matrix.android.sdk.api.session.Session
|
||||
import org.matrix.android.sdk.api.session.content.ContentUrlResolver
|
||||
import timber.log.Timber
|
||||
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun renderReactionImage(reactionUrl: String?,
|
||||
reactionKey: String?,
|
||||
size: Int,
|
||||
session: Session,
|
||||
textView: TextView,
|
||||
imageView: ImageView) {
|
||||
val effectiveReactionUrl = when {
|
||||
!reactionUrl.isNullOrEmpty() -> reactionUrl
|
||||
reactionKey?.isMxcUrl().orFalse() -> reactionKey
|
||||
else -> null
|
||||
}
|
||||
if (effectiveReactionUrl.isNullOrEmpty()) {
|
||||
textView.isVisible = true
|
||||
imageView.isVisible = false
|
||||
} else {
|
||||
// Not all thumbnail providers allow GIFs!
|
||||
//val url = session.contentUrlResolver().resolveThumbnail(effectiveReactionUrl, size, size, ContentUrlResolver.ThumbnailMethod.SCALE)
|
||||
val url = session.contentUrlResolver().resolveFullSize(effectiveReactionUrl)
|
||||
if (url == null) {
|
||||
textView.isVisible = true
|
||||
imageView.isVisible = false
|
||||
} else {
|
||||
textView.isVisible = false
|
||||
imageView.isVisible = true
|
||||
GlideApp.with(imageView)
|
||||
.load(url)
|
||||
.centerCrop()
|
||||
.listener(object : RequestListener<Drawable> {
|
||||
override fun onLoadFailed(e: GlideException?, model: Any?, target: Target<Drawable>?, isFirstResource: Boolean): Boolean {
|
||||
Timber.w("Reaction image load failed for $effectiveReactionUrl: $e")
|
||||
textView.isVisible = true
|
||||
imageView.isVisible = false
|
||||
return false
|
||||
}
|
||||
override fun onResourceReady(resource: Drawable?,
|
||||
model: Any?,
|
||||
target: Target<Drawable>?,
|
||||
dataSource: DataSource?,
|
||||
isFirstResource: Boolean): Boolean {
|
||||
return false
|
||||
}
|
||||
})
|
||||
.into(imageView)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -39,7 +39,7 @@ class ReactionsSummaryFactory @Inject constructor() {
|
|||
val showAllStates = showAllReactionsByEvent.contains(eventId)
|
||||
val reactions = event.annotations?.reactionsSummary
|
||||
?.map {
|
||||
ReactionInfoData(it.key, it.count, it.addedByMe, it.localEchoEvents.isEmpty())
|
||||
ReactionInfoData(it.key, it.url, it.count, it.addedByMe, it.localEchoEvents.isEmpty())
|
||||
}
|
||||
return ReactionsSummaryData(
|
||||
reactions = reactions,
|
||||
|
|
|
@ -120,6 +120,7 @@ abstract class AbsBaseMessageItem<H : AbsBaseMessageItem.Holder> : BaseEventItem
|
|||
reactionButton.reactedListener = reactionClickListener
|
||||
reactionButton.setTag(R.id.reactionsContainer, reaction.key)
|
||||
reactionButton.reactionString = reaction.key
|
||||
reactionButton.reactionUrl = reaction.url
|
||||
reactionButton.reactionCount = reaction.count
|
||||
reactionButton.setChecked(reaction.addedByMe)
|
||||
reactionButton.isEnabled = reaction.synced
|
||||
|
|
|
@ -75,6 +75,7 @@ data class ReactionsSummaryEvents(
|
|||
@Parcelize
|
||||
data class ReactionInfoData(
|
||||
val key: String,
|
||||
val url: String?,
|
||||
val count: Int,
|
||||
val addedByMe: Boolean,
|
||||
val synced: Boolean
|
||||
|
|
|
@ -16,15 +16,19 @@
|
|||
|
||||
package im.vector.app.features.home.room.detail.timeline.reactions
|
||||
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.core.view.isVisible
|
||||
import com.airbnb.epoxy.EpoxyAttribute
|
||||
import com.airbnb.epoxy.EpoxyModelClass
|
||||
import com.airbnb.epoxy.EpoxyModelWithHolder
|
||||
import im.vector.app.R
|
||||
import im.vector.app.core.di.ActiveSessionHolder
|
||||
import im.vector.app.core.epoxy.ClickListener
|
||||
import im.vector.app.core.epoxy.VectorEpoxyHolder
|
||||
import im.vector.app.core.epoxy.onClick
|
||||
import im.vector.app.core.glide.renderReactionImage
|
||||
import im.vector.app.core.utils.DimensionConverter
|
||||
import im.vector.lib.core.utils.epoxy.charsequence.EpoxyCharSequence
|
||||
|
||||
/**
|
||||
|
@ -36,6 +40,9 @@ abstract class ReactionInfoSimpleItem : EpoxyModelWithHolder<ReactionInfoSimpleI
|
|||
@EpoxyAttribute
|
||||
lateinit var reactionKey: EpoxyCharSequence
|
||||
|
||||
@EpoxyAttribute
|
||||
var reactionUrl: String? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
lateinit var authorDisplayName: String
|
||||
|
||||
|
@ -45,6 +52,12 @@ abstract class ReactionInfoSimpleItem : EpoxyModelWithHolder<ReactionInfoSimpleI
|
|||
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
|
||||
var userClicked: ClickListener? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
lateinit var dimensionConverter: DimensionConverter
|
||||
|
||||
@EpoxyAttribute
|
||||
lateinit var activeSessionHolder: ActiveSessionHolder
|
||||
|
||||
override fun bind(holder: Holder) {
|
||||
super.bind(holder)
|
||||
holder.emojiReactionView.text = reactionKey.charSequence
|
||||
|
@ -56,10 +69,16 @@ abstract class ReactionInfoSimpleItem : EpoxyModelWithHolder<ReactionInfoSimpleI
|
|||
holder.timeStampView.isVisible = false
|
||||
}
|
||||
holder.view.onClick(userClicked)
|
||||
|
||||
activeSessionHolder.getSafeActiveSession()?.let { session ->
|
||||
val size = dimensionConverter.dpToPx(16)
|
||||
renderReactionImage(reactionUrl, reactionKey.charSequence.toString(), size, session, holder.emojiReactionView, holder.emojiReactionImageView)
|
||||
}
|
||||
}
|
||||
|
||||
class Holder : VectorEpoxyHolder() {
|
||||
val emojiReactionView by bind<TextView>(R.id.itemSimpleReactionInfoKey)
|
||||
val emojiReactionImageView by bind<ImageView>(R.id.itemSimpleReactionInfoImage)
|
||||
val displayNameView by bind<TextView>(R.id.itemSimpleReactionInfoMemberName)
|
||||
val timeStampView by bind<TextView>(R.id.itemSimpleReactionInfoTime)
|
||||
}
|
||||
|
|
|
@ -23,9 +23,11 @@ import com.airbnb.mvrx.Success
|
|||
import com.airbnb.mvrx.Uninitialized
|
||||
import im.vector.app.EmojiSpanify
|
||||
import im.vector.app.R
|
||||
import im.vector.app.core.di.ActiveSessionHolder
|
||||
import im.vector.app.core.resources.StringProvider
|
||||
import im.vector.app.core.ui.list.genericFooterItem
|
||||
import im.vector.app.core.ui.list.genericLoaderItem
|
||||
import im.vector.app.core.utils.DimensionConverter
|
||||
import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -34,6 +36,8 @@ import javax.inject.Inject
|
|||
*/
|
||||
class ViewReactionsEpoxyController @Inject constructor(
|
||||
private val stringProvider: StringProvider,
|
||||
private val dimensionConverter: DimensionConverter,
|
||||
private val activeSessionHolder: ActiveSessionHolder,
|
||||
private val emojiSpanify: EmojiSpanify) :
|
||||
TypedEpoxyController<DisplayReactionsViewState>() {
|
||||
|
||||
|
@ -60,6 +64,9 @@ class ViewReactionsEpoxyController @Inject constructor(
|
|||
id(reactionInfo.eventId)
|
||||
timeStamp(reactionInfo.timestamp)
|
||||
reactionKey(host.emojiSpanify.spanify(reactionInfo.reactionKey).toEpoxyCharSequence())
|
||||
reactionUrl(reactionInfo.reactionUrl)
|
||||
dimensionConverter(host.dimensionConverter)
|
||||
activeSessionHolder(host.activeSessionHolder)
|
||||
authorDisplayName(reactionInfo.authorName ?: reactionInfo.authorId)
|
||||
userClicked { host.listener?.didSelectUser(reactionInfo.authorId) }
|
||||
}
|
||||
|
|
|
@ -52,7 +52,8 @@ data class ReactionInfo(
|
|||
val reactionKey: String,
|
||||
val authorId: String,
|
||||
val authorName: String? = null,
|
||||
val timestamp: String? = null
|
||||
val timestamp: String? = null,
|
||||
val reactionUrl: String? = null
|
||||
)
|
||||
|
||||
/**
|
||||
|
@ -95,7 +96,8 @@ class ViewReactionsViewModel @AssistedInject constructor(
|
|||
reactionsSummary.key,
|
||||
event.root.senderId ?: "",
|
||||
event.senderInfo.disambiguatedDisplayName,
|
||||
dateFormatter.format(event.root.originServerTs, DateFormatKind.DEFAULT_DATE_AND_TIME)
|
||||
dateFormatter.format(event.root.originServerTs, DateFormatKind.DEFAULT_DATE_AND_TIME),
|
||||
reactionUrl = reactionsSummary.url
|
||||
|
||||
)
|
||||
}
|
||||
|
|
|
@ -25,6 +25,9 @@ import androidx.core.content.withStyledAttributes
|
|||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import im.vector.app.EmojiSpanify
|
||||
import im.vector.app.R
|
||||
import im.vector.app.core.di.ActiveSessionHolder
|
||||
import im.vector.app.core.glide.renderReactionImage
|
||||
import im.vector.app.core.utils.DimensionConverter
|
||||
import im.vector.app.core.utils.TextUtils
|
||||
import im.vector.app.databinding.ReactionButtonBinding
|
||||
import javax.inject.Inject
|
||||
|
@ -40,6 +43,9 @@ class ReactionButton @JvmOverloads constructor(context: Context,
|
|||
defStyleRes: Int = R.style.TimelineReactionView) :
|
||||
LinearLayout(context, attrs, defStyleAttr, defStyleRes), View.OnClickListener, View.OnLongClickListener {
|
||||
|
||||
@Inject lateinit var activeSessionHolder: ActiveSessionHolder
|
||||
@Inject lateinit var dimensionConverter: DimensionConverter
|
||||
|
||||
@Inject lateinit var emojiSpanify: EmojiSpanify
|
||||
|
||||
private val views: ReactionButtonBinding
|
||||
|
@ -60,6 +66,16 @@ class ReactionButton @JvmOverloads constructor(context: Context,
|
|||
views.reactionText.text = emojiSpanned
|
||||
}
|
||||
|
||||
var reactionUrl: String? = null
|
||||
set(value) {
|
||||
field = value
|
||||
|
||||
activeSessionHolder.getSafeActiveSession()?.let { session ->
|
||||
val size = dimensionConverter.dpToPx(12)
|
||||
renderReactionImage(reactionUrl, reactionString, size, session,views.reactionText, views.reactionImage)
|
||||
}
|
||||
}
|
||||
|
||||
private var isChecked: Boolean = false
|
||||
private var onDrawable: Drawable? = null
|
||||
private var offDrawable: Drawable? = null
|
||||
|
|
|
@ -11,6 +11,16 @@
|
|||
android:paddingEnd="8dp"
|
||||
tools:viewBindingIgnore="true">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/itemSimpleReactionInfoImage"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="4dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:gravity="center"
|
||||
android:visibility="gone"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/itemSimpleReactionInfoKey"
|
||||
style="@style/Widget.Vector.TextView.HeadlineMedium"
|
||||
|
@ -32,4 +42,4 @@
|
|||
style="@style/BottomSheetItemTime"
|
||||
tools:text="10:44" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
|
|
@ -14,6 +14,15 @@
|
|||
<!--android:layout_height="match_parent"-->
|
||||
<!--android:background="@drawable/rounded_rect_shape" />-->
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/reactionImage"
|
||||
android:layout_width="12dp"
|
||||
android:layout_height="12dp"
|
||||
android:layout_marginHorizontal="4dp"
|
||||
android:gravity="center"
|
||||
android:visibility="gone"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/reactionText"
|
||||
style="@style/Widget.Vector.TextView.Caption"
|
||||
|
|
Loading…
Add table
Reference in a new issue