Merge pull request #132 from vector-im/feature/reactions_chooser

Feature/reactions chooser
This commit is contained in:
Valere 2019-05-10 16:59:14 +02:00 committed by GitHub
commit 9a5f96f80b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
72 changed files with 2764 additions and 125 deletions

View File

@ -2,7 +2,7 @@ Changes in RiotX 0.XX (2019-XX-XX)
===================================================

Features:
-
- Contextual action menu for messages in room

Improvements:
-

View File

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

View File

@ -16,6 +16,7 @@

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


interface MessageContent {
val type: String
val body: String

View File

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

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


View File

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

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

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

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

View File

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


fun getTimeLineEvent(eventId: String): TimelineEvent?
}

View File

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

internal class SenderRoomMemberExtractor(private val roomId: String) {

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

View File

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

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

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

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

}

View File

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

internal class TimelineEventFactory(private val roomMemberExtractor: SenderRoomMemberExtractor) {

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

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

View File

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

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

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

View File

@ -124,6 +124,7 @@ dependencies {
def markwon_version = '3.0.0-SNAPSHOT'
def big_image_viewer_version = '1.5.6'
def glide_version = '4.9.0'
def moshi_version = '1.8.0'

implementation project(":matrix-sdk-android")
implementation project(":matrix-sdk-android-rx")
@ -138,6 +139,8 @@ dependencies {
implementation 'androidx.core:core-ktx:1.0.1'

implementation 'com.jakewharton.threetenabp:threetenabp:1.1.1'
implementation "com.squareup.moshi:moshi-adapters:$moshi_version"
kapt "com.squareup.moshi:moshi-kotlin-codegen:$moshi_version"

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

View File

@ -39,10 +39,24 @@
android:windowSoftInputMode="adjustResize" />
<activity android:name=".features.media.VideoMediaViewerActivity" />

<activity
android:name="im.vector.riotredesign.features.reactions.EmojiReactionPickerActivity"
android:label="@string/title_activity_emoji_reaction_picker" />

<service
android:name=".core.services.CallService"
android:exported="false" />

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

</application>

</manifest>

View File

@ -199,7 +199,7 @@ abstract class VectorBaseActivity : BaseMvRxActivity() {
* MENU MANAGEMENT
* ========================================================================================== */

final override fun onCreateOptionsMenu(menu: Menu): Boolean {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
val menuRes = getMenuRes()

if (menuRes != -1) {

View File

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

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

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

View File

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

import android.view.View
import java.util.*


/**
* Simple Debounced OnClickListener
* Safe to use in different views
*/
class DebouncedClickListener(val original: View.OnClickListener, private val minimumInterval: Long = 400) : View.OnClickListener {
private val lastClickMap = WeakHashMap<View, Long>()

override fun onClick(clickedView: View) {
val previousClickTimestamp = lastClickMap[clickedView]
val currentTimestamp = System.currentTimeMillis()

lastClickMap[clickedView] = currentTimestamp

if (previousClickTimestamp == null || currentTimestamp - previousClickTimestamp.toLong() > minimumInterval) {
original.onClick(clickedView)
}
}
}

View File

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

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

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


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

context.startActivity(sendIntent)

}
}

View File

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

/**

View File

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

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

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

View File

@ -20,16 +20,28 @@ import android.app.Activity.RESULT_OK
import android.content.Intent
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import android.text.Editable
import android.text.Spannable
import android.view.HapticFeedbackConstants
import android.view.LayoutInflater
import android.view.View
import android.widget.TextView
import android.widget.Toast
import androidx.appcompat.app.AlertDialog
import androidx.core.content.ContextCompat
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.fragmentViewModel
import com.github.piasy.biv.BigImageViewer
import com.github.piasy.biv.loader.ImageLoader
import com.google.android.material.snackbar.Snackbar
import com.jaiselrahman.filepicker.activity.FilePickerActivity
import com.jaiselrahman.filepicker.config.Configurations
import com.jaiselrahman.filepicker.model.MediaFile
@ -37,12 +49,10 @@ import com.otaliastudios.autocomplete.Autocomplete
import com.otaliastudios.autocomplete.AutocompleteCallback
import com.otaliastudios.autocomplete.CharPolicy
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.user.model.User
import im.vector.riotredesign.features.reactions.EmojiReactionPickerActivity
import im.vector.riotredesign.R
import im.vector.riotredesign.core.dialogs.DialogListItem
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
@ -62,7 +72,11 @@ import im.vector.riotredesign.features.home.room.detail.composer.TextComposerAct
import im.vector.riotredesign.features.home.room.detail.composer.TextComposerViewModel
import im.vector.riotredesign.features.home.room.detail.composer.TextComposerViewState
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.action.ActionsHandler
import im.vector.riotredesign.features.home.room.detail.timeline.action.MessageActionsBottomSheet
import im.vector.riotredesign.features.home.room.detail.timeline.action.MessageMenuViewModel
import im.vector.riotredesign.features.home.room.detail.timeline.helper.EndlessRecyclerViewScrollListener
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotredesign.features.html.PillImageSpan
import im.vector.riotredesign.features.media.ImageContentRenderer
import im.vector.riotredesign.features.media.ImageMediaViewerActivity
@ -75,6 +89,7 @@ import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope
import org.koin.core.parameter.parametersOf
import timber.log.Timber
import java.io.File


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

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

companion object {

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

override fun getLayoutResId() = R.layout.fragment_room_detail

private lateinit var actionViewModel: ActionsHandler

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

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

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


override fun onResume() {
super.onResume()
roomDetailViewModel.process(RoomDetailActions.IsDisplayed)
@ -401,9 +425,93 @@ class RoomDetailFragment : VectorBaseFragment(), TimelineEventController.Callbac
vectorBaseActivity.notImplemented()
}

// AutocompleteUserPresenter.Callback
override fun onEventCellClicked(informationData: MessageInformationData, messageContent: MessageContent, view: View) {

}

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

override fun onAvatarClicked(informationData: MessageInformationData) {
vectorBaseActivity.notImplemented()
}
// AutocompleteUserPresenter.Callback

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

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

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

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

override fun onFail(error: Exception?) {}

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

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

override fun onProgress(progress: Int) {}

override fun onStart() {}

}

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

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

View File

@ -26,27 +26,17 @@ import com.airbnb.epoxy.EpoxyController
import com.airbnb.epoxy.EpoxyModel
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.RoomMember
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.core.epoxy.LoadingItemModel_
import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.features.home.room.detail.timeline.factory.TimelineItemFactory
import im.vector.riotredesign.features.home.room.detail.timeline.helper.RoomMemberEventHelper
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineAsyncHelper
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineEventDiffUtilCallback
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.detail.timeline.helper.canBeMerged
import im.vector.riotredesign.features.home.room.detail.timeline.helper.nextDisplayableEvent
import im.vector.riotredesign.features.home.room.detail.timeline.helper.prevSameTypeEvents
import im.vector.riotredesign.features.home.room.detail.timeline.helper.*
import im.vector.riotredesign.features.home.room.detail.timeline.item.DaySeparatorItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.DaySeparatorItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MergedHeaderItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotredesign.features.media.ImageContentRenderer
import im.vector.riotredesign.features.media.VideoContentRenderer
import org.threeten.bp.LocalDateTime
@ -64,6 +54,9 @@ class TimelineEventController(private val dateFormatter: TimelineDateFormatter,
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)
}

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

View File

@ -0,0 +1,38 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.action

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

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

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

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

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

}

View File

@ -0,0 +1,45 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.action

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

/**
* Add MvRx capabilities to bottomsheetdialog (like BaseMvRxFragment)
*/
abstract class BaseMvRxBottomSheetDialog() : BottomSheetDialogFragment(), MvRxView {
override val mvrxViewModelStore by lazy { MvRxViewModelStore(viewModelStore) }

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

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

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

View File

@ -0,0 +1,158 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.action

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

/**
* Bottom sheet fragment that shows a message preview with list of contextual actions
* (Includes fragments for quick reactions and list of actions)
*/
class MessageActionsBottomSheet : BaseMvRxBottomSheetDialog() {

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

private lateinit var actionHandlerModel: ActionsHandler

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

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

@BindView(R.id.bottom_sheet_message_preview_timestamp)
lateinit var messageTimestampText: TextView

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


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

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

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


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

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

override fun invalidate() = withState(viewModel) {
senderNameTextView.text = it.senderName
messageBodyTextView.text = it.messageBody
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)
} else {
senderAvatarImageView.setImageDrawable(AvatarRenderer.getPlaceholderDrawable(requireContext(), it.userId, it.senderName))
}
return@withState
}


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

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

}
}
}

View File

@ -0,0 +1,70 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.action

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


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

/**
* Information related to an event and used to display preview in contextual bottomsheet.
*/
class MessageActionsViewModel(initialState: MessageActionState) : VectorViewModel<MessageActionState>(initialState) {

companion object : MvRxViewModelFactory<MessageActionsViewModel, MessageActionState> {

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

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

val event = currentSession.getRoom(parcel.roomId)?.getTimeLineEvent(parcel.eventId)
return if (event != null) {
val messageContent: MessageContent? = event.root.content.toModel()
val originTs = event.root.originServerTs
MessageActionState(
event.root.sender ?: "",
parcel.informationData.memberName.toString(),
messageContent?.body ?: "",
dateFormat.format(Date(originTs ?: 0)),
currentSession.contentUrlResolver().resolveFullSize(parcel.informationData.avatarUrl)
)
} else {
//can this happen?
Timber.e("Failed to retrieve event")
null
}
}
}
}

