[WIP] Emoji Reactions

This commit is contained in:
Valere 2019-05-07 14:02:15 +02:00
parent a64f509872
commit 56a2a3a065
60 changed files with 1985 additions and 194 deletions

View File

@ -8,7 +8,8 @@ buildscript {
jcenter()
maven {
url "https://plugins.gradle.org/m2/"
} }
}
}
dependencies {
classpath 'com.android.tools.build:gradle:3.3.2'
classpath 'com.google.gms:google-services:4.2.0'

View File

@ -0,0 +1,23 @@
/*
* Copyright 2019 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.matrix.android.api.session.room

import im.vector.matrix.android.api.session.events.model.Event

interface RoomEventService {

fun getEvent(eventId: String?) : Event
}

View File

@ -16,6 +16,7 @@

package im.vector.matrix.android.api.session.room.model.message


interface MessageContent {
val type: String
val body: String

View File

@ -18,9 +18,13 @@ package im.vector.matrix.android.api.session.room.send

enum class SendState {
UNKNOWN,
// the event has not been sent
UNSENT,
// the event is encrypting
ENCRYPTING,
// the event is currently sending
SENDING,
// the event has been sent
SENT,
SYNCED;


View File

@ -17,6 +17,7 @@
package im.vector.matrix.android.api.session.room.timeline

import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.send.SendState

@ -59,4 +60,8 @@ data class TimelineEvent(
inline fun <reified T> getMetadata(key: String): T? {
return metadata[key] as T?
}

fun isEncrypted() : Boolean {
return EventType.ENCRYPTED == root.type
}
}

View File

@ -30,4 +30,6 @@ interface TimelineService {
*/
fun createTimeline(eventId: String?, allowedTypes: List<String>? = null): Timeline


fun getTimeLineEvent(eventId: String): TimelineEvent?
}

View File

@ -28,17 +28,18 @@ import im.vector.matrix.android.internal.database.query.findIncludingEvent
import im.vector.matrix.android.internal.database.query.next
import im.vector.matrix.android.internal.database.query.prev
import im.vector.matrix.android.internal.database.query.where
import io.realm.Realm
import io.realm.RealmList
import io.realm.RealmQuery

internal class SenderRoomMemberExtractor(private val roomId: String) {

fun extractFrom(event: EventEntity): RoomMember? {
fun extractFrom(event: EventEntity, realm: Realm = event.realm): RoomMember? {
val sender = event.sender ?: return null
// If the event is unlinked we want to fetch unlinked state events
val unlinked = event.isUnlinked
val roomEntity = RoomEntity.where(event.realm, roomId = roomId).findFirst() ?: return null
val chunkEntity = ChunkEntity.findIncludingEvent(event.realm, event.eventId)
val roomEntity = RoomEntity.where(realm, roomId = roomId).findFirst() ?: return null
val chunkEntity = ChunkEntity.findIncludingEvent(realm, event.eventId)
val content = when {
chunkEntity == null -> null
event.stateIndex <= 0 -> baseQuery(chunkEntity.events, sender, unlinked).next(from = event.stateIndex)?.prevContent

View File

@ -18,8 +18,12 @@ package im.vector.matrix.android.internal.session.room.timeline

import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.TimelineService
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.util.fetchMappedCopied

internal class DefaultTimelineService(private val roomId: String,
private val monarchy: Monarchy,
@ -33,4 +37,12 @@ internal class DefaultTimelineService(private val roomId: String,
return DefaultTimeline(roomId, eventId, monarchy.realmConfiguration, taskExecutor, contextOfEventTask, timelineEventFactory, paginationTask, allowedTypes)
}

override fun getTimeLineEvent(eventId: String): TimelineEvent? {
return monarchy.fetchMappedCopied({
EventEntity.where(it, eventId = eventId).findFirst()
}, { entity, realm ->
timelineEventFactory.create(entity, realm)
})
}

}

View File

@ -20,16 +20,17 @@ import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.EventEntity
import im.vector.matrix.android.internal.session.room.members.SenderRoomMemberExtractor
import io.realm.Realm

internal class TimelineEventFactory(private val roomMemberExtractor: SenderRoomMemberExtractor) {

private val cached = mutableMapOf<String, SenderData>()

fun create(eventEntity: EventEntity): TimelineEvent {
fun create(eventEntity: EventEntity, realm: Realm = eventEntity.realm): TimelineEvent {
val sender = eventEntity.sender
val cacheKey = sender + eventEntity.stateIndex
val senderData = cached.getOrPut(cacheKey) {
val senderRoomMember = roomMemberExtractor.extractFrom(eventEntity)
val senderRoomMember = roomMemberExtractor.extractFrom(eventEntity,realm)
SenderData(senderRoomMember?.displayName, senderRoomMember?.avatarUrl)
}
return TimelineEvent(

View File

@ -42,6 +42,17 @@ fun <T : RealmModel> Monarchy.fetchCopied(query: (Realm) -> T?): T? {
return fetch(query, true)
}

fun <U, T : RealmModel> Monarchy.fetchMappedCopied(query: (Realm) -> T?, map: (T, realm: Realm) -> U): U? {
val ref = AtomicReference<U?>()
doWithRealm { realm ->
val result = query.invoke(realm)?.let {
map(realm.copyFromRealm(it), realm)
}
ref.set(result)
}
return ref.get()
}

private fun <T : RealmModel> Monarchy.fetch(query: (Realm) -> T?, copyFromRealm: Boolean): T? {
val ref = AtomicReference<T>()
doWithRealm { realm ->

View File

@ -12,6 +12,7 @@ android {
targetSdkVersion 28
versionCode 1
versionName "1.0"
multiDexEnabled true

testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"

@ -36,6 +37,9 @@ dependencies {
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version"

// Log
implementation 'com.jakewharton.timber:timber:4.7.1'

implementation 'com.google.code.gson:gson:2.8.5'
implementation 'com.android.support:appcompat-v7:28.0.0'
implementation 'androidx.appcompat:appcompat:1.0.0-beta01'

View File

@ -3,6 +3,10 @@
package="im.vector.reactions">

<application>
<meta-data
android:name="fontProviderRequests"
android:value="Noto Color Emoji Compat" />

<activity
android:name=".EmojiReactionPickerActivity"
android:label="@string/title_activity_emoji_reaction_picker"

View File

@ -41,7 +41,9 @@ class EmojiChooserFragment : Fragment() {

override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
viewModel = ViewModelProviders.of(this).get(EmojiChooserViewModel::class.java)
viewModel = activity?.run {
ViewModelProviders.of(this).get(EmojiChooserViewModel::class.java)
} ?: throw Exception("Invalid Activity")
viewModel.initWithContect(context!!)
(view as? RecyclerView)?.let {
it.adapter = viewModel.adapter

View File

@ -16,14 +16,29 @@
package im.vector.reactions

import android.content.Context
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel

class EmojiChooserViewModel : ViewModel() {

var adapter: EmojiRecyclerAdapter? = null
val emojiSourceLiveData: MutableLiveData<EmojiDataSource> = MutableLiveData()

val currentSection: MutableLiveData<Int> = MutableLiveData()

fun initWithContect(context: Context) {
adapter = EmojiRecyclerAdapter(EmojiDataSource(context))
val emojiDataSource = EmojiDataSource(context)
emojiSourceLiveData.value = emojiDataSource
adapter = EmojiRecyclerAdapter(emojiDataSource)
adapter?.interactionListener = object : EmojiRecyclerAdapter.InteractionListener {
override fun firstVisibleSectionChange(section: Int) {
currentSection.value = section
}

}
}

fun scrollToSection(sectionIndex: Int) {
adapter?.scrollToSection(sectionIndex)
}
}

View File

@ -3,17 +3,14 @@ package im.vector.reactions
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Typeface
import android.text.Layout
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import android.text.Layout
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import java.lang.Exception
import kotlin.math.abs
import kotlin.math.max


/**
@ -25,29 +22,26 @@ class EmojiDrawView @JvmOverloads constructor(
) : View(context, attrs, defStyleAttr) {

var mLayout: StaticLayout? = null
set(value) {
field = value
invalidate()
}

// var _mySpacing = 0f

var emoji: String? = null
set(value) {
field = value
if (value != null) {
EmojiRecyclerAdapter.beginTraceSession("EmojiDrawView.TextStaticLayout")
// GlobalScope.launch {
// val sl = StaticLayout(value, tPaint, emojiSize, Layout.Alignment.ALIGN_CENTER, 1f, 0f, true)
// GlobalScope.launch(Dispatchers.Main) {
// if (emoji == value) {
// mLayout = sl
// //invalidate()
// }
// }
// }
mLayout = StaticLayout(value, tPaint, emojiSize, Layout.Alignment.ALIGN_CENTER, 1f, 0f, true)
EmojiRecyclerAdapter.endTraceSession()
} else {
mLayout = null
}
}
// set(value) {
// if (value != null) {
// EmojiRecyclerAdapter.beginTraceSession("EmojiDrawView.TextStaticLayout")
// mLayout = StaticLayout(value, tPaint, emojiSize, Layout.Alignment.ALIGN_CENTER, 1f, 0f, true)
// if (value != field) invalidate()
// EmojiRecyclerAdapter.endTraceSession()
// } else {
// mLayout = null
//// if (value != field) invalidate()
// }
// field = value
// }

override fun onDraw(canvas: Canvas?) {
EmojiRecyclerAdapter.beginTraceSession("EmojiDrawView.onDraw")
@ -55,7 +49,7 @@ class EmojiDrawView @JvmOverloads constructor(
canvas?.save()
val space = abs((width - emojiSize) / 2f)
if (mLayout == null) {
canvas?.drawCircle(width / 2f ,width / 2f, emojiSize / 2f,tPaint)
// canvas?.drawCircle(width / 2f ,width / 2f, emojiSize / 2f,tPaint)
} else {
canvas?.translate(space, space)
mLayout!!.draw(canvas)
@ -65,14 +59,18 @@ class EmojiDrawView @JvmOverloads constructor(
}

companion object {
private val tPaint = TextPaint()
val tPaint = TextPaint()

private var emojiSize = 40
var emojiSize = 40

fun configureTextPaint(context: Context) {
fun configureTextPaint(context: Context, typeface: Typeface?) {
tPaint.isAntiAlias = true;
tPaint.textSize = 24 * context.resources.displayMetrics.density
tPaint.color = Color.LTGRAY
typeface?.let {
tPaint.typeface = it
}

emojiSize = tPaint.measureText("😅").toInt()
}
}

View File

@ -15,34 +15,121 @@
*/
package im.vector.reactions

import android.content.Context
import android.content.Intent
import android.graphics.Typeface
import android.os.Bundle
import android.os.Handler
import android.os.HandlerThread
import android.util.TypedValue
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import android.widget.SearchView
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.provider.FontRequest
import androidx.core.provider.FontsContractCompat
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import com.google.android.material.tabs.TabLayout
import timber.log.Timber


/**
*
* TODO: Loading indicator while getting emoji data source?
* TODO: migrate to maverick
*/
class EmojiReactionPickerActivity : AppCompatActivity() {

lateinit var tabLayout: TabLayout
private lateinit var tabLayout: TabLayout

lateinit var viewModel: EmojiChooserViewModel

private var mHandler: Handler? = null

private var tabLayoutSelectionListener = object : TabLayout.BaseOnTabSelectedListener<TabLayout.Tab> {
override fun onTabReselected(p0: TabLayout.Tab) {
}

override fun onTabUnselected(p0: TabLayout.Tab) {
}

override fun onTabSelected(p0: TabLayout.Tab) {
viewModel.scrollToSection(p0.position)
}

}

private fun getFontThreadHandler(): Handler {
if (mHandler == null) {
val handlerThread = HandlerThread("fonts")
handlerThread.start()
mHandler = Handler(handlerThread.looper)
}
return mHandler!!
}

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

requestEmojivUnicode10CompatibleFont()


setContentView(R.layout.activity_emoji_reaction_picker)
setSupportActionBar(findViewById(R.id.toolbar))

tabLayout = findViewById(R.id.tabs)


tabLayout.addTab(tabLayout.newTab().setText("Tab 1"));
tabLayout.addTab(tabLayout.newTab().setText("Tab 2"));
tabLayout.addTab(tabLayout.newTab().setText("Tab 3"));

viewModel = ViewModelProviders.of(this).get(EmojiChooserViewModel::class.java)

viewModel.emojiSourceLiveData.observe(this, Observer {
it.rawData?.categories?.let { categories ->
for (category in categories) {
val s = category.emojis[0]
tabLayout.addTab(tabLayout.newTab().setText(it.rawData!!.emojis[s]!!.emojiString()))
}
tabLayout.addOnTabSelectedListener(tabLayoutSelectionListener)
}

})

viewModel.currentSection.observe(this, Observer { section ->
section?.let {
tabLayout.removeOnTabSelectedListener(tabLayoutSelectionListener)
tabLayout.getTabAt(it)?.select()
tabLayout.addOnTabSelectedListener(tabLayoutSelectionListener)
}
})
supportActionBar?.title = getString(R.string.title_activity_emoji_reaction_picker)
}

private fun requestEmojivUnicode10CompatibleFont() {
val fontRequest = FontRequest(
"com.google.android.gms.fonts",
"com.google.android.gms",
"Noto Color Emoji Compat",
R.array.com_google_android_gms_fonts_certs
)

EmojiDrawView.configureTextPaint(this, null)
val callback = object : FontsContractCompat.FontRequestCallback() {

override fun onTypefaceRetrieved(typeface: Typeface) {
EmojiDrawView.configureTextPaint(this@EmojiReactionPickerActivity, typeface)
}

override fun onTypefaceRequestFailed(reason: Int) {
Timber.e("Failed to load Emoji Compatible font, reason:$reason")
}
}

FontsContractCompat.requestFont(this, fontRequest, callback, getFontThreadHandler())
}

override fun onCreateOptionsMenu(menu: Menu): Boolean {
val inflater: MenuInflater = menuInflater
inflater.inflate(R.menu.menu_emoji_reaction_picker, menu)
@ -54,20 +141,40 @@ class EmojiReactionPickerActivity : AppCompatActivity() {
override fun onMenuItemActionExpand(p0: MenuItem?): Boolean {
it.isIconified = false
it.requestFocusFromTouch()
//we want to force the tool bar as visible even if hidden with scroll flags
findViewById<Toolbar>(R.id.toolbar)?.minimumHeight = getActionBarSize()
return true
}

override fun onMenuItemActionCollapse(p0: MenuItem?): Boolean {
// when back, clear all search
findViewById<Toolbar>(R.id.toolbar)?.minimumHeight = 0
it.setQuery("", true)
return true
}
})
}


return true
}

//TODO move to ThemeUtils when core module is created
private fun getActionBarSize(): Int {
return try {
val typedValue = TypedValue()
theme.resolveAttribute(R.attr.actionBarSize, typedValue, true)
TypedValue.complexToDimensionPixelSize(typedValue.data, resources.displayMetrics)
} catch (e: Exception) {
//Timber.e(e, "Unable to get color")
TypedValue.complexToDimensionPixelSize(56, resources.displayMetrics)
}
}

companion object {
fun intent(context: Context): Intent {
val intent = Intent(context, EmojiReactionPickerActivity::class.java)
// intent.putExtra(EXTRA_MATRIX_ID, matrixID)
return intent
}
}
}

View File

@ -15,66 +15,58 @@
*/
package im.vector.reactions

import android.annotation.SuppressLint
import android.os.Build
import android.os.Trace
import android.text.Layout
import android.text.StaticLayout
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.TextView
import androidx.recyclerview.widget.DefaultItemAnimator
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlin.math.abs


/**
*
* TODO: Configure Span using available width and emoji size
* TODO: Search
* TODO: Performances
* TODO: Scroll to section - Find a way to snap section to the top
*/
class EmojiRecyclerAdapter(val dataSource: EmojiDataSource? = null) :
RecyclerView.Adapter<EmojiRecyclerAdapter.ViewHolder>() {

// data class EmojiInfo(val stringValue: String)
// data class SectionInfo(val sectionName: String)
var interactionListener: InteractionListener? = null
var mRecyclerView: RecyclerView? = null

//val mockData: ArrayList<Pair<SectionInfo, ArrayList<EmojiInfo>>> = ArrayList()

// val dataSource : EmojiDataSource? = null

init {
// val faces = ArrayList<EmojiInfo>()
// for (i in 0..50) {
// faces.add(EmojiInfo("😅"))
// }
// val animalsNature = ArrayList<EmojiInfo>()
// for (i in 0..160) {
// animalsNature.add(EmojiInfo("🐶"))
// }
// val foods = ArrayList<EmojiInfo>()
// for (i in 0..150) {
// foods.add(EmojiInfo("🍎"))
// }
//
// mockData.add(SectionInfo("Smiley & People") to faces)
// mockData.add(SectionInfo("Animals & Nature") to animalsNature)
// mockData.add(SectionInfo("Food & Drinks") to foods)
// dataSource = EMp
var currentFirstVisibleSection = 0

enum class ScrollState {
IDLE,
DRAGGING,
SETTLING,
UNKNWON
}

// enum class ScrollState {
// IDLE,
// DRAGGING,
// SETTLING,
// UNKNWON
// }
private var scrollState = ScrollState.UNKNWON
private var isFastScroll = false

val toUpdateWhenNotBusy = ArrayList<Pair<String, EmojiViewHolder>>()

private val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
}
}

override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)

EmojiDrawView.configureTextPaint(recyclerView.context)
this.mRecyclerView = recyclerView

val gridLayoutManager = GridLayoutManager(recyclerView.context, 8)
gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
@ -98,6 +90,24 @@ class EmojiRecyclerAdapter(val dataSource: EmojiDataSource? = null) :
recyclerView.addOnScrollListener(scrollListener)
}

override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
this.mRecyclerView = null
recyclerView.removeOnScrollListener(scrollListener)
staticLayoutCache.clear()
super.onDetachedFromRecyclerView(recyclerView)
}

fun scrollToSection(section: Int) {
if (section < 0 || section >= dataSource?.rawData?.categories?.size ?: 0) {
//ignore
return
}
//mRecyclerView?.smoothScrollToPosition(getSectionOffset(section) - 1)
//TODO Snap section header to top
mRecyclerView?.scrollToPosition(getSectionOffset(section) - 1)
}


override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
beginTraceSession("MyAdapter.onCreateViewHolder")
val inflater = LayoutInflater.from(parent.context)
@ -162,7 +172,6 @@ class EmojiRecyclerAdapter(val dataSource: EmojiDataSource? = null) :
return sectionOffset
}

@SuppressLint("NewApi")
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
beginTraceSession("MyAdapter.onBindViewHolder")
dataSource?.rawData?.categories?.let { categories ->
@ -174,13 +183,36 @@ class EmojiRecyclerAdapter(val dataSource: EmojiDataSource? = null) :
val sectionOffset = getSectionOffset(sectionNumber)
val emoji = sectionMojis[position - sectionOffset]
val item = dataSource!!.rawData!!.emojis[emoji]!!.emojiString()
holder.bind(item)
(holder as EmojiViewHolder).data = item
if (scrollState != ScrollState.SETTLING || !isFastScroll) {
// Log.i("PERF","Bind with draw at position:$position")
holder.bind(item)
} else {
// Log.i("PERF","Bind without draw at position:$position")
toUpdateWhenNotBusy.add(item to holder)
holder.bind(null)
}
}
}
endTraceSession()

}

override fun onViewRecycled(holder: ViewHolder) {
if (holder is EmojiViewHolder) {
holder.data = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
toUpdateWhenNotBusy.removeIf { it.second == holder }
} else {
val index = toUpdateWhenNotBusy.indexOfFirst { it.second == holder }
if (index != -1) {
toUpdateWhenNotBusy.removeAt(index)
}
}
}
super.onViewRecycled(holder)
}


override fun getItemCount(): Int {
dataSource?.rawData?.categories?.let {
var count = /*number of sections*/ it.size
@ -193,17 +225,28 @@ class EmojiRecyclerAdapter(val dataSource: EmojiDataSource? = null) :


abstract class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
abstract fun bind(s: String)
abstract fun bind(s: String?)
}


class EmojiViewHolder(itemView: View) : ViewHolder(itemView) {

var emojiView: EmojiDrawView = itemView.findViewById(R.id.grid_item_emoji_text)
val placeHolder: View = itemView.findViewById(R.id.grid_item_place_holder)

var data: String? = null

override fun bind(s: String) {
override fun bind(s: String?) {
emojiView.emoji = s
if (s != null) {
emojiView.mLayout = getStaticLayoutForEmoji(s)
placeHolder.visibility = View.GONE
// emojiView.visibility = View.VISIBLE
} else {
emojiView.mLayout = null
placeHolder.visibility = View.VISIBLE
// emojiView.visibility = View.GONE
}
}
}

@ -211,11 +254,10 @@ class EmojiRecyclerAdapter(val dataSource: EmojiDataSource? = null) :

var textView: TextView = itemView.findViewById(R.id.section_header_textview)

override fun bind(s: String) {
override fun bind(s: String?) {
textView.text = s
}


}

companion object {
@ -230,40 +272,75 @@ class EmojiRecyclerAdapter(val dataSource: EmojiDataSource? = null) :
Trace.beginSection(sectionName)
}
}

val staticLayoutCache = HashMap<String, StaticLayout>()

fun getStaticLayoutForEmoji(emoji: String): StaticLayout {
var cachedLayout = staticLayoutCache[emoji]
if (cachedLayout == null) {
cachedLayout = StaticLayout(emoji, EmojiDrawView.tPaint, EmojiDrawView.emojiSize, Layout.Alignment.ALIGN_CENTER, 1f, 0f, true)
staticLayoutCache[emoji] = cachedLayout!!
}
return cachedLayout!!
}

}

interface InteractionListener {
fun firstVisibleSectionChange(section: Int)
}

//privates

private val scrollListener = object : RecyclerView.OnScrollListener() {
override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) {
super.onScrollStateChanged(recyclerView, newState)
scrollState = when (newState) {
RecyclerView.SCROLL_STATE_IDLE -> ScrollState.IDLE
RecyclerView.SCROLL_STATE_SETTLING -> ScrollState.SETTLING
RecyclerView.SCROLL_STATE_DRAGGING -> ScrollState.DRAGGING
else -> ScrollState.UNKNWON
}

//TODO better
if (scrollState == ScrollState.IDLE) {
//
val toUpdate = toUpdateWhenNotBusy.clone() as ArrayList<Pair<String, EmojiViewHolder>>
toUpdateWhenNotBusy.clear()
toUpdate.chunked(8).forEach {
recyclerView.post {
val transition = AutoTransition().apply {
duration = 150
}
for (pair in it) {
val holder = pair.second
if (pair.first == holder.data) {
TransitionManager.beginDelayedTransition(holder.itemView as FrameLayout, transition)
val data = holder.data
holder.bind(data)
}
}
toUpdateWhenNotBusy.clear()
}
}

}
}

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
//Log.i("SCROLL SPEED","scroll speed $dy")
isFastScroll = abs(dy) > 50
val visible = (recyclerView.layoutManager as GridLayoutManager).findFirstCompletelyVisibleItemPosition()
GlobalScope.launch {
val section = getSectionForAbsoluteIndex(visible)
if (section != currentFirstVisibleSection) {
currentFirstVisibleSection = section
GlobalScope.launch(Dispatchers.Main) {
interactionListener?.firstVisibleSectionChange(currentFirstVisibleSection)
}
}
}
}
}
// data class SectionsIndex(val dataSource: EmojiDataSource) {
// var sectionsIndex: ArrayList<Int> = ArrayList()
// var sectionsInfo: ArrayList<Pair<Int, Int>> = ArrayList()
// var itemCount = 0
//
// init {
// var sectionOffset = 1
// var lastItemInSection = 0
// dataSource.rawData?.categories?.let {
// for (category in it) {
// sectionsIndex.add(sectionOffset - 1)
// lastItemInSection = sectionOffset + category.emojis.size - 1
// sectionsInfo.add(sectionOffset to lastItemInSection)
// sectionOffset = lastItemInSection + 2
// itemCount += (1 + category.emojis.size)
// }
// }
// }
//
// fun getCount(): Int = this.itemCount
//
// fun isSection(position: Int): Int? {
// return sectionsIndex.indexOf(position)
// }
//
// fun getSectionForAbsoluteIndex(position: Int): Int {
// for (i in sectionsIndex.size - 1 downTo 0) {
// val sectionOffset = sectionsIndex[i]
// if (position >= sectionOffset) {
// return i
// }
// }
// return 0
// }
// }
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>

<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/pale_grey" />
</shape>

View File

@ -57,7 +57,7 @@
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
android:layout_width="match_parent"
android:layout_height="30dp"
android:layout_height="40dp"
android:background="?attr/colorPrimary"
android:elevation="4dp" />


View File

@ -5,6 +5,16 @@
android:layout_height="40dp"
tools:showIn="@layout/activity_emoji_reaction_picker">

<View
android:id="@+id/grid_item_place_holder"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_margin="4dp"
android:visibility="gone"
tools:visibility="visible"
android:background="@drawable/circle" />

<im.vector.reactions.EmojiDrawView
android:id="@+id/grid_item_emoji_text"
android:layout_width="40dp"

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="pale_grey">#f2f5f8</color>
<color name="light_blue_grey">#4ac1c9d6</color>
</resources>

View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<array name="com_google_android_gms_fonts_certs">
<item>@array/com_google_android_gms_fonts_certs_dev</item>
<item>@array/com_google_android_gms_fonts_certs_prod</item>
</array>
<string-array name="com_google_android_gms_fonts_certs_dev">
<item>
MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpFeVyxW0qMBujb8X8ETrWy550NaFtI6t9+u7hZeTfHwqNvacKhp1RbE6dBRGWynwMVX8XW8N1+UjFaq6GCJukT4qmpN2afb8sCjUigq0GuMwYXrFVee74bQgLHWGJwPmvmLHC69EH6kWr22ijx4OKXlSIx2xT1AsSHee70w5iDBiK4aph27yH3TxkXy9V89TDdexAcKk/cVHYNnDBapcavl7y0RiQ4biu8ymM8Ga/nmzhRKya6G0cGw8CAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs=
</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_prod">
<item>
MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAQOjgdkwgdYwHQYDVR0OBBYEFMd9jMIhF1Ylmn/Tgt9r45jk14alMIGmBgNVHSMEgZ4wgZuAFMd9jMIhF1Ylmn/Tgt9r45jk14aloXikdjB0MQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEUMBIGA1UEChMLR29vZ2xlIEluYy4xEDAOBgNVBAsTB0FuZHJvaWQxEDAOBgNVBAMTB0FuZHJvaWSCCQDC4IdGZEowjTAMBgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBAUAA4IBAQBt0lLO74UwLDYKqs6Tm8/yzKkEu116FmH4rkaymUIE0P9KaMftGlMexFlaYjzmB2OxZyl6euNXEsQH8gjwyxCUKRJNexBiGcCEyj6z+a1fuHHvkiaai+KL8W1EyNmgjmyy8AW7P+LLlkR+ho5zEHatRbM/YAnqGcFh5iZBqpknHf1SKMXFh4dd239FJ1jWYfbMDMy3NS5CTMQ2XFI1MvcyUTdZPErjQfTbQe3aDQsQcafEQPD+nqActifKZ0Np0IS9L9kR/wbNvyz6ENwPiTrjV2KRkEjH78ZMcUQXg0L3BYHJ3lc69Vs5Ddf9uUGGMYldX3WfMBEmh/9iFBDAaTCK
</item>
</string-array>
</resources>

View File

@ -43,6 +43,16 @@
android:name=".core.services.CallService"
android:exported="false" />

<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/riotx_provider_paths" />
</provider>

</application>

</manifest>

View File

@ -18,6 +18,7 @@ package im.vector.riotredesign.core.platform

import com.airbnb.mvrx.BaseMvRxViewModel
import com.airbnb.mvrx.MvRxState
import im.vector.riotredesign.BuildConfig

abstract class VectorViewModel<S : MvRxState>(initialState: S)
: BaseMvRxViewModel<S>(initialState, debugMode = false)
: BaseMvRxViewModel<S>(initialState, debugMode = BuildConfig.DEBUG)

View File

@ -238,3 +238,26 @@ fun openMedia(activity: Activity, savedMediaPath: String, mimeType: String) {
activity.toast(R.string.error_no_external_application_found)
}
}

fun shareMedia(context: Context, file: File, mediaMimeType: String?) {

var mediaUri: Uri? = null
try {
mediaUri = FileProvider.getUriForFile(context, BuildConfig.APPLICATION_ID + ".fileProvider", file)
} catch (e: Exception) {
Timber.e("onMediaAction Selected File cannot be shared " + e.message)
}


if (null != mediaUri) {
val sendIntent = Intent()
sendIntent.action = Intent.ACTION_SEND
// Grant temporary read permission to the content URI
sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
sendIntent.type = mediaMimeType
sendIntent.putExtra(Intent.EXTRA_STREAM, mediaUri)

context.startActivity(sendIntent)

}
}

View File

@ -76,10 +76,12 @@ fun requestDisablingBatteryOptimization(activity: Activity, fragment: Fragment?,
* @param context the context
* @param text the text to copy
*/
fun copyToClipboard(context: Context, text: CharSequence) {
fun copyToClipboard(context: Context, text: CharSequence, showToast: Boolean = true) {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.primaryClip = ClipData.newPlainText("", text)
context.toast(R.string.copied_to_clipboard)
if (showToast) {
context.toast(R.string.copied_to_clipboard)
}
}

/**

View File

@ -33,7 +33,7 @@ import im.vector.riotredesign.features.home.room.list.RoomSelectionRepository
import io.reactivex.rxkotlin.subscribeBy
import org.koin.android.ext.android.get

class EmptyState : MvRxState
data class EmptyState(val isEmpty: Boolean = true) : MvRxState

class HomeActivityViewModel(state: EmptyState,
private val session: Session,

View File

@ -20,16 +20,28 @@ import android.app.Activity.RESULT_OK
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.text.Editable
import android.text.Spannable
import android.view.HapticFeedbackConstants
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.fragmentViewModel
import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.ImageLoader
import com.google.android.material.snackbar.Snackbar
import com.jaiselrahman.filepicker.activity.FilePickerActivity
import com.jaiselrahman.filepicker.config.Configurations
import com.jaiselrahman.filepicker.model.MediaFile
@ -41,8 +53,10 @@ import im.vector.matrix.android.api.session.room.model.message.MessageAudioConte
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.user.model.User
import im.vector.reactions.EmojiReactionPickerActivity
import im.vector.riotredesign.R
import im.vector.riotredesign.core.dialogs.DialogListItem
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
@ -62,7 +76,11 @@ import im.vector.riotredesign.features.home.room.detail.composer.TextComposerAct
import im.vector.riotredesign.features.home.room.detail.composer.TextComposerViewModel
import im.vector.riotredesign.features.home.room.detail.composer.TextComposerViewState
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
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.MessageMenuViewModel
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.html.PillImageSpan
import im.vector.riotredesign.features.media.ImageContentRenderer
import im.vector.riotredesign.features.media.ImageMediaViewerActivity
@ -75,6 +93,7 @@ import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope
import org.koin.core.parameter.parametersOf
import timber.log.Timber
import java.io.File


@Parcelize
@ -88,7 +107,10 @@ private const val CAMERA_VALUE_TITLE = "attachment"
private const val REQUEST_FILES_REQUEST_CODE = 0
private const val TAKE_IMAGE_REQUEST_CODE = 1

class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callback, AutocompleteUserPresenter.Callback {
class RoomDetailFragment :
VectorBaseFragment(),
TimelineEventController.Callback,
AutocompleteUserPresenter.Callback {

companion object {

@ -115,8 +137,11 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac

override fun getLayoutResId() = R.layout.fragment_room_detail

lateinit var actionViewModel: ActionsHandler

override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
actionViewModel = ViewModelProviders.of(requireActivity()).get(ActionsHandler::class.java)
bindScope(getOrCreateScope(HomeModule.ROOM_DETAIL_SCOPE))
setupRecyclerView()
setupToolbar()
@ -125,6 +150,10 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
roomDetailViewModel.subscribe { renderState(it) }
textComposerViewModel.subscribe { renderTextComposerState(it) }
roomDetailViewModel.sendMessageResultLiveData.observeEvent(this) { renderSendMessageResult(it) }

actionViewModel.actionCommandEvent.observe(this, Observer {
handleActions(it)
})
}

override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
@ -136,7 +165,6 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
}
}


override fun onResume() {
super.onResume()
roomDetailViewModel.process(RoomDetailActions.IsDisplayed)
@ -402,8 +430,87 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
}

// AutocompleteUserPresenter.Callback
override fun onEventLongClicked(eventId: String, informationData: MessageInformationData, messageContent: MessageContent, view: View): Boolean {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
val roomId = (arguments?.get(MvRx.KEY_ARG) as? RoomDetailArgs)?.roomId
if (roomId.isNullOrBlank()) {
Timber.e("Missing RoomId, cannot open bottomsheet")
return false
}
MessageActionsBottomSheet
.newInstance(eventId, roomId, informationData)
.show(requireActivity().supportFragmentManager, "MESSAGE_CONTEXTUAL_ACTIONS")
return true
}


// AutocompleteUserPresenter.Callback

override fun onQueryUsers(query: CharSequence?) {
textComposerViewModel.process(TextComposerActions.QueryUsers(query))
}

private fun handleActions(it: LiveEvent<ActionsHandler.ActionData>?) {
it?.getContentIfNotHandled()?.let { actionData ->

when (actionData.actionId) {
MessageMenuViewModel.ACTION_ADD_REACTION -> {
startActivityForResult(EmojiReactionPickerActivity.intent(requireContext()), 0)
}
MessageMenuViewModel.ACTION_COPY -> {
//I need info about the current selected message :/
copyToClipboard(requireContext(), actionData.data?.toString() ?: "", false)
val snack = Snackbar.make(view!!, requireContext().getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT)
snack.view.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.notification_accent_color))
snack.show()
}
MessageMenuViewModel.ACTION_SHARE -> {
//TODO current data communication is too limited
//Need to now the media type
actionData.data?.toString()?.let {
//TODO bad, just POC
BigImageViewer.imageLoader().loadImage(
actionData.hashCode(),
Uri.parse(it),
object : ImageLoader.Callback {
override fun onFinish() {}

override fun onSuccess(image: File?) {
if (image != null)
shareMedia(requireContext(), image!!, "image/*")
}

override fun onFail(error: Exception?) {}

override fun onCacheHit(imageType: Int, image: File?) {}

override fun onCacheMiss(imageType: Int, image: File?) {}

override fun onProgress(progress: Int) {}

override fun onStart() {}

}

)
}
}
MessageMenuViewModel.VIEW_SOURCE,
MessageMenuViewModel.VIEW_DECRYPTED_SOURCE -> {
val view = LayoutInflater.from(requireContext()).inflate(R.layout.dialog_event_content, null)
view.findViewById<TextView>(R.id.event_content_text_view)?.let {
it.text = actionData.data?.toString() ?: ""
}

AlertDialog.Builder(requireActivity())
.setView(view)
.setPositiveButton(R.string.ok) { dialog, id -> dialog.cancel() }
.show()
}
else -> {
Toast.makeText(context, "Action ${actionData.actionId} not implemented", Toast.LENGTH_LONG).show()
}
}
}
}
}

View File

@ -26,27 +26,17 @@ import com.airbnb.epoxy.EpoxyController
import com.airbnb.epoxy.EpoxyModel
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.core.epoxy.LoadingItemModel_
import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.features.home.room.detail.timeline.factory.TimelineItemFactory
import im.vector.riotredesign.features.home.room.detail.timeline.helper.RoomMemberEventHelper
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineAsyncHelper
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineEventDiffUtilCallback
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.detail.timeline.helper.canBeMerged
import im.vector.riotredesign.features.home.room.detail.timeline.helper.nextDisplayableEvent
import im.vector.riotredesign.features.home.room.detail.timeline.helper.prevSameTypeEvents
import im.vector.riotredesign.features.home.room.detail.timeline.helper.*
import im.vector.riotredesign.features.home.room.detail.timeline.item.DaySeparatorItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.DaySeparatorItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MergedHeaderItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotredesign.features.media.ImageContentRenderer
import im.vector.riotredesign.features.media.VideoContentRenderer
import org.threeten.bp.LocalDateTime
@ -64,6 +54,7 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View)
fun onFileMessageClicked(messageFileContent: MessageFileContent)
fun onAudioMessageClicked(messageAudioContent: MessageAudioContent)
fun onEventLongClicked(eventId: String, informationData: MessageInformationData, messageContent: MessageContent, view: View): Boolean
}

private val collapsedEventIds = linkedSetOf<String>()
@ -170,8 +161,8 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
// Should be build if not cached or if cached but contains mergedHeader or formattedDay
// We then are sure we always have items up to date.
if (modelCache[position] == null
|| modelCache[position]?.mergedHeaderModel != null
|| modelCache[position]?.formattedDayModel != null) {
|| modelCache[position]?.mergedHeaderModel != null
|| modelCache[position]?.formattedDayModel != null) {
modelCache[position] = buildItemModels(position, currentSnapshot)
}
}
@ -245,7 +236,7 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
// => handle case where paginating from mergeable events and we get more
val previousCollapseStateKey = mergedEventIds.intersect(mergeItemCollapseStates.keys).firstOrNull()
val initialCollapseState = mergeItemCollapseStates.remove(previousCollapseStateKey)
?: true
?: true
val isCollapsed = mergeItemCollapseStates.getOrPut(event.localId) { initialCollapseState }
if (isCollapsed) {
collapsedEventIds.addAll(mergedEventIds)

View File

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

import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import im.vector.riotredesign.core.utils.LiveEvent

/**
* Activity shared view model to handle message actions
*/
class ActionsHandler : ViewModel() {

data class ActionData(
val actionId: String,
val data: Any?
)

val actionCommandEvent = MutableLiveData<LiveEvent<ActionData>>()

fun fireAction(actionId: String, data: Any? = null) {
actionCommandEvent.value = LiveEvent(ActionData(actionId,data))
}

}

View File

@ -0,0 +1,43 @@
/*
* Copyright 2019 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.riotredesign.features.home.room.detail.timeline.action

import android.os.Bundle
import com.airbnb.mvrx.MvRxView
import com.airbnb.mvrx.MvRxViewModelStore
import com.google.android.material.bottomsheet.BottomSheetDialogFragment


abstract class BaseMvRxBottomSheetDialog() : BottomSheetDialogFragment(), MvRxView {
override val mvrxViewModelStore by lazy { MvRxViewModelStore(viewModelStore) }

override fun onCreate(savedInstanceState: Bundle?) {
mvrxViewModelStore.restoreViewModels(this, savedInstanceState)
super.onCreate(savedInstanceState)
}

override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
mvrxViewModelStore.saveViewModels(outState)
}

override fun onStart() {
super.onStart()
// This ensures that invalidate() is called for static screens that don't
// subscribe to a ViewModel.
postInvalidate()
}
}

View File

@ -0,0 +1,163 @@
/*
* Copyright 2019 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.riotredesign.features.home.room.detail.timeline.action

import android.app.Dialog
import android.os.Bundle
import android.os.Parcelable
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.TextView
import androidx.lifecycle.ViewModelProviders
import butterknife.BindView
import butterknife.ButterKnife
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.bottomsheet.BottomSheetDialog
import im.vector.riotredesign.R
import im.vector.riotredesign.core.glide.GlideApp
import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import kotlinx.android.parcel.Parcelize


class MessageActionsBottomSheet : BaseMvRxBottomSheetDialog() {

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

private lateinit var actionHandlerModel: ActionsHandler

@BindView(R.id.bottom_sheet_message_preview_avatar)
lateinit var senderAvatarImageView: ImageView

@BindView(R.id.bottom_sheet_message_preview_sender)
lateinit var senderNameTextView: TextView

@BindView(R.id.bottom_sheet_message_preview_body)
lateinit var messageBodyTextView: TextView


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

override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
actionHandlerModel = ViewModelProviders.of(requireActivity()).get(ActionsHandler::class.java)

val cfm = childFragmentManager
var menuActionFragment = cfm.findFragmentByTag("MenuActionFragment") as? MessageMenuFragment
if (menuActionFragment == null) {
menuActionFragment = MessageMenuFragment.newInstance(arguments!!.get(MvRx.KEY_ARG) as ParcelableArgs)
cfm.beginTransaction()
.replace(R.id.bottom_sheet_menu_container, menuActionFragment, "MenuActionFragment")
.commit()
}
menuActionFragment.interactionListener = object : MessageMenuFragment.InteractionListener {
override fun didSelectMenuAction(simpleAction: SimpleAction) {
actionHandlerModel.fireAction(simpleAction.uid, simpleAction.data)
dismiss()
}
}


var quickReactionFragment = cfm.findFragmentByTag("QuickReaction") as? QuickReactionFragment
if (quickReactionFragment == null) {
quickReactionFragment = QuickReactionFragment.newInstance()
cfm.beginTransaction()
.replace(R.id.bottom_sheet_quick_reaction_container, quickReactionFragment, "QuickReaction")
.commit()
}
quickReactionFragment.interactionListener = object : QuickReactionFragment.InteractionListener {
override fun didQuickReactWith(reactions: List<String>) {
actionHandlerModel.fireAction("Quick React", reactions)
dismiss()
}
}
}

override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
val dialog = super.onCreateDialog(savedInstanceState)
//We want to force the bottom sheet initial state to expanded
(dialog as? BottomSheetDialog)?.let { bottomSheetDialog ->
bottomSheetDialog.setOnShowListener { dialog ->
val d = dialog as BottomSheetDialog
(d.findViewById<View>(com.google.android.material.R.id.design_bottom_sheet) as? FrameLayout)?.let {
BottomSheetBehavior.from(it).state = BottomSheetBehavior.STATE_COLLAPSED
}
}
}
return dialog
}

override fun invalidate() = withState(viewModel) {
senderNameTextView.text = it.senderName
messageBodyTextView.text = it.messageBody

GlideApp.with(this).clear(senderAvatarImageView)
if (it.senderAvatarPath != null) {
GlideApp.with(this)
.load(it.senderAvatarPath)
.circleCrop()
.placeholder(AvatarRenderer.getPlaceholderDrawable(requireContext(), it.userId, it.senderName))
.into(senderAvatarImageView)
} else {
senderAvatarImageView.setImageDrawable(AvatarRenderer.getPlaceholderDrawable(requireContext(), it.userId, it.senderName))
}
return@withState
}


@Parcelize
data class ParcelableArgs(
val eventId: String,
val roomId: String,
val informationData: MessageInformationData
// val body: String,
// val type: String,
// var url: String? = null
) : Parcelable

companion object {
fun newInstance(eventId: String, roomId: String, informationData: MessageInformationData): MessageActionsBottomSheet {
val args = Bundle()
val parcelableArgs = ParcelableArgs(
eventId,
roomId,
informationData
// messageContent.body,
// messageContent.type
)
// if (messageContent is MessageImageContent) {
// parcelableArgs.url = messageContent.url
// }
// if (messageContent is MessageVideoContent) {
// parcelableArgs.url = messageContent.url
// }

args.putParcelable(MvRx.KEY_ARG, parcelableArgs)
return MessageActionsBottomSheet().apply { arguments = args }

}
}
}

View File

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

import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.riotredesign.core.platform.VectorViewModel
import org.koin.android.ext.android.get
import timber.log.Timber


data class MessageActionState(
val userId: String,
val senderName: String,
val messageBody: String,
val senderAvatarPath: String? = null)
: MvRxState


class MessageActionsViewModel(initialState: MessageActionState) : VectorViewModel<MessageActionState>(initialState) {

companion object : MvRxViewModelFactory<MessageActionsViewModel, MessageActionState> {

// override fun create(viewModelContext: ViewModelContext, state: MessageActionState): MessageActionsViewModel? {
// //val currentSession = viewModelContext.activity.get<Session>()
// return MessageActionsViewModel(state/*,currentSession*/)
// }

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


val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId)
return if (event != null) {
val messageContent: MessageContent? = event.root.content.toModel()

MessageActionState(
event.root.sender ?: "",
parcel.informationData.memberName.toString(),
messageContent?.body ?: "",
currentSession.contentUrlResolver().resolveFullSize(parcel.informationData.avatarUrl)
)
} else {
//can this happen?
Timber.e("Failed to retrieve event")
null
}
}
}
}

