New View Reactions bottom sheet

+ visible on reaction long click
+ Reaction pills size adapt to count, and number format
This commit is contained in:
Valere 2019-06-05 19:23:57 +02:00
parent d2f648edec
commit 440442bb99
23 changed files with 492 additions and 68 deletions

View File

@ -15,7 +15,9 @@
*/ */
package im.vector.matrix.android.api.session.room.model.relation package im.vector.matrix.android.api.session.room.model.relation


import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.api.util.Cancelable


/** /**
@ -91,4 +93,5 @@ interface RelationService {
*/ */
fun replyToMessage(eventReplied: Event, replyText: String): Cancelable? fun replyToMessage(eventReplied: Event, replyText: String): Cancelable?


fun getEventSummaryLive(eventId: String): LiveData<List<EventAnnotationsSummary>>
} }

View File

@ -15,15 +15,19 @@
*/ */
package im.vector.matrix.android.internal.session.room.relation package im.vector.matrix.android.internal.session.room.relation


import androidx.lifecycle.LiveData
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.model.relation.RelationService import im.vector.matrix.android.api.session.room.model.relation.RelationService
import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.database.helper.addSendingEvent import im.vector.matrix.android.internal.database.helper.addSendingEvent
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity
import im.vector.matrix.android.internal.database.model.RoomEntity import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where import im.vector.matrix.android.internal.database.query.where
@ -169,6 +173,18 @@ internal class DefaultRelationService(private val roomId: String,
return CancelableWork(workRequest.id) return CancelableWork(workRequest.id)
} }



override fun getEventSummaryLive(eventId: String): LiveData<List<EventAnnotationsSummary>> {
return monarchy.findAllMappedWithChanges(
{ realm ->
EventAnnotationsSummaryEntity.where(realm, eventId)
},
{
it.asDomain()
}
)
}