View File

@ -0,0 +1,112 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.action

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import com.airbnb.mvrx.BaseMvRxFragment
import com.airbnb.mvrx.MvRx
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
import im.vector.riotredesign.R
import im.vector.riotredesign.features.themes.ThemeUtils

/**
* Fragment showing the list of available contextual action for a given message.
*/
class MessageMenuFragment : BaseMvRxFragment() {

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

private var addSeparators = false

var interactionListener: InteractionListener? = null

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

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

}


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

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

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

}

interface InteractionListener {
fun didSelectMenuAction(simpleAction: SimpleAction)
}


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

View File

@ -0,0 +1,190 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.action

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

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

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

/**
* Manages list actions for a given message (copy / paste / forward...)
*/
class MessageMenuViewModel(initialState: MessageMenuState) : VectorViewModel<MessageMenuState>(initialState) {

companion object : MvRxViewModelFactory<MessageMenuViewModel, MessageMenuState> {

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

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

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


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

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

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

//TODO is uploading
//TODO is downloading

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

//TODO Can be redacted

//TODO sent by me or sufficient power level
}


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

if (currentSession.sessionParams.credentials.userId != event.root.sender) {
//not sent by me
this.add(SimpleAction(ACTION_FLAG, R.string.report_content, R.drawable.ic_flag, parcel.eventId))
}
}

return MessageMenuState(actions)
}

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

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

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


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

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


}
}

View File

@ -0,0 +1,134 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.action

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

/**
* Quick Reaction Fragment (agree / like reactions)
*/
class QuickReactionFragment : BaseMvRxFragment() {

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

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

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

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

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

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

var interactionListener: InteractionListener? = null

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

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

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

//configure click listeners
quickReact1Text.setOnClickListener {
viewModel.toggleAgree(true)
}
quickReact2Text.setOnClickListener {
viewModel.toggleAgree(false)
}
quickReact3Text.setOnClickListener {
viewModel.toggleLike(true)
}
quickReact4Text.setOnClickListener {
viewModel.toggleLike(false)
}

}

override fun invalidate() = withState(viewModel) {

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

}
TriggleState.SECOND -> {
quickReact1Text.alpha = 0.2f
quickReact2Text.alpha = 1f
}
}
when (it.likeTriggleState) {
TriggleState.NONE -> {
quickReact3Text.alpha = 1f
quickReact4Text.alpha = 1f
}
TriggleState.FIRST -> {
quickReact3Text.alpha = 1f
quickReact4Text.alpha = 0.2f

}
TriggleState.SECOND -> {
quickReact3Text.alpha = 0.2f
quickReact4Text.alpha = 1f
}
}

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

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

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

View File

@ -0,0 +1,108 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.home.room.detail.timeline.action

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

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

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

/**
* Quick reaction view model
* TODO: configure initial state from event
*/
class QuickReactionViewModel(initialState: QuickReactionState) : VectorViewModel<QuickReactionState>(initialState) {

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


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

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

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


companion object : MvRxViewModelFactory<QuickReactionViewModel, QuickReactionState> {

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

View File

@ -16,39 +16,26 @@

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

import android.text.Spannable
import android.text.SpannableStringBuilder
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
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageEmoteContent
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageNoticeContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.model.message.*
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R
import im.vector.riotredesign.core.epoxy.VectorEpoxyModel
import im.vector.riotredesign.core.extensions.localDateTime
import im.vector.riotredesign.core.linkify.VectorLinkify
import im.vector.riotredesign.core.resources.ColorProvider
import im.vector.riotredesign.core.utils.DebouncedClickListener
import im.vector.riotredesign.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineDateFormatter
import im.vector.riotredesign.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.riotredesign.features.home.room.detail.timeline.item.DefaultItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.DefaultItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageFileItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageFileItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageImageVideoItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageImageVideoItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageInformationData
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageTextItem
import im.vector.riotredesign.features.home.room.detail.timeline.item.MessageTextItem_
import im.vector.riotredesign.features.home.room.detail.timeline.item.*
import im.vector.riotredesign.features.html.EventHtmlRenderer
import im.vector.riotredesign.features.media.ImageContentRenderer
import im.vector.riotredesign.features.media.VideoContentRenderer
@ -70,13 +57,13 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
val nextDate = nextEvent?.root?.localDateTime()
val addDaySeparator = date.toLocalDate() != nextDate?.toLocalDate()
val isNextMessageReceivedMoreThanOneHourAgo = nextDate?.isBefore(date.minusMinutes(60))
?: false
?: false

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

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

// val all = event.root.toContent()
// val ev = all.toModel<Event>()
return when (messageContent) {
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageTextContent -> buildTextMessageItem(messageContent, informationData, callback)
is MessageImageContent -> buildImageMessageItem(messageContent, informationData, callback)
is MessageEmoteContent -> buildEmoteMessageItem(messageContent, informationData, callback)
is MessageTextContent -> buildTextMessageItem(event.sendState, 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)
}
}

private fun buildAudioMessageItem(messageContent: MessageAudioContent,
informationData: MessageInformationData,
private fun buildAudioMessageItem(messageContent: MessageAudioContent, informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageFileItem? {
return MessageFileItem_()
.informationData(informationData)
.filename(messageContent.body)
.iconRes(R.drawable.filetype_audio)
.clickListener { _ -> callback?.onAudioMessageClicked(messageContent) }
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
}))
.clickListener(
DebouncedClickListener(View.OnClickListener { _ ->
callback?.onAudioMessageClicked(messageContent)
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
}
}

private fun buildFileMessageItem(messageContent: MessageFileContent,
informationData: MessageInformationData,
private fun buildFileMessageItem(messageContent: MessageFileContent, informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageFileItem? {
return MessageFileItem_()
.informationData(informationData)
.filename(messageContent.body)
.iconRes(R.drawable.filetype_attachment)
.clickListener { _ -> callback?.onFileMessageClicked(messageContent) }
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
}
.clickListener(
DebouncedClickListener(View.OnClickListener { _ ->
callback?.onFileMessageClicked(messageContent)
}))
}

private fun buildNotHandledMessageItem(messageContent: MessageContent): DefaultItem? {
@ -130,8 +147,7 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return DefaultItem_().text(text)
}

private fun buildImageMessageItem(messageContent: MessageImageContent,
informationData: MessageInformationData,
private fun buildImageMessageItem(messageContent: MessageImageContent, informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageImageVideoItem? {

val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize()
@ -149,11 +165,26 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
.playable(messageContent.info?.mimeType == "image/gif")
.informationData(informationData)
.mediaData(data)
.clickListener { view -> callback?.onImageMessageClicked(messageContent, data, view) }
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.clickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onImageMessageClicked(messageContent, data, view)
}))
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
}))

.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
}
}

private fun buildVideoMessageItem(messageContent: MessageVideoContent,
informationData: MessageInformationData,
private fun buildVideoMessageItem(messageContent: MessageVideoContent, informationData: MessageInformationData,
callback: TimelineEventController.Callback?): MessageImageVideoItem? {

val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize()
@ -176,10 +207,22 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
.playable(true)
.informationData(informationData)
.mediaData(thumbnailData)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
}))
.clickListener { view -> callback?.onVideoMessageClicked(messageContent, videoData, view) }
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
}
}

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

@ -191,10 +234,26 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return MessageTextItem_()
.message(linkifiedBody)
.informationData(informationData)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
//click on the text
.clickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
}))
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
}
}

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

val message = messageContent.body.let {
@ -208,10 +267,21 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return MessageTextItem_()
.message(message)
.informationData(informationData)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
}
}

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

val message = messageContent.body.let {
@ -221,9 +291,21 @@ class MessageItemFactory(private val colorProvider: ColorProvider,
return MessageTextItem_()
.message(message)
.informationData(informationData)
.avatarClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onAvatarClicked(informationData)
}))
.cellClickListener(
DebouncedClickListener(View.OnClickListener { view ->
callback?.onEventCellClicked(informationData, messageContent, view)
}))
.longClickListener { view ->
return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view)
?: false
}
}

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

View File

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

abstract val informationData: MessageInformationData

@EpoxyAttribute
var longClickListener: View.OnLongClickListener? = null

@EpoxyAttribute
var cellClickListener: View.OnClickListener? = null

@EpoxyAttribute
var avatarClickListener: View.OnClickListener? = null

override fun bind(holder: H) {
super.bind(holder)
if (informationData.showInformation) {
holder.avatarImageView.visibility = View.VISIBLE
holder.avatarImageView.setOnClickListener(avatarClickListener)
holder.memberNameView.visibility = View.VISIBLE
holder.timeView.visibility = View.VISIBLE
holder.timeView.text = informationData.time
@ -41,6 +53,9 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : VectorEpoxyModel<H>()
holder.memberNameView.visibility = View.GONE
holder.timeView.visibility = View.GONE
}
holder.view.setOnClickListener(cellClickListener)
holder.view.setOnLongClickListener(longClickListener)

}

protected fun View.renderSendState() {

View File

@ -39,6 +39,7 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
ImageContentRenderer.render(mediaData, ImageContentRenderer.Mode.THUMBNAIL, holder.imageView)
ContentUploadStateTrackerBinder.bind(informationData.eventId, mediaData, holder.progressLayout)
holder.imageView.setOnClickListener(clickListener)
holder.imageView.setOnLongClickListener(longClickListener)
holder.imageView.renderSendState()
holder.playContentView.visibility = if (playable) View.VISIBLE else View.GONE
}

View File

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

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

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

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

View File

@ -16,11 +16,12 @@

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

import android.text.Spannable
import android.view.View
import android.widget.ImageView
import android.widget.TextView
import androidx.appcompat.widget.AppCompatTextView
import androidx.core.text.PrecomputedTextCompat
import androidx.core.text.toSpannable
import androidx.core.widget.TextViewCompat
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
@ -35,24 +36,30 @@ import kotlinx.coroutines.withContext
@EpoxyModelClass(layout = R.layout.item_timeline_event_text_message)
abstract class MessageTextItem : AbsMessageItem<MessageTextItem.Holder>() {

@EpoxyAttribute var message: Spannable? = null
@EpoxyAttribute override lateinit var informationData: MessageInformationData
@EpoxyAttribute
var message: CharSequence? = null
@EpoxyAttribute
override lateinit var informationData: MessageInformationData
@EpoxyAttribute
var clickListener: View.OnClickListener? = null

override fun bind(holder: Holder) {
super.bind(holder)
MatrixLinkify.addLinkMovementMethod(holder.messageView)
val textFuture = PrecomputedTextCompat.getTextFuture(message ?: "",
TextViewCompat.getTextMetricsParams(holder.messageView),
null)
TextViewCompat.getTextMetricsParams(holder.messageView),
null)
holder.messageView.setTextFuture(textFuture)
holder.messageView.renderSendState()
holder.messageView.setOnClickListener (clickListener)
holder.messageView.setOnLongClickListener(longClickListener)
findPillsAndProcess { it.bind(holder.messageView) }
}

