Search reaction by name/keywords

This commit is contained in:
Valere 2019-10-28 14:36:15 +01:00
parent a04802b238
commit 199456487c
13 changed files with 385 additions and 10 deletions

View File

@ -5,7 +5,7 @@ Features ✨:
- -
Improvements 🙌: Improvements 🙌:
- - Search reaction by name or keyword in emoji picker
Other changes: 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.BugReporter
import im.vector.riotx.features.rageshake.RageShake import im.vector.riotx.features.rageshake.RageShake
import im.vector.riotx.features.reactions.EmojiReactionPickerActivity 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.reactions.widget.ReactionButton
import im.vector.riotx.features.roomdirectory.PublicRoomsFragment import im.vector.riotx.features.roomdirectory.PublicRoomsFragment
import im.vector.riotx.features.roomdirectory.RoomDirectoryActivity import im.vector.riotx.features.roomdirectory.RoomDirectoryActivity
@ -197,6 +198,8 @@ interface ScreenComponent {
fun inject(incomingShareActivity: IncomingShareActivity) fun inject(incomingShareActivity: IncomingShareActivity)
fun inject(emojiSearchResultFragment: EmojiSearchResultFragment)
@Component.Factory @Component.Factory
interface Factory { interface Factory {
fun create(vectorComponent: VectorComponent, fun create(vectorComponent: VectorComponent,

View File

@ -36,12 +36,11 @@ class EmojiChooserFragment : VectorBaseFragment() {
viewModel = activity?.run { viewModel = activity?.run {
ViewModelProviders.of(this, viewModelFactory).get(EmojiChooserViewModel::class.java) ViewModelProviders.of(this, viewModelFactory).get(EmojiChooserViewModel::class.java)
} ?: throw Exception("Invalid Activity") } ?: throw Exception("Invalid Activity")
viewModel.initWithContect(context!!) viewModel.initWithContext(context!!)
(view as? RecyclerView)?.let { (view as? RecyclerView)?.let {
it.adapter = viewModel.adapter it.adapter = viewModel.adapter
it.adapter?.notifyDataSetChanged() 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 // TODO load async
val emojiDataSource = EmojiDataSource(context) val emojiDataSource = EmojiDataSource(context)
emojiSourceLiveData.value = emojiDataSource emojiSourceLiveData.value = emojiDataSource

View File

@ -25,15 +25,22 @@ import android.view.MenuInflater
import android.view.MenuItem import android.view.MenuItem
import android.widget.SearchView import android.widget.SearchView
import androidx.appcompat.widget.Toolbar import androidx.appcompat.widget.Toolbar
import androidx.core.view.isInvisible
import androidx.core.view.isVisible
import androidx.lifecycle.Observer import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.ViewModelProviders
import com.airbnb.mvrx.viewModel
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import im.vector.riotx.EmojiCompatFontProvider import im.vector.riotx.EmojiCompatFontProvider
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.extensions.observeEvent import im.vector.riotx.core.extensions.observeEvent
import im.vector.riotx.core.platform.VectorBaseActivity 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 kotlinx.android.synthetic.main.activity_emoji_reaction_picker.*
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -41,9 +48,9 @@ import javax.inject.Inject
* TODO: Loading indicator while getting emoji data source? * TODO: Loading indicator while getting emoji data source?
* TODO: migrate to MvRx * TODO: migrate to MvRx
* TODO: Finish Refactor to vector base activity * 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 private lateinit var tabLayout: TabLayout
@ -57,6 +64,8 @@ class EmojiReactionPickerActivity : VectorBaseActivity(), EmojiCompatFontProvide
@Inject lateinit var emojiCompatFontProvider: EmojiCompatFontProvider @Inject lateinit var emojiCompatFontProvider: EmojiCompatFontProvider
val searchResultViewModel: EmojiSearchResultViewModel by viewModel()
private var tabLayoutSelectionListener = object : TabLayout.OnTabSelectedListener { private var tabLayoutSelectionListener = object : TabLayout.OnTabSelectedListener {
override fun onTabReselected(tab: TabLayout.Tab) { override fun onTabReselected(tab: TabLayout.Tab) {
} }
@ -121,10 +130,15 @@ class EmojiReactionPickerActivity : VectorBaseActivity(), EmojiCompatFontProvide
finish() 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?) { override fun compatibilityFontUpdate(typeface: Typeface?) {
EmojiDrawView.configureTextPaint(this, typeface) EmojiDrawView.configureTextPaint(this, typeface)
searchResultViewModel.dataSource
} }
override fun onDestroy() { override fun onDestroy() {
@ -137,11 +151,11 @@ class EmojiReactionPickerActivity : VectorBaseActivity(), EmojiCompatFontProvide
inflater.inflate(getMenuRes(), menu) inflater.inflate(getMenuRes(), menu)
val searchItem = menu.findItem(R.id.search) val searchItem = menu.findItem(R.id.search)
(searchItem.actionView as? SearchView)?.let { (searchItem.actionView as? SearchView)?.let { searchView ->
searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener { searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(p0: MenuItem?): Boolean { override fun onMenuItemActionExpand(p0: MenuItem?): Boolean {
it.isIconified = false searchView.isIconified = false
it.requestFocusFromTouch() searchView.requestFocusFromTouch()
// we want to force the tool bar as visible even if hidden with scroll flags // we want to force the tool bar as visible even if hidden with scroll flags
findViewById<Toolbar>(R.id.toolbar)?.minimumHeight = getActionBarSize() findViewById<Toolbar>(R.id.toolbar)?.minimumHeight = getActionBarSize()
return true return true
@ -150,10 +164,35 @@ class EmojiReactionPickerActivity : VectorBaseActivity(), EmojiCompatFontProvide
override fun onMenuItemActionCollapse(p0: MenuItem?): Boolean { override fun onMenuItemActionCollapse(p0: MenuItem?): Boolean {
// when back, clear all search // when back, clear all search
findViewById<Toolbar>(R.id.toolbar)?.minimumHeight = 0 findViewById<Toolbar>(R.id.toolbar)?.minimumHeight = 0
it.setQuery("", true) searchView.setQuery("", true)
return true return true
} }
}) })
val searchObservable = Observable.create<String> { 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 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 { companion object {
const val EXTRA_EVENT_ID = "EXTRA_EVENT_ID" const val EXTRA_EVENT_ID = "EXTRA_EVENT_ID"

View File

@ -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<EmojiSearchResultViewState>() {
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)
}
}
}
}
}

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,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<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" app:layout_behavior="@string/appbar_scrolling_view_behavior"
tools:layout="@layout/emoji_chooser_fragment" /> 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 <com.google.android.material.appbar.AppBarLayout
style="@style/VectorAppBarLayoutStyle" style="@style/VectorAppBarLayoutStyle"
android:layout_width="match_parent" 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 <item
android:id="@+id/search" android:id="@+id/search"
android:icon="@drawable/ic_search_white" android:icon="@drawable/ic_search_white"
app:iconTint="?riotx_text_primary"
android:title="@string/search" android:title="@string/search"
app:actionViewClass="android.widget.SearchView" app:actionViewClass="android.widget.SearchView"
app:showAsAction="collapseActionView|ifRoom" /> 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_add_reaction">Add Reaction</string>
<string name="message_view_reaction">View Reactions</string> <string name="message_view_reaction">View Reactions</string>
<string name="reactions">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_user_reason">Event deleted by user</string>
<string name="event_redacted_by_admin_reason">Event moderated by room admin</string> <string name="event_redacted_by_admin_reason">Event moderated by room admin</string>