Skip to content

Commit

Permalink
merge hashtag dialogs into one (#4861)
Browse files Browse the repository at this point in the history
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?
  • Loading branch information
connyduck authored Jan 14, 2025
1 parent f8829bc commit 9735683
Show file tree
Hide file tree
Showing 54 changed files with 234 additions and 211 deletions.
48 changes: 13 additions & 35 deletions app/src/main/java/com/keylesspalace/tusky/TabPreferenceActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

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()
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

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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,26 @@ 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
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
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
Expand All @@ -34,8 +31,8 @@ import kotlinx.coroutines.launch
@AndroidEntryPoint
class FollowedTagsActivity :
BaseActivity(),
HashtagActionListener,
ComposeAutoCompleteAdapter.AutocompletionProvider {
HashtagActionListener {

@Inject
lateinit var api: MastodonApi

Expand Down Expand Up @@ -105,25 +102,28 @@ 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)
} else {
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()
}
}

Expand Down Expand Up @@ -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))
}
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
}
Expand Down
Loading

0 comments on commit 9735683

Please sign in to comment.