private fun findPillsAndProcess(processBlock: (span: PillImageSpan) -> Unit) {
GlobalScope.launch(Dispatchers.Main) {
val pillImageSpans: Array<PillImageSpan>? = withContext(Dispatchers.IO) {
message?.let { spannable ->
message?.toSpannable()?.let { spannable ->
spannable.getSpans(0, spannable.length, PillImageSpan::class.java)
}
}

View File

@ -16,6 +16,7 @@

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

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


@EpoxyAttribute
var longClickListener: View.OnLongClickListener? = null

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

class Holder : VectorEpoxyHolder() {

View File

@ -0,0 +1,61 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.reactions

import androidx.lifecycle.ViewModelProviders
import android.os.Bundle
import android.util.Log
import androidx.fragment.app.Fragment
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import im.vector.riotredesign.R


class EmojiChooserFragment : Fragment() {

companion object {
fun newInstance() = EmojiChooserFragment()
}

private lateinit var viewModel: EmojiChooserViewModel

override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?): View? {
return inflater.inflate(R.layout.emoji_chooser_fragment, container, false)
}

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

// val ds = EmojiDataSource(this.context!!)
}

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


}
}

View File

@ -0,0 +1,45 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.reactions

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

class EmojiChooserViewModel : ViewModel() {

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

val currentSection: MutableLiveData<Int> = MutableLiveData()

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

}
}

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

View File

@ -0,0 +1,96 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.reactions

import android.content.Context
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import com.squareup.moshi.Moshi
import im.vector.riotredesign.R
import java.io.InputStreamReader
import com.squareup.moshi.JsonAdapter



class EmojiDataSource(val context: Context) {

var rawData: EmojiData? = null

init {
context.resources.openRawResource(R.raw.emoji_picker_datasource).use { input ->
val moshi = Moshi.Builder().build()
val jsonAdapter = moshi.adapter(EmojiData::class.java)
val inputAsString = input.bufferedReader().use { it.readText() }
this.rawData = jsonAdapter.fromJson(inputAsString)
// this.rawData = mb.fr(InputStreamReader(it), EmojiData::class.java)
}
}
@JsonClass(generateAdapter = true)
data class EmojiData(val categories: List<EmojiCategory>,
val emojis: Map<String, EmojiItem>,
val aliases: Map<String, String>)

@JsonClass(generateAdapter = true)
data class EmojiCategory(val id: String, val name: String, val emojis: List<String>)

@JsonClass(generateAdapter = true)
data class EmojiItem(
@Json(name = "a") val name: String,
@Json(name = "b") val unicode: String,
@Json(name = "j") val keywords: List<String>?,
val k: List<String>?) {

var _emojiText: String? = null

fun emojiString() : String {
if (_emojiText == null) {
val utf8Text = unicode.split("-").joinToString("") { "\\u${it}" }//"\u0048\u0065\u006C\u006C\u006F World"
_emojiText = fromUnicode(utf8Text)
}
return _emojiText!!
}
}

companion object {
fun fromUnicode(unicode: String): String {
val str = unicode.replace("\\", "")
val arr = str.split("u".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()
val text = StringBuffer()
for (i in 1 until arr.size) {
val hexVal = Integer.parseInt(arr[i], 16)
text.append(Character.toChars(hexVal))
}
return text.toString()
}
}


// name: 'a',
// unified: 'b',
// non_qualified: 'c',
// has_img_apple: 'd',
// has_img_google: 'e',
// has_img_twitter: 'f',
// has_img_emojione: 'g',
// has_img_facebook: 'h',
// has_img_messenger: 'i',
// keywords: 'j',
// sheet: 'k',
// emoticons: 'l',
// text: 'm',
// short_names: 'n',
// added_in: 'o',
}

View File

@ -0,0 +1,60 @@
package im.vector.riotredesign.features.reactions

import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Typeface
import android.text.StaticLayout
import android.text.TextPaint
import android.util.AttributeSet
import android.view.View
import kotlin.math.abs


/**
* We want to use a custom view for rendering an emoji.
* With generic textview, the performance in the recycler view are very bad
*/
class EmojiDrawView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

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

var emoji: String? = null

override fun onDraw(canvas: Canvas?) {
EmojiRecyclerAdapter.beginTraceSession("EmojiDrawView.onDraw")
super.onDraw(canvas)
canvas?.save()
val space = abs((width - emojiSize) / 2f)
if (mLayout != null) {
canvas?.translate(space, space)
mLayout!!.draw(canvas)
}
canvas?.restore()
EmojiRecyclerAdapter.endTraceSession()
}

companion object {
val tPaint = TextPaint()

var emojiSize = 40

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

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

}

View File

@ -0,0 +1,181 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.reactions

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.R
import im.vector.riotredesign.core.platform.VectorBaseActivity
import timber.log.Timber

/**
*
* TODO: Loading indicator while getting emoji data source?
* TODO: migrate to maverick
* TODO: Finish Refactor to vector base activity
* TODO: Move font request to app
*/
class EmojiReactionPickerActivity : VectorBaseActivity() {

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

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

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

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

}

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

override fun initUiAndData() {

configureToolbar()

requestEmojivUnicode10CompatibleFont()

tabLayout = findViewById(R.id.tabs)

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

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

})

viewModel.currentSection.observe(this, Observer { section ->
section?.let {
tabLayout.removeOnTabSelectedListener(tabLayoutSelectionListener)
tabLayout.getTabAt(it)?.select()
tabLayout.addOnTabSelectedListener(tabLayoutSelectionListener)
}
})
}

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

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

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

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

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

override fun onCreateOptionsMenu(menu: Menu): Boolean {
val inflater: MenuInflater = menuInflater
inflater.inflate(getMenuRes(), menu)

val searchItem = menu.findItem(R.id.search)
(searchItem.actionView as? SearchView)?.let {

searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(p0: MenuItem?): Boolean {
it.isIconified = false
it.requestFocusFromTouch()
//we want to force the tool bar as visible even if hidden with scroll flags
findViewById<Toolbar>(R.id.toolbar)?.minimumHeight = getActionBarSize()
return true
}

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

return true
}

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

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

View File

@ -0,0 +1,347 @@
/*
* Copyright 2019 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package im.vector.riotredesign.features.reactions

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


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

var interactionListener: InteractionListener? = null
var mRecyclerView: RecyclerView? = null


var currentFirstVisibleSection = 0

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

private var scrollState = ScrollState.UNKNWON
private var isFastScroll = false

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


override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
super.onAttachedToRecyclerView(recyclerView)
this.mRecyclerView = recyclerView

val gridLayoutManager = GridLayoutManager(recyclerView.context, 8)
gridLayoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return if (isSection(position)) gridLayoutManager.spanCount else 1
}
}.apply {
isSpanIndexCacheEnabled = true
}
recyclerView.layoutManager = gridLayoutManager

recyclerView.itemAnimator = DefaultItemAnimator().apply {
supportsChangeAnimations = false
}

recyclerView.setHasFixedSize(true)
//Default is 5 but we have lots of views for emojis
recyclerView.recycledViewPool
.setMaxRecycledViews(R.layout.grid_item_emoji, 300)

recyclerView.addOnScrollListener(scrollListener)
}

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

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


override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
beginTraceSession("MyAdapter.onCreateViewHolder")
val inflater = LayoutInflater.from(parent.context)
val itemView = inflater.inflate(viewType, parent, false)
val viewHolder = when (viewType) {
R.layout.grid_section_header -> SectionViewHolder(itemView)
else -> EmojiViewHolder(itemView)
}
endTraceSession()
return viewHolder

}

override fun getItemViewType(position: Int): Int {
beginTraceSession("MyAdapter.getItemViewType")
if (isSection(position)) {
return R.layout.grid_section_header
}
endTraceSession()
return R.layout.grid_item_emoji
}

private fun isSection(position: Int): Boolean {
dataSource?.rawData?.categories?.let { categories ->
var sectionOffset = 1
var lastItemInSection = 0
for (category in categories) {
lastItemInSection = sectionOffset + category.emojis.size - 1
if (position == sectionOffset - 1) return true
sectionOffset = lastItemInSection + 2
}
}
return false
}

private fun getSectionForAbsoluteIndex(position: Int): Int {
var sectionOffset = 1
var lastItemInSection = 0
var index = 0
dataSource?.rawData?.categories?.let {
for (category in it) {
lastItemInSection = sectionOffset + category.emojis.size - 1
if (position <= lastItemInSection) return index
sectionOffset = lastItemInSection + 2
index++
}
}
return index
}

private fun getSectionOffset(section: Int): Int {
//Todo cache this for fast access
var sectionOffset = 1
var lastItemInSection = 0
dataSource?.rawData?.categories?.let {
for ((index, category) in it.withIndex()) {
lastItemInSection = sectionOffset + category.emojis.size - 1
if (section == index) return sectionOffset
sectionOffset = lastItemInSection + 2
}
}
return sectionOffset
}

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

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


override fun getItemCount(): Int {
dataSource?.rawData?.categories?.let {
var count = /*number of sections*/ it.size
for (ad in it) {
count += ad.emojis.size
}
return count
} ?: kotlin.run { return 0 }
}


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


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

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

var data: String? = null

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

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

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

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

}

companion object {
fun endTraceSession() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
Trace.endSection()
}
}

fun beginTraceSession(sectionName: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
Trace.beginSection(sectionName)
}
}

val staticLayoutCache = HashMap<String, StaticLayout>()

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

}

interface InteractionListener {
fun firstVisibleSectionChange(section: Int)
}