View File

@ -0,0 +1,96 @@
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.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.airbnb.mvrx.BaseMvRxFragment
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.riotredesign.R
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotredesign.features.themes.ThemeUtils

class MessageMenuFragment : BaseMvRxFragment() {

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

private var addSeparators = false

var interactionListener: InteractionListener? = null

override fun invalidate() = withState(viewModel) { state ->

val linearLayout = view as? LinearLayout
if (linearLayout != null) {
val inflater = LayoutInflater.from(linearLayout.context)
linearLayout.removeAllViews()
var insertIndex = 0
state.actions.forEachIndexed { index, action ->
inflateActionView(action, inflater, linearLayout)?.let {
it.setOnClickListener {
interactionListener?.didSelectMenuAction(action)
}
linearLayout.addView(it, insertIndex)
insertIndex++
if (addSeparators) {
if (index < state.actions.size - 1) {
linearLayout.addView(inflateSeparatorView(), insertIndex)
insertIndex++
}
}
}
}
}

}


override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
//we just create programmatically
val contentView = LinearLayout(context)
contentView.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)
contentView.orientation = LinearLayout.VERTICAL
return contentView
}

private fun inflateActionView(action: SimpleAction, inflater: LayoutInflater, container: ViewGroup?): View? {
return inflater.inflate(R.layout.adapter_item_action, container, false)?.apply {
if (action.iconResId != null) {
findViewById<ImageView>(R.id.action_icon)?.setImageResource(action.iconResId)
} else {
findViewById<ImageView>(R.id.action_icon)?.setImageDrawable(null)
}
findViewById<TextView>(R.id.action_title)?.setText(action.titleRes)
}
}

