feat: Provide a UI to edit different search operators (#829)
Mastodon supports in-query search operators, such as `has:image`, `language:en`, or `in:library`. Previously the user had to enter them in to the query directly. This provides a chip-based UI that allows the user to set values for these operators. ## Server - Add new search capabilities to record the faceted search features the server reports. - Update definitions for Mastodon, Friendica, and GoToSocial to specify which versions of the operations they support. ## SearchOperator / SearchOperatorViewData - Represents each supported operator and associated viewdata. ## SearchActivity / activity_search.xml - Conditionally display a chip for each facet depending on the server's level of support. - Implement the UI for each chip. They display dialogs of varying levels of complexity depending on the underlying operation. ## FragmentSearch - Display the progress as a LinearProgressIndicator instead of an indeterminate ProgressBar. This makes it more visible under the search facets.
This commit is contained in:
parent
e063ae69e6
commit
71e006b0d2
|
@ -16,6 +16,7 @@
|
|||
|
||||
package app.pachli.components.search
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.SearchManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
@ -24,24 +25,105 @@ import android.view.Menu
|
|||
import android.view.MenuInflater
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.core.view.MenuProvider
|
||||
import androidx.core.widget.doAfterTextChanged
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import app.pachli.R
|
||||
import app.pachli.components.compose.ComposeAutoCompleteAdapter
|
||||
import app.pachli.components.search.SearchOperator.DateOperator
|
||||
import app.pachli.components.search.SearchOperator.DateOperator.DateRange
|
||||
import app.pachli.components.search.SearchOperator.FromOperator
|
||||
import app.pachli.components.search.SearchOperator.FromOperator.FromKind.FromAccount
|
||||
import app.pachli.components.search.SearchOperator.FromOperator.FromKind.FromMe
|
||||
import app.pachli.components.search.SearchOperator.HasEmbedOperator
|
||||
import app.pachli.components.search.SearchOperator.HasEmbedOperator.EmbedKind
|
||||
import app.pachli.components.search.SearchOperator.HasLinkOperator
|
||||
import app.pachli.components.search.SearchOperator.HasLinkOperator.LinkKind
|
||||
import app.pachli.components.search.SearchOperator.HasMediaOperator
|
||||
import app.pachli.components.search.SearchOperator.HasMediaOperator.HasMediaOption.HasMedia
|
||||
import app.pachli.components.search.SearchOperator.HasMediaOperator.HasMediaOption.NoMedia
|
||||
import app.pachli.components.search.SearchOperator.HasMediaOperator.HasMediaOption.SpecificMedia
|
||||
import app.pachli.components.search.SearchOperator.HasMediaOperator.MediaKind
|
||||
import app.pachli.components.search.SearchOperator.HasPollOperator
|
||||
import app.pachli.components.search.SearchOperator.IsReplyOperator
|
||||
import app.pachli.components.search.SearchOperator.IsReplyOperator.ReplyKind
|
||||
import app.pachli.components.search.SearchOperator.IsSensitiveOperator
|
||||
import app.pachli.components.search.SearchOperator.IsSensitiveOperator.SensitiveKind
|
||||
import app.pachli.components.search.SearchOperator.LanguageOperator
|
||||
import app.pachli.components.search.SearchOperator.WhereOperator
|
||||
import app.pachli.components.search.SearchOperator.WhereOperator.WhereLocation
|
||||
import app.pachli.components.search.SearchOperatorViewData.DateOperatorViewData
|
||||
import app.pachli.components.search.SearchOperatorViewData.FromOperatorViewData
|
||||
import app.pachli.components.search.SearchOperatorViewData.HasEmbedOperatorViewData
|
||||
import app.pachli.components.search.SearchOperatorViewData.HasLinkOperatorViewData
|
||||
import app.pachli.components.search.SearchOperatorViewData.HasMediaOperatorViewData
|
||||
import app.pachli.components.search.SearchOperatorViewData.HasPollOperatorViewData
|
||||
import app.pachli.components.search.SearchOperatorViewData.IsReplyOperatorViewData
|
||||
import app.pachli.components.search.SearchOperatorViewData.IsSensitiveOperatorViewData
|
||||
import app.pachli.components.search.SearchOperatorViewData.LanguageOperatorViewData
|
||||
import app.pachli.components.search.SearchOperatorViewData.WhereOperatorViewData
|
||||
import app.pachli.components.search.adapter.SearchPagerAdapter
|
||||
import app.pachli.core.activity.BottomSheetActivity
|
||||
import app.pachli.core.common.extensions.hide
|
||||
import app.pachli.core.common.extensions.show
|
||||
import app.pachli.core.common.extensions.viewBinding
|
||||
import app.pachli.core.common.extensions.visible
|
||||
import app.pachli.core.network.Server
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_FROM
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_EMBED
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_IMAGE
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_LINK
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_MEDIA
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_POLL
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_VIDEO
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IN_PUBLIC
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE
|
||||
import app.pachli.core.preferences.PrefKeys
|
||||
import app.pachli.core.ui.extensions.await
|
||||
import app.pachli.core.ui.extensions.awaitSingleChoiceItem
|
||||
import app.pachli.core.ui.extensions.reduceSwipeSensitivity
|
||||
import app.pachli.core.ui.makeIcon
|
||||
import app.pachli.databinding.ActivitySearchBinding
|
||||
import app.pachli.databinding.SearchOperatorAttachmentDialogBinding
|
||||
import app.pachli.databinding.SearchOperatorFromDialogBinding
|
||||
import app.pachli.databinding.SearchOperatorWhereLocationDialogBinding
|
||||
import com.github.michaelbull.result.get
|
||||
import com.google.android.material.chip.Chip
|
||||
import com.google.android.material.datepicker.CalendarConstraints
|
||||
import com.google.android.material.datepicker.DateValidatorPointBackward
|
||||
import com.google.android.material.datepicker.MaterialDatePicker
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import com.mikepenz.iconics.IconicsSize
|
||||
import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import io.github.z4kn4fein.semver.constraints.toConstraint
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneOffset
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@AndroidEntryPoint
|
||||
class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTextListener {
|
||||
class SearchActivity :
|
||||
BottomSheetActivity(),
|
||||
MenuProvider,
|
||||
SearchView.OnQueryTextListener,
|
||||
ComposeAutoCompleteAdapter.AutocompletionProvider {
|
||||
private val viewModel: SearchViewModel by viewModels()
|
||||
|
||||
private val binding by viewBinding(ActivitySearchBinding::inflate)
|
||||
|
||||
private lateinit var searchView: SearchView
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(binding.root)
|
||||
|
@ -53,6 +135,7 @@ class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTe
|
|||
}
|
||||
addMenuProvider(this)
|
||||
setupPages()
|
||||
bindOperators()
|
||||
handleIntent(intent)
|
||||
}
|
||||
|
||||
|
@ -63,12 +146,741 @@ class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTe
|
|||
val enableSwipeForTabs = sharedPreferencesRepository.getBoolean(PrefKeys.ENABLE_SWIPE_FOR_TABS, true)
|
||||
binding.pages.isUserInputEnabled = enableSwipeForTabs
|
||||
|
||||
TabLayoutMediator(binding.tabs, binding.pages) {
|
||||
tab, position ->
|
||||
TabLayoutMediator(binding.tabs, binding.pages) { tab, position ->
|
||||
tab.text = getPageTitle(position)
|
||||
}.attach()
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds the initial search operator chips UI and updates as the search
|
||||
* operators change.
|
||||
*/
|
||||
private fun bindOperators() {
|
||||
val viewDataToChip: Map<Class<out SearchOperatorViewData<SearchOperator>>, Chip> = mapOf(
|
||||
DateOperatorViewData::class.java to binding.chipDate,
|
||||
FromOperatorViewData::class.java to binding.chipFrom,
|
||||
HasEmbedOperatorViewData::class.java to binding.chipHasEmbed,
|
||||
HasLinkOperatorViewData::class.java to binding.chipHasLink,
|
||||
HasMediaOperatorViewData::class.java to binding.chipHasMedia,
|
||||
HasPollOperatorViewData::class.java to binding.chipHasPoll,
|
||||
IsReplyOperatorViewData::class.java to binding.chipIsReply,
|
||||
IsSensitiveOperatorViewData::class.java to binding.chipIsSensitive,
|
||||
LanguageOperatorViewData::class.java to binding.chipLanguage,
|
||||
WhereOperatorViewData::class.java to binding.chipWhere,
|
||||
)
|
||||
|
||||
lifecycleScope.launch {
|
||||
repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
launch {
|
||||
viewModel.server.collectLatest {
|
||||
// Ignore errors for the moment
|
||||
val server = it?.get() ?: return@collectLatest
|
||||
bindDateChip(server)
|
||||
bindFromChip(server)
|
||||
bindHasMediaChip(server)
|
||||
bindHasEmbedChip(server)
|
||||
bindHasLinkChip(server)
|
||||
bindHasPollChip(server)
|
||||
bindIsReplyChip(server)
|
||||
bindIsSensitiveChip(server)
|
||||
bindLanguageChip(server)
|
||||
bindWhereChip(server)
|
||||
}
|
||||
}
|
||||
|
||||
launch {
|
||||
viewModel.operatorViewData.collectLatest { operators ->
|
||||
operators.forEach { viewData ->
|
||||
viewDataToChip[viewData::class.java]?.let { chip ->
|
||||
chip.isChecked = viewData.operator.choice != null
|
||||
chip.setCloseIconVisible(viewData.operator.choice != null)
|
||||
chip.text = viewData.chipLabel(this@SearchActivity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Binds the chip for [HasMediaOperatorViewData]. */
|
||||
private fun bindHasMediaChip(server: Server) {
|
||||
val constraint = ">=1.0.0".toConstraint()
|
||||
val canHasMedia = server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_MEDIA, constraint)
|
||||
val canHasImage = server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_IMAGE, constraint)
|
||||
val canHasVideo = server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_VIDEO, constraint)
|
||||
val canHasAudio = server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO, constraint)
|
||||
|
||||
// Entire chip is hidden if there is no support for filtering by any kind of media.
|
||||
if (!canHasMedia && !canHasImage && !canHasVideo && !canHasAudio) {
|
||||
binding.chipHasMedia.hide()
|
||||
return
|
||||
}
|
||||
binding.chipHasMedia.show()
|
||||
|
||||
binding.chipHasMedia.setOnCloseIconClickListener {
|
||||
viewModel.replaceOperator(SearchOperatorViewData.from(HasMediaOperator()))
|
||||
}
|
||||
|
||||
binding.chipHasMedia.setOnClickListener {
|
||||
binding.chipHasMedia.toggle()
|
||||
|
||||
lifecycleScope.launch {
|
||||
val dialogBinding = SearchOperatorAttachmentDialogBinding.inflate(layoutInflater, null, false)
|
||||
|
||||
with(dialogBinding) {
|
||||
// Disable individual groups/headings based on the type of media that
|
||||
// searches can be filtered by
|
||||
titleMedia.visible(canHasMedia)
|
||||
chipgroupMedia.visible(canHasMedia)
|
||||
mediaDivider.visible(canHasMedia && (canHasImage || canHasVideo || canHasAudio))
|
||||
|
||||
titleImage.visible(canHasImage)
|
||||
chipgroupImages.visible(canHasImage)
|
||||
|
||||
titleVideo.visible(canHasVideo)
|
||||
chipgroupVideo.visible(canHasVideo)
|
||||
|
||||
titleAudio.visible(canHasAudio)
|
||||
chipgroupAudio.visible(canHasAudio)
|
||||
|
||||
// Turning on "No media" unchecks the images, video, and audio chips
|
||||
chipNoMedia.setOnCheckedChangeListener { _, isChecked ->
|
||||
if (!isChecked) return@setOnCheckedChangeListener
|
||||
|
||||
chipgroupImages.clearCheck()
|
||||
chipgroupVideo.clearCheck()
|
||||
chipgroupAudio.clearCheck()
|
||||
}
|
||||
|
||||
// Turning on "With media" unchecks any more specific "With..." chips,
|
||||
// as they're superfluous
|
||||
chipHasMedia.setOnCheckedChangeListener { _, isChecked ->
|
||||
if (!isChecked) return@setOnCheckedChangeListener
|
||||
|
||||
chipHasImage.isChecked = false
|
||||
chipHasVideo.isChecked = false
|
||||
chipHasAudio.isChecked = false
|
||||
}
|
||||
|
||||
// Clear chipGroupMedia if one of the more specific chips is clicked.
|
||||
// Clicking a specific "At least one" button always clears chipGroupMedia,
|
||||
// as that's a more specific choice.
|
||||
//
|
||||
// Clicking a specific "None" button only clears chipGroupMedia if chipGroupMedia
|
||||
// is also "None"; chipGroupMedia set to "At least one" with a specific media
|
||||
// choice set to "None" is a valid user choice.
|
||||
chipgroupImages.setOnCheckedStateChangeListener { _, checkedIds ->
|
||||
if (checkedIds.contains(R.id.chip_has_image) ||
|
||||
(checkedIds.contains(R.id.chip_no_image) && chipgroupMedia.checkedChipId == R.id.chip_no_media)
|
||||
) {
|
||||
chipgroupMedia.clearCheck()
|
||||
}
|
||||
}
|
||||
chipgroupVideo.setOnCheckedStateChangeListener { _, checkedIds ->
|
||||
if (checkedIds.contains(R.id.chip_has_video) ||
|
||||
(checkedIds.contains(R.id.chip_no_video) && chipgroupMedia.checkedChipId == R.id.chip_no_media)
|
||||
) {
|
||||
chipgroupMedia.clearCheck()
|
||||
}
|
||||
}
|
||||
chipgroupAudio.setOnCheckedStateChangeListener { _, checkedIds ->
|
||||
if (checkedIds.contains(R.id.chip_has_audio) ||
|
||||
(checkedIds.contains(R.id.chip_no_audio) && chipgroupMedia.checkedChipId == R.id.chip_no_media)
|
||||
) {
|
||||
chipgroupMedia.clearCheck()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialise the UI from the existing operator
|
||||
val choice = viewModel.getOperator<HasMediaOperator>()?.choice
|
||||
|
||||
choice?.let { option ->
|
||||
when (option) {
|
||||
NoMedia -> dialogBinding.chipNoMedia.isChecked = true
|
||||
is HasMedia -> {
|
||||
dialogBinding.chipHasMedia.isChecked = true
|
||||
|
||||
option.exclude.forEach { exceptMedia ->
|
||||
when (exceptMedia) {
|
||||
MediaKind.IMAGE -> dialogBinding.chipNoImage.isChecked = true
|
||||
MediaKind.VIDEO -> dialogBinding.chipNoVideo.isChecked = true
|
||||
MediaKind.AUDIO -> dialogBinding.chipNoAudio.isChecked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SpecificMedia -> {
|
||||
option.include.forEach { withMedia ->
|
||||
when (withMedia) {
|
||||
MediaKind.IMAGE -> dialogBinding.chipHasImage.isChecked = true
|
||||
MediaKind.VIDEO -> dialogBinding.chipHasVideo.isChecked = true
|
||||
MediaKind.AUDIO -> dialogBinding.chipHasAudio.isChecked = true
|
||||
}
|
||||
}
|
||||
option.exclude.forEach { exceptMedia ->
|
||||
when (exceptMedia) {
|
||||
MediaKind.IMAGE -> dialogBinding.chipNoImage.isChecked = true
|
||||
MediaKind.VIDEO -> dialogBinding.chipNoVideo.isChecked = true
|
||||
MediaKind.AUDIO -> dialogBinding.chipNoAudio.isChecked = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val button = AlertDialog.Builder(this@SearchActivity)
|
||||
.setView(dialogBinding.root)
|
||||
.setTitle(R.string.search_operator_attachment_dialog_title)
|
||||
.create()
|
||||
.await(android.R.string.ok, android.R.string.cancel)
|
||||
|
||||
if (button == AlertDialog.BUTTON_POSITIVE) {
|
||||
val option = if (dialogBinding.chipNoMedia.isChecked) {
|
||||
NoMedia
|
||||
} else {
|
||||
if (dialogBinding.chipHasMedia.isChecked) {
|
||||
val except = buildList {
|
||||
dialogBinding.chipNoImage.isChecked && add(MediaKind.IMAGE)
|
||||
dialogBinding.chipNoVideo.isChecked && add(MediaKind.VIDEO)
|
||||
dialogBinding.chipNoAudio.isChecked && add(MediaKind.AUDIO)
|
||||
}
|
||||
HasMedia(exclude = except)
|
||||
} else {
|
||||
val include = buildList {
|
||||
dialogBinding.chipHasImage.isChecked && add(MediaKind.IMAGE)
|
||||
dialogBinding.chipHasVideo.isChecked && add(MediaKind.VIDEO)
|
||||
dialogBinding.chipHasAudio.isChecked && add(MediaKind.AUDIO)
|
||||
}
|
||||
val exclude = buildList {
|
||||
dialogBinding.chipNoImage.isChecked && add(MediaKind.IMAGE)
|
||||
dialogBinding.chipNoVideo.isChecked && add(MediaKind.VIDEO)
|
||||
dialogBinding.chipNoAudio.isChecked && add(MediaKind.AUDIO)
|
||||
}
|
||||
if (include.isEmpty() && exclude.isEmpty()) {
|
||||
null
|
||||
} else {
|
||||
SpecificMedia(include = include, exclude = exclude)
|
||||
}
|
||||
}
|
||||
}
|
||||
viewModel.replaceOperator(SearchOperatorViewData.from(HasMediaOperator(option)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindDateChip(server: Server) {
|
||||
if (!server.can(ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE, ">=1.0.0".toConstraint())) {
|
||||
binding.chipDate.hide()
|
||||
return
|
||||
}
|
||||
binding.chipDate.show()
|
||||
|
||||
binding.chipDate.chipIcon = makeIcon(this, GoogleMaterial.Icon.gmd_date_range, IconicsSize.dp(24))
|
||||
binding.chipDate.setOnCloseIconClickListener {
|
||||
viewModel.replaceOperator(SearchOperatorViewData.from(DateOperator()))
|
||||
}
|
||||
|
||||
binding.chipDate.setOnClickListener {
|
||||
binding.chipDate.toggle()
|
||||
|
||||
lifecycleScope.launch {
|
||||
val picker = MaterialDatePicker.Builder.dateRangePicker()
|
||||
.setTitleText(R.string.search_operator_date_dialog_title)
|
||||
.setCalendarConstraints(
|
||||
CalendarConstraints.Builder()
|
||||
.setValidator(DateValidatorPointBackward.now())
|
||||
// Default behaviour is to show two months, with the current month
|
||||
// at the top. This wastes space, as the user can't select beyond
|
||||
// the current month, so open one month earlier to show this month
|
||||
// and the previous month on screen.
|
||||
.setOpenAt(
|
||||
LocalDateTime.now().minusMonths(1).toInstant(ZoneOffset.UTC).toEpochMilli(),
|
||||
)
|
||||
.build(),
|
||||
|
||||
)
|
||||
.build()
|
||||
.await(supportFragmentManager, "dateRangePicker")
|
||||
|
||||
picker ?: return@launch
|
||||
|
||||
val after = Instant.ofEpochMilli(picker.first).atOffset(ZoneOffset.UTC).toLocalDate()
|
||||
val before = Instant.ofEpochMilli(picker.second).atOffset(ZoneOffset.UTC).toLocalDate()
|
||||
|
||||
viewModel.replaceOperator(SearchOperatorViewData.from(DateOperator(DateRange(after, before))))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindFromChip(server: Server) {
|
||||
val canFrom = server.can(ORG_JOINMASTODON_SEARCH_QUERY_FROM, ">=1.0.0".toConstraint())
|
||||
val canFromMe = server.can(ORG_JOINMASTODON_SEARCH_QUERY_FROM, ">=1.1.0".toConstraint())
|
||||
if (!canFrom) {
|
||||
binding.chipFrom.hide()
|
||||
return
|
||||
}
|
||||
binding.chipFrom.show()
|
||||
|
||||
binding.chipFrom.chipIcon = makeIcon(this@SearchActivity, GoogleMaterial.Icon.gmd_person_search, IconicsSize.dp(24))
|
||||
binding.chipFrom.setOnCloseIconClickListener {
|
||||
viewModel.replaceOperator(SearchOperatorViewData.from(FromOperator()))
|
||||
}
|
||||
|
||||
binding.chipFrom.setOnClickListener {
|
||||
binding.chipFrom.toggle()
|
||||
|
||||
lifecycleScope.launch {
|
||||
val dialogBinding = SearchOperatorFromDialogBinding.inflate(layoutInflater, null, false)
|
||||
|
||||
dialogBinding.radioMe.visible(canFromMe)
|
||||
dialogBinding.radioIgnoreMe.visible(canFromMe)
|
||||
|
||||
// Initialise the UI from the existing operator
|
||||
when (val choice = viewModel.getOperator<FromOperator>()?.choice) {
|
||||
null -> dialogBinding.radioGroup.check(R.id.radioAll)
|
||||
is FromMe -> {
|
||||
if (choice.ignore) {
|
||||
dialogBinding.radioGroup.check(R.id.radioIgnoreMe)
|
||||
} else {
|
||||
dialogBinding.radioGroup.check(R.id.radioMe)
|
||||
}
|
||||
}
|
||||
|
||||
is FromAccount -> {
|
||||
if (choice.ignore) {
|
||||
dialogBinding.radioGroup.check(R.id.radioIgnoreOtherAccount)
|
||||
} else {
|
||||
dialogBinding.radioGroup.check(R.id.radioOtherAccount)
|
||||
}
|
||||
dialogBinding.account.setText(choice.account)
|
||||
}
|
||||
}
|
||||
|
||||
dialogBinding.account.setAdapter(
|
||||
ComposeAutoCompleteAdapter(
|
||||
this@SearchActivity,
|
||||
animateAvatar = false,
|
||||
animateEmojis = false,
|
||||
showBotBadge = true,
|
||||
),
|
||||
)
|
||||
|
||||
val dialog = AlertDialog.Builder(this@SearchActivity)
|
||||
.setView(dialogBinding.root)
|
||||
.setTitle(R.string.search_operator_from_dialog_title)
|
||||
.create()
|
||||
|
||||
// Configure UI that needs to refer to the create dialog.
|
||||
dialog.setOnShowListener {
|
||||
/**
|
||||
* Updates UI state when the user clicks on options.
|
||||
*
|
||||
* - Adjusts focus on the account entry view as necessary
|
||||
* - Disables the Ok button if one of the "other account" options is chosen and
|
||||
* another account hasn't been entered.
|
||||
*/
|
||||
fun updateUi() {
|
||||
val okButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE)
|
||||
|
||||
val enabled = when (dialogBinding.radioGroup.checkedRadioButtonId) {
|
||||
R.id.radioAll, R.id.radioMe, R.id.radioIgnoreMe -> {
|
||||
dialogBinding.account.clearFocus()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.radioOtherAccount, R.id.radioIgnoreOtherAccount -> {
|
||||
dialogBinding.account.requestFocus()
|
||||
val text = dialogBinding.account.text
|
||||
text.isNotBlank() && text.length >= 2
|
||||
}
|
||||
|
||||
else -> true
|
||||
}
|
||||
okButton.isEnabled = enabled
|
||||
}
|
||||
|
||||
dialogBinding.radioGroup.setOnCheckedChangeListener { _, _ -> updateUi() }
|
||||
dialogBinding.account.doAfterTextChanged {
|
||||
// Typing another account should set the other account radio button if one of
|
||||
// the two has not already been set. This ensures the user doesn't enter text
|
||||
// and tap OK before changing one of the radio buttons, losing their text.
|
||||
val checkedId = dialogBinding.radioGroup.checkedRadioButtonId
|
||||
if (checkedId != R.id.radioOtherAccount && checkedId != R.id.radioIgnoreOtherAccount) {
|
||||
dialogBinding.radioGroup.check(R.id.radioOtherAccount)
|
||||
}
|
||||
|
||||
updateUi()
|
||||
}
|
||||
}
|
||||
|
||||
val button = dialog.await(android.R.string.ok, android.R.string.cancel)
|
||||
|
||||
if (button == AlertDialog.BUTTON_POSITIVE) {
|
||||
val operator = when {
|
||||
dialogBinding.radioMe.isChecked -> FromOperator(FromMe(ignore = false))
|
||||
dialogBinding.radioIgnoreMe.isChecked -> FromOperator(FromMe(ignore = true))
|
||||
dialogBinding.radioOtherAccount.isChecked -> FromOperator(
|
||||
FromAccount(
|
||||
account =
|
||||
dialogBinding.account.text.toString(),
|
||||
ignore = false,
|
||||
),
|
||||
)
|
||||
|
||||
dialogBinding.radioIgnoreOtherAccount.isChecked -> FromOperator(
|
||||
FromAccount(
|
||||
account = dialogBinding.account.text.toString(),
|
||||
ignore = true,
|
||||
),
|
||||
)
|
||||
|
||||
else -> FromOperator()
|
||||
}
|
||||
viewModel.replaceOperator(SearchOperatorViewData.from(operator))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindLanguageChip(server: Server) {
|
||||
if (!server.can(ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE, ">=1.0.0".toConstraint())) {
|
||||
binding.chipLanguage.hide()
|
||||
return
|
||||
}
|
||||
binding.chipLanguage.show()
|
||||
|
||||
binding.chipLanguage.chipIcon = makeIcon(this, GoogleMaterial.Icon.gmd_translate, IconicsSize.dp(24))
|
||||
binding.chipLanguage.setOnCloseIconClickListener {
|
||||
viewModel.replaceOperator(SearchOperatorViewData.from(LanguageOperator()))
|
||||
}
|
||||
val locales = listOf(null) + viewModel.locales.value
|
||||
val displayLanguages = locales.map {
|
||||
it?.displayLanguage ?: getString(R.string.search_operator_language_dialog_all)
|
||||
}
|
||||
|
||||
binding.chipLanguage.setOnClickListener {
|
||||
binding.chipLanguage.toggle()
|
||||
|
||||
lifecycleScope.launch {
|
||||
val choice = viewModel.getOperator<LanguageOperator>()?.choice
|
||||
val index = locales.indexOf(choice)
|
||||
|
||||
val result = AlertDialog.Builder(this@SearchActivity)
|
||||
.setTitle(R.string.search_operator_language_dialog_title)
|
||||
.awaitSingleChoiceItem(
|
||||
displayLanguages,
|
||||
index,
|
||||
android.R.string.ok,
|
||||
android.R.string.cancel,
|
||||
)
|
||||
if (result.button == AlertDialog.BUTTON_POSITIVE && result.index != -1) {
|
||||
viewModel.replaceOperator(
|
||||
SearchOperatorViewData.from(LanguageOperator(locales[result.index])),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// binding.chipLink
|
||||
// empty operator (LinkOperator() here)
|
||||
// options to string map
|
||||
// Dialog title resource
|
||||
private fun bindHasLinkChip(server: Server) {
|
||||
if (!server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_LINK, ">=1.0.0".toConstraint())) {
|
||||
binding.chipHasLink.hide()
|
||||
return
|
||||
}
|
||||
binding.chipHasLink.show()
|
||||
|
||||
binding.chipHasLink.setOnCloseIconClickListener {
|
||||
viewModel.replaceOperator(SearchOperatorViewData.from(HasLinkOperator()))
|
||||
}
|
||||
|
||||
val options = listOf(
|
||||
null to R.string.search_operator_link_dialog_all,
|
||||
LinkKind.LINKS_ONLY to R.string.search_operator_link_dialog_only,
|
||||
LinkKind.NO_LINKS to R.string.search_operator_link_dialog_no_link,
|
||||
)
|
||||
|
||||
val displayOptions = options.map { getString(it.second) }
|
||||
|
||||
binding.chipHasLink.setOnClickListener {
|
||||
binding.chipHasLink.toggle()
|
||||
|
||||
lifecycleScope.launch {
|
||||
val selectedOption = viewModel.getOperator<HasLinkOperator>()?.choice
|
||||
val index = options.indexOfFirst { it.first == selectedOption }
|
||||
|
||||
val result = AlertDialog.Builder(this@SearchActivity)
|
||||
.setTitle(R.string.search_operator_link_dialog_title)
|
||||
.awaitSingleChoiceItem(
|
||||
displayOptions,
|
||||
index,
|
||||
android.R.string.ok,
|
||||
android.R.string.cancel,
|
||||
)
|
||||
|
||||
if (result.button == AlertDialog.BUTTON_POSITIVE && result.index != -1) {
|
||||
viewModel.replaceOperator(
|
||||
SearchOperatorViewData.from(HasLinkOperator(options[result.index].first)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindHasEmbedChip(server: Server) {
|
||||
if (!server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_EMBED, ">=1.0.0".toConstraint())) {
|
||||
binding.chipHasEmbed.hide()
|
||||
return
|
||||
}
|
||||
binding.chipHasEmbed.show()
|
||||
|
||||
binding.chipHasEmbed.chipIcon = makeIcon(this, GoogleMaterial.Icon.gmd_photo_size_select_actual, IconicsSize.dp(24))
|
||||
binding.chipHasEmbed.setOnCloseIconClickListener {
|
||||
viewModel.replaceOperator(SearchOperatorViewData.from(HasEmbedOperator()))
|
||||
}
|
||||
|
||||
val options = listOf(
|
||||
null to R.string.search_operator_embed_dialog_all,
|
||||
EmbedKind.EMBED_ONLY to R.string.search_operator_embed_dialog_only,
|
||||
EmbedKind.NO_EMBED to R.string.search_operator_embed_dialog_no_embeds,
|
||||
)
|
||||
|
||||
val displayOptions = options.map { getString(it.second) }
|
||||
|
||||
binding.chipHasEmbed.setOnClickListener {
|
||||
binding.chipHasEmbed.toggle()
|
||||
|
||||
lifecycleScope.launch {
|
||||
val selectedOption = viewModel.getOperator<HasEmbedOperator>()?.choice
|
||||
val index = options.indexOfFirst { it.first == selectedOption }
|
||||
|
||||
val result = AlertDialog.Builder(this@SearchActivity)
|
||||
.setTitle(R.string.search_operator_embed_dialog_title)
|
||||
.awaitSingleChoiceItem(
|
||||
displayOptions,
|
||||
index,
|
||||
android.R.string.ok,
|
||||
android.R.string.cancel,
|
||||
)
|
||||
|
||||
if (result.button == AlertDialog.BUTTON_POSITIVE && result.index != -1) {
|
||||
viewModel.replaceOperator(
|
||||
SearchOperatorViewData.from(HasEmbedOperator(options[result.index].first)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindHasPollChip(server: Server) {
|
||||
if (!server.can(ORG_JOINMASTODON_SEARCH_QUERY_HAS_POLL, ">=1.0.0".toConstraint())) {
|
||||
binding.chipHasPoll.hide()
|
||||
return
|
||||
}
|
||||
binding.chipHasPoll.show()
|
||||
|
||||
binding.chipHasPoll.chipIcon = makeIcon(this, GoogleMaterial.Icon.gmd_poll, IconicsSize.dp(24))
|
||||
binding.chipHasPoll.setOnCloseIconClickListener {
|
||||
viewModel.replaceOperator(SearchOperatorViewData.from(HasPollOperator()))
|
||||
}
|
||||
|
||||
val options = listOf(
|
||||
null to R.string.search_operator_poll_dialog_all,
|
||||
HasPollOperator.PollKind.POLLS_ONLY to R.string.search_operator_poll_dialog_only,
|
||||
HasPollOperator.PollKind.NO_POLLS to R.string.search_operator_poll_dialog_no_polls,
|
||||
)
|
||||
|
||||
val displayOptions = options.map { getString(it.second) }
|
||||
|
||||
binding.chipHasPoll.setOnClickListener {
|
||||
binding.chipHasPoll.toggle()
|
||||
|
||||
lifecycleScope.launch {
|
||||
val choice = viewModel.getOperator<HasPollOperator>()?.choice
|
||||
val index = options.indexOfFirst { it.first == choice }
|
||||
|
||||
val result = AlertDialog.Builder(this@SearchActivity)
|
||||
.setTitle(R.string.search_operator_poll_dialog_title)
|
||||
.awaitSingleChoiceItem(
|
||||
displayOptions,
|
||||
index,
|
||||
android.R.string.ok,
|
||||
android.R.string.cancel,
|
||||
)
|
||||
|
||||
if (result.button == AlertDialog.BUTTON_POSITIVE && result.index != -1) {
|
||||
viewModel.replaceOperator(
|
||||
SearchOperatorViewData.from(HasPollOperator(options[result.index].first)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindIsReplyChip(server: Server) {
|
||||
if (!server.can(ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY, ">=1.0.0".toConstraint())) {
|
||||
binding.chipIsReply.hide()
|
||||
return
|
||||
}
|
||||
binding.chipIsReply.show()
|
||||
|
||||
binding.chipIsReply.setOnCloseIconClickListener {
|
||||
viewModel.replaceOperator(SearchOperatorViewData.from(IsReplyOperator()))
|
||||
}
|
||||
|
||||
val options = listOf(
|
||||
null to R.string.search_operator_replies_dialog_all,
|
||||
ReplyKind.REPLIES_ONLY to R.string.search_operator_replies_dialog_replies_only,
|
||||
ReplyKind.NO_REPLIES to R.string.search_operator_replies_dialog_no_replies,
|
||||
)
|
||||
|
||||
val displayOptions = options.map { getString(it.second) }
|
||||
|
||||
binding.chipIsReply.setOnClickListener {
|
||||
binding.chipIsReply.toggle()
|
||||
|
||||
lifecycleScope.launch {
|
||||
val choice = viewModel.getOperator<IsReplyOperator>()?.choice
|
||||
val index = options.indexOfFirst { it.first == choice }
|
||||
|
||||
val result = AlertDialog.Builder(this@SearchActivity)
|
||||
.setTitle(R.string.search_operator_replies_dialog_title)
|
||||
.awaitSingleChoiceItem(
|
||||
displayOptions,
|
||||
index,
|
||||
android.R.string.ok,
|
||||
android.R.string.cancel,
|
||||
)
|
||||
|
||||
if (result.button == AlertDialog.BUTTON_POSITIVE && result.index != -1) {
|
||||
viewModel.replaceOperator(
|
||||
SearchOperatorViewData.from(IsReplyOperator(options[result.index].first)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindIsSensitiveChip(server: Server) {
|
||||
if (!server.can(ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE, ">=1.0.0".toConstraint())) {
|
||||
binding.chipIsSensitive.hide()
|
||||
return
|
||||
}
|
||||
binding.chipIsSensitive.show()
|
||||
|
||||
binding.chipIsSensitive.setOnCloseIconClickListener {
|
||||
viewModel.replaceOperator(SearchOperatorViewData.from(IsSensitiveOperator()))
|
||||
}
|
||||
|
||||
val options = listOf(
|
||||
null to R.string.search_operator_sensitive_dialog_all,
|
||||
SensitiveKind.SENSITIVE_ONLY to R.string.search_operator_sensitive_dialog_sensitive_only,
|
||||
SensitiveKind.NO_SENSITIVE to R.string.search_operator_sensitive_dialog_no_sensitive,
|
||||
)
|
||||
|
||||
val displayOptions = options.map { getString(it.second) }
|
||||
|
||||
binding.chipIsSensitive.setOnClickListener {
|
||||
binding.chipIsSensitive.toggle()
|
||||
|
||||
lifecycleScope.launch {
|
||||
val choice = viewModel.getOperator<IsSensitiveOperator>()?.choice
|
||||
val index = options.indexOfFirst { it.first == choice }
|
||||
|
||||
val result = AlertDialog.Builder(this@SearchActivity)
|
||||
.setTitle(R.string.search_operator_sensitive_dialog_title)
|
||||
.awaitSingleChoiceItem(
|
||||
displayOptions,
|
||||
index,
|
||||
android.R.string.ok,
|
||||
android.R.string.cancel,
|
||||
)
|
||||
|
||||
if (result.button == AlertDialog.BUTTON_POSITIVE && result.index != -1) {
|
||||
viewModel.replaceOperator(
|
||||
SearchOperatorViewData.from(IsSensitiveOperator(options[result.index].first)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressLint("ClickableViewAccessibility")
|
||||
private fun bindWhereChip(server: Server) {
|
||||
val canInLibrary = server.can(ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY, ">=1.0.0".toConstraint())
|
||||
val canInPublic = server.can(ORG_JOINMASTODON_SEARCH_QUERY_IN_PUBLIC, ">=1.0.0".toConstraint())
|
||||
|
||||
if (!canInLibrary && !canInPublic) {
|
||||
binding.chipWhere.hide()
|
||||
return
|
||||
}
|
||||
binding.chipWhere.show()
|
||||
|
||||
binding.chipWhere.chipIcon = makeIcon(this, GoogleMaterial.Icon.gmd_find_in_page, IconicsSize.dp(24))
|
||||
binding.chipWhere.setOnCloseIconClickListener {
|
||||
viewModel.replaceOperator(SearchOperatorViewData.from(WhereOperator()))
|
||||
}
|
||||
|
||||
binding.chipWhere.setOnClickListener {
|
||||
binding.chipWhere.toggle()
|
||||
|
||||
lifecycleScope.launch {
|
||||
val choice = viewModel.getOperator<WhereOperator>()?.choice
|
||||
|
||||
val dialogBinding = SearchOperatorWhereLocationDialogBinding.inflate(layoutInflater, null, false)
|
||||
|
||||
dialogBinding.whereLibraryTitle.visible(canInLibrary)
|
||||
dialogBinding.whereLibraryDescription.visible(canInLibrary)
|
||||
|
||||
dialogBinding.wherePublicTitle.visible(canInPublic)
|
||||
dialogBinding.wherePublicDescription.visible(canInPublic)
|
||||
|
||||
dialogBinding.whereAllDialogGroup.check(
|
||||
when (choice) {
|
||||
null -> R.id.where_all_title
|
||||
WhereLocation.LIBRARY -> R.id.where_library_title
|
||||
WhereLocation.PUBLIC -> R.id.where_public_title
|
||||
},
|
||||
)
|
||||
|
||||
// Dispatch touch events on the descriptions to the view with
|
||||
// the radiobutton. Dispatching touch events ensures behaviour
|
||||
// -- like the ripple that appears if the view is long-pressed --
|
||||
// is retained.
|
||||
dialogBinding.whereAllDescription.setOnTouchListener { _, event ->
|
||||
dialogBinding.whereAllTitle.onTouchEvent(event)
|
||||
}
|
||||
dialogBinding.whereLibraryDescription.setOnTouchListener { _, event ->
|
||||
dialogBinding.whereLibraryTitle.onTouchEvent(event)
|
||||
}
|
||||
dialogBinding.wherePublicDescription.setOnTouchListener { _, event ->
|
||||
dialogBinding.wherePublicTitle.onTouchEvent(event)
|
||||
}
|
||||
|
||||
val button = AlertDialog.Builder(this@SearchActivity)
|
||||
.setView(dialogBinding.root)
|
||||
.setTitle(R.string.search_operator_where_dialog_title)
|
||||
.create()
|
||||
.await(android.R.string.ok, android.R.string.cancel)
|
||||
|
||||
if (button == AlertDialog.BUTTON_POSITIVE) {
|
||||
val location = when (dialogBinding.whereAllDialogGroup.checkedRadioButtonId) {
|
||||
R.id.where_library_title -> WhereLocation.LIBRARY
|
||||
R.id.where_public_title -> WhereLocation.PUBLIC
|
||||
else -> null
|
||||
}
|
||||
viewModel.replaceOperator(SearchOperatorViewData.from(WhereOperator(location)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
handleIntent(intent)
|
||||
|
@ -79,8 +891,8 @@ class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTe
|
|||
menuInflater.inflate(R.menu.search_toolbar, menu)
|
||||
val searchViewMenuItem = menu.findItem(R.id.action_search)
|
||||
searchViewMenuItem.expandActionView()
|
||||
val searchView = searchViewMenuItem.actionView as SearchView
|
||||
setupSearchView(searchView)
|
||||
searchView = searchViewMenuItem.actionView as SearchView
|
||||
bindSearchView()
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
|
@ -99,12 +911,13 @@ class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTe
|
|||
|
||||
private fun handleIntent(intent: Intent) {
|
||||
if (Intent.ACTION_SEARCH == intent.action) {
|
||||
searchView.clearFocus()
|
||||
viewModel.currentQuery = intent.getStringExtra(SearchManager.QUERY).orEmpty()
|
||||
viewModel.search(viewModel.currentQuery)
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupSearchView(searchView: SearchView) {
|
||||
private fun bindSearchView() {
|
||||
searchView.setIconifiedByDefault(false)
|
||||
searchView.setSearchableInfo((getSystemService(Context.SEARCH_SERVICE) as? SearchManager)?.getSearchableInfo(componentName))
|
||||
|
||||
|
@ -142,7 +955,7 @@ class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTe
|
|||
// Only focus if the query is empty. This ensures that if the user is returning
|
||||
// to the search results after visiting a result the full list is available,
|
||||
// instead of being obscured by the keyboard.
|
||||
if (searchView.query.isBlank()) searchView.requestFocus()
|
||||
if (viewModel.currentSearchFieldContent?.isBlank() == true) searchView.requestFocus()
|
||||
}
|
||||
|
||||
override fun onQueryTextSubmit(query: String?): Boolean {
|
||||
|
@ -154,4 +967,9 @@ class SearchActivity : BottomSheetActivity(), MenuProvider, SearchView.OnQueryTe
|
|||
|
||||
return false
|
||||
}
|
||||
|
||||
// Seach for autocomplete suggestions
|
||||
override suspend fun search(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
return viewModel.searchAccountAutocompleteSuggestions(token)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,242 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.components.search
|
||||
|
||||
import app.pachli.BuildConfig
|
||||
import app.pachli.util.modernLanguageCode
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/** Mastodon search operators. */
|
||||
sealed interface SearchOperator {
|
||||
/**
|
||||
* The user's choice from the set of possible choices the operator supports.
|
||||
*
|
||||
* If null the user has not made a specific choice, and the operator's default
|
||||
* should be used.
|
||||
*/
|
||||
val choice: Any?
|
||||
|
||||
/**
|
||||
* @return Text to include in the search query if [choice] is non-null.
|
||||
*/
|
||||
fun query(): String?
|
||||
|
||||
/**
|
||||
* The `has:{media,image,video,audio}` operator.
|
||||
*
|
||||
* Mastodon does not let you create posts that have attached media and a poll
|
||||
* or other attachment. But it will store and return statuses sent from other
|
||||
* systems that do not have this restriction.
|
||||
*
|
||||
* @see HasEmbedOperator
|
||||
* @see HasPollOperator
|
||||
*/
|
||||
data class HasMediaOperator(override val choice: HasMediaOption? = null) : SearchOperator {
|
||||
enum class MediaKind(val q: String) {
|
||||
IMAGE("image"),
|
||||
VIDEO("video"),
|
||||
AUDIO("audio"),
|
||||
}
|
||||
|
||||
/** The specific `has:{media,image,video,audio}` operator in use. */
|
||||
sealed interface HasMediaOption {
|
||||
/** Exclude posts that have any media attached.
|
||||
*
|
||||
* Equivalent to `-has:media`.
|
||||
*/
|
||||
data object NoMedia : HasMediaOption
|
||||
|
||||
/**
|
||||
* Include only posts that have any media attached, except posts that
|
||||
* have media types in [exclude].
|
||||
*
|
||||
* Equivalent to `has:media`, with zero or more additional `-has:[exclude]`
|
||||
* after.
|
||||
*/
|
||||
data class HasMedia(val exclude: List<MediaKind> = emptyList()) : HasMediaOption
|
||||
|
||||
/**
|
||||
* Include only posts that have [include] media attached, excluding posts
|
||||
* that have [exclude] attached media.
|
||||
*/
|
||||
data class SpecificMedia(
|
||||
val include: List<MediaKind> = emptyList(),
|
||||
val exclude: List<MediaKind> = emptyList(),
|
||||
) : HasMediaOption {
|
||||
init {
|
||||
// Check
|
||||
// - with and without can't both be empty
|
||||
// - with and without should not contain any shared elements
|
||||
if (BuildConfig.DEBUG) {
|
||||
assert(!(include.isEmpty() && exclude.isEmpty()))
|
||||
assert(include.intersect(exclude.toSet()).isEmpty())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun query(): String? {
|
||||
choice ?: return null
|
||||
|
||||
return when (choice) {
|
||||
HasMediaOption.NoMedia -> ("-has:media")
|
||||
is HasMediaOption.HasMedia -> buildList {
|
||||
add("has:media")
|
||||
choice.exclude.forEach { add("-has:${it.q}") }
|
||||
}.joinToString(" ")
|
||||
|
||||
is HasMediaOption.SpecificMedia -> buildList {
|
||||
choice.include.forEach { add("has:${it.q}") }
|
||||
choice.exclude.forEach { add("-has:${it.q}") }
|
||||
}.joinToString(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The date-range operator. Creates `after:... before:...`. */
|
||||
class DateOperator(override val choice: DateRange? = null) : SearchOperator {
|
||||
/**
|
||||
* The date range to search.
|
||||
*
|
||||
* @param startDate Earliest date to search (inclusive)
|
||||
* @param endDate Latest date to search (inclusive)
|
||||
*/
|
||||
data class DateRange(val startDate: LocalDate, val endDate: LocalDate) {
|
||||
// This class treats the date range as **inclusive** of the start and
|
||||
// end dates, Mastodon's search treats the dates as exclusive, so the
|
||||
// range must be expanded by one day in each direction when creating
|
||||
// the search string.
|
||||
fun fmt() = "after:${formatter.format(startDate.minusDays(1))} before:${formatter.format(endDate.plusDays(1))}"
|
||||
|
||||
companion object {
|
||||
private val formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd")
|
||||
}
|
||||
}
|
||||
|
||||
override fun query() = choice?.fmt()
|
||||
}
|
||||
|
||||
/** The `from:...` operator. */
|
||||
class FromOperator(override val choice: FromKind? = null) : SearchOperator {
|
||||
/** The specific `from:...` operator in use. */
|
||||
sealed interface FromKind {
|
||||
val ignore: Boolean
|
||||
val q: String
|
||||
|
||||
/** `from:me`, or `-from:me` if [ignore] is true. */
|
||||
data class FromMe(override val ignore: Boolean) : FromKind {
|
||||
override val q: String
|
||||
get() = if (ignore) "-from:me" else "from:me"
|
||||
}
|
||||
|
||||
/**
|
||||
* `from:<account>` or `-from:<account>` if [ignore] is true.
|
||||
*
|
||||
* @param account The account name. Any leading `@` will be removed.
|
||||
*/
|
||||
data class FromAccount(val account: String, override val ignore: Boolean) : FromKind {
|
||||
override val q: String
|
||||
get() = if (ignore) "-from:${account.removePrefix("@")}" else "from:${account.removePrefix("@")}"
|
||||
}
|
||||
}
|
||||
|
||||
override fun query() = choice?.q
|
||||
}
|
||||
|
||||
/**
|
||||
* The `has:embed` operator.
|
||||
*
|
||||
* @see HasMediaOperator
|
||||
* @see HasPollOperator
|
||||
*/
|
||||
data class HasEmbedOperator(override val choice: EmbedKind? = null) : SearchOperator {
|
||||
enum class EmbedKind(val q: String) {
|
||||
EMBED_ONLY("has:embed"),
|
||||
NO_EMBED("-has:embed"),
|
||||
}
|
||||
|
||||
override fun query() = choice?.q
|
||||
}
|
||||
|
||||
/**
|
||||
* The `language:...` operator.
|
||||
*
|
||||
* @param choice Restrict results to posts written in [Locale.modernLanguageCode].
|
||||
*/
|
||||
class LanguageOperator(override val choice: Locale? = null) : SearchOperator {
|
||||
override fun query() = choice?.let { "language:${it.modernLanguageCode}" }
|
||||
}
|
||||
|
||||
/** The `has:link` operator. */
|
||||
data class HasLinkOperator(override val choice: LinkKind? = null) : SearchOperator {
|
||||
enum class LinkKind(val q: String) {
|
||||
LINKS_ONLY("has:link"),
|
||||
NO_LINKS("-has:link"),
|
||||
}
|
||||
|
||||
override fun query() = choice?.q
|
||||
}
|
||||
|
||||
/**
|
||||
* The `has:poll` operator.
|
||||
*
|
||||
* @see HasEmbedOperator
|
||||
* @see HasMediaOperator
|
||||
*/
|
||||
data class HasPollOperator(override val choice: PollKind? = null) : SearchOperator {
|
||||
enum class PollKind(val q: String) {
|
||||
POLLS_ONLY("has:poll"),
|
||||
NO_POLLS("-has:poll"),
|
||||
}
|
||||
|
||||
override fun query() = choice?.q
|
||||
}
|
||||
|
||||
/** The `is:reply` operator. */
|
||||
class IsReplyOperator(override val choice: ReplyKind? = null) : SearchOperator {
|
||||
enum class ReplyKind(val q: String) {
|
||||
REPLIES_ONLY("is:reply"),
|
||||
NO_REPLIES("-is:reply"),
|
||||
}
|
||||
|
||||
override fun query() = choice?.q
|
||||
}
|
||||
|
||||
/** The `is:sensitive` operator. */
|
||||
class IsSensitiveOperator(override val choice: SensitiveKind? = null) : SearchOperator {
|
||||
// (choice) {
|
||||
enum class SensitiveKind(val q: String) {
|
||||
SENSITIVE_ONLY("is:sensitive"),
|
||||
NO_SENSITIVE("-is:sensitive"),
|
||||
}
|
||||
|
||||
override fun query() = choice?.q
|
||||
}
|
||||
|
||||
/** The `in:...` operator. */
|
||||
class WhereOperator(override val choice: WhereLocation? = null) : SearchOperator {
|
||||
enum class WhereLocation(val q: String) {
|
||||
LIBRARY("in:library"),
|
||||
PUBLIC("in:public"),
|
||||
}
|
||||
|
||||
override fun query() = choice?.q
|
||||
}
|
||||
}
|
|
@ -0,0 +1,288 @@
|
|||
/*
|
||||
* Copyright 2024 Pachli Association
|
||||
*
|
||||
* This file is a part of Pachli.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* Pachli 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 Pachli; if not,
|
||||
* see <http://www.gnu.org/licenses>.
|
||||
*/
|
||||
|
||||
package app.pachli.components.search
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import app.pachli.R
|
||||
import app.pachli.components.search.SearchOperator.DateOperator
|
||||
import app.pachli.components.search.SearchOperator.FromOperator
|
||||
import app.pachli.components.search.SearchOperator.HasEmbedOperator
|
||||
import app.pachli.components.search.SearchOperator.HasLinkOperator
|
||||
import app.pachli.components.search.SearchOperator.HasMediaOperator
|
||||
import app.pachli.components.search.SearchOperator.HasMediaOperator.HasMediaOption.HasMedia
|
||||
import app.pachli.components.search.SearchOperator.HasMediaOperator.HasMediaOption.NoMedia
|
||||
import app.pachli.components.search.SearchOperator.HasMediaOperator.HasMediaOption.SpecificMedia
|
||||
import app.pachli.components.search.SearchOperator.HasMediaOperator.MediaKind
|
||||
import app.pachli.components.search.SearchOperator.HasPollOperator
|
||||
import app.pachli.components.search.SearchOperator.IsReplyOperator
|
||||
import app.pachli.components.search.SearchOperator.IsSensitiveOperator
|
||||
import app.pachli.components.search.SearchOperator.LanguageOperator
|
||||
import app.pachli.components.search.SearchOperator.WhereOperator
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
/**
|
||||
* Wrapper for [SearchOperator] that includes additional information to show the
|
||||
* operator as a chip in the UI.
|
||||
*/
|
||||
sealed interface SearchOperatorViewData<out T : SearchOperator> {
|
||||
/** Underlying [SearchOperator]. */
|
||||
val operator: T
|
||||
|
||||
/** @return The label to use on the chip for [operator]. */
|
||||
fun chipLabel(context: Context): String
|
||||
|
||||
companion object {
|
||||
/** @return The correct [SearchOperatorViewData] for the [operator]. */
|
||||
fun from(operator: SearchOperator) = when (operator) {
|
||||
is HasMediaOperator -> HasMediaOperatorViewData(operator)
|
||||
is DateOperator -> DateOperatorViewData(operator)
|
||||
is FromOperator -> FromOperatorViewData(operator)
|
||||
is HasEmbedOperator -> HasEmbedOperatorViewData(operator)
|
||||
is LanguageOperator -> LanguageOperatorViewData(operator)
|
||||
is HasLinkOperator -> HasLinkOperatorViewData(operator)
|
||||
is HasPollOperator -> HasPollOperatorViewData(operator)
|
||||
is IsReplyOperator -> IsReplyOperatorViewData(operator)
|
||||
is IsSensitiveOperator -> IsSensitiveOperatorViewData(operator)
|
||||
is WhereOperator -> WhereOperatorViewData(operator)
|
||||
}
|
||||
}
|
||||
|
||||
data class HasMediaOperatorViewData(override val operator: HasMediaOperator) : SearchOperatorViewData<HasMediaOperator> {
|
||||
override fun chipLabel(context: Context): String {
|
||||
fun MediaKind.label() = context.getString(this.stringResource)
|
||||
|
||||
return when (val choice = operator.choice) {
|
||||
null -> context.getString(R.string.search_operator_attachment_all)
|
||||
NoMedia -> context.getString(R.string.search_operator_attachment_no_media_label)
|
||||
is HasMedia -> {
|
||||
val exclude = choice.exclude
|
||||
if (exclude.isEmpty()) {
|
||||
context.getString(R.string.search_operator_attachment_has_media_label)
|
||||
} else {
|
||||
when (exclude.size) {
|
||||
1 -> context.getString(
|
||||
R.string.search_operator_attachment_has_media_except_1_label_fmt,
|
||||
exclude[0].label(),
|
||||
)
|
||||
|
||||
2 -> context.getString(
|
||||
R.string.search_operator_attachment_has_media_except_2_label_fmt,
|
||||
exclude[0].label(),
|
||||
exclude[1].label(),
|
||||
)
|
||||
|
||||
else -> context.getString(
|
||||
R.string.search_operator_attachment_has_media_except_3_label_fmt,
|
||||
exclude[0].label(),
|
||||
exclude[1].label(),
|
||||
exclude[2].label(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
is SpecificMedia -> {
|
||||
val include = choice.include
|
||||
val exclude = choice.exclude
|
||||
|
||||
when {
|
||||
// With and except
|
||||
include.isNotEmpty() && exclude.isNotEmpty() -> when {
|
||||
// Include 2, exclude 1
|
||||
include.size == 2 -> context.getString(
|
||||
R.string.search_operator_attachment_specific_media_include_2_exclude_1_fmt,
|
||||
include[0].label(),
|
||||
include[1].label(),
|
||||
exclude[0].label(),
|
||||
)
|
||||
|
||||
// Include 1, exclude 2
|
||||
include.size == 1 && exclude.size == 2 -> context.getString(
|
||||
R.string.search_operator_attachment_specific_media_include_1_exclude_2_fmt,
|
||||
include[0].label(),
|
||||
exclude[0].label(),
|
||||
exclude[1].label(),
|
||||
)
|
||||
|
||||
// Include 1, exclude 1
|
||||
else -> context.getString(
|
||||
R.string.search_operator_attachment_specific_media_include_1_exclude_1_fmt,
|
||||
include[0].label(),
|
||||
exclude[0].label(),
|
||||
)
|
||||
}
|
||||
// Include only
|
||||
include.isNotEmpty() -> when (include.size) {
|
||||
1 -> context.getString(
|
||||
R.string.search_operator_attachment_specific_media_include_1_fmt,
|
||||
include[0].label(),
|
||||
)
|
||||
|
||||
2 -> context.getString(
|
||||
R.string.search_operator_attachment_specific_media_include_2_fmt,
|
||||
include[0].label(),
|
||||
include[1].label(),
|
||||
)
|
||||
|
||||
else -> context.getString(
|
||||
R.string.search_operator_attachment_specific_media_include_3_fmt,
|
||||
include[0].label(),
|
||||
include[1].label(),
|
||||
include[2].label(),
|
||||
)
|
||||
}
|
||||
// exclude only
|
||||
else -> when (exclude.size) {
|
||||
1 -> context.getString(
|
||||
R.string.search_operator_attachment_specific_media_exclude_1_fmt,
|
||||
exclude[0].label(),
|
||||
)
|
||||
|
||||
2 -> context.getString(
|
||||
R.string.search_operator_attachment_specific_media_exclude_2_fmt,
|
||||
exclude[0].label(),
|
||||
exclude[1].label(),
|
||||
)
|
||||
|
||||
else -> context.getString(
|
||||
R.string.search_operator_attachment_specific_media_exclude_3_fmt,
|
||||
exclude[0].label(),
|
||||
exclude[1].label(),
|
||||
exclude[2].label(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@get:StringRes
|
||||
val MediaKind.stringResource: Int
|
||||
get() = when (this) {
|
||||
MediaKind.IMAGE -> R.string.search_operator_attachment_kind_image_label
|
||||
MediaKind.VIDEO -> R.string.search_operator_attachment_kind_video_label
|
||||
MediaKind.AUDIO -> R.string.search_operator_attachment_kind_audio_label
|
||||
}
|
||||
}
|
||||
|
||||
data class DateOperatorViewData(override val operator: DateOperator) : SearchOperatorViewData<DateOperator> {
|
||||
private val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
|
||||
override fun chipLabel(context: Context) = when (operator.choice) {
|
||||
null -> context.getString(R.string.search_operator_date_all)
|
||||
else -> {
|
||||
if (operator.choice.startDate == operator.choice.endDate) {
|
||||
context.getString(
|
||||
R.string.search_operator_date_checked_same_day,
|
||||
formatter.format(operator.choice.startDate),
|
||||
)
|
||||
} else {
|
||||
context.getString(
|
||||
R.string.search_operator_date_checked,
|
||||
formatter.format(operator.choice.startDate),
|
||||
formatter.format(operator.choice.endDate),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class HasEmbedOperatorViewData(override val operator: HasEmbedOperator) : SearchOperatorViewData<HasEmbedOperator> {
|
||||
override fun chipLabel(context: Context) = context.getString(
|
||||
when (operator.choice) {
|
||||
null -> R.string.search_operator_embed_all
|
||||
HasEmbedOperator.EmbedKind.EMBED_ONLY -> R.string.search_operator_embed_only
|
||||
HasEmbedOperator.EmbedKind.NO_EMBED -> R.string.search_operator_embed_no_embeds
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
data class FromOperatorViewData(override val operator: FromOperator) : SearchOperatorViewData<FromOperator> {
|
||||
override fun chipLabel(context: Context) = when (val choice = operator.choice) {
|
||||
null -> context.getString(R.string.search_operator_from_all)
|
||||
is FromOperator.FromKind.FromMe -> context.getString(
|
||||
if (choice.ignore) R.string.search_operator_from_ignore_me else R.string.search_operator_from_me,
|
||||
)
|
||||
|
||||
is FromOperator.FromKind.FromAccount -> context.getString(
|
||||
if (choice.ignore) {
|
||||
R.string.search_operator_from_ignore_account_fmt
|
||||
} else {
|
||||
R.string.search_operator_from_account_fmt
|
||||
},
|
||||
choice.account,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class LanguageOperatorViewData(override val operator: LanguageOperator) : SearchOperatorViewData<LanguageOperator> {
|
||||
override fun chipLabel(context: Context) = when (operator.choice) {
|
||||
null -> context.getString(R.string.search_operator_language_all)
|
||||
else -> context.getString(
|
||||
R.string.search_operator_language_checked_fmt,
|
||||
operator.choice.displayLanguage,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class HasLinkOperatorViewData(override val operator: HasLinkOperator) : SearchOperatorViewData<HasLinkOperator> {
|
||||
override fun chipLabel(context: Context) = context.getString(
|
||||
when (operator.choice) {
|
||||
null -> R.string.search_operator_link_all
|
||||
HasLinkOperator.LinkKind.LINKS_ONLY -> R.string.search_operator_link_only
|
||||
HasLinkOperator.LinkKind.NO_LINKS -> R.string.search_operator_no_link
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
data class HasPollOperatorViewData(override val operator: HasPollOperator) : SearchOperatorViewData<HasPollOperator> {
|
||||
override fun chipLabel(context: Context) = context.getString(
|
||||
when (operator.choice) {
|
||||
null -> R.string.search_operator_poll_all
|
||||
HasPollOperator.PollKind.POLLS_ONLY -> R.string.search_operator_poll_only
|
||||
HasPollOperator.PollKind.NO_POLLS -> R.string.search_operator_poll_no_polls
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
data class IsReplyOperatorViewData(override val operator: IsReplyOperator) : SearchOperatorViewData<IsReplyOperator> {
|
||||
override fun chipLabel(context: Context) = when (operator.choice) {
|
||||
null -> context.getString(R.string.search_operator_replies_all)
|
||||
IsReplyOperator.ReplyKind.REPLIES_ONLY -> context.getString(R.string.search_operator_replies_replies_only)
|
||||
IsReplyOperator.ReplyKind.NO_REPLIES -> context.getString(R.string.search_operator_replies_no_replies)
|
||||
}
|
||||
}
|
||||
|
||||
data class IsSensitiveOperatorViewData(override val operator: IsSensitiveOperator) : SearchOperatorViewData<IsSensitiveOperator> {
|
||||
override fun chipLabel(context: Context) = when (operator.choice) {
|
||||
null -> context.getString(R.string.search_operator_sensitive_all)
|
||||
IsSensitiveOperator.SensitiveKind.SENSITIVE_ONLY -> context.getString(R.string.search_operator_sensitive_sensitive_only)
|
||||
IsSensitiveOperator.SensitiveKind.NO_SENSITIVE -> context.getString(R.string.search_operator_sensitive_no_sensitive)
|
||||
}
|
||||
}
|
||||
|
||||
data class WhereOperatorViewData(override val operator: WhereOperator) : SearchOperatorViewData<WhereOperator> {
|
||||
override fun chipLabel(context: Context) = when (operator.choice) {
|
||||
null -> context.getString(R.string.search_operator_where_all)
|
||||
WhereOperator.WhereLocation.LIBRARY -> context.getString(R.string.search_operator_where_library)
|
||||
WhereOperator.WhereLocation.PUBLIC -> context.getString(R.string.search_operator_where_public)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -21,14 +21,28 @@ import androidx.lifecycle.viewModelScope
|
|||
import androidx.paging.Pager
|
||||
import androidx.paging.PagingConfig
|
||||
import androidx.paging.cachedIn
|
||||
import app.pachli.components.compose.ComposeAutoCompleteAdapter
|
||||
import app.pachli.components.search.SearchOperator.DateOperator
|
||||
import app.pachli.components.search.SearchOperator.FromOperator
|
||||
import app.pachli.components.search.SearchOperator.HasEmbedOperator
|
||||
import app.pachli.components.search.SearchOperator.HasLinkOperator
|
||||
import app.pachli.components.search.SearchOperator.HasMediaOperator
|
||||
import app.pachli.components.search.SearchOperator.HasPollOperator
|
||||
import app.pachli.components.search.SearchOperator.IsReplyOperator
|
||||
import app.pachli.components.search.SearchOperator.IsSensitiveOperator
|
||||
import app.pachli.components.search.SearchOperator.LanguageOperator
|
||||
import app.pachli.components.search.SearchOperator.WhereOperator
|
||||
import app.pachli.components.search.adapter.SearchPagingSourceFactory
|
||||
import app.pachli.core.accounts.AccountManager
|
||||
import app.pachli.core.data.repository.ServerRepository
|
||||
import app.pachli.core.database.model.AccountEntity
|
||||
import app.pachli.core.network.model.DeletedStatus
|
||||
import app.pachli.core.network.model.Poll
|
||||
import app.pachli.core.network.model.Status
|
||||
import app.pachli.core.network.retrofit.MastodonApi
|
||||
import app.pachli.usecase.TimelineCases
|
||||
import app.pachli.util.getInitialLanguages
|
||||
import app.pachli.util.getLocaleList
|
||||
import app.pachli.viewdata.StatusViewData
|
||||
import at.connyduck.calladapter.networkresult.NetworkResult
|
||||
import at.connyduck.calladapter.networkresult.fold
|
||||
|
@ -37,14 +51,21 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
|||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@HiltViewModel
|
||||
class SearchViewModel @Inject constructor(
|
||||
mastodonApi: MastodonApi,
|
||||
private val mastodonApi: MastodonApi,
|
||||
private val timelineCases: TimelineCases,
|
||||
private val accountManager: AccountManager,
|
||||
private val serverRepository: ServerRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
var currentQuery: String = ""
|
||||
|
@ -57,6 +78,46 @@ class SearchViewModel @Inject constructor(
|
|||
private val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false
|
||||
private val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false
|
||||
|
||||
private val _operatorViewData = MutableStateFlow(
|
||||
setOf(
|
||||
SearchOperatorViewData.from(HasMediaOperator()),
|
||||
SearchOperatorViewData.from(DateOperator()),
|
||||
SearchOperatorViewData.from(HasEmbedOperator()),
|
||||
SearchOperatorViewData.from(FromOperator()),
|
||||
SearchOperatorViewData.from(LanguageOperator()),
|
||||
SearchOperatorViewData.from(HasLinkOperator()),
|
||||
SearchOperatorViewData.from(HasPollOperator()),
|
||||
SearchOperatorViewData.from(IsReplyOperator()),
|
||||
SearchOperatorViewData.from(IsSensitiveOperator()),
|
||||
SearchOperatorViewData.from(WhereOperator()),
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* Complete set of [SearchOperatorViewData].
|
||||
*
|
||||
* Items are never added or removed from this, only replaced with [replaceOperator].
|
||||
* An item can be retrieved by class using [getOperator]
|
||||
*
|
||||
* @see [replaceOperator]
|
||||
* @see [getOperator]
|
||||
*/
|
||||
val operatorViewData = _operatorViewData.asStateFlow()
|
||||
|
||||
val locales = accountManager.activeAccountFlow.map {
|
||||
getLocaleList(getInitialLanguages(activeAccount = it))
|
||||
}.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
getLocaleList(getInitialLanguages()),
|
||||
)
|
||||
|
||||
val server = serverRepository.flow.stateIn(
|
||||
viewModelScope,
|
||||
SharingStarted.WhileSubscribed(5000),
|
||||
null,
|
||||
)
|
||||
|
||||
private val loadedStatuses: MutableList<StatusViewData> = mutableListOf()
|
||||
|
||||
private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) {
|
||||
|
@ -81,26 +142,37 @@ class SearchViewModel @Inject constructor(
|
|||
val statusesFlow = Pager(
|
||||
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
|
||||
pagingSourceFactory = statusesPagingSourceFactory,
|
||||
).flow
|
||||
.cachedIn(viewModelScope)
|
||||
).flow.cachedIn(viewModelScope)
|
||||
|
||||
val accountsFlow = Pager(
|
||||
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
|
||||
pagingSourceFactory = accountsPagingSourceFactory,
|
||||
).flow
|
||||
.cachedIn(viewModelScope)
|
||||
).flow.cachedIn(viewModelScope)
|
||||
|
||||
val hashtagsFlow = Pager(
|
||||
config = PagingConfig(pageSize = DEFAULT_LOAD_SIZE, initialLoadSize = DEFAULT_LOAD_SIZE),
|
||||
pagingSourceFactory = hashtagsPagingSourceFactory,
|
||||
).flow
|
||||
.cachedIn(viewModelScope)
|
||||
).flow.cachedIn(viewModelScope)
|
||||
|
||||
/** @return The operator of type T. */
|
||||
inline fun <reified T : SearchOperator> getOperator() = operatorViewData.value.find { it.operator is T }?.operator as T?
|
||||
|
||||
/**
|
||||
* Replaces the existing [SearchOperatorViewData] in [_operatorViewData]
|
||||
* with [viewData].
|
||||
*/
|
||||
fun <T : SearchOperator> replaceOperator(viewData: SearchOperatorViewData<T>) = _operatorViewData.update { operators ->
|
||||
operators.find { it.javaClass == viewData.javaClass }?.let { operators - it + viewData } ?: (operators + viewData)
|
||||
}
|
||||
|
||||
fun search(query: String) {
|
||||
val operatorQuery = _operatorViewData.value.mapNotNull { it.operator.query() }.joinToString(" ")
|
||||
val finalQuery = if (operatorQuery.isNotBlank()) arrayOf(query, operatorQuery).joinToString(" ") else query
|
||||
|
||||
loadedStatuses.clear()
|
||||
statusesPagingSourceFactory.newSearch(query)
|
||||
accountsPagingSourceFactory.newSearch(query)
|
||||
hashtagsPagingSourceFactory.newSearch(query)
|
||||
statusesPagingSourceFactory.newSearch(finalQuery)
|
||||
accountsPagingSourceFactory.newSearch(finalQuery)
|
||||
hashtagsPagingSourceFactory.newSearch(finalQuery)
|
||||
}
|
||||
|
||||
fun removeItem(statusViewData: StatusViewData) {
|
||||
|
@ -194,6 +266,21 @@ class SearchViewModel @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
/** Searches for autocomplete suggestions. */
|
||||
suspend fun searchAccountAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
||||
// "resolve" is false as, by definition, the server will only return statuses it
|
||||
// knows about, therefore the accounts that posted those statuses will definitely
|
||||
// be known by the server and there is no need to resolve them further.
|
||||
return mastodonApi.search(query = token, resolve = false, type = SearchType.Account.apiParameter, limit = 10)
|
||||
.fold(
|
||||
{ it.accounts.map { ComposeAutoCompleteAdapter.AutocompleteResult.AccountResult(it) } },
|
||||
{
|
||||
Timber.e(it, "Autocomplete search for %s failed.", token)
|
||||
emptyList()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun updateStatusViewData(newStatusViewData: StatusViewData) {
|
||||
val idx = loadedStatuses.indexOfFirst { it.id == newStatusViewData.id }
|
||||
if (idx >= 0) {
|
||||
|
|
|
@ -4,6 +4,6 @@
|
|||
android:viewportWidth="24.0"
|
||||
android:viewportHeight="24.0">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillColor="?attr/colorControlNormal"
|
||||
android:pathData="M16.5,6v11.5c0,2.21 -1.79,4 -4,4s-4,-1.79 -4,-4V5c0,-1.38 1.12,-2.5 2.5,-2.5s2.5,1.12 2.5,2.5v10.5c0,0.55 -0.45,1 -1,1s-1,-0.45 -1,-1V6H10v9.5c0,1.38 1.12,2.5 2.5,2.5s2.5,-1.12 2.5,-2.5V5c0,-2.21 -1.79,-4 -4,-4S7,2.79 7,5v12.5c0,3.04 2.46,5.5 5.5,5.5s5.5,-2.46 5.5,-5.5V6h-1.5z"/>
|
||||
</vector>
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
<!--
|
||||
~ Copyright 2024 Pachli Association
|
||||
~
|
||||
~ This file is a part of Pachli.
|
||||
~
|
||||
~ 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.
|
||||
~
|
||||
~ Pachli 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 Pachli; if not,
|
||||
~ see <http://www.gnu.org/licenses>.
|
||||
-->
|
||||
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:autoMirrored="true" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
|
||||
|
||||
<path android:fillColor="?attr/colorControlNormal" android:pathData="M3.9,12c0,-1.71 1.39,-3.1 3.1,-3.1h4L11,7L7,7c-2.76,0 -5,2.24 -5,5s2.24,5 5,5h4v-1.9L7,15.1c-1.71,0 -3.1,-1.39 -3.1,-3.1zM8,13h8v-2L8,11v2zM17,7h-4v1.9h4c1.71,0 3.1,1.39 3.1,3.1s-1.39,3.1 -3.1,3.1h-4L13,17h4c2.76,0 5,-2.24 5,-5s-2.24,-5 -5,-5z"/>
|
||||
|
||||
</vector>
|
|
@ -5,6 +5,6 @@
|
|||
android:viewportHeight="24.0"
|
||||
android:viewportWidth="24.0">
|
||||
<path
|
||||
android:fillColor="#fff"
|
||||
android:fillColor="?attr/colorControlNormal"
|
||||
android:pathData="M7,8L7,5l-7,7 7,7v-3l-4,-4 4,-4zM13,9L13,5l-7,7 7,7v-4.1c5,0 8.5,1.6 11,5.1 -1,-5 -4,-10 -11,-11z" />
|
||||
</vector>
|
||||
|
|
|
@ -21,6 +21,101 @@
|
|||
app:layout_scrollFlags="scroll|snap|enterAlways"
|
||||
app:navigationIcon="?attr/homeAsUpIndicator" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/chipsFilter"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="?listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?listPreferredItemPaddingEnd"
|
||||
app:layout_scrollFlags="scroll|snap|enterAlways"
|
||||
android:animateLayoutChanges="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chipFrom"
|
||||
style="@style/Widget.Material3.Chip.Suggestion"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:chipIconEnabled="true"
|
||||
android:text="@string/search_operator_from_all" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chipDate"
|
||||
style="@style/Widget.Material3.Chip.Suggestion"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:chipIconEnabled="true"
|
||||
android:text="@string/search_operator_date_all" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chipLanguage"
|
||||
style="@style/Widget.Material3.Chip.Suggestion"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:chipIconEnabled="true"
|
||||
android:text="@string/search_operator_language_all" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_has_media"
|
||||
style="@style/Widget.Material3.Chip.Suggestion"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:chipIcon="@drawable/ic_attach_file_24dp"
|
||||
app:chipIconEnabled="true"
|
||||
android:text="@string/search_operator_attachment_all" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_is_reply"
|
||||
style="@style/Widget.Material3.Chip.Suggestion"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:chipIcon="@drawable/ic_reply_all_24dp"
|
||||
app:chipIconEnabled="true"
|
||||
android:text="@string/search_operator_replies_all" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_is_sensitive"
|
||||
style="@style/Widget.Material3.Chip.Suggestion"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:chipIcon="@drawable/ic_eye_24dp"
|
||||
app:chipIconEnabled="true"
|
||||
android:text="@string/search_operator_sensitive_all" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_has_poll"
|
||||
style="@style/Widget.Material3.Chip.Suggestion"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:chipIcon="@drawable/ic_attach_file_24dp"
|
||||
app:chipIconEnabled="true"
|
||||
android:text="@string/search_operator_poll_all" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_has_embed"
|
||||
style="@style/Widget.Material3.Chip.Suggestion"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:chipIconEnabled="true"
|
||||
android:text="@string/search_operator_embed_all" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_has_link"
|
||||
style="@style/Widget.Material3.Chip.Suggestion"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:chipIcon="@drawable/ic_link_24"
|
||||
app:chipIconEnabled="true"
|
||||
android:text="@string/search_operator_link_all" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chipWhere"
|
||||
style="@style/Widget.Material3.Chip.Suggestion"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:chipIconEnabled="true"
|
||||
android:text="@string/search_operator_where_all" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/tabs"
|
||||
android:layout_width="match_parent"
|
||||
|
|
|
@ -4,6 +4,15 @@
|
|||
android:layout_width="@dimen/timeline_width"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/searchProgressBar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top"
|
||||
android:visibility="gone"
|
||||
android:indeterminate="true"
|
||||
android:contentDescription="" />
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/swipeRefreshLayout"
|
||||
android:layout_width="match_parent"
|
||||
|
@ -19,14 +28,6 @@
|
|||
tools:listitem="@layout/item_account" />
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/searchProgressBar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/searchNoResultsText"
|
||||
android:layout_width="wrap_content"
|
||||
|
|
|
@ -0,0 +1,196 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright 2024 Pachli Association
|
||||
~
|
||||
~ This file is a part of Pachli.
|
||||
~
|
||||
~ 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.
|
||||
~
|
||||
~ Pachli 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 Pachli; if not,
|
||||
~ see <http://www.gnu.org/licenses>.
|
||||
-->
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="?attr/listPreferredItemPaddingStart"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingEnd="?attr/listPreferredItemPaddingEnd">
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/chipgroup_media"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintStart_toEndOf="@id/barrier4"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:selectionRequired="false"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_no_media"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_operator_attachment_none" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_has_media"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_operator_attachment_at_least_one" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<com.google.android.material.divider.MaterialDivider
|
||||
android:id="@+id/mediaDivider"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginBottom="6dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/chipgroup_media" />
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/chipgroup_images"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintStart_toEndOf="@id/barrier4"
|
||||
app:layout_constraintTop_toBottomOf="@id/mediaDivider"
|
||||
app:selectionRequired="false"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_no_image"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_operator_attachment_none" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_has_image"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_operator_attachment_at_least_one" />
|
||||
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/chipgroup_video"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintStart_toEndOf="@id/barrier4"
|
||||
app:layout_constraintStart_toStartOf="@+id/chipgroup_media"
|
||||
app:layout_constraintTop_toBottomOf="@id/chipgroup_images"
|
||||
app:selectionRequired="false"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_no_video"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_operator_attachment_none" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_has_video"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_operator_attachment_at_least_one" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<com.google.android.material.chip.ChipGroup
|
||||
android:id="@+id/chipgroup_audio"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintStart_toEndOf="@id/barrier4"
|
||||
app:layout_constraintTop_toBottomOf="@id/chipgroup_video"
|
||||
app:selectionRequired="false"
|
||||
app:singleSelection="true">
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_no_audio"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_operator_attachment_none" />
|
||||
|
||||
<com.google.android.material.chip.Chip
|
||||
android:id="@+id/chip_has_audio"
|
||||
style="@style/Widget.Material3.Chip.Filter"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_operator_attachment_at_least_one" />
|
||||
</com.google.android.material.chip.ChipGroup>
|
||||
|
||||
<androidx.constraintlayout.widget.Barrier
|
||||
android:id="@+id/barrier4"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:barrierDirection="end"
|
||||
app:constraint_referenced_ids="titleMedia,titleImage,titleVideo,titleAudio" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleMedia"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="?attr/listPreferredItemPaddingEnd"
|
||||
android:text="@string/search_operator_attachment_dialog_any_label"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintBottom_toBottomOf="@+id/chipgroup_media"
|
||||
app:layout_constraintEnd_toStartOf="@+id/chipgroup_media"
|
||||
app:layout_constraintHorizontal_bias="1.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@+id/chipgroup_media" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleImage"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="?attr/listPreferredItemPaddingEnd"
|
||||
android:text="@string/search_operator_attachment_dialog_image_label"
|
||||
android:textStyle="bold"
|
||||
app:layout_constrainedHeight="false"
|
||||
app:layout_constrainedWidth="true"
|
||||
app:layout_constraintBottom_toTopOf="@+id/chipgroup_video"
|
||||
app:layout_constraintEnd_toStartOf="@+id/chipgroup_images"
|
||||
app:layout_constraintHorizontal_bias="1.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/mediaDivider" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleVideo"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="?attr/listPreferredItemPaddingEnd"
|
||||
android:text="@string/search_operator_attachment_dialog_video_label"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintBottom_toTopOf="@+id/chipgroup_audio"
|
||||
app:layout_constraintEnd_toStartOf="@+id/chipgroup_video"
|
||||
app:layout_constraintHorizontal_bias="1.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@+id/chipgroup_video" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/titleAudio"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="?attr/listPreferredItemPaddingEnd"
|
||||
android:text="@string/search_operator_attachment_dialog_audio_label"
|
||||
android:textStyle="bold"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/chipgroup_audio"
|
||||
app:layout_constraintHorizontal_bias="1.0"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="@+id/chipgroup_audio" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,117 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright 2024 Pachli Association
|
||||
~
|
||||
~ This file is a part of Pachli.
|
||||
~
|
||||
~ 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.
|
||||
~
|
||||
~ Pachli 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 Pachli; if not,
|
||||
~ see <http://www.gnu.org/licenses>.
|
||||
-->
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingStart="?attr/listPreferredItemPaddingStart"
|
||||
android:paddingEnd="?attr/listPreferredItemPaddingEnd">
|
||||
|
||||
<!-- textNoSuggestions is to disable spell check, it will auto-complete -->
|
||||
<RadioGroup
|
||||
android:id="@+id/radioGroup"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
tools:layout_editor_absoluteY="4dp">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/radioAll"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||
android:text="@string/search_operator_from_dialog_all"
|
||||
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||
android:textColor="?attr/textColorAlertDialogListItem"
|
||||
tools:ignore="RtlSymmetry" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/radioMe"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||
android:text="@string/search_operator_from_dialog_me"
|
||||
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||
android:textColor="?attr/textColorAlertDialogListItem"
|
||||
tools:ignore="RtlSymmetry" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/radioIgnoreMe"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||
android:textColor="?attr/textColorAlertDialogListItem"
|
||||
tools:ignore="RtlSymmetry"
|
||||
android:text="@string/search_operator_from_dialog_ignore_me" />
|
||||
|
||||
<com.google.android.material.divider.MaterialDivider
|
||||
android:id="@+id/materialDivider"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:layout_marginBottom="6dp" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/radioOtherAccount"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||
android:text="@string/search_operator_from_dialog_other_account"
|
||||
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||
android:textColor="?attr/textColorAlertDialogListItem"
|
||||
tools:ignore="RtlSymmetry" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/radioIgnoreOtherAccount"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/listPreferredItemHeightSmall"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||
android:text="@string/search_operator_from_dialog_ignore_other_account"
|
||||
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||
android:textColor="?attr/textColorAlertDialogListItem"
|
||||
tools:ignore="RtlSymmetry" />
|
||||
|
||||
<com.google.android.material.textfield.TextInputLayout
|
||||
style="@style/AppTextInput"
|
||||
android:id="@+id/accountEditTextLayout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="48dp">
|
||||
|
||||
<com.google.android.material.textfield.MaterialAutoCompleteTextView
|
||||
android:id="@+id/account"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:minHeight="48dp"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:hint="@string/search_operator_from_dialog_account_hint"/>
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
</RadioGroup>
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
|
@ -0,0 +1,109 @@
|
|||
<?xml version="1.0" encoding="utf-8"?><!--
|
||||
~ Copyright 2024 Pachli Association
|
||||
~
|
||||
~ This file is a part of Pachli.
|
||||
~
|
||||
~ 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.
|
||||
~
|
||||
~ Pachli 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 Pachli; if not,
|
||||
~ see <http://www.gnu.org/licenses>.
|
||||
-->
|
||||
|
||||
<!--
|
||||
Displays the options for SearchViewModel.WhereOperator.
|
||||
|
||||
This **cannot** be a listview because alert dialogs assume the height of any
|
||||
interior listview can be computed by taking the number of items and multiplying
|
||||
by the height of the first item (i.e., items are an identical height).
|
||||
|
||||
This is not the case for this layout, as the descriptions for the three options
|
||||
may span multiple lines, and the different items may be different heights.
|
||||
|
||||
This causes AlertDialog to make the list height too small, and enforces scrolling.
|
||||
-->
|
||||
<RadioGroup xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/where_all_dialog_group"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="fill_parent"
|
||||
android:minHeight="?attr/listPreferredItemHeight"
|
||||
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingEnd="?attr/dialogPreferredPadding"
|
||||
android:orientation="vertical">
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/where_all_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_operator_where_dialog_all"
|
||||
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||
android:textColor="?attr/textColorAlertDialogListItem"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||
tools:ignore="RtlSymmetry" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/where_all_description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_operator_where_dialog_all_hint"
|
||||
android:textAppearance="?android:attr/textAppearanceSmall"
|
||||
android:textColor="?attr/textColorAlertDialogListItem"
|
||||
android:paddingStart="52dp"
|
||||
android:gravity="center_vertical"
|
||||
tools:ignore="RtlSymmetry" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/where_library_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_operator_where_dialog_library"
|
||||
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||
android:textColor="?attr/textColorAlertDialogListItem"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||
android:layout_marginTop="8dp"
|
||||
tools:ignore="RtlSymmetry" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/where_library_description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_operator_where_dialog_library_hint"
|
||||
android:textAppearance="?android:attr/textAppearanceSmall"
|
||||
android:textColor="?attr/textColorAlertDialogListItem"
|
||||
android:paddingStart="52dp"
|
||||
android:gravity="center_vertical"
|
||||
tools:ignore="RtlSymmetry" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/where_public_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_operator_where_dialog_public"
|
||||
android:textAppearance="?android:attr/textAppearanceMedium"
|
||||
android:textColor="?attr/textColorAlertDialogListItem"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingStart="@dimen/abc_select_dialog_padding_start_material"
|
||||
android:layout_marginTop="8dp"
|
||||
tools:ignore="RtlSymmetry" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/where_public_description"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/search_operator_where_dialog_public_hint"
|
||||
android:textAppearance="?android:attr/textAppearanceSmall"
|
||||
android:textColor="?attr/textColorAlertDialogListItem"
|
||||
android:paddingStart="52dp"
|
||||
android:gravity="center_vertical"
|
||||
tools:ignore="RtlSymmetry" />
|
||||
|
||||
</RadioGroup>
|
|
@ -716,4 +716,109 @@
|
|||
<string name="error_load_filter_failed_fmt">Loading filter failed: %1$s</string>
|
||||
<string name="error_save_filter_failed_fmt">Saving filter failed: %1$s</string>
|
||||
<string name="error_delete_filter_failed_fmt">Deleting filter failed: %1$s</string>
|
||||
|
||||
<string name="search_operator_attachment_dialog_title">Limit to posts with attachments?</string>
|
||||
<string name="search_operator_attachment_dialog_any_label">Any type</string>
|
||||
<string name="search_operator_attachment_dialog_image_label">Images</string>
|
||||
<string name="search_operator_attachment_dialog_video_label">Video</string>
|
||||
<string name="search_operator_attachment_dialog_audio_label">Sound</string>
|
||||
|
||||
<string name="search_operator_attachment_all">Attachments ▾</string>
|
||||
|
||||
<string name="search_operator_attachment_no_media_label">No attachments</string>
|
||||
<string name="search_operator_attachment_kind_image_label">images</string>
|
||||
<string name="search_operator_attachment_kind_video_label">video</string>
|
||||
<string name="search_operator_attachment_kind_audio_label">sound</string>
|
||||
|
||||
<string name="search_operator_attachment_has_media_label">At least one attachment</string>
|
||||
<string name="search_operator_attachment_has_media_except_1_label_fmt">Any except %1$s</string>
|
||||
<string name="search_operator_attachment_has_media_except_2_label_fmt">Any except %1$s, %2$s</string>
|
||||
<string name="search_operator_attachment_has_media_except_3_label_fmt">Any except %1$s, %2$s, %3$s</string>
|
||||
|
||||
<string name="search_operator_attachment_specific_media_include_2_exclude_1_fmt">With %1$s, %2$s, no %3$s</string>
|
||||
<string name="search_operator_attachment_specific_media_include_1_exclude_2_fmt">With %1$s, no %2$s, %3$s</string>
|
||||
<string name="search_operator_attachment_specific_media_include_1_exclude_1_fmt">With %1$s, no %2$s</string>
|
||||
<string name="search_operator_attachment_specific_media_include_1_fmt">With %1$s</string>
|
||||
<string name="search_operator_attachment_specific_media_include_2_fmt">With %1$s, %2$s</string>
|
||||
<string name="search_operator_attachment_specific_media_include_3_fmt">With %1$s, %2$s, %3$s</string>
|
||||
<string name="search_operator_attachment_specific_media_exclude_1_fmt">No %1$s</string>
|
||||
<string name="search_operator_attachment_specific_media_exclude_2_fmt">No %1$s, %2$s</string>
|
||||
<string name="search_operator_attachment_specific_media_exclude_3_fmt">No %1$s, %2$s, %3$s</string>
|
||||
|
||||
<string name="search_operator_attachment_none">None</string>
|
||||
<string name="search_operator_attachment_at_least_one">At least one</string>
|
||||
|
||||
<string name="search_operator_from_all">From ▾</string>
|
||||
<string name="search_operator_from_me">Only my posts</string>
|
||||
<string name="search_operator_from_ignore_me">Not my posts</string>
|
||||
<string name="search_operator_from_account_fmt">Only %1$s</string>
|
||||
<string name="search_operator_from_ignore_account_fmt">Not %1$s</string>
|
||||
<string name="search_operator_from_dialog_title">Limit to specific accounts?</string>
|
||||
<string name="search_operator_from_dialog_all">All accounts</string>
|
||||
<string name="search_operator_from_dialog_me">Only my posts</string>
|
||||
<string name="search_operator_from_dialog_ignore_me">All posts except mine</string>
|
||||
<string name="search_operator_from_dialog_other_account">Only posts from this account</string>
|
||||
<string name="search_operator_from_dialog_ignore_other_account">All posts except this account</string>
|
||||
<string name="search_operator_from_dialog_account_hint">\@account</string>
|
||||
|
||||
<string name="search_operator_poll_dialog_title">Show posts with polls?</string>
|
||||
<string name="search_operator_poll_all">Polls ▾</string>
|
||||
<string name="search_operator_poll_only">Only show polls</string>
|
||||
<string name="search_operator_poll_no_polls">Without polls</string>
|
||||
<string name="search_operator_poll_dialog_all">With or without polls</string>
|
||||
<string name="search_operator_poll_dialog_only">Only posts with polls</string>
|
||||
<string name="search_operator_poll_dialog_no_polls">Only posts without polls</string>
|
||||
|
||||
<string name="search_operator_embed_all">Preview cards ▾</string>
|
||||
<string name="search_operator_embed_only">Only preview cards</string>
|
||||
<string name="search_operator_embed_no_embeds">Without preview cards</string>
|
||||
<string name="search_operator_embed_dialog_title">Show posts with preview cards?</string>
|
||||
<string name="search_operator_embed_dialog_all">With or without preview cards</string>
|
||||
<string name="search_operator_embed_dialog_only">Only posts with preview cards</string>
|
||||
<string name="search_operator_embed_dialog_no_embeds">Only posts without preview cards</string>
|
||||
|
||||
<string name="search_operator_link_all">Links ▾</string>
|
||||
<string name="search_operator_link_only">Only links</string>
|
||||
<string name="search_operator_no_link">Without links</string>
|
||||
<string name="search_operator_link_dialog_title">Show posts with links?</string>
|
||||
<string name="search_operator_link_dialog_all">With or without links</string>
|
||||
<string name="search_operator_link_dialog_only">Only posts with links</string>
|
||||
<string name="search_operator_link_dialog_no_link">Only posts without links</string>
|
||||
|
||||
<string name="search_operator_date_all">Dates ▾</string>
|
||||
<string name="search_operator_date_checked">%1$s - %2$s</string>
|
||||
<string name="search_operator_date_checked_same_day">On %1$s</string>
|
||||
<string name="search_operator_date_dialog_title">Limit to posts between…</string>
|
||||
|
||||
<string name="search_operator_language_dialog_title">Limit to posts written in…</string>
|
||||
<string name="search_operator_language_all">All languages ▾</string>
|
||||
<string name="search_operator_language_checked_fmt">%1$s</string>
|
||||
<string name="search_operator_language_dialog_all">All languages</string>
|
||||
|
||||
<string name="search_operator_replies_all">Include replies ▾</string>
|
||||
<string name="search_operator_replies_replies_only">Only show replies</string>
|
||||
<string name="search_operator_replies_no_replies">Exclude replies</string>
|
||||
<string name="search_operator_replies_dialog_title">Include replies?</string>
|
||||
<string name="search_operator_replies_dialog_all">All posts</string>
|
||||
<string name="search_operator_replies_dialog_replies_only">Only show replies</string>
|
||||
<string name="search_operator_replies_dialog_no_replies">Without replies</string>
|
||||
|
||||
<string name="search_operator_sensitive_all">Include sensitive content ▾</string>
|
||||
<string name="search_operator_sensitive_sensitive_only">Sensitive content only</string>
|
||||
<string name="search_operator_sensitive_no_sensitive">Exclude sensitive content</string>
|
||||
<string name="search_operator_sensitive_dialog_title">Posts with sensitive content</string>
|
||||
<string name="search_operator_sensitive_dialog_all">All posts</string>
|
||||
<string name="search_operator_sensitive_dialog_sensitive_only">Sensitive content only</string>
|
||||
<string name="search_operator_sensitive_dialog_no_sensitive">Exclude sensitive content</string>
|
||||
|
||||
<string name="search_operator_where_all">All posts ▾</string>
|
||||
<string name="search_operator_where_library">Your library</string>
|
||||
<string name="search_operator_where_public">Public posts</string>
|
||||
<string name="search_operator_where_dialog_title">Limit to…</string>
|
||||
<string name="search_operator_where_dialog_all">All posts</string>
|
||||
<string name="search_operator_where_dialog_all_hint">Posts in your library and public posts</string>
|
||||
<string name="search_operator_where_dialog_library">Your library</string>
|
||||
<string name="search_operator_where_dialog_library_hint">Your own posts, boosts, favorites, bookmarks, and posts that @mention you</string>
|
||||
<string name="search_operator_where_dialog_public">Public posts</string>
|
||||
<string name="search_operator_where_dialog_public_hint">Public, searchable posts known by server</string>
|
||||
</resources>
|
||||
|
|
|
@ -36,6 +36,20 @@ import app.pachli.core.network.ServerKind.SHARKEY
|
|||
import app.pachli.core.network.ServerKind.UNKNOWN
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_FROM
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_EMBED
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_IMAGE
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_LINK
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_MEDIA
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_POLL
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_VIDEO
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IN_PUBLIC
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE
|
||||
import app.pachli.core.network.model.InstanceV1
|
||||
import app.pachli.core.network.model.InstanceV2
|
||||
|
@ -89,6 +103,30 @@ enum class ServerOperation(id: String, versions: List<Version>) {
|
|||
Version(major = 1, minor = 1),
|
||||
),
|
||||
),
|
||||
|
||||
/** Search for posts from a particular account */
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_FROM(
|
||||
"org.joinmastodon.search.query:from",
|
||||
listOf(
|
||||
// Initial introduction in Mastodon 3.5.0
|
||||
Version(major = 1),
|
||||
// Support for `from:me` in Mastodon 4.2.0
|
||||
Version(major = 1, minor = 1),
|
||||
),
|
||||
),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE("org.joinmastodon.search.query:language", listOf(Version(major = 1))),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_HAS_MEDIA("org.joinmastodon.search.query:has:media", listOf(Version(major = 1))),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_HAS_IMAGE("org.joinmastodon.search.query:has:image", listOf(Version(major = 1))),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_HAS_VIDEO("org.joinmastodon.search.query:has:video", listOf(Version(major = 1))),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO("org.joinmastodon.search.query:has:audio", listOf(Version(major = 1))),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_HAS_POLL("org.joinmastodon.search.query:has:poll", listOf(Version(major = 1))),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_HAS_LINK("org.joinmastodon.search.query:has:link", listOf(Version(major = 1))),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_HAS_EMBED("org.joinmastodon.search.query:has:embed", listOf(Version(major = 1))),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY("org.joinmastodon.search.query:is:reply", listOf(Version(major = 1))),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE("org.joinmastodon.search.query:is:sensitive", listOf(Version(major = 1))),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY("org.joinmastodon.search.query:in:library", listOf(Version(major = 1))),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_IN_PUBLIC("org.joinmastodon.search.query:in:public", listOf(Version(major = 1))),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE("org.joinmastodon.search.query:in:public", listOf(Version(major = 1))),
|
||||
}
|
||||
|
||||
data class Server(
|
||||
|
@ -271,6 +309,33 @@ data class Server(
|
|||
when {
|
||||
v >= "4.0.0".toVersion() -> c[ORG_JOINMASTODON_FILTERS_SERVER] = "1.0.0".toVersion()
|
||||
}
|
||||
|
||||
// Search operators
|
||||
when {
|
||||
v >= "4.3.0".toVersion() -> {
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_IN_PUBLIC] = "1.0.0".toVersion()
|
||||
}
|
||||
|
||||
v >= "4.2.0".toVersion() -> {
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_FROM] = "1.1.0".toVersion()
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE] = "1.0.0".toVersion()
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_HAS_MEDIA] = "1.0.0".toVersion()
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_HAS_IMAGE] = "1.0.0".toVersion()
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_HAS_VIDEO] = "1.0.0".toVersion()
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO] = "1.0.0".toVersion()
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_HAS_POLL] = "1.0.0".toVersion()
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_HAS_LINK] = "1.0.0".toVersion()
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_HAS_EMBED] = "1.0.0".toVersion()
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY] = "1.0.0".toVersion()
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE] = "1.0.0".toVersion()
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY] = "1.0.0".toVersion()
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE] = "1.0.0".toVersion()
|
||||
}
|
||||
|
||||
v >= "3.5.0".toVersion() -> {
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_FROM] = "1.0.0".toVersion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GOTOSOCIAL -> {
|
||||
|
@ -284,6 +349,14 @@ data class Server(
|
|||
// Implemented in https://github.com/superseriousbusiness/gotosocial/pull/2594
|
||||
v >= "0.15.0".toVersion() -> c[ORG_JOINMASTODON_FILTERS_CLIENT] = "1.1.0".toVersion()
|
||||
}
|
||||
|
||||
// Search
|
||||
when {
|
||||
// from: in https://github.com/superseriousbusiness/gotosocial/pull/2943
|
||||
v >= "0.16.0".toVersion() -> {
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_FROM] = "1.0.0".toVersion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// FireFish can't filter (conversation in the Firefish dev. chat )
|
||||
|
@ -292,9 +365,25 @@ data class Server(
|
|||
// Sharkey can't filter, https://activitypub.software/TransFem-org/Sharkey/-/issues/492
|
||||
SHARKEY -> { }
|
||||
|
||||
FRIENDICA -> {
|
||||
// Assume filter support (may be wrong)
|
||||
c[ORG_JOINMASTODON_FILTERS_SERVER] = "1.0.0".toVersion()
|
||||
|
||||
// Search
|
||||
when {
|
||||
// Friendica has a number of search operators that are not in Mastodon.
|
||||
// See https://github.com/friendica/friendica/blob/develop/doc/Channels.md
|
||||
// for details.
|
||||
v >= "2024.3.0".toVersion() -> {
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_FROM] = "1.0.0".toVersion()
|
||||
c[ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE] = "1.0.0".toVersion()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Everything else. Assume server side filtering and no translation. This may be an
|
||||
// incorrect assumption.
|
||||
AKKOMA, FEDIBIRD, FRIENDICA, HOMETOWN, ICESHRIMP, PIXELFED, PLEROMA, UNKNOWN -> {
|
||||
AKKOMA, FEDIBIRD, HOMETOWN, ICESHRIMP, PIXELFED, PLEROMA, UNKNOWN -> {
|
||||
c[ORG_JOINMASTODON_FILTERS_SERVER] = "1.0.0".toVersion()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,6 +26,19 @@ import app.pachli.core.network.ServerKind.PLEROMA
|
|||
import app.pachli.core.network.ServerKind.UNKNOWN
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_CLIENT
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_FILTERS_SERVER
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_FROM
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_EMBED
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_IMAGE
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_LINK
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_MEDIA
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_POLL
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_HAS_VIDEO
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE
|
||||
import app.pachli.core.network.ServerOperation.ORG_JOINMASTODON_STATUSES_TRANSLATE
|
||||
import app.pachli.core.network.model.Account
|
||||
import app.pachli.core.network.model.Configuration
|
||||
|
@ -133,6 +146,7 @@ class ServerTest(
|
|||
capabilities = mapOf(
|
||||
ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(),
|
||||
ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_FROM to "1.0.0".toVersion(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -154,6 +168,7 @@ class ServerTest(
|
|||
capabilities = mapOf(
|
||||
ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(),
|
||||
ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_FROM to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_STATUSES_TRANSLATE to "1.0.0".toVersion(),
|
||||
),
|
||||
),
|
||||
|
@ -161,7 +176,7 @@ class ServerTest(
|
|||
),
|
||||
arrayOf(
|
||||
Triple(
|
||||
"Mastodon 4.2.0 has has translate 1.1.0",
|
||||
"Mastodon 4.2.0 has translate 1.1.0",
|
||||
NodeInfo.Software("mastodon", "4.2.0"),
|
||||
defaultInstance.copy(
|
||||
configuration = defaultInstance.configuration.copy(
|
||||
|
@ -176,6 +191,19 @@ class ServerTest(
|
|||
capabilities = mapOf(
|
||||
ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(),
|
||||
ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_FROM to "1.1.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_LANGUAGE to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_HAS_MEDIA to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_HAS_IMAGE to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_HAS_VIDEO to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_HAS_AUDIO to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_HAS_POLL to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_HAS_LINK to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_HAS_EMBED to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_IS_REPLY to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_IS_SENSITIVE to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_IN_LIBRARY to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_BY_DATE to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_STATUSES_TRANSLATE to "1.1.0".toVersion(),
|
||||
),
|
||||
),
|
||||
|
@ -224,6 +252,7 @@ class ServerTest(
|
|||
capabilities = mapOf(
|
||||
ORG_JOINMASTODON_FILTERS_CLIENT to "1.1.0".toVersion(),
|
||||
ORG_JOINMASTODON_FILTERS_SERVER to "1.0.0".toVersion(),
|
||||
ORG_JOINMASTODON_SEARCH_QUERY_FROM to "1.0.0".toVersion(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
@ -18,9 +18,11 @@
|
|||
package app.pachli.core.ui.extensions
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.content.DialogInterface.BUTTON_NEGATIVE
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import com.google.android.material.datepicker.MaterialDatePicker
|
||||
import com.google.android.material.datepicker.MaterialPickerOnPositiveButtonClickListener
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
|
||||
/**
|
||||
|
@ -32,7 +34,6 @@ import kotlinx.coroutines.suspendCancellableCoroutine
|
|||
* @param negativeText Optional text to show on the negative button
|
||||
* @param neutralText Optional text to show on the neutral button
|
||||
*/
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
suspend fun AlertDialog.await(
|
||||
positiveText: String,
|
||||
negativeText: String? = null,
|
||||
|
@ -63,3 +64,84 @@ suspend fun AlertDialog.await(
|
|||
negativeTextResource?.let { context.getString(it) },
|
||||
neutralTextResource?.let { context.getString(it) },
|
||||
)
|
||||
|
||||
/**
|
||||
* Result from [AlertDialog.Builder.awaitSingleChoiceItem].
|
||||
*
|
||||
* @param button ID of the button that was pressed, [AlertDialog.BUTTON_POSITIVE],
|
||||
* [AlertDialog.BUTTON_NEGATIVE], or [AlertDialog.BUTTON_NEUTRAL].
|
||||
* @param index Index of the selected item when the button was pressed.
|
||||
*/
|
||||
data class SingleChoiceItemResult(val button: Int, val index: Int)
|
||||
|
||||
/**
|
||||
* Shows an [AlertDialog] displaying [items] with the item at
|
||||
* [initialIndex] selected.
|
||||
*
|
||||
* @param items List of items to display.
|
||||
* @param initialIndex Index of the item that should be selected when
|
||||
* the dialog is shown. Use `-1` to leave all items unselected.
|
||||
* @param positiveTextResource String resource to use as positive button
|
||||
* text
|
||||
* @param negativeTextResource Optional string resource to use as negative
|
||||
* button text. If null the button is not shown.
|
||||
* @param neutralTextResource Optional string resource to use as neutral
|
||||
* button tet. If null the button is not shown.
|
||||
* @return [SingleChoiceItemResult] with the button that was pressed and
|
||||
* the index of the selected item in [items] when the button was pressed.
|
||||
*/
|
||||
suspend inline fun <reified T : CharSequence> AlertDialog.Builder.awaitSingleChoiceItem(
|
||||
items: List<T>,
|
||||
initialIndex: Int,
|
||||
@StringRes positiveTextResource: Int,
|
||||
@StringRes negativeTextResource: Int? = null,
|
||||
@StringRes neutralTextResource: Int? = null,
|
||||
) = suspendCancellableCoroutine { cont ->
|
||||
var selectedIndex = initialIndex
|
||||
|
||||
val itemListener = DialogInterface.OnClickListener { _, which ->
|
||||
selectedIndex = which
|
||||
}
|
||||
|
||||
val buttonListener = DialogInterface.OnClickListener { _, which ->
|
||||
cont.resume(SingleChoiceItemResult(which, selectedIndex)) { }
|
||||
}
|
||||
|
||||
setSingleChoiceItems(items.toTypedArray(), selectedIndex, itemListener)
|
||||
setPositiveButton(positiveTextResource, buttonListener)
|
||||
negativeTextResource?.let { setNegativeButton(it, buttonListener) }
|
||||
neutralTextResource?.let { setNeutralButton(it, buttonListener) }
|
||||
setOnCancelListener { cont.resume(SingleChoiceItemResult(BUTTON_NEGATIVE, selectedIndex)) {} }
|
||||
setOnDismissListener { if (!cont.isCompleted) cont.resume(SingleChoiceItemResult(BUTTON_NEGATIVE, selectedIndex)) {} }
|
||||
val dialog = create()
|
||||
|
||||
cont.invokeOnCancellation { dialog.dismiss() }
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a [MaterialDatePicker] and returns the result after the user makes
|
||||
* their selection.
|
||||
*
|
||||
* @param fragmentManager The FragmentManager this fragment will be added to
|
||||
* @param tag Optional tag name for the fragment, to later retrieve the fragment with
|
||||
* [FragmentManager.findFragmentByTag(String)][androidx.fragment.app.FragmentManager.findFragmentById]
|
||||
* @return Object of type [S] if the dialog's positive button is clicked,
|
||||
* otherwise null.
|
||||
*/
|
||||
suspend fun <S> MaterialDatePicker<S>.await(
|
||||
fragmentManager: androidx.fragment.app.FragmentManager,
|
||||
tag: String?,
|
||||
) = suspendCancellableCoroutine { cont ->
|
||||
val listener = MaterialPickerOnPositiveButtonClickListener<S> { selection ->
|
||||
cont.resume(selection) { dismiss() }
|
||||
}
|
||||
|
||||
addOnPositiveButtonClickListener(listener)
|
||||
addOnNegativeButtonClickListener { cont.resume(null) { dismiss() } }
|
||||
addOnCancelListener { cont.resume(null) { dismiss() } }
|
||||
addOnDismissListener { if (!cont.isCompleted) cont.resume(null) { dismiss() } }
|
||||
cont.invokeOnCancellation { dismiss() }
|
||||
|
||||
show(fragmentManager, tag)
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue