merge hashtag dialogs into one (#4861)
A hashtag picker dialog was implemented twice, with slight differences. Now there is only one - with hashtag validation - no more api errors when following an invalid one - The dialog can now be closed with the keyboard, for extra fast hashtag selection - with autocomplete I also added a new snackbar when following a hashtag was succesfull. Although I'm not sure about the auto complete, it can be very annoying as the drop down covers the buttons. I found no way to make it size to its content: https://chaos.social/@ConnyDuck/113803457147888844 Should we get rid of it?
This commit is contained in:
parent
f8829bc47c
commit
9735683df4
54 changed files with 233 additions and 210 deletions
|
|
@ -18,17 +18,13 @@ package com.keylesspalace.tusky
|
|||
import android.graphics.Color
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.OnBackPressedCallback
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.transition.TransitionManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.transition.MaterialArcMotion
|
||||
import com.google.android.material.transition.MaterialContainerTransform
|
||||
import com.keylesspalace.tusky.adapter.ItemInteractionListener
|
||||
|
|
@ -37,13 +33,13 @@ import com.keylesspalace.tusky.appstore.EventHub
|
|||
import com.keylesspalace.tusky.appstore.MainTabsChangedEvent
|
||||
import com.keylesspalace.tusky.components.account.list.ListSelectionFragment
|
||||
import com.keylesspalace.tusky.databinding.ActivityTabPreferenceBinding
|
||||
import com.keylesspalace.tusky.databinding.DialogAddHashtagBinding
|
||||
import com.keylesspalace.tusky.entity.MastoList
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.hashtagPattern
|
||||
import com.keylesspalace.tusky.util.unsafeLazy
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.view.showHashtagPickerDialog
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -224,39 +220,21 @@ class TabPreferenceActivity : BaseActivity(), ItemInteractionListener, ListSelec
|
|||
}
|
||||
|
||||
private fun showAddHashtagDialog(tab: TabData? = null, tabPosition: Int = 0) {
|
||||
val dialogBinding = DialogAddHashtagBinding.inflate(layoutInflater)
|
||||
val editText = dialogBinding.addHashtagEditText
|
||||
showHashtagPickerDialog(mastodonApi, R.string.add_hashtag_title) { hashtag ->
|
||||
if (tab == null) {
|
||||
val newTab = createTabDataFromId(HASHTAG, listOf(hashtag))
|
||||
currentTabs.add(newTab)
|
||||
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
|
||||
} else {
|
||||
val newTab = tab.copy(arguments = tab.arguments + hashtag)
|
||||
currentTabs[tabPosition] = newTab
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.add_hashtag_title)
|
||||
.setView(dialogBinding.root)
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setPositiveButton(R.string.action_save) { _, _ ->
|
||||
val input = editText.text.toString().trim()
|
||||
if (tab == null) {
|
||||
val newTab = createTabDataFromId(HASHTAG, listOf(input))
|
||||
currentTabs.add(newTab)
|
||||
currentTabsAdapter.notifyItemInserted(currentTabs.size - 1)
|
||||
} else {
|
||||
val newTab = tab.copy(arguments = tab.arguments + input)
|
||||
currentTabs[tabPosition] = newTab
|
||||
|
||||
currentTabsAdapter.notifyItemChanged(tabPosition)
|
||||
}
|
||||
|
||||
updateAvailableTabs()
|
||||
saveTabs()
|
||||
currentTabsAdapter.notifyItemChanged(tabPosition)
|
||||
}
|
||||
.create()
|
||||
|
||||
editText.doOnTextChanged { s, _, _, _ ->
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s)
|
||||
updateAvailableTabs()
|
||||
saveTabs()
|
||||
}
|
||||
|
||||
dialog.show()
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(editText.text)
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
editText.requestFocus()
|
||||
}
|
||||
|
||||
private var listSelectDialog: ListSelectionFragment? = null
|
||||
|
|
|
|||
|
|
@ -464,9 +464,9 @@ class ComposeActivity :
|
|||
binding.composeEditField.setAdapter(
|
||||
ComposeAutoCompleteAdapter(
|
||||
this,
|
||||
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
||||
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
|
||||
animateAvatar = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
||||
animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||
showBotBadge = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
|
||||
)
|
||||
)
|
||||
binding.composeEditField.setTokenizer(ComposeTokenizer())
|
||||
|
|
|
|||
|
|
@ -37,7 +37,9 @@ class ComposeAutoCompleteAdapter(
|
|||
private val autocompletionProvider: AutocompletionProvider,
|
||||
private val animateAvatar: Boolean,
|
||||
private val animateEmojis: Boolean,
|
||||
private val showBotBadge: Boolean
|
||||
private val showBotBadge: Boolean,
|
||||
// if true, @ # : are returned in the result, otherwise only the raw value
|
||||
private val withDecoration: Boolean = true,
|
||||
) : BaseAdapter(), Filterable {
|
||||
|
||||
private var resultList: List<AutocompleteResult> = emptyList()
|
||||
|
|
@ -52,37 +54,35 @@ class ComposeAutoCompleteAdapter(
|
|||
return position.toLong()
|
||||
}
|
||||
|
||||
override fun getFilter(): Filter {
|
||||
return object : Filter() {
|
||||
override fun getFilter() = object : Filter() {
|
||||
|
||||
override fun convertResultToString(resultValue: Any): CharSequence {
|
||||
return when (resultValue) {
|
||||
is AutocompleteResult.AccountResult -> "@${resultValue.account.username}"
|
||||
is AutocompleteResult.HashtagResult -> "#${resultValue.hashtag}"
|
||||
is AutocompleteResult.EmojiResult -> ":${resultValue.emoji.shortcode}:"
|
||||
else -> ""
|
||||
}
|
||||
override fun convertResultToString(resultValue: Any): CharSequence {
|
||||
return when (resultValue) {
|
||||
is AutocompleteResult.AccountResult -> if (withDecoration) "@${resultValue.account.username}" else resultValue.account.username
|
||||
is AutocompleteResult.HashtagResult -> if (withDecoration) "#${resultValue.hashtag}" else resultValue.hashtag
|
||||
is AutocompleteResult.EmojiResult -> if (withDecoration) ":${resultValue.emoji.shortcode}:" else resultValue.emoji.shortcode
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
override fun performFiltering(constraint: CharSequence?): FilterResults {
|
||||
val filterResults = FilterResults()
|
||||
if (constraint != null) {
|
||||
val results = autocompletionProvider.search(constraint.toString())
|
||||
filterResults.values = results
|
||||
filterResults.count = results.size
|
||||
}
|
||||
return filterResults
|
||||
@WorkerThread
|
||||
override fun performFiltering(constraint: CharSequence?): FilterResults {
|
||||
val filterResults = FilterResults()
|
||||
if (constraint != null) {
|
||||
val results = autocompletionProvider.search(constraint.toString())
|
||||
filterResults.values = results
|
||||
filterResults.count = results.size
|
||||
}
|
||||
return filterResults
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
|
||||
if (results.count > 0) {
|
||||
resultList = results.values as List<AutocompleteResult>
|
||||
notifyDataSetChanged()
|
||||
} else {
|
||||
notifyDataSetInvalidated()
|
||||
}
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
|
||||
if (results.count > 0) {
|
||||
resultList = results.values as List<AutocompleteResult>
|
||||
notifyDataSetChanged()
|
||||
} else {
|
||||
notifyDataSetInvalidated()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package com.keylesspalace.tusky.components.followedtags
|
|||
import android.content.SharedPreferences
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.paging.LoadState
|
||||
|
|
@ -11,14 +10,11 @@ import androidx.recyclerview.widget.DividerItemDecoration
|
|||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.SimpleItemAnimator
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.keylesspalace.tusky.BaseActivity
|
||||
import com.keylesspalace.tusky.R
|
||||
import com.keylesspalace.tusky.StatusListActivity
|
||||
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter
|
||||
import com.keylesspalace.tusky.databinding.ActivityFollowedTagsBinding
|
||||
import com.keylesspalace.tusky.databinding.DialogFollowHashtagBinding
|
||||
import com.keylesspalace.tusky.interfaces.HashtagActionListener
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.copyToClipboard
|
||||
|
|
@ -26,6 +22,7 @@ import com.keylesspalace.tusky.util.hide
|
|||
import com.keylesspalace.tusky.util.show
|
||||
import com.keylesspalace.tusky.util.viewBinding
|
||||
import com.keylesspalace.tusky.util.visible
|
||||
import com.keylesspalace.tusky.view.showHashtagPickerDialog
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
|
@ -34,8 +31,8 @@ import kotlinx.coroutines.launch
|
|||
@AndroidEntryPoint
|
||||
class FollowedTagsActivity :
|
||||
BaseActivity(),
|
||||
HashtagActionListener,
|
||||
ComposeAutoCompleteAdapter.AutocompletionProvider {
|
||||
HashtagActionListener {
|
||||
|
||||
@Inject
|
||||
lateinit var api: MastodonApi
|
||||
|
||||
|
|
@ -105,7 +102,7 @@ class FollowedTagsActivity :
|
|||
|
||||
private fun follow(tagName: String, position: Int = -1) {
|
||||
lifecycleScope.launch {
|
||||
api.followTag(tagName).fold(
|
||||
val snackbarText = api.followTag(tagName).fold(
|
||||
{
|
||||
if (position == -1) {
|
||||
viewModel.tags.add(it)
|
||||
|
|
@ -113,17 +110,20 @@ class FollowedTagsActivity :
|
|||
viewModel.tags.add(position, it)
|
||||
}
|
||||
viewModel.currentSource?.invalidate()
|
||||
getString(R.string.follow_hashtag_success, tagName)
|
||||
},
|
||||
{
|
||||
Snackbar.make(
|
||||
this@FollowedTagsActivity,
|
||||
binding.followedTagsView,
|
||||
getString(R.string.error_following_hashtag_format, tagName),
|
||||
Snackbar.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
{ t ->
|
||||
Log.w(TAG, "failed to follow hashtag $tagName", t)
|
||||
getString(R.string.error_following_hashtag_format, tagName)
|
||||
}
|
||||
)
|
||||
Snackbar.make(
|
||||
this@FollowedTagsActivity,
|
||||
binding.followedTagsView,
|
||||
snackbarText,
|
||||
Snackbar.LENGTH_SHORT
|
||||
)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -160,10 +160,6 @@ class FollowedTagsActivity :
|
|||
}
|
||||
}
|
||||
|
||||
override fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
return viewModel.searchAutocompleteSuggestions(token)
|
||||
}
|
||||
|
||||
override fun viewTag(tagName: String) {
|
||||
startActivity(StatusListActivity.newHashtagIntent(this, tagName))
|
||||
}
|
||||
|
|
@ -176,30 +172,9 @@ class FollowedTagsActivity :
|
|||
}
|
||||
|
||||
private fun showDialog() {
|
||||
val dialogBinding = DialogFollowHashtagBinding.inflate(layoutInflater)
|
||||
dialogBinding.hashtagAutoCompleteTextView.setAdapter(
|
||||
ComposeAutoCompleteAdapter(
|
||||
this,
|
||||
animateAvatar = false,
|
||||
animateEmojis = false,
|
||||
showBotBadge = false
|
||||
)
|
||||
)
|
||||
dialogBinding.hashtagAutoCompleteTextView.requestFocus()
|
||||
dialogBinding.hashtagAutoCompleteTextView.setSelection(dialogBinding.hashtagAutoCompleteTextView.length())
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(this)
|
||||
.setTitle(R.string.dialog_follow_hashtag_title)
|
||||
.setView(dialogBinding.root)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
follow(
|
||||
dialogBinding.hashtagAutoCompleteTextView.text.toString().removePrefix("#")
|
||||
)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
dialog.show()
|
||||
showHashtagPickerDialog(api, R.string.dialog_follow_hashtag_title) { hashtag ->
|
||||
follow(hashtag)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
|
|
|||
|
|
@ -1,24 +1,19 @@
|
|||
package com.keylesspalace.tusky.components.followedtags
|
||||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.paging.ExperimentalPagingApi
|
||||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter
|
||||
import com.keylesspalace.tusky.components.search.SearchType
|
||||
import com.keylesspalace.tusky.entity.HashTag
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
@HiltViewModel
|
||||
class FollowedTagsViewModel @Inject constructor(
|
||||
private val api: MastodonApi
|
||||
val api: MastodonApi
|
||||
) : ViewModel() {
|
||||
val tags: MutableList<HashTag> = mutableListOf()
|
||||
var nextKey: String? = null
|
||||
|
|
@ -39,24 +34,6 @@ class FollowedTagsViewModel @Inject constructor(
|
|||
}
|
||||
).flow.cachedIn(viewModelScope)
|
||||
|
||||
fun searchAutocompleteSuggestions(
|
||||
token: String
|
||||
): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
return runBlocking {
|
||||
api.search(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||
.fold({ searchResult ->
|
||||
searchResult.hashtags.map {
|
||||
ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult(
|
||||
it.name
|
||||
)
|
||||
}
|
||||
}, { e ->
|
||||
Log.e(TAG, "Autocomplete search for $token failed.", e)
|
||||
emptyList()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "FollowedTagsViewModel"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,118 @@
|
|||
/* Copyright 2025 Tusky Contributors
|
||||
*
|
||||
* This file is a part of Tusky.
|
||||
*
|
||||
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
||||
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
||||
* License, or (at your option) any later version.
|
||||
*
|
||||
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
||||
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
||||
* Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||
* see <http://www.gnu.org/licenses>. */
|
||||
|
||||
@file:JvmName("HashTagPickerDialog")
|
||||
|
||||
package com.keylesspalace.tusky.view
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import android.view.LayoutInflater
|
||||
import android.view.WindowManager
|
||||
import android.view.inputmethod.EditorInfo
|
||||
import android.widget.TextView
|
||||
import android.widget.TextView.OnEditorActionListener
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.widget.doOnTextChanged
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter
|
||||
import com.keylesspalace.tusky.components.search.SearchType
|
||||
import com.keylesspalace.tusky.databinding.DialogPickHashtagBinding
|
||||
import com.keylesspalace.tusky.network.MastodonApi
|
||||
import com.keylesspalace.tusky.util.hashtagPattern
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
||||
fun Context.showHashtagPickerDialog(
|
||||
api: MastodonApi,
|
||||
@StringRes title: Int,
|
||||
onHashtagSelected: (String) -> Unit
|
||||
) {
|
||||
val dialogScope = CoroutineScope(Dispatchers.Main)
|
||||
val dialogBinding = DialogPickHashtagBinding.inflate(LayoutInflater.from(this))
|
||||
val autocompleteTextView = dialogBinding.pickHashtagEditText
|
||||
|
||||
val autoCompleteProvider = object : ComposeAutoCompleteAdapter.AutocompletionProvider {
|
||||
override fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
return runBlocking {
|
||||
api.search(query = token, type = SearchType.Hashtag.apiParameter, limit = 5)
|
||||
.fold({ searchResult ->
|
||||
searchResult.hashtags.map {
|
||||
ComposeAutoCompleteAdapter.AutocompleteResult.HashtagResult(
|
||||
it.name
|
||||
)
|
||||
}
|
||||
}, { e ->
|
||||
Log.e("HashtagPickerDialog", "Autocomplete search for $token failed", e)
|
||||
emptyList()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
autocompleteTextView.setAdapter(
|
||||
ComposeAutoCompleteAdapter(
|
||||
autoCompleteProvider,
|
||||
animateAvatar = false,
|
||||
animateEmojis = false,
|
||||
showBotBadge = false,
|
||||
withDecoration = false
|
||||
)
|
||||
)
|
||||
|
||||
autocompleteTextView.setSelection(autocompleteTextView.length())
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(this)
|
||||
.setTitle(title)
|
||||
.setView(dialogBinding.root)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
onHashtagSelected(autocompleteTextView.text.toString())
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.setOnDismissListener {
|
||||
dialogScope.cancel()
|
||||
}
|
||||
.create()
|
||||
|
||||
autocompleteTextView.doOnTextChanged { s, _, _, _ ->
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(s)
|
||||
}
|
||||
|
||||
autocompleteTextView.setOnEditorActionListener(object : OnEditorActionListener {
|
||||
override fun onEditorAction(v: TextView?, actionId: Int, event: KeyEvent?): Boolean {
|
||||
if (actionId == EditorInfo.IME_ACTION_DONE && validateHashtag(autocompleteTextView.text)) {
|
||||
onHashtagSelected(autocompleteTextView.text.toString())
|
||||
dialog.dismiss()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
})
|
||||
|
||||
dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE)
|
||||
dialog.show()
|
||||
dialog.getButton(AlertDialog.BUTTON_POSITIVE).isEnabled = validateHashtag(autocompleteTextView.text)
|
||||
autocompleteTextView.requestFocus()
|
||||
}
|
||||
|
||||
private fun validateHashtag(input: CharSequence?): Boolean {
|
||||
val trimmedInput = input?.trim() ?: ""
|
||||
return trimmedInput.isNotEmpty() && hashtagPattern.matcher(trimmedInput).matches()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue