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:
Nik Clayton 2024-07-22 17:11:08 +02:00 committed by GitHub
parent e063ae69e6
commit 71e006b0d2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 2311 additions and 31 deletions

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}

View File

@ -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) {

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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"

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

@ -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>

View File

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

View File

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

View File

@ -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)
}