/** /**
* Saves the event in database as a local echo. * Saves the event in database as a local echo.
* SendState is set to UNSENT and it's added to a the sendingTimelineEvents list of the room. * SendState is set to UNSENT and it's added to a the sendingTimelineEvents list of the room.

View File

@ -0,0 +1,22 @@
{
"data": [
{
"reaction" : "👍"
},
{
"reaction" : "😀"
},
{
"reaction" : "😞"
},
{
"reaction" : "Not a reaction"
},
{
"reaction" : "✅"
},
{
"reaction" : "🎉"
}
]
}

View File

@ -0,0 +1,29 @@
package im.vector.riotredesign.core.utils

import java.util.*

object TextUtils {

private val suffixes = TreeMap<Int, String>().also {
it.put(1000, "k")
it.put(1000000, "M")
it.put(1000000000, "G")
}

fun formatCountToShortDecimal(value: Int): String {
try {
if (value < 0) return "-" + formatCountToShortDecimal(-value)
if (value < 1000) return value.toString() //deal with easy case

val e = suffixes.floorEntry(value)
val divideBy = e.key
val suffix = e.value

val truncated = value / (divideBy!! / 10) //the number part of the output times 10
val hasDecimal = truncated < 100 && truncated / 10.0 != (truncated / 10).toDouble()
return if (hasDecimal) "${truncated / 10.0}$suffix" else "${truncated / 10}$suffix"
} catch (t: Throwable) {
return value.toString()
}
}
}

View File

@ -84,6 +84,7 @@ import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventCo
import im.vector.riotredesign.features.home.room.detail.timeline.action.ActionsHandler import im.vector.riotredesign.features.home.room.detail.timeline.action.ActionsHandler
import im.vector.riotredesign.features.home.room.detail.timeline.action.MessageActionsBottomSheet import im.vector.riotredesign.features.home.room.detail.timeline.action.MessageActionsBottomSheet
import im.vector.riotredesign.features.home.room.detail.timeline.action.MessageMenuViewModel import im.vector.riotredesign.features.home.room.detail.timeline.action.MessageMenuViewModel
import im.vector.riotredesign.features.home.room.detail.timeline.action.ViewReactionBottomSheet
import im.vector.riotredesign.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener import im.vector.riotredesign.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotredesign.features.html.PillImageSpan import im.vector.riotredesign.features.html.PillImageSpan
@ -235,11 +236,13 @@ class RoomDetailFragment :
var formattedBody: CharSequence? = null var formattedBody: CharSequence? = null
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) { if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
val parser = Parser.builder().build() val parser = Parser.builder().build()
val document = parser.parse(messageContent.formattedBody ?: messageContent.body) val document = parser.parse(messageContent.formattedBody
?: messageContent.body)
formattedBody = Markwon.builder(requireContext()) formattedBody = Markwon.builder(requireContext())
.usePlugin(HtmlPlugin.create()).build().render(document) .usePlugin(HtmlPlugin.create()).build().render(document)
} }
composerLayout.composerRelatedMessageContent.text = formattedBody ?: nonFormattedBody composerLayout.composerRelatedMessageContent.text = formattedBody
?: nonFormattedBody




if (mode == SendMode.EDIT) { if (mode == SendMode.EDIT) {
@ -593,6 +596,11 @@ class RoomDetailFragment :
} }
} }


override fun onLongClickOnReactionPill(informationData: MessageInformationData, reaction: String) {
ViewReactionBottomSheet.newInstance(roomDetailArgs.roomId, informationData)
.show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS")
}

override fun onEditedDecorationClicked(informationData: MessageInformationData, editAggregatedSummary: EditAggregatedSummary?) { override fun onEditedDecorationClicked(informationData: MessageInformationData, editAggregatedSummary: EditAggregatedSummary?) {
editAggregatedSummary?.also { editAggregatedSummary?.also {
roomDetailViewModel.process(RoomDetailActions.ShowEditHistoryAction(informationData.eventId, it)) roomDetailViewModel.process(RoomDetailActions.ShowEditHistoryAction(informationData.eventId, it))

View File

@ -62,6 +62,7 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,


interface ReactionPillCallback { interface ReactionPillCallback {
fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean) fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean)
fun onLongClickOnReactionPill(informationData: MessageInformationData, reaction: String)
} }


private val collapsedEventIds = linkedSetOf<String>() private val collapsedEventIds = linkedSetOf<String>()

View File

@ -17,7 +17,6 @@ package im.vector.riotredesign.features.home.room.detail.timeline.action


import android.app.Dialog import android.app.Dialog
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@ -36,7 +35,6 @@ import im.vector.riotredesign.R
import im.vector.riotredesign.core.glide.GlideApp import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.features.home.AvatarRenderer import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import kotlinx.android.parcel.Parcelize


/** /**
* Bottom sheet fragment that shows a message preview with list of contextual actions * Bottom sheet fragment that shows a message preview with list of contextual actions
@ -74,7 +72,7 @@ class MessageActionsBottomSheet : BaseMvRxBottomSheetDialog() {
val cfm = childFragmentManager val cfm = childFragmentManager
var menuActionFragment = cfm.findFragmentByTag("MenuActionFragment") as? MessageMenuFragment var menuActionFragment = cfm.findFragmentByTag("MenuActionFragment") as? MessageMenuFragment
if (menuActionFragment == null) { if (menuActionFragment == null) {
menuActionFragment = MessageMenuFragment.newInstance(arguments!!.get(MvRx.KEY_ARG) as ParcelableArgs) menuActionFragment = MessageMenuFragment.newInstance(arguments!!.get(MvRx.KEY_ARG) as TimelineEventFragmentArgs)
cfm.beginTransaction() cfm.beginTransaction()
.replace(R.id.bottom_sheet_menu_container, menuActionFragment, "MenuActionFragment") .replace(R.id.bottom_sheet_menu_container, menuActionFragment, "MenuActionFragment")
.commit() .commit()
@ -89,7 +87,7 @@ class MessageActionsBottomSheet : BaseMvRxBottomSheetDialog() {


var quickReactionFragment = cfm.findFragmentByTag("QuickReaction") as? QuickReactionFragment var quickReactionFragment = cfm.findFragmentByTag("QuickReaction") as? QuickReactionFragment
if (quickReactionFragment == null) { if (quickReactionFragment == null) {
quickReactionFragment = QuickReactionFragment.newInstance(arguments!!.get(MvRx.KEY_ARG) as ParcelableArgs) quickReactionFragment = QuickReactionFragment.newInstance(arguments!!.get(MvRx.KEY_ARG) as TimelineEventFragmentArgs)
cfm.beginTransaction() cfm.beginTransaction()
.replace(R.id.bottom_sheet_quick_reaction_container, quickReactionFragment, "QuickReaction") .replace(R.id.bottom_sheet_quick_reaction_container, quickReactionFragment, "QuickReaction")
.commit() .commit()
@ -135,18 +133,11 @@ class MessageActionsBottomSheet : BaseMvRxBottomSheetDialog() {
} }




@Parcelize
data class ParcelableArgs(
val eventId: String,
val roomId: String,
val informationData: MessageInformationData
) : Parcelable

companion object { companion object {
fun newInstance(roomId: String, informationData: MessageInformationData): MessageActionsBottomSheet { fun newInstance(roomId: String, informationData: MessageInformationData): MessageActionsBottomSheet {
return MessageActionsBottomSheet().apply { return MessageActionsBottomSheet().apply {
setArguments( setArguments(
ParcelableArgs( TimelineEventFragmentArgs(
informationData.eventId, informationData.eventId,
roomId, roomId,
informationData informationData

View File

@ -25,7 +25,6 @@ import im.vector.matrix.android.api.session.room.model.message.MessageTextConten
import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.riotredesign.core.platform.VectorViewModel import im.vector.riotredesign.core.platform.VectorViewModel
import org.commonmark.parser.Parser import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import org.koin.android.ext.android.get import org.koin.android.ext.android.get
import ru.noties.markwon.Markwon import ru.noties.markwon.Markwon
import ru.noties.markwon.html.HtmlPlugin import ru.noties.markwon.html.HtmlPlugin
@ -51,7 +50,7 @@ class MessageActionsViewModel(initialState: MessageActionState) : VectorViewMode


override fun initialState(viewModelContext: ViewModelContext): MessageActionState? { override fun initialState(viewModelContext: ViewModelContext): MessageActionState? {
val currentSession = viewModelContext.activity.get<Session>() val currentSession = viewModelContext.activity.get<Session>()
val parcel = viewModelContext.args as MessageActionsBottomSheet.ParcelableArgs val parcel = viewModelContext.args as TimelineEventFragmentArgs


val dateFormat = SimpleDateFormat("EEE, d MMM yyyy HH:mm", Locale.getDefault()) val dateFormat = SimpleDateFormat("EEE, d MMM yyyy HH:mm", Locale.getDefault())



View File

@ -101,7 +101,7 @@ class MessageMenuFragment : BaseMvRxFragment() {




companion object { companion object {
fun newInstance(pa: MessageActionsBottomSheet.ParcelableArgs): MessageMenuFragment { fun newInstance(pa: TimelineEventFragmentArgs): MessageMenuFragment {
val args = Bundle() val args = Bundle()
args.putParcelable(MvRx.KEY_ARG, pa) args.putParcelable(MvRx.KEY_ARG, pa)
val fragment = MessageMenuFragment() val fragment = MessageMenuFragment()

View File

@ -46,7 +46,7 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
override fun initialState(viewModelContext: ViewModelContext): MessageMenuState? { override fun initialState(viewModelContext: ViewModelContext): MessageMenuState? {
// Args are accessible from the context. // Args are accessible from the context.
val currentSession = viewModelContext.activity.get<Session>() val currentSession = viewModelContext.activity.get<Session>()
val parcel = viewModelContext.args as MessageActionsBottomSheet.ParcelableArgs val parcel = viewModelContext.args as TimelineEventFragmentArgs
val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId) val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId)
?: return null ?: return null



View File

@ -139,7 +139,7 @@ class QuickReactionFragment : BaseMvRxFragment() {
} }


companion object { companion object {
fun newInstance(pa: MessageActionsBottomSheet.ParcelableArgs): QuickReactionFragment { fun newInstance(pa: TimelineEventFragmentArgs): QuickReactionFragment {
val args = Bundle() val args = Bundle()
args.putParcelable(MvRx.KEY_ARG, pa) args.putParcelable(MvRx.KEY_ARG, pa)
val fragment = QuickReactionFragment() val fragment = QuickReactionFragment()

View File

@ -124,7 +124,7 @@ class QuickReactionViewModel(initialState: QuickReactionState) : VectorViewModel
// Args are accessible from the context. // Args are accessible from the context.
// val foo = vieWModelContext.args<MyArgs>.foo // val foo = vieWModelContext.args<MyArgs>.foo
val currentSession = viewModelContext.activity.get<Session>() val currentSession = viewModelContext.activity.get<Session>()
val parcel = viewModelContext.args as MessageActionsBottomSheet.ParcelableArgs val parcel = viewModelContext.args as TimelineEventFragmentArgs
val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId) val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId)
?: return null ?: return null
var agreeTriggle: TriggleState = TriggleState.NONE var agreeTriggle: TriggleState = TriggleState.NONE

View File

@ -0,0 +1,39 @@
package im.vector.riotredesign.features.home.room.detail.timeline.action

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.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyHolder


@EpoxyModelClass(layout = R.layout.item_simple_reaction_info)
abstract class ReactionInfoSimpleItem : EpoxyModelWithHolder<ReactionInfoSimpleItem.Holder>() {

@EpoxyAttribute
lateinit var reactionKey: CharSequence
@EpoxyAttribute
lateinit var authorDisplayName: CharSequence
@EpoxyAttribute
var timeStamp: CharSequence? = null

override fun bind(holder: Holder) {
holder.titleView.text = reactionKey
holder.displayNameView.text = authorDisplayName
timeStamp?.let {
holder.timeStampView.text = it
holder.timeStampView.isVisible = true
} ?: run {
holder.timeStampView.isVisible = false
}
}

class Holder : VectorEpoxyHolder() {
val titleView by bind<TextView>(R.id.itemSimpleReactionInfoKey)
val displayNameView by bind<TextView>(R.id.itemSimpleReactionInfoMemberName)
val timeStampView by bind<TextView>(R.id.itemSimpleReactionInfoTime)
}

}

View File

@ -0,0 +1,12 @@
package im.vector.riotredesign.features.home.room.detail.timeline.action

import android.os.Parcelable
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import kotlinx.android.parcel.Parcelize

@Parcelize
data class TimelineEventFragmentArgs(
val eventId: String,
val roomId: String,
val informationData: MessageInformationData
) : Parcelable

View File

@ -0,0 +1,71 @@
package im.vector.riotredesign.features.home.room.detail.timeline.action

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DividerItemDecoration
import butterknife.BindView
import butterknife.ButterKnife
import com.airbnb.epoxy.EpoxyRecyclerView
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.riotredesign.R
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import kotlinx.android.synthetic.main.bottom_sheet_display_reactions.*


class ViewReactionBottomSheet : BaseMvRxBottomSheetDialog() {

private val viewModel: ViewReactionViewModel by fragmentViewModel(ViewReactionViewModel::class)

private val eventArgs: TimelineEventFragmentArgs by args()

@BindView(R.id.bottom_sheet_display_reactions_list)
lateinit var epoxyRecyclerView: EpoxyRecyclerView

private val epoxyController by lazy { ViewReactionsEpoxyController() }

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.bottom_sheet_display_reactions, container, false)
ButterKnife.bind(this, view)
return view
}

override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
epoxyRecyclerView.setController(epoxyController)
val dividerItemDecoration = DividerItemDecoration(epoxyRecyclerView.context,
LinearLayout.VERTICAL)
epoxyRecyclerView.addItemDecoration(dividerItemDecoration)
}


override fun invalidate() = withState(viewModel) {
if (it.mapReactionKeyToMemberList() == null) {
bottomSheetViewReactionSpinner.isVisible = true
bottomSheetViewReactionSpinner.animate()
} else {
bottomSheetViewReactionSpinner.isVisible = false
}
epoxyController.setData(it)
}

companion object {
fun newInstance(roomId: String, informationData: MessageInformationData): ViewReactionBottomSheet {
val args = Bundle()
val parcelableArgs = TimelineEventFragmentArgs(
informationData.eventId,
roomId,
informationData
)
args.putParcelable(MvRx.KEY_ARG, parcelableArgs)
return ViewReactionBottomSheet().apply { arguments = args }

}
}
}

View File

@ -0,0 +1,106 @@
package im.vector.riotredesign.features.home.room.detail.timeline.action

import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import com.airbnb.mvrx.*
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.core.platform.VectorViewModel
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import org.koin.android.ext.android.get


data class DisplayReactionsViewState(
val eventId: String = "",
val roomId: String = "",
val mapReactionKeyToMemberList: Async<List<ReactionInfo>> = Uninitialized)
: MvRxState

data class ReactionInfo(
val eventId: String,
val reactionKey: String,
val authorId: String,
val authorName: String? = null,
val timestamp: String? = null
)

/**
* Used to display the list of members that reacted to a given event
*/
class ViewReactionViewModel(private val session: Session,
private val timelineDateFormatter: TimelineDateFormatter,
lifecycleOwner: LifecycleOwner?,
liveSummary: LiveData<List<EventAnnotationsSummary>>?,
initialState: DisplayReactionsViewState) : VectorViewModel<DisplayReactionsViewState>(initialState) {

init {
loadReaction()
if (lifecycleOwner != null) {
liveSummary?.observe(lifecycleOwner, Observer {
it?.firstOrNull()?.let {
loadReaction()
}
})
}

}

private fun loadReaction() = withState { state ->

GlobalScope.launch {
try {
val room = session.getRoom(state.roomId)
val event = room?.getTimeLineEvent(state.eventId)
if (event == null) {
setState { copy(mapReactionKeyToMemberList = Fail(Throwable())) }
return@launch
}
var results = ArrayList<ReactionInfo>()
event.annotations?.reactionsSummary?.forEach { sum ->

sum.sourceEvents.mapNotNull { room.getTimeLineEvent(it) }.forEach {
val localDate = it.root.localDateTime()
results.add(ReactionInfo(it.root.eventId!!, sum.key, it.root.sender
?: "", it.senderName, timelineDateFormatter.formatMessageHour(localDate)))
}
}
setState {
copy(
mapReactionKeyToMemberList = Success(results.sortedBy { it.timestamp })
)
}
} catch (t: Throwable) {
setState {
copy(
mapReactionKeyToMemberList = Fail(t)
)
}
}
}
}


companion object : MvRxViewModelFactory<ViewReactionViewModel, DisplayReactionsViewState> {

override fun initialState(viewModelContext: ViewModelContext): DisplayReactionsViewState? {

val roomId = (viewModelContext.args as? TimelineEventFragmentArgs)?.roomId
?: return null
val info = (viewModelContext.args as? TimelineEventFragmentArgs)?.informationData
?: return null
return DisplayReactionsViewState(info.eventId, roomId)
}

override fun create(viewModelContext: ViewModelContext, state: DisplayReactionsViewState): ViewReactionViewModel? {
val session = viewModelContext.activity.get<Session>()
val eventId = (viewModelContext.args as TimelineEventFragmentArgs).eventId
return ViewReactionViewModel(session, viewModelContext.activity.get(), viewModelContext.activity, session.getRoom(state.roomId)?.getEventSummaryLive(eventId), state)
}


}
}

View File

@ -0,0 +1,19 @@
package im.vector.riotredesign.features.home.room.detail.timeline.action

import com.airbnb.epoxy.TypedEpoxyController


class ViewReactionsEpoxyController : TypedEpoxyController<DisplayReactionsViewState>() {

override fun buildModels(state: DisplayReactionsViewState) {
val map = state.mapReactionKeyToMemberList() ?: return
map.forEach {
reactionInfoSimpleItem {
id(it.eventId)
timeStamp(it.timestamp)
reactionKey(it.reactionKey)
authorDisplayName(it.authorName ?: it.authorId)
}
}
}
}

View File

@ -65,6 +65,10 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
override fun onUnReacted(reactionButton: ReactionButton) { override fun onUnReacted(reactionButton: ReactionButton) {
reactionPillCallback?.onClickOnReactionPill(informationData, reactionButton.reactionString, false) reactionPillCallback?.onClickOnReactionPill(informationData, reactionButton.reactionString, false)
} }

override fun onLongClick(reactionButton: ReactionButton) {
reactionPillCallback?.onLongClickOnReactionPill(informationData, reactionButton.reactionString)
}
} }


override fun bind(holder: H) { override fun bind(holder: H) {
@ -112,7 +116,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
//clear all reaction buttons (but not the Flow helper!) //clear all reaction buttons (but not the Flow helper!)
holder.reactionWrapper?.children?.forEach { (it as? ReactionButton)?.isGone = true } holder.reactionWrapper?.children?.forEach { (it as? ReactionButton)?.isGone = true }
val idToRefInFlow = ArrayList<Int>() val idToRefInFlow = ArrayList<Int>()
informationData.orderedReactionList?.chunked(7)?.firstOrNull()?.forEachIndexed { index, reaction -> informationData.orderedReactionList?.chunked(8)?.firstOrNull()?.forEachIndexed { index, reaction ->
(holder.reactionWrapper?.children?.elementAtOrNull(index) as? ReactionButton)?.let { reactionButton -> (holder.reactionWrapper?.children?.elementAtOrNull(index) as? ReactionButton)?.let { reactionButton ->
reactionButton.isVisible = true reactionButton.isVisible = true
reactionButton.reactedListener = reactionClickListener reactionButton.reactedListener = reactionClickListener

View File

@ -37,13 +37,14 @@ import androidx.annotation.ColorInt
import androidx.annotation.ColorRes import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.core.utils.TextUtils


/** /**
* An animated reaction button. * An animated reaction button.
* Displays a String reaction (emoji), with a count, and that can be selected or not (toggle) * Displays a String reaction (emoji), with a count, and that can be selected or not (toggle)
*/ */
class ReactionButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null, class ReactionButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr), View.OnClickListener { defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr), View.OnClickListener, View.OnLongClickListener {


companion object { companion object {
private val DECCELERATE_INTERPOLATOR = DecelerateInterpolator() private val DECCELERATE_INTERPOLATOR = DecelerateInterpolator()
@ -74,7 +75,7 @@ class ReactionButton @JvmOverloads constructor(context: Context, attrs: Attribut
var reactionCount = 11 var reactionCount = 11
set(value) { set(value) {
field = value field = value
countTextView?.text = value.toString() countTextView?.text = TextUtils.formatCountToShortDecimal(value)
} }




@ -101,7 +102,7 @@ class ReactionButton @JvmOverloads constructor(context: Context, attrs: Attribut
reactionSelector = findViewById(R.id.reactionSelector) reactionSelector = findViewById(R.id.reactionSelector)
countTextView = findViewById(R.id.reactionCount) countTextView = findViewById(R.id.reactionCount)


countTextView?.text = reactionCount.toString() countTextView?.text = TextUtils.formatCountToShortDecimal(reactionCount)


emojiView?.typeface = this.emojiTypeFace ?: Typeface.DEFAULT emojiView?.typeface = this.emojiTypeFace ?: Typeface.DEFAULT


@ -136,6 +137,7 @@ class ReactionButton @JvmOverloads constructor(context: Context, attrs: Attribut
val status = array.getBoolean(R.styleable.ReactionButton_toggled, false) val status = array.getBoolean(R.styleable.ReactionButton_toggled, false)
setChecked(status) setChecked(status)
setOnClickListener(this) setOnClickListener(this)
setOnLongClickListener(this)
array.recycle() array.recycle()
} }


@ -242,40 +244,45 @@ class ReactionButton @JvmOverloads constructor(context: Context, attrs: Attribut
* @param event * @param event
* @return * @return
*/ */
override fun onTouchEvent(event: MotionEvent): Boolean { // override fun onTouchEvent(event: MotionEvent): Boolean {
if (!isEnabled) // if (!isEnabled)
return true // return true
//
// when (event.action) {
// MotionEvent.ACTION_DOWN ->
// /*
// Commented out this line and moved the animation effect to the action up event due to
// conflicts that were occurring when library is used in sliding type views.
//
// icon.animate().scaleX(0.7f).scaleY(0.7f).setDuration(150).setInterpolator(DECCELERATE_INTERPOLATOR);
// */
// isPressed = true
//
// MotionEvent.ACTION_MOVE -> {
// val x = event.x
// val y = event.y
// val isInside = x > 0 && x < width && y > 0 && y < height
// if (isPressed != isInside) {
// isPressed = isInside
// }
// }
//
// MotionEvent.ACTION_UP -> {
// emojiView!!.animate().scaleX(0.7f).scaleY(0.7f).setDuration(150).interpolator = DECCELERATE_INTERPOLATOR
// emojiView!!.animate().scaleX(1f).scaleY(1f).interpolator = DECCELERATE_INTERPOLATOR
// if (isPressed) {
// performClick()
// isPressed = false
// }
// }
// MotionEvent.ACTION_CANCEL -> isPressed = false
// }
// return true
// }


when (event.action) { override fun onLongClick(v: View?): Boolean {
MotionEvent.ACTION_DOWN -> reactedListener?.onLongClick(this)
/* return reactedListener != null
Commented out this line and moved the animation effect to the action up event due to
conflicts that were occurring when library is used in sliding type views.

icon.animate().scaleX(0.7f).scaleY(0.7f).setDuration(150).setInterpolator(DECCELERATE_INTERPOLATOR);
*/
isPressed = true

MotionEvent.ACTION_MOVE -> {
val x = event.x
val y = event.y
val isInside = x > 0 && x < width && y > 0 && y < height
if (isPressed != isInside) {
isPressed = isInside
}
}

MotionEvent.ACTION_UP -> {
emojiView!!.animate().scaleX(0.7f).scaleY(0.7f).setDuration(150).interpolator = DECCELERATE_INTERPOLATOR
emojiView!!.animate().scaleX(1f).scaleY(1f).interpolator = DECCELERATE_INTERPOLATOR
if (isPressed) {
performClick()
isPressed = false
}
}
MotionEvent.ACTION_CANCEL -> isPressed = false
}
return true
} }


/** /**
@ -335,5 +342,6 @@ class ReactionButton @JvmOverloads constructor(context: Context, attrs: Attribut
interface ReactedListener { interface ReactedListener {
fun onReacted(reactionButton: ReactionButton) fun onReacted(reactionButton: ReactionButton)
fun onUnReacted(reactionButton: ReactionButton) fun onUnReacted(reactionButton: ReactionButton)
fun onLongClick(reactionButton: ReactionButton)
} }
} }

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="400dp"
android:orientation="vertical">

<TextView
android:layout_width="match_parent"
android:layout_height="44dp"
android:gravity="center_vertical"
android:padding="8dp"
android:text="@string/reactions"
android:textColor="?android:textColorSecondary"
android:textSize="16sp" />

<ProgressBar
android:id="@+id/bottomSheetViewReactionSpinner"
style="?android:attr/progressBarStyleSmall"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:visibility="gone"
tools:visibility="visible" />


<com.airbnb.epoxy.EpoxyRecyclerView
android:id="@+id/bottom_sheet_display_reactions_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fadeScrollbars="false"
android:orientation="vertical"
android:scrollbars="vertical"
tools:itemCount="15"
tools:listitem="@layout/item_simple_reaction_info">

</com.airbnb.epoxy.EpoxyRecyclerView>
</LinearLayout>

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="44dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingLeft="8dp"
android:paddingEnd="8dp">

<TextView
android:id="@+id/itemSimpleReactionInfoKey"
android:layout_width="44dp"
android:layout_height="wrap_content"
android:gravity="center"
android:lines="1"
android:textColor="?android:textColorPrimary"
android:textSize="18sp"
tools:text="@sample/reactions.json/data/reaction" />

<TextView
android:id="@+id/itemSimpleReactionInfoMemberName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
android:layout_weight="1"
android:ellipsize="end"
android:lines="1"
android:textColor="?android:textColorPrimary"
android:textSize="16sp"
tools:text="@sample/matrix.json/data/displayName" />

<TextView
android:id="@+id/itemSimpleReactionInfoTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="1"
android:textColor="?android:textColorSecondary"
android:textSize="12sp"
tools:text="10:44" />


</LinearLayout>

View File

@ -2,16 +2,19 @@
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android" <androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="44dp" android:id="@+id/reactionSelector"
android:layout_width="wrap_content"
android:minWidth="44dp"
android:layout_height="26dp" android:layout_height="26dp"
android:background="@drawable/rounded_rect_shape"
android:clipChildren="false"> android:clipChildren="false">




<View <!--<View-->
android:id="@+id/reactionSelector" <!--android:id="@+id/reactionSelector"-->
android:layout_width="match_parent" <!--android:layout_width="match_parent"-->
android:layout_height="match_parent" <!--android:layout_height="match_parent"-->
android:background="@drawable/rounded_rect_shape" /> <!--android:background="@drawable/rounded_rect_shape" />-->


<im.vector.riotredesign.features.reactions.widget.DotsView <im.vector.riotredesign.features.reactions.widget.DotsView
android:id="@+id/dots" android:id="@+id/dots"
@ -42,17 +45,23 @@
android:gravity="center" android:gravity="center"
android:textColor="@color/black" android:textColor="@color/black"
android:textSize="13sp" android:textSize="13sp"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@id/reactionCount"
tools:text="👍" /> tools:text="👍" />


<TextView <TextView
android:id="@+id/reactionCount" android:id="@+id/reactionCount"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="match_parent" android:layout_height="wrap_content"
android:layout_marginEnd="6dp" app:layout_constraintHorizontal_chainStyle="packed"
android:layout_marginRight="6dp" app:layout_constraintBaseline_toBaselineOf="@id/reactionText"
android:layout_marginStart="-4dp"
android:layout_marginLeft="-4dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:gravity="center" android:gravity="center"
android:maxLines="1" android:maxLines="1"
android:textColor="?riotx_text_secondary" android:textColor="?riotx_text_secondary"
@ -61,7 +70,8 @@
app:autoSizeMaxTextSize="14sp" app:autoSizeMaxTextSize="14sp"
app:autoSizeMinTextSize="8sp" app:autoSizeMinTextSize="8sp"
app:autoSizeTextType="uniform" app:autoSizeTextType="uniform"
app:layout_constraintStart_toEndOf="@id/reactionText"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
tools:text="10" /> tools:text="13450" />


</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -20,6 +20,7 @@
<string name="reactions_agree">Agree</string> <string name="reactions_agree">Agree</string>
<string name="reactions_like">Like</string> <string name="reactions_like">Like</string>
<string name="message_add_reaction">Add Reaction</string> <string name="message_add_reaction">Add Reaction</string>
<string name="reactions">Reactions</string>


<string name="event_redacted_by_user_reason">Event deleted by user</string> <string name="event_redacted_by_user_reason">Event deleted by user</string>
<string name="event_redacted_by_admin_reason">Event moderated by room admin</string> <string name="event_redacted_by_admin_reason">Event moderated by room admin</string>