Merge pull request #646 from vector-im/feature/search_reaction

Search reaction by name/keywords
This commit is contained in:
Benoit Marty 2019-11-04 15:51:24 +01:00 committed by GitHub
commit 84d6c8ec16
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 370 additions and 11 deletions

View File

@ -5,6 +5,7 @@ Features ✨:
-
Improvements 🙌:
- Search reaction by name or keyword in emoji picker
- Handle code tags (#567)
Other changes:

View File

@ -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,

View File

@ -36,12 +36,10 @@ 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!!)
}
}

View File

@ -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

View File

@ -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 com.jakewharton.rxbinding3.widget.queryTextChanges
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.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<Toolbar>(R.id.toolbar)?.minimumHeight = getActionBarSize()
return true
@ -150,12 +164,20 @@ class EmojiReactionPickerActivity : VectorBaseActivity(), EmojiCompatFontProvide
override fun onMenuItemActionCollapse(p0: MenuItem?): Boolean {
// when back, clear all search
findViewById<Toolbar>(R.id.toolbar)?.minimumHeight = 0
it.setQuery("", true)
searchView.setQuery("", true)
return true
}
})
}
searchView.queryTextChanges()
.throttleWithTimeout(600, TimeUnit.MILLISECONDS)
.doOnError { err -> Timber.e(err) }
.observeOn(AndroidSchedulers.mainThread())
.subscribe { query ->
onQueryText(query.toString())
}
.disposeOnDestroy()
}
return true
}
@ -171,6 +193,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"

View File

@ -0,0 +1,80 @@
/*
* 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 androidx.recyclerview.widget.RecyclerView
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,
private val fontProvider: EmojiCompatFontProvider)
: TypedEpoxyController<EmojiSearchResultViewState>() {
var emojiTypeface: Typeface? = fontProvider.typeface
private val fontProviderListener = object : EmojiCompatFontProvider.FontProviderListener {
override fun compatibilityFontUpdate(typeface: Typeface?) {
emojiTypeface = typeface
}
}
init {
fontProvider.addListener(fontProviderListener)
}
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)
}
}
}
}
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
super.onDetachedFromRecyclerView(recyclerView)
fontProvider.removeListener(fontProviderListener)
}
}

View File

@ -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)
}
}

View File

@ -0,0 +1,58 @@
/*
* 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<EmojiSearchResultItem.Holder>() {
@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<TextView>(R.id.item_emoji_tv)
val emojiNameText by bind<TextView>(R.id.item_emoji_name)
val emojiKeywordText by bind<TextView>(R.id.item_emoji_keyword)
}
}

View File

@ -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<EmojiDataSource.EmojiItem> = emptyList()
) : MvRxState
class EmojiSearchResultViewModel(val dataSource: EmojiDataSource, initialState: EmojiSearchResultViewState)
: VectorViewModel<EmojiSearchResultViewState>(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<EmojiSearchResultViewModel, EmojiSearchResultViewState> {
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)
}
}
}

View File

@ -14,6 +14,14 @@
app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:layout="@layout/emoji_chooser_fragment" />
<fragment
android:id="@+id/searchFragment"
android:name="im.vector.riotx.features.reactions.EmojiSearchResultFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layout_behavior="@string/appbar_scrolling_view_behavior"
android:visibility="invisible" />
<com.google.android.material.appbar.AppBarLayout
style="@style/VectorAppBarLayoutStyle"
android:layout_width="match_parent"

View File

@ -0,0 +1,51 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:paddingEnd="8dp"
android:paddingStart="8dp"
android:minHeight="44dp">
<!-- size in dp, because we do not want the display to be impacted by font size setting -->
<TextView
android:id="@+id/item_emoji_tv"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:layout_marginEnd="8dp"
android:textSize="25sp"
tools:ignore="SpUsage"
android:textColor="?android:textColorPrimary"
tools:text="@sample/reactions.json/data/reaction" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:orientation="vertical"
android:layout_weight="1">
<TextView
android:id="@+id/item_emoji_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textStyle="bold"
android:textSize="16sp"
android:textColor="?android:textColorPrimary"
tools:text="Smiley Face" />
<TextView
android:id="@+id/item_emoji_keyword"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:textSize="14sp"
android:maxLines="2"
android:textColor="?android:textColorPrimary"
tools:text="Smile, foo, bar" />
</LinearLayout>
</LinearLayout>

View File

@ -4,6 +4,7 @@
<item
android:id="@+id/search"
android:icon="@drawable/ic_search_white"
app:iconTint="?riotx_text_primary"
android:title="@string/search"
app:actionViewClass="android.widget.SearchView"
app:showAsAction="collapseActionView|ifRoom" />

View File

@ -1528,6 +1528,7 @@ Why choose Riot.im?
<string name="message_add_reaction">Add Reaction</string>
<string name="message_view_reaction">View Reactions</string>
<string name="reactions">Reactions</string>
<string name="reaction_search_type_hint">Type keywords to find a reaction.</string>
<string name="event_redacted_by_user_reason">Event deleted by user</string>
<string name="event_redacted_by_admin_reason">Event moderated by room admin</string>