//privates

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

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

}
}

override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
//Log.i("SCROLL SPEED","scroll speed $dy")
isFastScroll = abs(dy) > 50
val visible = (recyclerView.layoutManager as GridLayoutManager).findFirstCompletelyVisibleItemPosition()
GlobalScope.launch {
val section = getSectionForAbsoluteIndex(visible)
if (section != currentFirstVisibleSection) {
currentFirstVisibleSection = section
GlobalScope.launch(Dispatchers.Main) {
interactionListener?.firstVisibleSectionChange(currentFirstVisibleSection)
}
}
}
}
}
}

View File

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

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="18dp"
android:height="22dp"
android:viewportWidth="18"
android:viewportHeight="22">
<path
android:pathData="M1,14s1,-1 4,-1 5,2 8,2 4,-1 4,-1V2s-1,1 -4,1 -5,-2 -8,-2 -4,1 -4,1v12zM1,21v-7"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
</vector>

View File

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

View File

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

View File

@ -0,0 +1,22 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="22dp"
android:viewportWidth="22"
android:viewportHeight="22">
<path
android:pathData="M9,12a5,5 0,0 0,7.54 0.54l3,-3a5,5 0,0 0,-7.07 -7.07l-1.72,1.71"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
<path
android:pathData="M13,10a5,5 0,0 0,-7.54 -0.54l-3,3a5,5 0,0 0,7.07 7.07l1.71,-1.71"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="14dp"
android:viewportWidth="20"
android:viewportHeight="14">
<path
android:pathData="M19,5H1M19,1H1M10,9H1M10,13H1"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
</vector>

View File

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

View File

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

View File

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="22dp"
android:height="14dp"
android:viewportWidth="22"
android:viewportHeight="14">
<path
android:pathData="M15,13l6,-6 -6,-6M7,1L1,7l6,6"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:fillType="evenOdd"
android:strokeColor="#9E9E9E"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="im.vector.riotredesign.features.reactions.EmojiReactionPickerActivity">

<fragment
android:id="@+id/fragment"
android:name="im.vector.riotredesign.features.reactions.EmojiChooserFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:layout="@layout/emoji_chooser_fragment" />

<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">

<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
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:popupTheme="@style/ThemeOverlay.AppCompat.Light" />

<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" />

</com.google.android.material.appbar.AppBarLayout>


</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

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

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

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


<FrameLayout

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:layout_height="50dp"
android:clickable="true"
android:focusable="true"
android:foreground="?attr/selectableItemBackground"
android:minHeight="50dp"
android:orientation="horizontal"
android:paddingLeft="@dimen/layout_horizontal_margin"
android:paddingTop="8dp"
android:paddingRight="@dimen/layout_horizontal_margin"
android:paddingBottom="8dp">

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

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

</LinearLayout>

View File

@ -0,0 +1,114 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/root_layout"
android:layout_width="match_parent"
android:layout_height="96dp">

<TextView
android:id="@+id/quick_react_1_text"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="4dp"
android:layout_marginRight="4dp"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:textSize="30sp"
app:autoSizeTextType="uniform"
app:layout_constraintBottom_toTopOf="@id/quick_react_agree_text"
app:layout_constraintEnd_toStartOf="@id/quick_react_2_text"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:text="👍" />

<TextView
android:id="@+id/quick_react_2_text"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:textSize="30sp"
app:autoSizeTextType="uniform"
app:layout_constraintBottom_toBottomOf="@id/quick_react_1_text"

app:layout_constraintEnd_toStartOf="@id/center_guideline"
app:layout_constraintStart_toEndOf="@id/quick_react_1_text"
app:layout_constraintTop_toTopOf="@id/quick_react_1_text"
tools:text="👎" />


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

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

<TextView
android:id="@+id/quick_react_3_text"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="4dp"
android:layout_marginRight="4dp"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:textSize="30sp"
app:autoSizeTextType="uniform"
app:layout_constraintBottom_toBottomOf="@+id/quick_react_1_text"
app:layout_constraintEnd_toStartOf="@id/quick_react_4_text"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toEndOf="@id/center_guideline"
app:layout_constraintTop_toTopOf="@id/quick_react_1_text"
tools:text="😀" />

<TextView
android:id="@+id/quick_react_4_text"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:textSize="30sp"
app:autoSizeTextType="uniform"
app:layout_constraintBottom_toBottomOf="@id/quick_react_3_text"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/quick_react_3_text"
app:layout_constraintTop_toTopOf="@id/quick_react_3_text"
tools:text="😞" />

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

</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,111 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.core.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_behavior="@string/bottom_sheet_behavior">

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

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

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

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

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

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

<TextView
android:id="@+id/bottom_sheet_message_preview_timestamp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginEnd="@dimen/layout_horizontal_margin"
android:layout_marginRight="@dimen/layout_horizontal_margin"
android:textColor="?android:textColorSecondary"
android:textSize="12sp"
android:layout_marginBottom="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_body"
tools:text="Friday 8pm" />
</androidx.constraintlayout.widget.ConstraintLayout>

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

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

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


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

