diff --git a/app/src/main/java/app/pachli/components/search/SearchActivity.kt b/app/src/main/java/app/pachli/components/search/SearchActivity.kt index 39b541e2c..e860178fa 100644 --- a/app/src/main/java/app/pachli/components/search/SearchActivity.kt +++ b/app/src/main/java/app/pachli/components/search/SearchActivity.kt @@ -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>, 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()?.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()?.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()?.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()?.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()?.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()?.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()?.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()?.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()?.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 { + return viewModel.searchAccountAutocompleteSuggestions(token) + } } diff --git a/app/src/main/java/app/pachli/components/search/SearchOperator.kt b/app/src/main/java/app/pachli/components/search/SearchOperator.kt new file mode 100644 index 000000000..3360e9e85 --- /dev/null +++ b/app/src/main/java/app/pachli/components/search/SearchOperator.kt @@ -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 . + */ + +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 = emptyList()) : HasMediaOption + + /** + * Include only posts that have [include] media attached, excluding posts + * that have [exclude] attached media. + */ + data class SpecificMedia( + val include: List = emptyList(), + val exclude: List = 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:` or `-from:` 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 + } +} diff --git a/app/src/main/java/app/pachli/components/search/SearchOperatorViewData.kt b/app/src/main/java/app/pachli/components/search/SearchOperatorViewData.kt new file mode 100644 index 000000000..cd563ae14 --- /dev/null +++ b/app/src/main/java/app/pachli/components/search/SearchOperatorViewData.kt @@ -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 . + */ + +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 { + /** 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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) + } + } +} diff --git a/app/src/main/java/app/pachli/components/search/SearchViewModel.kt b/app/src/main/java/app/pachli/components/search/SearchViewModel.kt index dac32b31b..90aa4e84a 100644 --- a/app/src/main/java/app/pachli/components/search/SearchViewModel.kt +++ b/app/src/main/java/app/pachli/components/search/SearchViewModel.kt @@ -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 = 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 getOperator() = operatorViewData.value.find { it.operator is T }?.operator as T? + + /** + * Replaces the existing [SearchOperatorViewData] in [_operatorViewData] + * with [viewData]. + */ + fun replaceOperator(viewData: SearchOperatorViewData) = _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 { + // "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) { diff --git a/app/src/main/res/drawable/ic_attach_file_24dp.xml b/app/src/main/res/drawable/ic_attach_file_24dp.xml index 806cac00e..05ba4bd6d 100644 --- a/app/src/main/res/drawable/ic_attach_file_24dp.xml +++ b/app/src/main/res/drawable/ic_attach_file_24dp.xml @@ -4,6 +4,6 @@ android:viewportWidth="24.0" android:viewportHeight="24.0"> diff --git a/app/src/main/res/drawable/ic_link_24.xml b/app/src/main/res/drawable/ic_link_24.xml new file mode 100644 index 000000000..66b0c317b --- /dev/null +++ b/app/src/main/res/drawable/ic_link_24.xml @@ -0,0 +1,22 @@ + + + + + + + diff --git a/app/src/main/res/drawable/ic_reply_all_24dp.xml b/app/src/main/res/drawable/ic_reply_all_24dp.xml index 9da31f037..e53192ad0 100644 --- a/app/src/main/res/drawable/ic_reply_all_24dp.xml +++ b/app/src/main/res/drawable/ic_reply_all_24dp.xml @@ -5,6 +5,6 @@ android:viewportHeight="24.0" android:viewportWidth="24.0"> diff --git a/app/src/main/res/layout/activity_search.xml b/app/src/main/res/layout/activity_search.xml index 679fb7880..aaf235457 100644 --- a/app/src/main/res/layout/activity_search.xml +++ b/app/src/main/res/layout/activity_search.xml @@ -21,6 +21,101 @@ app:layout_scrollFlags="scroll|snap|enterAlways" app:navigationIcon="?attr/homeAsUpIndicator" /> + + + + + + + + + + + + + + + + + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/search_operator_from_dialog.xml b/app/src/main/res/layout/search_operator_from_dialog.xml new file mode 100644 index 000000000..69d41dcda --- /dev/null +++ b/app/src/main/res/layout/search_operator_from_dialog.xml @@ -0,0 +1,117 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/search_operator_where_location_dialog.xml b/app/src/main/res/layout/search_operator_where_location_dialog.xml new file mode 100644 index 000000000..fc33fddbc --- /dev/null +++ b/app/src/main/res/layout/search_operator_where_location_dialog.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7e8a68c4f..d6d47e8af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -716,4 +716,109 @@ Loading filter failed: %1$s Saving filter failed: %1$s Deleting filter failed: %1$s + + Limit to posts with attachments? + Any type + Images + Video + Sound + + Attachments ▾ + + No attachments + images + video + sound + + At least one attachment + Any except %1$s + Any except %1$s, %2$s + Any except %1$s, %2$s, %3$s + + With %1$s, %2$s, no %3$s + With %1$s, no %2$s, %3$s + With %1$s, no %2$s + With %1$s + With %1$s, %2$s + With %1$s, %2$s, %3$s + No %1$s + No %1$s, %2$s + No %1$s, %2$s, %3$s + + None + At least one + + From ▾ + Only my posts + Not my posts + Only %1$s + Not %1$s + Limit to specific accounts? + All accounts + Only my posts + All posts except mine + Only posts from this account + All posts except this account + \@account + + Show posts with polls? + Polls ▾ + Only show polls + Without polls + With or without polls + Only posts with polls + Only posts without polls + + Preview cards ▾ + Only preview cards + Without preview cards + Show posts with preview cards? + With or without preview cards + Only posts with preview cards + Only posts without preview cards + + Links ▾ + Only links + Without links + Show posts with links? + With or without links + Only posts with links + Only posts without links + + Dates ▾ + %1$s - %2$s + On %1$s + Limit to posts between… + + Limit to posts written in… + All languages ▾ + %1$s + All languages + + Include replies ▾ + Only show replies + Exclude replies + Include replies? + All posts + Only show replies + Without replies + + Include sensitive content ▾ + Sensitive content only + Exclude sensitive content + Posts with sensitive content + All posts + Sensitive content only + Exclude sensitive content + + All posts ▾ + Your library + Public posts + Limit to… + All posts + Posts in your library and public posts + Your library + Your own posts, boosts, favorites, bookmarks, and posts that @mention you + Public posts + Public, searchable posts known by server diff --git a/core/network/src/main/kotlin/app/pachli/core/network/Server.kt b/core/network/src/main/kotlin/app/pachli/core/network/Server.kt index ebe4042e1..8bab7012d 100644 --- a/core/network/src/main/kotlin/app/pachli/core/network/Server.kt +++ b/core/network/src/main/kotlin/app/pachli/core/network/Server.kt @@ -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(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() } } diff --git a/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt b/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt index fff3c1dd2..759adcd7e 100644 --- a/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt +++ b/core/network/src/test/kotlin/app/pachli/core/network/ServerTest.kt @@ -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(), ), ), ), diff --git a/core/ui/src/main/kotlin/app/pachli/core/ui/extensions/AlertDialogExtensions.kt b/core/ui/src/main/kotlin/app/pachli/core/ui/extensions/AlertDialogExtensions.kt index df222410e..73b88c29e 100644 --- a/core/ui/src/main/kotlin/app/pachli/core/ui/extensions/AlertDialogExtensions.kt +++ b/core/ui/src/main/kotlin/app/pachli/core/ui/extensions/AlertDialogExtensions.kt @@ -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 AlertDialog.Builder.awaitSingleChoiceItem( + items: List, + 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 MaterialDatePicker.await( + fragmentManager: androidx.fragment.app.FragmentManager, + tag: String?, +) = suspendCancellableCoroutine { cont -> + val listener = MaterialPickerOnPositiveButtonClickListener { 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) +}