From 199456487c3050d7cc57d9032d4e8ec609a9c274 Mon Sep 17 00:00:00 2001 From: Valere Date: Mon, 28 Oct 2019 14:36:15 +0100 Subject: [PATCH] Search reaction by name/keywords --- CHANGES.md | 2 +- .../vector/riotx/core/di/ScreenComponent.kt | 3 + .../reactions/EmojiChooserFragment.kt | 3 +- .../reactions/EmojiChooserViewModel.kt | 2 +- .../reactions/EmojiReactionPickerActivity.kt | 64 +++++++++++++-- .../reactions/EmojiSearchResultController.kt | 77 +++++++++++++++++++ .../reactions/EmojiSearchResultFragment.kt | 69 +++++++++++++++++ .../reactions/EmojiSearchResultItem.kt | 60 +++++++++++++++ .../reactions/EmojiSearchResultViewModel.kt | 54 +++++++++++++ .../layout/activity_emoji_reaction_picker.xml | 8 ++ .../src/main/res/layout/item_emoji_result.xml | 51 ++++++++++++ .../res/menu/menu_emoji_reaction_picker.xml | 1 + vector/src/main/res/values/strings.xml | 1 + 13 files changed, 385 insertions(+), 10 deletions(-) create mode 100644 vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultController.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultFragment.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultItem.kt create mode 100644 vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultViewModel.kt create mode 100644 vector/src/main/res/layout/item_emoji_result.xml diff --git a/CHANGES.md b/CHANGES.md index b05300f94b..da1c26e425 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,7 +5,7 @@ Features ✨: - Improvements 🙌: - - + - Search reaction by name or keyword in emoji picker Other changes: - diff --git a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt index 0efbc0e173..79e496c141 100644 --- a/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt +++ b/vector/src/main/java/im/vector/riotx/core/di/ScreenComponent.kt @@ -59,6 +59,7 @@ import im.vector.riotx.features.rageshake.BugReportActivity import im.vector.riotx.features.rageshake.BugReporter import im.vector.riotx.features.rageshake.RageShake import im.vector.riotx.features.reactions.EmojiReactionPickerActivity +import im.vector.riotx.features.reactions.EmojiSearchResultFragment import im.vector.riotx.features.reactions.widget.ReactionButton import im.vector.riotx.features.roomdirectory.PublicRoomsFragment import im.vector.riotx.features.roomdirectory.RoomDirectoryActivity @@ -197,6 +198,8 @@ interface ScreenComponent { fun inject(incomingShareActivity: IncomingShareActivity) + fun inject(emojiSearchResultFragment: EmojiSearchResultFragment) + @Component.Factory interface Factory { fun create(vectorComponent: VectorComponent, diff --git a/vector/src/main/java/im/vector/riotx/features/reactions/EmojiChooserFragment.kt b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiChooserFragment.kt index 8aec8231db..36b1a52b27 100644 --- a/vector/src/main/java/im/vector/riotx/features/reactions/EmojiChooserFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiChooserFragment.kt @@ -36,12 +36,11 @@ class EmojiChooserFragment : VectorBaseFragment() { viewModel = activity?.run { ViewModelProviders.of(this, viewModelFactory).get(EmojiChooserViewModel::class.java) } ?: throw Exception("Invalid Activity") - viewModel.initWithContect(context!!) + viewModel.initWithContext(context!!) (view as? RecyclerView)?.let { it.adapter = viewModel.adapter it.adapter?.notifyDataSetChanged() } -// val ds = EmojiDataSource(this.context!!) } } diff --git a/vector/src/main/java/im/vector/riotx/features/reactions/EmojiChooserViewModel.kt b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiChooserViewModel.kt index 16aecd0906..bbde2ac54c 100644 --- a/vector/src/main/java/im/vector/riotx/features/reactions/EmojiChooserViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiChooserViewModel.kt @@ -39,7 +39,7 @@ class EmojiChooserViewModel @Inject constructor() : ViewModel() { } } - fun initWithContect(context: Context) { + fun initWithContext(context: Context) { // TODO load async val emojiDataSource = EmojiDataSource(context) emojiSourceLiveData.value = emojiDataSource diff --git a/vector/src/main/java/im/vector/riotx/features/reactions/EmojiReactionPickerActivity.kt b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiReactionPickerActivity.kt index 0a4e05a4c8..7de38602cb 100644 --- a/vector/src/main/java/im/vector/riotx/features/reactions/EmojiReactionPickerActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiReactionPickerActivity.kt @@ -25,15 +25,22 @@ import android.view.MenuInflater import android.view.MenuItem import android.widget.SearchView import androidx.appcompat.widget.Toolbar +import androidx.core.view.isInvisible +import androidx.core.view.isVisible import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders +import com.airbnb.mvrx.viewModel import com.google.android.material.tabs.TabLayout import im.vector.riotx.EmojiCompatFontProvider import im.vector.riotx.R import im.vector.riotx.core.di.ScreenComponent import im.vector.riotx.core.extensions.observeEvent import im.vector.riotx.core.platform.VectorBaseActivity +import io.reactivex.Observable +import io.reactivex.android.schedulers.AndroidSchedulers import kotlinx.android.synthetic.main.activity_emoji_reaction_picker.* +import timber.log.Timber +import java.util.concurrent.TimeUnit import javax.inject.Inject /** @@ -41,9 +48,9 @@ import javax.inject.Inject * TODO: Loading indicator while getting emoji data source? * TODO: migrate to MvRx * TODO: Finish Refactor to vector base activity - * TODO: Move font request to app */ -class EmojiReactionPickerActivity : VectorBaseActivity(), EmojiCompatFontProvider.FontProviderListener { +class EmojiReactionPickerActivity : VectorBaseActivity(), + EmojiCompatFontProvider.FontProviderListener { private lateinit var tabLayout: TabLayout @@ -57,6 +64,8 @@ class EmojiReactionPickerActivity : VectorBaseActivity(), EmojiCompatFontProvide @Inject lateinit var emojiCompatFontProvider: EmojiCompatFontProvider + val searchResultViewModel: EmojiSearchResultViewModel by viewModel() + private var tabLayoutSelectionListener = object : TabLayout.OnTabSelectedListener { override fun onTabReselected(tab: TabLayout.Tab) { } @@ -121,10 +130,15 @@ class EmojiReactionPickerActivity : VectorBaseActivity(), EmojiCompatFontProvide finish() } } + + supportFragmentManager.findFragmentById(R.id.fragment)?.view?.isVisible = true + supportFragmentManager.findFragmentById(R.id.searchFragment)?.view?.isInvisible = true + tabLayout.isVisible = true } override fun compatibilityFontUpdate(typeface: Typeface?) { EmojiDrawView.configureTextPaint(this, typeface) + searchResultViewModel.dataSource } override fun onDestroy() { @@ -137,11 +151,11 @@ class EmojiReactionPickerActivity : VectorBaseActivity(), EmojiCompatFontProvide inflater.inflate(getMenuRes(), menu) val searchItem = menu.findItem(R.id.search) - (searchItem.actionView as? SearchView)?.let { + (searchItem.actionView as? SearchView)?.let { searchView -> searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { override fun onMenuItemActionExpand(p0: MenuItem?): Boolean { - it.isIconified = false - it.requestFocusFromTouch() + searchView.isIconified = false + searchView.requestFocusFromTouch() // we want to force the tool bar as visible even if hidden with scroll flags findViewById(R.id.toolbar)?.minimumHeight = getActionBarSize() return true @@ -150,10 +164,35 @@ class EmojiReactionPickerActivity : VectorBaseActivity(), EmojiCompatFontProvide override fun onMenuItemActionCollapse(p0: MenuItem?): Boolean { // when back, clear all search findViewById(R.id.toolbar)?.minimumHeight = 0 - it.setQuery("", true) + searchView.setQuery("", true) return true } }) + + val searchObservable = Observable.create { emitter -> + + searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener { + override fun onQueryTextSubmit(query: String?): Boolean { + query?.let { emitter.onNext(it) } + return true + } + + override fun onQueryTextChange(newText: String?): Boolean { + Timber.d("onQueryTextChange $newText") + newText?.let { emitter.onNext(it) } + return true + } + + }) + } + + searchObservable + .throttleWithTimeout(600, TimeUnit.MILLISECONDS) + .doOnError { err -> Timber.e(err) } + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { query -> + onQueryText(query) + } } return true @@ -171,6 +210,19 @@ class EmojiReactionPickerActivity : VectorBaseActivity(), EmojiCompatFontProvide } } + private fun onQueryText(query: String) { + if (query.isEmpty()) { + supportFragmentManager.findFragmentById(R.id.fragment)?.view?.isVisible = true + supportFragmentManager.findFragmentById(R.id.searchFragment)?.view?.isInvisible = true + tabLayout.isVisible = true + } else { + tabLayout.isVisible = false + supportFragmentManager.findFragmentById(R.id.fragment)?.view?.isInvisible = true + supportFragmentManager.findFragmentById(R.id.searchFragment)?.view?.isVisible = true + searchResultViewModel.updateQuery(query) + } + } + companion object { const val EXTRA_EVENT_ID = "EXTRA_EVENT_ID" diff --git a/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultController.kt b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultController.kt new file mode 100644 index 0000000000..41bcfe7be5 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultController.kt @@ -0,0 +1,77 @@ +/* + * 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.riotx.features.reactions + +import android.graphics.Typeface +import com.airbnb.epoxy.TypedEpoxyController +import im.vector.riotx.EmojiCompatFontProvider +import im.vector.riotx.R +import im.vector.riotx.core.resources.StringProvider +import im.vector.riotx.core.ui.list.genericFooterItem +import javax.inject.Inject + + +class EmojiSearchResultController @Inject constructor(val stringProvider: StringProvider, + fontProvider: EmojiCompatFontProvider) + : TypedEpoxyController() { + + + var emojiTypeface: Typeface? = fontProvider.typeface + + init { + fontProvider.addListener(object : EmojiCompatFontProvider.FontProviderListener { + override fun compatibilityFontUpdate(typeface: Typeface?) { + emojiTypeface = typeface + } + }) + } + + + var listener: ReactionClickListener? = null + + override fun buildModels(data: EmojiSearchResultViewState?) { + val results = data?.results ?: return + + if (results.isEmpty()) { + if (data.query.isEmpty()) { + //display 'Type something to find' + genericFooterItem { + id("type.query.item") + text(stringProvider.getString(R.string.reaction_search_type_hint)) + } + } else { + //Display no search Results + genericFooterItem { + id("no.results.item") + text(stringProvider.getString(R.string.no_result_placeholder)) + } + } + } else { + //Build the search results + results.forEach { + emojiSearchResultItem { + id(it.name) + emojiItem(it) + emojiTypeFace(emojiTypeface) + currentQuery(data.query) + onClickListener(listener) + } + } + + } + } + +} diff --git a/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultFragment.kt b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultFragment.kt new file mode 100644 index 0000000000..a4f443de1e --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultFragment.kt @@ -0,0 +1,69 @@ +/* + * 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.riotx.features.reactions + +import android.os.Bundle +import androidx.lifecycle.ViewModelProviders +import androidx.recyclerview.widget.DividerItemDecoration +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import com.airbnb.epoxy.EpoxyRecyclerView +import com.airbnb.mvrx.activityViewModel +import com.airbnb.mvrx.withState +import im.vector.riotx.R +import im.vector.riotx.core.di.ScreenComponent +import im.vector.riotx.core.platform.VectorBaseFragment +import im.vector.riotx.core.utils.LiveEvent +import javax.inject.Inject + +class EmojiSearchResultFragment : VectorBaseFragment() { + + override fun getLayoutResId(): Int = R.layout.fragment_generic_recycler_epoxy + + val viewModel: EmojiSearchResultViewModel by activityViewModel() + + var sharedViewModel: EmojiChooserViewModel? = null + + @Inject lateinit var epoxyController: EmojiSearchResultController + + override fun injectWith(injector: ScreenComponent) { + injector.inject(this) + } + + override fun onActivityCreated(savedInstanceState: Bundle?) { + super.onActivityCreated(savedInstanceState) + + sharedViewModel = ViewModelProviders.of(requireActivity(), viewModelFactory).get(EmojiChooserViewModel::class.java) + + epoxyController.listener = object : ReactionClickListener { + override fun onReactionSelected(reaction: String) { + sharedViewModel?.selectedReaction = reaction + sharedViewModel?.navigateEvent?.value = LiveEvent(EmojiChooserViewModel.NAVIGATE_FINISH) + } + } + + val lmgr = LinearLayoutManager(context, RecyclerView.VERTICAL, false) + val epoxyRecyclerView = view as? EpoxyRecyclerView ?: return + epoxyRecyclerView.layoutManager = lmgr + val dividerItemDecoration = DividerItemDecoration(epoxyRecyclerView.context, lmgr.orientation) + epoxyRecyclerView.addItemDecoration(dividerItemDecoration) + epoxyRecyclerView.setController(epoxyController) + } + + override fun invalidate() = withState(viewModel) { state -> + epoxyController.setData(state) + } +} diff --git a/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultItem.kt b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultItem.kt new file mode 100644 index 0000000000..4a9380d452 --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultItem.kt @@ -0,0 +1,60 @@ +/* + * 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.riotx.features.reactions + +import android.graphics.Typeface +import android.widget.TextView +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import com.airbnb.epoxy.EpoxyModelWithHolder +import im.vector.riotx.R +import im.vector.riotx.core.epoxy.VectorEpoxyHolder + + +@EpoxyModelClass(layout = R.layout.item_emoji_result) +abstract class EmojiSearchResultItem : EpoxyModelWithHolder() { + + @EpoxyAttribute + lateinit var emojiItem: EmojiDataSource.EmojiItem + + @EpoxyAttribute + var currentQuery: String? = null + + @EpoxyAttribute + var onClickListener: ReactionClickListener? = null + + @EpoxyAttribute + var emojiTypeFace: Typeface? = null + + override fun bind(holder: Holder) { + super.bind(holder) + //TODO use query string to highlight the matched query in name and keywords? + holder.emojiText.text = emojiItem.emojiString() + holder.emojiText.typeface = emojiTypeFace ?: Typeface.DEFAULT + holder.emojiNameText.text = emojiItem.name + holder.emojiKeywordText.text = emojiItem.keywords?.joinToString(", ") + holder.view.setOnClickListener { + onClickListener?.onReactionSelected(emojiItem.emojiString()) + } + } + + class Holder : VectorEpoxyHolder() { + val emojiText by bind(R.id.item_emoji_tv) + val emojiNameText by bind(R.id.item_emoji_name) + val emojiKeywordText by bind(R.id.item_emoji_keyword) + } +} + diff --git a/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultViewModel.kt b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultViewModel.kt new file mode 100644 index 0000000000..e0c9453e4c --- /dev/null +++ b/vector/src/main/java/im/vector/riotx/features/reactions/EmojiSearchResultViewModel.kt @@ -0,0 +1,54 @@ +/* + * 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.riotx.features.reactions + +import com.airbnb.mvrx.MvRxState +import com.airbnb.mvrx.MvRxViewModelFactory +import com.airbnb.mvrx.ViewModelContext +import im.vector.riotx.core.platform.VectorViewModel + +data class EmojiSearchResultViewState( + val query: String = "", + val results: List = emptyList() +) : MvRxState + +class EmojiSearchResultViewModel(val dataSource: EmojiDataSource, initialState: EmojiSearchResultViewState) + : VectorViewModel(initialState) { + + fun updateQuery(queryString: String) { + setState { + copy( + query = queryString, + results = dataSource.rawData?.emojis?.toList() + ?.map { it.second } + ?.filter { + it.name.contains(queryString, true) + || queryString.split("\\s".toRegex()).fold(true, { prev, q -> + prev && (it.keywords?.any { it.contains(q, true) } ?: false) + }) + } ?: emptyList() + ) + } + } + + companion object : MvRxViewModelFactory { + + override fun create(viewModelContext: ViewModelContext, state: EmojiSearchResultViewState): EmojiSearchResultViewModel? { + //TODO get the data source from activity? share it with other fragment + return EmojiSearchResultViewModel(EmojiDataSource(viewModelContext.activity), state) + } + } +} diff --git a/vector/src/main/res/layout/activity_emoji_reaction_picker.xml b/vector/src/main/res/layout/activity_emoji_reaction_picker.xml index ccaf149732..d56198d341 100644 --- a/vector/src/main/res/layout/activity_emoji_reaction_picker.xml +++ b/vector/src/main/res/layout/activity_emoji_reaction_picker.xml @@ -14,6 +14,14 @@ app:layout_behavior="@string/appbar_scrolling_view_behavior" tools:layout="@layout/emoji_chooser_fragment" /> + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/menu/menu_emoji_reaction_picker.xml b/vector/src/main/res/menu/menu_emoji_reaction_picker.xml index 98242f57bb..87135d64ea 100644 --- a/vector/src/main/res/menu/menu_emoji_reaction_picker.xml +++ b/vector/src/main/res/menu/menu_emoji_reaction_picker.xml @@ -4,6 +4,7 @@ diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 83ce65783f..eb1eaf1368 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -1528,6 +1528,7 @@ Why choose Riot.im? Add Reaction View Reactions Reactions + Type keywords to find a reaction. Event deleted by user Event moderated by room admin