Merge pull request #4065 from vector-im/feature/fga/improve_room_detail_start

Feature/fga/improve room detail start
This commit is contained in:
Benoit Marty 2021-09-23 19:35:28 +02:00 committed by GitHub
commit 9c559a7c2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 398 additions and 157 deletions

1
changelog.d/4065.misc Normal file
View File

@ -0,0 +1 @@
Improve performances on RoomDetail screen

View File

@ -17,6 +17,10 @@
package im.vector.app.features.reactions.data
import im.vector.app.InstrumentedTest
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.FixMethodOrder
@ -30,64 +34,80 @@ import kotlin.system.measureTimeMillis
@FixMethodOrder(MethodSorters.JVM)
class EmojiDataSourceTest : InstrumentedTest {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
@Test
fun checkParsingTime() {
val time = measureTimeMillis {
EmojiDataSource(context().resources)
createEmojiDataSource()
}
assertTrue("Too long to parse", time < 100)
}
@Test
fun checkNumberOfResult() {
val emojiDataSource = EmojiDataSource(context().resources)
assertTrue("Wrong number of emojis", emojiDataSource.rawData.emojis.size >= 500)
assertTrue("Wrong number of categories", emojiDataSource.rawData.categories.size >= 8)
val emojiDataSource = createEmojiDataSource()
val rawData = runBlocking {
emojiDataSource.rawData.await()
}
assertTrue("Wrong number of emojis", rawData.emojis.size >= 500)
assertTrue("Wrong number of categories", rawData.categories.size >= 8)
}
@Test
fun searchTestEmptySearch() {
val emojiDataSource = EmojiDataSource(context().resources)
assertTrue("Empty search should return at least 500 results", emojiDataSource.filterWith("").size >= 500)
val emojiDataSource = createEmojiDataSource()
val result = runBlocking {
emojiDataSource.filterWith("")
}
assertTrue("Empty search should return at least 500 results", result.size >= 500)
}
@Test
fun searchTestNoResult() {
val emojiDataSource = EmojiDataSource(context().resources)
assertTrue("Should not have result", emojiDataSource.filterWith("noresult").isEmpty())
val emojiDataSource = createEmojiDataSource()
val result = runBlocking {
emojiDataSource.filterWith("noresult")
}
assertTrue("Should not have result", result.isEmpty())
}
@Test
fun searchTestOneResult() {
val emojiDataSource = EmojiDataSource(context().resources)
assertEquals("Should have 1 result", 1, emojiDataSource.filterWith("france").size)
val emojiDataSource = createEmojiDataSource()
val result = runBlocking {
emojiDataSource.filterWith("france")
}
assertEquals("Should have 1 result", 1, result.size)
}
@Test
fun searchTestManyResult() {
val emojiDataSource = EmojiDataSource(context().resources)
assertTrue("Should have many result", emojiDataSource.filterWith("fra").size > 1)
val emojiDataSource = createEmojiDataSource()
val result = runBlocking {
emojiDataSource.filterWith("fra")
}
assertTrue("Should have many result", result.size > 1)
}
@Test
fun testTada() {
val emojiDataSource = EmojiDataSource(context().resources)
val result = emojiDataSource.filterWith("tada")
val emojiDataSource = createEmojiDataSource()
val result = runBlocking {
emojiDataSource.filterWith("tada")
}
assertEquals("Should find tada emoji", 1, result.size)
assertEquals("Should find tada emoji", "🎉", result[0].emoji)
}
@Test
fun testQuickReactions() {
val emojiDataSource = EmojiDataSource(context().resources)
assertEquals("Should have 8 quick reactions", 8, emojiDataSource.getQuickReactions().size)
val emojiDataSource = createEmojiDataSource()
val result = runBlocking {
emojiDataSource.getQuickReactions()
}
assertEquals("Should have 8 quick reactions", 8, result.size)
}
private fun createEmojiDataSource() = EmojiDataSource(coroutineScope, context().resources)
}

View File

@ -98,6 +98,7 @@ import im.vector.app.features.usercode.UserCodeActivity
import im.vector.app.features.widgets.WidgetActivity
import im.vector.app.features.widgets.permissions.RoomWidgetPermissionBottomSheet
import im.vector.app.features.workers.signout.SignOutBottomSheetDialogFragment
import kotlinx.coroutines.CoroutineScope
@Component(
dependencies = [
@ -129,6 +130,7 @@ interface ScreenComponent {
fun uiStateRepository(): UiStateRepository
fun unrecognizedCertificateDialog(): UnrecognizedCertificateDialog
fun autoAcceptInvites(): AutoAcceptInvites
fun appCoroutineScope(): CoroutineScope
/* ==========================================================================================
* Activities

View File

@ -61,6 +61,7 @@ import im.vector.app.features.session.SessionListener
import im.vector.app.features.settings.VectorDataStore
import im.vector.app.features.settings.VectorPreferences
import im.vector.app.features.ui.UiStateRepository
import kotlinx.coroutines.CoroutineScope
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.HomeServerHistoryService
@ -168,6 +169,8 @@ interface VectorComponent {
fun webRtcCallManager(): WebRtcCallManager
fun appCoroutineScope(): CoroutineScope
fun jitsiActiveConferenceHolder(): JitsiActiveConferenceHolder
@Component.Factory

View File

@ -33,12 +33,16 @@ import im.vector.app.features.pin.PinCodeStore
import im.vector.app.features.pin.SharedPrefPinCodeStore
import im.vector.app.features.ui.SharedPreferencesUiStateRepository
import im.vector.app.features.ui.UiStateRepository
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import org.matrix.android.sdk.api.Matrix
import org.matrix.android.sdk.api.auth.AuthenticationService
import org.matrix.android.sdk.api.auth.HomeServerHistoryService
import org.matrix.android.sdk.api.legacy.LegacySessionImporter
import org.matrix.android.sdk.api.raw.RawService
import org.matrix.android.sdk.api.session.Session
import javax.inject.Singleton
@Module
abstract class VectorModule {
@ -94,6 +98,13 @@ abstract class VectorModule {
fun providesHomeServerHistoryService(matrix: Matrix): HomeServerHistoryService {
return matrix.homeServerHistoryService()
}
@Provides
@JvmStatic
@Singleton
fun providesApplicationCoroutineScope(): CoroutineScope {
return CoroutineScope(SupervisorJob() + Dispatchers.Main)
}
}
@Binds

View File

@ -0,0 +1,75 @@
/*
* Copyright (c) 2021 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.app.core.platform
import androidx.annotation.MainThread
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.OnLifecycleEvent
fun <T> LifecycleOwner.lifecycleAwareLazy(initializer: () -> T): Lazy<T> = LifecycleAwareLazy(this, initializer)
private object UninitializedValue
class LifecycleAwareLazy<out T>(
private val owner: LifecycleOwner,
initializer: () -> T
) : Lazy<T>, LifecycleObserver {
private var initializer: (() -> T)? = initializer
private var _value: Any? = UninitializedValue
@Suppress("UNCHECKED_CAST")
override val value: T
@MainThread
get() {
if (_value === UninitializedValue) {
_value = initializer!!()
attachToLifecycle()
}
return _value as T
}
@OnLifecycleEvent(Lifecycle.Event.ON_DESTROY)
fun resetValue() {
_value = UninitializedValue
detachFromLifecycle()
}
private fun attachToLifecycle() {
if (getLifecycleOwner().lifecycle.currentState == Lifecycle.State.DESTROYED) {
throw IllegalStateException("Initialization failed because lifecycle has been destroyed!")
}
getLifecycleOwner().lifecycle.addObserver(this)
}
private fun detachFromLifecycle() {
getLifecycleOwner().lifecycle.removeObserver(this)
}
private fun getLifecycleOwner() = when (owner) {
is Fragment -> owner.viewLifecycleOwner
else -> owner
}
override fun isInitialized(): Boolean = _value !== UninitializedValue
override fun toString(): String = if (isInitialized()) value.toString() else "Lazy value not initialized yet."
}

View File

@ -19,7 +19,6 @@ package im.vector.app.core.ui.views
import android.content.Context
import android.util.AttributeSet
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import im.vector.app.R
import im.vector.app.databinding.ViewFailedMessagesWarningBinding
@ -49,8 +48,4 @@ class FailedMessagesWarningView @JvmOverloads constructor(
views.failedMessagesDeleteAllButton.setOnClickListener { callback?.onDeleteAllClicked() }
views.failedMessagesRetryButton.setOnClickListener { callback?.onRetryClicked() }
}
fun render(hasFailedMessages: Boolean) {
isVisible = hasFailedMessages
}
}

View File

@ -21,6 +21,11 @@ import androidx.recyclerview.widget.RecyclerView
import im.vector.app.features.autocomplete.AutocompleteClickListener
import im.vector.app.features.autocomplete.RecyclerViewPresenter
import im.vector.app.features.reactions.data.EmojiDataSource
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import javax.inject.Inject
class AutocompleteEmojiPresenter @Inject constructor(context: Context,
@ -28,11 +33,14 @@ class AutocompleteEmojiPresenter @Inject constructor(context: Context,
private val controller: AutocompleteEmojiController) :
RecyclerViewPresenter<String>(context), AutocompleteClickListener<String> {
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
init {
controller.listener = this
}
fun clear() {
coroutineScope.coroutineContext.cancelChildren()
controller.listener = null
}
@ -45,12 +53,14 @@ class AutocompleteEmojiPresenter @Inject constructor(context: Context,
}
override fun onQuery(query: CharSequence?) {
val data = if (query.isNullOrBlank()) {
// Return common emojis
emojiDataSource.getQuickReactions()
} else {
emojiDataSource.filterWith(query.toString())
coroutineScope.launch {
val data = if (query.isNullOrBlank()) {
// Return common emojis
emojiDataSource.getQuickReactions()
} else {
emojiDataSource.filterWith(query.toString())
}
controller.setData(data)
}
controller.setData(data)
}
}

View File

@ -19,8 +19,8 @@ package im.vector.app.features.autocomplete.member
import android.content.Context
import androidx.recyclerview.widget.RecyclerView
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.features.autocomplete.AutocompleteClickListener
import im.vector.app.features.autocomplete.RecyclerViewPresenter
import org.matrix.android.sdk.api.query.QueryStringValue
@ -35,7 +35,7 @@ class AutocompleteMemberPresenter @AssistedInject constructor(context: Context,
private val controller: AutocompleteMemberController
) : RecyclerViewPresenter<RoomMemberSummary>(context), AutocompleteClickListener<RoomMemberSummary> {
private val room = session.getRoom(roomId)!!
private val room by lazy { session.getRoom(roomId)!! }
init {
controller.listener = this

View File

@ -86,6 +86,7 @@ import im.vector.app.core.hardware.vibrate
import im.vector.app.core.intent.getFilenameFromUri
import im.vector.app.core.intent.getMimeTypeFromUri
import im.vector.app.core.platform.VectorBaseFragment
import im.vector.app.core.platform.lifecycleAwareLazy
import im.vector.app.core.platform.showOptimizedSnackbar
import im.vector.app.core.resources.ColorProvider
import im.vector.app.core.ui.views.CurrentCallsView
@ -153,6 +154,7 @@ import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData
import im.vector.app.features.home.room.detail.timeline.reactions.ViewReactionsBottomSheet
import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever
import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet
import im.vector.app.features.home.room.detail.views.RoomDetailLazyLoadedViews
import im.vector.app.features.home.room.detail.widget.RoomWidgetsBottomSheet
import im.vector.app.features.html.EventHtmlRenderer
import im.vector.app.features.html.PillImageSpan
@ -312,7 +314,10 @@ class RoomDetailFragment @Inject constructor(
private var lockSendButton = false
private val currentCallsViewPresenter = CurrentCallsViewPresenter()
private lateinit var emojiPopup: EmojiPopup
private val lazyLoadedViews = RoomDetailLazyLoadedViews()
private val emojiPopup: EmojiPopup by lifecycleAwareLazy {
createEmojiPopup()
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@ -340,16 +345,15 @@ class RoomDetailFragment @Inject constructor(
onTapToReturnToCall = ::onTapToReturnToCall
)
keyboardStateUtils = KeyboardStateUtils(requireActivity())
lazyLoadedViews.bind(views)
setupToolbar(views.roomToolbar)
setupRecyclerView()
setupComposer()
setupInviteView()
setupNotificationView()
setupJumpToReadMarkerView()
setupActiveCallView()
setupJumpToBottomView()
setupEmojiPopup()
setupFailedMessagesWarningView()
setupEmojiButton()
setupRemoveJitsiWidgetView()
setupVoiceMessageView()
@ -593,8 +597,14 @@ class RoomDetailFragment @Inject constructor(
)
}
private fun setupEmojiPopup() {
emojiPopup = EmojiPopup
private fun setupEmojiButton() {
views.composerLayout.views.composerEmojiButton.debouncedClicks {
emojiPopup.toggle()
}
}
private fun createEmojiPopup(): EmojiPopup {
return EmojiPopup
.Builder
.fromRootView(views.rootConstraintLayout)
.setKeyboardAnimationStyle(R.style.emoji_fade_animation_style)
@ -611,14 +621,18 @@ class RoomDetailFragment @Inject constructor(
}
}
.build(views.composerLayout.views.composerEditText)
}
views.composerLayout.views.composerEmojiButton.debouncedClicks {
emojiPopup.toggle()
private val permissionVoiceMessageLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
if (allGranted) {
// In this case, let the user start again the gesture
} else if (deniedPermanently) {
vectorBaseActivity.onPermissionDeniedSnackbar(R.string.denied_permission_voice_message)
}
}
private fun setupFailedMessagesWarningView() {
views.failedMessagesWarningView.callback = object : FailedMessagesWarningView.Callback {
private fun createFailedMessagesWarningCallback(): FailedMessagesWarningView.Callback {
return object : FailedMessagesWarningView.Callback {
override fun onDeleteAllClicked() {
MaterialAlertDialogBuilder(requireContext())
.setTitle(R.string.event_status_delete_all_failed_dialog_title)
@ -636,14 +650,6 @@ class RoomDetailFragment @Inject constructor(
}
}
private val permissionVoiceMessageLauncher = registerForPermissionsResult { allGranted, deniedPermanently ->
if (allGranted) {
// In this case, let the user start again the gesture
} else if (deniedPermanently) {
vectorBaseActivity.onPermissionDeniedSnackbar(R.string.denied_permission_voice_message)
}
}
private fun setupVoiceMessageView() {
views.voiceMessageRecorderView.voiceMessagePlaybackTracker = voiceMessagePlaybackTracker
@ -776,6 +782,7 @@ class RoomDetailFragment @Inject constructor(
}
override fun onDestroyView() {
lazyLoadedViews.unBind()
timelineEventController.callback = null
timelineEventController.removeModelBuildListener(modelBuildListener)
currentCallsViewPresenter.unBind()
@ -783,8 +790,6 @@ class RoomDetailFragment @Inject constructor(
autoCompleter.clear()
debouncer.cancelAll()
views.timelineRecyclerView.cleanup()
emojiPopup.dismiss()
super.onDestroyView()
}
@ -1359,22 +1364,22 @@ class RoomDetailFragment @Inject constructor(
return isHandled
}
private fun setupInviteView() {
views.inviteView.callback = this
}
override fun invalidate() = withState(roomDetailViewModel) { state ->
invalidateOptionsMenu()
val summary = state.asyncRoomSummary()
renderToolbar(summary, state.typingMessage)
views.removeJitsiWidgetView.render(state)
views.failedMessagesWarningView.render(state.hasFailedSending)
if (state.hasFailedSending) {
lazyLoadedViews.failedMessagesWarningView(inflateIfNeeded = true, createFailedMessagesWarningCallback())?.isVisible = true
} else {
lazyLoadedViews.failedMessagesWarningView(inflateIfNeeded = false)?.isVisible = false
}
val inviter = state.asyncInviter()
if (summary?.membership == Membership.JOIN) {
views.jumpToBottomView.count = summary.notificationCount
views.jumpToBottomView.drawBadge = summary.hasUnreadMessages
timelineEventController.update(state)
views.inviteView.isVisible = false
lazyLoadedViews.inviteView(false)?.isVisible = false
if (state.tombstoneEvent == null) {
if (state.canSendMessage) {
if (!views.voiceMessageRecorderView.isActive()) {
@ -1395,10 +1400,15 @@ class RoomDetailFragment @Inject constructor(
views.notificationAreaView.render(NotificationAreaView.State.Tombstone(state.tombstoneEvent))
}
} else if (summary?.membership == Membership.INVITE && inviter != null) {
views.inviteView.isVisible = true
views.inviteView.render(inviter, VectorInviteView.Mode.LARGE, state.changeMembershipState)
// Intercept click event
views.inviteView.setOnClickListener { }
views.composerLayout.isVisible = false
views.voiceMessageRecorderView.isVisible = false
lazyLoadedViews.inviteView(true)?.apply {
callback = this@RoomDetailFragment
isVisible = true
render(inviter, VectorInviteView.Mode.LARGE, state.changeMembershipState)
setOnClickListener { }
}
Unit
} else if (state.asyncInviter.complete) {
vectorBaseActivity.finish()
}

View File

@ -39,13 +39,13 @@ import im.vector.app.features.home.room.detail.timeline.factory.MergedHeaderItem
import im.vector.app.features.home.room.detail.timeline.factory.ReadReceiptsItemFactory
import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactory
import im.vector.app.features.home.room.detail.timeline.factory.TimelineItemFactoryParams
import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventsGroups
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.TimelineControllerInterceptorHelper
import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventDiffUtilCallback
import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityHelper
import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventVisibilityStateChangedListener
import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventsGroups
import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider
import im.vector.app.features.home.room.detail.timeline.item.BasedMergedItem
import im.vector.app.features.home.room.detail.timeline.item.DaySeparatorItem
@ -276,6 +276,10 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
}
override fun buildModels() {
// Don't build anything if membership is not joined
if (partialState.roomSummary?.membership != Membership.JOIN) {
return
}
val timestamp = System.currentTimeMillis()
val showingForwardLoader = LoadingItem_()

View File

@ -0,0 +1,63 @@
/*
* Copyright (c) 2021 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.app.features.home.room.detail.views
import android.view.View
import android.view.ViewStub
import im.vector.app.core.ui.views.FailedMessagesWarningView
import im.vector.app.databinding.FragmentRoomDetailBinding
import im.vector.app.features.invite.VectorInviteView
import kotlin.reflect.KMutableProperty0
/**
* This is an holder for lazy loading some views of the RoomDetail screen.
* It's using some ViewStub where it makes sense.
*/
class RoomDetailLazyLoadedViews {
private var roomDetailBinding: FragmentRoomDetailBinding? = null
private var failedMessagesWarningView: FailedMessagesWarningView? = null
private var inviteView: VectorInviteView? = null
fun bind(roomDetailBinding: FragmentRoomDetailBinding) {
this.roomDetailBinding = roomDetailBinding
}
fun unBind() {
roomDetailBinding = null
inviteView = null
failedMessagesWarningView = null
}
fun failedMessagesWarningView(inflateIfNeeded: Boolean, callback: FailedMessagesWarningView.Callback? = null): FailedMessagesWarningView? {
return getOrInflate(inflateIfNeeded, roomDetailBinding?.failedMessagesWarningStub, this::failedMessagesWarningView)?.apply {
this.callback = callback
}
}
fun inviteView(inflateIfNeeded: Boolean): VectorInviteView? {
return getOrInflate(inflateIfNeeded, roomDetailBinding?.inviteViewStub, this::inviteView)
}
private inline fun <reified T : View> getOrInflate(inflateIfNeeded: Boolean, stub: ViewStub?, reference: KMutableProperty0<T?>): T? {
if (!inflateIfNeeded || stub == null || stub.parent == null) return reference.get()
val inflatedView = stub.inflate() as T
reference.set(inflatedView)
return inflatedView
}
}

View File

@ -41,15 +41,15 @@ class EmojiChooserFragment @Inject constructor(
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = activityViewModelProvider.get(EmojiChooserViewModel::class.java)
emojiRecyclerAdapter.reactionClickListener = this
emojiRecyclerAdapter.interactionListener = this
views.emojiRecyclerView.adapter = emojiRecyclerAdapter
viewModel.moveToSection.observe(viewLifecycleOwner) { section ->
emojiRecyclerAdapter.scrollToSection(section)
}
viewModel.emojiData.observe(viewLifecycleOwner) {
emojiRecyclerAdapter.update(it)
}
}
override fun getCoroutineScope() = lifecycleScope

View File

@ -17,11 +17,16 @@ package im.vector.app.features.reactions
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import im.vector.app.core.utils.LiveEvent
import im.vector.app.features.reactions.data.EmojiData
import im.vector.app.features.reactions.data.EmojiDataSource
import kotlinx.coroutines.launch
import javax.inject.Inject
class EmojiChooserViewModel @Inject constructor() : ViewModel() {
class EmojiChooserViewModel @Inject constructor(private val emojiDataSource: EmojiDataSource) : ViewModel() {
val emojiData: MutableLiveData<EmojiData> = MutableLiveData()
val navigateEvent: MutableLiveData<LiveEvent<String>> = MutableLiveData()
var selectedReaction: String? = null
var eventId: String? = null
@ -29,6 +34,17 @@ class EmojiChooserViewModel @Inject constructor() : ViewModel() {
val currentSection: MutableLiveData<Int> = MutableLiveData()
val moveToSection: MutableLiveData<Int> = MutableLiveData()
init {
loadEmojiData()
}
private fun loadEmojiData() {
viewModelScope.launch {
val rawData = emojiDataSource.rawData.await()
emojiData.postValue(rawData)
}
}
fun onReactionSelected(reaction: String) {
selectedReaction = reaction
navigateEvent.value = LiveEvent(NAVIGATE_FINISH)

View File

@ -25,6 +25,7 @@ import android.view.MenuInflater
import android.view.MenuItem
import android.widget.SearchView
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.airbnb.mvrx.viewModel
import com.google.android.material.tabs.TabLayout
import com.jakewharton.rxbinding3.widget.queryTextChanges
@ -36,6 +37,7 @@ import im.vector.app.core.platform.VectorBaseActivity
import im.vector.app.databinding.ActivityEmojiReactionPickerBinding
import im.vector.app.features.reactions.data.EmojiDataSource
import io.reactivex.android.schedulers.AndroidSchedulers
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.TimeUnit
@ -91,17 +93,19 @@ class EmojiReactionPickerActivity : VectorBaseActivity<ActivityEmojiReactionPick
viewModel = viewModelProvider.get(EmojiChooserViewModel::class.java)
viewModel.eventId = intent.getStringExtra(EXTRA_EVENT_ID)
emojiDataSource.rawData.categories.forEach { category ->
val s = category.emojis[0]
views.tabs.newTab()
.also { tab ->
tab.text = emojiDataSource.rawData.emojis[s]!!.emoji
tab.contentDescription = category.name
}
.also { tab ->
views.tabs.addTab(tab)
}
lifecycleScope.launch {
val rawData = emojiDataSource.rawData.await()
rawData.categories.forEach { category ->
val s = category.emojis[0]
views.tabs.newTab()
.also { tab ->
tab.text = rawData.emojis[s]!!.emoji
tab.contentDescription = category.name
}
.also { tab ->
views.tabs.addTab(tab)
}
}
}
views.tabs.addOnTabSelectedListener(tabLayoutSelectionListener)

View File

@ -15,6 +15,7 @@
*/
package im.vector.app.features.reactions
import android.annotation.SuppressLint
import android.os.Build
import android.os.Trace
import android.text.Layout
@ -30,7 +31,7 @@ import androidx.recyclerview.widget.RecyclerView
import androidx.transition.AutoTransition
import androidx.transition.TransitionManager
import im.vector.app.R
import im.vector.app.features.reactions.data.EmojiDataSource
import im.vector.app.features.reactions.data.EmojiData
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -43,13 +44,13 @@ import kotlin.math.abs
* TODO: Performances
* TODO: Scroll to section - Find a way to snap section to the top
*/
class EmojiRecyclerAdapter @Inject constructor(
private val dataSource: EmojiDataSource
) :
class EmojiRecyclerAdapter @Inject constructor() :
RecyclerView.Adapter<EmojiRecyclerAdapter.ViewHolder>() {
var reactionClickListener: ReactionClickListener? = null
var interactionListener: InteractionListener? = null
private var rawData: EmojiData = EmojiData(emptyList(), emptyMap(), emptyMap())
private var mRecyclerView: RecyclerView? = null
private var currentFirstVisibleSection = 0
@ -61,6 +62,12 @@ class EmojiRecyclerAdapter @Inject constructor(
UNKNOWN
}
@SuppressLint("NotifyDataSetChanged")
fun update(emojiData: EmojiData) {
rawData = emojiData
notifyDataSetChanged()
}
private var scrollState = ScrollState.UNKNOWN
private var isFastScroll = false
@ -71,10 +78,10 @@ class EmojiRecyclerAdapter @Inject constructor(
if (itemPosition != RecyclerView.NO_POSITION) {
val sectionNumber = getSectionForAbsoluteIndex(itemPosition)
if (!isSection(itemPosition)) {
val sectionMojis = dataSource.rawData.categories[sectionNumber].emojis
val sectionMojis = rawData.categories[sectionNumber].emojis
val sectionOffset = getSectionOffset(sectionNumber)
val emoji = sectionMojis[itemPosition - sectionOffset]
val item = dataSource.rawData.emojis.getValue(emoji).emoji
val item = rawData.emojis.getValue(emoji).emoji
reactionClickListener?.onReactionSelected(item)
}
}
@ -115,7 +122,7 @@ class EmojiRecyclerAdapter @Inject constructor(
}
fun scrollToSection(section: Int) {
if (section < 0 || section >= dataSource.rawData.categories.size) {
if (section < 0 || section >= rawData.categories.size) {
// ignore
return
}
@ -149,7 +156,7 @@ class EmojiRecyclerAdapter @Inject constructor(
private fun isSection(position: Int): Boolean {
var sectionOffset = 1
var lastItemInSection: Int
dataSource.rawData.categories.forEach { category ->
rawData.categories.forEach { category ->
lastItemInSection = sectionOffset + category.emojis.size - 1
if (position == sectionOffset - 1) return true
sectionOffset = lastItemInSection + 2
@ -161,7 +168,7 @@ class EmojiRecyclerAdapter @Inject constructor(
var sectionOffset = 1
var lastItemInSection: Int
var index = 0
dataSource.rawData.categories.forEach { category ->
rawData.categories.forEach { category ->
lastItemInSection = sectionOffset + category.emojis.size - 1
if (position <= lastItemInSection) return index
sectionOffset = lastItemInSection + 2
@ -174,7 +181,7 @@ class EmojiRecyclerAdapter @Inject constructor(
// Todo cache this for fast access
var sectionOffset = 1
var lastItemInSection: Int
dataSource.rawData.categories.forEachIndexed { index, category ->
rawData.categories.forEachIndexed { index, category ->
lastItemInSection = sectionOffset + category.emojis.size - 1
if (section == index) return sectionOffset
sectionOffset = lastItemInSection + 2
@ -186,12 +193,12 @@ class EmojiRecyclerAdapter @Inject constructor(
Trace.beginSection("MyAdapter.onBindViewHolder")
val sectionNumber = getSectionForAbsoluteIndex(position)
if (isSection(position)) {
holder.bind(dataSource.rawData.categories[sectionNumber].name)
holder.bind(rawData.categories[sectionNumber].name)
} else {
val sectionMojis = dataSource.rawData.categories[sectionNumber].emojis
val sectionMojis = rawData.categories[sectionNumber].emojis
val sectionOffset = getSectionOffset(sectionNumber)
val emoji = sectionMojis[position - sectionOffset]
val item = dataSource.rawData.emojis[emoji]!!.emoji
val item = rawData.emojis[emoji]!!.emoji
(holder as EmojiViewHolder).data = item
if (scrollState != ScrollState.SETTLING || !isFastScroll) {
// Log.i("PERF","Bind with draw at position:$position")
@ -220,7 +227,7 @@ class EmojiRecyclerAdapter @Inject constructor(
super.onViewRecycled(holder)
}
override fun getItemCount() = dataSource.rawData.categories
override fun getItemCount() = rawData.categories
.sumOf { emojiCategory -> 1 /* Section */ + emojiCategory.emojis.size }
abstract class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

View File

@ -15,17 +15,19 @@
*/
package im.vector.app.features.reactions
import androidx.lifecycle.viewModelScope
import com.airbnb.mvrx.ActivityViewModelContext
import com.airbnb.mvrx.MvRxState
import com.airbnb.mvrx.MvRxViewModelFactory
import com.airbnb.mvrx.ViewModelContext
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import im.vector.app.core.platform.EmptyViewEvents
import im.vector.app.core.platform.VectorViewModel
import im.vector.app.features.reactions.data.EmojiDataSource
import im.vector.app.features.reactions.data.EmojiItem
import kotlinx.coroutines.launch
data class EmojiSearchResultViewState(
val query: String = "",
@ -58,11 +60,14 @@ class EmojiSearchResultViewModel @AssistedInject constructor(
}
private fun updateQuery(action: EmojiSearchAction.UpdateQuery) {
setState {
copy(
query = action.queryString,
results = dataSource.filterWith(action.queryString)
)
viewModelScope.launch {
val results = dataSource.filterWith(action.queryString)
setState {
copy(
query = action.queryString,
results = results
)
}
}
}
}

View File

@ -20,53 +20,60 @@ import android.graphics.Paint
import androidx.core.graphics.PaintCompat
import com.squareup.moshi.Moshi
import im.vector.app.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class EmojiDataSource @Inject constructor(
appScope: CoroutineScope,
resources: Resources
) {
private val paint = Paint()
val rawData = resources.openRawResource(R.raw.emoji_picker_datasource)
.use { input ->
Moshi.Builder()
.build()
.adapter(EmojiData::class.java)
.fromJson(input.bufferedReader().use { it.readText() })
}
?.let { parsedRawData ->
// Add key as a keyword, it will solve the issue that ":tada" is not available in completion
// Only add emojis to emojis/categories that can be rendered by the system
parsedRawData.copy(
emojis = mutableMapOf<String, EmojiItem>().apply {
parsedRawData.emojis.keys.forEach { key ->
val origin = parsedRawData.emojis[key] ?: return@forEach
val rawData = appScope.async(Dispatchers.IO, CoroutineStart.LAZY) {
resources.openRawResource(R.raw.emoji_picker_datasource)
.use { input ->
Moshi.Builder()
.build()
.adapter(EmojiData::class.java)
.fromJson(input.bufferedReader().use { it.readText() })
}
?.let { parsedRawData ->
// Add key as a keyword, it will solve the issue that ":tada" is not available in completion
// Only add emojis to emojis/categories that can be rendered by the system
parsedRawData.copy(
emojis = mutableMapOf<String, EmojiItem>().apply {
parsedRawData.emojis.keys.forEach { key ->
val origin = parsedRawData.emojis[key] ?: return@forEach
// Do not add keys containing '_'
if (isEmojiRenderable(origin.emoji)) {
if (origin.keywords.contains(key) || key.contains("_")) {
put(key, origin)
} else {
put(key, origin.copy(keywords = origin.keywords + key))
}
}
}
},
categories = mutableListOf<EmojiCategory>().apply {
parsedRawData.categories.forEach { entry ->
add(EmojiCategory(entry.id, entry.name, mutableListOf<String>().apply {
entry.emojis.forEach { e ->
if (isEmojiRenderable(parsedRawData.emojis[e]!!.emoji)) {
add(e)
// Do not add keys containing '_'
if (isEmojiRenderable(origin.emoji)) {
if (origin.keywords.contains(key) || key.contains("_")) {
put(key, origin)
} else {
put(key, origin.copy(keywords = origin.keywords + key))
}
}
}))
}
},
categories = mutableListOf<EmojiCategory>().apply {
parsedRawData.categories.forEach { entry ->
add(EmojiCategory(entry.id, entry.name, mutableListOf<String>().apply {
entry.emojis.forEach { e ->
if (isEmojiRenderable(parsedRawData.emojis[e]!!.emoji)) {
add(e)
}
}
}))
}
}
}
)
}
?: EmojiData(emptyList(), emptyMap(), emptyMap())
)
}
?: EmojiData(emptyList(), emptyMap(), emptyMap())
}
private val quickReactions = mutableListOf<EmojiItem>()
@ -74,9 +81,9 @@ class EmojiDataSource @Inject constructor(
return PaintCompat.hasGlyph(paint, emoji)
}
fun filterWith(query: String): List<EmojiItem> {
suspend fun filterWith(query: String): List<EmojiItem> {
val words = query.split("\\s".toRegex())
val rawData = this.rawData.await()
// First add emojis with name matching query, sorted by name
return (rawData.emojis.values
.asSequence()
@ -87,9 +94,9 @@ class EmojiDataSource @Inject constructor(
// Then emojis with keyword matching any of the word in the query, sorted by name
rawData.emojis.values
.filter { emojiItem ->
words.fold(true, { prev, word ->
words.fold(true) { prev, word ->
prev && emojiItem.keywords.any { keyword -> keyword.contains(word, true) }
})
}
}
.sortedBy { it.name })
// and ensure they will not be present twice
@ -97,7 +104,7 @@ class EmojiDataSource @Inject constructor(
.toList()
}
fun getQuickReactions(): List<EmojiItem> {
suspend fun getQuickReactions(): List<EmojiItem> {
if (quickReactions.isEmpty()) {
listOf(
"thumbs-up", // 👍
@ -109,7 +116,7 @@ class EmojiDataSource @Inject constructor(
"rocket", // 🚀
"eyes" // 👀
)
.mapNotNullTo(quickReactions) { rawData.emojis[it] }
.mapNotNullTo(quickReactions) { rawData.await().emojis[it] }
}
return quickReactions

View File

@ -155,15 +155,14 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<im.vector.app.core.ui.views.FailedMessagesWarningView
android:id="@+id/failedMessagesWarningView"
<ViewStub
android:id="@+id/failedMessagesWarningStub"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:inflatedId="@layout/view_stub_failed_message_warning_layout"
app:layout_constraintBottom_toTopOf="@id/composerLayout"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
app:layout_constraintStart_toStartOf="parent" />
<im.vector.app.features.home.room.detail.composer.TextComposerView
android:id="@+id/composerLayout"
@ -180,7 +179,7 @@
<im.vector.app.features.home.room.detail.composer.VoiceMessageRecorderView
android:id="@+id/voiceMessageRecorderView"
android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
@ -188,24 +187,23 @@
app:layout_constraintStart_toStartOf="parent"
tools:visibility="visible" />
<im.vector.app.features.invite.VectorInviteView
android:id="@+id/inviteView"
<ViewStub
android:id="@+id/inviteViewStub"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_height="0dp"
android:background="?android:colorBackground"
android:visibility="gone"
android:layout="@layout/view_stub_invite_layout"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/appBarLayout"
tools:visibility="gone" />
app:layout_constraintTop_toBottomOf="@+id/appBarLayout" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/badgeBarrier"
android:layout_width="0dp"
android:layout_height="0dp"
app:barrierDirection="top"
app:constraint_referenced_ids="composerLayout,notificationAreaView, failedMessagesWarningView" />
app:constraint_referenced_ids="composerLayout,notificationAreaView, failedMessagesWarningStub" />
<im.vector.app.core.platform.BadgeFloatingActionButton
android:id="@+id/jumpToBottomView"

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<im.vector.app.core.ui.views.FailedMessagesWarningView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"/>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<im.vector.app.features.invite.VectorInviteView
xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"/>