</LinearLayout>
</androidx.core.widget.NestedScrollView>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content">

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

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/emoji_recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
tools:context="im.vector.riotredesign.features.reactions.EmojiChooserFragment"
tools:itemCount="100"
tools:layoutManager="androidx.recyclerview.widget.GridLayoutManager"
tools:listitem="@layout/grid_item_emoji"
tools:spanCount="10">

</androidx.recyclerview.widget.RecyclerView>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="40dp"
android:layout_height="40dp"
tools:showIn="@layout/activity_emoji_reaction_picker">

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

<im.vector.riotredesign.features.reactions.EmojiDrawView
android:id="@+id/grid_item_emoji_text"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_gravity="center" />
<!--<TextView-->
<!--android:layout_gravity="center"-->
<!--android:id="@+id/grid_item_emoji_text"-->
<!--android:layout_width="wrap_content"-->
<!--android:layout_height="wrap_content"-->
<!--tools:text="😀"-->
<!--android:textSize="24sp"-->
<!--/>-->
</FrameLayout>

View File

@ -0,0 +1,18 @@
<?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">

<TextView
android:id="@+id/section_header_textview"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="8dp"
android:paddingStart="8dp"
android:paddingLeft="8dp"
android:paddingRight="8dp"
android:textStyle="bold"
tools:text="Smiley &amp; Peolple" />

</LinearLayout>

View File

@ -19,6 +19,7 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:addStatesFromChildren="true"
android:paddingLeft="16dp"
android:paddingRight="16dp">

@ -58,7 +59,6 @@
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:duplicateParentState="true"
android:textColor="@color/brown_grey"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
@ -76,7 +76,6 @@
android:layout_marginEnd="32dp"
android:layout_marginRight="32dp"
android:layout_marginBottom="8dp"
android:duplicateParentState="true"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"

View File

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


<ImageView
android:id="@+id/messageAvatarImageView"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintStart_toStartOf="parent"
@ -37,8 +39,8 @@
android:id="@+id/messageMemberNameView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:layout_marginLeft="64dp"
android:layout_marginStart="56dp"
android:layout_marginLeft="56dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
@ -58,7 +60,6 @@
android:layout_height="wrap_content"
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:duplicateParentState="true"
android:textColor="@color/brown_grey"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
@ -74,13 +75,13 @@
android:layout_marginTop="8dp"
android:layout_marginEnd="32dp"
android:layout_marginRight="32dp"
android:layout_marginBottom="8dp"
android:duplicateParentState="true"
android:foreground="?attr/selectableItemBackground"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/messageMemberNameView"
tools:layout_height="300dp" />
tools:layout_height="300dp"
tools:srcCompat="@tools:sample/backgrounds/scenic" />

<ImageView
android:id="@+id/messageMediaPlayView"

View File

@ -12,8 +12,8 @@
android:id="@+id/itemNoticeAvatarView"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_marginStart="64dp"
android:layout_marginLeft="64dp"
android:layout_marginStart="56dp"
android:layout_marginLeft="56dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintStart_toStartOf="parent"

View File

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


<ImageView
android:id="@+id/messageAvatarImageView"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginTop="8dp"
android:layout_marginBottom="8dp"
app:layout_constraintStart_toStartOf="parent"
@ -22,8 +24,8 @@
android:id="@+id/messageMemberNameView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:layout_marginLeft="64dp"
android:layout_marginStart="56dp"
android:layout_marginLeft="56dp"
android:layout_marginTop="8dp"
android:layout_marginEnd="8dp"
android:layout_marginRight="8dp"
@ -44,7 +46,6 @@
android:layout_marginStart="8dp"
android:layout_marginLeft="8dp"
android:textColor="@color/brown_grey"
android:duplicateParentState="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintTop_toTopOf="@id/messageMemberNameView"
@ -54,10 +55,10 @@
android:id="@+id/messageTextView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="64dp"
android:layout_marginLeft="64dp"
android:layout_marginStart="56dp"
android:layout_marginLeft="56dp"
android:layout_marginBottom="8dp"
android:duplicateParentState="true"
android:clickable="true"
android:textColor="@color/dark_grey"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/search"
android:icon="@drawable/ic_search_white"
android:title="@string/search"
app:actionViewClass="android.widget.SearchView"
app:showAsAction="collapseActionView|ifRoom" />
</menu>

File diff suppressed because one or more lines are too long

View File

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

View File

@ -57,6 +57,8 @@
<string name="stay">Stay</string>
<string name="send">Send</string>
<string name="copy">Copy</string>
<string name="edit">Edit</string>
<string name="reply">Reply</string>
<string name="resend">Resend</string>
<string name="redact">Remove</string>
<string name="quote">Quote</string>

View File

@ -5,4 +5,10 @@
<string name="global_retry">"Retry"</string>
<string name="room_list_empty">"Join a room to start using the app."</string>


<string name="title_activity_emoji_reaction_picker">Reactions</string>
<string name="reactions_agree">Agree</string>
<string name="reactions_like">Like</string>
<string name="message_add_reaction">Add Reaction</string>

</resources>

View File

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