diff --git a/app/src/main/java/org/wikipedia/Constants.kt b/app/src/main/java/org/wikipedia/Constants.kt index 0db327ef9d5..aaa114099c3 100644 --- a/app/src/main/java/org/wikipedia/Constants.kt +++ b/app/src/main/java/org/wikipedia/Constants.kt @@ -18,6 +18,7 @@ object Constants { const val ARG_TITLE = "title" const val ARG_WIKISITE = "wikiSite" const val ARG_TEXT = "text" + const val ARG_BOOLEAN = "boolean" const val INTENT_APP_SHORTCUT_CONTINUE_READING = "appShortcutContinueReading" const val INTENT_APP_SHORTCUT_RANDOMIZER = "appShortcutRandomizer" const val INTENT_APP_SHORTCUT_SEARCH = "appShortcutSearch" @@ -105,6 +106,7 @@ object Constants { USER_CONTRIB_ACTIVITY("userContribActivity"), EDIT_ADD_IMAGE("editAddImage"), SUGGESTED_EDITS_RECENT_EDITS("suggestedEditsRecentEdits"), + RECOMMENDED_CONTENT("recommendedContent"), } enum class ImageEditType(name: String) { diff --git a/app/src/main/java/org/wikipedia/analytics/eventplatform/ABTest.kt b/app/src/main/java/org/wikipedia/analytics/ABTest.kt similarity index 92% rename from app/src/main/java/org/wikipedia/analytics/eventplatform/ABTest.kt rename to app/src/main/java/org/wikipedia/analytics/ABTest.kt index d080dde0a84..3d803819ac7 100644 --- a/app/src/main/java/org/wikipedia/analytics/eventplatform/ABTest.kt +++ b/app/src/main/java/org/wikipedia/analytics/ABTest.kt @@ -1,4 +1,4 @@ -package org.wikipedia.analytics.eventplatform +package org.wikipedia.analytics import org.wikipedia.settings.PrefsIoUtil import kotlin.random.Random @@ -24,6 +24,7 @@ open class ABTest(private val abTestName: String, private val abTestGroupCount: companion object { private const val AB_TEST_KEY_PREFIX = "ab_test_" const val GROUP_SIZE_2 = 2 + const val GROUP_SIZE_3 = 3 const val GROUP_1 = 0 const val GROUP_2 = 1 const val GROUP_3 = 2 diff --git a/app/src/main/java/org/wikipedia/analytics/metricsplatform/ArticleEvent.kt b/app/src/main/java/org/wikipedia/analytics/metricsplatform/ArticleEvent.kt index f7531311e33..8e6b5ec552d 100644 --- a/app/src/main/java/org/wikipedia/analytics/metricsplatform/ArticleEvent.kt +++ b/app/src/main/java/org/wikipedia/analytics/metricsplatform/ArticleEvent.kt @@ -1,7 +1,10 @@ package org.wikipedia.analytics.metricsplatform +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable import org.wikimedia.metrics_platform.context.PageData import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.json.JsonUtil import org.wikipedia.page.PageFragment import org.wikipedia.page.PageTitle import org.wikipedia.settings.Prefs @@ -201,9 +204,14 @@ class ArticleTocInteraction(private val fragment: PageFragment, private val numS } } -class ArticleLinkPreviewInteraction : TimedMetricsEvent { +open class ArticleLinkPreviewInteraction : TimedMetricsEvent { private val pageData: PageData? - private val source: Int + var source: Int + + constructor(source: Int) { + this.source = source + this.pageData = null + } constructor(fragment: PageFragment, source: Int) { this.source = source @@ -221,18 +229,18 @@ class ArticleLinkPreviewInteraction : TimedMetricsEvent { } fun logLinkClick() { - submitEvent("linkclick") + submitEvent("linkclick", ContextData(timeSpentMillis = timer.elapsedMillis)) } - fun logNavigate() { - submitEvent(if (Prefs.isLinkPreviewEnabled) "navigate" else "disabled") + open fun logNavigate() { + submitEvent(if (Prefs.isLinkPreviewEnabled) "navigate" else "disabled", ContextData(timeSpentMillis = timer.elapsedMillis)) } fun logCancel() { - submitEvent("cancel") + submitEvent("cancel", ContextData(timeSpentMillis = timer.elapsedMillis)) } - private fun submitEvent(action: String) { + protected fun submitEvent(action: String, contextData: ContextData) { submitEvent( "android.product_metrics.article_link_preview_interaction", "article_link_preview_interaction", @@ -240,9 +248,54 @@ class ArticleLinkPreviewInteraction : TimedMetricsEvent { action, null, source.toString(), - "time_spent_ms.${timer.elapsedMillis}", + JsonUtil.encodeToString(contextData) ), pageData ) } + + @Serializable + class ContextData( + @SerialName("group_assigned") val groupAssigned: String? = null, + @SerialName("time_spent_ms") val timeSpentMillis: Long? = null, + @SerialName("rec_shown") val recShown: Boolean? = null, + @SerialName("feedback_shown") val feedbackShown: Boolean? = null, + @SerialName("feedback_select") val feedbackSelect: String? = null, + @SerialName("feedback_text") val feedbackText: String? = null, + ) +} + +class ExperimentalLinkPreviewInteraction( + source: Int, + private val groupAssigned: String, + private val recommendationsShown: Boolean? = null +) : ArticleLinkPreviewInteraction(source) { + + fun logImpression(feedbackShown: Boolean? = null, feedbackSelect: String? = null, + feedbackText: String? = null) { + submitEvent("impression", ContextData( + timeSpentMillis = timer.elapsedMillis, + groupAssigned = groupAssigned, + recShown = recommendationsShown, + feedbackShown = feedbackShown, + feedbackSelect = feedbackSelect, + feedbackText = feedbackText + )) + } + + override fun logNavigate() { + logNavigate(null, null, null) + } + + fun logNavigate(feedbackShown: Boolean? = null, feedbackSelect: String? = null, + feedbackText: String? = null) { + submitEvent("navigate", ContextData( + timeSpentMillis = timer.elapsedMillis, + groupAssigned = groupAssigned, + recShown = recommendationsShown, + feedbackShown = feedbackShown, + feedbackSelect = feedbackSelect, + feedbackText = feedbackText + )) + } } diff --git a/app/src/main/java/org/wikipedia/analytics/metricsplatform/RecommendedContentABCTest.kt b/app/src/main/java/org/wikipedia/analytics/metricsplatform/RecommendedContentABCTest.kt new file mode 100644 index 00000000000..1c2f9be68b7 --- /dev/null +++ b/app/src/main/java/org/wikipedia/analytics/metricsplatform/RecommendedContentABCTest.kt @@ -0,0 +1,13 @@ +package org.wikipedia.analytics.metricsplatform + +import org.wikipedia.analytics.ABTest + +class RecommendedContentABCTest : ABTest("recommendedContent", GROUP_SIZE_3) { + fun getGroupName(): String { + return when (group) { + GROUP_2 -> "general" + GROUP_3 -> "personal" + else -> "control" + } + } +} diff --git a/app/src/main/java/org/wikipedia/analytics/metricsplatform/RecommendedContentAnalyticsHelper.kt b/app/src/main/java/org/wikipedia/analytics/metricsplatform/RecommendedContentAnalyticsHelper.kt new file mode 100644 index 00000000000..bcbd4b9d3a2 --- /dev/null +++ b/app/src/main/java/org/wikipedia/analytics/metricsplatform/RecommendedContentAnalyticsHelper.kt @@ -0,0 +1,25 @@ +package org.wikipedia.analytics.metricsplatform + +import org.wikipedia.util.GeoUtil +import org.wikipedia.util.ReleaseUtil +import java.time.LocalDate + +class RecommendedContentAnalyticsHelper { + + companion object { + val abcTest = RecommendedContentABCTest() + + private val enabledCountries = listOf( + // sub-saharan africa + "AO", "BJ", "BW", "IO", "BF", "BI", "CV", "CM", "CF", "TD", "KM", "CG", "IC", "CD", "DJ", "GQ", "ER", + "SZ", "ET", "GA", "GM", "GH", "GN", "GW", "KE", "LS", "LR", "MG", "MW", "ML", "MR", "YT", "MZ", "NA", + "NE", "NG", "RE", "RW", "SH", "ST", "SN", "SC", "SL", "SO", "ZA", "SS", "TG", "UG", "TZ", "ZM", "ZW", + // south asia + "IN", "PK", "BD", "LK", "MU", "MV", "NP", "BT", "AF" + ) + + val recommendedContentEnabled get() = ReleaseUtil.isPreBetaRelease || + (enabledCountries.contains(GeoUtil.geoIPCountry.orEmpty()) && + LocalDate.now() <= LocalDate.of(2024, 10, 2)) + } +} diff --git a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsFragment.kt b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsFragment.kt index a971423ae70..19f3c0a1578 100644 --- a/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsFragment.kt +++ b/app/src/main/java/org/wikipedia/diff/ArticleEditDetailsFragment.kt @@ -689,7 +689,7 @@ class ArticleEditDetailsFragment : Fragment(), WatchlistExpiryDialog.Callback, M if (Prefs.showOneTimeRecentEditsFeedbackForm) { sendPatrollerExperienceEvent("toolbar_first_feedback", "pt_feedback") } - SurveyDialog.showFeedbackOptionsDialog(requireActivity(), InvokeSource.SUGGESTED_EDITS_RECENT_EDITS) + SurveyDialog.showFeedbackOptionsDialog(requireActivity(), invokeSource = InvokeSource.SUGGESTED_EDITS_RECENT_EDITS) } private fun updateActionButtons() { diff --git a/app/src/main/java/org/wikipedia/history/HistoryEntry.kt b/app/src/main/java/org/wikipedia/history/HistoryEntry.kt index 2cf1a5034bb..0275e057793 100644 --- a/app/src/main/java/org/wikipedia/history/HistoryEntry.kt +++ b/app/src/main/java/org/wikipedia/history/HistoryEntry.kt @@ -100,5 +100,7 @@ class HistoryEntry( const val SOURCE_SINGLE_WEBVIEW = 40 const val SOURCE_SUGGESTED_EDITS_RECENT_EDITS = 41 const val SOURCE_FEED_PLACES = 42 + const val SOURCE_RECOMMENDED_CONTENT_PERSONALIZED = 43 + const val SOURCE_RECOMMENDED_CONTENT_GENERALIZED = 44 } } diff --git a/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt b/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt index d29620cd79e..fc6e0b25410 100644 --- a/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt +++ b/app/src/main/java/org/wikipedia/history/db/HistoryEntryWithImageDao.kt @@ -41,14 +41,13 @@ interface HistoryEntryWithImageDao { else SearchResults(entries.take(3).map { SearchResult(toHistoryEntry(it).title, SearchResult.SearchResultType.HISTORY) }.toMutableList()) } + fun filterHistoryItemsWithoutTime(searchQuery: String = ""): List { + return findEntriesBySearchTerm("%${normalizedQuery(searchQuery)}%").map { toHistoryEntry(it) } + } + fun filterHistoryItems(searchQuery: String): List { val list = mutableListOf() - val normalizedQuery = StringUtils.stripAccents(searchQuery).lowercase(Locale.getDefault()) - .replace("\\", "\\\\") - .replace("%", "\\%") - .replace("_", "\\_") - - val entries = findEntriesBySearchTerm("%$normalizedQuery%") + val entries = findEntriesBySearchTerm("%${normalizedQuery(searchQuery)}%") for (i in entries.indices) { // Check the previous item, see if the times differ enough @@ -78,6 +77,13 @@ interface HistoryEntryWithImageDao { return entries.map { toHistoryEntry(it) } } + private fun normalizedQuery(searchQuery: String): String { + return StringUtils.stripAccents(searchQuery).lowercase(Locale.getDefault()) + .replace("\\", "\\\\") + .replace("%", "\\%") + .replace("_", "\\_") + } + private fun toHistoryEntry(entryWithImage: HistoryEntryWithImage): HistoryEntry { val entry = HistoryEntry(entryWithImage.authority, entryWithImage.lang, entryWithImage.apiTitle, entryWithImage.displayTitle, 0, entryWithImage.namespace, entryWithImage.timestamp, diff --git a/app/src/main/java/org/wikipedia/page/PageFragment.kt b/app/src/main/java/org/wikipedia/page/PageFragment.kt index c0e2fc23269..47c20deafa6 100644 --- a/app/src/main/java/org/wikipedia/page/PageFragment.kt +++ b/app/src/main/java/org/wikipedia/page/PageFragment.kt @@ -101,6 +101,7 @@ import org.wikipedia.theme.ThemeChooserDialog import org.wikipedia.util.ActiveTimer import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil +import org.wikipedia.util.ReleaseUtil import org.wikipedia.util.ResourceUtil import org.wikipedia.util.ShareUtil import org.wikipedia.util.ThrowableUtil @@ -108,12 +109,14 @@ import org.wikipedia.util.UriUtil import org.wikipedia.util.log.L import org.wikipedia.views.ObservableWebView import org.wikipedia.views.PageActionOverflowView +import org.wikipedia.views.SurveyDialog import org.wikipedia.views.ViewUtil import org.wikipedia.watchlist.WatchlistExpiry import org.wikipedia.watchlist.WatchlistExpiryDialog import org.wikipedia.wiktionary.WiktionaryDialog import java.time.Duration import java.time.Instant +import java.util.concurrent.TimeUnit class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.CommunicationBridgeListener, ThemeChooserDialog.Callback, ReferenceDialog.Callback, WiktionaryDialog.Callback, WatchlistExpiryDialog.Callback { @@ -685,6 +688,31 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi } } + private fun maybeShowRecommendedContentSurvey() { + if (Prefs.recommendedContentSurveyShown) { + return + } + historyEntry?.let { + val duration = if (ReleaseUtil.isDevRelease) 1L else 10L + binding.pageContentsContainer.postDelayed({ + if (!isAdded) { + return@postDelayed + } + if (it.source == HistoryEntry.SOURCE_RECOMMENDED_CONTENT_PERSONALIZED || + it.source == HistoryEntry.SOURCE_RECOMMENDED_CONTENT_GENERALIZED) { + SurveyDialog.showFeedbackOptionsDialog( + requireActivity(), + titleId = R.string.recommended_content_survey_dialog_title, + messageId = R.string.recommended_content_survey_dialog_message, + snackbarMessageId = R.string.recommended_content_survey_dialog_submitted_message, + invokeSource = InvokeSource.RECOMMENDED_CONTENT, + historyEntry = it + ) + } + }, TimeUnit.SECONDS.toMillis(duration)) + } + } + private fun showFindReferenceInPage(referenceAnchor: String, backLinksList: List, referenceText: String) { @@ -928,6 +956,7 @@ class PageFragment : Fragment(), BackPressedHandler, CommunicationBridge.Communi webView.visibility = View.VISIBLE } maybeShowAnnouncement() + maybeShowRecommendedContentSurvey() bridge.onMetadataReady() // Explicitly set the top margin (even though it might have already been set in the setup // handler), since the page metadata might have altered the lead image display state. diff --git a/app/src/main/java/org/wikipedia/recommendedcontent/RecommendedContentFragment.kt b/app/src/main/java/org/wikipedia/recommendedcontent/RecommendedContentFragment.kt new file mode 100644 index 00000000000..47b6180b40c --- /dev/null +++ b/app/src/main/java/org/wikipedia/recommendedcontent/RecommendedContentFragment.kt @@ -0,0 +1,185 @@ +package org.wikipedia.recommendedcontent + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.fragment.app.viewModels +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import kotlinx.coroutines.launch +import org.wikipedia.Constants +import org.wikipedia.analytics.metricsplatform.ExperimentalLinkPreviewInteraction +import org.wikipedia.analytics.metricsplatform.RecommendedContentAnalyticsHelper +import org.wikipedia.databinding.FragmentRecommendedContentBinding +import org.wikipedia.databinding.ItemRecommendedContentSearchHistoryBinding +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.history.HistoryEntry +import org.wikipedia.page.PageTitle +import org.wikipedia.search.SearchFragment +import org.wikipedia.settings.Prefs +import org.wikipedia.util.Resource +import org.wikipedia.util.StringUtil +import org.wikipedia.util.log.L + +class RecommendedContentFragment : Fragment() { + private var _binding: FragmentRecommendedContentBinding? = null + private val binding get() = _binding!! + private val viewModel: RecommendedContentViewModel by viewModels { RecommendedContentViewModel.Factory(requireArguments()) } + + private val parentSearchFragment get() = requireParentFragment().requireParentFragment() as SearchFragment + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + super.onCreateView(inflater, container, savedInstanceState) + + _binding = FragmentRecommendedContentBinding.inflate(layoutInflater, container, false) + + viewLifecycleOwner.lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + launch { + viewModel.recentSearchesState.collect { + when (it) { + is Resource.Success -> { + buildHistoryList(it.data) + } + is Resource.Error -> { + parentSearchFragment.onSearchProgressBar(false) + parentFragmentManager.popBackStack() + L.d(it.throwable) + } + } + } + } + launch { + viewModel.recommendedContentState.collect { + when (it) { + is Resource.Loading -> { + parentSearchFragment.onSearchProgressBar(true) + } + is Resource.Success -> { + buildRecommendedContent(it.data) + } + is Resource.Error -> { + parentSearchFragment.onSearchProgressBar(false) + parentFragmentManager.popBackStack() + L.d(it.throwable) + } + } + } + } + launch { + viewModel.actionState.collect { + when (it) { + is Resource.Success -> { + reloadRecentSearchesList(it.data.first, it.data.second) + } + + is Resource.Error -> { + L.d(it.throwable) + } + } + } + } + } + } + + return binding.root + } + + override fun onResume() { + super.onResume() + viewModel.loadSearchHistory() + parentSearchFragment.setUpLanguageScroll(Prefs.selectedLanguagePositionInSearch) + } + + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } + + private fun buildHistoryList(list: List) { + binding.recentSearchesList.layoutManager = LinearLayoutManager(requireContext()) + binding.recentSearchesList.adapter = RecyclerViewAdapter(list) + } + + private fun buildRecommendedContent(list: List) { + parentSearchFragment.onSearchProgressBar(false) + if (list.isEmpty()) { + parentSearchFragment.analyticsEvent = ExperimentalLinkPreviewInteraction(HistoryEntry.SOURCE_SEARCH, RecommendedContentAnalyticsHelper.abcTest.getGroupName(), false) + .also { it.logImpression() } + parentFragmentManager.popBackStack() + return + } + parentSearchFragment.analyticsEvent = ExperimentalLinkPreviewInteraction(HistoryEntry.SOURCE_SEARCH, RecommendedContentAnalyticsHelper.abcTest.getGroupName(), true) + .also { it.logImpression() } + binding.recommendedContent.buildContent(viewModel.wikiSite, list, parentSearchFragment.analyticsEvent) + } + + private fun reloadRecentSearchesList(position: Int, list: List) { + val adapter = binding.recentSearchesList.adapter as RecyclerViewAdapter + adapter.notifyItemRemoved(position) + adapter.setList(list) + adapter.notifyItemRangeChanged(0, list.size) + } + + private inner class RecyclerViewAdapter(list: List) : RecyclerView.Adapter() { + + var pages: List + + init { + this.pages = list + } + + fun setList(list: List) { + this.pages = list + } + + override fun getItemCount(): Int { + return pages.size + } + + override fun onCreateViewHolder(parent: ViewGroup, type: Int): RecyclerViewItemHolder { + return RecyclerViewItemHolder(ItemRecommendedContentSearchHistoryBinding.inflate(layoutInflater, parent, false)) + } + + override fun onBindViewHolder(holder: RecyclerViewItemHolder, position: Int) { + holder.bindItem(pages[position], position) + } + } + + private inner class RecyclerViewItemHolder(val binding: ItemRecommendedContentSearchHistoryBinding) : + RecyclerView.ViewHolder(binding.root) { + + fun bindItem(pageTitle: PageTitle, position: Int) { + binding.listItem.text = StringUtil.fromHtml(pageTitle.displayText) + binding.deleteIcon.setOnClickListener { + viewModel.removeRecentSearchItem(pageTitle, position) + } + + binding.listItem.setOnClickListener { + parentSearchFragment.setSearchText(pageTitle.displayText) + } + } + } + + fun reload(wikiSite: WikiSite) { + viewModel.reload(wikiSite) + binding.nestedScrollView.smoothScrollTo(0, 0) + } + + companion object { + + fun newInstance(wikiSite: WikiSite, isGeneralized: Boolean) = RecommendedContentFragment().apply { + arguments = bundleOf( + Constants.ARG_WIKISITE to wikiSite, + Constants.ARG_BOOLEAN to isGeneralized + ) + } + } +} diff --git a/app/src/main/java/org/wikipedia/recommendedcontent/RecommendedContentSectionView.kt b/app/src/main/java/org/wikipedia/recommendedcontent/RecommendedContentSectionView.kt new file mode 100644 index 00000000000..082d717165f --- /dev/null +++ b/app/src/main/java/org/wikipedia/recommendedcontent/RecommendedContentSectionView.kt @@ -0,0 +1,79 @@ +package org.wikipedia.recommendedcontent + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.FrameLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import org.wikipedia.R +import org.wikipedia.analytics.ABTest +import org.wikipedia.analytics.metricsplatform.ExperimentalLinkPreviewInteraction +import org.wikipedia.analytics.metricsplatform.RecommendedContentAnalyticsHelper +import org.wikipedia.databinding.ItemRecommendedContentSectionTextBinding +import org.wikipedia.databinding.ViewRecommendedContentSectionBinding +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.history.HistoryEntry +import org.wikipedia.page.PageActivity +import org.wikipedia.util.StringUtil + +class RecommendedContentSectionView(context: Context, attrs: AttributeSet? = null) : FrameLayout(context, attrs) { + + private val binding = ViewRecommendedContentSectionBinding.inflate(LayoutInflater.from(context), this, true) + private var analyticsEvent: ExperimentalLinkPreviewInteraction? = null + + fun buildContent(wikiSite: WikiSite, pageSummaries: List, analyticsEvent: ExperimentalLinkPreviewInteraction?) { + this.analyticsEvent = analyticsEvent + binding.sectionHeader.text = context.getString(R.string.recommended_content_section_you_might_like) + binding.sectionList.layoutManager = LinearLayoutManager(context) + binding.sectionList.adapter = RecyclerViewAdapter(pageSummaries, wikiSite) + } + + private inner class RecyclerViewAdapter(val list: List, val wikiSite: WikiSite) : RecyclerView.Adapter() { + override fun getItemCount(): Int { + return list.size + } + + override fun onCreateViewHolder(parent: ViewGroup, type: Int): RecyclerViewItemHolder { + return RecyclerViewItemHolder(ItemRecommendedContentSectionTextBinding.inflate(LayoutInflater.from(parent.context), parent, false)) + } + + override fun onBindViewHolder(holder: RecyclerViewItemHolder, position: Int) { + holder.bindItem(list[position], wikiSite) + } + } + + private inner class RecyclerViewItemHolder(val binding: ItemRecommendedContentSectionTextBinding) : + RecyclerView.ViewHolder(binding.root), OnClickListener { + + private lateinit var pageSummary: PageSummary + private lateinit var wikiSite: WikiSite + + init { + itemView.setOnClickListener(this) + } + + fun bindItem(pageSummary: PageSummary, wikiSite: WikiSite) { + this.pageSummary = pageSummary + this.wikiSite = wikiSite + binding.listItem.text = StringUtil.fromHtml(pageSummary.displayTitle) + } + + override fun onClick(v: View) { + val source = if (RecommendedContentAnalyticsHelper.abcTest.group == ABTest.GROUP_2) { + HistoryEntry.SOURCE_RECOMMENDED_CONTENT_GENERALIZED + } else { + HistoryEntry.SOURCE_RECOMMENDED_CONTENT_PERSONALIZED + } + val entry = HistoryEntry(pageSummary.getPageTitle(wikiSite), source) + context.startActivity(PageActivity.newIntentForNewTab(context, entry, entry.title)) + analyticsEvent?.let { + it.source = source + it.logNavigate() + } + } + } +} diff --git a/app/src/main/java/org/wikipedia/recommendedcontent/RecommendedContentViewModel.kt b/app/src/main/java/org/wikipedia/recommendedcontent/RecommendedContentViewModel.kt new file mode 100644 index 00000000000..fc15ce1b6a8 --- /dev/null +++ b/app/src/main/java/org/wikipedia/recommendedcontent/RecommendedContentViewModel.kt @@ -0,0 +1,255 @@ +package org.wikipedia.recommendedcontent + +import android.location.Location +import android.os.Bundle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.wikipedia.Constants +import org.wikipedia.R +import org.wikipedia.WikipediaApp +import org.wikipedia.database.AppDatabase +import org.wikipedia.dataclient.ServiceFactory +import org.wikipedia.dataclient.WikiSite +import org.wikipedia.dataclient.page.PageSummary +import org.wikipedia.extensions.parcelable +import org.wikipedia.feed.aggregated.AggregatedFeedContent +import org.wikipedia.feed.topread.TopRead +import org.wikipedia.page.PageTitle +import org.wikipedia.places.PlacesFragment +import org.wikipedia.settings.Prefs +import org.wikipedia.util.DateUtil +import org.wikipedia.util.ImageUrlUtil +import org.wikipedia.util.L10nUtil +import org.wikipedia.util.Resource +import org.wikipedia.util.StringUtil +import java.util.Date + +class RecommendedContentViewModel(bundle: Bundle) : ViewModel() { + + var wikiSite = bundle.parcelable(Constants.ARG_WIKISITE)!! + private val isGeneralized = bundle.getBoolean(Constants.ARG_BOOLEAN) + + private var exploreTerm: String? = null + private var feedContent = mutableMapOf() + + private val _recentSearchesState = MutableStateFlow(Resource>()) + val recentSearchesState = _recentSearchesState.asStateFlow() + + private val _actionState = MutableStateFlow(Resource>>()) + val actionState = _actionState.asStateFlow() + + private val _recommendedContentState = MutableStateFlow(Resource>()) + val recommendedContentState = _recommendedContentState.asStateFlow() + + private var recommendedContentFetchJob: Job? = null + + init { + reload(wikiSite) + } + + fun reload(wikiSite: WikiSite) { + this.wikiSite = wikiSite + loadSearchHistory() + loadRecommendedContent(isGeneralized) + } + + fun removeRecentSearchItem(title: PageTitle, position: Int) { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + _actionState.value = Resource.Error(throwable) + }) { + withContext(Dispatchers.IO) { + AppDatabase.instance.recentSearchDao().deleteBy(title.displayText, Date(title.description.orEmpty().toLong())) + } + _actionState.value = Resource.Success(position to loadRecentSearches()) + } + } + + fun loadSearchHistory() { + viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + _recentSearchesState.value = Resource.Error(throwable) + }) { + _recentSearchesState.value = Resource.Success(loadRecentSearches()) + } + } + + private suspend fun getExploreSearchTerm(): String { + return withContext(Dispatchers.IO) { + // Get term from last opened article + var term = WikipediaApp.instance.tabList.lastOrNull { + it.backStackPositionTitle?.wikiSite == wikiSite + }?.backStackPositionTitle?.displayText ?: "" + + // Get term from last history entry if no article is opened + if (term.isEmpty()) { + term = AppDatabase.instance.historyEntryWithImageDao().filterHistoryItemsWithoutTime().firstOrNull { + it.title.wikiSite == wikiSite + }?.apiTitle ?: "" + } + + // Ger term from Because you read if no history entry is found + if (term.isEmpty()) { + term = AppDatabase.instance.historyEntryWithImageDao().findEntryForReadMore(0, WikipediaApp.instance.resources.getInteger( + R.integer.article_engagement_threshold_sec)).lastOrNull { + it.title.wikiSite == wikiSite + }?.title?.displayText ?: "" + } + + // Get term from last recent search if no because you read is found + if (term.isEmpty()) { + term = AppDatabase.instance.recentSearchDao().getRecentSearches().firstOrNull()?.text ?: "" + } + + StringUtil.addUnderscores(StringUtil.removeHTMLTags(term)) + } + } + + private fun loadRecommendedContent(isGeneralized: Boolean) { + recommendedContentFetchJob?.cancel() + recommendedContentFetchJob = viewModelScope.launch(CoroutineExceptionHandler { _, throwable -> + _recommendedContentState.value = Resource.Error(throwable) + }) { + _recommendedContentState.value = Resource.Loading() + delay(200) + val recommendedContent = if (isGeneralized) { + loadGeneralizedContent() + } else { + loadPersonalizedContent() + } + _recommendedContentState.value = Resource.Success(recommendedContent) + } + } + + private suspend fun loadRecentSearches(): List { + return withContext(Dispatchers.IO) { + AppDatabase.instance.recentSearchDao().getRecentSearches().map { + PageTitle(it.text, wikiSite).apply { + // Put timestamp in description for the delete action. + description = it.timestamp.time.toString() + } + }.take(RECENT_SEARCHES_ITEMS) + } + } + + private suspend fun loadFeed(): AggregatedFeedContent { + return withContext(Dispatchers.IO) { + + feedContent[wikiSite.languageCode]?.let { + return@withContext it + } + + val hasParentLanguageCode = !WikipediaApp.instance.languageState.getDefaultLanguageCode(wikiSite.languageCode).isNullOrEmpty() + val date = DateUtil.getUtcRequestDateFor(0) + var feedContentResponse = ServiceFactory.getRest(wikiSite).getFeedFeatured(date.year, date.month, date.day) + if (hasParentLanguageCode) { + feedContentResponse.topRead?.let { + val topReadResponse = L10nUtil.getPagesForLanguageVariant(it.articles, wikiSite) + feedContentResponse = AggregatedFeedContent( + tfa = feedContentResponse.tfa, + news = feedContentResponse.news, + topRead = TopRead(it.date, topReadResponse), + potd = feedContentResponse.potd, + onthisday = feedContentResponse.onthisday + ) + } + } + // set map to feedContent + feedContent[wikiSite.languageCode] = feedContentResponse + feedContentResponse + } + } + + private suspend fun loadTopRead(): List { + return withContext(Dispatchers.IO) { + loadFeed().topRead?.articles ?: emptyList() + } + } + + private suspend fun loadInTheNews(): List { + return withContext(Dispatchers.IO) { + loadFeed().news?.mapNotNull { it.links.firstOrNull() } ?: emptyList() + } + } + + private suspend fun loadGeneralizedContent(): List { + return withContext(Dispatchers.IO) { + val news = async { loadInTheNews() } + val topRead = async { loadTopRead() } + // Take most news items and fill the rest with top read items + (news.await() + topRead.await()).distinct().take(RECOMMENDED_CONTENT_ITEMS).shuffled() + } + } + + private suspend fun loadPersonalizedContent(): List { + return withContext(Dispatchers.IO) { + val places = async { loadPlaces() } + val explore = async { + exploreTerm = getExploreSearchTerm() + exploreTerm?.let { + loadExplore(it) + } ?: emptyList() + } + // Take at most 5 places and fill the rest with explore items + (places.await().take(5) + explore.await()).distinct().take(RECOMMENDED_CONTENT_ITEMS).shuffled() + } + } + + private suspend fun loadExplore(searchTerm: String): List { + return withContext(Dispatchers.IO) { + val moreLikeResponse = ServiceFactory.get(wikiSite).searchMoreLike("morelike:$searchTerm", Constants.SUGGESTION_REQUEST_ITEMS, Constants.SUGGESTION_REQUEST_ITEMS) + val hasParentLanguageCode = !WikipediaApp.instance.languageState.getDefaultLanguageCode(wikiSite.languageCode).isNullOrEmpty() + + val list = moreLikeResponse.query?.pages?.map { + PageSummary(it.displayTitle(wikiSite.languageCode), it.title, it.description, it.extract, it.thumbUrl(), wikiSite.languageCode) + } ?: emptyList() + + if (hasParentLanguageCode) { + L10nUtil.getPagesForLanguageVariant(list, wikiSite) + } else { + list + } + } + } + + private suspend fun loadPlaces(): List { + return withContext(Dispatchers.IO) { + Prefs.placesLastLocationAndZoomLevel?.let { pair -> + val location = pair.first + val response = ServiceFactory.get(wikiSite).getGeoSearch("${location.latitude}|${location.longitude}", 10000, 10, 10) + val pages = response.query?.pages.orEmpty() + .filter { it.coordinates != null } + .map { + val thumbUrl = if (it.thumbUrl().isNullOrEmpty()) null else ImageUrlUtil.getUrlForPreferredSize(it.thumbUrl()!!, PlacesFragment.THUMB_SIZE) + PageSummary(it.displayTitle(wikiSite.languageCode), it.title, it.description, it.extract, thumbUrl, wikiSite.languageCode).apply { + this.coordinates = Location("").apply { + latitude = it.coordinates!![0].lat + longitude = it.coordinates[0].lon + } + } + } + pages + } ?: emptyList() + } + } + + class Factory(private val bundle: Bundle) : ViewModelProvider.Factory { + @Suppress("unchecked_cast") + override fun create(modelClass: Class): T { + return RecommendedContentViewModel(bundle) as T + } + } + + companion object { + const val RECOMMENDED_CONTENT_ITEMS = 10 + const val RECENT_SEARCHES_ITEMS = 3 + } +} diff --git a/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt b/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt index 0960e3038e1..ffbdfb2cde7 100644 --- a/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt +++ b/app/src/main/java/org/wikipedia/search/RecentSearchesFragment.kt @@ -16,12 +16,17 @@ import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.launch import org.wikipedia.R import org.wikipedia.WikipediaApp +import org.wikipedia.analytics.ABTest +import org.wikipedia.analytics.metricsplatform.ExperimentalLinkPreviewInteraction +import org.wikipedia.analytics.metricsplatform.RecommendedContentAnalyticsHelper import org.wikipedia.database.AppDatabase import org.wikipedia.databinding.FragmentSearchRecentBinding import org.wikipedia.dataclient.ServiceFactory import org.wikipedia.dataclient.WikiSite import org.wikipedia.dataclient.mwapi.MwQueryResult +import org.wikipedia.history.HistoryEntry import org.wikipedia.page.Namespace +import org.wikipedia.recommendedcontent.RecommendedContentFragment import org.wikipedia.search.db.RecentSearch import org.wikipedia.util.FeedbackUtil.setButtonTooltip import org.wikipedia.util.ResourceUtil @@ -41,6 +46,7 @@ class RecentSearchesFragment : Fragment() { private val namespaceHints = listOf(Namespace.USER, Namespace.PORTAL, Namespace.HELP) private val namespaceMap = ConcurrentHashMap>() private val coroutineExceptionHandler = CoroutineExceptionHandler { _, throwable -> L.e(throwable) } + private var recommendedContentFragment: RecommendedContentFragment? = null var callback: Callback? = null val recentSearchList = mutableListOf() @@ -71,6 +77,11 @@ class RecentSearchesFragment : Fragment() { return binding.root } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + loadRecommendedContent() + } + fun show() { binding.recentSearchesContainer.visibility = View.VISIBLE } @@ -84,6 +95,23 @@ class RecentSearchesFragment : Fragment() { _binding = null } + private fun loadRecommendedContent() { + if (!RecommendedContentAnalyticsHelper.recommendedContentEnabled || + RecommendedContentAnalyticsHelper.abcTest.group == ABTest.GROUP_1) { + // Construct and send an impression event now, since there will be no loading of recommended content. + (requireParentFragment() as SearchFragment).analyticsEvent = ExperimentalLinkPreviewInteraction(HistoryEntry.SOURCE_SEARCH, RecommendedContentAnalyticsHelper.abcTest.getGroupName(), false) + .also { it.logImpression() } + return + } + val isGeneralized = RecommendedContentAnalyticsHelper.abcTest.group == ABTest.GROUP_2 + val langeCode = callback?.getLangCode() ?: WikipediaApp.instance.appOrSystemLanguageCode + recommendedContentFragment = RecommendedContentFragment.newInstance(wikiSite = WikiSite.forLanguageCode(langeCode), isGeneralized) + childFragmentManager.beginTransaction() + .add(R.id.fragmentOverlayContainer, recommendedContentFragment!!, null) + .addToBackStack(null) + .commit() + } + private fun updateSearchEmptyView(searchesEmpty: Boolean) { if (searchesEmpty) { binding.searchEmptyContainer.visibility = View.VISIBLE @@ -103,12 +131,16 @@ class RecentSearchesFragment : Fragment() { callback?.onAddLanguageClicked() } - fun onLangCodeChanged() { + fun reloadRecentSearches() { lifecycleScope.launch(coroutineExceptionHandler) { updateList() } } + fun reloadRecommendedContent(wikiSite: WikiSite) { + recommendedContentFragment?.reload(wikiSite) + } + suspend fun updateList() { val searches: List val nsMap: Map diff --git a/app/src/main/java/org/wikipedia/search/SearchFragment.kt b/app/src/main/java/org/wikipedia/search/SearchFragment.kt index f8395c90f5f..f95c86901b9 100644 --- a/app/src/main/java/org/wikipedia/search/SearchFragment.kt +++ b/app/src/main/java/org/wikipedia/search/SearchFragment.kt @@ -23,8 +23,10 @@ import org.wikipedia.Constants.InvokeSource import org.wikipedia.R import org.wikipedia.WikipediaApp import org.wikipedia.analytics.eventplatform.PlacesEvent +import org.wikipedia.analytics.metricsplatform.ExperimentalLinkPreviewInteraction import org.wikipedia.database.AppDatabase import org.wikipedia.databinding.FragmentSearchBinding +import org.wikipedia.dataclient.WikiSite import org.wikipedia.history.HistoryEntry import org.wikipedia.json.JsonUtil import org.wikipedia.page.PageActivity @@ -57,6 +59,9 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche var searchLanguageCode = app.languageState.appLanguageCode private set + // TODO: remove after completion of experiment + var analyticsEvent: ExperimentalLinkPreviewInteraction? = null + private val searchCloseListener = SearchView.OnCloseListener { closeSearch() false @@ -163,7 +168,7 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche binding.searchLanguageScrollViewContainer.visibility = View.GONE binding.searchLangButton.visibility = View.VISIBLE initLangButton() - recentSearchesFragment.onLangCodeChanged() + recentSearchesFragment.reloadRecentSearches() } } @@ -207,6 +212,8 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche val historyEntry = HistoryEntry(item, HistoryEntry.SOURCE_SEARCH) startActivity(if (inNewTab) PageActivity.newIntentForNewTab(requireContext(), historyEntry, historyEntry.title) else PageActivity.newIntentForCurrentTab(requireContext(), historyEntry, historyEntry.title, false)) + + analyticsEvent?.logNavigate() } closeSearch() } @@ -335,7 +342,8 @@ class SearchFragment : Fragment(), SearchResultsFragment.Callback, RecentSearche } searchLanguageCode = selectedLanguageCode searchResultsFragment.setLayoutDirection(searchLanguageCode) - recentSearchesFragment.onLangCodeChanged() + recentSearchesFragment.reloadRecentSearches() + recentSearchesFragment.reloadRecommendedContent(WikiSite.forLanguageCode(searchLanguageCode)) startSearch(query, false) } diff --git a/app/src/main/java/org/wikipedia/settings/Prefs.kt b/app/src/main/java/org/wikipedia/settings/Prefs.kt index 2c895f5530b..bb3515aa2ce 100644 --- a/app/src/main/java/org/wikipedia/settings/Prefs.kt +++ b/app/src/main/java/org/wikipedia/settings/Prefs.kt @@ -725,4 +725,8 @@ object Prefs { var isDonationTestEnvironment get() = PrefsIoUtil.getBoolean(R.string.preference_key_donation_test_env, false) set(value) = PrefsIoUtil.setBoolean(R.string.preference_key_donation_test_env, value) + + var recommendedContentSurveyShown + get() = PrefsIoUtil.getBoolean(R.string.preference_key_recommended_content_survey_shown, false) + set(value) = PrefsIoUtil.setBoolean(R.string.preference_key_recommended_content_survey_shown, value) } diff --git a/app/src/main/java/org/wikipedia/views/SurveyDialog.kt b/app/src/main/java/org/wikipedia/views/SurveyDialog.kt index 79e580f8011..1dfc8833e93 100644 --- a/app/src/main/java/org/wikipedia/views/SurveyDialog.kt +++ b/app/src/main/java/org/wikipedia/views/SurveyDialog.kt @@ -2,88 +2,143 @@ package org.wikipedia.views import android.app.Activity import android.view.View +import android.widget.ScrollView import android.widget.TextView import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.textfield.TextInputEditText import org.wikipedia.Constants import org.wikipedia.R import org.wikipedia.analytics.eventplatform.PatrollerExperienceEvent +import org.wikipedia.analytics.metricsplatform.ExperimentalLinkPreviewInteraction +import org.wikipedia.analytics.metricsplatform.RecommendedContentAnalyticsHelper +import org.wikipedia.databinding.DialogFeedbackOptionsBinding +import org.wikipedia.history.HistoryEntry import org.wikipedia.settings.Prefs import org.wikipedia.util.DimenUtil import org.wikipedia.util.FeedbackUtil object SurveyDialog { - fun showFeedbackOptionsDialog(activity: Activity, source: Constants.InvokeSource) { + fun showFeedbackOptionsDialog(activity: Activity, + titleId: Int = R.string.patroller_diff_feedback_dialog_title, + messageId: Int = R.string.patroller_diff_feedback_dialog_message, + snackbarMessageId: Int = R.string.patroller_diff_feedback_submitted_snackbar, + invokeSource: Constants.InvokeSource, + historyEntry: HistoryEntry? = null) { var dialog: AlertDialog? = null - val feedbackView = activity.layoutInflater.inflate(R.layout.dialog_patrol_edit_feedback_options, null) + val binding = DialogFeedbackOptionsBinding.inflate(activity.layoutInflater) + binding.titleText.text = activity.getString(titleId) + binding.messageText.text = activity.getString(messageId) + binding.feedbackInput.setOnFocusChangeListener { _, hasFocus -> + if (hasFocus) { + binding.dialogContainer.postDelayed({ + binding.dialogContainer.fullScroll(ScrollView.FOCUS_DOWN) + }, 200) + } + } + + if (invokeSource == Constants.InvokeSource.SUGGESTED_EDITS_RECENT_EDITS) { + val clickListener = View.OnClickListener { + val feedbackOption = (it as TextView).text.toString() + dialog?.dismiss() + if (feedbackOption == activity.getString(R.string.patroller_diff_feedback_dialog_option_satisfied)) { + showFeedbackSnackbarAndTooltip(activity, snackbarMessageId, invokeSource) + } else { + showFeedbackInputDialog(activity, snackbarMessageId, invokeSource) + } - val clickListener = View.OnClickListener { - val feedbackOption = (it as TextView).text.toString() - dialog?.dismiss() - if (feedbackOption == activity.getString(R.string.patroller_diff_feedback_dialog_option_satisfied)) { - showFeedbackSnackbarAndTooltip(activity, source) - } else { - showFeedbackInputDialog(activity, source) + PatrollerExperienceEvent.logAction("feedback_selection", "feedback_form", + PatrollerExperienceEvent.getActionDataString(feedbackOption = feedbackOption)) } + binding.optionSatisfied.setOnClickListener(clickListener) + binding.optionNeutral.setOnClickListener(clickListener) + binding.optionUnsatisfied.setOnClickListener(clickListener) - sendAnalyticsEvent("feedback_selection", "feedback_form", source, - PatrollerExperienceEvent.getActionDataString(feedbackOption = feedbackOption)) + PatrollerExperienceEvent.logAction("impression", "feedback_form") + } else if (invokeSource == Constants.InvokeSource.RECOMMENDED_CONTENT) { + binding.optionNeutral.isChecked = true + binding.feedbackInputContainer.isVisible = true + + ExperimentalLinkPreviewInteraction(source = historyEntry?.source ?: HistoryEntry.SOURCE_SEARCH, RecommendedContentAnalyticsHelper.abcTest.getGroupName()) + .logImpression(feedbackShown = true) } - feedbackView.findViewById(R.id.optionSatisfied).setOnClickListener(clickListener) - feedbackView.findViewById(R.id.optionNeutral).setOnClickListener(clickListener) - feedbackView.findViewById(R.id.optionUnsatisfied).setOnClickListener(clickListener) - sendAnalyticsEvent("impression", "feedback_form", source) - val dialogBuilder = MaterialAlertDialogBuilder(activity) - .setTitle(R.string.patroller_diff_feedback_dialog_title) + val dialogBuilder = MaterialAlertDialogBuilder(activity, R.style.AlertDialogTheme_AdjustResize) .setCancelable(false) - .setView(feedbackView) + .setView(binding.root) + + if (invokeSource == Constants.InvokeSource.RECOMMENDED_CONTENT) { + binding.submitButton.setOnClickListener { + val feedbackInput = binding.feedbackInput.text.toString() + + ExperimentalLinkPreviewInteraction(source = historyEntry?.source ?: HistoryEntry.SOURCE_SEARCH, + RecommendedContentAnalyticsHelper.abcTest.getGroupName()) + .logNavigate(feedbackShown = true, feedbackSelect = when { + binding.optionSatisfied.isChecked -> "satisfied" + binding.optionUnsatisfied.isChecked -> "unsatisfied" + else -> "neutral" + }, feedbackText = feedbackInput) + + showFeedbackSnackbarAndTooltip(activity, snackbarMessageId, invokeSource) + dialog?.dismiss() + Prefs.recommendedContentSurveyShown = true + } + binding.cancelButton.setOnClickListener { + dialog?.dismiss() + Prefs.recommendedContentSurveyShown = true + } + } + dialog = dialogBuilder.show() } - private fun showFeedbackInputDialog(activity: Activity, source: Constants.InvokeSource) { - val feedbackView = activity.layoutInflater.inflate(R.layout.dialog_patrol_edit_feedback_input, null) - sendAnalyticsEvent("impression", "feedback_input_form", source) + private fun showFeedbackInputDialog(activity: Activity, messageId: Int, source: Constants.InvokeSource) { + val feedbackView = activity.layoutInflater.inflate(R.layout.dialog_feedback_input, null) + PatrollerExperienceEvent.logAction("impression", "feedback_input_form") MaterialAlertDialogBuilder(activity) .setTitle(R.string.patroller_diff_feedback_dialog_feedback_title) .setView(feedbackView) .setPositiveButton(R.string.patroller_diff_feedback_dialog_submit) { _, _ -> val feedbackInput = feedbackView.findViewById(R.id.feedbackInput).text.toString() - sendAnalyticsEvent("feedback_input_submit", "feedback_input_form", source, + PatrollerExperienceEvent.logAction("feedback_input_submit", "feedback_input_form", PatrollerExperienceEvent.getActionDataString(feedbackText = feedbackInput)) - showFeedbackSnackbarAndTooltip(activity, source) + showFeedbackSnackbarAndTooltip(activity, messageId, source) } .show() } - private fun showFeedbackSnackbarAndTooltip(activity: Activity, source: Constants.InvokeSource) { - FeedbackUtil.showMessage(activity, R.string.patroller_diff_feedback_submitted_snackbar) - sendAnalyticsEvent("feedback_submit_toast", "feedback_form", source) - activity.window.decorView.postDelayed({ - val anchorView = activity.findViewById(R.id.more_options) - if (!activity.isDestroyed && anchorView != null && Prefs.showOneTimeRecentEditsFeedbackForm) { - sendAnalyticsEvent("tooltip_impression", "feedback_form", source) - FeedbackUtil.getTooltip( - activity, - activity.getString(R.string.patroller_diff_feedback_tooltip), - arrowAnchorPadding = -DimenUtil.roundedDpToPx(7f), - topOrBottomMargin = 0, - aboveOrBelow = false, - autoDismiss = false, - showDismissButton = true - ).apply { - showAlignBottom(anchorView) - Prefs.showOneTimeRecentEditsFeedbackForm = false - } + private fun showFeedbackSnackbarAndTooltip(activity: Activity, messageId: Int, source: Constants.InvokeSource) { + FeedbackUtil.showMessage(activity, messageId) + when (source) { + Constants.InvokeSource.SUGGESTED_EDITS_RECENT_EDITS -> { + PatrollerExperienceEvent.logAction("feedback_submit_toast", "feedback_form") + activity.window.decorView.postDelayed({ + val anchorView = activity.findViewById(R.id.more_options) + if (!activity.isDestroyed && anchorView != null && Prefs.showOneTimeRecentEditsFeedbackForm) { + PatrollerExperienceEvent.logAction("tooltip_impression", "feedback_form") + FeedbackUtil.getTooltip( + activity, + activity.getString(R.string.patroller_diff_feedback_tooltip), + arrowAnchorPadding = -DimenUtil.roundedDpToPx(7f), + topOrBottomMargin = 0, + aboveOrBelow = false, + autoDismiss = false, + showDismissButton = true + ).apply { + showAlignBottom(anchorView) + when (source) { + Constants.InvokeSource.SUGGESTED_EDITS_RECENT_EDITS -> { + Prefs.showOneTimeRecentEditsFeedbackForm = false + } + else -> { } + } + } + } + }, 100) } - }, 100) - } - - private fun sendAnalyticsEvent(action: String, activeInterface: String, source: Constants.InvokeSource, actionData: String = "") { - if (source == Constants.InvokeSource.SUGGESTED_EDITS_RECENT_EDITS) { - PatrollerExperienceEvent.logAction(action, activeInterface, actionData) + else -> {} } } } diff --git a/app/src/main/res/layout/dialog_patrol_edit_feedback_input.xml b/app/src/main/res/layout/dialog_feedback_input.xml similarity index 100% rename from app/src/main/res/layout/dialog_patrol_edit_feedback_input.xml rename to app/src/main/res/layout/dialog_feedback_input.xml diff --git a/app/src/main/res/layout/dialog_feedback_options.xml b/app/src/main/res/layout/dialog_feedback_options.xml new file mode 100644 index 00000000000..f62ad4ed799 --- /dev/null +++ b/app/src/main/res/layout/dialog_feedback_options.xml @@ -0,0 +1,140 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +