Merge pull request #168 from vector-im/feature/aggregation_p1_wrapup

Feature/aggregation p1 wrapup
This commit is contained in:
Valere 2019-06-07 16:40:22 +02:00 committed by GitHub
commit 8f2c005d82
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 1237 additions and 455 deletions

View File

@ -48,7 +48,7 @@ android {
buildConfigField "boolean", "LOG_PRIVATE_DATA", "false"
// Set to BODY instead of NONE to enable logging
buildConfigField "okhttp3.logging.HttpLoggingInterceptor.Level", "OKHTTP_LOGGING_LEVEL", "okhttp3.logging.HttpLoggingInterceptor.Level.BASIC"
buildConfigField "okhttp3.logging.HttpLoggingInterceptor.Level", "OKHTTP_LOGGING_LEVEL", "okhttp3.logging.HttpLoggingInterceptor.Level.NONE"
}
release {

View File

@ -15,7 +15,9 @@
*/
package im.vector.matrix.android.api.session.room.model.relation
import androidx.lifecycle.LiveData
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.api.util.Cancelable
/**
@ -91,4 +93,5 @@ interface RelationService {
*/
fun replyToMessage(eventReplied: Event, replyText: String): Cancelable?
fun getEventSummaryLive(eventId: String): LiveData<List<EventAnnotationsSummary>>
}

View File