private fun inflateSeparatorView(): View {
val frame = FrameLayout(context)
frame.setBackgroundColor(ThemeUtils.getColor(requireContext(), R.attr.vctr_list_divider_color))
frame.layoutParams = LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, requireContext().resources.displayMetrics.density.toInt())
return frame

}

interface InteractionListener {
fun didSelectMenuAction(simpleAction: SimpleAction)
}


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

View File

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

import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toContent
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.platform.VectorViewModel
import org.json.JSONObject
import org.koin.android.ext.android.get


data class SimpleAction(val uid: String, val titleRes: Int, val iconResId: Int?, val data: Any? = null)

data class MessageMenuState(
val actions: List<SimpleAction>
) : MvRxState

class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<MessageMenuState>(initialState) {

companion object : MvRxViewModelFactory<MessageMenuViewModel, MessageMenuState> {

override fun initialState(viewModelContext: ViewModelContext): MessageMenuState? {
// Args are accessible from the context.
val currentSession = viewModelContext.activity.get<Session>()
val parcel = viewModelContext.args as MessageActionsBottomSheet.ParcelableArgs
val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId)
?: return null

val messageContent: MessageContent = event.root.content.toModel() ?: return null
val type = messageContent.type

if (event.sendState == SendState.UNSENT) {
//Resend and Delete
return MessageMenuState(
listOf(
SimpleAction(ACTION_RESEND, R.string.resend, R.drawable.ic_corner_down_right, event.root.eventId),
//TODO delete icon
SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_material_delete, event.root.eventId)
)
)
}


//TODO determine if can copy, forward, reply, quote, report?
val actions = ArrayList<SimpleAction>().apply {
this.add(SimpleAction(ACTION_ADD_REACTION, R.string.message_add_reaction, R.drawable.ic_smile))
if (canCopy(type)) {
//TODO copy images? html? see ClipBoard
this.add(SimpleAction(ACTION_COPY, R.string.copy, R.drawable.ic_copy, messageContent.body))
}

if (canQuote(event, messageContent)) {
//TODO quote icon
this.add(SimpleAction(ACTION_QUOTE, R.string.quote, R.drawable.ic_material_quote, parcel.eventId))
}

if (canReply(event, messageContent)) {
this.add(SimpleAction(ACTION_REPLY, R.string.reply, R.drawable.ic_corner_down_right))
}
if (canShare(type)) {
if (messageContent is MessageImageContent) {
this.add(SimpleAction(ACTION_SHARE, R.string.share, R.drawable.ic_share, currentSession.contentUrlResolver().resolveFullSize(messageContent.url)))
}
//TODO
}

//TODO is uploading
//TODO is downloading

if (event.sendState == SendState.SENT) {

//TODO Can be redacted

//TODO sent by me or sufficient power level
}


this.add(SimpleAction(VIEW_SOURCE, R.string.view_source, R.drawable.ic_view_source, JSONObject(event.root.toContent()).toString(4)))
if (event.isEncrypted()) {
this.add(SimpleAction(VIEW_DECRYPTED_SOURCE, R.string.view_decrypted_source, null, parcel.eventId))
}
this.add(SimpleAction(PERMALINK, R.string.permalink, R.drawable.ic_permalink, parcel.eventId))
}

return MessageMenuState(actions)
}

