diff --git a/changelog.d/4065.misc b/changelog.d/4065.misc new file mode 100644 index 00000000000..35725f7fa14 --- /dev/null +++ b/changelog.d/4065.misc @@ -0,0 +1 @@ +Improve performances on RoomDetail screen \ No newline at end of file diff --git a/vector/src/androidTest/java/im/vector/app/features/reactions/data/EmojiDataSourceTest.kt b/vector/src/androidTest/java/im/vector/app/features/reactions/data/EmojiDataSourceTest.kt index 79090c42dd0..a880b17e0c8 100644 --- a/vector/src/androidTest/java/im/vector/app/features/reactions/data/EmojiDataSourceTest.kt +++ b/vector/src/androidTest/java/im/vector/app/features/reactions/data/EmojiDataSourceTest.kt @@ -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) } diff --git a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt index 40ba57103ba..a02b5256a5c 100644 --- a/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt +++ b/vector/src/main/java/im/vector/app/core/di/ScreenComponent.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt b/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt index 68b212c8309..54f07a7b0e0 100644 --- a/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt +++ b/vector/src/main/java/im/vector/app/core/di/VectorComponent.kt @@ -60,6 +60,7 @@ import im.vector.app.features.reactions.data.EmojiDataSource import im.vector.app.features.session.SessionListener 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 @@ -165,6 +166,8 @@ interface VectorComponent { fun webRtcCallManager(): WebRtcCallManager + fun appCoroutineScope(): CoroutineScope + fun jitsiActiveConferenceHolder(): JitsiActiveConferenceHolder @Component.Factory diff --git a/vector/src/main/java/im/vector/app/core/di/VectorModule.kt b/vector/src/main/java/im/vector/app/core/di/VectorModule.kt index 006a2f5aa01..dd1ffee8ec0 100644 --- a/vector/src/main/java/im/vector/app/core/di/VectorModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/VectorModule.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/core/platform/LifecycleAwareLazy.kt b/vector/src/main/java/im/vector/app/core/platform/LifecycleAwareLazy.kt new file mode 100644 index 00000000000..283106232ef --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/platform/LifecycleAwareLazy.kt @@ -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 LifecycleOwner.lifecycleAwareLazy(initializer: () -> T): Lazy = LifecycleAwareLazy(this, initializer) + +private object UninitializedValue + +class LifecycleAwareLazy( + private val owner: LifecycleOwner, + initializer: () -> T +) : Lazy, 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." +} diff --git a/vector/src/main/java/im/vector/app/core/ui/views/FailedMessagesWarningView.kt b/vector/src/main/java/im/vector/app/core/ui/views/FailedMessagesWarningView.kt index f9518552a34..755230b5bfc 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/FailedMessagesWarningView.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/FailedMessagesWarningView.kt @@ -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 - } } diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/emoji/AutocompleteEmojiPresenter.kt b/vector/src/main/java/im/vector/app/features/autocomplete/emoji/AutocompleteEmojiPresenter.kt index bf180746de7..4f272c7a24e 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/emoji/AutocompleteEmojiPresenter.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/emoji/AutocompleteEmojiPresenter.kt @@ -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(context), AutocompleteClickListener { + 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) } } diff --git a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt index aa0c10e0a26..4976cb39b9e 100644 --- a/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt +++ b/vector/src/main/java/im/vector/app/features/autocomplete/member/AutocompleteMemberPresenter.kt @@ -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(context), AutocompleteClickListener { - private val room = session.getRoom(roomId)!! + private val room by lazy { session.getRoom(roomId)!! } init { controller.listener = this diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index 287ff70dde5..0e7551087b2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -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() @@ -584,8 +588,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) @@ -602,14 +612,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) @@ -627,14 +641,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 @@ -767,6 +773,7 @@ class RoomDetailFragment @Inject constructor( } override fun onDestroyView() { + lazyLoadedViews.unBind() timelineEventController.callback = null timelineEventController.removeModelBuildListener(modelBuildListener) currentCallsViewPresenter.unBind() @@ -774,8 +781,6 @@ class RoomDetailFragment @Inject constructor( autoCompleter.clear() debouncer.cancelAll() views.timelineRecyclerView.cleanup() - emojiPopup.dismiss() - super.onDestroyView() } @@ -1350,22 +1355,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()) { @@ -1386,10 +1391,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() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt index 8be319f2a80..39b3cd50617 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt @@ -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_() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/views/RoomDetailLazyLoadedViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/views/RoomDetailLazyLoadedViews.kt new file mode 100644 index 00000000000..fafb49ad5c5 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/views/RoomDetailLazyLoadedViews.kt @@ -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 getOrInflate(inflateIfNeeded: Boolean, stub: ViewStub?, reference: KMutableProperty0): T? { + if (!inflateIfNeeded || stub == null || stub.parent == null) return reference.get() + val inflatedView = stub.inflate() as T + reference.set(inflatedView) + return inflatedView + } +} diff --git a/vector/src/main/java/im/vector/app/features/reactions/EmojiChooserFragment.kt b/vector/src/main/java/im/vector/app/features/reactions/EmojiChooserFragment.kt index 51dc62af8bc..822f291e1f9 100644 --- a/vector/src/main/java/im/vector/app/features/reactions/EmojiChooserFragment.kt +++ b/vector/src/main/java/im/vector/app/features/reactions/EmojiChooserFragment.kt @@ -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 diff --git a/vector/src/main/java/im/vector/app/features/reactions/EmojiChooserViewModel.kt b/vector/src/main/java/im/vector/app/features/reactions/EmojiChooserViewModel.kt index df2085e41ba..3a4caa296a5 100644 --- a/vector/src/main/java/im/vector/app/features/reactions/EmojiChooserViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/reactions/EmojiChooserViewModel.kt @@ -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 = MutableLiveData() val navigateEvent: MutableLiveData> = MutableLiveData() var selectedReaction: String? = null var eventId: String? = null @@ -29,6 +34,17 @@ class EmojiChooserViewModel @Inject constructor() : ViewModel() { val currentSection: MutableLiveData = MutableLiveData() val moveToSection: MutableLiveData = 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) diff --git a/vector/src/main/java/im/vector/app/features/reactions/EmojiReactionPickerActivity.kt b/vector/src/main/java/im/vector/app/features/reactions/EmojiReactionPickerActivity.kt index ecfaf937475..7140bb0baa1 100644 --- a/vector/src/main/java/im/vector/app/features/reactions/EmojiReactionPickerActivity.kt +++ b/vector/src/main/java/im/vector/app/features/reactions/EmojiReactionPickerActivity.kt @@ -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 - 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) diff --git a/vector/src/main/java/im/vector/app/features/reactions/EmojiRecyclerAdapter.kt b/vector/src/main/java/im/vector/app/features/reactions/EmojiRecyclerAdapter.kt index 45d26e81eb7..d64ee0f7054 100644 --- a/vector/src/main/java/im/vector/app/features/reactions/EmojiRecyclerAdapter.kt +++ b/vector/src/main/java/im/vector/app/features/reactions/EmojiRecyclerAdapter.kt @@ -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() { 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) { diff --git a/vector/src/main/java/im/vector/app/features/reactions/EmojiSearchResultViewModel.kt b/vector/src/main/java/im/vector/app/features/reactions/EmojiSearchResultViewModel.kt index ac7aee797ad..8e12dd2ccae 100644 --- a/vector/src/main/java/im/vector/app/features/reactions/EmojiSearchResultViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/reactions/EmojiSearchResultViewModel.kt @@ -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 + ) + } } } } diff --git a/vector/src/main/java/im/vector/app/features/reactions/data/EmojiDataSource.kt b/vector/src/main/java/im/vector/app/features/reactions/data/EmojiDataSource.kt index 96eda22eb99..7218eb993b0 100644 --- a/vector/src/main/java/im/vector/app/features/reactions/data/EmojiDataSource.kt +++ b/vector/src/main/java/im/vector/app/features/reactions/data/EmojiDataSource.kt @@ -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().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().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)) + // 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().apply { - parsedRawData.categories.forEach { entry -> - add(EmojiCategory(entry.id, entry.name, mutableListOf().apply { - entry.emojis.forEach { e -> - if (isEmojiRenderable(parsedRawData.emojis[e]!!.emoji)) { - add(e) + }, + categories = mutableListOf().apply { + parsedRawData.categories.forEach { entry -> + add(EmojiCategory(entry.id, entry.name, mutableListOf().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() @@ -74,9 +81,9 @@ class EmojiDataSource @Inject constructor( return PaintCompat.hasGlyph(paint, emoji) } - fun filterWith(query: String): List { + suspend fun filterWith(query: String): List { 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 { + suspend fun getQuickReactions(): List { 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 diff --git a/vector/src/main/res/layout/fragment_room_detail.xml b/vector/src/main/res/layout/fragment_room_detail.xml index be4559d0097..66dbbd28409 100644 --- a/vector/src/main/res/layout/fragment_room_detail.xml +++ b/vector/src/main/res/layout/fragment_room_detail.xml @@ -155,15 +155,14 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" /> - + app:layout_constraintStart_toStartOf="parent" /> - + app:layout_constraintTop_toBottomOf="@+id/appBarLayout" /> + app:constraint_referenced_ids="composerLayout,notificationAreaView, failedMessagesWarningStub" /> + \ No newline at end of file diff --git a/vector/src/main/res/layout/view_stub_invite_layout.xml b/vector/src/main/res/layout/view_stub_invite_layout.xml new file mode 100644 index 00000000000..29cabce86d3 --- /dev/null +++ b/vector/src/main/res/layout/view_stub_invite_layout.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file