mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-21 09:20:19 +01:00
Add Emoji Search, Sticker Search, and GIF Keyboard.
Co-authored-by: Alex Hart <alex@signal.org> Co-authored-by: Cody Henthorne <cody@signal.org> Co-authored-by: Greyson Parrelli<greyson@signal.org>
This commit is contained in:
committed by
Cody Henthorne
parent
66c3b1388a
commit
08e86b8c82
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.keyboard
|
||||
|
||||
enum class KeyboardPage {
|
||||
EMOJI,
|
||||
STICKER,
|
||||
GIF
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package org.thoughtcrime.securesms.keyboard
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
interface KeyboardPageCategoryIconMappingModel<T : KeyboardPageCategoryIconMappingModel<T>> : MappingModel<T> {
|
||||
val key: String
|
||||
val selected: Boolean
|
||||
|
||||
fun getIcon(context: Context): Drawable
|
||||
}
|
||||
|
||||
class KeyboardPageCategoryIconViewHolder<T : KeyboardPageCategoryIconMappingModel<T>>(itemView: View, private val onPageSelected: (String) -> Unit) : MappingViewHolder<T>(itemView) {
|
||||
|
||||
private val iconView: AppCompatImageView = itemView.findViewById(R.id.category_icon)
|
||||
private val iconSelected: View = itemView.findViewById(R.id.category_icon_selected)
|
||||
|
||||
override fun bind(model: T) {
|
||||
itemView.setOnClickListener {
|
||||
onPageSelected(model.key)
|
||||
}
|
||||
|
||||
iconView.setImageDrawable(model.getIcon(context))
|
||||
iconView.isSelected = model.selected
|
||||
iconSelected.isSelected = model.selected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
|
||||
package org.thoughtcrime.securesms.keyboard
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboard
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.keyboard.gif.GifKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.keyboard.sticker.StickerKeyboardPageFragment
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
class KeyboardPagerFragment : Fragment(R.layout.keyboard_pager_fragment) {
|
||||
|
||||
private lateinit var emojiButton: View
|
||||
private lateinit var stickerButton: View
|
||||
private lateinit var gifButton: View
|
||||
private lateinit var viewModel: KeyboardPagerViewModel
|
||||
|
||||
private val fragments: MutableMap<KClass<*>, Fragment> = mutableMapOf()
|
||||
private var currentFragment: Fragment? = null
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
emojiButton = view.findViewById(R.id.keyboard_pager_fragment_emoji)
|
||||
stickerButton = view.findViewById(R.id.keyboard_pager_fragment_sticker)
|
||||
gifButton = view.findViewById(R.id.keyboard_pager_fragment_gif)
|
||||
|
||||
viewModel = ViewModelProviders.of(requireActivity())[KeyboardPagerViewModel::class.java]
|
||||
|
||||
viewModel.page().observe(viewLifecycleOwner, this::onPageSelected)
|
||||
viewModel.pages().observe(viewLifecycleOwner) { pages ->
|
||||
emojiButton.visible = pages.contains(KeyboardPage.EMOJI) && pages.size > 1
|
||||
stickerButton.visible = pages.contains(KeyboardPage.STICKER) && pages.size > 1
|
||||
gifButton.visible = pages.contains(KeyboardPage.GIF) && pages.size > 1
|
||||
}
|
||||
|
||||
emojiButton.setOnClickListener { viewModel.switchToPage(KeyboardPage.EMOJI) }
|
||||
stickerButton.setOnClickListener { viewModel.switchToPage(KeyboardPage.STICKER) }
|
||||
gifButton.setOnClickListener { viewModel.switchToPage(KeyboardPage.GIF) }
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
viewModel.page().value?.let(this::onPageSelected)
|
||||
}
|
||||
|
||||
private fun onPageSelected(page: KeyboardPage) {
|
||||
emojiButton.isSelected = page == KeyboardPage.EMOJI
|
||||
stickerButton.isSelected = page == KeyboardPage.STICKER
|
||||
gifButton.isSelected = page == KeyboardPage.GIF
|
||||
|
||||
when (page) {
|
||||
KeyboardPage.EMOJI -> displayEmojiPage()
|
||||
KeyboardPage.GIF -> displayGifPage()
|
||||
KeyboardPage.STICKER -> displayStickerPage()
|
||||
}
|
||||
|
||||
findListener<MediaKeyboard.MediaKeyboardListener>()?.onKeyboardChanged(page)
|
||||
}
|
||||
|
||||
private fun displayEmojiPage() = displayPage(::EmojiKeyboardPageFragment)
|
||||
|
||||
private fun displayGifPage() = displayPage(::GifKeyboardPageFragment)
|
||||
|
||||
private fun displayStickerPage() = displayPage(::StickerKeyboardPageFragment)
|
||||
|
||||
private inline fun <reified F : Fragment> displayPage(fragmentFactory: () -> F) {
|
||||
if (currentFragment is F) {
|
||||
return
|
||||
}
|
||||
|
||||
val transaction = childFragmentManager.beginTransaction()
|
||||
|
||||
currentFragment?.let { transaction.hide(it) }
|
||||
|
||||
var fragment = fragments[F::class]
|
||||
if (fragment == null) {
|
||||
fragment = fragmentFactory()
|
||||
transaction.add(R.id.fragment_container, fragment)
|
||||
fragments[F::class] = fragment
|
||||
} else {
|
||||
transaction.show(fragment)
|
||||
}
|
||||
|
||||
currentFragment = fragment
|
||||
transaction.commitAllowingStateLoss()
|
||||
}
|
||||
|
||||
fun show() {
|
||||
if (isAdded && view != null) {
|
||||
viewModel.page().value?.let(this::onPageSelected)
|
||||
}
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
if (isAdded && view != null) {
|
||||
val transaction = childFragmentManager.beginTransaction()
|
||||
fragments.values.forEach { transaction.remove(it) }
|
||||
transaction.commitAllowingStateLoss()
|
||||
currentFragment = null
|
||||
fragments.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package org.thoughtcrime.securesms.keyboard
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
import org.thoughtcrime.securesms.stickers.StickerSearchRepository
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData
|
||||
|
||||
class KeyboardPagerViewModel : ViewModel() {
|
||||
|
||||
private val page: DefaultValueLiveData<KeyboardPage>
|
||||
private val pages: DefaultValueLiveData<Set<KeyboardPage>>
|
||||
|
||||
init {
|
||||
val startingPages: MutableSet<KeyboardPage> = KeyboardPage.values().toMutableSet()
|
||||
if (SignalStore.settings().isPreferSystemEmoji) {
|
||||
startingPages.remove(KeyboardPage.EMOJI)
|
||||
}
|
||||
pages = DefaultValueLiveData(startingPages)
|
||||
page = DefaultValueLiveData(startingPages.first())
|
||||
|
||||
StickerSearchRepository(ApplicationDependencies.getApplication()).getStickerFeatureAvailability { available ->
|
||||
if (!available) {
|
||||
val updatedPages = pages.value.toMutableSet().apply { remove(KeyboardPage.STICKER) }
|
||||
pages.postValue(updatedPages)
|
||||
if (page.value == KeyboardPage.STICKER) {
|
||||
switchToPage(KeyboardPage.GIF)
|
||||
switchToPage(KeyboardPage.EMOJI)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun page(): LiveData<KeyboardPage> = page
|
||||
fun pages(): LiveData<Set<KeyboardPage>> = pages
|
||||
|
||||
fun setOnlyPage(page: KeyboardPage) {
|
||||
pages.value = setOf(page)
|
||||
switchToPage(page)
|
||||
}
|
||||
|
||||
fun switchToPage(page: KeyboardPage) {
|
||||
if (this.pages.value.contains(page) && this.page.value != page) {
|
||||
this.page.value = page
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package org.thoughtcrime.securesms.keyboard
|
||||
|
||||
import androidx.fragment.app.Fragment
|
||||
|
||||
/**
|
||||
* Given an input type [T], find an instance of it first looking through all
|
||||
* parents, and then the activity.
|
||||
*
|
||||
* @return First instance found of type [T] or null
|
||||
*/
|
||||
inline fun <reified T> Fragment.findListener(): T? {
|
||||
var parent: Fragment? = parentFragment
|
||||
while (parent != null) {
|
||||
if (parent is T) {
|
||||
return parent
|
||||
}
|
||||
parent = parent.parentFragment
|
||||
}
|
||||
|
||||
return requireActivity() as? T
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import android.view.ViewGroup
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageView
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
class EmojiKeyboardPageAdapter(
|
||||
private val emojiSelectionListener: EmojiKeyboardProvider.EmojiEventListener,
|
||||
private val variationSelectorListener: EmojiPageViewGridAdapter.VariationSelectorListener,
|
||||
private val searchCallbacks: KeyboardPageSearchView.Callbacks
|
||||
) : MappingAdapter() {
|
||||
|
||||
init {
|
||||
registerFactory(EmojiPageMappingModel::class.java) { parent ->
|
||||
val pageView = EmojiPageView(parent.context, emojiSelectionListener, variationSelectorListener, true, searchCallbacks)
|
||||
|
||||
val layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
|
||||
pageView.layoutParams = layoutParams
|
||||
|
||||
ViewHolder(pageView)
|
||||
}
|
||||
}
|
||||
|
||||
private class ViewHolder(
|
||||
private val emojiPageView: EmojiPageView,
|
||||
) : MappingViewHolder<EmojiPageMappingModel>(emojiPageView) {
|
||||
|
||||
override fun bind(model: EmojiPageMappingModel) {
|
||||
emojiPageView.bindSearchableAdapter(model.emojiPageModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPageCategoryIconViewHolder
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
|
||||
class EmojiKeyboardPageCategoriesAdapter(private val onPageSelected: (String) -> Unit) : MappingAdapter() {
|
||||
init {
|
||||
registerFactory(EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel::class.java, LayoutFactory({ v -> KeyboardPageCategoryIconViewHolder<EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel>(v, onPageSelected) }, R.layout.keyboard_pager_category_icon))
|
||||
registerFactory(EmojiKeyboardPageCategoryMappingModel.EmojiCategoryMappingModel::class.java, LayoutFactory({ v -> KeyboardPageCategoryIconViewHolder<EmojiKeyboardPageCategoryMappingModel.EmojiCategoryMappingModel>(v, onPageSelected) }, R.layout.keyboard_pager_category_icon))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.annotation.AttrRes
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.emoji.EmojiCategory
|
||||
import org.thoughtcrime.securesms.keyboard.KeyboardPageCategoryIconMappingModel
|
||||
import org.thoughtcrime.securesms.util.ThemeUtil
|
||||
|
||||
sealed class EmojiKeyboardPageCategoryMappingModel(
|
||||
override val key: String,
|
||||
@AttrRes val iconId: Int,
|
||||
override val selected: Boolean
|
||||
) : KeyboardPageCategoryIconMappingModel<EmojiKeyboardPageCategoryMappingModel> {
|
||||
|
||||
override fun getIcon(context: Context): Drawable {
|
||||
return requireNotNull(ThemeUtil.getThemedDrawable(context, iconId))
|
||||
}
|
||||
|
||||
override fun areItemsTheSame(newItem: EmojiKeyboardPageCategoryMappingModel): Boolean {
|
||||
return newItem.key == key
|
||||
}
|
||||
|
||||
class RecentsMappingModel(selected: Boolean) : EmojiKeyboardPageCategoryMappingModel(KEY, R.attr.emoji_category_recent, selected) {
|
||||
override fun areContentsTheSame(newItem: EmojiKeyboardPageCategoryMappingModel): Boolean {
|
||||
return newItem is RecentsMappingModel && super.areContentsTheSame(newItem)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val KEY = "Recents"
|
||||
}
|
||||
}
|
||||
|
||||
class EmojiCategoryMappingModel(private val emojiCategory: EmojiCategory, selected: Boolean) : EmojiKeyboardPageCategoryMappingModel(emojiCategory.key, emojiCategory.icon, selected) {
|
||||
override fun areContentsTheSame(newItem: EmojiKeyboardPageCategoryMappingModel): Boolean {
|
||||
return newItem is EmojiCategoryMappingModel &&
|
||||
super.areContentsTheSame(newItem) &&
|
||||
newItem.emojiCategory == emojiCategory
|
||||
}
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: EmojiKeyboardPageCategoryMappingModel): Boolean {
|
||||
return areItemsTheSame(newItem) && selected == newItem.selected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.KeyEvent
|
||||
import android.view.View
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager2.widget.ViewPager2
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter
|
||||
import org.thoughtcrime.securesms.keyboard.findListener
|
||||
import org.thoughtcrime.securesms.keyvalue.SignalStore
|
||||
|
||||
private val DELETE_KEY_EVENT: KeyEvent = KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_DEL)
|
||||
|
||||
class EmojiKeyboardPageFragment : Fragment(R.layout.keyboard_pager_emoji_page_fragment), EmojiKeyboardProvider.EmojiEventListener, EmojiPageViewGridAdapter.VariationSelectorListener {
|
||||
|
||||
private lateinit var viewModel: EmojiKeyboardPageViewModel
|
||||
private lateinit var emojiPager: ViewPager2
|
||||
private lateinit var searchView: View
|
||||
private lateinit var emojiCategoriesRecycler: RecyclerView
|
||||
private lateinit var backspaceView: View
|
||||
private lateinit var eventListener: EmojiKeyboardProvider.EmojiEventListener
|
||||
private lateinit var callback: Callback
|
||||
private lateinit var pagesAdapter: EmojiKeyboardPageAdapter
|
||||
private lateinit var categoriesAdapter: EmojiKeyboardPageCategoriesAdapter
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
|
||||
callback = context as Callback
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
emojiPager = view.findViewById(R.id.emoji_pager)
|
||||
searchView = view.findViewById(R.id.emoji_search)
|
||||
emojiCategoriesRecycler = view.findViewById(R.id.emoji_categories_recycler)
|
||||
backspaceView = view.findViewById(R.id.emoji_backspace)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(EmojiKeyboardPageViewModel::class.java)
|
||||
|
||||
pagesAdapter = EmojiKeyboardPageAdapter(this, this, EmojiKeyboardPageSearchViewCallbacks())
|
||||
|
||||
categoriesAdapter = EmojiKeyboardPageCategoriesAdapter { key ->
|
||||
viewModel.onKeySelected(key)
|
||||
|
||||
val page = pagesAdapter.currentList.indexOfFirst {
|
||||
(it as EmojiPageMappingModel).key == key
|
||||
}
|
||||
|
||||
if (emojiPager.currentItem != page) {
|
||||
emojiPager.currentItem = page
|
||||
}
|
||||
}
|
||||
|
||||
emojiPager.adapter = pagesAdapter
|
||||
emojiCategoriesRecycler.adapter = categoriesAdapter
|
||||
|
||||
searchView.setOnClickListener {
|
||||
callback.openEmojiSearch()
|
||||
}
|
||||
|
||||
backspaceView.setOnClickListener { eventListener.onKeyEvent(DELETE_KEY_EVENT) }
|
||||
|
||||
viewModel.categories.observe(viewLifecycleOwner) { categories ->
|
||||
categoriesAdapter.submitList(categories)
|
||||
}
|
||||
|
||||
viewModel.pages.observe(viewLifecycleOwner) { pages ->
|
||||
val registerPageCallback: Boolean = pagesAdapter.currentList.isEmpty() && pages.isNotEmpty()
|
||||
pagesAdapter.submitList(pages) { updatePagerPosition(registerPageCallback) }
|
||||
}
|
||||
|
||||
viewModel.selectedKey.observe(viewLifecycleOwner) { updateCategoryTab() }
|
||||
|
||||
eventListener = findListener() ?: throw AssertionError("No emoji listener found")
|
||||
}
|
||||
|
||||
private fun updateCategoryTab() {
|
||||
emojiCategoriesRecycler.post {
|
||||
val index: Int = categoriesAdapter.currentList.indexOfFirst { (it as? EmojiKeyboardPageCategoryMappingModel)?.key == viewModel.selectedKey.value }
|
||||
|
||||
if (index != -1) {
|
||||
emojiCategoriesRecycler.smoothScrollToPosition(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePagerPosition(registerPageCallback: Boolean) {
|
||||
val page = pagesAdapter.currentList.indexOfFirst {
|
||||
(it as EmojiPageMappingModel).key == viewModel.selectedKey.value
|
||||
}
|
||||
|
||||
if (emojiPager.currentItem != page && page != -1) {
|
||||
emojiPager.setCurrentItem(page, false)
|
||||
}
|
||||
|
||||
if (registerPageCallback) {
|
||||
emojiPager.registerOnPageChangeCallback(PageChanged(pagesAdapter))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onEmojiSelected(emoji: String) {
|
||||
SignalStore.emojiValues().setPreferredVariation(emoji)
|
||||
eventListener.onEmojiSelected(emoji)
|
||||
viewModel.addToRecents(emoji)
|
||||
}
|
||||
|
||||
override fun onKeyEvent(keyEvent: KeyEvent?) {
|
||||
eventListener.onKeyEvent(keyEvent)
|
||||
}
|
||||
|
||||
override fun onVariationSelectorStateChanged(open: Boolean) {
|
||||
emojiPager.isUserInputEnabled = !open
|
||||
}
|
||||
|
||||
private inner class PageChanged(private val adapter: EmojiKeyboardPageAdapter) : ViewPager2.OnPageChangeCallback() {
|
||||
override fun onPageSelected(position: Int) {
|
||||
val mappingModel: EmojiPageMappingModel = adapter.currentList[position] as EmojiPageMappingModel
|
||||
viewModel.onKeySelected(mappingModel.key)
|
||||
}
|
||||
}
|
||||
|
||||
private inner class EmojiKeyboardPageSearchViewCallbacks : KeyboardPageSearchView.Callbacks {
|
||||
override fun onClicked() {
|
||||
callback.openEmojiSearch()
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun openEmojiSearch()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.Transformations
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider
|
||||
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
|
||||
import org.thoughtcrime.securesms.dependencies.ApplicationDependencies
|
||||
import org.thoughtcrime.securesms.emoji.EmojiCategory
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource
|
||||
import org.thoughtcrime.securesms.util.DefaultValueLiveData
|
||||
import org.thoughtcrime.securesms.util.MappingModelList
|
||||
|
||||
class EmojiKeyboardPageViewModel : ViewModel() {
|
||||
|
||||
private val internalSelectedKey = DefaultValueLiveData<String>(getStartingTab())
|
||||
|
||||
val selectedKey: LiveData<String>
|
||||
get() = internalSelectedKey
|
||||
|
||||
val categories: LiveData<MappingModelList> = Transformations.map(internalSelectedKey) { selected ->
|
||||
MappingModelList().apply {
|
||||
add(EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel(selected == EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel.KEY))
|
||||
|
||||
EmojiCategory.values().forEach {
|
||||
add(EmojiKeyboardPageCategoryMappingModel.EmojiCategoryMappingModel(it, it.key == selected))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val pages: LiveData<MappingModelList> = Transformations.map(categories) { categories ->
|
||||
MappingModelList().apply {
|
||||
categories.forEach {
|
||||
add(getPageForCategory(it as EmojiKeyboardPageCategoryMappingModel))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onKeySelected(key: String) {
|
||||
internalSelectedKey.value = key
|
||||
}
|
||||
|
||||
private fun getPageForCategory(mappingModel: EmojiKeyboardPageCategoryMappingModel): EmojiPageMappingModel {
|
||||
val page = if (mappingModel.key == EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel.KEY) {
|
||||
RecentEmojiPageModel(ApplicationDependencies.getApplication(), EmojiKeyboardProvider.RECENT_STORAGE_KEY)
|
||||
} else {
|
||||
EmojiSource.latest.displayPages.first { it.iconAttr == mappingModel.iconId }
|
||||
}
|
||||
|
||||
return EmojiPageMappingModel(mappingModel.key, page)
|
||||
}
|
||||
|
||||
fun addToRecents(emoji: String) {
|
||||
RecentEmojiPageModel(ApplicationDependencies.getApplication(), EmojiKeyboardProvider.RECENT_STORAGE_KEY).onCodePointSelected(emoji)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun getStartingTab(): String {
|
||||
return if (RecentEmojiPageModel.hasRecents(ApplicationDependencies.getApplication(), EmojiKeyboardProvider.RECENT_STORAGE_KEY)) {
|
||||
EmojiKeyboardPageCategoryMappingModel.RecentsMappingModel.KEY
|
||||
} else {
|
||||
EmojiCategory.PEOPLE.key
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
|
||||
class EmojiPageMappingModel(val key: String, val emojiPageModel: EmojiPageModel) : MappingModel<EmojiPageMappingModel> {
|
||||
override fun areItemsTheSame(newItem: EmojiPageMappingModel): Boolean {
|
||||
return key == newItem.key
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: EmojiPageMappingModel): Boolean {
|
||||
return areItemsTheSame(newItem) &&
|
||||
newItem.emojiPageModel.spriteUri == emojiPageModel.spriteUri &&
|
||||
newItem.emojiPageModel.iconAttr == emojiPageModel.iconAttr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji
|
||||
|
||||
import android.animation.Animator
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.drawable.ColorDrawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import android.widget.EditText
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.res.use
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.widget.ImageViewCompat
|
||||
import androidx.core.widget.addTextChangedListener
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.animation.AnimationCompleteListener
|
||||
import org.thoughtcrime.securesms.animation.ResizeAnimation
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
import org.thoughtcrime.securesms.util.visible
|
||||
|
||||
private const val REVEAL_DURATION = 250L
|
||||
|
||||
/**
|
||||
* Search bar to be used in the various keyboard views (emoji, sticker, gif)
|
||||
*/
|
||||
class KeyboardPageSearchView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : ConstraintLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
var callbacks: Callbacks? = null
|
||||
|
||||
private var state: State = State.HIDE_REQUESTED
|
||||
private var targetInputWidth: Int = -1
|
||||
|
||||
private val navButton: AppCompatImageView
|
||||
private val clearButton: AppCompatImageView
|
||||
private val input: EditText
|
||||
|
||||
init {
|
||||
inflate(context, R.layout.keyboard_pager_search_bar, this)
|
||||
|
||||
navButton = findViewById(R.id.emoji_search_nav_icon)
|
||||
clearButton = findViewById(R.id.emoji_search_clear_icon)
|
||||
input = findViewById(R.id.emoji_search_entry)
|
||||
|
||||
input.addTextChangedListener {
|
||||
if (it.isNullOrEmpty()) {
|
||||
clearButton.setImageDrawable(null)
|
||||
clearButton.isClickable = false
|
||||
} else {
|
||||
clearButton.setImageResource(R.drawable.ic_x)
|
||||
clearButton.isClickable = true
|
||||
}
|
||||
|
||||
if (it.isNullOrEmpty()) {
|
||||
callbacks?.onQueryChanged("")
|
||||
} else {
|
||||
callbacks?.onQueryChanged(it.toString())
|
||||
}
|
||||
}
|
||||
|
||||
input.setOnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) {
|
||||
callbacks?.onFocusGained()
|
||||
} else {
|
||||
callbacks?.onFocusLost()
|
||||
}
|
||||
}
|
||||
|
||||
clearButton.setOnClickListener {
|
||||
input.text.clear()
|
||||
}
|
||||
|
||||
context.obtainStyledAttributes(attrs, R.styleable.KeyboardPageSearchView, 0, 0).use { typedArray ->
|
||||
val showAlways: Boolean = typedArray.getBoolean(R.styleable.KeyboardPageSearchView_show_always, false)
|
||||
if (showAlways) {
|
||||
alpha = 1f
|
||||
state = State.SHOW_REQUESTED
|
||||
} else {
|
||||
alpha = 0f
|
||||
input.layoutParams = input.layoutParams.apply { width = 1 }
|
||||
state = State.HIDE_REQUESTED
|
||||
}
|
||||
|
||||
input.hint = typedArray.getString(R.styleable.KeyboardPageSearchView_search_hint) ?: ""
|
||||
|
||||
val backgroundTint = typedArray.getColor(R.styleable.KeyboardPageSearchView_search_bar_tint, ContextCompat.getColor(context, R.color.signal_background_primary))
|
||||
val backgroundTintList = ColorStateList.valueOf(backgroundTint)
|
||||
input.background = ColorDrawable(backgroundTint)
|
||||
ViewCompat.setBackgroundTintList(findViewById(R.id.emoji_search_nav), backgroundTintList)
|
||||
ViewCompat.setBackgroundTintList(findViewById(R.id.emoji_search_clear), backgroundTintList)
|
||||
|
||||
val iconTint = typedArray.getColorStateList(R.styleable.KeyboardPageSearchView_search_icon_tint) ?: ContextCompat.getColorStateList(context, R.color.signal_icon_tint_primary)
|
||||
ImageViewCompat.setImageTintList(navButton, iconTint)
|
||||
ImageViewCompat.setImageTintList(clearButton, iconTint)
|
||||
|
||||
val clickOnly: Boolean = typedArray.getBoolean(R.styleable.KeyboardPageSearchView_click_only, false)
|
||||
if (clickOnly) {
|
||||
val clickIntercept: View = findViewById(R.id.keyboard_search_click_only)
|
||||
clickIntercept.visible = true
|
||||
clickIntercept.setOnClickListener { callbacks?.onClicked() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun showRequested(): Boolean = state == State.SHOW_REQUESTED
|
||||
|
||||
fun enableBackNavigation() {
|
||||
navButton.setImageResource(R.drawable.ic_arrow_left_24)
|
||||
navButton.setOnClickListener {
|
||||
callbacks?.onNavigationClicked()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||
targetInputWidth = w - ViewUtil.dpToPx(32) - ViewUtil.dpToPx(90)
|
||||
}
|
||||
|
||||
fun show() {
|
||||
if (state == State.SHOW_REQUESTED) {
|
||||
return
|
||||
}
|
||||
|
||||
visibility = VISIBLE
|
||||
state = State.SHOW_REQUESTED
|
||||
|
||||
post {
|
||||
animate()
|
||||
.setDuration(REVEAL_DURATION)
|
||||
.alpha(1f)
|
||||
.setListener(null)
|
||||
|
||||
val resizeAnimation = ResizeAnimation(input, targetInputWidth, input.measuredHeight)
|
||||
resizeAnimation.duration = REVEAL_DURATION
|
||||
input.startAnimation(resizeAnimation)
|
||||
}
|
||||
}
|
||||
|
||||
fun hide() {
|
||||
if (state == State.HIDE_REQUESTED) {
|
||||
return
|
||||
}
|
||||
|
||||
state = State.HIDE_REQUESTED
|
||||
|
||||
post {
|
||||
animate()
|
||||
.setDuration(REVEAL_DURATION)
|
||||
.alpha(0f)
|
||||
.setListener(object : AnimationCompleteListener() {
|
||||
override fun onAnimationEnd(animation: Animator?) {
|
||||
visibility = INVISIBLE
|
||||
}
|
||||
})
|
||||
|
||||
val resizeAnimation = ResizeAnimation(input, 1, input.measuredHeight)
|
||||
resizeAnimation.duration = REVEAL_DURATION
|
||||
input.startAnimation(resizeAnimation)
|
||||
}
|
||||
}
|
||||
|
||||
fun presentForEmojiSearch() {
|
||||
ViewUtil.focusAndShowKeyboard(input)
|
||||
enableBackNavigation()
|
||||
}
|
||||
|
||||
interface Callbacks {
|
||||
fun onFocusLost() = Unit
|
||||
fun onFocusGained() = Unit
|
||||
fun onNavigationClicked() = Unit
|
||||
fun onQueryChanged(query: String) = Unit
|
||||
fun onClicked() = Unit
|
||||
}
|
||||
|
||||
enum class State {
|
||||
SHOW_REQUESTED,
|
||||
HIDE_REQUESTED
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji.search
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageView
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageViewGridAdapter
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
|
||||
import org.thoughtcrime.securesms.keyboard.findListener
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
class EmojiSearchFragment : Fragment(R.layout.emoji_search_fragment), EmojiPageViewGridAdapter.VariationSelectorListener {
|
||||
|
||||
private lateinit var viewModel: EmojiSearchViewModel
|
||||
private lateinit var callback: Callback
|
||||
|
||||
override fun onAttach(context: Context) {
|
||||
super.onAttach(context)
|
||||
|
||||
callback = context as Callback
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
val repository = EmojiSearchRepository(requireContext())
|
||||
val factory = EmojiSearchViewModel.Factory(repository)
|
||||
|
||||
viewModel = ViewModelProviders.of(this, factory)[EmojiSearchViewModel::class.java]
|
||||
|
||||
val eventListener: EmojiKeyboardProvider.EmojiEventListener = requireNotNull(findListener())
|
||||
val searchBar: KeyboardPageSearchView = view.findViewById(R.id.emoji_search_view)
|
||||
val resultsContainer: FrameLayout = view.findViewById(R.id.emoji_search_results_container)
|
||||
val noResults: TextView = view.findViewById(R.id.emoji_search_empty)
|
||||
val emojiPageView = EmojiPageView(requireContext(), eventListener, this, true, null, LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false), R.layout.emoji_search_result_display_item)
|
||||
|
||||
resultsContainer.addView(emojiPageView)
|
||||
|
||||
searchBar.presentForEmojiSearch()
|
||||
searchBar.callbacks = SearchCallbacks()
|
||||
|
||||
viewModel.pageModel.observe(viewLifecycleOwner) { pageModel ->
|
||||
emojiPageView.setModel(pageModel)
|
||||
|
||||
if (pageModel.emoji.isNotEmpty() || pageModel.iconAttr == R.attr.emoji_category_recent) {
|
||||
emojiPageView.visibility = View.VISIBLE
|
||||
noResults.visibility = View.GONE
|
||||
} else {
|
||||
emojiPageView.visibility = View.INVISIBLE
|
||||
noResults.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class SearchCallbacks : KeyboardPageSearchView.Callbacks {
|
||||
override fun onNavigationClicked() {
|
||||
ViewUtil.hideKeyboard(requireContext(), requireView())
|
||||
callback.closeEmojiSearch()
|
||||
}
|
||||
|
||||
override fun onQueryChanged(query: String) {
|
||||
viewModel.onQueryChanged(query)
|
||||
}
|
||||
}
|
||||
|
||||
interface Callback {
|
||||
fun closeEmojiSearch()
|
||||
}
|
||||
|
||||
override fun onVariationSelectorStateChanged(open: Boolean) = Unit
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji.search
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import org.signal.core.util.concurrent.SignalExecutors
|
||||
import org.thoughtcrime.securesms.components.emoji.Emoji
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiKeyboardProvider
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
|
||||
import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase
|
||||
import org.thoughtcrime.securesms.emoji.EmojiSource
|
||||
|
||||
private const val MINIMUM_QUERY_THRESHOLD = 1
|
||||
private const val EMOJI_SEARCH_LIMIT = 20
|
||||
|
||||
class EmojiSearchRepository(private val context: Context) {
|
||||
|
||||
private val emojiSearchDatabase: EmojiSearchDatabase = DatabaseFactory.getEmojiSearchDatabase(context)
|
||||
|
||||
fun submitQuery(query: String, consumer: (EmojiPageModel) -> Unit) {
|
||||
if (query.length < MINIMUM_QUERY_THRESHOLD) {
|
||||
consumer(RecentEmojiPageModel(context, EmojiKeyboardProvider.RECENT_STORAGE_KEY))
|
||||
} else {
|
||||
SignalExecutors.SERIAL.execute {
|
||||
val emoji: List<String> = emojiSearchDatabase.query(query, EMOJI_SEARCH_LIMIT)
|
||||
|
||||
val variationMap: Map<String, String> = EmojiSource.latest.variationMap
|
||||
val emojiVariationSets: MutableMap<String, LinkedHashSet<String>> = mutableMapOf()
|
||||
|
||||
variationMap
|
||||
.filterKeys { emoji.contains(it) }
|
||||
.forEach { (variation, canonical) ->
|
||||
val set: LinkedHashSet<String> = emojiVariationSets.getOrDefault(canonical, linkedSetOf())
|
||||
|
||||
set.add(variation)
|
||||
emojiVariationSets[canonical] = set
|
||||
}
|
||||
|
||||
val displayEmoji: List<Emoji> = emoji.map { canonical ->
|
||||
val variationSet: LinkedHashSet<String> = linkedSetOf(canonical).apply {
|
||||
addAll(emojiVariationSets.getOrDefault(canonical, linkedSetOf()))
|
||||
}
|
||||
|
||||
Emoji(variationSet.toList())
|
||||
}
|
||||
|
||||
consumer(EmojiSearchResultsPageModel(emoji, displayEmoji))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class EmojiSearchResultsPageModel(
|
||||
private val emoji: List<String>,
|
||||
private val displayEmoji: List<Emoji>
|
||||
) : EmojiPageModel {
|
||||
override fun getIconAttr(): Int = -1
|
||||
|
||||
override fun getEmoji(): List<String> = emoji
|
||||
|
||||
override fun getDisplayEmoji(): List<Emoji> = displayEmoji
|
||||
|
||||
override fun getSpriteUri(): Uri? = null
|
||||
|
||||
override fun isDynamic(): Boolean = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.thoughtcrime.securesms.keyboard.emoji.search
|
||||
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiPageModel
|
||||
|
||||
class EmojiSearchViewModel(private val repository: EmojiSearchRepository) : ViewModel() {
|
||||
|
||||
private val internalPageModel = MutableLiveData<EmojiPageModel>()
|
||||
|
||||
val pageModel: LiveData<EmojiPageModel> = internalPageModel
|
||||
|
||||
init {
|
||||
onQueryChanged("")
|
||||
}
|
||||
|
||||
fun onQueryChanged(query: String) {
|
||||
repository.submitQuery(query, internalPageModel::postValue)
|
||||
}
|
||||
|
||||
class Factory(private val repository: EmojiSearchRepository) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(EmojiSearchViewModel(repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package org.thoughtcrime.securesms.keyboard.gif
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.conversation.ConversationActivity
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4Fragment
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4SaveResult
|
||||
import org.thoughtcrime.securesms.giph.mp4.GiphyMp4ViewModel
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
|
||||
import org.thoughtcrime.securesms.keyboard.findListener
|
||||
import org.thoughtcrime.securesms.mms.AttachmentManager
|
||||
import org.thoughtcrime.securesms.util.views.SimpleProgressDialog
|
||||
|
||||
class GifKeyboardPageFragment : LoggingFragment(R.layout.gif_keyboard_page_fragment) {
|
||||
|
||||
private lateinit var host: Host
|
||||
private lateinit var quickSearchAdapter: GifQuickSearchAdapter
|
||||
private lateinit var giphyMp4ViewModel: GiphyMp4ViewModel
|
||||
|
||||
private lateinit var viewModel: GifKeyboardPageViewModel
|
||||
|
||||
private var progressDialog: AlertDialog? = null
|
||||
private lateinit var quickSearchList: RecyclerView
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
host = findListener<Host>() ?: throw AssertionError("Parent fragment or activity must implement Host")
|
||||
|
||||
childFragmentManager.beginTransaction()
|
||||
.replace(R.id.gif_keyboard_giphy_frame, GiphyMp4Fragment.create(host.isMms()))
|
||||
.commitAllowingStateLoss()
|
||||
|
||||
val searchKeyboard: KeyboardPageSearchView = view.findViewById(R.id.gif_keyboard_search_text)
|
||||
searchKeyboard.callbacks = object : KeyboardPageSearchView.Callbacks {
|
||||
override fun onClicked() {
|
||||
openGifSearch()
|
||||
}
|
||||
}
|
||||
|
||||
view.findViewById<View>(R.id.gif_keyboard_search).setOnClickListener { openGifSearch() }
|
||||
|
||||
quickSearchList = view.findViewById(R.id.gif_keyboard_quick_search_recycler)
|
||||
quickSearchAdapter = GifQuickSearchAdapter(this::onQuickSearchSelected)
|
||||
quickSearchList.adapter = quickSearchAdapter
|
||||
|
||||
giphyMp4ViewModel = ViewModelProviders.of(requireActivity(), GiphyMp4ViewModel.Factory(host.isMms())).get(GiphyMp4ViewModel::class.java)
|
||||
giphyMp4ViewModel.saveResultEvents.observe(viewLifecycleOwner, this::handleGiphyMp4SaveResult)
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(GifKeyboardPageViewModel::class.java)
|
||||
updateQuickSearchTabs()
|
||||
}
|
||||
|
||||
private fun onQuickSearchSelected(gifQuickSearchOption: GifQuickSearchOption) {
|
||||
if (viewModel.selectedTab == gifQuickSearchOption) {
|
||||
return
|
||||
}
|
||||
|
||||
viewModel.selectedTab = gifQuickSearchOption
|
||||
giphyMp4ViewModel.updateSearchQuery(gifQuickSearchOption.query)
|
||||
|
||||
updateQuickSearchTabs()
|
||||
}
|
||||
|
||||
private fun updateQuickSearchTabs() {
|
||||
val quickSearches: List<GifQuickSearch> = GifQuickSearchOption.ranked
|
||||
.map { search -> GifQuickSearch(search, search == viewModel.selectedTab) }
|
||||
|
||||
quickSearchAdapter.submitList(quickSearches, this::scrollToTab)
|
||||
}
|
||||
|
||||
private fun scrollToTab() {
|
||||
quickSearchList.post { quickSearchList.smoothScrollToPosition(GifQuickSearchOption.ranked.indexOf(viewModel.selectedTab)) }
|
||||
}
|
||||
|
||||
private fun handleGiphyMp4SaveResult(result: GiphyMp4SaveResult) {
|
||||
if (result is GiphyMp4SaveResult.Success) {
|
||||
hideProgressDialog()
|
||||
handleGiphyMp4SuccessfulResult(result)
|
||||
} else if (result is GiphyMp4SaveResult.Error) {
|
||||
hideProgressDialog()
|
||||
handleGiphyMp4ErrorResult()
|
||||
} else {
|
||||
progressDialog = SimpleProgressDialog.show(requireContext())
|
||||
}
|
||||
}
|
||||
|
||||
private fun hideProgressDialog() {
|
||||
progressDialog?.dismiss()
|
||||
}
|
||||
|
||||
private fun handleGiphyMp4SuccessfulResult(success: GiphyMp4SaveResult.Success) {
|
||||
host.onGifSelectSuccess(success.blobUri, success.width, success.height)
|
||||
}
|
||||
|
||||
private fun handleGiphyMp4ErrorResult() {
|
||||
Toast.makeText(requireContext(), R.string.GiphyActivity_error_while_retrieving_full_resolution_gif, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
|
||||
private fun openGifSearch() {
|
||||
AttachmentManager.selectGif(requireActivity(), ConversationActivity.PICK_GIF, host.isMms())
|
||||
}
|
||||
|
||||
interface Host {
|
||||
fun isMms(): Boolean
|
||||
fun onGifSelectSuccess(blobUri: Uri, width: Int, height: Int)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.keyboard.gif
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class GifKeyboardPageViewModel : ViewModel() {
|
||||
var selectedTab: GifQuickSearchOption = GifQuickSearchOption.TRENDING
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.thoughtcrime.securesms.keyboard.gif
|
||||
|
||||
import org.thoughtcrime.securesms.util.MappingModel
|
||||
|
||||
data class GifQuickSearch(val gifQuickSearchOption: GifQuickSearchOption, val selected: Boolean) : MappingModel<GifQuickSearch> {
|
||||
override fun areItemsTheSame(newItem: GifQuickSearch): Boolean {
|
||||
return gifQuickSearchOption == newItem.gifQuickSearchOption
|
||||
}
|
||||
|
||||
override fun areContentsTheSame(newItem: GifQuickSearch): Boolean {
|
||||
return selected == newItem.selected
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.thoughtcrime.securesms.keyboard.gif
|
||||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.util.MappingAdapter
|
||||
import org.thoughtcrime.securesms.util.MappingViewHolder
|
||||
|
||||
class GifQuickSearchAdapter(clickListener: (GifQuickSearchOption) -> Unit) : MappingAdapter() {
|
||||
init {
|
||||
registerFactory(GifQuickSearch::class.java, LayoutFactory({ v -> ViewHolder(v, clickListener) }, R.layout.keyboard_pager_category_icon))
|
||||
}
|
||||
|
||||
private class ViewHolder(itemView: View, private val listener: (GifQuickSearchOption) -> Unit) : MappingViewHolder<GifQuickSearch>(itemView) {
|
||||
private val image: ImageView = findViewById(R.id.category_icon)
|
||||
private val imageSelected: View = findViewById(R.id.category_icon_selected)
|
||||
|
||||
override fun bind(model: GifQuickSearch) {
|
||||
image.setImageResource(model.gifQuickSearchOption.image)
|
||||
image.isSelected = model.selected
|
||||
imageSelected.isSelected = model.selected
|
||||
itemView.setOnClickListener { listener(model.gifQuickSearchOption) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package org.thoughtcrime.securesms.keyboard.gif
|
||||
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
enum class GifQuickSearchOption(private val rank: Int, val image: Int, val query: String) {
|
||||
TRENDING(0, R.drawable.ic_gif_trending_24, ""),
|
||||
CELEBRATE(1, R.drawable.ic_gif_celebrate_24, "celebrate"),
|
||||
LOVE(2, R.drawable.ic_gif_love_24, "love"),
|
||||
THUMBS_UP(3, R.drawable.ic_gif_thumbsup_24, "thumbs up"),
|
||||
SURPRISED(4, R.drawable.ic_gif_surprised_24, "surprised"),
|
||||
EXCITED(5, R.drawable.ic_gif_excited_24, "excited"),
|
||||
SAD(6, R.drawable.ic_gif_sad_24, "sad"),
|
||||
ANGRY(7, R.drawable.ic_gif_angry_24, "angry");
|
||||
|
||||
companion object {
|
||||
val ranked: List<GifQuickSearchOption> by lazy { values().sortedBy { it.rank } }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package org.thoughtcrime.securesms.keyboard.sticker
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewpager.widget.PagerAdapter
|
||||
import androidx.viewpager.widget.ViewPager
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboardBottomTabAdapter
|
||||
import org.thoughtcrime.securesms.components.emoji.MediaKeyboardProvider
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider
|
||||
import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider.StickerEventListener
|
||||
|
||||
class StickerKeyboardPageFragment : LoggingFragment(R.layout.keyboard_pager_sticker_page_fragment) {
|
||||
|
||||
private val presenter: StickerPresenter = StickerPresenter()
|
||||
private lateinit var provider: StickerKeyboardProvider
|
||||
|
||||
private lateinit var stickerPager: ViewPager
|
||||
private lateinit var searchView: View
|
||||
private lateinit var stickerPacksRecycler: RecyclerView
|
||||
private lateinit var manageStickers: View
|
||||
private lateinit var tabAdapter: MediaKeyboardBottomTabAdapter
|
||||
|
||||
private lateinit var viewModel: StickerKeyboardPageViewModel
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
stickerPager = view.findViewById(R.id.sticker_pager)
|
||||
searchView = view.findViewById(R.id.sticker_search)
|
||||
manageStickers = view.findViewById(R.id.sticker_manage)
|
||||
stickerPacksRecycler = view.findViewById(R.id.sticker_packs_recycler)
|
||||
|
||||
searchView.setOnClickListener { StickerSearchDialogFragment.show(requireActivity().supportFragmentManager) }
|
||||
}
|
||||
|
||||
override fun onActivityCreated(savedInstanceState: Bundle?) {
|
||||
super.onActivityCreated(savedInstanceState)
|
||||
|
||||
viewModel = ViewModelProviders.of(requireActivity()).get(StickerKeyboardPageViewModel::class.java)
|
||||
|
||||
tabAdapter = MediaKeyboardBottomTabAdapter(GlideApp.with(this), this::onTabSelected)
|
||||
stickerPacksRecycler.adapter = tabAdapter
|
||||
|
||||
provider = StickerKeyboardProvider(requireActivity(), findListener() ?: throw AssertionError("No sticker listener"))
|
||||
provider.requestPresentation(presenter, true)
|
||||
}
|
||||
|
||||
private fun findListener(): StickerEventListener? {
|
||||
return parentFragment as? StickerEventListener ?: requireActivity() as? StickerEventListener
|
||||
}
|
||||
|
||||
private fun onTabSelected(index: Int) {
|
||||
stickerPager.currentItem = index
|
||||
stickerPacksRecycler.smoothScrollToPosition(index)
|
||||
viewModel.selectedTab = index
|
||||
}
|
||||
|
||||
private inner class StickerPresenter : MediaKeyboardProvider.Presenter {
|
||||
override fun present(
|
||||
provider: MediaKeyboardProvider,
|
||||
pagerAdapter: PagerAdapter,
|
||||
iconProvider: MediaKeyboardProvider.TabIconProvider,
|
||||
backspaceObserver: MediaKeyboardProvider.BackspaceObserver?,
|
||||
addObserver: MediaKeyboardProvider.AddObserver?,
|
||||
searchObserver: MediaKeyboardProvider.SearchObserver?,
|
||||
startingIndex: Int
|
||||
) {
|
||||
if (stickerPager.adapter != pagerAdapter) {
|
||||
stickerPager.adapter = pagerAdapter
|
||||
}
|
||||
stickerPager.currentItem = viewModel.selectedTab
|
||||
|
||||
stickerPager.clearOnPageChangeListeners()
|
||||
stickerPager.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
|
||||
override fun onPageSelected(position: Int) {
|
||||
tabAdapter.setActivePosition(position)
|
||||
stickerPacksRecycler.smoothScrollToPosition(position)
|
||||
provider.setCurrentPosition(position)
|
||||
}
|
||||
|
||||
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) = Unit
|
||||
override fun onPageScrollStateChanged(state: Int) = Unit
|
||||
})
|
||||
|
||||
tabAdapter.setTabIconProvider(iconProvider, pagerAdapter.count)
|
||||
tabAdapter.setActivePosition(stickerPager.currentItem)
|
||||
|
||||
manageStickers.setOnClickListener { addObserver?.onAddClicked() }
|
||||
}
|
||||
|
||||
override fun getCurrentPosition(): Int {
|
||||
return stickerPager.currentItem
|
||||
}
|
||||
|
||||
override fun requestDismissal() = Unit
|
||||
override fun isVisible(): Boolean = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.thoughtcrime.securesms.keyboard.sticker
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
|
||||
class StickerKeyboardPageViewModel : ViewModel() {
|
||||
var selectedTab: Int = 0
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package org.thoughtcrime.securesms.keyboard.sticker
|
||||
|
||||
import android.content.res.Configuration
|
||||
import android.graphics.Point
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.Px
|
||||
import androidx.fragment.app.DialogFragment
|
||||
import androidx.fragment.app.FragmentManager
|
||||
import androidx.lifecycle.ViewModelProviders
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord
|
||||
import org.thoughtcrime.securesms.keyboard.emoji.KeyboardPageSearchView
|
||||
import org.thoughtcrime.securesms.keyboard.findListener
|
||||
import org.thoughtcrime.securesms.mms.GlideApp
|
||||
import org.thoughtcrime.securesms.stickers.StickerKeyboardPageAdapter
|
||||
import org.thoughtcrime.securesms.stickers.StickerKeyboardProvider
|
||||
import org.thoughtcrime.securesms.util.DeviceProperties
|
||||
import org.thoughtcrime.securesms.util.ViewUtil
|
||||
|
||||
/**
|
||||
* Search dialog for finding stickers.
|
||||
*/
|
||||
class StickerSearchDialogFragment : DialogFragment(), StickerKeyboardPageAdapter.EventListener {
|
||||
|
||||
private lateinit var search: KeyboardPageSearchView
|
||||
private lateinit var list: RecyclerView
|
||||
private lateinit var noResults: View
|
||||
|
||||
private lateinit var adapter: StickerKeyboardPageAdapter
|
||||
private lateinit var layoutManager: GridLayoutManager
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setStyle(STYLE_NO_FRAME, R.style.Signal_DayNight_Dialog_Animated_Bottom)
|
||||
}
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? {
|
||||
return inflater.inflate(R.layout.sticker_search_dialog_fragment, container, false)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
search = view.findViewById(R.id.sticker_search_text)
|
||||
list = view.findViewById(R.id.sticker_search_list)
|
||||
noResults = view.findViewById(R.id.sticker_search_no_results)
|
||||
|
||||
adapter = StickerKeyboardPageAdapter(GlideApp.with(this), this, DeviceProperties.shouldAllowApngStickerAnimation(requireContext()))
|
||||
layoutManager = GridLayoutManager(requireContext(), 2)
|
||||
|
||||
list.layoutManager = layoutManager
|
||||
list.adapter = adapter
|
||||
|
||||
onScreenWidthChanged(getScreenWidth())
|
||||
|
||||
val viewModel: StickerSearchViewModel = ViewModelProviders.of(this, StickerSearchViewModel.Factory(requireContext())).get(StickerSearchViewModel::class.java)
|
||||
|
||||
viewModel.searchResults.observe(viewLifecycleOwner) { stickerRecords ->
|
||||
adapter.setStickers(stickerRecords, calculateStickerSize(getScreenWidth()))
|
||||
noResults.visibility = if (stickerRecords.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
search.enableBackNavigation()
|
||||
search.callbacks = object : KeyboardPageSearchView.Callbacks {
|
||||
override fun onQueryChanged(query: String) {
|
||||
viewModel.query(query)
|
||||
}
|
||||
|
||||
override fun onNavigationClicked() {
|
||||
ViewUtil.hideKeyboard(requireContext(), view)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
}
|
||||
|
||||
search.requestFocus()
|
||||
}
|
||||
|
||||
override fun onConfigurationChanged(newConfig: Configuration) {
|
||||
super.onConfigurationChanged(newConfig)
|
||||
onScreenWidthChanged(getScreenWidth())
|
||||
}
|
||||
|
||||
private fun onScreenWidthChanged(@Px newWidth: Int) {
|
||||
layoutManager.spanCount = calculateColumnCount(newWidth)
|
||||
adapter.setStickerSize(calculateStickerSize(newWidth))
|
||||
}
|
||||
|
||||
private fun getScreenWidth(): Int {
|
||||
val size = Point()
|
||||
requireActivity().windowManager.defaultDisplay.getSize(size)
|
||||
return size.x
|
||||
}
|
||||
|
||||
private fun calculateColumnCount(@Px screenWidth: Int): Int {
|
||||
val modifier = resources.getDimensionPixelOffset(R.dimen.sticker_page_item_padding).toFloat()
|
||||
val divisor = resources.getDimensionPixelOffset(R.dimen.sticker_page_item_divisor).toFloat()
|
||||
return ((screenWidth - modifier) / divisor).toInt()
|
||||
}
|
||||
|
||||
private fun calculateStickerSize(@Px screenWidth: Int): Int {
|
||||
val multiplier = resources.getDimensionPixelOffset(R.dimen.sticker_page_item_multiplier).toFloat()
|
||||
val columnCount = calculateColumnCount(screenWidth)
|
||||
return ((screenWidth - (columnCount + 1) * multiplier) / columnCount).toInt()
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun show(fragmentManager: FragmentManager) {
|
||||
StickerSearchDialogFragment().show(fragmentManager, "TAG")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStickerClicked(sticker: StickerRecord) {
|
||||
ViewUtil.hideKeyboard(requireContext(), requireView())
|
||||
findListener<StickerKeyboardProvider.StickerEventListener>()?.onStickerSelected(sticker)
|
||||
dismissAllowingStateLoss()
|
||||
}
|
||||
|
||||
override fun onStickerLongClicked(targetView: View) = Unit
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package org.thoughtcrime.securesms.keyboard.sticker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import org.thoughtcrime.securesms.components.emoji.EmojiUtil
|
||||
import org.thoughtcrime.securesms.database.DatabaseFactory
|
||||
import org.thoughtcrime.securesms.database.EmojiSearchDatabase
|
||||
import org.thoughtcrime.securesms.database.StickerDatabase
|
||||
import org.thoughtcrime.securesms.database.StickerDatabase.StickerRecordReader
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord
|
||||
|
||||
private const val RECENT_LIMIT = 24
|
||||
private const val EMOJI_SEARCH_RESULTS_LIMIT = 20
|
||||
|
||||
class StickerSearchRepository(context: Context) {
|
||||
|
||||
private val emojiSearchDatabase: EmojiSearchDatabase = DatabaseFactory.getEmojiSearchDatabase(context)
|
||||
private val stickerDatabase: StickerDatabase = DatabaseFactory.getStickerDatabase(context)
|
||||
|
||||
@WorkerThread
|
||||
fun search(query: String): List<StickerRecord> {
|
||||
if (query.isEmpty()) {
|
||||
return StickerRecordReader(stickerDatabase.getRecentlyUsedStickers(RECENT_LIMIT)).readAll()
|
||||
}
|
||||
|
||||
val maybeEmojiQuery: List<StickerRecord> = findStickersForEmoji(query)
|
||||
val searchResults: List<StickerRecord> = emojiSearchDatabase.query(query, EMOJI_SEARCH_RESULTS_LIMIT)
|
||||
.map { findStickersForEmoji(it) }
|
||||
.flatten()
|
||||
|
||||
return maybeEmojiQuery + searchResults
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun findStickersForEmoji(emoji: String): List<StickerRecord> {
|
||||
val searchEmoji: String = EmojiUtil.getCanonicalRepresentation(emoji)
|
||||
|
||||
return EmojiUtil.getAllRepresentations(searchEmoji)
|
||||
.filterNotNull()
|
||||
.map { candidate -> StickerRecordReader(stickerDatabase.getStickersByEmoji(candidate)).readAll() }
|
||||
.flatten()
|
||||
}
|
||||
}
|
||||
|
||||
private fun StickerRecordReader.readAll(): List<StickerRecord> {
|
||||
val stickers: MutableList<StickerRecord> = mutableListOf()
|
||||
use { reader ->
|
||||
var record: StickerRecord? = reader.next
|
||||
while (record != null) {
|
||||
stickers.add(record)
|
||||
record = reader.next
|
||||
}
|
||||
}
|
||||
return stickers
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.thoughtcrime.securesms.keyboard.sticker
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.ViewModelProvider
|
||||
import org.thoughtcrime.securesms.database.model.StickerRecord
|
||||
import org.thoughtcrime.securesms.util.livedata.LiveDataUtil
|
||||
|
||||
class StickerSearchViewModel(private val searchRepository: StickerSearchRepository) : ViewModel() {
|
||||
|
||||
private val searchQuery: MutableLiveData<String> = MutableLiveData("")
|
||||
|
||||
val searchResults: LiveData<List<StickerRecord>> = LiveDataUtil.mapAsync(searchQuery) { q -> searchRepository.search(q) }
|
||||
|
||||
fun query(query: String) {
|
||||
searchQuery.postValue(query)
|
||||
}
|
||||
|
||||
class Factory(context: Context) : ViewModelProvider.Factory {
|
||||
val repository = StickerSearchRepository(context)
|
||||
|
||||
override fun <T : ViewModel?> create(modelClass: Class<T>): T {
|
||||
return requireNotNull(modelClass.cast(StickerSearchViewModel(repository)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user