private fun canReply(event: TimelineEvent, messageContent: MessageContent): Boolean {
//Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.type != EventType.MESSAGE) return false
return when (messageContent.type) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE,
MessageType.MSGTYPE_EMOTE,
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_FILE -> true
else -> false
}
}

private fun canQuote(event: TimelineEvent, messageContent: MessageContent): Boolean {
//Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.type != EventType.MESSAGE) return false
return when (messageContent.type) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE,
MessageType.MSGTYPE_EMOTE,
MessageType.FORMAT_MATRIX_HTML,
MessageType.MSGTYPE_LOCATION -> {
true
}
else -> false
}
}

private fun canCopy(type: String): Boolean {
return when (type) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE,
MessageType.MSGTYPE_EMOTE,
MessageType.FORMAT_MATRIX_HTML,
MessageType.MSGTYPE_LOCATION -> {
true
}
else -> false
}
}


private fun canShare(type: String): Boolean {
return when (type) {
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_VIDEO -> {
true
}
else -> false
}
}

const val ACTION_ADD_REACTION = "add_reaction"
const val ACTION_COPY = "copy"
const val ACTION_QUOTE = "quote"
const val ACTION_REPLY = "reply"
const val ACTION_SHARE = "share"
const val ACTION_RESEND = "resend"
const val ACTION_DELETE = "delete"
const val VIEW_SOURCE = "VIEW_SOURCE"
const val VIEW_DECRYPTED_SOURCE = "VIEW_DECRYPTED_SOURCE"
const val PERMALINK = "PERMALINK"


}
}