@ -16,6 +16,7 @@
package im.vector.matrix.android.internal.database.mapper
import com.squareup.moshi.JsonDataException
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.UnsignedData
import im.vector.matrix.android.internal.database.model.EventEntity
@ -46,8 +47,16 @@ internal object EventMapper {
fun map(eventEntity: EventEntity): Event {
//TODO proxy the event to only parse unsigned data when accessed?
var ud = if (eventEntity.unsignedData.isNullOrBlank()) null
else MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).fromJson(eventEntity.unsignedData)
val ud = if (eventEntity.unsignedData.isNullOrBlank()) {
null
} else {
try {
MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).fromJson(eventEntity.unsignedData)
} catch (t: JsonDataException) {
null
}
}
return Event(
type = eventEntity.type,
eventId = eventEntity.eventId,

View File

@ -19,12 +19,10 @@ package im.vector.matrix.android.internal.database.model
import im.vector.matrix.android.api.session.room.send.SendState
import io.realm.RealmObject
import io.realm.RealmResults
import io.realm.annotations.Ignore
import io.realm.annotations.Index
import io.realm.annotations.LinkingObjects
import io.realm.annotations.PrimaryKey
import java.util.*
import kotlin.properties.Delegates
internal open class EventEntity(@PrimaryKey var localId: String = UUID.randomUUID().toString(),
@Index var eventId: String = "",
@ -51,10 +49,14 @@ internal open class EventEntity(@PrimaryKey var localId: String = UUID.randomUUI
private var sendStateStr: String = SendState.UNKNOWN.name
@delegate:Ignore
var sendState: SendState by Delegates.observable(SendState.valueOf(sendStateStr)) { _, _, newValue ->
sendStateStr = newValue.name
}
var sendState: SendState
get() {
return SendState.valueOf(sendStateStr)
}
set(value) {
sendStateStr = value.name
}
companion object

View File

@ -27,7 +27,7 @@ import im.vector.matrix.android.internal.database.model.*
import im.vector.matrix.android.internal.database.query.create
import im.vector.matrix.android.internal.database.query.where
import im.vector.matrix.android.internal.task.Task
import im.vector.matrix.android.internal.util.tryTransactionAsync
import im.vector.matrix.android.internal.util.tryTransactionSync
import io.realm.Realm
import timber.log.Timber
@ -44,61 +44,79 @@ internal interface EventRelationsAggregationTask : Task<EventRelationsAggregatio
*/
internal class DefaultEventRelationsAggregationTask(private val monarchy: Monarchy) : EventRelationsAggregationTask {
//OPT OUT serer aggregation until API mature enough
private val SHOULD_HANDLE_SERVER_AGREGGATION = false
override fun execute(params: EventRelationsAggregationTask.Params): Try<Unit> {
return monarchy.tryTransactionAsync { realm ->
update(realm, params.events, params.userId)
val events = params.events
val userId = params.userId
return monarchy.tryTransactionSync { realm ->
Timber.v(">>> DefaultEventRelationsAggregationTask[${params.hashCode()}] called with ${events.size} events")
update(realm, events, userId)
Timber.v("<<< DefaultEventRelationsAggregationTask[${params.hashCode()}] finished")
}
}
private fun update(realm: Realm, events: List<Pair<Event, SendState>>, userId: String) {
events.forEach { pair ->
val roomId = pair.first.roomId ?: return@forEach
val event = pair.first
val sendState = pair.second
val isLocalEcho = sendState == SendState.UNSENT
when (event.type) {
EventType.REACTION -> {
//we got a reaction!!
Timber.v("###REACTION in room $roomId")
handleReaction(event, roomId, realm, userId, isLocalEcho)
try { //Temporary catch, should be removed
val roomId = pair.first.roomId
if (roomId == null) {
Timber.w("Event has no room id ${pair.first.eventId}")
return@forEach
}
EventType.MESSAGE -> {
if (event.unsignedData?.relations?.annotations != null) {
Timber.v("###REACTION Agreggation in room $roomId for event ${event.eventId}")
handleInitialAggregatedRelations(event, roomId, event.unsignedData.relations.annotations, realm)
} else {
val content: MessageContent? = event.content.toModel()
if (content?.relatesTo?.type == RelationType.REPLACE) {
Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
//A replace!
handleReplace(realm, event, content, roomId, isLocalEcho)
}
val event = pair.first
val sendState = pair.second
val isLocalEcho = sendState == SendState.UNSENT
when (event.type) {
EventType.REACTION -> {
//we got a reaction!!
Timber.v("###REACTION in room $roomId , reaction eventID ${event.eventId}")
handleReaction(event, roomId, realm, userId, isLocalEcho)
}
}
EventType.REDACTION -> {
val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() }
?: return
when (eventToPrune.type) {
EventType.MESSAGE -> {
Timber.d("REDACTION for message ${eventToPrune.eventId}")
val unsignedData = EventMapper.map(eventToPrune).unsignedData
?: UnsignedData(null, null)
//was this event a m.replace
val contentModel = ContentMapper.map(eventToPrune.content)?.toModel<MessageContent>()
if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) {
handleRedactionOfReplace(eventToPrune, contentModel.relatesTo!!.eventId!!, realm)
EventType.MESSAGE -> {
if (event.unsignedData?.relations?.annotations != null) {
Timber.v("###REACTION Agreggation in room $roomId for event ${event.eventId}")
handleInitialAggregatedRelations(event, roomId, event.unsignedData.relations.annotations, realm)
} else {
val content: MessageContent? = event.content.toModel()
if (content?.relatesTo?.type == RelationType.REPLACE) {
Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
//A replace!
handleReplace(realm, event, content, roomId, isLocalEcho)
}
}
EventType.REACTION -> {
handleReactionRedact(eventToPrune, realm, userId)
}
EventType.REDACTION -> {
val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() }
?: return@forEach
when (eventToPrune.type) {
EventType.MESSAGE -> {
Timber.d("REDACTION for message ${eventToPrune.eventId}")
val unsignedData = EventMapper.map(eventToPrune).unsignedData
?: UnsignedData(null, null)
//was this event a m.replace
val contentModel = ContentMapper.map(eventToPrune.content)?.toModel<MessageContent>()
if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) {
handleRedactionOfReplace(eventToPrune, contentModel.relatesTo!!.eventId!!, realm)
}
}
EventType.REACTION -> {
handleReactionRedact(eventToPrune, realm, userId)
}
}
}
else -> Timber.v("UnHandled event ${event.eventId}")
}
} catch (t: Throwable) {
Timber.e(t, "## Should not happen ")
}
}
}
private fun handleReplace(realm: Realm, event: Event, content: MessageContent, roomId: String, isLocalEcho: Boolean) {
@ -108,7 +126,7 @@ internal class DefaultEventRelationsAggregationTask(private val monarchy: Monarc
//ok, this is a replace
var existing = EventAnnotationsSummaryEntity.where(realm, targetEventId).findFirst()
if (existing == null) {
Timber.v("###REPLACE creating no relation summary for ${targetEventId}")
Timber.v("###REPLACE creating new relation summary for ${targetEventId}")
existing = EventAnnotationsSummaryEntity.create(realm, targetEventId)
existing.roomId = roomId
}
@ -116,7 +134,7 @@ internal class DefaultEventRelationsAggregationTask(private val monarchy: Monarc
//we have it
val existingSummary = existing.editSummary
if (existingSummary == null) {
Timber.v("###REPLACE no edit summary for ${targetEventId}, creating one (localEcho:$isLocalEcho)")
Timber.v("###REPLACE new edit summary for ${targetEventId}, creating one (localEcho:$isLocalEcho)")
//create the edit summary
val editSummary = realm.createObject(EditAggregatedSummaryEntity::class.java)
editSummary.lastEditTs = event.originServerTs ?: System.currentTimeMillis()
@ -155,82 +173,92 @@ internal class DefaultEventRelationsAggregationTask(private val monarchy: Monarc
}
private fun handleInitialAggregatedRelations(event: Event, roomId: String, aggregation: AggregatedAnnotation, realm: Realm) {
aggregation.chunk?.forEach {
if (it.type == EventType.REACTION) {
val eventId = event.eventId ?: ""
val existing = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst()
if (existing == null) {
val eventSummary = EventAnnotationsSummaryEntity.create(realm, eventId)
eventSummary.roomId = roomId
val sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java)
sum.key = it.key
sum.firstTimestamp = event.originServerTs ?: 0 //TODO how to maintain order?
sum.count = it.count
eventSummary.reactionsSummary.add(sum)
} else {
//TODO how to handle that
if (SHOULD_HANDLE_SERVER_AGREGGATION) {
aggregation.chunk?.forEach {
if (it.type == EventType.REACTION) {
val eventId = event.eventId ?: ""
val existing = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst()
if (existing == null) {
val eventSummary = EventAnnotationsSummaryEntity.create(realm, eventId)
eventSummary.roomId = roomId
val sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java)
sum.key = it.key
sum.firstTimestamp = event.originServerTs ?: 0 //TODO how to maintain order?
sum.count = it.count
eventSummary.reactionsSummary.add(sum)
} else {
//TODO how to handle that
}
}
}
}
}
private fun handleReaction(event: Event, roomId: String, realm: Realm, userId: String, isLocalEcho: Boolean) {
event.content.toModel<ReactionContent>()?.let { content ->
//rel_type must be m.annotation
if (RelationType.ANNOTATION == content.relatesTo?.type) {
val reaction = content.relatesTo.key
val eventId = content.relatesTo.eventId
val eventSummary = EventAnnotationsSummaryEntity.where(realm, eventId).findFirst()
?: EventAnnotationsSummaryEntity.create(realm, eventId).apply { this.roomId = roomId }
val content = event.content.toModel<ReactionContent>()
if (content == null) {
Timber.e("Malformed reaction content ${event.content}")
return
}
//rel_type must be m.annotation
if (RelationType.ANNOTATION == content.relatesTo?.type) {
val reaction = content.relatesTo.key
val relatedEventID = content.relatesTo.eventId
val reactionEventId = event.eventId
Timber.v("Reaction $reactionEventId relates to $relatedEventID")
val eventSummary = EventAnnotationsSummaryEntity.where(realm, relatedEventID).findFirst()
?: EventAnnotationsSummaryEntity.create(realm, relatedEventID).apply { this.roomId = roomId }
var sum = eventSummary.reactionsSummary.find { it.key == reaction }
val txId = event.unsignedData?.transactionId
if (isLocalEcho && txId.isNullOrBlank()) {
Timber.w("Received a local echo with no transaction ID")
}
if (sum == null) {
sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java)
sum.key = reaction
sum.firstTimestamp = event.originServerTs ?: 0
if (isLocalEcho) {
Timber.v("Adding local echo reaction $reaction")
sum.sourceLocalEcho.add(txId)
sum.count = 1
} else {
Timber.v("Adding synced reaction $reaction")
sum.count = 1
sum.sourceEvents.add(event.eventId)
}
sum.addedByMe = sum.addedByMe || (userId == event.sender)
eventSummary.reactionsSummary.add(sum)
var sum = eventSummary.reactionsSummary.find { it.key == reaction }
val txId = event.unsignedData?.transactionId
if (isLocalEcho && txId.isNullOrBlank()) {
Timber.w("Received a local echo with no transaction ID")
}
if (sum == null) {
sum = realm.createObject(ReactionAggregatedSummaryEntity::class.java)
sum.key = reaction
sum.firstTimestamp = event.originServerTs ?: 0
if (isLocalEcho) {
Timber.v("Adding local echo reaction $reaction")
sum.sourceLocalEcho.add(txId)
sum.count = 1
} else {
//is this a known event (is possible? pagination?)
if (!sum.sourceEvents.contains(eventId)) {
Timber.v("Adding synced reaction $reaction")
sum.count = 1
sum.sourceEvents.add(reactionEventId)
}
sum.addedByMe = sum.addedByMe || (userId == event.sender)
eventSummary.reactionsSummary.add(sum)
} else {
//is this a known event (is possible? pagination?)
if (!sum.sourceEvents.contains(reactionEventId)) {
//check if it's not the sync of a local echo
if (!isLocalEcho && sum.sourceLocalEcho.contains(txId)) {
//ok it has already been counted, just sync the list, do not touch count
Timber.v("Ignoring synced of local echo for reaction $reaction")
sum.sourceLocalEcho.remove(txId)
sum.sourceEvents.add(event.eventId)
//check if it's not the sync of a local echo
if (!isLocalEcho && sum.sourceLocalEcho.contains(txId)) {
//ok it has already been counted, just sync the list, do not touch count
Timber.v("Ignoring synced of local echo for reaction $reaction")
sum.sourceLocalEcho.remove(txId)
sum.sourceEvents.add(reactionEventId)
} else {
sum.count += 1
if (isLocalEcho) {
Timber.v("Adding local echo reaction $reaction")
sum.sourceLocalEcho.add(txId)
} else {
sum.count += 1
if (isLocalEcho) {
Timber.v("Adding local echo reaction $reaction")
sum.sourceLocalEcho.add(txId)
} else {
Timber.v("Adding synced reaction $reaction")
sum.sourceEvents.add(event.eventId)
}
sum.addedByMe = sum.addedByMe || (userId == event.sender)
Timber.v("Adding synced reaction $reaction")
sum.sourceEvents.add(reactionEventId)
}
sum.addedByMe = sum.addedByMe || (userId == event.sender)
}
}
}
}
} else {
Timber.e("Unknwon relation type ${content.relatesTo?.type} for event ${event.eventId}")
}
}
/**

View File

@ -46,11 +46,11 @@ internal class EventRelationsAggregationUpdater(monarchy: Monarchy,
override fun processChanges(inserted: List<EventEntity>, updated: List<EventEntity>, deleted: List<EventEntity>) {
Timber.v("EventRelationsAggregationUpdater called with ${inserted.size} insertions")
val inserted = inserted
.mapNotNull { it.asDomain() to it.sendState }
val domainInserted = inserted
.map { it.asDomain() to it.sendState }
val params = EventRelationsAggregationTask.Params(
inserted,
domainInserted,
credentials.userId
)

View File

@ -15,15 +15,19 @@
*/
package im.vector.matrix.android.internal.session.room.relation
import androidx.lifecycle.LiveData
import androidx.work.OneTimeWorkRequest
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.room.model.EventAnnotationsSummary
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.model.relation.RelationService
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.database.helper.addSendingEvent
import im.vector.matrix.android.internal.database.mapper.asDomain
import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.model.EventAnnotationsSummaryEntity
import im.vector.matrix.android.internal.database.model.RoomEntity
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.database.query.where
@ -169,6 +173,18 @@ internal class DefaultRelationService(private val roomId: String,
return CancelableWork(workRequest.id)
}
override fun getEventSummaryLive(eventId: String): LiveData<List<EventAnnotationsSummary>> {
return monarchy.findAllMappedWithChanges(
{ realm ->
EventAnnotationsSummaryEntity.where(realm, eventId)
},
{
it.asDomain()
}
)
}
/**
* Saves the event in database as a local echo.
* SendState is set to UNSENT and it's added to a the sendingTimelineEvents list of the room.

View File

@ -38,7 +38,12 @@ internal class TaskExecutor(private val coroutineDispatchers: MatrixCoroutineDis
task.execute(task.params)
}
}
resultOrFailure.fold({ task.callback.onFailure(it) }, { task.callback.onSuccess(it) })
resultOrFailure.fold({
Timber.d(it, "Task failed")
task.callback.onFailure(it)
}, {
task.callback.onSuccess(it)
})
}
return CancelableCoroutine(job)
}

View File

@ -84,6 +84,7 @@ android {
debug {
resValue "bool", "debug_mode", "true"
buildConfigField "boolean", "LOW_PRIVACY_LOG_ENABLE", "false"
buildConfigField "boolean", "SHOW_HIDDEN_TIMELINE_EVENTS", "false"
signingConfig signingConfigs.debug
}
@ -91,6 +92,7 @@ android {
release {
resValue "bool", "debug_mode", "false"
buildConfigField "boolean", "LOW_PRIVACY_LOG_ENABLE", "false"
buildConfigField "boolean", "SHOW_HIDDEN_TIMELINE_EVENTS", "false"
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'

View File

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

View File

@ -0,0 +1,48 @@
package im.vector.riotredesign
import android.graphics.Typeface
import androidx.core.provider.FontsContractCompat
import timber.log.Timber
class EmojiCompatFontProvider : FontsContractCompat.FontRequestCallback() {
var typeface: Typeface? = null
set(value) {
if (value != field) {
field = value
listeners.forEach {
try {
it.compatibilityFontUpdate(value)
} catch (t: Throwable) {
Timber.e(t)
}
}
}
}
private val listeners = ArrayList<FontProviderListener>()
override fun onTypefaceRetrieved(typeface: Typeface) {
this.typeface = typeface
}
override fun onTypefaceRequestFailed(reason: Int) {
Timber.e("Failed to load Emoji Compatible font, reason:$reason")
}
fun addListener(listener: FontProviderListener) {
if (!listeners.contains(listener)) {
listeners.add(listener)
}
}
fun removeListener(listener: FontProviderListener) {
listeners.remove(listener)
}
interface FontProviderListener {
fun compatibilityFontUpdate(typeface: Typeface?)
}
}

View File

@ -18,6 +18,10 @@ package im.vector.riotredesign
import android.app.Application
import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import androidx.core.provider.FontRequest
import androidx.core.provider.FontsContractCompat
import android.content.res.Configuration
import androidx.multidex.MultiDex
import com.airbnb.epoxy.EpoxyAsyncUtil
@ -41,6 +45,10 @@ import timber.log.Timber
class VectorApplication : Application() {
//font thread handler
private var mFontThreadHandler: Handler? = null
val vectorConfiguration: VectorConfiguration by inject()
override fun onCreate() {
@ -63,10 +71,20 @@ class VectorApplication : Application() {
val appModule = AppModule(applicationContext).definition
val homeModule = HomeModule().definition
val roomDirectoryModule = RoomDirectoryModule().definition
startKoin(listOf(appModule, homeModule, roomDirectoryModule), logger = EmptyLogger())
val koin = startKoin(listOf(appModule, homeModule, roomDirectoryModule), logger = EmptyLogger())
Matrix.getInstance().setApplicationFlavor(BuildConfig.FLAVOR_DESCRIPTION)
val fontRequest = FontRequest(
"com.google.android.gms.fonts",
"com.google.android.gms",
"Noto Color Emoji Compat",
R.array.com_google_android_gms_fonts_certs
)
// val efp = koin.koinContext.get<EmojiCompatFontProvider>()
FontsContractCompat.requestFont(this, fontRequest, koin.koinContext.get<EmojiCompatFontProvider>(), getFontThreadHandler())
vectorConfiguration.initConfiguration()
}
@ -81,4 +99,13 @@ class VectorApplication : Application() {
vectorConfiguration.onConfigurationChanged(newConfig)
}
private fun getFontThreadHandler(): Handler {
if (mFontThreadHandler == null) {
val handlerThread = HandlerThread("fonts")
handlerThread.start()
mFontThreadHandler = Handler(handlerThread.looper)
}
return mFontThreadHandler!!
}
}

View File

@ -18,8 +18,8 @@ package im.vector.riotredesign.core.di
import android.content.Context
import android.content.Context.MODE_PRIVATE
import androidx.fragment.app.Fragment
import im.vector.matrix.android.api.Matrix
import im.vector.riotredesign.EmojiCompatFontProvider
import im.vector.riotredesign.core.error.ErrorFormatter
import im.vector.riotredesign.core.resources.LocaleProvider
import im.vector.riotredesign.core.resources.StringArrayProvider
@ -86,8 +86,12 @@ class AppModule(private val context: Context) {
Matrix.getInstance().currentSession!!
}
factory { (fragment: Fragment) ->
DefaultNavigator(fragment) as Navigator
factory {
DefaultNavigator() as Navigator
}
single {
EmojiCompatFontProvider()
}
}

View File

@ -28,4 +28,12 @@ object DimensionUtils {
context.resources.displayMetrics
).toInt()
}
fun spToPx(sp: Int, context: Context): Int {
return TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
sp.toFloat(),
context.resources.displayMetrics
).toInt()
}
}

View File

@ -0,0 +1,69 @@
package im.vector.riotredesign.core.utils
import java.util.regex.Pattern
private val emojisPattern = Pattern.compile("((?:[\uD83C\uDF00-\uD83D\uDDFF]" +
"|[\uD83E\uDD00-\uD83E\uDDFF]" +
"|[\uD83D\uDE00-\uD83D\uDE4F]" +
"|[\uD83D\uDE80-\uD83D\uDEFF]" +
"|[\u2600-\u26FF]\uFE0F?" +
"|[\u2700-\u27BF]\uFE0F?" +
"|\u24C2\uFE0F?" +
"|[\uD83C\uDDE6-\uD83C\uDDFF]{1,2}" +
"|[\uD83C\uDD70\uD83C\uDD71\uD83C\uDD7E\uD83C\uDD7F\uD83C\uDD8E\uD83C\uDD91-\uD83C\uDD9A]\uFE0F?" +
"|[\u0023\u002A\u0030-\u0039]\uFE0F?\u20E3" +
"|[\u2194-\u2199\u21A9-\u21AA]\uFE0F?" +
"|[\u2B05-\u2B07\u2B1B\u2B1C\u2B50\u2B55]\uFE0F?" +
"|[\u2934\u2935]\uFE0F?" +
"|[\u3030\u303D]\uFE0F?" +
"|[\u3297\u3299]\uFE0F?" +
"|[\uD83C\uDE01\uD83C\uDE02\uD83C\uDE1A\uD83C\uDE2F\uD83C\uDE32-\uD83C\uDE3A\uD83C\uDE50\uD83C\uDE51]\uFE0F?" +
"|[\u203C\u2049]\uFE0F?" +
"|[\u25AA\u25AB\u25B6\u25C0\u25FB-\u25FE]\uFE0F?" +
"|[\u00A9\u00AE]\uFE0F?" +
"|[\u2122\u2139]\uFE0F?" +
"|\uD83C\uDC04\uFE0F?" +
"|\uD83C\uDCCF\uFE0F?" +
"|[\u231A\u231B\u2328\u23CF\u23E9-\u23F3\u23F8-\u23FA]\uFE0F?))")
/**
* Test if a string contains emojis.
* It seems that the regex [emoji_regex]+ does not work.
* Some characters like ?, # or digit are accepted.
*
* @param str the body to test
* @return true if the body contains only emojis
*/
fun containsOnlyEmojis(str: String?): Boolean {
var res = false
if (str != null && str.isNotEmpty()) {
val matcher = emojisPattern.matcher(str)
var start = -1
var end = -1
while (matcher.find()) {
val nextStart = matcher.start()
// first emoji position
if (start < 0) {
if (nextStart > 0) {
return false
}
} else {
// must not have a character between
if (nextStart != end) {
return false
}
}
start = nextStart
end = matcher.end()
}
res = -1 != start && end == str.length
}
return res
}

View File

@ -0,0 +1,29 @@
package im.vector.riotredesign.core.utils
import java.util.*
object TextUtils {
private val suffixes = TreeMap<Int, String>().also {
it.put(1000, "k")
it.put(1000000, "M")
it.put(1000000000, "G")
}
fun formatCountToShortDecimal(value: Int): String {
try {
if (value < 0) return "-" + formatCountToShortDecimal(-value)
if (value < 1000) return value.toString() //deal with easy case
val e = suffixes.floorEntry(value)
val divideBy = e.key
val suffix = e.value
val truncated = value / (divideBy!! / 10) //the number part of the output times 10
val hasDecimal = truncated < 100 && truncated / 10.0 != (truncated / 10).toDouble()
return if (hasDecimal) "${truncated / 10.0}$suffix" else "${truncated / 10}$suffix"
} catch (t: Throwable) {
return value.toString()
}
}
}

View File

@ -54,12 +54,12 @@ class HomeDrawerFragment : VectorBaseFragment() {
}
}
homeDrawerHeaderSettingsView.setOnClickListener {
navigator.openSettings()
navigator.openSettings(requireActivity())
}
// Debug menu
homeDrawerHeaderDebugView.setOnClickListener {
navigator.openDebug()
navigator.openDebug(requireActivity())
}
}
}

View File

@ -72,7 +72,8 @@ class HomeModule {
val timelineMediaSizeProvider = TimelineMediaSizeProvider()
val colorProvider = ColorProvider(fragment.requireContext())
val timelineDateFormatter = get<TimelineDateFormatter>()
val messageItemFactory = MessageItemFactory(colorProvider, timelineMediaSizeProvider, timelineDateFormatter, eventHtmlRenderer, get())
val messageItemFactory = MessageItemFactory(colorProvider, timelineMediaSizeProvider,
timelineDateFormatter, eventHtmlRenderer, get(), get())
val timelineItemFactory = TimelineItemFactory(
messageItemFactory = messageItemFactory,

View File

@ -48,7 +48,7 @@ class HomeNavigator {
activity?.let {
//TODO enable eventId permalink. It doesn't work enough at the moment.
it.drawerLayout?.closeDrawer(GravityCompat.START)
navigator.openRoom(roomId)
navigator.openRoom(roomId, it)
}
}

View File

@ -53,6 +53,7 @@ import com.jaiselrahman.filepicker.model.MediaFile
import com.otaliastudios.autocomplete.Autocomplete
import com.otaliastudios.autocomplete.AutocompleteCallback
import com.otaliastudios.autocomplete.CharPolicy
import im.vector.matrix.android.api.permalinks.PermalinkFactory
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.EditAggregatedSummary
@ -84,6 +85,7 @@ import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventCo
import im.vector.riotredesign.features.home.room.detail.timeline.action.ActionsHandler
import im.vector.riotredesign.features.home.room.detail.timeline.action.MessageActionsBottomSheet
import im.vector.riotredesign.features.home.room.detail.timeline.action.MessageMenuViewModel
import im.vector.riotredesign.features.home.room.detail.timeline.action.ViewReactionBottomSheet
import im.vector.riotredesign.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotredesign.features.html.PillImageSpan
@ -235,11 +237,13 @@ class RoomDetailFragment :
var formattedBody: CharSequence? = null
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
val parser = Parser.builder().build()
val document = parser.parse(messageContent.formattedBody ?: messageContent.body)
val document = parser.parse(messageContent.formattedBody
?: messageContent.body)
formattedBody = Markwon.builder(requireContext())
.usePlugin(HtmlPlugin.create()).build().render(document)
}
composerLayout.composerRelatedMessageContent.text = formattedBody ?: nonFormattedBody
composerLayout.composerRelatedMessageContent.text = formattedBody
?: nonFormattedBody
if (mode == SendMode.EDIT) {
@ -559,11 +563,11 @@ class RoomDetailFragment :
vectorBaseActivity.notImplemented()
}
override fun onEventCellClicked(informationData: MessageInformationData, messageContent: MessageContent, view: View) {
override fun onEventCellClicked(informationData: MessageInformationData, messageContent: MessageContent?, view: View) {
}
override fun onEventLongClicked(informationData: MessageInformationData, messageContent: MessageContent, view: View): Boolean {
override fun onEventLongClicked(informationData: MessageInformationData, messageContent: MessageContent?, view: View): Boolean {
view.performHapticFeedback(HapticFeedbackConstants.LONG_PRESS)
val roomId = roomDetailArgs.roomId
@ -593,6 +597,11 @@ class RoomDetailFragment :
}
}
override fun onLongClickOnReactionPill(informationData: MessageInformationData, reaction: String) {
ViewReactionBottomSheet.newInstance(roomDetailArgs.roomId, informationData)
.show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS")
}
override fun onEditedDecorationClicked(informationData: MessageInformationData, editAggregatedSummary: EditAggregatedSummary?) {
editAggregatedSummary?.also {
roomDetailViewModel.process(RoomDetailActions.ShowEditHistoryAction(informationData.eventId, it))
@ -613,12 +622,17 @@ class RoomDetailFragment :
val eventId = actionData.data?.toString() ?: return
startActivityForResult(EmojiReactionPickerActivity.intent(requireContext(), eventId), REACTION_SELECT_REQUEST_CODE)
}
MessageMenuViewModel.ACTION_VIEW_REACTIONS -> {
val messageInformationData = actionData.data as? MessageInformationData
?: return
ViewReactionBottomSheet.newInstance(roomDetailArgs.roomId, messageInformationData)
.show(requireActivity().supportFragmentManager, "DISPLAY_REACTIONS")
}
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()
val msg = requireContext().getString(R.string.copied_to_clipboard)
showSnackWithMessage(msg, Snackbar.LENGTH_SHORT)
}
MessageMenuViewModel.ACTION_DELETE -> {
val eventId = actionData.data?.toString() ?: return
@ -685,6 +699,13 @@ class RoomDetailFragment :
val eventId = actionData.data.toString()
roomDetailViewModel.process(RoomDetailActions.EnterReplyMode(eventId))
}
MessageMenuViewModel.ACTION_COPY_PERMALINK -> {
val eventId = actionData.data.toString()
val permalink = PermalinkFactory.createPermalink(roomDetailArgs.roomId, eventId)
copyToClipboard(requireContext(), permalink, false)
showSnackWithMessage(requireContext().getString(R.string.copied_to_clipboard), Snackbar.LENGTH_SHORT)
}
else -> {
Toast.makeText(context, "Action ${actionData.actionId} not implemented", Toast.LENGTH_LONG).show()
}

View File

@ -55,7 +55,12 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
private val roomId = initialState.roomId
private val eventId = initialState.eventId
private val displayedEventsObservable = BehaviorRelay.create<RoomDetailActions.EventDisplayed>()
private val timeline = room.createTimeline(eventId, TimelineDisplayableEvents.DISPLAYABLE_TYPES)
private val allowedTypes = if (TimelineDisplayableEvents.DEBUG_HIDDEN_EVENT) {
TimelineDisplayableEvents.DEBUG_DISPLAYABLE_TYPES
} else {
TimelineDisplayableEvents.DISPLAYABLE_TYPES
}
private val timeline = room.createTimeline(eventId, allowedTypes)
companion object : MvRxViewModelFactory<RoomDetailViewModel, RoomDetailViewState> {
@ -195,7 +200,8 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
}
}
SendMode.EDIT -> {
room.editTextMessage(state.selectedEvent?.root?.eventId ?: "", action.text, action.autoMarkdown)
room.editTextMessage(state.selectedEvent?.root?.eventId
?: "", action.text, action.autoMarkdown)
setState {
copy(
sendMode = SendMode.REGULAR,
@ -330,7 +336,6 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
room.updateQuickReaction(action.selectedReaction, action.opposite, action.targetEventId, session.sessionParams.credentials.userId)
}
private fun handleSendMedia(action: RoomDetailActions.SendMedia) {
val attachments = action.mediaFiles.map {
ContentAttachmentData(
@ -350,6 +355,12 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) {
displayedEventsObservable.accept(action)
//We need to update this with the related m.replace also (to move read receipt)
action.event.annotations?.editSummary?.sourceEvents?.forEach {
room.getTimeLineEvent(it)?.let { event ->
displayedEventsObservable.accept(RoomDetailActions.EventDisplayed(event))
}
}
}
private fun handleLoadMore(action: RoomDetailActions.LoadMore) {

View File

@ -46,22 +46,29 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
private val backgroundHandler: Handler = TimelineAsyncHelper.getBackgroundHandler()
) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener {
interface Callback : ReactionPillCallback {
interface Callback : ReactionPillCallback, AvatarCallback, BaseCallback {
fun onEventVisible(event: TimelineEvent)
fun onUrlClicked(url: String)
fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View)
fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View)
fun onFileMessageClicked(messageFileContent: MessageFileContent)
fun onAudioMessageClicked(messageAudioContent: MessageAudioContent)
fun onEventCellClicked(informationData: MessageInformationData, messageContent: MessageContent, view: View)
fun onEventLongClicked(informationData: MessageInformationData, messageContent: MessageContent, view: View): Boolean
fun onAvatarClicked(informationData: MessageInformationData)
fun onMemberNameClicked(informationData: MessageInformationData)
fun onEditedDecorationClicked(informationData: MessageInformationData, editAggregatedSummary: EditAggregatedSummary?)
}
interface ReactionPillCallback {
fun onClickOnReactionPill(informationData: MessageInformationData, reaction: String, on: Boolean)
fun onLongClickOnReactionPill(informationData: MessageInformationData, reaction: String)
}
interface BaseCallback {
fun onEventCellClicked(informationData: MessageInformationData, messageContent: MessageContent?, view: View)
fun onEventLongClicked(informationData: MessageInformationData, messageContent: MessageContent?, view: View): Boolean
}
interface AvatarCallback {
fun onAvatarClicked(informationData: MessageInformationData)
fun onMemberNameClicked(informationData: MessageInformationData)
}
private val collapsedEventIds = linkedSetOf<String>()

View File

@ -17,13 +17,13 @@ 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.core.view.isVisible
import androidx.lifecycle.ViewModelProviders
import butterknife.BindView
import butterknife.ButterKnife
@ -33,10 +33,9 @@ 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
import kotlinx.android.synthetic.main.bottom_sheet_message_actions.*
/**
* Bottom sheet fragment that shows a message preview with list of contextual actions
@ -74,7 +73,7 @@ class MessageActionsBottomSheet : BaseMvRxBottomSheetDialog() {
val cfm = childFragmentManager
var menuActionFragment = cfm.findFragmentByTag("MenuActionFragment") as? MessageMenuFragment
if (menuActionFragment == null) {
menuActionFragment = MessageMenuFragment.newInstance(arguments!!.get(MvRx.KEY_ARG) as ParcelableArgs)
menuActionFragment = MessageMenuFragment.newInstance(arguments!!.get(MvRx.KEY_ARG) as TimelineEventFragmentArgs)
cfm.beginTransaction()
.replace(R.id.bottom_sheet_menu_container, menuActionFragment, "MenuActionFragment")
.commit()
@ -89,7 +88,7 @@ class MessageActionsBottomSheet : BaseMvRxBottomSheetDialog() {
var quickReactionFragment = cfm.findFragmentByTag("QuickReaction") as? QuickReactionFragment
if (quickReactionFragment == null) {
quickReactionFragment = QuickReactionFragment.newInstance(arguments!!.get(MvRx.KEY_ARG) as ParcelableArgs)
quickReactionFragment = QuickReactionFragment.newInstance(arguments!!.get(MvRx.KEY_ARG) as TimelineEventFragmentArgs)
cfm.beginTransaction()
.replace(R.id.bottom_sheet_quick_reaction_container, quickReactionFragment, "QuickReaction")
.commit()
@ -117,36 +116,26 @@ class MessageActionsBottomSheet : BaseMvRxBottomSheetDialog() {
}
override fun invalidate() = withState(viewModel) {
senderNameTextView.text = it.senderName
messageBodyTextView.text = it.messageBody
messageTimestampText.text = it.ts
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)
if (it.showPreview) {
bottom_sheet_message_preview.isVisible = true
senderNameTextView.text = it.senderName
messageBodyTextView.text = it.messageBody
messageTimestampText.text = it.ts
AvatarRenderer.render(it.senderAvatarPath, it.userId, it.senderName, senderAvatarImageView)
} else {
senderAvatarImageView.setImageDrawable(AvatarRenderer.getPlaceholderDrawable(requireContext(), it.userId, it.senderName))
bottom_sheet_message_preview.isVisible = false
}
quickReactBottomDivider.isVisible = it.canReact
bottom_sheet_quick_reaction_container.isVisible = it.canReact
return@withState
}
@Parcelize
data class ParcelableArgs(
val eventId: String,
val roomId: String,
val informationData: MessageInformationData
) : Parcelable
companion object {
fun newInstance(roomId: String, informationData: MessageInformationData): MessageActionsBottomSheet {
return MessageActionsBottomSheet().apply {
setArguments(
ParcelableArgs(
TimelineEventFragmentArgs(
informationData.eventId,
roomId,
informationData

View File

@ -15,18 +15,21 @@
*/
package im.vector.riotredesign.features.home.room.detail.timeline.action
import com.airbnb.mvrx.FragmentViewModelContext
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.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.riotredesign.core.platform.VectorViewModel
import im.vector.riotredesign.features.home.room.detail.timeline.format.NoticeEventFormatter
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import org.koin.android.ext.android.get
import org.koin.core.parameter.parametersOf
import ru.noties.markwon.Markwon
import ru.noties.markwon.html.HtmlPlugin
import timber.log.Timber
@ -35,10 +38,12 @@ import java.util.*
data class MessageActionState(
val userId: String,
val senderName: String,
val messageBody: CharSequence,
val ts: String?,
val userId: String = "",
val senderName: String = "",
val messageBody: CharSequence? = null,
val ts: String? = null,
val showPreview: Boolean = false,
val canReact: Boolean = false,
val senderAvatarPath: String? = null)
: MvRxState
@ -51,30 +56,47 @@ class MessageActionsViewModel(initialState: MessageActionState) : VectorViewMode
override fun initialState(viewModelContext: ViewModelContext): MessageActionState? {
val currentSession = viewModelContext.activity.get<Session>()
val parcel = viewModelContext.args as MessageActionsBottomSheet.ParcelableArgs
val fragment = (viewModelContext as? FragmentViewModelContext)?.fragment
val noticeFormatter = fragment?.get<NoticeEventFormatter>(parameters = { parametersOf(fragment) })
val parcel = viewModelContext.args as TimelineEventFragmentArgs
val dateFormat = SimpleDateFormat("EEE, d MMM yyyy HH:mm", Locale.getDefault())
val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId)
var body: CharSequence? = null
val originTs = event?.root?.originServerTs
return if (event != null) {
val messageContent: MessageContent? = event.annotations?.editSummary?.aggregatedContent?.toModel()
?: event.root.content.toModel()
val originTs = event.root.originServerTs
var body: CharSequence = messageContent?.body ?: ""
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
val parser = Parser.builder().build()
val document = parser.parse(messageContent.formattedBody?.trim() ?: messageContent.body)
// val renderer = HtmlRenderer.builder().build()
body = Markwon.builder(viewModelContext.activity)
.usePlugin(HtmlPlugin.create()).build().render(document)
// body = renderer.render(document)
when (event.root.type) {
EventType.MESSAGE -> {
val messageContent: MessageContent? = event.annotations?.editSummary?.aggregatedContent?.toModel()
?: event.root.content.toModel()
body = messageContent?.body
if (messageContent is MessageTextContent && messageContent.format == MessageType.FORMAT_MATRIX_HTML) {
val parser = Parser.builder().build()
val document = parser.parse(messageContent.formattedBody
?: messageContent.body)
body = Markwon.builder(viewModelContext.activity)
.usePlugin(HtmlPlugin.create()).build().render(document)
}
}
EventType.STATE_ROOM_NAME,
EventType.STATE_ROOM_TOPIC,
EventType.STATE_ROOM_MEMBER,
EventType.STATE_HISTORY_VISIBILITY,
EventType.CALL_INVITE,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> {
body = noticeFormatter?.format(event)
}
}
MessageActionState(
event.root.sender ?: "",
parcel.informationData.memberName.toString(),
body,
dateFormat.format(Date(originTs ?: 0)),
currentSession.contentUrlResolver().resolveFullSize(parcel.informationData.avatarUrl)
userId = event.root.sender ?: "",
senderName = parcel.informationData.memberName?.toString() ?: "",
messageBody = body,
ts = dateFormat.format(Date(originTs ?: 0)),
showPreview = body != null,
canReact = event.root.type == EventType.MESSAGE && event.sendState.isSent(),
senderAvatarPath = parcel.informationData.avatarUrl
)
} else {
//can this happen?

View File

@ -101,7 +101,7 @@ class MessageMenuFragment : BaseMvRxFragment() {
companion object {
fun newInstance(pa: MessageActionsBottomSheet.ParcelableArgs): MessageMenuFragment {
fun newInstance(pa: TimelineEventFragmentArgs): MessageMenuFragment {
val args = Bundle()
args.putParcelable(MvRx.KEY_ARG, pa)
val fragment = MessageMenuFragment()

View File

@ -34,7 +34,7 @@ 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
data class MessageMenuState(val actions: List<SimpleAction> = emptyList()) : MvRxState
/**
* Manages list actions for a given message (copy / paste / forward...)
@ -46,27 +46,26 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
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 parcel = viewModelContext.args as TimelineEventFragmentArgs
val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId)
?: return null
val messageContent: MessageContent = event.annotations?.editSummary?.aggregatedContent?.toModel()
?: event.root.content.toModel() ?: return null
val type = messageContent.type
val messageContent: MessageContent? = event.annotations?.editSummary?.aggregatedContent?.toModel()
?: event.root.content.toModel()
val type = messageContent?.type
if (event.sendState == SendState.UNSENT) {
if (!event.sendState.isSent()) {
//Resend and Delete
return MessageMenuState(
//TODO
listOf(
SimpleAction(ACTION_RESEND, R.string.resend, R.drawable.ic_send, event.root.eventId),
//TODO delete icon
SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, event.root.eventId)
// SimpleAction(ACTION_RESEND, R.string.resend, R.drawable.ic_send, event.root.eventId),
// //TODO delete icon
// SimpleAction(ACTION_DELETE, R.string.delete, R.drawable.ic_delete, event.root.eventId)
)
)
}
//TODO determine if can copy, forward, reply, quote, report?
val actions = ArrayList<SimpleAction>().apply {
if (event.sendState == SendState.SENDING) {
@ -75,10 +74,12 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
}
//TODO is downloading attachement?
this.add(SimpleAction(ACTION_ADD_REACTION, R.string.message_add_reaction, R.drawable.ic_add_reaction, event.root.eventId))
if (canReact(event, messageContent)) {
this.add(SimpleAction(ACTION_ADD_REACTION, R.string.message_add_reaction, R.drawable.ic_add_reaction, event.root.eventId))
}
if (canCopy(type)) {
//TODO copy images? html? see ClipBoard
this.add(SimpleAction(ACTION_COPY, R.string.copy, R.drawable.ic_copy, messageContent.body))
this.add(SimpleAction(ACTION_COPY, R.string.copy, R.drawable.ic_copy, messageContent!!.body))
}
if (canReply(event, messageContent)) {
@ -94,10 +95,13 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
}
if (canQuote(event, messageContent)) {
//TODO quote icon
this.add(SimpleAction(ACTION_QUOTE, R.string.quote, R.drawable.ic_quote, parcel.eventId))
}
if (canViewReactions(event)) {
this.add(SimpleAction(ACTION_VIEW_REACTIONS, R.string.message_view_reaction, R.drawable.ic_view_reactions, parcel.informationData))
}
if (canShare(type)) {
if (messageContent is MessageImageContent) {
this.add(
@ -122,9 +126,9 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
if (event.isEncrypted()) {
this.add(SimpleAction(VIEW_DECRYPTED_SOURCE, R.string.view_decrypted_source, R.drawable.ic_view_source, parcel.eventId))
}
this.add(SimpleAction(PERMALINK, R.string.permalink, R.drawable.ic_permalink, parcel.eventId))
this.add(SimpleAction(ACTION_COPY_PERMALINK, R.string.permalink, R.drawable.ic_permalink, parcel.eventId))
if (currentSession.sessionParams.credentials.userId != event.root.sender) {
if (currentSession.sessionParams.credentials.userId != event.root.sender && event.root.type == EventType.MESSAGE) {
//not sent by me
this.add(SimpleAction(ACTION_FLAG, R.string.report_content, R.drawable.ic_flag, parcel.eventId))
}
@ -133,10 +137,10 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
return MessageMenuState(actions)
}
private fun canReply(event: TimelineEvent, messageContent: MessageContent): Boolean {
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) {
return when (messageContent?.type) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE,
MessageType.MSGTYPE_EMOTE,
@ -144,14 +148,19 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_FILE -> true
else -> false
else -> false
}
}
private fun canQuote(event: TimelineEvent, messageContent: MessageContent): Boolean {
private fun canReact(event: TimelineEvent, messageContent: MessageContent?): Boolean {
//Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
return event.root.type == EventType.MESSAGE
}
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) {
return when (messageContent?.type) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE,
MessageType.MSGTYPE_EMOTE,
@ -159,7 +168,7 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
MessageType.MSGTYPE_LOCATION -> {
true
}
else -> false
else -> false
}
}
@ -170,6 +179,13 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
return event.root.sender == myUserId
}
private fun canViewReactions(event: TimelineEvent): Boolean {
//Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.type != EventType.MESSAGE) return false
//TODO if user is admin or moderator
return event.annotations?.reactionsSummary?.isNotEmpty() ?: false
}
private fun canEdit(event: TimelineEvent, myUserId: String): Boolean {
//Only event of type Event.EVENT_TYPE_MESSAGE are supported for the moment
if (event.root.type != EventType.MESSAGE) return false
@ -182,7 +198,7 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
}
private fun canCopy(type: String): Boolean {
private fun canCopy(type: String?): Boolean {
return when (type) {
MessageType.MSGTYPE_TEXT,
MessageType.MSGTYPE_NOTICE,
@ -191,19 +207,19 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
MessageType.MSGTYPE_LOCATION -> {
true
}
else -> false
else -> false
}
}
private fun canShare(type: String): Boolean {
private fun canShare(type: String?): Boolean {
return when (type) {
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_VIDEO -> {
true
}
else -> false
else -> false
}
}
@ -217,9 +233,10 @@ class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<Mes
const val ACTION_DELETE = "delete"
const val VIEW_SOURCE = "VIEW_SOURCE"
const val VIEW_DECRYPTED_SOURCE = "VIEW_DECRYPTED_SOURCE"
const val PERMALINK = "PERMALINK"
const val ACTION_COPY_PERMALINK = "ACTION_COPY_PERMALINK"
const val ACTION_FLAG = "ACTION_FLAG"
const val ACTION_QUICK_REACT = "ACTION_QUICK_REACT"
const val ACTION_VIEW_REACTIONS = "ACTION_VIEW_REACTIONS"
}

View File

@ -15,6 +15,7 @@
*/
package im.vector.riotredesign.features.home.room.detail.timeline.action
import android.graphics.Typeface
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@ -28,7 +29,9 @@ import com.airbnb.mvrx.BaseMvRxFragment
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.riotredesign.EmojiCompatFontProvider
import im.vector.riotredesign.R
import org.koin.android.ext.android.inject
/**
* Quick Reaction Fragment (agree / like reactions)
@ -54,6 +57,8 @@ class QuickReactionFragment : BaseMvRxFragment() {
var interactionListener: InteractionListener? = null
val fontProvider by inject<EmojiCompatFontProvider>()
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)
@ -68,6 +73,10 @@ class QuickReactionFragment : BaseMvRxFragment() {
quickReact3Text.text = QuickReactionViewModel.likePositive
quickReact4Text.text = QuickReactionViewModel.likeNegative
listOf(quickReact1Text, quickReact2Text, quickReact3Text, quickReact4Text).forEach {
it.typeface = fontProvider.typeface ?: Typeface.DEFAULT
}
//configure click listeners
quickReact1Text.setOnClickListener {
viewModel.toggleAgree(true)
@ -88,11 +97,11 @@ class QuickReactionFragment : BaseMvRxFragment() {
TransitionManager.beginDelayedTransition(rootLayout)
when (it.agreeTrigleState) {
TriggleState.NONE -> {
TriggleState.NONE -> {
quickReact1Text.alpha = 1f
quickReact2Text.alpha = 1f
}
TriggleState.FIRST -> {
TriggleState.FIRST -> {
quickReact1Text.alpha = 1f
quickReact2Text.alpha = 0.2f
@ -103,11 +112,11 @@ class QuickReactionFragment : BaseMvRxFragment() {
}
}
when (it.likeTriggleState) {
TriggleState.NONE -> {
TriggleState.NONE -> {
quickReact3Text.alpha = 1f
quickReact4Text.alpha = 1f
}
TriggleState.FIRST -> {
TriggleState.FIRST -> {
quickReact3Text.alpha = 1f
quickReact4Text.alpha = 0.2f
@ -130,7 +139,7 @@ class QuickReactionFragment : BaseMvRxFragment() {
}
companion object {
fun newInstance(pa: MessageActionsBottomSheet.ParcelableArgs): QuickReactionFragment {
fun newInstance(pa: TimelineEventFragmentArgs): QuickReactionFragment {
val args = Bundle()
args.putParcelable(MvRx.KEY_ARG, pa)
val fragment = QuickReactionFragment()

View File

@ -32,15 +32,14 @@ enum class TriggleState {
}
data class QuickReactionState(
val agreeTrigleState: TriggleState,
val likeTriggleState: TriggleState,
val agreeTrigleState: TriggleState = TriggleState.NONE,
val likeTriggleState: TriggleState = TriggleState.NONE,
/** Pair of 'clickedOn' and current toggles state*/
val selectionResult: Pair<String, List<String>>? = null,
val eventId: String) : MvRxState
val eventId: String = "") : MvRxState
/**
* Quick reaction view model
* TODO: configure initial state from event
*/
class QuickReactionViewModel(initialState: QuickReactionState) : VectorViewModel<QuickReactionState>(initialState) {
@ -88,15 +87,15 @@ class QuickReactionViewModel(initialState: QuickReactionState) : VectorViewModel
private fun getReactions(state: QuickReactionState, newState1: TriggleState?, newState2: TriggleState?): List<String> {
return ArrayList<String>(4).apply {
when (newState2 ?: state.likeTriggleState) {
TriggleState.FIRST -> add(likePositive)
TriggleState.FIRST -> add(likePositive)
TriggleState.SECOND -> add(likeNegative)
else -> {
else -> {
}
}
when (newState1 ?: state.agreeTrigleState) {
TriggleState.FIRST -> add(agreePositive)
TriggleState.FIRST -> add(agreePositive)
TriggleState.SECOND -> add(agreeNegative)
else -> {
else -> {
}
}
}
@ -114,9 +113,9 @@ class QuickReactionViewModel(initialState: QuickReactionState) : VectorViewModel
return when (reaction) {
agreePositive -> agreeNegative
agreeNegative -> agreePositive
likePositive -> likeNegative
likeNegative -> likePositive
else -> null
likePositive -> likeNegative
likeNegative -> likePositive
else -> null
}
}
@ -124,7 +123,7 @@ class QuickReactionViewModel(initialState: QuickReactionState) : VectorViewModel
// Args are accessible from the context.
// val foo = vieWModelContext.args<MyArgs>.foo
val currentSession = viewModelContext.activity.get<Session>()
val parcel = viewModelContext.args as MessageActionsBottomSheet.ParcelableArgs
val parcel = viewModelContext.args as TimelineEventFragmentArgs
val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId)
?: return null
var agreeTriggle: TriggleState = TriggleState.NONE

View File

@ -0,0 +1,46 @@
package im.vector.riotredesign.features.home.room.detail.timeline.action
import android.graphics.Typeface
import android.widget.TextView
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.airbnb.epoxy.EpoxyModelWithHolder
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyHolder
/**
* Item displaying an emoji reaction (single line with emoji, author, time)
*/
@EpoxyModelClass(layout = R.layout.item_simple_reaction_info)
abstract class ReactionInfoSimpleItem : EpoxyModelWithHolder<ReactionInfoSimpleItem.Holder>() {
@EpoxyAttribute
lateinit var reactionKey: CharSequence
@EpoxyAttribute
lateinit var authorDisplayName: CharSequence
@EpoxyAttribute
var timeStamp: CharSequence? = null
@EpoxyAttribute
var emojiTypeFace: Typeface? = null
override fun bind(holder: Holder) {
holder.emojiReactionView.text = reactionKey
holder.emojiReactionView.typeface = emojiTypeFace ?: Typeface.DEFAULT
holder.displayNameView.text = authorDisplayName
timeStamp?.let {
holder.timeStampView.text = it
holder.timeStampView.isVisible = true
} ?: run {
holder.timeStampView.isVisible = false
}
}
class Holder : VectorEpoxyHolder() {
val emojiReactionView by bind<TextView>(R.id.itemSimpleReactionInfoKey)
val displayNameView by bind<TextView>(R.id.itemSimpleReactionInfoMemberName)
val timeStampView by bind<TextView>(R.id.itemSimpleReactionInfoTime)
}
}

View File

@ -0,0 +1,12 @@
package im.vector.riotredesign.features.home.room.detail.timeline.action
import android.os.Parcelable
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import kotlinx.android.parcel.Parcelize
@Parcelize
data class TimelineEventFragmentArgs(
val eventId: String,
val roomId: String,
val informationData: MessageInformationData
) : Parcelable

View File

@ -0,0 +1,74 @@
package im.vector.riotredesign.features.home.room.detail.timeline.action
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.core.view.isVisible
import androidx.recyclerview.widget.DividerItemDecoration
import butterknife.BindView
import butterknife.ButterKnife
import com.airbnb.epoxy.EpoxyRecyclerView
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.riotredesign.EmojiCompatFontProvider
import im.vector.riotredesign.R
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import kotlinx.android.synthetic.main.bottom_sheet_display_reactions.*
import org.koin.android.ext.android.inject
/**
* Bottom sheet displaying list of reactions for a given event ordered by timestamp
*/
class ViewReactionBottomSheet : BaseMvRxBottomSheetDialog() {
private val viewModel: ViewReactionViewModel by fragmentViewModel(ViewReactionViewModel::class)
private val emojiCompatFontProvider by inject<EmojiCompatFontProvider>()
@BindView(R.id.bottom_sheet_display_reactions_list)
lateinit var epoxyRecyclerView: EpoxyRecyclerView
private val epoxyController by lazy { ViewReactionsEpoxyController(emojiCompatFontProvider.typeface) }
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
val view = inflater.inflate(R.layout.bottom_sheet_display_reactions, container, false)
ButterKnife.bind(this, view)
return view
}
override fun onActivityCreated(savedInstanceState: Bundle?) {
super.onActivityCreated(savedInstanceState)
epoxyRecyclerView.setController(epoxyController)
val dividerItemDecoration = DividerItemDecoration(epoxyRecyclerView.context,
LinearLayout.VERTICAL)
epoxyRecyclerView.addItemDecoration(dividerItemDecoration)
}
override fun invalidate() = withState(viewModel) {
if (it.mapReactionKeyToMemberList() == null) {
bottomSheetViewReactionSpinner.isVisible = true
bottomSheetViewReactionSpinner.animate()
} else {
bottomSheetViewReactionSpinner.isVisible = false
}
epoxyController.setData(it)
}
companion object {
fun newInstance(roomId: String, informationData: MessageInformationData): ViewReactionBottomSheet {
val args = Bundle()
val parcelableArgs = TimelineEventFragmentArgs(
informationData.eventId,
roomId,
informationData
)
args.putParcelable(MvRx.KEY_ARG, parcelableArgs)
return ViewReactionBottomSheet().apply { arguments = args }
}
}
}

View File

@ -0,0 +1,101 @@
package im.vector.riotredesign.features.home.room.detail.timeline.action
import androidx.fragment.app.Fragment
import androidx.lifecycle.Observer
import com.airbnb.mvrx.*
import im.vector.matrix.android.api.session.Session
import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.core.platform.VectorViewModel
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
import org.koin.android.ext.android.get
data class DisplayReactionsViewState(
val eventId: String = "",
val roomId: String = "",
val mapReactionKeyToMemberList: Async<List<ReactionInfo>> = Uninitialized)
: MvRxState
data class ReactionInfo(
val eventId: String,
val reactionKey: String,
val authorId: String,
val authorName: String? = null,
val timestamp: String? = null
)
/**
* Used to display the list of members that reacted to a given event
*/
class ViewReactionViewModel(private val session: Session,
private val timelineDateFormatter: TimelineDateFormatter,
initialState: DisplayReactionsViewState) : VectorViewModel<DisplayReactionsViewState>(initialState) {
init {
loadReaction()
}
fun loadReaction() = withState { state ->
try {
val room = session.getRoom(state.roomId)
val event = room?.getTimeLineEvent(state.eventId)
if (event == null) {
setState { copy(mapReactionKeyToMemberList = Fail(Throwable())) }
return@withState
}
var results = ArrayList<ReactionInfo>()
event.annotations?.reactionsSummary?.forEach { sum ->
sum.sourceEvents.mapNotNull { room.getTimeLineEvent(it) }.forEach {
val localDate = it.root.localDateTime()
results.add(ReactionInfo(it.root.eventId!!, sum.key, it.root.sender
?: "", it.senderName, timelineDateFormatter.formatMessageHour(localDate)))
}
}
setState {
copy(
mapReactionKeyToMemberList = Success(results.sortedBy { it.timestamp })
)
}
} catch (t: Throwable) {
setState {
copy(
mapReactionKeyToMemberList = Fail(t)
)
}
}
}
companion object : MvRxViewModelFactory<ViewReactionViewModel, DisplayReactionsViewState> {
override fun initialState(viewModelContext: ViewModelContext): DisplayReactionsViewState? {
val roomId = (viewModelContext.args as? TimelineEventFragmentArgs)?.roomId
?: return null
val info = (viewModelContext.args as? TimelineEventFragmentArgs)?.informationData
?: return null
return DisplayReactionsViewState(info.eventId, roomId)
}
override fun create(viewModelContext: ViewModelContext, state: DisplayReactionsViewState): ViewReactionViewModel? {
val session = viewModelContext.activity.get<Session>()
val eventId = (viewModelContext.args as TimelineEventFragmentArgs).eventId
val lifecycleOwner = (viewModelContext as FragmentViewModelContext).fragment<Fragment>()
val liveSummary = session.getRoom(state.roomId)?.getEventSummaryLive(eventId)
val viewReactionViewModel = ViewReactionViewModel(session, viewModelContext.activity.get(), state)
// This states observes the live summary
// When fragment context will be destroyed the observer will automatically removed
liveSummary?.observe(lifecycleOwner, Observer {
it?.firstOrNull()?.let {
viewReactionViewModel.loadReaction()
}
})
return viewReactionViewModel
}
}
}

View File

@ -0,0 +1,23 @@
package im.vector.riotredesign.features.home.room.detail.timeline.action
import android.graphics.Typeface
import com.airbnb.epoxy.TypedEpoxyController
/**
* Epoxy controller for reaction event list
*/
class ViewReactionsEpoxyController(private val emojiCompatTypeface: Typeface?) : TypedEpoxyController<DisplayReactionsViewState>() {
override fun buildModels(state: DisplayReactionsViewState) {
val map = state.mapReactionKeyToMemberList() ?: return
map.forEach {
reactionInfoSimpleItem {
id(it.eventId)
emojiTypeFace(emojiCompatTypeface)
timeStamp(it.timestamp)
reactionKey(it.reactionKey)
authorDisplayName(it.authorName ?: it.authorId)
}
}
}
}

View File

@ -23,7 +23,6 @@ import android.text.style.ClickableSpan
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.view.View
import androidx.annotation.ColorRes
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
@ -33,6 +32,7 @@ import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
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.EmojiCompatFontProvider
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
import im.vector.riotredesign.core.extensions.localDateTime
@ -40,7 +40,6 @@ import im.vector.riotredesign.core.linkify.VectorLinkify
import im.vector.riotredesign.core.resources.ColorProvider
import im.vector.riotredesign.core.resources.StringProvider
import im.vector.riotredesign.core.utils.DebouncedClickListener
import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.getColorFromUserId
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
@ -55,7 +54,8 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val timelineDateFormatter: TimelineDateFormatter,
private val htmlRenderer: EventHtmlRenderer,
private val stringProvider: StringProvider) {
private val stringProvider: StringProvider,
private val emojiCompatFontProvider: EmojiCompatFontProvider) {
fun create(event: TimelineEvent,
nextEvent: TimelineEvent?,
@ -115,24 +115,24 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
// val all = event.root.toContent()
// val ev = all.toModel<Event>()
return when (messageContent) {
is MessageEmoteContent -> buildEmoteMessageItem(messageContent,
is MessageEmoteContent -> buildEmoteMessageItem(messageContent,
informationData,
hasBeenEdited,
event.annotations?.editSummary,
callback)
is MessageTextContent -> buildTextMessageItem(event.sendState,
is MessageTextContent -> buildTextMessageItem(event.sendState,
messageContent,
informationData,
hasBeenEdited,
event.annotations?.editSummary,
callback
)
is MessageImageContent -> buildImageMessageItem(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 MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, callback)
is MessageFileContent -> buildFileMessageItem(messageContent, informationData, callback)
is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, callback)
else -> buildNotHandledMessageItem(messageContent)
}
}
@ -141,23 +141,17 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
callback: TimelineEventController.Callback?): MessageFileItem? {
return MessageFileItem_()
.informationData(informationData)
.avatarCallback(callback)
.filename(messageContent.body)
.iconRes(R.drawable.filetype_audio)
.reactionPillCallback(callback)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
.emojiTypeFace(emojiCompatFontProvider.typeface)
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
DebouncedClickListener(View.OnClickListener { view: View ->
callback?.onEventCellClicked(informationData, messageContent, view)
}))
.clickListener(
DebouncedClickListener(View.OnClickListener { _ ->
DebouncedClickListener(View.OnClickListener {
callback?.onAudioMessageClicked(messageContent)
}))
.longClickListener { view ->
@ -171,17 +165,11 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
callback: TimelineEventController.Callback?): MessageFileItem? {
return MessageFileItem_()
.informationData(informationData)
.avatarCallback(callback)
.filename(messageContent.body)
.reactionPillCallback(callback)
.emojiTypeFace(emojiCompatFontProvider.typeface)
.iconRes(R.drawable.filetype_attachment)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
@ -219,16 +207,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return MessageImageVideoItem_()
.playable(messageContent.info?.mimeType == "image/gif")
.informationData(informationData)
.avatarCallback(callback)
.mediaData(data)
.reactionPillCallback(callback)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
.emojiTypeFace(emojiCompatFontProvider.typeface)
.clickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onImageMessageClicked(messageContent, data, view)
@ -266,16 +248,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return MessageImageVideoItem_()
.playable(true)
.informationData(informationData)
.avatarCallback(callback)
.mediaData(thumbnailData)
.reactionPillCallback(callback)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
.emojiTypeFace(emojiCompatFontProvider.typeface)
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
@ -310,15 +286,10 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
}
}
.informationData(informationData)
.avatarCallback(callback)
.reactionPillCallback(callback)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
.emojiTypeFace(emojiCompatFontProvider.typeface)
//click on the text
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
@ -378,11 +349,9 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return MessageTextItem_()
.message(message)
.informationData(informationData)
.avatarCallback(callback)
.reactionPillCallback(callback)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.emojiTypeFace(emojiCompatFontProvider.typeface)
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
@ -417,15 +386,9 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
}
}
.informationData(informationData)
.avatarCallback(callback)
.reactionPillCallback(callback)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
.emojiTypeFace(emojiCompatFontProvider.typeface)
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
@ -440,14 +403,7 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
callback: TimelineEventController.Callback?): RedactedMessageItem? {
return RedactedMessageItem_()
.informationData(informationData)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.memberClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onMemberNameClicked(informationData)
}))
.avatarCallback(callback)
}
private fun linkifyBody(body: CharSequence, callback: TimelineEventController.Callback?): CharSequence {

View File

@ -17,23 +17,32 @@
package im.vector.riotredesign.features.home.room.detail.timeline.factory
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.format.NoticeEventFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.helper.senderAvatar
import im.vector.riotredesign.features.home.room.detail.timeline.helper.senderName
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.NoticeItem_
class NoticeItemFactory(private val eventFormatter: NoticeEventFormatter) {
fun create(event: TimelineEvent): NoticeItem? {
fun create(event: TimelineEvent,
callback: TimelineEventController.Callback?): NoticeItem? {
val formattedText = eventFormatter.format(event) ?: return null
val senderName = event.senderName()
val senderAvatar = event.senderAvatar()
val informationData = MessageInformationData(
eventId = event.root.eventId ?: "?",
senderId = event.root.sender ?: "",
sendState = event.sendState,
avatarUrl = event.senderAvatar(),
memberName = event.senderName(),
showInformation = false
)
return NoticeItem_()
.noticeText(formattedText)
.avatarUrl(senderAvatar)
.memberName(senderName)
.informationData(informationData)
.baseCallback(callback)
}

View File

@ -17,10 +17,16 @@
package im.vector.riotredesign.features.home.room.detail.timeline.factory
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.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageDefaultContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.core.epoxy.EmptyItem_
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDisplayableEvents
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageTextItem_
import timber.log.Timber
class TimelineItemFactory(private val messageItemFactory: MessageItemFactory,
@ -43,7 +49,7 @@ class TimelineItemFactory(private val messageItemFactory: MessageItemFactory,
EventType.STATE_HISTORY_VISIBILITY,
EventType.CALL_INVITE,
EventType.CALL_HANGUP,
EventType.CALL_ANSWER -> noticeItemFactory.create(event)
EventType.CALL_ANSWER -> noticeItemFactory.create(event, callback)
// Unhandled event types (yet)
EventType.ENCRYPTED,
@ -51,9 +57,32 @@ class TimelineItemFactory(private val messageItemFactory: MessageItemFactory,
EventType.STATE_ROOM_THIRD_PARTY_INVITE,
EventType.STICKER,
EventType.STATE_ROOM_CREATE -> defaultItemFactory.create(event)
else -> {
Timber.w("Ignored event (type: ${event.root.type}")
null
//These are just for debug to display hidden event, they should be filtered out in normal mode
if (TimelineDisplayableEvents.DEBUG_HIDDEN_EVENT) {
val informationData = MessageInformationData(eventId = event.root.eventId
?: "?",
senderId = event.root.sender ?: "",
sendState = event.sendState,
time = "",
avatarUrl = null,
memberName = "",
showInformation = false
)
val messageContent = event.root.content.toModel<MessageContent>()
?: MessageDefaultContent("", "", null, null)
MessageTextItem_()
.informationData(informationData)
.message("{ \"type\": ${event.root.type} }")
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
}
} else {
Timber.w("Ignored event (type: ${event.root.type}")
null
}
}
}
} catch (e: Exception) {

View File

@ -22,10 +22,14 @@ 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.MessageContent
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.BuildConfig
import im.vector.riotredesign.core.extensions.localDateTime
object TimelineDisplayableEvents {
//Debug helper, to show invisible items in time line (reaction, redacts)
val DEBUG_HIDDEN_EVENT = BuildConfig.SHOW_HIDDEN_TIMELINE_EVENTS
val DISPLAYABLE_TYPES = listOf(
EventType.MESSAGE,
EventType.STATE_ROOM_NAME,
@ -41,6 +45,11 @@ object TimelineDisplayableEvents {
EventType.STICKER,
EventType.STATE_ROOM_CREATE
)
val DEBUG_DISPLAYABLE_TYPES = DISPLAYABLE_TYPES + listOf(
EventType.REDACTION,
EventType.REACTION
)
}
fun TimelineEvent.isDisplayable(): Boolean {

View File

@ -16,6 +16,7 @@
package im.vector.riotredesign.features.home.room.detail.timeline.item
import android.graphics.Typeface
import android.os.Build
import android.view.View
import android.view.ViewGroup
@ -28,6 +29,7 @@ import androidx.core.view.isGone
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import im.vector.riotredesign.R
import im.vector.riotredesign.core.utils.DebouncedClickListener
import im.vector.riotredesign.core.utils.DimensionUtils.dpToPx
import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
@ -44,15 +46,26 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
@EpoxyAttribute
var cellClickListener: View.OnClickListener? = null
@EpoxyAttribute
var avatarClickListener: View.OnClickListener? = null
@EpoxyAttribute
var memberClickListener: View.OnClickListener? = null
@EpoxyAttribute
var emojiTypeFace: Typeface? = null
@EpoxyAttribute
var reactionPillCallback: TimelineEventController.ReactionPillCallback? = null
@EpoxyAttribute
var avatarCallback: TimelineEventController.AvatarCallback?= null
private val _avatarClickListener = DebouncedClickListener(View.OnClickListener {
avatarCallback?.onAvatarClicked(informationData)
})
private val _memberNameClickListener = DebouncedClickListener(View.OnClickListener {
avatarCallback?.onMemberNameClicked(informationData)
})
var reactionClickListener: ReactionButton.ReactedListener = object : ReactionButton.ReactedListener {
override fun onReacted(reactionButton: ReactionButton) {
reactionPillCallback?.onClickOnReactionPill(informationData, reactionButton.reactionString, true)
@ -61,6 +74,10 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
override fun onUnReacted(reactionButton: ReactionButton) {
reactionPillCallback?.onClickOnReactionPill(informationData, reactionButton.reactionString, false)
}
override fun onLongClick(reactionButton: ReactionButton) {
reactionPillCallback?.onLongClickOnReactionPill(informationData, reactionButton.reactionString)
}
}
override fun bind(holder: H) {
@ -73,9 +90,9 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
width = size
}
holder.avatarImageView.visibility = View.VISIBLE
holder.avatarImageView.setOnClickListener(avatarClickListener)
holder.avatarImageView.setOnClickListener(_avatarClickListener)
holder.memberNameView.visibility = View.VISIBLE
holder.memberNameView.setOnClickListener(memberClickListener)
holder.memberNameView.setOnClickListener(_memberNameClickListener)
holder.timeView.visibility = View.VISIBLE
holder.timeView.text = informationData.time
holder.memberNameView.text = informationData.memberName
@ -108,7 +125,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
//clear all reaction buttons (but not the Flow helper!)
holder.reactionWrapper?.children?.forEach { (it as? ReactionButton)?.isGone = true }
val idToRefInFlow = ArrayList<Int>()
informationData.orderedReactionList?.chunked(7)?.firstOrNull()?.forEachIndexed { index, reaction ->
informationData.orderedReactionList?.chunked(8)?.firstOrNull()?.forEachIndexed { index, reaction ->
(holder.reactionWrapper?.children?.elementAtOrNull(index) as? ReactionButton)?.let { reactionButton ->
reactionButton.isVisible = true
reactionButton.reactedListener = reactionClickListener
@ -116,6 +133,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : BaseEventItem<H>() {
idToRefInFlow.add(reactionButton.id)
reactionButton.reactionString = reaction.key
reactionButton.reactionCount = reaction.count
reactionButton.emojiTypeFace = emojiTypeFace
reactionButton.setChecked(reaction.addedByMe)
reactionButton.isEnabled = reaction.synced
}

View File

@ -23,6 +23,7 @@ import androidx.core.widget.TextViewCompat
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
import im.vector.riotredesign.core.utils.containsOnlyEmojis
import im.vector.riotredesign.features.html.PillImageSpan
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
@ -51,12 +52,20 @@ abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {
override fun bind(holder: Holder) {
super.bind(holder)
holder.messageView.movementMethod = mvmtMethod
val msg = message ?: ""
if (msg.length <= 4 && containsOnlyEmojis(msg.toString())) {
holder.messageView.textSize = 44F
} else {
holder.messageView.textSize = 14F
}
val textFuture = PrecomputedTextCompat.getTextFuture(message ?: "",
TextViewCompat.getTextMetricsParams(holder.messageView),
null)
holder.messageView.setTextFuture(textFuture)
holder.messageView.renderSendState()
holder.messageView.setOnClickListener(cellClickListener)

View File

@ -23,27 +23,36 @@ import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotredesign.R
import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
@EpoxyModelClass(layout = R.layout.item_timeline_event_base_noinfo)
abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
@EpoxyAttribute
var noticeText: CharSequence? = null
@EpoxyAttribute
var avatarUrl: String? = null
@EpoxyAttribute
var userId: String = ""
@EpoxyAttribute
var memberName: CharSequence? = null
@EpoxyAttribute
var longClickListener: View.OnLongClickListener? = null
lateinit var informationData: MessageInformationData
@EpoxyAttribute
var baseCallback: TimelineEventController.BaseCallback? = null
private var longClickListener = View.OnLongClickListener {
baseCallback?.onEventLongClicked(informationData, null, it)
baseCallback != null
}
override fun bind(holder: Holder) {
super.bind(holder)
holder.noticeTextView.text = noticeText
AvatarRenderer.render(avatarUrl, userId, memberName?.toString(), holder.avatarImageView)
AvatarRenderer.render(
informationData.avatarUrl,
informationData.senderId,
informationData.memberName?.toString()
?: informationData.senderId,
holder.avatarImageView
)
holder.view.setOnLongClickListener(longClickListener)
}
@ -51,7 +60,6 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
class Holder : BaseHolder() {
override fun getStubId(): Int = STUB_ID
val avatarImageView by bind<ImageView>(R.id.itemNoticeAvatarView)
val noticeTextView by bind<TextView>(R.id.itemNoticeTextView)
}

View File

@ -72,7 +72,7 @@ class RoomListFragment : VectorBaseFragment(), RoomSummaryController.Callback, O
setupRecyclerView()
roomListViewModel.subscribe { renderState(it) }
roomListViewModel.openRoomLiveData.observeEvent(this) {
navigator.openRoom(it)
navigator.openRoom(it, requireActivity())
}
createChatFabMenu.listener = this
@ -116,7 +116,7 @@ class RoomListFragment : VectorBaseFragment(), RoomSummaryController.Callback, O
override fun openRoomDirectory() {
navigator.openRoomDirectory()
navigator.openRoomDirectory(requireActivity())
}
override fun createDirectChat() {

View File

@ -17,6 +17,7 @@
package im.vector.riotredesign.features.navigation
import android.app.Activity
import android.content.Context
import android.content.Intent
import androidx.fragment.app.Fragment
import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom
@ -27,32 +28,31 @@ import im.vector.riotredesign.features.roomdirectory.RoomDirectoryActivity
import im.vector.riotredesign.features.roomdirectory.roompreview.RoomPreviewActivity
import im.vector.riotredesign.features.settings.VectorSettingsActivity
class DefaultNavigator(private val fraqment: Fragment) : Navigator {
class DefaultNavigator : Navigator {
val activity: Activity = fraqment.requireActivity()
override fun openRoom(roomId: String) {
override fun openRoom(roomId: String, context: Context) {
val args = RoomDetailArgs(roomId)
val intent = RoomDetailActivity.newIntent(activity, args)
activity.startActivity(intent)
val intent = RoomDetailActivity.newIntent(context, args)
context.startActivity(intent)
}
override fun openRoomPreview(publicRoom: PublicRoom) {
val intent = RoomPreviewActivity.getIntent(activity, publicRoom)
activity.startActivity(intent)
override fun openRoomPreview(publicRoom: PublicRoom, context: Context) {
val intent = RoomPreviewActivity.getIntent(context, publicRoom)
context.startActivity(intent)
}
override fun openRoomDirectory() {
val intent = Intent(activity, RoomDirectoryActivity::class.java)
activity.startActivity(intent)
override fun openRoomDirectory(context: Context) {
val intent = Intent(context, RoomDirectoryActivity::class.java)
context.startActivity(intent)
}
override fun openSettings() {
val intent = VectorSettingsActivity.getIntent(activity, "TODO")
activity.startActivity(intent)
override fun openSettings(context: Context) {
val intent = VectorSettingsActivity.getIntent(context, "TODO")
context.startActivity(intent)
}
override fun openDebug() {
activity.startActivity(Intent(activity, DebugMenuActivity::class.java))
override fun openDebug(context: Context) {
context.startActivity(Intent(context, DebugMenuActivity::class.java))
}
}

View File

@ -16,18 +16,19 @@
package im.vector.riotredesign.features.navigation
import android.content.Context
import im.vector.matrix.android.api.session.room.model.roomdirectory.PublicRoom
interface Navigator {
fun openRoom(roomId: String)
fun openRoom(roomId: String, context: Context)
fun openRoomPreview(publicRoom: PublicRoom)
fun openRoomPreview(publicRoom: PublicRoom, context: Context)
fun openRoomDirectory()
fun openRoomDirectory(context: Context)
fun openSettings()
fun openSettings(context: Context)
fun openDebug()
fun openDebug(context: Context)
}

View File

@ -19,23 +19,20 @@ import android.app.Activity
import android.content.Context
import android.content.Intent
import android.graphics.Typeface
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.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 im.vector.riotredesign.EmojiCompatFontProvider
import im.vector.riotredesign.R
import im.vector.riotredesign.core.platform.VectorBaseActivity
import kotlinx.android.synthetic.main.activity_emoji_reaction_picker.*
import timber.log.Timber
import org.koin.android.ext.android.inject
/**
*
@ -44,20 +41,21 @@ import timber.log.Timber
* TODO: Finish Refactor to vector base activity
* TODO: Move font request to app
*/
class EmojiReactionPickerActivity : VectorBaseActivity() {
class EmojiReactionPickerActivity : VectorBaseActivity(), EmojiCompatFontProvider.FontProviderListener {
private lateinit var tabLayout: TabLayout
lateinit var viewModel: EmojiChooserViewModel
private var mHandler: Handler? = null
override fun getMenuRes(): Int = R.menu.menu_emoji_reaction_picker
override fun getLayoutRes(): Int = R.layout.activity_emoji_reaction_picker
override fun getTitleRes(): Int = R.string.title_activity_emoji_reaction_picker
val emojiCompatFontProvider by inject<EmojiCompatFontProvider>()
private var tabLayoutSelectionListener = object : TabLayout.BaseOnTabSelectedListener<TabLayout.Tab> {
override fun onTabReselected(p0: TabLayout.Tab) {
}
@ -71,19 +69,13 @@ class EmojiReactionPickerActivity : VectorBaseActivity() {
}
private fun getFontThreadHandler(): Handler {
if (mHandler == null) {
val handlerThread = HandlerThread("fonts")
handlerThread.start()
mHandler = Handler(handlerThread.looper)
}
return mHandler!!
}
override fun initUiAndData() {
configureToolbar(emojiPickerToolbar)
requestEmojivUnicode10CompatibleFont()
emojiCompatFontProvider.let {
EmojiDrawView.configureTextPaint(this, it.typeface)
it.addListener(this)
}
tabLayout = findViewById(R.id.tabs)
@ -124,27 +116,13 @@ class EmojiReactionPickerActivity : VectorBaseActivity() {
})
}
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
)
override fun compatibilityFontUpdate(typeface: Typeface?) {
EmojiDrawView.configureTextPaint(this, typeface)
}
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 onDestroy() {
emojiCompatFontProvider.removeListener(this)
super.onDestroy()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {

View File

@ -21,10 +21,10 @@ import android.animation.AnimatorSet
import android.animation.ObjectAnimator
import android.content.Context
import android.content.res.TypedArray
import android.graphics.Typeface
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.LayoutInflater
import android.view.MotionEvent
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.DecelerateInterpolator
@ -36,13 +36,15 @@ import androidx.annotation.ColorInt
import androidx.annotation.ColorRes
import androidx.core.content.ContextCompat
import im.vector.riotredesign.R
import im.vector.riotredesign.core.utils.TextUtils
/**
* An animated reaction button.
* Displays a String reaction (emoji), with a count, and that can be selected or not (toggle)
*/
class ReactionButton @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
defStyleAttr: Int = 0) : FrameLayout(context, attrs, defStyleAttr), View.OnClickListener {
defStyleAttr: Int = 0)
: FrameLayout(context, attrs, defStyleAttr), View.OnClickListener, View.OnLongClickListener {
companion object {
private val DECCELERATE_INTERPOLATOR = DecelerateInterpolator()
@ -56,6 +58,11 @@ class ReactionButton @JvmOverloads constructor(context: Context, attrs: Attribut
private var reactionSelector: View? = null
var emojiTypeFace: Typeface? = null
set(value) {
field = value
emojiView?.typeface = value ?: Typeface.DEFAULT
}
private var dotsView: DotsView
private var circleView: CircleView
@ -68,7 +75,7 @@ class ReactionButton @JvmOverloads constructor(context: Context, attrs: Attribut
var reactionCount = 11
set(value) {
field = value
countTextView?.text = value.toString()
countTextView?.text = TextUtils.formatCountToShortDecimal(value)
}
@ -95,7 +102,9 @@ class ReactionButton @JvmOverloads constructor(context: Context, attrs: Attribut
reactionSelector = findViewById(R.id.reactionSelector)
countTextView = findViewById(R.id.reactionCount)
countTextView?.text = reactionCount.toString()
countTextView?.text = TextUtils.formatCountToShortDecimal(reactionCount)
emojiView?.typeface = this.emojiTypeFace ?: Typeface.DEFAULT
val array = context.obtainStyledAttributes(attrs, R.styleable.ReactionButton, defStyleAttr, 0)
@ -128,6 +137,7 @@ class ReactionButton @JvmOverloads constructor(context: Context, attrs: Attribut
val status = array.getBoolean(R.styleable.ReactionButton_toggled, false)
setChecked(status)
setOnClickListener(this)
setOnLongClickListener(this)
array.recycle()
}
@ -234,40 +244,45 @@ class ReactionButton @JvmOverloads constructor(context: Context, attrs: Attribut
* @param event
* @return
*/
override fun onTouchEvent(event: MotionEvent): Boolean {
if (!isEnabled)
return true
// override fun onTouchEvent(event: MotionEvent): Boolean {
// if (!isEnabled)
// return true
//
// when (event.action) {
// MotionEvent.ACTION_DOWN ->
// /*
// Commented out this line and moved the animation effect to the action up event due to
// conflicts that were occurring when library is used in sliding type views.
//
// icon.animate().scaleX(0.7f).scaleY(0.7f).setDuration(150).setInterpolator(DECCELERATE_INTERPOLATOR);
// */
// isPressed = true
//
// MotionEvent.ACTION_MOVE -> {
// val x = event.x
// val y = event.y
// val isInside = x > 0 && x < width && y > 0 && y < height
// if (isPressed != isInside) {
// isPressed = isInside
// }
// }
//
// MotionEvent.ACTION_UP -> {
// emojiView!!.animate().scaleX(0.7f).scaleY(0.7f).setDuration(150).interpolator = DECCELERATE_INTERPOLATOR
// emojiView!!.animate().scaleX(1f).scaleY(1f).interpolator = DECCELERATE_INTERPOLATOR
// if (isPressed) {
// performClick()
// isPressed = false
// }
// }
// MotionEvent.ACTION_CANCEL -> isPressed = false
// }
// return true
// }
when (event.action) {
MotionEvent.ACTION_DOWN ->
/*
Commented out this line and moved the animation effect to the action up event due to
conflicts that were occurring when library is used in sliding type views.
icon.animate().scaleX(0.7f).scaleY(0.7f).setDuration(150).setInterpolator(DECCELERATE_INTERPOLATOR);
*/
isPressed = true
MotionEvent.ACTION_MOVE -> {
val x = event.x
val y = event.y
val isInside = x > 0 && x < width && y > 0 && y < height
if (isPressed != isInside) {
isPressed = isInside
}
}
MotionEvent.ACTION_UP -> {
emojiView!!.animate().scaleX(0.7f).scaleY(0.7f).setDuration(150).interpolator = DECCELERATE_INTERPOLATOR
emojiView!!.animate().scaleX(1f).scaleY(1f).interpolator = DECCELERATE_INTERPOLATOR
if (isPressed) {
performClick()
isPressed = false
}
}
MotionEvent.ACTION_CANCEL -> isPressed = false
}
return true
override fun onLongClick(v: View?): Boolean {
reactedListener?.onLongClick(this)
return reactedListener != null
}
/**
@ -327,5 +342,6 @@ class ReactionButton @JvmOverloads constructor(context: Context, attrs: Attribut
interface ReactedListener {
fun onReacted(reactionButton: ReactionButton)
fun onUnReacted(reactionButton: ReactionButton)
fun onLongClick(reactionButton: ReactionButton)
}
}

View File

@ -124,12 +124,12 @@ class PublicRoomsFragment : VectorBaseFragment(), PublicRoomsController.Callback
when (joinState) {
JoinState.JOINED -> {
navigator.openRoom(publicRoom.roomId)
navigator.openRoom(publicRoom.roomId, requireActivity())
}
JoinState.NOT_JOINED,
JoinState.JOINING_ERROR -> {
// ROOM PREVIEW
navigator.openRoomPreview(publicRoom)
navigator.openRoomPreview(publicRoom, requireActivity())
}
else -> {
Snackbar.make(publicRoomsCoordinator, getString(R.string.please_wait), Snackbar.LENGTH_SHORT)

View File

@ -108,7 +108,7 @@ class RoomPreviewNoPreviewFragment : VectorBaseFragment() {
// Quit this screen
requireActivity().finish()
// Open room
navigator.openRoom(roomPreviewData.roomId)
navigator.openRoom(roomPreviewData.roomId, requireActivity())
}
}
}

View File

@ -0,0 +1,38 @@
<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:fillType="evenOdd"
android:strokeColor="#9e9e9e"
android:strokeLineCap="round"/>
<path
android:pathData="m7,13s1.5,2 4,2 4,-2 4,-2"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9e9e9e"
android:strokeLineCap="round"/>
<path
android:pathData="m8,8h0.01"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9e9e9e"
android:strokeLineCap="round"/>
<path
android:pathData="m14,8h0.01"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9e9e9e"
android:strokeLineCap="round"/>
</vector>

View File

@ -2,7 +2,7 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<size android:width="40dp" android:height="22dp"/>
<!--<size android:width="40dp" android:height="22dp"/>-->
<solid android:color="?vctr_list_header_background_color" />

View File

@ -15,25 +15,23 @@
tools:layout="@layout/emoji_chooser_fragment" />
<com.google.android.material.appbar.AppBarLayout
style="@style/VectorAppBarLayoutStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:elevation="4dp">
<androidx.appcompat.widget.Toolbar
android:id="@+id/emojiPickerToolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize"
android:background="?attr/colorPrimary"
android:elevation="4dp"
android:minHeight="0dp"
android:theme="@style/ThemeOverlay.AppCompat.ActionBar"
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap|enterAlways" />
app:layout_scrollFlags="scroll|exitUntilCollapsed|snap|enterAlways"
tools:title="@string/reactions" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabs"
android:layout_width="match_parent"
android:layout_height="40dp"
android:background="?attr/colorPrimary"
android:elevation="4dp" />
android:layout_height="40dp" />
</com.google.android.material.appbar.AppBarLayout>

View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="400dp"
android:orientation="vertical">
<TextView
android:layout_width="match_parent"
android:layout_height="44dp"
android:gravity="center_vertical"
android:padding="8dp"
android:text="@string/reactions"
android:textColor="?android:textColorSecondary"
android:textSize="16sp" />
<ProgressBar
android:id="@+id/bottomSheetViewReactionSpinner"
style="?android:attr/progressBarStyleSmall"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:visibility="gone"
tools:visibility="visible" />
<com.airbnb.epoxy.EpoxyRecyclerView
android:id="@+id/bottom_sheet_display_reactions_list"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1"
android:fadeScrollbars="false"
android:orientation="vertical"
android:scrollbars="vertical"
tools:itemCount="15"
tools:listitem="@layout/item_simple_reaction_info">
</com.airbnb.epoxy.EpoxyRecyclerView>
</LinearLayout>

View File

@ -86,7 +86,8 @@
tools:text="Friday 8pm" />
</androidx.constraintlayout.widget.ConstraintLayout>
<LinearLayout
<View
android:id="@+id/quickReactTopDivider"
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="?attr/vctr_list_divider_color" />
@ -94,18 +95,22 @@
<FrameLayout
android:id="@+id/bottom_sheet_quick_reaction_container"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
android:layout_height="wrap_content"
tools:background="@android:color/holo_green_light"
tools:layout_height="180dp" />
<LinearLayout
<View
android:id="@+id/quickReactBottomDivider"
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" />
android:layout_height="wrap_content"
tools:background="@android:color/holo_blue_dark"
tools:layout_height="250dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="44dp"
android:gravity="center_vertical"
android:orientation="horizontal"
android:paddingStart="8dp"
android:paddingLeft="8dp"
android:paddingEnd="8dp">
<TextView
android:id="@+id/itemSimpleReactionInfoKey"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:gravity="center"
android:lines="1"
android:textColor="?android:textColorPrimary"
android:textSize="18sp"
tools:text="@sample/reactions.json/data/reaction" />
<TextView
android:id="@+id/itemSimpleReactionInfoMemberName"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
android:layout_weight="1"
android:ellipsize="end"
android:lines="1"
android:textColor="?android:textColorPrimary"
android:textSize="16sp"
tools:text="@sample/matrix.json/data/displayName" />
<TextView
android:id="@+id/itemSimpleReactionInfoTime"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:lines="1"
android:textColor="?android:textColorSecondary"
android:textSize="12sp"
tools:text="10:44" />
</LinearLayout>

View File

@ -4,6 +4,7 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:addStatesFromChildren="true"
android:background="?attr/selectableItemBackground"
android:paddingLeft="8dp"
android:paddingRight="8dp">
@ -31,9 +32,9 @@
<ViewStub
android:id="@+id/messageContentBlankStub"
style="@style/TimelineContentStubNoInfoLayoutParams"
android:layout="@layout/item_timeline_event_blank_stub"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout="@layout/item_timeline_event_blank_stub"
tools:ignore="MissingConstraints" />
<ViewStub

View File

@ -2,16 +2,19 @@
<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="44dp"
android:id="@+id/reactionSelector"
android:layout_width="wrap_content"
android:minWidth="44dp"
android:layout_height="26dp"
android:background="@drawable/rounded_rect_shape"
android:clipChildren="false">
<View
android:id="@+id/reactionSelector"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/rounded_rect_shape" />
<!--<View-->
<!--android:id="@+id/reactionSelector"-->
<!--android:layout_width="match_parent"-->
<!--android:layout_height="match_parent"-->
<!--android:background="@drawable/rounded_rect_shape" />-->
<im.vector.riotredesign.features.reactions.widget.DotsView
android:id="@+id/dots"
@ -42,17 +45,23 @@
android:gravity="center"
android:textColor="@color/black"
android:textSize="13sp"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintEnd_toStartOf="@id/reactionCount"
tools:text="👍" />
<TextView
android:id="@+id/reactionCount"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginEnd="6dp"
android:layout_marginRight="6dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintBaseline_toBaselineOf="@id/reactionText"
android:layout_marginStart="-4dp"
android:layout_marginLeft="-4dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
android:gravity="center"
android:maxLines="1"
android:textColor="?riotx_text_secondary"
@ -61,7 +70,8 @@
app:autoSizeMaxTextSize="14sp"
app:autoSizeMinTextSize="8sp"
app:autoSizeTextType="uniform"
app:layout_constraintStart_toEndOf="@id/reactionText"
app:layout_constraintEnd_toEndOf="parent"
tools:text="10" />
tools:text="13450" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -20,6 +20,8 @@
<string name="reactions_agree">Agree</string>
<string name="reactions_like">Like</string>
<string name="message_add_reaction">Add Reaction</string>
<string name="message_view_reaction">View Reactions</string>
<string name="reactions">Reactions</string>
<string name="event_redacted_by_user_reason">Event deleted by user</string>
<string name="event_redacted_by_admin_reason">Event moderated by room admin</string>

View File

@ -26,6 +26,10 @@
<item name="android:fontFamily">"sans-serif"</item>
</style>
<style name="VectorAppBarLayoutStyle" parent="Widget.Design.AppBarLayout">
<item name="android:background">?riotx_background</item>
</style>
<!-- Alert Dialog: Button color are not colorAccent by default -->
<style name="VectorAlertDialogStyleLight" parent="Theme.MaterialComponents.Light.Dialog.Alert">
<item name="buttonBarButtonStyle">@style/VectorAlertDialogButtonStyle</item>