View File

@ -0,0 +1,149 @@
/*
* Copyright 2019 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.riotredesign.features.home.room.detail.timeline.action

import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.transition.TransitionManager
import butterknife.BindView
import butterknife.ButterKnife
import com.airbnb.mvrx.BaseMvRxFragment
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.riotredesign.R


class QuickReactionFragment : BaseMvRxFragment() {

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


@BindView(R.id.root_layout)
lateinit var rootLayout: ConstraintLayout


@BindView(R.id.quick_react_1)
lateinit var quickReact1: View
@BindView(R.id.quick_react_2)
lateinit var quickReact2: View
@BindView(R.id.quick_react_3)
lateinit var quickReact3: View
@BindView(R.id.quick_react_4)
lateinit var quickReact4: View


@BindView(R.id.quick_react_1_text)
lateinit var quickReact1Text: TextView

@BindView(R.id.quick_react_2_text)
lateinit var quickReact2Text: TextView

@BindView(R.id.quick_react_3_text)
lateinit var quickReact3Text: TextView

@BindView(R.id.quick_react_4_text)
lateinit var quickReact4Text: TextView

var interactionListener: InteractionListener? = null

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

override fun onAttach(context: Context) {
super.onAttach(context)
}

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)

quickReact1Text.text = viewModel.agreePositive
quickReact2Text.text = viewModel.agreeNegative
quickReact3Text.text = viewModel.likePositive
quickReact4Text.text = viewModel.likeNegative

//configure click listeners
quickReact1.setOnClickListener {
viewModel.toggleAgree(true)
}
quickReact2.setOnClickListener {
viewModel.toggleAgree(false)
}
quickReact3.setOnClickListener {
viewModel.toggleLike(true)
}
quickReact4.setOnClickListener {
viewModel.toggleLike(false)
}

}

override fun invalidate() = withState(viewModel) {

TransitionManager.beginDelayedTransition(rootLayout)
when (it.agreeTrigleState) {
TriggleState.NONE -> {
quickReact1.alpha = 1f
quickReact2.alpha = 1f
}
TriggleState.FIRST -> {
quickReact1.alpha = 1f
quickReact2.alpha = 0.2f

}
TriggleState.SECOND -> {
quickReact1.alpha = 0.2f
quickReact2.alpha = 1f
}
}
when (it.likeTriggleState) {
TriggleState.NONE -> {
quickReact3.alpha = 1f
quickReact4.alpha = 1f
}
TriggleState.FIRST -> {
quickReact3.alpha = 1f
quickReact4.alpha = 0.2f

}
TriggleState.SECOND -> {
quickReact3.alpha = 0.2f
quickReact4.alpha = 1f
}
}

if (it.selectionResult != null) {
interactionListener?.didQuickReactWith(it.selectionResult)
}
}

interface InteractionListener {
fun didQuickReactWith(reactions: List<String>)
}

companion object {
fun newInstance(): QuickReactionFragment {
return QuickReactionFragment()
}
}
}

View File

@ -0,0 +1,100 @@
/*
* Copyright 2019 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.riotredesign.features.home.room.detail.timeline.action

import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import im.vector.riotredesign.core.platform.VectorViewModel

/**
* Quick reactions state, it's a toggle with 3rd state
*/
enum class TriggleState {
NONE,
FIRST,
SECOND
}

data class QuickReactionState(val agreeTrigleState: TriggleState, val likeTriggleState: TriggleState, val selectionResult: List<String>? = null) : MvRxState

class QuickReactionViewModel(initialState: QuickReactionState) : VectorViewModel<QuickReactionState>(initialState) {

val agreePositive = "👍"
val agreeNegative = "👎"
val likePositive = "😀"
val likeNegative = "😞"


fun toggleAgree(isFirst: Boolean) = withState {
if (isFirst) {
setState {
copy(
agreeTrigleState = if (it.agreeTrigleState == TriggleState.FIRST) TriggleState.NONE else TriggleState.FIRST,
selectionResult = getReactions(this)
)
}
} else {
setState {
copy(
agreeTrigleState = if (it.agreeTrigleState == TriggleState.SECOND) TriggleState.NONE else TriggleState.SECOND,
selectionResult = getReactions(this)
)
}
}
}

fun toggleLike(isFirst: Boolean) = withState {
if (isFirst) {
setState {
copy(
likeTriggleState = if (it.likeTriggleState == TriggleState.FIRST) TriggleState.NONE else TriggleState.FIRST,
selectionResult = getReactions(this)
)
}
} else {
setState {
copy(
likeTriggleState = if (it.likeTriggleState == TriggleState.SECOND) TriggleState.NONE else TriggleState.SECOND,
selectionResult = getReactions(this)
)
}
}
}

private fun getReactions(state: QuickReactionState): List<String> {
return ArrayList<String>(4).apply {
when (state.likeTriggleState) {
TriggleState.FIRST -> add(likePositive)
TriggleState.SECOND -> add(likeNegative)
}
when (state.agreeTrigleState) {
TriggleState.FIRST -> add(agreePositive)
TriggleState.SECOND -> add(agreeNegative)
}
}
}


companion object : MvRxViewModelFactory<QuickReactionViewModel, QuickReactionState> {

override fun initialState(viewModelContext: ViewModelContext): QuickReactionState? {
// Args are accessible from the context.
// val foo = vieWModelContext.args<MyArgs>.foo
return QuickReactionState(TriggleState.NONE, TriggleState.NONE)
}
}
}

View File

@ -23,14 +23,8 @@ import im.vector.matrix.android.api.permalinks.MatrixLinkify
import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan
import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageEmoteContent
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageNoticeContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
@ -40,15 +34,7 @@ import im.vector.riotredesign.core.resources.ColorProvider
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.detail.timeline.item.DefaultItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.DefaultItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageFileItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageFileItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageImageVideoItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageImageVideoItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageTextItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageTextItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.*
import im.vector.riotredesign.features.html.EventHtmlRenderer
import im.vector.riotredesign.features.media.ImageContentRenderer
import im.vector.riotredesign.features.media.VideoContentRenderer
@ -70,13 +56,13 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
val nextDate = nextEvent?.root?.localDateTime()
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()
val isNextMessageReceivedMoreThanOneHourAgo = nextDate?.isBefore(date.minusMinutes(60))
?: false
?: false

val showInformation = addDaySeparator
|| event.senderAvatar != nextEvent?.senderAvatar
|| event.senderName != nextEvent?.senderName
|| nextEvent?.root?.type != EventType.MESSAGE
|| isNextMessageReceivedMoreThanOneHourAgo
|| event.senderAvatar != nextEvent?.senderAvatar
|| event.senderName != nextEvent?.senderName
|| nextEvent?.root?.type != EventType.MESSAGE
|| isNextMessageReceivedMoreThanOneHourAgo

val messageContent: MessageContent = event.root.content.toModel() ?: return null
val time = timelineDateFormatter.formatMessageHour(date)
@ -86,22 +72,24 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
textColor = colorProvider.getColor(getColorFor(event.root.sender ?: ""))
}
val informationData = MessageInformationData(eventId = eventId,
senderId = event.root.sender ?: "",
sendState = event.sendState,
time = time,
avatarUrl = avatarUrl,
memberName = formattedMemberName,
showInformation = showInformation)
senderId = event.root.sender ?: "",
sendState = event.sendState,
time = time,
avatarUrl = avatarUrl,
memberName = formattedMemberName,
showInformation = showInformation)

// val all = event.root.toContent()
// val ev = all.toModel<Event>()
return when (messageContent) {
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageTextContent -> buildTextMessageItem(messageContent, informationData, callback)
is MessageImageContent -> buildImageMessageItem(messageContent, informationData, callback)
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, callback)
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, callback)
is MessageFileContent -> buildFileMessageItem(messageContent, informationData, callback)
is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, callback)
else -> buildNotHandledMessageItem(messageContent)
is MessageEmoteContent -> buildEmoteMessageItem(eventId, messageContent, informationData, callback)
is MessageTextContent -> buildTextMessageItem(eventId, event.sendState, messageContent, informationData, callback)
is MessageImageContent -> buildImageMessageItem(messageContent, informationData, callback)
is MessageNoticeContent -> buildNoticeMessageItem(eventId, messageContent, informationData, callback)
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, callback)
is MessageFileContent -> buildFileMessageItem(messageContent, informationData, callback)
is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, callback)
else -> buildNotHandledMessageItem(messageContent)
}
}

@ -179,7 +167,8 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
.clickListener { view -> callback?.onVideoMessageClicked(messageContent, videoData, view) }
}

private fun buildTextMessageItem(messageContent: MessageTextContent,
private fun buildTextMessageItem(eventId: String, sendState: SendState,
messageContent: MessageTextContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageTextItem? {

@ -191,9 +180,13 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return MessageTextItem_()
.message(linkifiedBody)
.informationData(informationData)
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(eventId, informationData, messageContent, view)
?: false
}
}

private fun buildNoticeMessageItem(messageContent: MessageNoticeContent,
private fun buildNoticeMessageItem(eventId: String, messageContent: MessageNoticeContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageTextItem? {

@ -208,9 +201,13 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return MessageTextItem_()
.message(message)
.informationData(informationData)
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(eventId, informationData, messageContent, view)
?: false
}
}

private fun buildEmoteMessageItem(messageContent: MessageEmoteContent,
private fun buildEmoteMessageItem(eventId: String, messageContent: MessageEmoteContent,
informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageTextItem? {

@ -221,6 +218,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return MessageTextItem_()
.message(message)
.informationData(informationData)
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(eventId, informationData, messageContent, view)
?: false
}
}

private fun linkifyBody(body: CharSequence, callback: TimelineEventController.Callback?): Spannable {
@ -251,13 +252,13 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
}
val cI = Math.abs(hash) % 8 + 1
return when (cI) {
1 -> R.color.username_1
2 -> R.color.username_2
3 -> R.color.username_3
4 -> R.color.username_4
5 -> R.color.username_5
6 -> R.color.username_6
7 -> R.color.username_7
1 -> R.color.username_1
2 -> R.color.username_2
3 -> R.color.username_3
4 -> R.color.username_4
5 -> R.color.username_5
6 -> R.color.username_6
7 -> R.color.username_7
else -> R.color.username_8
}
}

View File

@ -19,6 +19,7 @@ package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
import im.vector.riotredesign.core.epoxy.VectorEpoxyHolder
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
import im.vector.riotredesign.features.home.AvatarRenderer
@ -27,6 +28,9 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : VectorEpoxyModel<H>()

abstract val informationData: MessageInformationData

@EpoxyAttribute
var longClickListener: View.OnLongClickListener? = null

override fun bind(holder: H) {
super.bind(holder)
if (informationData.showInformation) {
@ -41,6 +45,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : VectorEpoxyModel<H>()
holder.memberNameView.visibility = View.GONE
holder.timeView.visibility = View.GONE
}
holder.view.setOnLongClickListener(longClickListener)
}

protected fun View.renderSendState() {

View File

@ -18,6 +18,10 @@ package im.vector.riotredesign.features.home.room.detail.timeline.item

import im.vector.matrix.android.api.session.room.send.SendState

import android.os.Parcelable
import kotlinx.android.parcel.Parcelize

@Parcelize
data class MessageInformationData(
val eventId: String,
val senderId: String,
@ -26,4 +30,4 @@ data class MessageInformationData(
val avatarUrl: String?,
val memberName: CharSequence? = null,
val showInformation: Boolean = true
)
) : Parcelable

View File

@ -46,6 +46,7 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
null)
holder.messageView.setTextFuture(textFuture)
holder.messageView.renderSendState()
holder.messageView.setOnLongClickListener(longClickListener)
findPillsAndProcess { it.bind(holder.messageView) }
}


View File

@ -16,6 +16,7 @@

package im.vector.riotredesign.features.home.room.detail.timeline.item

import android.view.View
import android.widget.ImageView
import android.widget.TextView
import com.airbnb.epoxy.EpoxyAttribute
@ -33,9 +34,14 @@ abstract class NoticeItem : VectorEpoxyModel<NoticeItem.Holder>() {
@EpoxyAttribute var userId: String = ""
@EpoxyAttribute var memberName: CharSequence? = null


@EpoxyAttribute
var longClickListener: View.OnLongClickListener? = null

override fun bind(holder: Holder) {
holder.noticeTextView.text = noticeText
AvatarRenderer.render(avatarUrl, userId, memberName?.toString(), holder.avatarImageView)
holder.view.setOnLongClickListener(longClickListener)
}

class Holder : VectorEpoxyHolder() {

View File

@ -160,6 +160,17 @@ object ThemeUtils {
return matchedColor
}

fun getAttribute(c: Context, @AttrRes attribute: Int): TypedValue? {
try {
val typedValue = TypedValue()
c.theme.resolveAttribute(attribute, typedValue, true)
return typedValue
} catch (e: Exception) {
Timber.e(e, "Unable to get color")
}
return null
}

/**
* Get the resource Id applied to the current theme
*

View File

@ -0,0 +1,11 @@
<vector android:height="24dp" android:viewportHeight="22"
android:viewportWidth="22" android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#00000000" android:fillType="evenOdd"
android:pathData="M10.032,8L18.968,8A2.032,2.032 0,0 1,21 10.032L21,18.968A2.032,2.032 0,0 1,18.968 21L10.032,21A2.032,2.032 0,0 1,8 18.968L8,10.032A2.032,2.032 0,0 1,10.032 8z"
android:strokeColor="#9E9E9E" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
<path android:fillColor="#00000000" android:fillType="evenOdd"
android:pathData="M4,14L3,14a2,2 0,0 1,-2 -2L1,3a2,2 0,0 1,2 -2h9a2,2 0,0 1,2 2v1"
android:strokeColor="#9E9E9E" android:strokeLineCap="round"
android:strokeLineJoin="round" android:strokeWidth="2"/>
</vector>

View File

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M14.75,8.5L21,14.75 14.75,21"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
<path
android:pathData="M1,1v8.75a5,5 0,0 0,5 5h15"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="21dp"
android:height="22dp"
android:viewportWidth="21"
android:viewportHeight="22">
<path
android:pathData="M9.497,3.06H2.888C1.845,3.06 1,3.93 1,5v13.576c0,1.07 0.845,1.94 1.888,1.94h13.218c1.042,0 1.888,-0.87 1.888,-1.94v-6.788"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
<path
android:pathData="M16.578,1.606a1.966,1.966 0,0 1,2.832 0,2.097 2.097,0 0,1 0,2.91l-8.969,9.211 -3.776,0.97 0.944,-3.879 8.969,-9.212z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="5dp"
android:viewportWidth="22"
android:viewportHeight="5">
<path
android:pathData="M9.333,2.5a1.429,1.5 0,1 0,2.858 0a1.429,1.5 0,1 0,-2.858 0z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
<path
android:pathData="M17.666,2.5a1.429,1.5 0,1 0,2.858 0a1.429,1.5 0,1 0,-2.858 0z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
<path
android:pathData="M1,2.5a1.429,1.5 0,1 0,2.858 0a1.429,1.5 0,1 0,-2.858 0z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="5dp"
android:viewportWidth="22"
android:viewportHeight="5">
<path
android:pathData="M9.333,2.5a1.429,1.5 0,1 0,2.858 0a1.429,1.5 0,1 0,-2.858 0z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
<path
android:pathData="M17.666,2.5a1.429,1.5 0,1 0,2.858 0a1.429,1.5 0,1 0,-2.858 0z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
<path
android:pathData="M1,2.5a1.429,1.5 0,1 0,2.858 0a1.429,1.5 0,1 0,-2.858 0z"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,24 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="21dp"
android:height="21dp"
android:viewportWidth="21"
android:viewportHeight="21">
<path
android:pathData="M7.7782,11.7279L12.7279,6.7782A1,1 0,0 1,14.1421 6.7782L14.1421,6.7782A1,1 0,0 1,14.1421 8.1924L9.1924,13.1421A1,1 0,0 1,7.7782 13.1421L7.7782,13.1421A1,1 0,0 1,7.7782 11.7279z"
android:strokeWidth="1"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M6.9248,9.1065C6.7645,9.813 6.8139,10.5708 7.0693,11.2839C6.8715,11.3172 6.6817,11.4102 6.5291,11.5628L3.2857,14.8062C2.8952,15.1967 2.8952,15.8299 3.2857,16.2204L4.6999,17.6346C5.0904,18.0251 5.7236,18.0251 6.1141,17.6346L9.3575,14.3912C9.5102,14.2386 9.6031,14.0488 9.6364,13.851C10.3495,14.1064 11.1073,14.1558 11.8138,13.9955C11.7988,14.4866 11.6039,14.9733 11.229,15.3482L7.0711,19.5061C6.29,20.2871 5.0237,20.2871 4.2426,19.5061L1.4142,16.6777C0.6332,15.8966 0.6332,14.6303 1.4142,13.8492L5.5721,9.6913C5.947,9.3164 6.4337,9.1215 6.9248,9.1065Z"
android:strokeWidth="1"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M9.6269,6.4044C10.3334,6.2441 11.0913,6.2935 11.8043,6.5489C11.8376,6.351 11.9306,6.1613 12.0832,6.0086L15.3266,2.7653C15.7171,2.3748 16.3503,2.3748 16.7408,2.7653L18.155,4.1795C18.5455,4.57 18.5455,5.2032 18.155,5.5937L14.9117,8.8371C14.759,8.9897 14.5693,9.0827 14.3714,9.116C14.6268,9.829 14.6762,10.5869 14.5159,11.2934C15.0071,11.2784 15.4937,11.0834 15.8686,10.7086L20.0265,6.5506C20.8076,5.7696 20.8076,4.5033 20.0265,3.7222L17.1981,0.8938C16.417,0.1127 15.1507,0.1127 14.3697,0.8938L10.2117,5.0517C9.8369,5.4266 9.6419,5.9132 9.6269,6.4044Z"
android:strokeWidth="1"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M5.4861,9.7344L5.4501,9.3925L14.9912,4.5311L15.2479,4.7833C15.7127,5.24 16.3352,5.5 17,5.5C18.3807,5.5 19.5,4.3807 19.5,3C19.5,1.6193 18.3807,0.5 17,0.5C15.6193,0.5 14.5,1.6193 14.5,3C14.5,3.0963 14.5054,3.1918 14.5162,3.2863L14.5554,3.6308L5.0234,8.4876L4.7666,8.2311C4.3005,7.7656 3.6719,7.5 3,7.5C1.6193,7.5 0.5,8.6193 0.5,10C0.5,11.3807 1.6193,12.5 3,12.5C3.6072,12.5 4.1796,12.2834 4.6301,11.8955L4.8892,11.6724L14.493,16.7788L14.501,17.0699C14.5379,18.4208 15.6453,19.5 17,19.5C18.3807,19.5 19.5,18.3807 19.5,17C19.5,15.6193 18.3807,14.5 17,14.5C16.2197,14.5 15.4997,14.8592 15.0283,15.4628L14.77,15.7935L5.3947,10.8086L5.4598,10.4494C5.4865,10.3023 5.5,10.1521 5.5,10C5.5,9.9107 5.4953,9.8221 5.4861,9.7344Z"
android:strokeWidth="1"
android:fillColor="#979797"
android:strokeColor="#979797"
android:fillType="evenOdd"/>
</vector>

View File

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M11,11m-10,0a10,10 0,1 1,20 0a10,10 0,1 1,-20 0"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeLineCap="round"/>
<path
android:pathData="M7,13C7,13 8.5,15 11,15C13.5,15 15,13 15,13"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeLineCap="round"/>
<path
android:pathData="M7.5,7.5m-1.5,0a1.5,1.5 0,1 1,3 0a1.5,1.5 0,1 1,-3 0"
android:strokeWidth="1"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M14.5,7.5m-1.5,0a1.5,1.5 0,1 1,3 0a1.5,1.5 0,1 1,-3 0"
android:strokeWidth="1"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
</vector>

View File

@ -0,0 +1,24 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="17dp"
android:viewportWidth="22"
android:viewportHeight="17">
<path
android:pathData="M1.9413,8.3353L6.3938,12.3316C6.6985,12.6051 6.7238,13.0739 6.4503,13.3786C6.4325,13.3985 6.4136,13.4174 6.3938,13.4352C6.044,13.7491 5.514,13.7491 5.1643,13.4352L0.2462,9.021C0.0472,8.8423 -0.0327,8.5804 0.0121,8.3353C-0.0327,8.0902 0.0472,7.8283 0.2462,7.6496L5.1643,3.2354C5.514,2.9215 6.044,2.9215 6.3938,3.2354C6.4136,3.2532 6.4325,3.2721 6.4503,3.292C6.7238,3.5967 6.6985,4.0655 6.3938,4.339L1.9413,8.3353Z"
android:strokeWidth="1"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M19.6987,8.3353L15.2462,12.3316C14.9415,12.6051 14.9161,13.0739 15.1897,13.3786C15.2075,13.3985 15.2263,13.4174 15.2462,13.4352C15.5959,13.7491 16.126,13.7491 16.4757,13.4352L21.3938,9.021C21.5928,8.8423 21.6726,8.5804 21.6279,8.3353C21.6726,8.0902 21.5928,7.8283 21.3938,7.6496L16.4757,3.2354C16.126,2.9215 15.5959,2.9215 15.2462,3.2354C15.2263,3.2532 15.2075,3.2721 15.1897,3.292C14.9161,3.5967 14.9415,4.0655 15.2462,4.339L19.6987,8.3353Z"
android:strokeWidth="1"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
<path
android:pathData="M13,0.0467l2,0l-6.0855,16.9533l-2,0z"
android:strokeWidth="1"
android:fillColor="#9E9E9E"
android:fillType="evenOdd"
android:strokeColor="#00000000"/>
</vector>

View File

@ -6,10 +6,16 @@
android:layout_height="match_parent"
tools:openDrawer="start">

<FrameLayout
android:id="@+id/homeDetailFragmentContainer"
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/coordinatorLayout"
android:layout_width="match_parent"
android:layout_height="match_parent" />
android:layout_height="match_parent">

<FrameLayout
android:id="@+id/homeDetailFragmentContainer"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>


<FrameLayout

View File

@ -0,0 +1,37 @@
<?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="match_parent"
tools:layout_height="50dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:minHeight="50dp"
android:orientation="horizontal"
android:paddingLeft="@dimen/layout_horizontal_margin"
android:paddingTop="8dp"
android:paddingRight="@dimen/layout_horizontal_margin"
android:paddingBottom="8dp">

<ImageView
android:id="@+id/action_icon"
android:layout_width="22dp"
android:layout_height="22dp"
android:layout_gravity="center_vertical"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:src="@drawable/ic_material_delete"
android:tint="?android:attr/textColorSecondary" />

<TextView
android:id="@+id/action_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:textSize="17sp"
tools:text="@string/delete" />

</LinearLayout>

View File

@ -0,0 +1,141 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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:layout_width="match_parent"
android:id="@+id/root_layout"
android:layout_height="96dp">


<RelativeLayout
android:id="@+id/quick_react_1"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="4dp"
android:layout_marginRight="4dp"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"

android:focusable="true"
app:layout_constraintBottom_toTopOf="@id/quick_react_agree_text"
app:layout_constraintEnd_toStartOf="@id/quick_react_2"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed">

<TextView
android:id="@+id/quick_react_1_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:textSize="30sp"
tools:text="👍" />
</RelativeLayout>

<RelativeLayout
android:id="@+id/quick_react_2"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
app:layout_constraintBottom_toBottomOf="@id/quick_react_1"
app:layout_constraintEnd_toStartOf="@id/center_guideline"

app:layout_constraintStart_toEndOf="@id/quick_react_1"
app:layout_constraintTop_toTopOf="@id/quick_react_1">

<TextView
android:id="@+id/quick_react_2_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_gravity="center_vertical"
android:textSize="30sp"
tools:text="👎" />
</RelativeLayout>

<TextView
android:id="@+id/quick_react_agree_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:text="@string/reactions_agree"
android:textAlignment="center"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/center_guideline"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/quick_react_1" />

<androidx.constraintlayout.widget.Guideline
android:id="@+id/center_guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
app:layout_constraintGuide_percent="0.5" />

<RelativeLayout
android:id="@+id/quick_react_3"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="4dp"
android:layout_marginRight="4dp"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
app:layout_constraintBottom_toBottomOf="@+id/quick_react_1"
app:layout_constraintEnd_toStartOf="@id/quick_react_4"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/center_guideline"
app:layout_constraintTop_toTopOf="@id/quick_react_1">

<TextView
android:id="@+id/quick_react_3_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:textSize="30sp"
tools:text="😀" />
</RelativeLayout>

<RelativeLayout
android:id="@+id/quick_react_4"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
app:layout_constraintBottom_toBottomOf="@id/quick_react_3"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/quick_react_3"
app:layout_constraintTop_toTopOf="@id/quick_react_3">

<TextView
android:id="@+id/quick_react_4_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerInParent="true"
android:layout_gravity="center_vertical"
android:textSize="30sp"
tools:text="😞" />
</RelativeLayout>

<TextView
android:id="@+id/quick_react_like_text"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/reactions_like"
android:textAlignment="center"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="@id/quick_react_agree_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/center_guideline"
app:layout_constraintTop_toTopOf="@id/quick_react_agree_text" />

</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView 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:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/bottom_sheet_behavior">

<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">

<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/bottom_sheet_message_preview"
android:layout_width="match_parent"
android:layout_height="wrap_content">

<ImageView
android:id="@+id/bottom_sheet_message_preview_avatar"
android:layout_width="60dp"
android:layout_height="60dp"
android:layout_margin="@dimen/layout_horizontal_margin"
android:adjustViewBounds="true"
android:contentDescription="@string/avatar"
android:background="@drawable/circle"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:src="@tools:sample/avatars" />

<TextView
android:id="@+id/bottom_sheet_message_preview_sender"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_gravity="start"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:ellipsize="end"
android:fontFamily="sans-serif-bold"
android:singleLine="true"
android:textSize="14sp"
android:textStyle="bold"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/bottom_sheet_message_preview_avatar"
app:layout_constraintTop_toTopOf="@id/bottom_sheet_message_preview_avatar"
tools:text="@tools:sample/full_names" />

<TextView xmlns:android="http://schemas.android.com/apk/res/android"

android:id="@+id/bottom_sheet_message_preview_body"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_gravity="start"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:layout_marginBottom="@dimen/layout_vertical_margin"
android:ellipsize="end"
android:maxLines="2"
android:textColor="?android:textColorSecondary"
android:textIsSelectable="false"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/bottom_sheet_message_preview_avatar"
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_sender"
tools:text="Quis harum id autem cumque consequatur laboriosam aliquam sed. Sint accusamus dignissimos nobis ullam earum debitis aspernatur. " />

</androidx.constraintlayout.widget.ConstraintLayout>

<LinearLayout
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/vctr_list_divider_color" />

<FrameLayout
android:id="@+id/bottom_sheet_quick_reaction_container"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

<LinearLayout
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/vctr_list_divider_color" />


<FrameLayout
android:id="@+id/bottom_sheet_menu_container"
android:layout_width="match_parent"
android:layout_height="wrap_content" />

<!--<com.airbnb.epoxy.EpoxyRecyclerView-->
<!--android:visibility="invisible"-->
<!--android:id="@+id/bottom_sheet_actions_list"-->
<!--android:layout_width="match_parent"-->
<!--android:layout_height="match_parent"-->
<!--tools:itemCount="20"-->
<!--android:minHeight="80dp"-->
<!--tools:listitem="@layout/adapter_item_action">-->

<!--</com.airbnb.epoxy.EpoxyRecyclerView>-->
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView 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">

<TextView
android:id="@+id/event_content_text_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="5dp"
android:textSize="12sp"
android:fontFamily="monospace"
android:textIsSelectable="true" />
</ScrollView>

View File

@ -4,6 +4,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:paddingLeft="16dp"
android:paddingRight="16dp">

@ -54,6 +55,7 @@
android:id="@+id/messageTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:foreground="?attr/selectableItemBackgroundBorderless"
android:layout_marginStart="64dp"
android:layout_marginLeft="64dp"
android:layout_marginBottom="8dp"

View File

@ -57,6 +57,8 @@
<string name="stay">Stay</string>
<string name="send">Send</string>
<string name="copy">Copy</string>
<string name="edit">Edit</string>
<string name="reply">Reply</string>
<string name="resend">Resend</string>
<string name="redact">Remove</string>
<string name="quote">Quote</string>
@ -1399,5 +1401,10 @@ Why choose Riot.im?
<string name="autodiscover_well_known_autofill_dialog_title">"Autocomplete Server Options</string>
<string name="autodiscover_well_known_autofill_dialog_message">Riot detected a custom server configuration for your userId domain \"%s\":\n%s</string>
<string name="autodiscover_well_known_autofill_confirm">Use Config</string>
<string name="reactions_agree">Agree</string>
<string name="reactions_like">Like</string>
<string name="message_add_reaction">Add Reaction</string>

</resources>

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="shared"
path="/" />
</paths>