diff --git a/app/build.gradle b/app/build.gradle index 3c0702d3ed..5038e509db 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -186,23 +186,6 @@ dependencies { androidTestImplementation libs.androidx.test.junit } -// Work around warnings of: -// WARNING: Illegal reflective access by org.jetbrains.kotlin.kapt3.util.ModuleManipulationUtilsKt (file:/C:/Users/Andi/.gradle/caches/modules-2/files-2.1/org.jetbrains.kotlin/kotlin-annotation-processing-gradle/1.8.22/28dab7e0ee9ce62c03bf97de3543c911dc653700/kotlin-annotation-processing-gradle-1.8.22.jar) to constructor com.sun.tools.javac.util.Context() -// See https://youtrack.jetbrains.com/issue/KT-30589/Kapt-An-illegal-reflective-access-operation-has-occurred -tasks.withType(org.jetbrains.kotlin.gradle.internal.KaptWithoutKotlincTask) { - kaptProcessJvmArgs.addAll([ - "--add-opens", "jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED", - "--add-opens", "jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED"]) -} - tasks.register("newLintBaseline") { description 'Deletes and then recreates the lint baseline' diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 8dd9f6cf79..a4abc04bb1 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -15,23 +15,23 @@ + errorLine1=" if (showPlaceholder) placeholder(R.drawable.avatar_default)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="946" + column="42"/> + errorLine1=" if (showPlaceholder) placeholder(R.drawable.avatar_default)" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + line="973" + column="42"/> @@ -157,7 +157,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -234,7 +234,7 @@ errorLine2=" ^"> @@ -245,7 +245,7 @@ errorLine2=" ^"> @@ -278,7 +278,7 @@ errorLine2=" ^"> @@ -322,7 +322,7 @@ errorLine2=" ^"> @@ -333,7 +333,7 @@ errorLine2=" ^"> @@ -344,7 +344,7 @@ errorLine2=" ^"> @@ -355,7 +355,7 @@ errorLine2=" ^"> @@ -366,7 +366,7 @@ errorLine2=" ^"> @@ -377,7 +377,7 @@ errorLine2=" ^"> @@ -388,7 +388,7 @@ errorLine2=" ^"> @@ -399,7 +399,7 @@ errorLine2=" ^"> @@ -410,7 +410,7 @@ errorLine2=" ^"> @@ -421,7 +421,7 @@ errorLine2=" ^"> @@ -432,7 +432,7 @@ errorLine2=" ^"> @@ -443,7 +443,7 @@ errorLine2=" ^"> @@ -454,7 +454,7 @@ errorLine2=" ^"> @@ -465,7 +465,7 @@ errorLine2=" ^"> @@ -476,7 +476,7 @@ errorLine2=" ^"> @@ -487,7 +487,7 @@ errorLine2=" ^"> @@ -498,7 +498,7 @@ errorLine2=" ^"> @@ -509,7 +509,7 @@ errorLine2=" ^"> @@ -520,7 +520,7 @@ errorLine2=" ^"> @@ -531,7 +531,7 @@ errorLine2=" ^"> @@ -542,7 +542,7 @@ errorLine2=" ^"> @@ -553,7 +553,7 @@ errorLine2=" ^"> @@ -740,7 +740,7 @@ errorLine2=" ^"> @@ -751,7 +751,7 @@ errorLine2=" ^"> @@ -762,7 +762,7 @@ errorLine2=" ^"> @@ -773,7 +773,7 @@ errorLine2=" ^"> @@ -784,7 +784,7 @@ errorLine2=" ^"> @@ -795,7 +795,7 @@ errorLine2=" ^"> @@ -817,7 +817,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -828,7 +828,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -839,7 +839,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -850,7 +850,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -861,7 +861,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -872,7 +872,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1521,7 +1521,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1631,7 +1631,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1642,7 +1642,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1774,7 +1774,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1785,7 +1785,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1796,7 +1796,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1807,7 +1807,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1818,7 +1818,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -1829,7 +1829,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1840,7 +1840,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1851,7 +1851,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1862,7 +1862,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1873,7 +1873,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1884,7 +1884,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1895,7 +1895,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1906,7 +1906,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1917,7 +1917,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1928,7 +1928,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1939,7 +1939,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -1950,7 +1950,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1961,7 +1961,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -1972,7 +1972,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -1983,7 +1983,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -1994,7 +1994,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2005,7 +2005,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2016,7 +2016,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -2027,7 +2027,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2038,7 +2038,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -2049,7 +2049,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2060,7 +2060,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2071,7 +2071,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -2082,7 +2082,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2093,7 +2093,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2104,7 +2104,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2115,7 +2115,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2159,10 +2159,21 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> + + + + + + + + @@ -3138,7 +3160,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -3149,7 +3171,7 @@ errorLine2=" ~~~~~~~"> @@ -3160,7 +3182,7 @@ errorLine2=" ~~~~~~~"> @@ -3171,7 +3193,7 @@ errorLine2=" ~~~~~~~"> @@ -3182,7 +3204,7 @@ errorLine2=" ~~~~~~~"> @@ -3193,7 +3215,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3204,7 +3226,7 @@ errorLine2=" ~~~~~~~"> @@ -3215,7 +3237,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3226,7 +3248,7 @@ errorLine2=" ~~~~~~~"> @@ -3237,7 +3259,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3248,7 +3270,7 @@ errorLine2=" ~~~~~~~"> @@ -3259,7 +3281,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3270,7 +3292,7 @@ errorLine2=" ~~~~~~~"> @@ -3281,7 +3303,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3292,7 +3314,7 @@ errorLine2=" ~~~~~~~"> @@ -3303,7 +3325,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3314,7 +3336,7 @@ errorLine2=" ~~~~~~~"> @@ -3325,7 +3347,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3336,7 +3358,7 @@ errorLine2=" ~~~~~~~"> @@ -3347,7 +3369,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3358,7 +3380,7 @@ errorLine2=" ~~~~~~~"> @@ -3369,7 +3391,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3380,7 +3402,7 @@ errorLine2=" ~~~~~~~"> @@ -3391,7 +3413,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3402,7 +3424,7 @@ errorLine2=" ~~~~~~~"> @@ -3413,7 +3435,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3424,7 +3446,7 @@ errorLine2=" ~~~~~~~"> @@ -3435,7 +3457,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3446,7 +3468,7 @@ errorLine2=" ~~~~~~~"> @@ -3457,7 +3479,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3468,7 +3490,7 @@ errorLine2=" ~~~~~~~"> @@ -3479,7 +3501,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -3490,7 +3512,7 @@ errorLine2=" ~~~~~~~"> @@ -3501,7 +3523,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3512,7 +3534,7 @@ errorLine2=" ~~~~~~~"> @@ -3523,7 +3545,7 @@ errorLine2=" ~~~~~~~"> @@ -3534,7 +3556,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3545,76 +3567,10 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + message="Access to `private` method `getViewModel` of class `TimelineFragment` requires synthetic accessor" + errorLine1=" if (!viewModel.uiState.value.showFabWhileScrolling) {" + errorLine2=" ~~~~~~~~~"> - - - - - - - - + line="171" + column="30"/> @@ -4150,7 +4051,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4161,7 +4062,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4172,7 +4073,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4183,7 +4084,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4194,7 +4095,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4238,7 +4139,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -4783,8 +4684,8 @@ errorLine1=" <TextView" errorLine2=" ~~~~~~~~"> @@ -4794,8 +4695,8 @@ errorLine1=" <TextView" errorLine2=" ~~~~~~~~"> @@ -4806,7 +4707,7 @@ errorLine2=" ~~~~~~~~"> @@ -4816,8 +4717,8 @@ errorLine1=" <TextView" errorLine2=" ~~~~~~~~"> @@ -4827,8 +4728,8 @@ errorLine1=" <TextView" errorLine2=" ~~~~~~~~"> @@ -6192,7 +6093,7 @@ errorLine2=" ~~~~"> @@ -6203,7 +6104,7 @@ errorLine2=" ~~~~~~"> @@ -6214,7 +6115,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6225,7 +6126,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -6236,7 +6137,7 @@ errorLine2=" ~~~~~~"> @@ -6247,41 +6148,41 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" protected void setMetaData(StatusViewData statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {" + errorLine2=" ~~~~~~~~~~~~~~"> + errorLine1=" protected void setMetaData(StatusViewData statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + line="344" + column="63"/> + errorLine1=" protected void setMetaData(StatusViewData statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + line="344" + column="106"/> @@ -6302,7 +6203,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -6313,7 +6214,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -6324,7 +6225,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -6335,7 +6236,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -6346,7 +6247,7 @@ errorLine2=" ~~~~~~"> @@ -6357,7 +6258,7 @@ errorLine2=" ~~~~~~"> @@ -6368,30 +6269,30 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + errorLine1=" public void setupWithStatus(StatusViewData status, final StatusActionListener listener," + errorLine2=" ~~~~~~~~~~~~~~"> + errorLine1=" public void setupWithStatus(StatusViewData status, final StatusActionListener listener," + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + line="756" + column="62"/> @@ -6412,7 +6313,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -6423,7 +6324,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -6434,7 +6335,7 @@ errorLine2=" ~~~~~~~"> @@ -6445,7 +6346,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6456,7 +6357,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -6467,7 +6368,7 @@ errorLine2=" ~~~~~~~"> @@ -6478,7 +6379,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -6489,18 +6390,18 @@ errorLine2=" ~~~~~~~"> + errorLine1=" final StatusViewData status," + errorLine2=" ~~~~~~~~~~~~~~"> @@ -6511,7 +6412,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -6522,7 +6423,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -6533,7 +6434,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -6551,8 +6452,8 @@ + errorLine1=" protected void setMetaData(StatusViewData statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {" + errorLine2=" ~~~~~~~~~~~~~~"> + errorLine1=" protected void setMetaData(StatusViewData statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + column="63"/> + errorLine1=" protected void setMetaData(StatusViewData statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) {" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + column="106"/> Log.d(TAG, "Developer tools: $which") when (which) { 0 -> { - Log.d(TAG, "Creating \"Load more\" gap") + Log.d(TAG, "Clearing home timeline cache") + lifecycleScope.launch { + accountManager.activeAccount?.let { + developerToolsUseCase.clearHomeTimelineCache(it.id) + } + } + } + 1 -> { + Log.d(TAG, "Removing most recent 40 statuses") lifecycleScope.launch { accountManager.activeAccount?.let { - developerToolsUseCase.createLoadMoreGap( - it.id - ) + developerToolsUseCase.deleteFirstKStatuses(it.id, 40) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt index c055043d1c..f8f3b930c1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/StatusListActivity.kt @@ -28,7 +28,7 @@ import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel.Kind +import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.databinding.ActivityStatuslistBinding import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterV1 @@ -48,7 +48,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { lateinit var eventHub: EventHub private val binding: ActivityStatuslistBinding by viewBinding(ActivityStatuslistBinding::inflate) - private lateinit var kind: Kind + private lateinit var timelineKind: TimelineKind private var hashtag: String? = null private var followTagItem: MenuItem? = null private var unfollowTagItem: MenuItem? = null @@ -66,15 +66,17 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { setSupportActionBar(binding.includedToolbar.toolbar) - kind = Kind.valueOf(intent.getStringExtra(EXTRA_KIND)!!) - val listId = intent.getStringExtra(EXTRA_LIST_ID) - hashtag = intent.getStringExtra(EXTRA_HASHTAG) + timelineKind = intent.getParcelableExtra(EXTRA_KIND)!! - val title = when (kind) { - Kind.FAVOURITES -> getString(R.string.title_favourites) - Kind.BOOKMARKS -> getString(R.string.title_bookmarks) - Kind.TAG -> getString(R.string.title_tag).format(hashtag) - else -> intent.getStringExtra(EXTRA_LIST_TITLE) + val title = when (timelineKind) { + is TimelineKind.Favourites -> getString(R.string.title_favourites) + is TimelineKind.Bookmarks -> getString(R.string.title_bookmarks) + is TimelineKind.Tag -> { + hashtag = (timelineKind as TimelineKind.Tag).tags.first() + getString(R.string.title_tag).format(hashtag) + } + is TimelineKind.UserList -> (timelineKind as TimelineKind.UserList).title + else -> "Missing title!!!" } supportActionBar?.run { @@ -85,11 +87,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { if (supportFragmentManager.findFragmentById(R.id.fragmentContainer) == null) { supportFragmentManager.commit { - val fragment = if (kind == Kind.TAG) { - TimelineFragment.newHashtagInstance(listOf(hashtag!!)) - } else { - TimelineFragment.newInstance(kind, listId) - } + val fragment = TimelineFragment.newInstance(timelineKind) replace(R.id.fragmentContainer, fragment) } } @@ -97,7 +95,7 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { override fun onCreateOptionsMenu(menu: Menu): Boolean { val tag = hashtag - if (kind == Kind.TAG && tag != null) { + if (timelineKind is TimelineKind.Tag && tag != null) { lifecycleScope.launch { mastodonApi.tag(tag).fold( { tagEntity -> @@ -321,35 +319,28 @@ class StatusListActivity : BottomSheetActivity(), HasAndroidInjector { override fun androidInjector() = dispatchingAndroidInjector companion object { - private const val EXTRA_KIND = "kind" - private const val EXTRA_LIST_ID = "id" - private const val EXTRA_LIST_TITLE = "title" - private const val EXTRA_HASHTAG = "tag" - const val TAG = "StatusListActivity" + private const val TAG = "StatusListActivity" fun newFavouritesIntent(context: Context) = Intent(context, StatusListActivity::class.java).apply { - putExtra(EXTRA_KIND, Kind.FAVOURITES.name) + putExtra(EXTRA_KIND, TimelineKind.Favourites) } fun newBookmarksIntent(context: Context) = Intent(context, StatusListActivity::class.java).apply { - putExtra(EXTRA_KIND, Kind.BOOKMARKS.name) + putExtra(EXTRA_KIND, TimelineKind.Bookmarks) } fun newListIntent(context: Context, listId: String, listTitle: String) = Intent(context, StatusListActivity::class.java).apply { - putExtra(EXTRA_KIND, Kind.LIST.name) - putExtra(EXTRA_LIST_ID, listId) - putExtra(EXTRA_LIST_TITLE, listTitle) + putExtra(EXTRA_KIND, TimelineKind.UserList(listId, listTitle)) } @JvmStatic fun newHashtagIntent(context: Context, hashtag: String) = Intent(context, StatusListActivity::class.java).apply { - putExtra(EXTRA_KIND, Kind.TAG.name) - putExtra(EXTRA_HASHTAG, hashtag) + putExtra(EXTRA_KIND, TimelineKind.Tag(listOf(hashtag))) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/TabData.kt b/app/src/main/java/com/keylesspalace/tusky/TabData.kt index 58969292f2..49a8655177 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TabData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TabData.kt @@ -22,7 +22,7 @@ import androidx.fragment.app.Fragment import com.keylesspalace.tusky.components.conversation.ConversationsFragment import com.keylesspalace.tusky.components.notifications.NotificationsFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.components.trending.TrendingTagsFragment import java.util.Objects @@ -66,7 +66,7 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD id = HOME, text = R.string.title_home, icon = R.drawable.ic_home_24dp, - fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.HOME) } + fragment = { TimelineFragment.newInstance(TimelineKind.Home) } ) NOTIFICATIONS -> TabData( id = NOTIFICATIONS, @@ -78,31 +78,34 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD id = LOCAL, text = R.string.title_public_local, icon = R.drawable.ic_local_24dp, - fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_LOCAL) } + fragment = { TimelineFragment.newInstance(TimelineKind.PublicLocal) } ) FEDERATED -> TabData( id = FEDERATED, text = R.string.title_public_federated, icon = R.drawable.ic_public_24dp, - fragment = { TimelineFragment.newInstance(TimelineViewModel.Kind.PUBLIC_FEDERATED) } + fragment = { TimelineFragment.newInstance(TimelineKind.PublicFederated) } ) + DIRECT -> TabData( id = DIRECT, text = R.string.title_direct_messages, icon = R.drawable.ic_reblog_direct_24dp, fragment = { ConversationsFragment.newInstance() } ) + TRENDING_TAGS -> TabData( id = TRENDING_TAGS, text = R.string.title_public_trending_hashtags, icon = R.drawable.ic_trending_up_24px, fragment = { TrendingTagsFragment.newInstance() } ) + HASHTAG -> TabData( id = HASHTAG, text = R.string.hashtags, icon = R.drawable.ic_hashtag, - fragment = { args -> TimelineFragment.newHashtagInstance(args) }, + fragment = { args -> TimelineFragment.newInstance(TimelineKind.Tag(args)) }, arguments = arguments, title = { context -> arguments.joinToString(separator = " ") { context.getString(R.string.title_tag, it) } } ) @@ -110,7 +113,7 @@ fun createTabDataFromId(id: String, arguments: List = emptyList()): TabD id = LIST, text = R.string.list, icon = R.drawable.ic_list, - fragment = { args -> TimelineFragment.newInstance(TimelineViewModel.Kind.LIST, args.getOrNull(0).orEmpty()) }, + fragment = { args -> TimelineFragment.newInstance(TimelineKind.UserList(args.first(), args.last())) }, arguments = arguments, title = { arguments.getOrNull(1).orEmpty() } ) diff --git a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt index 3c943863db..87216fe23d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt +++ b/app/src/main/java/com/keylesspalace/tusky/TuskyApplication.kt @@ -136,6 +136,12 @@ class TuskyApplication : Application(), HasAndroidInjector { editor.remove(PrefKeys.Deprecated.SHOW_NOTIFICATIONS_FILTER) } + if (oldVersion < 2023082201) { + // Deleting the "Reading order" preference, as the need to "Load more" has been + // removed. + editor.remove(PrefKeys.Deprecated.READING_ORDER) + } + editor.putInt(PrefKeys.SCHEMA_VERSION, newVersion) editor.apply() } diff --git a/app/src/main/java/com/keylesspalace/tusky/WorkerFactory.kt b/app/src/main/java/com/keylesspalace/tusky/WorkerFactory.kt new file mode 100644 index 0000000000..5b31bd1f0f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/WorkerFactory.kt @@ -0,0 +1,56 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky + +import android.content.Context +import androidx.work.ListenableWorker +import androidx.work.WorkerFactory +import androidx.work.WorkerParameters +import javax.inject.Inject +import javax.inject.Provider + +/** + * Workers implement this and are added to the map in [com.keylesspalace.tusky.di.WorkerModule] + * so they can be created by [WorkerFactory.createWorker]. + */ +interface ChildWorkerFactory { + /** Create a new instance of the given worker. */ + fun createWorker(appContext: Context, params: WorkerParameters): ListenableWorker +} + +/** + * Creates workers, delegating to each worker's [ChildWorkerFactory.createWorker] to do the + * creation. + * + * @see [com.keylesspalace.tusky.components.notifications.NotificationWorker] + */ +class WorkerFactory @Inject constructor( + val workerFactories: Map, @JvmSuppressWildcards Provider> +) : WorkerFactory() { + override fun createWorker( + appContext: Context, + workerClassName: String, + workerParameters: WorkerParameters + ): ListenableWorker? { + val key = Class.forName(workerClassName) + workerFactories[key]?.let { + return it.get().createWorker(appContext, workerParameters) + } + return null + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt deleted file mode 100644 index c277ea385e..0000000000 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/PlaceholderViewHolder.kt +++ /dev/null @@ -1,67 +0,0 @@ -/* Copyright 2021 Tusky Contributors - * - * This file is a part of Tusky. - * - * 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. - * - * Tusky 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 Tusky; if not, - * see . */ -package com.keylesspalace.tusky.adapter - -import android.view.View -import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.button.MaterialButton -import com.google.android.material.progressindicator.CircularProgressIndicatorSpec -import com.google.android.material.progressindicator.IndeterminateDrawable -import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.interfaces.StatusActionListener - -/** - * Placeholder for different timelines. - * - * Displays a "Load more" button for a particular status ID, or a - * circular progress wheel if the status' page is being loaded. - * - * The user can only have one "Load more" operation in progress at - * a time (determined by the adapter), so the contents of the view - * and the enabled state is driven by that. - */ -class PlaceholderViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) { - private val loadMoreButton: MaterialButton = itemView.findViewById(R.id.button_load_more) - private val drawable = IndeterminateDrawable.createCircularDrawable( - itemView.context, - CircularProgressIndicatorSpec(itemView.context, null) - ) - - fun setup(listener: StatusActionListener, loading: Boolean) { - itemView.isEnabled = !loading - loadMoreButton.isEnabled = !loading - - if (loading) { - loadMoreButton.text = "" - loadMoreButton.icon = drawable - return - } - - loadMoreButton.text = itemView.context.getString(R.string.load_more_placeholder_text) - loadMoreButton.icon = null - - // To allow the user to click anywhere in the layout to load more content set the click - // listener on the parent layout instead of loadMoreButton. - // - // See the comments in item_status_placeholder.xml for more details. - itemView.setOnClickListener { - itemView.isEnabled = false - loadMoreButton.isEnabled = false - loadMoreButton.icon = drawable - loadMoreButton.text = "" - listener.onLoadMore(bindingAdapterPosition) - } - } -} diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java index 5435dc8f3a..e0e1446901 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusBaseViewHolder.java @@ -37,6 +37,7 @@ import com.google.android.material.imageview.ShapeableImageView; import com.google.android.material.shape.CornerFamily; import com.google.android.material.shape.ShapeAppearanceModel; +import com.google.android.material.snackbar.Snackbar; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.ViewMediaActivity; import com.keylesspalace.tusky.entity.Attachment; @@ -208,7 +209,7 @@ public void toggleContentWarning() { contentWarningButton.performClick(); } - protected void setSpoilerAndContent(@NonNull StatusViewData.Concrete status, + protected void setSpoilerAndContent(@NonNull StatusViewData status, @NonNull StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener) { @@ -246,7 +247,7 @@ private void setContentWarningButtonText(boolean expanded) { protected void toggleExpandedState(boolean sensitive, boolean expanded, - @NonNull final StatusViewData.Concrete status, + @NonNull final StatusViewData status, @NonNull final StatusDisplayOptions statusDisplayOptions, @NonNull final StatusActionListener listener) { @@ -264,7 +265,7 @@ protected void toggleExpandedState(boolean sensitive, private void setTextVisible(boolean sensitive, boolean expanded, - @NonNull final StatusViewData.Concrete status, + @NonNull final StatusViewData status, @NonNull final StatusDisplayOptions statusDisplayOptions, final StatusActionListener listener) { @@ -340,7 +341,7 @@ private void setAvatar(String url, Collections.singletonList(new CompositeWithOpaqueBackground(avatar))); } - protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { + protected void setMetaData(StatusViewData statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { Status status = statusViewData.getActionable(); Date createdAt = status.getCreatedAt(); @@ -752,12 +753,12 @@ private void showConfirmFavourite(StatusActionListener listener, popup.show(); } - public void setupWithStatus(StatusViewData.Concrete status, final StatusActionListener listener, + public void setupWithStatus(StatusViewData status, final StatusActionListener listener, StatusDisplayOptions statusDisplayOptions) { this.setupWithStatus(status, listener, statusDisplayOptions, null); } - public void setupWithStatus(@NonNull StatusViewData.Concrete status, + public void setupWithStatus(@NonNull StatusViewData status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, @Nullable Object payloads) { @@ -821,7 +822,7 @@ public void setupWithStatus(@NonNull StatusViewData.Concrete status, } } - private void setupFilterPlaceholder(StatusViewData.Concrete status, StatusActionListener listener, StatusDisplayOptions displayOptions) { + private void setupFilterPlaceholder(StatusViewData status, StatusActionListener listener, StatusDisplayOptions displayOptions) { if (status.getFilterAction() != Filter.Action.WARN) { showFilteredPlaceholder(false); return; @@ -852,7 +853,7 @@ protected static boolean hasPreviewableAttachment(List attachments) return true; } - private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status, + private void setDescriptionForStatus(@NonNull StatusViewData status, StatusDisplayOptions statusDisplayOptions) { Context context = itemView.getContext(); Status actionable = status.getActionable(); @@ -878,7 +879,7 @@ private void setDescriptionForStatus(@NonNull StatusViewData.Concrete status, } private static CharSequence getReblogDescription(Context context, - @NonNull StatusViewData.Concrete status) { + @NonNull StatusViewData status) { Status reblog = status.getRebloggingStatus(); if (reblog != null) { return context @@ -889,7 +890,7 @@ private static CharSequence getReblogDescription(Context context, } private static CharSequence getMediaDescription(Context context, - @NonNull StatusViewData.Concrete status) { + @NonNull StatusViewData status) { if (status.getActionable().getAttachments().isEmpty()) { return ""; } @@ -910,7 +911,7 @@ private static CharSequence getMediaDescription(Context context, } private static CharSequence getContentWarningDescription(Context context, - @NonNull StatusViewData.Concrete status) { + @NonNull StatusViewData status) { if (!TextUtils.isEmpty(status.getSpoilerText())) { return context.getString(R.string.description_post_cw, status.getSpoilerText()); } else { @@ -944,7 +945,7 @@ protected static CharSequence getVisibilityDescription(Context context, Status.V return context.getString(resource); } - private CharSequence getPollDescription(@NonNull StatusViewData.Concrete status, + private CharSequence getPollDescription(@NonNull StatusViewData status, Context context, StatusDisplayOptions statusDisplayOptions) { PollViewData poll = PollViewDataKt.toViewData(status.getActionable().getPoll()); @@ -1030,18 +1031,25 @@ private void setupPoll(PollViewData poll, List emojis, pollButton.setVisibility(View.VISIBLE); pollButton.setOnClickListener(v -> { - int position = getBindingAdapterPosition(); - if (position != RecyclerView.NO_POSITION) { - List pollResult = pollAdapter.getSelected(); - if (!pollResult.isEmpty()) { listener.onVoteInPoll(position, pollResult); + } else { + Snackbar.make( + itemView, + "pollAdapter.getSelected() returned an empty list", + Snackbar.LENGTH_INDEFINITE + ).show(); } + } else { + Snackbar.make( + itemView, + "getBindingAdapterPosition() returned NO_POSITION", + Snackbar.LENGTH_INDEFINITE + ).show(); } - }); } @@ -1077,7 +1085,7 @@ private CharSequence getPollInfoText(long timestamp, PollViewData poll, } protected void setupCard( - final StatusViewData.Concrete status, + final StatusViewData status, boolean expanded, final CardViewMode cardViewMode, final StatusDisplayOptions statusDisplayOptions, diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java index 76eda11074..66730786f1 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusDetailedViewHolder.java @@ -43,7 +43,7 @@ public StatusDetailedViewHolder(View view) { } @Override - protected void setMetaData(StatusViewData.Concrete statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { + protected void setMetaData(StatusViewData statusViewData, StatusDisplayOptions statusDisplayOptions, StatusActionListener listener) { Status status = statusViewData.getActionable(); @@ -149,12 +149,12 @@ private void setReblogAndFavCount(int reblogCount, int favCount, StatusActionLis } @Override - public void setupWithStatus(@NonNull final StatusViewData.Concrete status, + public void setupWithStatus(@NonNull final StatusViewData status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, @Nullable Object payloads) { // We never collapse statuses in the detail view - StatusViewData.Concrete uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ? + StatusViewData uncollapsedStatus = (status.isCollapsible() && status.isCollapsed()) ? status.copyWithCollapsed(false) : status; diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java index 304cf93a57..c9c6d10d37 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/StatusViewHolder.java @@ -60,7 +60,7 @@ public StatusViewHolder(View itemView) { } @Override - public void setupWithStatus(@NonNull StatusViewData.Concrete status, + public void setupWithStatus(@NonNull StatusViewData status, @NonNull final StatusActionListener listener, @NonNull StatusDisplayOptions statusDisplayOptions, @Nullable Object payloads) { @@ -127,7 +127,7 @@ protected void hideStatusInfo() { private void setupCollapsedState(boolean sensitive, boolean expanded, - final StatusViewData.Concrete status, + final StatusViewData status, final StatusActionListener listener) { /* input filter for TextViews have to be set before text */ if (status.isCollapsible() && (!sensitive || expanded)) { @@ -159,7 +159,7 @@ public void showStatusContent(boolean show) { @Override protected void toggleExpandedState(boolean sensitive, boolean expanded, - @NonNull StatusViewData.Concrete status, + @NonNull StatusViewData status, @NonNull StatusDisplayOptions statusDisplayOptions, @NonNull final StatusActionListener listener) { diff --git a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt index 494d67974c..930dc0c35b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt +++ b/app/src/main/java/com/keylesspalace/tusky/appstore/Events.kt @@ -13,6 +13,9 @@ data class UnfollowEvent(val accountId: String) : Event data class BlockEvent(val accountId: String) : Event data class MuteEvent(val accountId: String) : Event data class StatusDeletedEvent(val statusId: String) : Event + +/** A status the user wrote was successfully sent */ +// TODO: Rename, calling it "Composed" does imply anything about the sent state data class StatusComposedEvent(val status: Status) : Event data class StatusScheduledEvent(val status: Status) : Event data class StatusEditedEvent(val originalId: String, val status: Status) : Event diff --git a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt index baeeea43fd..5649c28b4f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/account/AccountPagerAdapter.kt @@ -19,7 +19,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity import com.keylesspalace.tusky.components.account.media.AccountMediaFragment import com.keylesspalace.tusky.components.timeline.TimelineFragment -import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.util.CustomFragmentStateAdapter @@ -32,9 +32,9 @@ class AccountPagerAdapter( override fun createFragment(position: Int): Fragment { return when (position) { - 0 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER, accountId, false) - 1 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_WITH_REPLIES, accountId, false) - 2 -> TimelineFragment.newInstance(TimelineViewModel.Kind.USER_PINNED, accountId, false) + 0 -> TimelineFragment.newInstance(TimelineKind.User.Posts(accountId), false) + 1 -> TimelineFragment.newInstance(TimelineKind.User.Replies(accountId), false) + 2 -> TimelineFragment.newInstance(TimelineKind.User.Pinned(accountId), false) 3 -> AccountMediaFragment.newInstance(accountId) else -> throw AssertionError("Page $position is out of AccountPagerAdapter bounds") } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt index f830b5bd98..009dcbcc13 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationEntity.kt @@ -100,8 +100,8 @@ data class ConversationStatusEntity( val language: String? ) { - fun toViewData(): StatusViewData.Concrete { - return StatusViewData.Concrete( + fun toViewData(): StatusViewData { + return StatusViewData( status = Status( id = id, url = url, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt index b197084df7..ceae7c6f7b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewData.kt @@ -23,7 +23,7 @@ data class ConversationViewData( val order: Int, val accounts: List, val unread: Boolean, - val lastStatus: StatusViewData.Concrete + val lastStatus: StatusViewData ) { fun toEntity( accountId: Long, @@ -54,7 +54,7 @@ data class ConversationViewData( } } -fun StatusViewData.Concrete.toConversationStatusEntity( +fun StatusViewData.toConversationStatusEntity( favourited: Boolean = status.favourited, bookmarked: Boolean = status.bookmarked, muted: Boolean = status.muted ?: false, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java index 5dc0bf982f..e577c265dd 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationViewHolder.java @@ -72,7 +72,7 @@ void setupWithConversation( @Nullable Object payloads ) { - StatusViewData.Concrete statusViewData = conversation.getLastStatus(); + StatusViewData statusViewData = conversation.getLastStatus(); Status status = statusViewData.getStatus(); if (payloads == null) { diff --git a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt index 48d1e87b85..462e3808ab 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/conversation/ConversationsFragment.kt @@ -315,10 +315,6 @@ class ConversationsFragment : } } - override fun onLoadMore(position: Int) { - // not using the old way of pagination - } - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { adapter.peek(position)?.let { conversation -> viewModel.collapseLongStatus(isCollapsed, conversation) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt index e28d251b87..fc7ce62b63 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/filters/FiltersViewModel.kt @@ -29,6 +29,7 @@ class FiltersViewModel @Inject constructor( val state: Flow get() = _state private val _state = MutableStateFlow(State(emptyList(), LoadingState.INITIAL)) + // TODO: Now that FilterRepository exists this code should be updated to use that. fun load() { this@FiltersViewModel._state.value = _state.value.copy(loadingState = LoadingState.LOADING) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt index 633ca08f7e..b8ebc47d6c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationFetcher.kt @@ -9,6 +9,7 @@ import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Marker import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.Links import com.keylesspalace.tusky.network.MastodonApi import com.keylesspalace.tusky.util.isLessThan import kotlinx.coroutines.delay diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt index 13d11eeb6e..9107e0a1e2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsFragment.kt @@ -49,6 +49,7 @@ import com.google.android.material.color.MaterialColors import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder +import com.keylesspalace.tusky.components.timeline.TimelineLoadStateAdapter import com.keylesspalace.tusky.databinding.FragmentTimelineNotificationsBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory @@ -60,7 +61,11 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate +import com.keylesspalace.tusky.util.UserRefreshState +import com.keylesspalace.tusky.util.asRefreshState +import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.openLink +import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.viewBinding import com.keylesspalace.tusky.viewdata.AttachmentViewData.Companion.list import com.keylesspalace.tusky.viewdata.NotificationViewData @@ -71,12 +76,11 @@ import com.mikepenz.iconics.utils.sizeDp import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import java.io.IOException import javax.inject.Inject class NotificationsFragment : @@ -194,28 +198,14 @@ class NotificationsFragment : }) binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter( - header = NotificationsLoadStateAdapter { adapter.retry() }, - footer = NotificationsLoadStateAdapter { adapter.retry() } + header = TimelineLoadStateAdapter { adapter.retry() }, + footer = TimelineLoadStateAdapter { adapter.retry() } ) (binding.recyclerView.itemAnimator as SimpleItemAnimator?)!!.supportsChangeAnimations = false - // Signal the user that a refresh has loaded new items above their current position - // by scrolling up slightly to disclose the new content - adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - if (positionStart == 0 && adapter.itemCount != itemCount) { - binding.recyclerView.post { - if (getView() != null) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) - } - } - } - } - }) - - // update post timestamps + // Update post timestamps val updateTimestampFlow = flow { while (true) { delay(60000) @@ -226,7 +216,7 @@ class NotificationsFragment : } viewLifecycleOwner.lifecycleScope.launch { - repeatOnLifecycle(Lifecycle.State.STARTED) { + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { launch { viewModel.pagingData.collectLatest { pagingData -> Log.d(TAG, "Submitting data to adapter") @@ -269,10 +259,10 @@ class NotificationsFragment : // that the action succeeded. Since it hasn't, re-bind the view // to show the correct data. error.action?.let { action -> - action is StatusAction || return@let + if (action !is StatusAction) return@let val position = adapter.snapshot().indexOfFirst { - it?.statusViewData?.status?.id == (action as StatusAction).statusViewData.id + it?.statusViewData?.status?.id == action.statusViewData.id } if (position != NO_POSITION) { adapter.notifyItemChanged(position) @@ -371,47 +361,98 @@ class NotificationsFragment : } } + /** StateFlow (to allow multiple consumers) of UserRefreshState */ + val refreshState = adapter.loadStateFlow.asRefreshState().stateIn(lifecycleScope) + + // Scroll the list down (peek) if a refresh has completely finished. A refresh is + // finished when both the initial refresh is complete and any prepends have + // finished (so that DiffUtil has had a chance to process the data). + launch { + /** True if the previous prepend resulted in a peek, false otherwise */ + var peeked = false + + /** ID of the item that was first in the adapter before the refresh */ + var previousFirstId: String? = null + + refreshState.collect { + when (it) { + // Refresh has started, reset peeked, and save the ID of the first item + // in the adapter + UserRefreshState.ACTIVE -> { + peeked = false + if (adapter.itemCount != 0) previousFirstId = adapter.peek(0)?.id + } + + // Refresh has finished, pages are being prepended. + UserRefreshState.COMPLETE -> { + // There might be multiple prepends after a refresh, only continue + // if one them has not already caused a peek. + if (peeked) return@collect + + // Compare the ID of the current first item with the previous first + // item. If they're the same then this prepend did not add any new + // items, and can be ignored. + val firstId = if (adapter.itemCount != 0) adapter.peek(0)?.id else null + if (previousFirstId == firstId) return@collect + + // New items were added and haven't peeked for this refresh. Schedule + // a scroll to disclose that new items are available. + binding.recyclerView.post { + getView() ?: return@post + binding.recyclerView.smoothScrollBy( + 0, + Utils.dpToPx(requireContext(), -30) + ) + } + peeked = true + } + else -> { /* nothing to do */ } + } + } + } + + // Manage the display of progress bars. Rather than hide them as soon as the + // Refresh portion completes, hide them when then first Prepend completes. This + // is a better signal to the user that it is now possible to scroll up and see + // new content. + launch { + refreshState.collect { + when (it) { + UserRefreshState.ACTIVE -> { + if (adapter.itemCount == 0 && !binding.swipeRefreshLayout.isRefreshing) { + binding.progressBar.show() + } + } + UserRefreshState.COMPLETE, UserRefreshState.ERROR -> { + binding.progressBar.hide() + binding.swipeRefreshLayout.isRefreshing = false + } + else -> { /* nothing to do */ } + } + } + } + // Update the UI from the loadState adapter.loadStateFlow - .distinctUntilChangedBy { it.refresh } .collect { loadState -> - binding.recyclerView.isVisible = true - binding.progressBar.isVisible = loadState.refresh is LoadState.Loading && - !binding.swipeRefreshLayout.isRefreshing - binding.swipeRefreshLayout.isRefreshing = - loadState.refresh is LoadState.Loading && !binding.progressBar.isVisible - - binding.statusView.isVisible = false + binding.statusView.hide() if (loadState.refresh is LoadState.NotLoading) { if (adapter.itemCount == 0) { binding.statusView.setup( R.drawable.elephant_friend_empty, R.string.message_empty ) - binding.recyclerView.isVisible = false - binding.statusView.isVisible = true + binding.recyclerView.hide() + binding.statusView.show() } else { - binding.statusView.isVisible = false + binding.statusView.hide() } } if (loadState.refresh is LoadState.Error) { - when ((loadState.refresh as LoadState.Error).error) { - is IOException -> { - binding.statusView.setup( - R.drawable.errorphant_offline, - R.string.error_network - ) { adapter.retry() } - } - else -> { - binding.statusView.setup( - R.drawable.errorphant_error, - R.string.error_generic - ) { adapter.retry() } - } - } - binding.recyclerView.isVisible = false - binding.statusView.isVisible = true + binding.statusView.setup((loadState.refresh as LoadState.Error).error) { adapter.retry() } + binding.recyclerView.hide() + binding.statusView.show() } } } @@ -503,7 +544,7 @@ class NotificationsFragment : override fun onVoteInPoll(position: Int, choices: List) { val statusViewData = adapter.peek(position)?.statusViewData ?: return - val poll = statusViewData.status.poll ?: return + val poll = statusViewData.actionable.poll ?: return viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData)) } @@ -547,10 +588,6 @@ class NotificationsFragment : adapter.notifyItemChanged(position) } - override fun onLoadMore(position: Int) { - // Empty -- this fragment doesn't show placeholders - } - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { val notificationViewData = adapter.snapshot()[position] ?: return notificationViewData.statusViewData = notificationViewData.statusViewData?.copy( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt index b754989d06..f483610470 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsPagingSource.kt @@ -19,33 +19,19 @@ package com.keylesspalace.tusky.components.notifications import android.util.Log import androidx.paging.PagingSource +import androidx.paging.PagingSource.LoadResult import androidx.paging.PagingState import com.google.gson.Gson import com.keylesspalace.tusky.entity.Notification +import com.keylesspalace.tusky.network.Links import com.keylesspalace.tusky.network.MastodonApi -import com.keylesspalace.tusky.util.HttpHeaderLink import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope import okhttp3.Headers import retrofit2.Response import javax.inject.Inject -/** Models next/prev links from the "Links" header in an API response */ -data class Links(val next: String?, val prev: String?) { - companion object { - fun from(linkHeader: String?): Links { - val links = HttpHeaderLink.parse(linkHeader) - return Links( - next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter( - "max_id" - ), - prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter( - "min_id" - ) - ) - } - } -} +private val INVALID = LoadResult.Invalid() /** [PagingSource] for Mastodon Notifications, identified by the Notification ID */ class NotificationsPagingSource @Inject constructor( @@ -94,6 +80,15 @@ class NotificationsPagingSource @Inject constructor( } val links = Links.from(response.headers()["link"]) + + // Bail if this paging source has already been invalidated. If you do not do this there + // is a lot of spurious animation, especially during the initial load, as multiple pages + // are loaded and the paging source is repeatedly invalidated. + if (invalid) { + Log.d(TAG, "Invalidated, returning LoadResult.Invalid") + return INVALID + } + return LoadResult.Page( data = response.body()!!, nextKey = links.next, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt index 4bec1aa328..080aeb3778 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsRepository.kt @@ -24,8 +24,11 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.PagingSource import com.google.gson.Gson +import com.keylesspalace.tusky.di.ApplicationScope import com.keylesspalace.tusky.entity.Notification import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow import okhttp3.ResponseBody import retrofit2.Response @@ -33,7 +36,8 @@ import javax.inject.Inject class NotificationsRepository @Inject constructor( private val mastodonApi: MastodonApi, - private val gson: Gson + private val gson: Gson, + @ApplicationScope private val externalScope: CoroutineScope ) { private var factory: InvalidatingPagingSourceFactory? = null @@ -65,9 +69,9 @@ class NotificationsRepository @Inject constructor( } /** Clear notifications */ - suspend fun clearNotifications(): Response { - return mastodonApi.clearNotifications() - } + suspend fun clearNotifications(): Response = externalScope.async { + return@async mastodonApi.clearNotifications() + }.await() companion object { private const val TAG = "NotificationsRepository" diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt index d06a8bbcfc..d135180d14 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModel.kt @@ -174,25 +174,25 @@ sealed class NotificationActionSuccess( /** Actions the user can trigger on an individual status */ sealed class StatusAction( - open val statusViewData: StatusViewData.Concrete + open val statusViewData: StatusViewData ) : FallibleUiAction() { /** Set the bookmark state for a status */ - data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData) : StatusAction(statusViewData) /** Set the favourite state for a status */ - data class Favourite(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + data class Favourite(val state: Boolean, override val statusViewData: StatusViewData) : StatusAction(statusViewData) /** Set the reblog state for a status */ - data class Reblog(val state: Boolean, override val statusViewData: StatusViewData.Concrete) : + data class Reblog(val state: Boolean, override val statusViewData: StatusViewData) : StatusAction(statusViewData) /** Vote in a poll */ data class VoteInPoll( val poll: Poll, val choices: List, - override val statusViewData: StatusViewData.Concrete + override val statusViewData: StatusViewData ) : StatusAction(statusViewData) } @@ -503,6 +503,7 @@ class NotificationsViewModel @Inject constructor( filters: Set, initialKey: String? = null ): Flow> { + Log.d(TAG, "getNotifications: $initialKey") return repository.getNotificationsStream(filter = filters, initialKey = initialKey) .map { pagingData -> pagingData.map { notification -> diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt index 0b1d8dca44..27c20ef273 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/notifications/StatusNotificationViewHolder.kt @@ -321,7 +321,7 @@ internal class StatusNotificationViewHolder( private fun setupContentAndSpoiler( listener: LinkListener, - statusViewData: StatusViewData.Concrete, + statusViewData: StatusViewData, animateEmojis: Boolean ) { val shouldShowContentIfSpoiler = statusViewData.isExpanded diff --git a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt index f6541f1fdc..b404d1b16c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/preference/PreferencesFragment.kt @@ -51,26 +51,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { private val iconSize by unsafeLazy { resources.getDimensionPixelSize(R.dimen.preference_icon_size) } - enum class ReadingOrder { - /** User scrolls up, reading statuses oldest to newest */ - OLDEST_FIRST, - - /** User scrolls down, reading statuses newest to oldest. Default behaviour. */ - NEWEST_FIRST; - - companion object { - fun from(s: String?): ReadingOrder { - s ?: return NEWEST_FIRST - - return try { - valueOf(s.uppercase()) - } catch (_: Throwable) { - NEWEST_FIRST - } - } - } - } - override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { makePreferenceScreen { preferenceCategory(R.string.pref_title_appearance_settings) { @@ -123,16 +103,6 @@ class PreferencesFragment : PreferenceFragmentCompat(), Injectable { icon = makeIcon(GoogleMaterial.Icon.gmd_format_size) } - listPreference { - setDefaultValue(ReadingOrder.NEWEST_FIRST.name) - setEntries(R.array.reading_order_names) - setEntryValues(R.array.reading_order_values) - key = PrefKeys.READING_ORDER - setSummaryProvider { entry } - setTitle(R.string.pref_title_reading_order) - icon = makeIcon(GoogleMaterial.Icon.gmd_sort) - } - listPreference { setDefaultValue("top") setEntries(R.array.pref_main_nav_position_options) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt index f30726a23c..c56be7d30d 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/ReportViewModel.kt @@ -77,7 +77,7 @@ class ReportViewModel @Inject constructor( ).flow } .map { pagingData -> - /* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData.Concrete + /* TODO: refactor reports to use the isShowingContent / isExpanded / isCollapsed attributes from StatusViewData instead of StatusViewState */ pagingData.map { status -> status.toViewData(false, false, false) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt index 0ee7ec5db6..ba076b77eb 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusViewHolder.kt @@ -47,7 +47,7 @@ class StatusViewHolder( private val statusDisplayOptions: StatusDisplayOptions, private val viewState: StatusViewState, private val adapterHandler: AdapterHandler, - private val getStatusForPosition: (Int) -> StatusViewData.Concrete? + private val getStatusForPosition: (Int) -> StatusViewData? ) : RecyclerView.ViewHolder(binding.root) { private val mediaViewHeight = itemView.context.resources.getDimensionPixelSize(R.dimen.status_media_preview_height) @@ -77,7 +77,7 @@ class StatusViewHolder( binding.statusMediaPreviewContainer.clipToOutline = true } - fun bind(viewData: StatusViewData.Concrete) { + fun bind(viewData: StatusViewData) { binding.statusSelection.isChecked = adapterHandler.isStatusChecked(viewData.id) updateTextView() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt index 7e2c24174d..dab371ccf6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/report/adapter/StatusesAdapter.kt @@ -29,9 +29,9 @@ class StatusesAdapter( private val statusDisplayOptions: StatusDisplayOptions, private val statusViewState: StatusViewState, private val adapterHandler: AdapterHandler -) : PagingDataAdapter(STATUS_COMPARATOR) { +) : PagingDataAdapter(STATUS_COMPARATOR) { - private val statusForPosition: (Int) -> StatusViewData.Concrete? = { position: Int -> + private val statusForPosition: (Int) -> StatusViewData? = { position: Int -> if (position != RecyclerView.NO_POSITION) getItem(position) else null } @@ -53,11 +53,11 @@ class StatusesAdapter( } companion object { - val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = + val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: StatusViewData, newItem: StatusViewData): Boolean = oldItem == newItem - override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = + override fun areItemsTheSame(oldItem: StatusViewData, newItem: StatusViewData): Boolean = oldItem.id == newItem.id } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt index 0ab4d1e1c9..dbc571a5d8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/SearchViewModel.kt @@ -54,7 +54,7 @@ class SearchViewModel @Inject constructor( val alwaysShowSensitiveMedia = activeAccount?.alwaysShowSensitiveMedia ?: false val alwaysOpenSpoiler = activeAccount?.alwaysOpenSpoiler ?: false - private val loadedStatuses: MutableList = mutableListOf() + private val loadedStatuses: MutableList = mutableListOf() private val statusesPagingSourceFactory = SearchPagingSourceFactory(mastodonApi, SearchType.Status, loadedStatuses) { it.statuses.map { status -> @@ -99,7 +99,7 @@ class SearchViewModel @Inject constructor( hashtagsPagingSourceFactory.newSearch(query) } - fun removeItem(statusViewData: StatusViewData.Concrete) { + fun removeItem(statusViewData: StatusViewData) { viewModelScope.launch { if (timelineCases.delete(statusViewData.id).isSuccess) { if (loadedStatuses.remove(statusViewData)) { @@ -109,11 +109,11 @@ class SearchViewModel @Inject constructor( } } - fun expandedChange(statusViewData: StatusViewData.Concrete, expanded: Boolean) { + fun expandedChange(statusViewData: StatusViewData, expanded: Boolean) { updateStatusViewData(statusViewData.copy(isExpanded = expanded)) } - fun reblog(statusViewData: StatusViewData.Concrete, reblog: Boolean) { + fun reblog(statusViewData: StatusViewData, reblog: Boolean) { viewModelScope.launch { timelineCases.reblog(statusViewData.id, reblog).fold({ updateStatus( @@ -128,15 +128,15 @@ class SearchViewModel @Inject constructor( } } - fun contentHiddenChange(statusViewData: StatusViewData.Concrete, isShowing: Boolean) { + fun contentHiddenChange(statusViewData: StatusViewData, isShowing: Boolean) { updateStatusViewData(statusViewData.copy(isShowingContent = isShowing)) } - fun collapsedChange(statusViewData: StatusViewData.Concrete, collapsed: Boolean) { + fun collapsedChange(statusViewData: StatusViewData, collapsed: Boolean) { updateStatusViewData(statusViewData.copy(isCollapsed = collapsed)) } - fun voteInPoll(statusViewData: StatusViewData.Concrete, choices: MutableList) { + fun voteInPoll(statusViewData: StatusViewData, choices: MutableList) { val votedPoll = statusViewData.status.actionableStatus.poll!!.votedCopy(choices) updateStatus(statusViewData.status.copy(poll = votedPoll)) viewModelScope.launch { @@ -145,14 +145,14 @@ class SearchViewModel @Inject constructor( } } - fun favorite(statusViewData: StatusViewData.Concrete, isFavorited: Boolean) { + fun favorite(statusViewData: StatusViewData, isFavorited: Boolean) { updateStatus(statusViewData.status.copy(favourited = isFavorited)) viewModelScope.launch { timelineCases.favourite(statusViewData.id, isFavorited) } } - fun bookmark(statusViewData: StatusViewData.Concrete, isBookmarked: Boolean) { + fun bookmark(statusViewData: StatusViewData, isBookmarked: Boolean) { updateStatus(statusViewData.status.copy(bookmarked = isBookmarked)) viewModelScope.launch { timelineCases.bookmark(statusViewData.id, isBookmarked) @@ -183,14 +183,14 @@ class SearchViewModel @Inject constructor( } } - fun muteConversation(statusViewData: StatusViewData.Concrete, mute: Boolean) { + fun muteConversation(statusViewData: StatusViewData, mute: Boolean) { updateStatus(statusViewData.status.copy(muted = mute)) viewModelScope.launch { timelineCases.muteConversation(statusViewData.id, mute) } } - private fun updateStatusViewData(newStatusViewData: StatusViewData.Concrete) { + private fun updateStatusViewData(newStatusViewData: StatusViewData) { val idx = loadedStatuses.indexOfFirst { it.id == newStatusViewData.id } if (idx >= 0) { loadedStatuses[idx] = newStatusViewData diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt index a7c4c90ebf..6517308bc9 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/adapter/SearchStatusesAdapter.kt @@ -28,7 +28,7 @@ import com.keylesspalace.tusky.viewdata.StatusViewData class SearchStatusesAdapter( private val statusDisplayOptions: StatusDisplayOptions, private val statusListener: StatusActionListener -) : PagingDataAdapter(STATUS_COMPARATOR) { +) : PagingDataAdapter(STATUS_COMPARATOR) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusViewHolder { val view = LayoutInflater.from(parent.context) @@ -44,11 +44,11 @@ class SearchStatusesAdapter( companion object { - val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { - override fun areContentsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = + val STATUS_COMPARATOR = object : DiffUtil.ItemCallback() { + override fun areContentsTheSame(oldItem: StatusViewData, newItem: StatusViewData): Boolean = oldItem == newItem - override fun areItemsTheSame(oldItem: StatusViewData.Concrete, newItem: StatusViewData.Concrete): Boolean = + override fun areItemsTheSame(oldItem: StatusViewData, newItem: StatusViewData): Boolean = oldItem.id == newItem.id } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt index 1144a06dcd..0cbe300479 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/search/fragments/SearchStatusesFragment.kt @@ -65,17 +65,17 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import javax.inject.Inject -class SearchStatusesFragment : SearchFragment(), StatusActionListener { +class SearchStatusesFragment : SearchFragment(), StatusActionListener { @Inject lateinit var accountManager: AccountManager - override val data: Flow> + override val data: Flow> get() = viewModel.statusesFlow private val searchAdapter get() = super.adapter as SearchStatusesAdapter - override fun createAdapter(): PagingDataAdapter { + override fun createAdapter(): PagingDataAdapter { val preferences = PreferenceManager.getDefaultSharedPreferences(binding.searchRecyclerView.context) val statusDisplayOptions = StatusDisplayOptions( animateAvatars = preferences.getBoolean("animateGifAvatars", false), @@ -177,10 +177,6 @@ class SearchStatusesFragment : SearchFragment(), Status } } - override fun onLoadMore(position: Int) { - // Not possible here - } - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { searchAdapter.peek(position)?.let { viewModel.collapsedChange(it, isCollapsed) @@ -211,7 +207,7 @@ class SearchStatusesFragment : SearchFragment(), Status fun newInstance() = SearchStatusesFragment() } - private fun reply(status: StatusViewData.Concrete) { + private fun reply(status: StatusViewData) { val actionableStatus = status.actionable val mentionedUsernames = actionableStatus.mentions.map { it.username } .toMutableSet() diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt new file mode 100644 index 0000000000..9a751d332d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRepository.kt @@ -0,0 +1,160 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import android.util.Log +import androidx.paging.ExperimentalPagingApi +import androidx.paging.InvalidatingPagingSourceFactory +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import com.google.gson.Gson +import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineRemoteMediator +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.TimelineStatusWithAccount +import com.keylesspalace.tusky.di.ApplicationScope +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.EmptyPagingSource +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.launch +import javax.inject.Inject + +// TODO: This is very similar to NetworkTimelineRepository. They could be merged (and the use +// of the cache be made a parameter to getStatusStream), except that they return Pagers of +// different generic types. +// +// NetworkTimelineRepository factory is , this is +// +// Re-writing the caching so that they can use the same types is the TODO. + +class CachedTimelineRepository @Inject constructor( + private val mastodonApi: MastodonApi, + private val accountManager: AccountManager, + private val appDatabase: AppDatabase, + private val gson: Gson, + @ApplicationScope private val externalScope: CoroutineScope +) { + private var factory: InvalidatingPagingSourceFactory? = null + + private val activeAccount = accountManager.activeAccount + + /** @return flow of Mastodon [TimelineStatusWithAccount], loaded in [pageSize] increments */ + @OptIn(ExperimentalPagingApi::class) + fun getStatusStream( + kind: TimelineKind, + pageSize: Int = PAGE_SIZE, + initialKey: String? = null + ): Flow> { + Log.d(TAG, "getStatusStream(): key: $initialKey") + + factory = InvalidatingPagingSourceFactory { + activeAccount?.let { appDatabase.timelineDao().getStatuses(it.id) } ?: EmptyPagingSource() + } + + val row = initialKey?.let { key -> + // Room is row-keyed (by Int), not item-keyed, so the status ID string that was + // passed as `initialKey` won't work. + // + // Instead, get all the status IDs for this account, in timeline order, and find the + // row index that contains the status. The row index is the correct initialKey. + activeAccount?.let { account -> + appDatabase.timelineDao().getStatusRowNumber(account.id) + .indexOfFirst { it == key }.takeIf { it != -1 } + } + } + + Log.d(TAG, "initialKey: $initialKey is row: $row") + + return Pager( + config = PagingConfig(pageSize = pageSize), + initialKey = row, + remoteMediator = CachedTimelineRemoteMediator( + mastodonApi, + accountManager, + factory!!, + appDatabase, + gson + ), + pagingSourceFactory = factory!! + ).flow + } + + /** Invalidate the active paging source, see [PagingSource.invalidate] */ + suspend fun invalidate() { + // Invalidating when no statuses have been loaded can cause empty timelines because it + // cancels the network load. + if (appDatabase.timelineDao().getStatusCount(activeAccount!!.id) < 1) { + return + } + + factory?.invalidate() + } + + /** Set and store the "expanded" state of the given status, for the active account */ + suspend fun setExpanded(expanded: Boolean, statusId: String) = externalScope.launch { + appDatabase.timelineDao() + .setExpanded(activeAccount!!.id, statusId, expanded) + }.join() + + /** Set and store the "content showing" state of the given status, for the active account */ + suspend fun setContentShowing(showing: Boolean, statusId: String) = externalScope.launch { + appDatabase.timelineDao() + .setContentShowing(activeAccount!!.id, statusId, showing) + }.join() + + /** Set and store the "content collapsed" ("Show more") state of the given status, for the active account */ + suspend fun setContentCollapsed(collapsed: Boolean, statusId: String) = externalScope.launch { + appDatabase.timelineDao() + .setContentCollapsed(activeAccount!!.id, statusId, collapsed) + }.join() + + /** Remove all statuses authored/boosted by the given account, for the active account */ + suspend fun removeAllByAccountId(accountId: String) = externalScope.launch { + appDatabase.timelineDao().removeAllByUser(activeAccount!!.id, accountId) + }.join() + + /** Remove all statuses from the given instance, for the active account */ + suspend fun removeAllByInstance(instance: String) = externalScope.launch { + appDatabase.timelineDao() + .deleteAllFromInstance(activeAccount!!.id, instance) + }.join() + + /** Clear the warning (remove the "filtered" setting) for the given status, for the active account */ + suspend fun clearStatusWarning(statusId: String) = externalScope.launch { + appDatabase.timelineDao().clearWarning(activeAccount!!.id, statusId) + }.join() + + /** Remove all statuses and invalidate the pager, for the active account */ + suspend fun clearAndReload() = externalScope.launch { + appDatabase.timelineDao().removeAll(activeAccount!!.id) + factory?.invalidate() + }.join() + + suspend fun clearAndReloadFromNewest() = externalScope.launch { + appDatabase.timelineDao().removeAll(activeAccount!!.id) + appDatabase.remoteKeyDao().delete(activeAccount.id) + invalidate() + } + + companion object { + private const val TAG = "CachedTimelineRepository" + private const val PAGE_SIZE = 30 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/FiltersRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/FiltersRepository.kt new file mode 100644 index 0000000000..50c874314c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/FiltersRepository.kt @@ -0,0 +1,63 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import at.connyduck.calladapter.networkresult.fold +import at.connyduck.calladapter.networkresult.getOrThrow +import com.keylesspalace.tusky.entity.Filter +import com.keylesspalace.tusky.entity.FilterV1 +import com.keylesspalace.tusky.network.MastodonApi +import retrofit2.HttpException +import javax.inject.Inject + +sealed class FilterKind { + /** API v1 filter, filtering happens client side */ + data class V1(val filters: List) : FilterKind() + + /** API v2 filter, filtering happens server side */ + data class V2(val filters: List) : FilterKind() +} + +/** Repository for filter information */ +class FiltersRepository @Inject constructor( + private val mastodonApi: MastodonApi +) { + /** + * Get the current set of filters. + * + * Checks for server-side (v2) filters first. If that fails then fetches filters to + * apply client-side. + * + * @throws HttpException if the requests fail + */ + suspend fun getFilters(): FilterKind = mastodonApi.getFilters().fold( + { filters -> FilterKind.V2(filters) }, + { throwable -> + if (throwable is HttpException && throwable.code() == 404) { + val filters = mastodonApi.getFiltersV1().getOrThrow() + FilterKind.V1(filters) + } else { + throw throwable + } + } + ) + + companion object { + private const val TAG = "FiltersRepository" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt new file mode 100644 index 0000000000..04226bd34f --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRepository.kt @@ -0,0 +1,180 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import android.util.Log +import androidx.paging.ExperimentalPagingApi +import androidx.paging.InvalidatingPagingSourceFactory +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.PagingSource +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineRemoteMediator +import com.keylesspalace.tusky.components.timeline.viewmodel.PageCache +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.util.getDomain +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +// Things that make this more difficult than it should be: +// +// - Mastodon API doesn't support "Fetch page that contains item X", you have to rely on having +// the page that contains item X, and the previous or next page, so you can use the prev/next +// link values from the next or previous page to step forwards or backwards to the page you +// actually want. +// +// - Not all Mastodon APIs that paginate support a "Fetch me just the item X". E.g., getting a +// list of bookmarks (https://docs.joinmastodon.org/methods/bookmarks/#get) paginates, but does +// not support a "Get a single bookmark" call. Ditto for favourites. So even though some API +// methods do support that they can't be used here, because this has to work for all paging APIs. +// +// - Values of next/prev in the Link header do not have to match any of the item keys (or be taken +// from the same namespace). +// +// - Two pages that are consecutive in the result set may not have next/prev values that point +// back to each other. I.e., this is a valid set of two pages from an API call: +// +// .--- page index +// | .-- ID of last item (key in `pageCache`) +// v V +// 0: k: 109934818460629189, prevKey: 995916, nextKey: 941865 +// 1: k: 110033940961955385, prevKey: 1073324, nextKey: 997376 +// +// They are consecutive in the result set, but pageCache[0].prevKey != pageCache[1].nextKey. So +// there's no benefit to using the nextKey/prevKey tokens as the keys in PageCache. +// +// - Bugs in the Paging library mean that on initial load (especially of rapidly changing timelines +// like Federated) the user's initial position can jump around a lot. See: +// - https://issuetracker.google.com/issues/235319241 +// - https://issuetracker.google.com/issues/289824257 + +/** Timeline repository where the timeline information is backed by an in-memory cache. */ +class NetworkTimelineRepository @Inject constructor( + private val mastodonApi: MastodonApi, + private val accountManager: AccountManager +) { + private val pageCache = PageCache() + + private var factory: InvalidatingPagingSourceFactory? = null + + /** @return flow of Mastodon [Status], loaded in [pageSize] increments */ + @OptIn(ExperimentalPagingApi::class) + fun getStatusStream( + viewModelScope: CoroutineScope, + kind: TimelineKind, + pageSize: Int = PAGE_SIZE, + initialKey: String? = null + ): Flow> { + Log.d(TAG, "getStatusStream(): key: $initialKey") + + factory = InvalidatingPagingSourceFactory { + NetworkTimelinePagingSource(pageCache) + } + + return Pager( + config = PagingConfig(pageSize = pageSize), + remoteMediator = NetworkTimelineRemoteMediator( + viewModelScope, + mastodonApi, + accountManager, + factory!!, + pageCache, + kind + ), + pagingSourceFactory = factory!! + ).flow + } + + /** Invalidate the active paging source, see [PagingSource.invalidate] */ + fun invalidate() { + factory?.invalidate() + } + + fun removeAllByAccountId(accountId: String) { + synchronized(pageCache) { + for (page in pageCache.values) { + page.data.removeAll { status -> + status.account.id == accountId || status.actionableStatus.account.id == accountId + } + } + } + invalidate() + } + + fun removeAllByInstance(instance: String) { + synchronized(pageCache) { + for (page in pageCache.values) { + page.data.removeAll { status -> getDomain(status.account.url) == instance } + } + } + invalidate() + } + + fun removeStatusWithId(statusId: String) { + synchronized(pageCache) { + pageCache.floorEntry(statusId)?.value?.data?.removeAll { status -> + status.id == statusId || status.reblog?.id == statusId + } + } + invalidate() + } + + fun updateStatusById(statusId: String, updater: (Status) -> Status) { + synchronized(pageCache) { + pageCache.floorEntry(statusId)?.value?.let { page -> + val index = page.data.indexOfFirst { it.id == statusId } + if (index != -1) { + page.data[index] = updater(page.data[index]) + } + } + } + invalidate() + } + + fun updateActionableStatusById(statusId: String, updater: (Status) -> Status) { + synchronized(pageCache) { + pageCache.floorEntry(statusId)?.value?.let { page -> + val index = page.data.indexOfFirst { it.id == statusId } + if (index != -1) { + val status = page.data[index] + if (status.reblog != null) { + page.data[index] = status.copy(reblog = updater(status.reblog)) + } else { + page.data[index] = updater(status) + } + } + } + } + } + + fun reload() { + synchronized(pageCache) { + pageCache.clear() + } + invalidate() + } + + companion object { + private const val TAG = "NetworkTimelineRepository" + private const val PAGE_SIZE = 30 + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt index ef5d9085df..b050ba92d7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineFragment.kt @@ -29,29 +29,29 @@ import androidx.core.view.MenuProvider import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.paging.LoadState -import androidx.preference.PreferenceManager import androidx.recyclerview.widget.DividerItemDecoration import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView +import androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE import androidx.recyclerview.widget.SimpleItemAnimator import androidx.swiperefreshlayout.widget.SwipeRefreshLayout.OnRefreshListener import at.connyduck.sparkbutton.helpers.Utils -import autodispose2.androidx.lifecycle.autoDispose import com.google.android.material.color.MaterialColors +import com.google.android.material.snackbar.Snackbar import com.keylesspalace.tusky.BaseActivity import com.keylesspalace.tusky.R import com.keylesspalace.tusky.adapter.StatusBaseViewHolder -import com.keylesspalace.tusky.appstore.EventHub -import com.keylesspalace.tusky.appstore.PreferenceChangedEvent -import com.keylesspalace.tusky.appstore.StatusComposedEvent -import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.components.accountlist.AccountListActivity import com.keylesspalace.tusky.components.accountlist.AccountListActivity.Companion.newIntent -import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.InfallibleUiAction import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.StatusAction +import com.keylesspalace.tusky.components.timeline.viewmodel.StatusActionSuccess import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.UiSuccess import com.keylesspalace.tusky.databinding.FragmentTimelineBinding import com.keylesspalace.tusky.di.Injectable import com.keylesspalace.tusky.di.ViewModelFactory @@ -61,26 +61,34 @@ import com.keylesspalace.tusky.interfaces.ActionButtonActivity import com.keylesspalace.tusky.interfaces.RefreshableFragment import com.keylesspalace.tusky.interfaces.ReselectableFragment import com.keylesspalace.tusky.interfaces.StatusActionListener -import com.keylesspalace.tusky.settings.PrefKeys -import com.keylesspalace.tusky.util.CardViewMode import com.keylesspalace.tusky.util.ListStatusAccessibilityDelegate -import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.PresentationState +import com.keylesspalace.tusky.util.UserRefreshState +import com.keylesspalace.tusky.util.asRefreshState +import com.keylesspalace.tusky.util.getDrawableRes +import com.keylesspalace.tusky.util.getErrorString import com.keylesspalace.tusky.util.hide import com.keylesspalace.tusky.util.show import com.keylesspalace.tusky.util.unsafeLazy import com.keylesspalace.tusky.util.viewBinding +import com.keylesspalace.tusky.util.withPresentationState import com.keylesspalace.tusky.viewdata.AttachmentViewData import com.keylesspalace.tusky.viewdata.StatusViewData import com.mikepenz.iconics.IconicsDrawable import com.mikepenz.iconics.typeface.library.googlematerial.GoogleMaterial import com.mikepenz.iconics.utils.colorInt import com.mikepenz.iconics.utils.sizeDp -import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers -import io.reactivex.rxjava3.core.Observable +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch -import java.util.concurrent.TimeUnit import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds class TimelineFragment : SFragment(), @@ -94,11 +102,8 @@ class TimelineFragment : @Inject lateinit var viewModelFactory: ViewModelFactory - @Inject - lateinit var eventHub: EventHub - private val viewModel: TimelineViewModel by unsafeLazy { - if (kind == TimelineViewModel.Kind.HOME) { + if (timelineKind == TimelineKind.Home) { ViewModelProvider(this, viewModelFactory)[CachedTimelineViewModel::class.java] } else { ViewModelProvider(this, viewModelFactory)[NetworkTimelineViewModel::class.java] @@ -107,103 +112,39 @@ class TimelineFragment : private val binding by viewBinding(FragmentTimelineBinding::bind) - private lateinit var kind: TimelineViewModel.Kind + private lateinit var timelineKind: TimelineKind private lateinit var adapter: TimelinePagingAdapter + private lateinit var layoutManager: LinearLayoutManager + + /** The active snackbar, if any */ + // TODO: This shouldn't be necessary, the snackbar should dismiss itself if the layout + // changes. It doesn't, because the CoordinatorLayout is in the activity, not the fragment. + // I think the correct fix is to include the FAB in each fragment layout that needs it, + // ensuring that the outermost fragment view is a CoordinatorLayout. That will auto-dismiss + // the snackbar when the fragment is paused. + private var snackbar: Snackbar? = null + private var isSwipeToRefreshEnabled = true - private var hideFab = false - /** - * Adapter position of the placeholder that was most recently clicked to "Load more". If null - * then there is no active "Load more" operation - */ - private var loadMorePosition: Int? = null - - /** ID of the status immediately below the most recent "Load more" placeholder click */ - // The Paging library assumes that the user will be scrolling down a list of items, - // and if new items are loaded but not visible then it's reasonable to scroll to the top - // of the inserted items. It does not seem to be possible to disable that behaviour. - // - // That behaviour should depend on the user's preferred reading order. If they prefer to - // read oldest first then the list should be scrolled to the bottom of the freshly - // inserted statuses. - // - // To do this: - // - // 1. When "Load more" is clicked (onLoadMore()): - // a. Remember the adapter position of the "Load more" item in loadMorePosition - // b. Remember the ID of the status immediately below the "Load more" item in - // statusIdBelowLoadMore - // 2. After the new items have been inserted, search the adapter for the position of the - // status with id == statusIdBelowLoadMore. - // 3. If this position is still visible on screen then do nothing, otherwise, scroll the view - // so that the status is visible. - // - // The user can then scroll up to read the new statuses. - private var statusIdBelowLoadMore: String? = null - - /** The user's preferred reading order */ - private lateinit var readingOrder: ReadingOrder + /** True if the reading position should be restored when new data is submitted to the adapter */ + private var shouldRestoreReadingPosition = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val arguments = requireArguments() - kind = TimelineViewModel.Kind.valueOf(arguments.getString(KIND_ARG)!!) - val id: String? = if (kind == TimelineViewModel.Kind.USER || - kind == TimelineViewModel.Kind.USER_PINNED || - kind == TimelineViewModel.Kind.USER_WITH_REPLIES || - kind == TimelineViewModel.Kind.LIST - ) { - arguments.getString(ID_ARG)!! - } else { - null - } - val tags = if (kind == TimelineViewModel.Kind.TAG) { - arguments.getStringArrayList(HASHTAGS_ARG)!! - } else { - listOf() - } - viewModel.init( - kind, - id, - tags - ) + timelineKind = arguments.getParcelable(KIND_ARG)!! + + shouldRestoreReadingPosition = timelineKind == TimelineKind.Home + + viewModel.init(timelineKind) isSwipeToRefreshEnabled = arguments.getBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) - val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - readingOrder = ReadingOrder.from(preferences.getString(PrefKeys.READING_ORDER, null)) - - val statusDisplayOptions = StatusDisplayOptions( - animateAvatars = preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false), - mediaPreviewEnabled = accountManager.activeAccount!!.mediaPreviewEnabled, - useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), - showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), - useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), - cardViewMode = if (preferences.getBoolean( - PrefKeys.SHOW_CARDS_IN_TIMELINES, - false - ) - ) { - CardViewMode.INDENTED - } else { - CardViewMode.NONE - }, - confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), - confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), - hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), - animateEmojis = preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false), - showStatsInline = preferences.getBoolean(PrefKeys.SHOW_STATS_INLINE, false), - showSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia, - openSpoiler = accountManager.activeAccount!!.alwaysOpenSpoiler - ) - adapter = TimelinePagingAdapter( - statusDisplayOptions, - this - ) + adapter = TimelinePagingAdapter(this, viewModel.statusDisplayOptions.value) } override fun onCreateView( @@ -217,72 +158,17 @@ class TimelineFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { requireActivity().addMenuProvider(this, viewLifecycleOwner, Lifecycle.State.RESUMED) + layoutManager = LinearLayoutManager(context) + setupSwipeRefreshLayout() setupRecyclerView() - adapter.addLoadStateListener { loadState -> - if (loadState.refresh != LoadState.Loading && loadState.source.refresh != LoadState.Loading) { - binding.swipeRefreshLayout.isRefreshing = false - } - - binding.statusView.hide() - binding.progressBar.hide() - - if (adapter.itemCount == 0) { - when (loadState.refresh) { - is LoadState.NotLoading -> { - if (loadState.append is LoadState.NotLoading && loadState.source.refresh is LoadState.NotLoading) { - binding.statusView.show() - binding.statusView.setup(R.drawable.elephant_friend_empty, R.string.message_empty) - if (kind == TimelineViewModel.Kind.HOME) { - binding.statusView.showHelp(R.string.help_empty_home) - } - } - } - is LoadState.Error -> { - binding.statusView.show() - binding.statusView.setup((loadState.refresh as LoadState.Error).error) { onRefresh() } - } - is LoadState.Loading -> { - binding.progressBar.show() - } - } - } - } - - adapter.registerAdapterDataObserver(object : RecyclerView.AdapterDataObserver() { - override fun onItemRangeInserted(positionStart: Int, itemCount: Int) { - if (positionStart == 0 && adapter.itemCount != itemCount) { - binding.recyclerView.post { - if (getView() != null) { - if (isSwipeToRefreshEnabled) { - binding.recyclerView.scrollBy(0, Utils.dpToPx(requireContext(), -30)) - } else { - binding.recyclerView.scrollToPosition(0) - } - } - } - } - if (readingOrder == ReadingOrder.OLDEST_FIRST) { - updateReadingPositionForOldestFirst() - } - } - }) - - viewLifecycleOwner.lifecycleScope.launch { - viewModel.statuses.collectLatest { pagingData -> - adapter.submitData(pagingData) - } - } - if (actionButtonPresent()) { - val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - hideFab = preferences.getBoolean("fabHide", false) binding.recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(view: RecyclerView, dx: Int, dy: Int) { val composeButton = (activity as ActionButtonActivity).actionButton if (composeButton != null) { - if (hideFab) { + if (!viewModel.uiState.value.showFabWhileScrolling) { if (dy > 0 && composeButton.isShown) { composeButton.hide() // hides the button if we're scrolling down } else if (dy < 0 && !composeButton.isShown) { @@ -293,23 +179,305 @@ class TimelineFragment : } } } + + override fun onScrollStateChanged(recyclerView: RecyclerView, newState: Int) { + newState != SCROLL_STATE_IDLE && return + saveVisibleId() + } }) } + /** + * Collect this flow to notify the adapter that the timestamps of the visible items have + * changed + */ + // TODO: Copied from NotificationsFragment + val updateTimestampFlow = flow { + while (true) { delay(60.seconds); emit(Unit) } + }.onEach { + adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) + } + viewLifecycleOwner.lifecycleScope.launch { - eventHub.events.collect { event -> - when (event) { - is PreferenceChangedEvent -> { - onPreferenceChanged(event.preferenceKey) + viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { + launch { + viewModel.statuses.collectLatest { pagingData -> + adapter.submitData(pagingData) } - is StatusComposedEvent -> { - val status = event.status - handleStatusComposeEvent(status) + } + + // Show errors from the view model as snack bars. + // + // Errors are shown: + // - Indefinitely, so the user has a chance to read and understand + // the message + // - With a max of 5 text lines, to allow space for longer errors. + // E.g., on a typical device, an error message like "Bookmarking + // post failed: Unable to resolve host 'mastodon.social': No + // address associated with hostname" is 3 lines. + // - With a "Retry" option if the error included a UiAction to retry. + // TODO: Very similar to same code in NotificationsFragment + launch { + viewModel.uiError.collect { error -> + Log.d(TAG, error.toString()) + val message = getString( + error.message, + error.throwable.localizedMessage + ?: getString(R.string.ui_error_unknown) + ) + snackbar = Snackbar.make( + // Without this the FAB will not move out of the way + (activity as ActionButtonActivity).actionButton ?: binding.root, + message, + Snackbar.LENGTH_INDEFINITE + ).setTextMaxLines(5) + error.action?.let { action -> + snackbar!!.setAction(R.string.action_retry) { + viewModel.accept(action) + } + } + snackbar!!.show() + + // The status view has pre-emptively updated its state to show + // that the action succeeded. Since it hasn't, re-bind the view + // to show the correct data. + error.action?.let { action -> + if (action !is StatusAction) return@let + + adapter.snapshot() + .indexOfFirst { it?.id == action.statusViewData.id } + .takeIf { it != RecyclerView.NO_POSITION } + ?.let { adapter.notifyItemChanged(it) } + } } - is StatusEditedEvent -> { - handleStatusComposeEvent(event.status) + } + + // Update adapter data when status actions are successful, and re-bind to update + // the UI. + launch { + viewModel.uiSuccess + .filterIsInstance() + .collect { + val indexedViewData = adapter.snapshot() + .withIndex() + .firstOrNull { indexed -> + indexed.value?.id == it.action.statusViewData.id + } ?: return@collect + + val statusViewData = + indexedViewData.value ?: return@collect + + val status = when (it) { + is StatusActionSuccess.Bookmark -> + statusViewData.status.copy(bookmarked = it.action.state) + is StatusActionSuccess.Favourite -> + statusViewData.status.copy(favourited = it.action.state) + is StatusActionSuccess.Reblog -> + statusViewData.status.copy(reblogged = it.action.state) + is StatusActionSuccess.VoteInPoll -> + statusViewData.status.copy( + poll = it.action.poll.votedCopy(it.action.choices) + ) + } + (indexedViewData.value as StatusViewData).status = status + + adapter.notifyItemChanged(indexedViewData.index) + } + } + + // Refresh adapter on mutes and blocks + launch { + viewModel.uiSuccess.collectLatest { + when (it) { + is UiSuccess.Block, + is UiSuccess.Mute, + is UiSuccess.MuteConversation -> + adapter.refresh() + + is UiSuccess.StatusSent -> handleStatusSentOrEdit(it.status) + is UiSuccess.StatusEdited -> handleStatusSentOrEdit(it.status) + + else -> { /* nothing to do */ } + } } } + + // Update status display from statusDisplayOptions. If the new options request + // relative time display collect the flow to periodically re-bind the UI. + launch { + viewModel.statusDisplayOptions + .collectLatest { + adapter.statusDisplayOptions = it + layoutManager.findFirstVisibleItemPosition().let { first -> + first == RecyclerView.NO_POSITION && return@let + val count = layoutManager.findLastVisibleItemPosition() - first + adapter.notifyItemRangeChanged( + first, + count, + null + ) + } + + if (!it.useAbsoluteTime) { + updateTimestampFlow.collect() + } + } + } + + // Restore the user's reading position, if appropriate. + // Collect the first page submitted to the adapter, which will be the Refresh. + // This should contain a status with an ID that matches the reading position. + // Find that status and scroll to it. + launch { + if (shouldRestoreReadingPosition) { + adapter.onPagesUpdatedFlow.take(1).collect() + Log.d(TAG, "Page updated, should restore reading position") + adapter.snapshot() + .indexOfFirst { it?.id == viewModel.readingPositionId } + .takeIf { it != -1 } + ?.let { pos -> + binding.recyclerView.post { + getView() ?: return@post + binding.recyclerView.scrollToPosition(pos) + } + } + shouldRestoreReadingPosition = false + } + } + + /** StateFlow (to allow multiple consumers) of UserRefreshState */ + val refreshState = adapter.loadStateFlow.asRefreshState().stateIn(lifecycleScope) + + // Scroll the list down (peek) if a refresh has completely finished. A refresh is + // finished when both the initial refresh is complete and any prepends have + // finished (so that DiffUtil has had a chance to process the data). + launch { + if (!isSwipeToRefreshEnabled) return@launch + + /** True if the previous prepend resulted in a peek, false otherwise */ + var peeked = false + + /** ID of the item that was first in the adapter before the refresh */ + var previousFirstId: String? = null + + refreshState.collect { + when (it) { + // Refresh has started, reset peeked, and save the ID of the first item + // in the adapter + UserRefreshState.ACTIVE -> { + peeked = false + if (adapter.itemCount != 0) previousFirstId = adapter.peek(0)?.id + } + + // Refresh has finished, pages are being prepended. + UserRefreshState.COMPLETE -> { + // There might be multiple prepends after a refresh, only continue + // if one them has not already caused a peek. + if (peeked) return@collect + + // Compare the ID of the current first item with the previous first + // item. If they're the same then this prepend did not add any new + // items, and can be ignored. + val firstId = if (adapter.itemCount != 0) adapter.peek(0)?.id else null + if (previousFirstId == firstId) return@collect + + // New items were added and haven't peeked for this refresh. Schedule + // a scroll to disclose that new items are available. + binding.recyclerView.post { + getView() ?: return@post + binding.recyclerView.smoothScrollBy( + 0, + Utils.dpToPx(requireContext(), -30) + ) + } + peeked = true + } + else -> { /* nothing to do */ } + } + } + } + + // Manage the display of progress bars. Rather than hide them as soon as the + // Refresh portion completes, hide them when then first Prepend completes. This + // is a better signal to the user that it is now possible to scroll up and see + // new content. + launch { + refreshState.collect { + when (it) { + UserRefreshState.ACTIVE -> { + if (adapter.itemCount == 0 && !binding.swipeRefreshLayout.isRefreshing) { + binding.progressBar.show() + } + } + UserRefreshState.COMPLETE, UserRefreshState.ERROR -> { + binding.progressBar.hide() + binding.swipeRefreshLayout.isRefreshing = false + } + else -> { /* nothing to do */ } + } + } + } + + // Update the UI from the combined load state + launch { + adapter.loadStateFlow.withPresentationState() + .collect { (loadState, presentationState) -> + when (presentationState) { + PresentationState.ERROR -> { + val message = + (loadState.refresh as LoadState.Error).error.getErrorString( + requireContext() + ) + + // Show errors as a snackbar if there is existing content to show + // (either cached, or in the adapter), or as a full screen error + // otherwise. + if (adapter.itemCount > 0) { + snackbar = Snackbar.make( + (activity as ActionButtonActivity).actionButton + ?: binding.root, + message, + Snackbar.LENGTH_INDEFINITE + ) + .setTextMaxLines(5) + .setAction(R.string.action_retry) { adapter.retry() } + snackbar!!.show() + } else { + val drawableRes = + (loadState.refresh as LoadState.Error).error.getDrawableRes() + binding.statusView.setup(drawableRes, message) { + snackbar?.dismiss() + adapter.retry() + } + binding.statusView.show() + binding.recyclerView.hide() + } + } + + PresentationState.PRESENTED -> { + if (adapter.itemCount == 0) { + binding.statusView.setup( + R.drawable.elephant_friend_empty, + R.string.message_empty + ) + if (timelineKind == TimelineKind.Home) { + binding.statusView.showHelp(R.string.help_empty_home) + } + binding.statusView.show() + binding.recyclerView.hide() + } else { + binding.recyclerView.show() + binding.statusView.hide() + } + } + + else -> { + // Nothing to do -- show/hiding the progress bars in non-error states + // is handled via refreshState. + } + } + } + } } } } @@ -331,43 +499,36 @@ class TimelineFragment : return when (menuItem.itemId) { R.id.action_refresh -> { if (isSwipeToRefreshEnabled) { - binding.swipeRefreshLayout.isRefreshing = true - refreshContent() true } else { false } } + R.id.action_load_newest -> { + viewModel.accept(InfallibleUiAction.LoadNewest) + refreshContent() + true + } else -> false } } /** - * Set the correct reading position in the timeline after the user clicked "Load more", - * assuming the reading position should be below the freshly-loaded statuses. + * Save [statusId] as the reading position. If null then the ID of the last completely visible + * status is used. */ - // Note: The positionStart parameter to onItemRangeInserted() does not always - // match the adapter position where data was inserted (which is why loadMorePosition - // is tracked manually, see this bug report for another example: - // https://github.com/android/architecture-components-samples/issues/726). - private fun updateReadingPositionForOldestFirst() { - var position = loadMorePosition ?: return - val statusIdBelowLoadMore = statusIdBelowLoadMore ?: return - - var status: StatusViewData? - while (adapter.peek(position).let { status = it; it != null }) { - if (status?.id == statusIdBelowLoadMore) { - val lastVisiblePosition = - (binding.recyclerView.layoutManager as LinearLayoutManager).findLastVisibleItemPosition() - if (position > lastVisiblePosition) { - binding.recyclerView.scrollToPosition(position) - } - break + fun saveVisibleId(statusId: String? = null) { + statusId ?: layoutManager.findLastCompletelyVisibleItemPosition() + .takeIf { it != RecyclerView.NO_POSITION } + ?.let { pos -> + val snapshot = adapter.snapshot() + adapter.snapshot().getOrNull(pos)?.id + } + ?.let { + Log.d(TAG, "Saving ID: $it") + viewModel.accept(InfallibleUiAction.SaveVisibleId(visibleId = it)) } - position++ - } - loadMorePosition = null } private fun setupSwipeRefreshLayout() { @@ -387,97 +548,110 @@ class TimelineFragment : } ) binding.recyclerView.setHasFixedSize(true) - binding.recyclerView.layoutManager = LinearLayoutManager(context) + binding.recyclerView.layoutManager = layoutManager val divider = DividerItemDecoration(context, RecyclerView.VERTICAL) binding.recyclerView.addItemDecoration(divider) // CWs are expanded without animation, buttons animate itself, we don't need it basically (binding.recyclerView.itemAnimator as SimpleItemAnimator).supportsChangeAnimations = false - binding.recyclerView.adapter = adapter + + binding.recyclerView.adapter = adapter.withLoadStateHeaderAndFooter( + header = TimelineLoadStateAdapter { adapter.retry() }, + footer = TimelineLoadStateAdapter { adapter.retry() } + ) } override fun onRefresh() { binding.statusView.hide() + snackbar?.dismiss() adapter.refresh() } override fun onReply(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return super.reply(status.status) } override fun onReblog(reblog: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return - viewModel.reblog(reblog, status) + val statusViewData = adapter.peek(position) ?: return + viewModel.accept(StatusAction.Reblog(reblog, statusViewData)) } override fun onFavourite(favourite: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return - viewModel.favorite(favourite, status) + val statusViewData = adapter.peek(position) ?: return + viewModel.accept(StatusAction.Favourite(favourite, statusViewData)) } override fun onBookmark(bookmark: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return - viewModel.bookmark(bookmark, status) + val statusViewData = adapter.peek(position) ?: return + viewModel.accept(StatusAction.Bookmark(bookmark, statusViewData)) } override fun onVoteInPoll(position: Int, choices: List) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return - viewModel.voteInPoll(choices, status) + val statusViewData = adapter.peek(position) ?: run { + Snackbar.make( + binding.root, + "null at adapter.peek($position)", + Snackbar.LENGTH_INDEFINITE + ).show() + null + } ?: return + val poll = statusViewData.actionable.poll ?: run { + Snackbar.make( + binding.root, + "statusViewData had null poll", + Snackbar.LENGTH_INDEFINITE + ).show() + null + } ?: return + viewModel.accept(StatusAction.VoteInPoll(poll, choices, statusViewData)) } override fun clearWarningAction(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return viewModel.clearWarning(status) } override fun onMore(view: View, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return super.more(status.status, view, position) } override fun onOpenReblog(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return super.openReblog(status.status) } override fun onExpandedChange(expanded: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return viewModel.changeExpanded(expanded, status) } override fun onContentHiddenChange(isShowing: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return viewModel.changeContentShowing(isShowing, status) } override fun onShowReblogs(position: Int) { - val statusId = adapter.peek(position)?.asStatusOrNull()?.id ?: return + val statusId = adapter.peek(position)?.id ?: return val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) (activity as BaseActivity).startActivityWithSlideInAnimation(intent) } override fun onShowFavs(position: Int) { - val statusId = adapter.peek(position)?.asStatusOrNull()?.id ?: return + val statusId = adapter.peek(position)?.id ?: return val intent = newIntent(requireContext(), AccountListActivity.Type.FAVOURITED, statusId) (activity as BaseActivity).startActivityWithSlideInAnimation(intent) } - override fun onLoadMore(position: Int) { - val placeholder = adapter.peek(position)?.asPlaceholderOrNull() ?: return - loadMorePosition = position - statusIdBelowLoadMore = if (position + 1 < adapter.itemCount) adapter.peek(position + 1)?.id else null - viewModel.loadMore(placeholder.id) - } - override fun onContentCollapsedChange(isCollapsed: Boolean, position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return viewModel.changeContentCollapsed(isCollapsed, status) } override fun onViewMedia(position: Int, attachmentIndex: Int, view: View?) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return super.viewMedia( attachmentIndex, AttachmentViewData.list(status.actionable), @@ -486,98 +660,72 @@ class TimelineFragment : } override fun onViewThread(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return super.viewThread(status.actionable.id, status.actionable.url) } override fun onViewTag(tag: String) { - if (viewModel.kind == TimelineViewModel.Kind.TAG && viewModel.tags.size == 1 && - viewModel.tags.contains(tag) - ) { - // If already viewing a tag page, then ignore any request to view that tag again. + val timelineKind = viewModel.timelineKind + + // If already viewing a tag page, then ignore any request to view that tag again. + if (timelineKind is TimelineKind.Tag && timelineKind.tags.contains(tag)) { return } + super.viewTag(tag) } override fun onViewAccount(id: String) { - if (( - viewModel.kind == TimelineViewModel.Kind.USER || - viewModel.kind == TimelineViewModel.Kind.USER_WITH_REPLIES - ) && - viewModel.id == id - ) { - /* If already viewing an account page, then any requests to view that account page - * should be ignored. */ + val timelineKind = viewModel.timelineKind + + // Ignore request to view the account page we're currently viewing + if (timelineKind is TimelineKind.User && timelineKind.id == id) { return } - super.viewAccount(id) - } - private fun onPreferenceChanged(key: String) { - val sharedPreferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - when (key) { - PrefKeys.FAB_HIDE -> { - hideFab = sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false) - } - PrefKeys.MEDIA_PREVIEW_ENABLED -> { - val enabled = accountManager.activeAccount!!.mediaPreviewEnabled - val oldMediaPreviewEnabled = adapter.mediaPreviewEnabled - if (enabled != oldMediaPreviewEnabled) { - adapter.mediaPreviewEnabled = enabled - adapter.notifyItemRangeChanged(0, adapter.itemCount) - } - } - PrefKeys.READING_ORDER -> { - readingOrder = ReadingOrder.from( - sharedPreferences.getString(PrefKeys.READING_ORDER, null) - ) - } - } + super.viewAccount(id) } - private fun handleStatusComposeEvent(status: Status) { - when (kind) { - TimelineViewModel.Kind.HOME, - TimelineViewModel.Kind.PUBLIC_FEDERATED, - TimelineViewModel.Kind.PUBLIC_LOCAL -> adapter.refresh() - TimelineViewModel.Kind.USER, - TimelineViewModel.Kind.USER_WITH_REPLIES -> if (status.account.id == viewModel.id) { + /** + * A status the user has written has either: + * + * - Been successfully posted + * - Been edited by the user + * + * Depending on the timeline kind it may need refreshing to show the new status or the changes + * that have been made to it. + */ + private fun handleStatusSentOrEdit(status: Status) { + when (timelineKind) { + is TimelineKind.User.Pinned -> return + + is TimelineKind.Home, + is TimelineKind.PublicFederated, + is TimelineKind.PublicLocal -> adapter.refresh() + is TimelineKind.User -> if (status.account.id == (timelineKind as TimelineKind.User).id) { adapter.refresh() } - TimelineViewModel.Kind.TAG, - TimelineViewModel.Kind.FAVOURITES, - TimelineViewModel.Kind.LIST, - TimelineViewModel.Kind.BOOKMARKS, - TimelineViewModel.Kind.USER_PINNED -> return + is TimelineKind.Bookmarks, + is TimelineKind.Favourites, + is TimelineKind.Tag, + is TimelineKind.UserList -> return } } public override fun removeItem(position: Int) { - val status = adapter.peek(position)?.asStatusOrNull() ?: return + val status = adapter.peek(position) ?: return viewModel.removeStatusWithId(status.id) } private fun actionButtonPresent(): Boolean { - return viewModel.kind != TimelineViewModel.Kind.TAG && - viewModel.kind != TimelineViewModel.Kind.FAVOURITES && - viewModel.kind != TimelineViewModel.Kind.BOOKMARKS && + return viewModel.timelineKind !is TimelineKind.Tag && + viewModel.timelineKind !is TimelineKind.Favourites && + viewModel.timelineKind !is TimelineKind.Bookmarks && activity is ActionButtonActivity } private var talkBackWasEnabled = false - override fun onPause() { - super.onPause() - (binding.recyclerView.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition()?.let { position -> - if (position != RecyclerView.NO_POSITION) { - adapter.snapshot().getOrNull(position)?.id?.let { statusId -> - viewModel.saveReadingPosition(statusId) - } - } - } - } - override fun onResume() { super.onResume() val a11yManager = @@ -589,68 +737,44 @@ class TimelineFragment : if (talkBackWasEnabled && !wasEnabled) { adapter.notifyItemRangeChanged(0, adapter.itemCount) } - startUpdateTimestamp() } - /** - * Start to update adapter every minute to refresh timestamp - * If setting absoluteTimeView is false - * Auto dispose observable on pause - */ - private fun startUpdateTimestamp() { - val preferences = PreferenceManager.getDefaultSharedPreferences(requireContext()) - val useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false) - if (!useAbsoluteTime) { - Observable.interval(0, 1, TimeUnit.MINUTES) - .observeOn(AndroidSchedulers.mainThread()) - .autoDispose(this, Lifecycle.Event.ON_PAUSE) - .subscribe { - adapter.notifyItemRangeChanged(0, adapter.itemCount, listOf(StatusBaseViewHolder.Key.KEY_CREATED)) - } - } + override fun onPause() { + super.onPause() + + saveVisibleId() + snackbar?.dismiss() } override fun onReselect() { if (isAdded) { binding.recyclerView.layoutManager?.scrollToPosition(0) binding.recyclerView.stopScroll() + // The first item in an ItemSnapshotList may not be at index 0, hence firstOrNull() + saveVisibleId(adapter.snapshot().firstOrNull()?.id) } } override fun refreshContent() { + binding.swipeRefreshLayout.isRefreshing = true onRefresh() } companion object { - private const val TAG = "TimelineF" // logging tag + private const val TAG = "TimelineFragment" // logging tag private const val KIND_ARG = "kind" - private const val ID_ARG = "id" - private const val HASHTAGS_ARG = "hashtags" private const val ARG_ENABLE_SWIPE_TO_REFRESH = "enableSwipeToRefresh" fun newInstance( - kind: TimelineViewModel.Kind, - hashtagOrId: String? = null, + timelineKind: TimelineKind, enableSwipeToRefresh: Boolean = true ): TimelineFragment { val fragment = TimelineFragment() - val arguments = Bundle(3) - arguments.putString(KIND_ARG, kind.name) - arguments.putString(ID_ARG, hashtagOrId) + val arguments = Bundle(2) + arguments.putParcelable(KIND_ARG, timelineKind) arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, enableSwipeToRefresh) fragment.arguments = arguments return fragment } - - @JvmStatic - fun newHashtagInstance(hashtags: List): TimelineFragment { - val fragment = TimelineFragment() - val arguments = Bundle(3) - arguments.putString(KIND_ARG, TimelineViewModel.Kind.TAG.name) - arguments.putStringArrayList(HASHTAGS_ARG, ArrayList(hashtags)) - arguments.putBoolean(ARG_ENABLE_SWIPE_TO_REFRESH, true) - fragment.arguments = arguments - return fragment - } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineKind.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineKind.kt new file mode 100644 index 0000000000..0bae170736 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineKind.kt @@ -0,0 +1,46 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +/** A timeline's type. Hold's data necessary to display that timeline. */ +@Parcelize +sealed class TimelineKind : Parcelable { + object Home : TimelineKind() + object PublicFederated : TimelineKind() + object PublicLocal : TimelineKind() + data class Tag(val tags: List) : TimelineKind() + + /** Any timeline showing statuses from a single user */ + @Parcelize + sealed class User(open val id: String) : TimelineKind() { + /** Timeline showing just the user's statuses (no replies) */ + data class Posts(override val id: String) : User(id) + + /** Timeline showing the user's pinned statuses */ + data class Pinned(override val id: String) : User(id) + + /** Timeline showing the user's top-level statuses and replies they have made */ + data class Replies(override val id: String) : User(id) + } + object Favourites : TimelineKind() + object Bookmarks : TimelineKind() + data class UserList(val id: String, val title: String) : TimelineKind() +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineLoadStateAdapter.kt similarity index 69% rename from app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt rename to app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineLoadStateAdapter.kt index 0a281ccd9c..d7adf23d0c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineLoadStateAdapter.kt @@ -15,24 +15,24 @@ * see . */ -package com.keylesspalace.tusky.components.notifications +package com.keylesspalace.tusky.components.timeline import android.view.ViewGroup import androidx.paging.LoadState import androidx.paging.LoadStateAdapter -/** Show load state and retry options when loading notifications */ -class NotificationsLoadStateAdapter( +/** Show load state and retry options when loading timelines */ +class TimelineLoadStateAdapter( private val retry: () -> Unit -) : LoadStateAdapter() { +) : LoadStateAdapter() { override fun onCreateViewHolder( parent: ViewGroup, loadState: LoadState - ): NotificationsLoadStateViewHolder { - return NotificationsLoadStateViewHolder.create(parent, retry) + ): TimelineLoadStateViewHolder { + return TimelineLoadStateViewHolder.create(parent, retry) } - override fun onBindViewHolder(holder: NotificationsLoadStateViewHolder, loadState: LoadState) { + override fun onBindViewHolder(holder: TimelineLoadStateViewHolder, loadState: LoadState) { holder.bind(loadState) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineLoadStateViewHolder.kt similarity index 82% rename from app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt rename to app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineLoadStateViewHolder.kt index f3c006d329..3b7dcc34a2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/notifications/NotificationsLoadStateViewHolder.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineLoadStateViewHolder.kt @@ -15,7 +15,7 @@ * see . */ -package com.keylesspalace.tusky.components.notifications +package com.keylesspalace.tusky.components.timeline import android.view.LayoutInflater import android.view.ViewGroup @@ -23,7 +23,7 @@ import androidx.core.view.isVisible import androidx.paging.LoadState import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.databinding.ItemNotificationsLoadStateFooterViewBinding +import com.keylesspalace.tusky.databinding.ItemLoadStateFooterViewBinding import java.net.SocketTimeoutException /** @@ -36,8 +36,8 @@ import java.net.SocketTimeoutException * * @param retry function to invoke if the user clicks the "retry" button */ -class NotificationsLoadStateViewHolder( - private val binding: ItemNotificationsLoadStateFooterViewBinding, +class TimelineLoadStateViewHolder( + private val binding: ItemLoadStateFooterViewBinding, retry: () -> Unit ) : RecyclerView.ViewHolder(binding.root) { init { @@ -61,13 +61,13 @@ class NotificationsLoadStateViewHolder( } companion object { - fun create(parent: ViewGroup, retry: () -> Unit): NotificationsLoadStateViewHolder { - val binding = ItemNotificationsLoadStateFooterViewBinding.inflate( + fun create(parent: ViewGroup, retry: () -> Unit): TimelineLoadStateViewHolder { + val binding = ItemLoadStateFooterViewBinding.inflate( LayoutInflater.from(parent.context), parent, false ) - return NotificationsLoadStateViewHolder(binding, retry) + return TimelineLoadStateViewHolder(binding, retry) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt index a232989b3a..60df0e8580 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelinePagingAdapter.kt @@ -21,7 +21,6 @@ import androidx.paging.PagingDataAdapter import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import com.keylesspalace.tusky.R -import com.keylesspalace.tusky.adapter.PlaceholderViewHolder import com.keylesspalace.tusky.adapter.StatusBaseViewHolder import com.keylesspalace.tusky.adapter.StatusViewHolder import com.keylesspalace.tusky.entity.Filter @@ -30,34 +29,19 @@ import com.keylesspalace.tusky.util.StatusDisplayOptions import com.keylesspalace.tusky.viewdata.StatusViewData class TimelinePagingAdapter( - private var statusDisplayOptions: StatusDisplayOptions, - private val statusListener: StatusActionListener + private val statusListener: StatusActionListener, + var statusDisplayOptions: StatusDisplayOptions ) : PagingDataAdapter(TimelineDifferCallback) { - - var mediaPreviewEnabled: Boolean - get() = statusDisplayOptions.mediaPreviewEnabled - set(mediaPreviewEnabled) { - statusDisplayOptions = statusDisplayOptions.copy( - mediaPreviewEnabled = mediaPreviewEnabled - ) - } - - init { - stateRestorationPolicy = StateRestorationPolicy.PREVENT_WHEN_EMPTY - } - override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): RecyclerView.ViewHolder { val inflater = LayoutInflater.from(viewGroup.context) return when (viewType) { VIEW_TYPE_STATUS_FILTERED -> { StatusViewHolder(inflater.inflate(R.layout.item_status_wrapper, viewGroup, false)) } - VIEW_TYPE_PLACEHOLDER -> { - PlaceholderViewHolder(inflater.inflate(R.layout.item_status_placeholder, viewGroup, false)) - } - else -> { + VIEW_TYPE_STATUS -> { StatusViewHolder(inflater.inflate(R.layout.item_status, viewGroup, false)) } + else -> return object : RecyclerView.ViewHolder(inflater.inflate(R.layout.item_placeholder, viewGroup, false)) {} } } @@ -78,26 +62,19 @@ class TimelinePagingAdapter( position: Int, payloads: List<*>? ) { - val status = getItem(position) - if (status is StatusViewData.Placeholder) { - val holder = viewHolder as PlaceholderViewHolder - holder.setup(statusListener, status.isLoading) - } else if (status is StatusViewData.Concrete) { - val holder = viewHolder as StatusViewHolder - holder.setupWithStatus( - status, + getItem(position)?.let { + (viewHolder as StatusViewHolder).setupWithStatus( + it, statusListener, statusDisplayOptions, - if (payloads != null && payloads.isNotEmpty()) payloads[0] else null + payloads?.getOrNull(0) ) } } override fun getItemViewType(position: Int): Int { - val viewData = getItem(position) - return if (viewData is StatusViewData.Placeholder) { - VIEW_TYPE_PLACEHOLDER - } else if (viewData?.filterAction == Filter.Action.WARN) { + val viewData = getItem(position) ?: return VIEW_TYPE_PLACEHOLDER + return if (viewData.filterAction == Filter.Action.WARN) { VIEW_TYPE_STATUS_FILTERED } else { VIEW_TYPE_STATUS @@ -105,9 +82,11 @@ class TimelinePagingAdapter( } companion object { + @Suppress("unused") + private const val TAG = "TimelinePagingAdapter" private const val VIEW_TYPE_STATUS = 0 private const val VIEW_TYPE_STATUS_FILTERED = 1 - private const val VIEW_TYPE_PLACEHOLDER = 2 + private const val VIEW_TYPE_PLACEHOLDER = -1 val TimelineDifferCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( @@ -121,7 +100,7 @@ class TimelinePagingAdapter( oldItem: StatusViewData, newItem: StatusViewData ): Boolean { - return false // Items are different always. It allows to refresh timestamp on every view holder update + return oldItem == newItem } override fun getChangePayload( diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt index bdb3d64d7c..e9f952c682 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/TimelineTypeMappers.kt @@ -15,7 +15,6 @@ package com.keylesspalace.tusky.components.timeline -import android.util.Log import com.google.gson.Gson import com.google.gson.reflect.TypeToken import com.keylesspalace.tusky.db.TimelineAccountEntity @@ -33,11 +32,6 @@ import java.util.Date private const val TAG = "TimelineTypeMappers" -data class Placeholder( - val id: String, - val loading: Boolean -) - private val attachmentArrayListType = object : TypeToken>() {}.type private val emojisListType = object : TypeToken>() {}.type private val mentionListType = object : TypeToken>() {}.type @@ -71,45 +65,6 @@ fun TimelineAccountEntity.toAccount(gson: Gson): TimelineAccount { ) } -fun Placeholder.toEntity(timelineUserId: Long): TimelineStatusEntity { - return TimelineStatusEntity( - serverId = this.id, - url = null, - timelineUserId = timelineUserId, - authorServerId = null, - inReplyToId = null, - inReplyToAccountId = null, - content = null, - createdAt = 0L, - editedAt = 0L, - emojis = null, - reblogsCount = 0, - favouritesCount = 0, - reblogged = false, - favourited = false, - bookmarked = false, - sensitive = false, - spoilerText = "", - visibility = Status.Visibility.UNKNOWN, - attachments = null, - mentions = null, - tags = null, - application = null, - reblogServerId = null, - reblogAccountId = null, - poll = null, - muted = false, - expanded = loading, - contentCollapsed = false, - contentShowing = false, - pinned = false, - card = null, - repliesCount = 0, - language = null, - filtered = null - ) -} - fun Status.toEntity( timelineUserId: Long, gson: Gson, @@ -156,11 +111,6 @@ fun Status.toEntity( } fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false): StatusViewData { - if (this.account == null) { - Log.d(TAG, "Constructing Placeholder(${this.status.serverId}, ${this.status.expanded})") - return StatusViewData.Placeholder(this.status.serverId, this.status.expanded) - } - val attachments: ArrayList = gson.fromJson(status.attachments, attachmentArrayListType) ?: arrayListOf() val mentions: List = gson.fromJson(status.mentions, mentionListType) ?: emptyList() val tags: List? = gson.fromJson(status.tags, tagListType) @@ -267,7 +217,7 @@ fun TimelineStatusWithAccount.toViewData(gson: Gson, isDetailed: Boolean = false filtered = status.filtered ) } - return StatusViewData.Concrete( + return StatusViewData( status = status, isExpanded = this.status.expanded, isShowingContent = this.status.contentShowing, diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt index 89afefecda..a6e1697eb2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineRemoteMediator.kt @@ -11,38 +11,44 @@ * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ + * see . + */ package com.keylesspalace.tusky.components.timeline.viewmodel +import android.util.Log +import androidx.annotation.VisibleForTesting import androidx.paging.ExperimentalPagingApi +import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.room.withTransaction import com.google.gson.Gson -import com.keylesspalace.tusky.components.timeline.Placeholder import com.keylesspalace.tusky.components.timeline.toEntity -import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase +import com.keylesspalace.tusky.db.RemoteKeyEntity +import com.keylesspalace.tusky.db.RemoteKeyKind import com.keylesspalace.tusky.db.TimelineStatusEntity import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.Links import com.keylesspalace.tusky.network.MastodonApi import retrofit2.HttpException +import java.io.IOException @OptIn(ExperimentalPagingApi::class) class CachedTimelineRemoteMediator( - accountManager: AccountManager, private val api: MastodonApi, + accountManager: AccountManager, + private val factory: InvalidatingPagingSourceFactory, private val db: AppDatabase, private val gson: Gson ) : RemoteMediator() { - private var initialRefresh = false - private val timelineDao = db.timelineDao() + private val remoteKeyDao = db.remoteKeyDao() private val activeAccount = accountManager.activeAccount!! override suspend fun load( @@ -53,72 +59,109 @@ class CachedTimelineRemoteMediator( return MediatorResult.Success(endOfPaginationReached = true) } - try { - var dbEmpty = false + Log.d(TAG, "load(), LoadType = $loadType") - val topPlaceholderId = if (loadType == LoadType.REFRESH) { - timelineDao.getTopPlaceholderId(activeAccount.id) - } else { - null // don't execute the query if it is not needed - } - - if (!initialRefresh && loadType == LoadType.REFRESH) { - val topId = timelineDao.getTopId(activeAccount.id) - topId?.let { cachedTopId -> - val statusResponse = api.homeTimeline( - maxId = cachedTopId, - sinceId = topPlaceholderId, // so already existing placeholders don't get accidentally overwritten - limit = state.config.pageSize - ) - - val statuses = statusResponse.body() - if (statusResponse.isSuccessful && statuses != null) { - db.withTransaction { - replaceStatusRange(statuses, state) - } - } - } - initialRefresh = true - dbEmpty = topId == null - } - - val statusResponse = when (loadType) { + return try { + val response = when (loadType) { LoadType.REFRESH -> { - api.homeTimeline(sinceId = topPlaceholderId, limit = state.config.pageSize) - } - LoadType.PREPEND -> { - return MediatorResult.Success(endOfPaginationReached = true) + val key = state.anchorPosition?.let { state.closestItemToPosition(it) }?.status?.serverId + Log.d(TAG, "Loading from item close to current position: $key") + api.homeTimeline(minId = key, limit = state.config.pageSize) } LoadType.APPEND -> { - val maxId = state.pages.findLast { it.data.isNotEmpty() }?.data?.lastOrNull()?.status?.serverId - api.homeTimeline(maxId = maxId, limit = state.config.pageSize) + val rke = db.withTransaction { + remoteKeyDao.remoteKeyForKind( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.NEXT + ) + } ?: return MediatorResult.Success(endOfPaginationReached = true) + Log.d(TAG, "Loading from remoteKey: $rke") + api.homeTimeline(maxId = rke.key, limit = state.config.pageSize) + } + LoadType.PREPEND -> { + val rke = db.withTransaction { + remoteKeyDao.remoteKeyForKind( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.PREV + ) + } ?: return MediatorResult.Success(endOfPaginationReached = true) + Log.d(TAG, "Loading from remoteKey: $rke") + api.homeTimeline(minId = rke.key, limit = state.config.pageSize) } } - val statuses = statusResponse.body() - if (!statusResponse.isSuccessful || statuses == null) { - return MediatorResult.Error(HttpException(statusResponse)) + val statuses = response.body() + if (!response.isSuccessful || statuses == null) { + return MediatorResult.Error(HttpException(response)) } + Log.d(TAG, "${statuses.size} - # statuses loaded") + + // This request succeeded with no new data, and pagination ends (unless this is a + // REFRESH, which must always set endOfPaginationReached to false). + if (statuses.isEmpty()) { + factory.invalidate() + return MediatorResult.Success(endOfPaginationReached = loadType != LoadType.REFRESH) + } + + Log.d(TAG, " ${statuses.first().id}..${statuses.last().id}") + + val links = Links.from(response.headers()["link"]) db.withTransaction { - val overlappedStatuses = replaceStatusRange(statuses, state) - - /* In case we loaded a whole page and there was no overlap with existing statuses, - we insert a placeholder because there might be even more unknown statuses */ - if (loadType == LoadType.REFRESH && overlappedStatuses == 0 && statuses.size == state.config.pageSize && !dbEmpty) { - /* This overrides the last of the newly loaded statuses with a placeholder - to guarantee the placeholder has an id that exists on the server as not all - servers handle client generated ids as expected */ - timelineDao.insertStatus( - Placeholder(statuses.last().id, loading = false).toEntity(activeAccount.id) - ) + when (loadType) { + LoadType.REFRESH -> { + remoteKeyDao.upsert( + RemoteKeyEntity( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.NEXT, + links.next + ) + ) + remoteKeyDao.upsert( + RemoteKeyEntity( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.PREV, + links.prev + ) + ) + } + // links.prev may be null if there are no statuses, only set if non-null, + // https://github.com/mastodon/mastodon/issues/25760 + LoadType.PREPEND -> links.prev?.let { prev -> + remoteKeyDao.upsert( + RemoteKeyEntity( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.PREV, + prev + ) + ) + } + // links.next may be null if there are no statuses, only set if non-null, + // https://github.com/mastodon/mastodon/issues/25760 + LoadType.APPEND -> links.next?.let { next -> + remoteKeyDao.upsert( + RemoteKeyEntity( + activeAccount.id, + TIMELINE_ID, + RemoteKeyKind.NEXT, + next + ) + ) + } } + replaceStatusRange(statuses, state) } - return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) - } catch (e: Exception) { - return ifExpected(e) { - MediatorResult.Error(e) - } + + return MediatorResult.Success(endOfPaginationReached = false) + } catch (e: IOException) { + MediatorResult.Error(e) + } catch (e: HttpException) { + MediatorResult.Error(e) } } @@ -152,14 +195,7 @@ class CachedTimelineRemoteMediator( if (oldStatus != null) break } - // The "expanded" property for Placeholders determines whether or not they are - // in the "loading" state, and should not be affected by the account's - // "alwaysOpenSpoiler" preference - val expanded = if (oldStatus?.isPlaceholder == true) { - oldStatus.expanded - } else { - oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler - } + val expanded = oldStatus?.expanded ?: activeAccount.alwaysOpenSpoiler val contentShowing = oldStatus?.contentShowing ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive val contentCollapsed = oldStatus?.contentCollapsed ?: true @@ -175,4 +211,11 @@ class CachedTimelineRemoteMediator( } return overlappedStatuses } + + companion object { + private const val TAG = "CachedTimelineRemoteMediator" + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + const val TIMELINE_ID = "HOME" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt index d33bf7e41f..1957a27962 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/CachedTimelineViewModel.kt @@ -18,131 +18,124 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.content.SharedPreferences import android.util.Log import androidx.lifecycle.viewModelScope -import androidx.paging.ExperimentalPagingApi -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.PagingSource +import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.filter import androidx.paging.map -import androidx.room.withTransaction import com.google.gson.Gson import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FavoriteEvent import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.ReblogEvent -import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.NEWEST_FIRST -import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder.OLDEST_FIRST -import com.keylesspalace.tusky.components.timeline.Placeholder -import com.keylesspalace.tusky.components.timeline.toEntity +import com.keylesspalace.tusky.components.timeline.CachedTimelineRepository +import com.keylesspalace.tusky.components.timeline.FiltersRepository +import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.components.timeline.toViewData -import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.db.AppDatabase -import com.keylesspalace.tusky.db.TimelineStatusWithAccount import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll import com.keylesspalace.tusky.network.FilterModel -import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.settings.AccountPreferenceDataStore import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.EmptyPagingSource import com.keylesspalace.tusky.viewdata.StatusViewData -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asExecutor -import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import kotlinx.coroutines.launch -import retrofit2.HttpException import javax.inject.Inject /** * TimelineViewModel that caches all statuses in a local database */ class CachedTimelineViewModel @Inject constructor( + private val repository: CachedTimelineRepository, timelineCases: TimelineCases, - private val api: MastodonApi, eventHub: EventHub, + filtersRepository: FiltersRepository, accountManager: AccountManager, - sharedPreferences: SharedPreferences, + preferences: SharedPreferences, + accountPreferenceDataStore: AccountPreferenceDataStore, filterModel: FilterModel, - private val db: AppDatabase, private val gson: Gson ) : TimelineViewModel( timelineCases, - api, eventHub, + filtersRepository, accountManager, - sharedPreferences, + preferences, + accountPreferenceDataStore, filterModel ) { - private var currentPagingSource: PagingSource? = null + override lateinit var statuses: Flow> - @OptIn(ExperimentalPagingApi::class) - override val statuses = Pager( - config = PagingConfig(pageSize = LOAD_AT_ONCE), - remoteMediator = CachedTimelineRemoteMediator(accountManager, api, db, gson), - pagingSourceFactory = { - val activeAccount = accountManager.activeAccount - if (activeAccount == null) { - EmptyPagingSource() - } else { - db.timelineDao().getStatuses(activeAccount.id) - }.also { newPagingSource -> - this.currentPagingSource = newPagingSource - } - } - ).flow - .map { pagingData -> - pagingData.map(Dispatchers.Default.asExecutor()) { timelineStatus -> - timelineStatus.toViewData(gson) - }.filter(Dispatchers.Default.asExecutor()) { statusViewData -> - shouldFilterStatus(statusViewData) != Filter.Action.HIDE + init { + readingPositionId = activeAccount.lastVisibleHomeTimelineStatusId + } + + @OptIn(ExperimentalCoroutinesApi::class) + override fun init(timelineKind: TimelineKind) { + super.init(timelineKind) + statuses = merge(getUiPrefs(), reload) + .flatMapLatest { + getStatuses(timelineKind, initialKey = getInitialKey()) + }.cachedIn(viewModelScope) + } + + /** @return Flow of statuses that make up the timeline of [kind] */ + private fun getStatuses( + kind: TimelineKind, + initialKey: String? = null + ): Flow> { + Log.d(TAG, "getStatuses: kind: $kind, initialKey: $initialKey") + return repository.getStatusStream(kind = kind, initialKey = initialKey) + .map { pagingData -> + pagingData + .map { it.toViewData(gson) } + .filter { shouldFilterStatus(it) != Filter.Action.HIDE } } - } - .flowOn(Dispatchers.Default) - .cachedIn(viewModelScope) + } - override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { + override fun updatePoll(newPoll: Poll, status: StatusViewData) { // handled by CacheUpdater } - override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { + override fun changeExpanded(expanded: Boolean, status: StatusViewData) { viewModelScope.launch { - db.timelineDao().setExpanded(accountManager.activeAccount!!.id, status.id, expanded) + repository.setExpanded(expanded, status.id) } } - override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { + override fun changeContentShowing(isShowing: Boolean, status: StatusViewData) { viewModelScope.launch { - db.timelineDao() - .setContentShowing(accountManager.activeAccount!!.id, status.id, isShowing) + repository.setContentShowing(isShowing, status.id) } } - override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { + override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) { viewModelScope.launch { - db.timelineDao() - .setContentCollapsed(accountManager.activeAccount!!.id, status.id, isCollapsed) + repository.setContentCollapsed(isCollapsed, status.id) } } override fun removeAllByAccountId(accountId: String) { viewModelScope.launch { - db.timelineDao().removeAllByUser(accountManager.activeAccount!!.id, accountId) + repository.removeAllByAccountId(accountId) } } override fun removeAllByInstance(instance: String) { viewModelScope.launch { - db.timelineDao().deleteAllFromInstance(accountManager.activeAccount!!.id, instance) + repository.removeAllByInstance(instance) } } - override fun clearWarning(status: StatusViewData.Concrete) { + override fun clearWarning(status: StatusViewData) { viewModelScope.launch { - db.timelineDao().clearWarning(accountManager.activeAccount!!.id, status.actionableId) + repository.clearStatusWarning(status.actionableId) } } @@ -150,109 +143,6 @@ class CachedTimelineViewModel @Inject constructor( // handled by CacheUpdater } - override fun loadMore(placeholderId: String) { - viewModelScope.launch { - try { - val timelineDao = db.timelineDao() - - val activeAccount = accountManager.activeAccount!! - - timelineDao.insertStatus( - Placeholder(placeholderId, loading = true).toEntity( - activeAccount.id - ) - ) - - val response = db.withTransaction { - val idAbovePlaceholder = timelineDao.getIdAbove(activeAccount.id, placeholderId) - val idBelowPlaceholder = timelineDao.getIdBelow(activeAccount.id, placeholderId) - when (readingOrder) { - // Using minId, loads up to LOAD_AT_ONCE statuses with IDs immediately - // after minId and no larger than maxId - OLDEST_FIRST -> api.homeTimeline( - maxId = idAbovePlaceholder, - minId = idBelowPlaceholder, - limit = LOAD_AT_ONCE - ) - // Using sinceId, loads up to LOAD_AT_ONCE statuses immediately before - // maxId, and no smaller than minId. - NEWEST_FIRST -> api.homeTimeline( - maxId = idAbovePlaceholder, - sinceId = idBelowPlaceholder, - limit = LOAD_AT_ONCE - ) - } - } - - val statuses = response.body() - if (!response.isSuccessful || statuses == null) { - loadMoreFailed(placeholderId, HttpException(response)) - return@launch - } - - db.withTransaction { - timelineDao.delete(activeAccount.id, placeholderId) - - val overlappedStatuses = if (statuses.isNotEmpty()) { - timelineDao.deleteRange( - activeAccount.id, - statuses.last().id, - statuses.first().id - ) - } else { - 0 - } - - for (status in statuses) { - timelineDao.insertAccount(status.account.toEntity(activeAccount.id, gson)) - status.reblog?.account?.toEntity(activeAccount.id, gson) - ?.let { rebloggedAccount -> - timelineDao.insertAccount(rebloggedAccount) - } - timelineDao.insertStatus( - status.toEntity( - timelineUserId = activeAccount.id, - gson = gson, - expanded = activeAccount.alwaysOpenSpoiler, - contentShowing = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, - contentCollapsed = true - ) - ) - } - - /* In case we loaded a whole page and there was no overlap with existing statuses, - we insert a placeholder because there might be even more unknown statuses */ - if (overlappedStatuses == 0 && statuses.size == LOAD_AT_ONCE) { - /* This overrides the first/last of the newly loaded statuses with a placeholder - to guarantee the placeholder has an id that exists on the server as not all - servers handle client generated ids as expected */ - val idToConvert = when (readingOrder) { - OLDEST_FIRST -> statuses.first().id - NEWEST_FIRST -> statuses.last().id - } - timelineDao.insertStatus( - Placeholder( - idToConvert, - loading = false - ).toEntity(activeAccount.id) - ) - } - } - } catch (e: Exception) { - ifExpected(e) { - loadMoreFailed(placeholderId, e) - } - } - } - } - - private suspend fun loadMoreFailed(placeholderId: String, e: Exception) { - Log.w("CachedTimelineVM", "failed loading statuses", e) - val activeAccount = accountManager.activeAccount!! - db.timelineDao() - .insertStatus(Placeholder(placeholderId, loading = false).toEntity(activeAccount.id)) - } - override fun handleReblogEvent(reblogEvent: ReblogEvent) { // handled by CacheUpdater } @@ -269,30 +159,25 @@ class CachedTimelineViewModel @Inject constructor( // handled by CacheUpdater } - override fun fullReload() { + override fun reloadKeepingReadingPosition() { + super.reloadKeepingReadingPosition() viewModelScope.launch { - val activeAccount = accountManager.activeAccount!! - db.timelineDao().removeAll(activeAccount.id) + repository.clearAndReload() } } - override fun saveReadingPosition(statusId: String) { - accountManager.activeAccount?.let { account -> - Log.d(TAG, "Saving position at: $statusId") - account.lastVisibleHomeTimelineStatusId = statusId - accountManager.saveAccount(account) + override fun reloadFromNewest() { + super.reloadFromNewest() + viewModelScope.launch { + repository.clearAndReloadFromNewest() } } override suspend fun invalidate() { - // invalidating when we don't have statuses yet can cause empty timelines because it cancels the network load - if (db.timelineDao().getStatusCount(accountManager.activeAccount!!.id) > 0) { - currentPagingSource?.invalidate() - } + repository.invalidate() } companion object { private const val TAG = "CachedTimelineViewModel" - private const val MAX_STATUSES_IN_CACHE = 1000 } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt index d1c9ce24e3..e46f2189ed 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelinePagingSource.kt @@ -15,22 +15,140 @@ package com.keylesspalace.tusky.components.timeline.viewmodel +import android.util.Log import androidx.paging.PagingSource +import androidx.paging.PagingSource.LoadResult import androidx.paging.PagingState -import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.entity.Status +import javax.inject.Inject -class NetworkTimelinePagingSource( - private val viewModel: NetworkTimelineViewModel -) : PagingSource() { +private val INVALID = LoadResult.Invalid() - override fun getRefreshKey(state: PagingState): String? = null +/** [PagingSource] for Mastodon Status, identified by the Status ID */ +class NetworkTimelinePagingSource @Inject constructor( + private val pageCache: PageCache +) : PagingSource() { - override suspend fun load(params: LoadParams): LoadResult { - return if (params is LoadParams.Refresh) { - val list = viewModel.statusData.toList() - LoadResult.Page(list, null, viewModel.nextKey) + override suspend fun load(params: LoadParams): LoadResult { + Log.d(TAG, "- load(), type = ${params.javaClass.simpleName}, key = ${params.key}") + pageCache.debug() + + val page = synchronized(pageCache) { + if (pageCache.isEmpty()) { + return@synchronized null + } + + when (params) { + is LoadParams.Refresh -> { + // Return the page that contains the given key, or the most recent page if + // the key isn't in the cache. + params.key?.let { key -> + pageCache.floorEntry(key)?.value + } ?: pageCache.lastEntry()?.value + } + // Loading previous / next pages (`Prepend` or `Append`) is a little complicated. + // + // Append and Prepend requests have a `params.key` that corresponds to the previous + // or next page. For some timeline types those keys have the same form as the + // item keys, and are valid item keys. + // + // But for some timeline types they are completely different. + // + // For example, bookmarks might have item keys that look like 110542553707722778 + // but prevKey / nextKey values that look like 1480606 / 1229303. + // + // There's no guarantee that the `nextKey` value for one page matches the `prevKey` + // value of the page immediately before it. + // + // E.g., suppose `pages` has the following entries (older entries have lower page + // indices). + // + // .--- page index + // | .-- ID of last item (key in `pageCache`) + // v V + // 0: k: 109934818460629189, prevKey: 995916, nextKey: 941865 + // 1: k: 110033940961955385, prevKey: 1073324, nextKey: 997376 + // 2: k: 110239564017438509, prevKey: 1224838, nextKey: 1073352 + // 3: k: 110542553707722778, prevKey: 1480606, nextKey: 1229303 + // + // And the request is `LoadParams.Append` with `params.key` == 1073352. This means + // "fetch the page *before* the page that has `nextKey` == 1073352". + // + // The desired page has index 1. But that can't be found directly, because although + // the page after it (index 2) points back to it with the `nextKey` value, the page + // at index 1 **does not** have a `prevKey` value of 1073352. There can be gaps in + // the `prevKey` / `nextKey` chain -- I assume this is a Mastodon implementation + // detail. + // + // Further, we can't assume anything about the structure of the keys. + // + // To find the correct page for Append we must: + // + // 1. Find the page that has a `nextKey` value that matches `params.key` (page 2) + // 2. Get that page's key ("110239564017438509") + // 3. Return the page with the key that is immediately lower than the key from step 2 + // + // The approach for Prepend is the same, except it is `prevKey` that is checked. + is LoadParams.Append -> { + pageCache.firstNotNullOfOrNull { entry -> entry.takeIf { it.value.nextKey == params.key }?.value } + ?.let { page -> pageCache.lowerEntry(page.data.last().id)?.value } + } + is LoadParams.Prepend -> { + pageCache.firstNotNullOfOrNull { entry -> entry.takeIf { it.value.prevKey == params.key }?.value } + ?.let { page -> pageCache.higherEntry(page.data.last().id)?.value } + } + } + } + + if (page == null) { + Log.d(TAG, " Returning empty page") } else { - LoadResult.Page(emptyList(), null, null) + Log.d(TAG, " Returning full page:") + Log.d(TAG, " $page") + } + + // Bail if this paging source has already been invalidated. If you do not do this there + // is a lot of spurious animation, especially during the initial load, as multiple pages + // are loaded and the paging source is repeatedly invalidated. + if (invalid) { + Log.d(TAG, "Invalidated, returning LoadResult.Invalid") + return INVALID + } + + // Calculate itemsBefore and itemsAfter values to include in the returned Page. + // If you do not do this (and this is not documented anywhere) then the anchorPosition + // in the PagingState (used in getRefreshKey) is bogus, and refreshing the list can + // result in large jumps in the user's position. + // + // The items are calculated relative to the local cache, not the remote data source. + val itemsBefore = page?.let { + pageCache.tailMap(it.data.first().id).values.fold(0) { sum, p -> sum + p.data.size } + } ?: 0 + val itemsAfter = page?.let { + pageCache.headMap(it.data.first().id).values.fold(0) { sum, p -> sum + p.data.size } + } ?: 0 + + return LoadResult.Page( + page?.data ?: emptyList(), + nextKey = page?.nextKey, + prevKey = page?.prevKey, + itemsAfter = itemsAfter, + itemsBefore = itemsBefore + ) + } + + override fun getRefreshKey(state: PagingState): String? { + val refreshKey = state.anchorPosition?.let { + state.closestItemToPosition(it)?.id + } ?: pageCache.firstEntry()?.value?.data?.let { + it.getOrNull(it.size / 2)?.id } + + Log.d(TAG, "- getRefreshKey(), state.anchorPosition = ${state.anchorPosition}, return $refreshKey") + return refreshKey + } + + companion object { + private const val TAG = "NetworkTimelinePagingSource" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt index 98da15beaa..75e9f28390 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineRemoteMediator.kt @@ -1,4 +1,5 @@ -/* Copyright 2021 Tusky Contributors +/* + * Copyright 2023 Tusky Contributors * * This file is a part of Tusky. * @@ -11,103 +12,163 @@ * Public License for more details. * * You should have received a copy of the GNU General Public License along with Tusky; if not, - * see . */ + * see . + */ package com.keylesspalace.tusky.components.timeline.viewmodel +import android.util.Log import androidx.paging.ExperimentalPagingApi +import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.LoadType import androidx.paging.PagingState import androidx.paging.RemoteMediator -import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.util.HttpHeaderLink -import com.keylesspalace.tusky.util.toViewData -import com.keylesspalace.tusky.viewdata.StatusViewData +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.MastodonApi +import kotlinx.coroutines.CoroutineScope import retrofit2.HttpException +import retrofit2.Response +import java.io.IOException +/** Remote mediator for accessing timelines that are not backed by the database. */ @OptIn(ExperimentalPagingApi::class) class NetworkTimelineRemoteMediator( - private val accountManager: AccountManager, - private val viewModel: NetworkTimelineViewModel -) : RemoteMediator() { - - override suspend fun load( - loadType: LoadType, - state: PagingState - ): MediatorResult { - try { - val statusResponse = when (loadType) { + private val viewModelScope: CoroutineScope, + private val api: MastodonApi, + accountManager: AccountManager, + private val factory: InvalidatingPagingSourceFactory, + private val pageCache: PageCache, + private val timelineKind: TimelineKind +) : RemoteMediator() { + + private val activeAccount = accountManager.activeAccount!! + + override suspend fun load(loadType: LoadType, state: PagingState): MediatorResult { + if (!activeAccount.isLoggedIn()) { + return MediatorResult.Success(endOfPaginationReached = true) + } + + return try { + val key = when (loadType) { LoadType.REFRESH -> { - viewModel.fetchStatusesForKind(null, null, limit = state.config.pageSize) - } - LoadType.PREPEND -> { - return MediatorResult.Success(endOfPaginationReached = true) + // Find the closest page to the current position + val itemKey = state.anchorPosition?.let { state.closestItemToPosition(it) }?.id + itemKey?.let { ik -> + val pageContainingItem = pageCache.floorEntry(ik) + ?: throw java.lang.IllegalStateException("$itemKey not found in the pageCache page") + + // Double check the item appears in the page + if (BuildConfig.DEBUG) { + pageContainingItem.value.data.find { it.id == itemKey } + ?: throw java.lang.IllegalStateException("$itemKey not found in returned page") + } + + // The desired key is the prevKey of the page immediately before this one + pageCache.lowerEntry(pageContainingItem.value.data.last().id)?.value?.prevKey + } } LoadType.APPEND -> { - val maxId = viewModel.nextKey - if (maxId != null) { - viewModel.fetchStatusesForKind(maxId, null, limit = state.config.pageSize) - } else { - return MediatorResult.Success(endOfPaginationReached = true) - } + pageCache.firstEntry()?.value?.nextKey ?: return MediatorResult.Success(endOfPaginationReached = true) + } + LoadType.PREPEND -> { + pageCache.lastEntry()?.value?.prevKey ?: return MediatorResult.Success(endOfPaginationReached = true) } } - val statuses = statusResponse.body() - if (!statusResponse.isSuccessful || statuses == null) { - return MediatorResult.Error(HttpException(statusResponse)) - } - - val activeAccount = accountManager.activeAccount!! - - val data = statuses.map { status -> - - val oldStatus = viewModel.statusData.find { s -> - s.asStatusOrNull()?.id == status.id - }?.asStatusOrNull() + Log.d(TAG, "- load(), type = $loadType, key = $key") - val contentShowing = oldStatus?.isShowingContent ?: activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive - val expanded = oldStatus?.isExpanded ?: activeAccount.alwaysOpenSpoiler - val contentCollapsed = oldStatus?.isCollapsed ?: true + val response = fetchStatusPageByKind(loadType, key, state.config.initialLoadSize) + val page = Page.tryFrom(response).getOrElse { return MediatorResult.Error(it) } - status.toViewData( - isShowingContent = contentShowing, - isExpanded = expanded, - isCollapsed = contentCollapsed - ) - } - - if (loadType == LoadType.REFRESH && viewModel.statusData.isNotEmpty()) { - val insertPlaceholder = if (statuses.isNotEmpty()) { - !viewModel.statusData.removeAll { statusViewData -> - statuses.any { status -> status.id == statusViewData.asStatusOrNull()?.id } + val endOfPaginationReached = page.data.isEmpty() + if (!endOfPaginationReached) { + synchronized(pageCache) { + if (loadType == LoadType.REFRESH) { + pageCache.clear() } - } else { - false - } - viewModel.statusData.addAll(0, data) - - if (insertPlaceholder) { - viewModel.statusData[statuses.size - 1] = StatusViewData.Placeholder(statuses.last().id, false) + pageCache.upsert(page) + Log.d( + TAG, + " Page $loadType complete for $timelineKind, now got ${pageCache.size} pages" + ) + pageCache.debug() } - } else { - val linkHeader = statusResponse.headers()["Link"] - val links = HttpHeaderLink.parse(linkHeader) - val nextId = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter("max_id") + Log.d(TAG, " Invalidating paging source") + factory.invalidate() + } - viewModel.nextKey = nextId + return MediatorResult.Success(endOfPaginationReached = endOfPaginationReached) + } catch (e: IOException) { + MediatorResult.Error(e) + } catch (e: HttpException) { + MediatorResult.Error(e) + } + } - viewModel.statusData.addAll(data) - } + @Throws(IOException::class, HttpException::class) + private suspend fun fetchStatusPageByKind(loadType: LoadType, key: String?, loadSize: Int): Response> { + val (maxId, minId) = when (loadType) { + // When refreshing fetch a page of statuses that are immediately *newer* than the key + // This is so that the user's reading position is not lost. + LoadType.REFRESH -> Pair(null, key) + // When appending fetch a page of statuses that are immediately *older* than the key + LoadType.APPEND -> Pair(key, null) + // When prepending fetch a page of statuses that are immediately *newer* than the key + LoadType.PREPEND -> Pair(null, key) + } - viewModel.currentSource?.invalidate() - return MediatorResult.Success(endOfPaginationReached = statuses.isEmpty()) - } catch (e: Exception) { - return ifExpected(e) { - MediatorResult.Error(e) + return when (timelineKind) { + TimelineKind.Bookmarks -> api.bookmarks(maxId = maxId, minId = minId, limit = loadSize) + TimelineKind.Favourites -> api.favourites(maxId = maxId, minId = minId, limit = loadSize) + TimelineKind.Home -> api.homeTimeline(maxId = maxId, minId = minId, limit = loadSize) + TimelineKind.PublicFederated -> api.publicTimeline(local = false, maxId = maxId, minId = minId, limit = loadSize) + TimelineKind.PublicLocal -> api.publicTimeline(local = true, maxId = maxId, minId = minId, limit = loadSize) + is TimelineKind.Tag -> { + val firstHashtag = timelineKind.tags.first() + val additionalHashtags = timelineKind.tags.subList(1, timelineKind.tags.size) + api.hashtagTimeline(firstHashtag, additionalHashtags, null, maxId = maxId, minId = minId, limit = loadSize) } + is TimelineKind.User.Pinned -> api.accountStatuses( + timelineKind.id, + maxId = maxId, + minId = minId, + limit = loadSize, + excludeReplies = null, + onlyMedia = null, + pinned = true + ) + is TimelineKind.User.Posts -> api.accountStatuses( + timelineKind.id, + maxId = maxId, + minId = minId, + limit = loadSize, + excludeReplies = true, + onlyMedia = null, + pinned = null + ) + is TimelineKind.User.Replies -> api.accountStatuses( + timelineKind.id, + maxId = maxId, + minId = minId, + limit = loadSize, + excludeReplies = null, + onlyMedia = null, + pinned = null + ) + is TimelineKind.UserList -> api.listTimeline( + timelineKind.id, + maxId = maxId, + minId = minId, + limit = loadSize + ) } } + + companion object { + private const val TAG = "NetworkTimelineRemoteMediator" + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt index f32443aee4..0323202462 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/NetworkTimelineViewModel.kt @@ -18,335 +18,195 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.content.SharedPreferences import android.util.Log import androidx.lifecycle.viewModelScope -import androidx.paging.ExperimentalPagingApi -import androidx.paging.Pager -import androidx.paging.PagingConfig +import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.filter +import androidx.paging.map import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.EventHub import com.keylesspalace.tusky.appstore.FavoriteEvent import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.ReblogEvent -import com.keylesspalace.tusky.components.timeline.util.ifExpected +import com.keylesspalace.tusky.components.timeline.FiltersRepository +import com.keylesspalace.tusky.components.timeline.NetworkTimelineRepository +import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.Poll -import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel -import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.settings.AccountPreferenceDataStore import com.keylesspalace.tusky.usecase.TimelineCases -import com.keylesspalace.tusky.util.getDomain -import com.keylesspalace.tusky.util.isLessThan -import com.keylesspalace.tusky.util.isLessThanOrEqual import com.keylesspalace.tusky.util.toViewData import com.keylesspalace.tusky.viewdata.StatusViewData -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.asExecutor -import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import retrofit2.HttpException -import retrofit2.Response -import java.io.IOException import javax.inject.Inject /** * TimelineViewModel that caches all statuses in an in-memory list */ class NetworkTimelineViewModel @Inject constructor( + private val repository: NetworkTimelineRepository, timelineCases: TimelineCases, - private val api: MastodonApi, eventHub: EventHub, + filtersRepository: FiltersRepository, accountManager: AccountManager, sharedPreferences: SharedPreferences, + accountPreferenceDataStore: AccountPreferenceDataStore, filterModel: FilterModel -) : TimelineViewModel(timelineCases, api, eventHub, accountManager, sharedPreferences, filterModel) { - - var currentSource: NetworkTimelinePagingSource? = null - - val statusData: MutableList = mutableListOf() - - var nextKey: String? = null - - @OptIn(ExperimentalPagingApi::class) - override val statuses = Pager( - config = PagingConfig(pageSize = LOAD_AT_ONCE), - pagingSourceFactory = { - NetworkTimelinePagingSource( - viewModel = this - ).also { source -> - currentSource = source - } - }, - remoteMediator = NetworkTimelineRemoteMediator(accountManager, this) - ).flow - .map { pagingData -> - pagingData.filter(Dispatchers.Default.asExecutor()) { statusViewData -> - shouldFilterStatus(statusViewData) != Filter.Action.HIDE +) : TimelineViewModel( + timelineCases, + eventHub, + filtersRepository, + accountManager, + sharedPreferences, + accountPreferenceDataStore, + filterModel +) { + private val modifiedViewData = mutableMapOf() + + override lateinit var statuses: Flow> + + @OptIn(ExperimentalCoroutinesApi::class) + override fun init(timelineKind: TimelineKind) { + super.init(timelineKind) + statuses = reload + .flatMapLatest { + getStatuses(timelineKind, initialKey = getInitialKey()) + }.cachedIn(viewModelScope) + } + + /** @return Flow of statuses that make up the timeline of [kind] */ + private fun getStatuses( + kind: TimelineKind, + initialKey: String? = null + ): Flow> { + Log.d(TAG, "getStatuses: kind: $kind, initialKey: $initialKey") + return repository.getStatusStream(viewModelScope, kind = kind, initialKey = initialKey) + .map { pagingData -> + pagingData.map { + modifiedViewData[it.id] ?: it.toViewData( + isShowingContent = statusDisplayOptions.value.showSensitiveMedia || !it.actionableStatus.sensitive, + isExpanded = statusDisplayOptions.value.openSpoiler, + isCollapsed = true + ) + }.filter { + shouldFilterStatus(it) != Filter.Action.HIDE + } } - } - .flowOn(Dispatchers.Default) - .cachedIn(viewModelScope) + } - override fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) { - status.copy( + override fun updatePoll(newPoll: Poll, status: StatusViewData) { + modifiedViewData[status.id] = status.copy( status = status.status.copy(poll = newPoll) - ).update() + ) + repository.invalidate() } - override fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { - status.copy( + override fun changeExpanded(expanded: Boolean, status: StatusViewData) { + modifiedViewData[status.id] = status.copy( isExpanded = expanded - ).update() + ) + repository.invalidate() } - override fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { - status.copy( + override fun changeContentShowing(isShowing: Boolean, status: StatusViewData) { + modifiedViewData[status.id] = status.copy( isShowingContent = isShowing - ).update() + ) + repository.invalidate() } - override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { - status.copy( + override fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) { + Log.d(TAG, "changeContentCollapsed: $isCollapsed") + Log.d(TAG, " " + status.content) + modifiedViewData[status.id] = status.copy( isCollapsed = isCollapsed - ).update() + ) + repository.invalidate() } override fun removeAllByAccountId(accountId: String) { - statusData.removeAll { vd -> - val status = vd.asStatusOrNull()?.status ?: return@removeAll false - status.account.id == accountId || status.actionableStatus.account.id == accountId + viewModelScope.launch { + repository.removeAllByAccountId(accountId) } - currentSource?.invalidate() } override fun removeAllByInstance(instance: String) { - statusData.removeAll { vd -> - val status = vd.asStatusOrNull()?.status ?: return@removeAll false - getDomain(status.account.url) == instance + viewModelScope.launch { + repository.removeAllByInstance(instance) } - currentSource?.invalidate() } override fun removeStatusWithId(id: String) { - statusData.removeAll { vd -> - val status = vd.asStatusOrNull()?.status ?: return@removeAll false - status.id == id || status.reblog?.id == id - } - currentSource?.invalidate() - } - - override fun loadMore(placeholderId: String) { viewModelScope.launch { - try { - val placeholderIndex = - statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } - statusData[placeholderIndex] = StatusViewData.Placeholder(placeholderId, isLoading = true) - - val idAbovePlaceholder = statusData.getOrNull(placeholderIndex - 1)?.id - - val statusResponse = fetchStatusesForKind( - fromId = idAbovePlaceholder, - uptoId = null, - limit = 20 - ) - - val statuses = statusResponse.body() - if (!statusResponse.isSuccessful || statuses == null) { - loadMoreFailed(placeholderId, HttpException(statusResponse)) - return@launch - } - - statusData.removeAt(placeholderIndex) - - val activeAccount = accountManager.activeAccount!! - val data: MutableList = statuses.map { status -> - status.toViewData( - isShowingContent = activeAccount.alwaysShowSensitiveMedia || !status.actionableStatus.sensitive, - isExpanded = activeAccount.alwaysOpenSpoiler, - isCollapsed = true - ) - }.toMutableList() - - if (statuses.isNotEmpty()) { - val firstId = statuses.first().id - val lastId = statuses.last().id - val overlappedFrom = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThanOrEqual(firstId) ?: false } - val overlappedTo = statusData.indexOfFirst { it.asStatusOrNull()?.id?.isLessThan(lastId) ?: false } - - if (overlappedFrom < overlappedTo) { - data.mapIndexed { i, status -> i to statusData.firstOrNull { it.asStatusOrNull()?.id == status.id }?.asStatusOrNull() } - .filter { (_, oldStatus) -> oldStatus != null } - .forEach { (i, oldStatus) -> - data[i] = data[i].asStatusOrNull()!! - .copy( - isShowingContent = oldStatus!!.isShowingContent, - isExpanded = oldStatus.isExpanded, - isCollapsed = oldStatus.isCollapsed - ) - } - - statusData.removeAll { status -> - when (status) { - is StatusViewData.Placeholder -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId) - is StatusViewData.Concrete -> lastId.isLessThan(status.id) && status.id.isLessThanOrEqual(firstId) - } - } - } else { - data[data.size - 1] = StatusViewData.Placeholder(statuses.last().id, isLoading = false) - } - } - - statusData.addAll(placeholderIndex, data) - - currentSource?.invalidate() - } catch (e: Exception) { - ifExpected(e) { - loadMoreFailed(placeholderId, e) - } - } + repository.removeStatusWithId(id) } } - private fun loadMoreFailed(placeholderId: String, e: Exception) { - Log.w("NetworkTimelineVM", "failed loading statuses", e) - - val index = - statusData.indexOfFirst { it is StatusViewData.Placeholder && it.id == placeholderId } - statusData[index] = StatusViewData.Placeholder(placeholderId, isLoading = false) - - currentSource?.invalidate() - } - override fun handleReblogEvent(reblogEvent: ReblogEvent) { - updateStatusById(reblogEvent.statusId) { - it.copy(status = it.status.copy(reblogged = reblogEvent.reblog)) + viewModelScope.launch { + repository.updateStatusById(reblogEvent.statusId) { + it.copy(reblogged = reblogEvent.reblog) + } } } override fun handleFavEvent(favEvent: FavoriteEvent) { - updateActionableStatusById(favEvent.statusId) { - it.copy(favourited = favEvent.favourite) + viewModelScope.launch { + repository.updateActionableStatusById(favEvent.statusId) { + it.copy(favourited = favEvent.favourite) + } } + repository.invalidate() } override fun handleBookmarkEvent(bookmarkEvent: BookmarkEvent) { - updateActionableStatusById(bookmarkEvent.statusId) { - it.copy(bookmarked = bookmarkEvent.bookmark) + viewModelScope.launch { + repository.updateActionableStatusById(bookmarkEvent.statusId) { + it.copy(bookmarked = bookmarkEvent.bookmark) + } } + repository.invalidate() } override fun handlePinEvent(pinEvent: PinEvent) { - updateActionableStatusById(pinEvent.statusId) { - it.copy(pinned = pinEvent.pinned) + viewModelScope.launch { + repository.updateActionableStatusById(pinEvent.statusId) { + it.copy(pinned = pinEvent.pinned) + } } + repository.invalidate() } - override fun fullReload() { - nextKey = statusData.firstOrNull { it is StatusViewData.Concrete }?.asStatusOrNull()?.id - statusData.clear() - currentSource?.invalidate() - } - - override fun clearWarning(status: StatusViewData.Concrete) { - updateActionableStatusById(status.actionableId) { - it.copy(filtered = null) + override fun reloadKeepingReadingPosition() { + super.reloadKeepingReadingPosition() + viewModelScope.launch { + repository.reload() } } - override fun saveReadingPosition(statusId: String) { - /** Does nothing for non-cached timelines */ + override fun reloadFromNewest() { + super.reloadFromNewest() + reloadKeepingReadingPosition() } - override suspend fun invalidate() { - currentSource?.invalidate() - } - - @Throws(IOException::class, HttpException::class) - suspend fun fetchStatusesForKind( - fromId: String?, - uptoId: String?, - limit: Int - ): Response> { - return when (kind) { - Kind.HOME -> api.homeTimeline(maxId = fromId, sinceId = uptoId, limit = limit) - Kind.PUBLIC_FEDERATED -> api.publicTimeline(null, fromId, uptoId, limit) - Kind.PUBLIC_LOCAL -> api.publicTimeline(true, fromId, uptoId, limit) - Kind.TAG -> { - val firstHashtag = tags[0] - val additionalHashtags = tags.subList(1, tags.size) - api.hashtagTimeline(firstHashtag, additionalHashtags, null, fromId, uptoId, limit) + override fun clearWarning(status: StatusViewData) { + viewModelScope.launch { + repository.updateActionableStatusById(status.actionableId) { + it.copy(filtered = null) } - Kind.USER -> api.accountStatuses( - id!!, - fromId, - uptoId, - limit, - excludeReplies = true, - onlyMedia = null, - pinned = null - ) - Kind.USER_PINNED -> api.accountStatuses( - id!!, - fromId, - uptoId, - limit, - excludeReplies = null, - onlyMedia = null, - pinned = true - ) - Kind.USER_WITH_REPLIES -> api.accountStatuses( - id!!, - fromId, - uptoId, - limit, - excludeReplies = null, - onlyMedia = null, - pinned = null - ) - Kind.FAVOURITES -> api.favourites(fromId, uptoId, limit) - Kind.BOOKMARKS -> api.bookmarks(fromId, uptoId, limit) - Kind.LIST -> api.listTimeline(id!!, fromId, uptoId, limit) } } - private fun StatusViewData.Concrete.update() { - val position = statusData.indexOfFirst { viewData -> viewData.asStatusOrNull()?.id == this.id } - statusData[position] = this - currentSource?.invalidate() - } - - private inline fun updateStatusById( - id: String, - updater: (StatusViewData.Concrete) -> StatusViewData.Concrete - ) { - val pos = statusData.indexOfFirst { it.asStatusOrNull()?.id == id } - if (pos == -1) return - updateViewDataAt(pos, updater) - } - - private inline fun updateActionableStatusById( - id: String, - updater: (Status) -> Status - ) { - val pos = statusData.indexOfFirst { it.asStatusOrNull()?.id == id } - if (pos == -1) return - updateViewDataAt(pos) { vd -> - if (vd.status.reblog != null) { - vd.copy(status = vd.status.copy(reblog = updater(vd.status.reblog))) - } else { - vd.copy(status = updater(vd.status)) - } - } + override suspend fun invalidate() { + repository.invalidate() } - private inline fun updateViewDataAt( - position: Int, - updater: (StatusViewData.Concrete) -> StatusViewData.Concrete - ) { - val status = statusData.getOrNull(position)?.asStatusOrNull() ?: return - statusData[position] = updater(status) - currentSource?.invalidate() + companion object { + private const val TAG = "NetworkTimelineViewModel" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt new file mode 100644 index 0000000000..388433e57d --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/PageCache.kt @@ -0,0 +1,175 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline.viewmodel + +import android.util.Log +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.entity.Status +import com.keylesspalace.tusky.network.Links +import com.keylesspalace.tusky.util.isLessThan +import retrofit2.HttpException +import retrofit2.Response +import java.util.TreeMap +import kotlin.Result.Companion.failure +import kotlin.Result.Companion.success + +/** A page of data from the Mastodon API */ +data class Page( + /** Loaded data */ + val data: MutableList, + /** + * Key for previous page (newer results, PREPEND operation) if more data can be loaded in + * that direction, `null` otherwise. + */ + val prevKey: String? = null, + /** + * Key for next page (older results, APPEND operation) if more data can be loaded in that + * direction, `null` otherwise. + */ + val nextKey: String? = null +) { + override fun toString() = "k: ${data.lastOrNull()?.id}, prev: $prevKey, next: $nextKey, size: ${"%2d".format(data.size)}, range: ${data.firstOrNull()?.id}..${data.lastOrNull()?.id}" + + /** + * Return a new page consisting of this page, plus the data from [pages]. + */ + fun merge(vararg pages: Page?): Page { + val d = data + var next = nextKey + var prev = prevKey + + pages.filterNotNull().forEach { + d.addAll(it.data) + if (next != null) { + if (it.nextKey == null || it.nextKey.isLessThan(next!!)) next = it.nextKey + } + if (prev != null) { + if (prev!!.isLessThan(it.prevKey ?: "")) prev = it.prevKey + } + } + + d.sortWith(compareBy({ it.id.length }, { it.id })) + d.reverse() + + if (nextKey?.isLessThan(next ?: "") == true) throw java.lang.IllegalStateException("New next $next is greater than old nextKey $nextKey") + if (prev?.isLessThan(prevKey ?: "") == true) throw java.lang.IllegalStateException("New prev $prev is less than old $prevKey") + + // Debug assertions + if (BuildConfig.DEBUG) { + // There should never be duplicate items across all the pages. + val ids = d.map { it.id } + val groups = ids.groupingBy { it }.eachCount().filter { it.value > 1 } + if (groups.isNotEmpty()) { + throw IllegalStateException("Duplicate item IDs in results!: $groups") + } + + // Data should always be sorted newest first + if (d.first().id.isLessThan(d.last().id)) { + throw IllegalStateException("Items in data are *not* sorted newest first") + } + } + + return Page( + data = d, + nextKey = next, + prevKey = prev + ) + } + + companion object { + private const val TAG = "Page" + + fun tryFrom(response: Response>): Result { + val statuses = response.body() + if (!response.isSuccessful || statuses == null) { + return failure(HttpException(response)) + } + + val links = Links.from(response.headers()["link"]) + Log.d(TAG, " link: " + response.headers()["link"]) + Log.d(TAG, " ${statuses.size} - # statuses loaded") + + return success( + Page( + data = statuses.toMutableList(), + nextKey = links.next, + prevKey = links.prev + ) + ) + } + } +} + +/** + * Cache of pages from Mastodon API calls. + * + * Cache pages are identified by the ID of the **last** (smallest, oldest) key in the page. + * + * It's the last item, and not the first because a page may be incomplete. E.g,. + * a prepend operation completes, and instead of loading pageSize items it loads + * (pageSize - 10) items, because only (pageSize - 10) items were available at the + * time of the API call. + * + * If the page was subsequently refreshed, *and* the ID of the first (newest) item + * was used as the key then you might have two pages that contain overlapping + * items. + */ +class PageCache : TreeMap(compareBy({ it.length }, { it })) { + /** + * Adds a new page to the cache or updates the existing page with the given key + */ + fun upsert(page: Page) { + val key = page.data.last().id + + Log.d(TAG, "Inserting new page:") + Log.d(TAG, " $page") + + this[key] = page + + // There should never be duplicate items across all the pages. Enforce this in debug mode. + if (BuildConfig.DEBUG) { + val ids = buildList { + this.addAll(this@PageCache.map { entry -> entry.value.data.map { it.id } }.flatten()) + } + val groups = ids.groupingBy { it }.eachCount().filter { it.value > 1 } + if (groups.isNotEmpty()) { + throw IllegalStateException("Duplicate item IDs in results!: $groups") + } + } + } + + /** + * Logs the current state of the cache + */ + fun debug() { + if (BuildConfig.DEBUG) { // Makes it easier for Proguard to optimise this out + Log.d(TAG, "Page cache state:") + if (this.isEmpty()) { + Log.d(TAG, " ** empty **") + } else { + this.onEachIndexed { index, entry -> + Log.d(TAG, " $index: ${entry.value}") + } + } + } + } + + companion object { + private const val TAG = "PageCache" + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt index adab92b333..8cad624cf0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/timeline/viewmodel/TimelineViewModel.kt @@ -17,12 +17,13 @@ package com.keylesspalace.tusky.components.timeline.viewmodel import android.content.SharedPreferences import android.util.Log +import androidx.annotation.CallSuper +import androidx.annotation.StringRes import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.PagingData -import at.connyduck.calladapter.networkresult.fold -import at.connyduck.calladapter.networkresult.getOrElse import at.connyduck.calladapter.networkresult.getOrThrow +import com.keylesspalace.tusky.R import com.keylesspalace.tusky.appstore.BlockEvent import com.keylesspalace.tusky.appstore.BookmarkEvent import com.keylesspalace.tusky.appstore.DomainMuteEvent @@ -34,133 +35,455 @@ import com.keylesspalace.tusky.appstore.MuteEvent import com.keylesspalace.tusky.appstore.PinEvent import com.keylesspalace.tusky.appstore.PreferenceChangedEvent import com.keylesspalace.tusky.appstore.ReblogEvent +import com.keylesspalace.tusky.appstore.StatusComposedEvent import com.keylesspalace.tusky.appstore.StatusDeletedEvent +import com.keylesspalace.tusky.appstore.StatusEditedEvent import com.keylesspalace.tusky.appstore.UnfollowEvent -import com.keylesspalace.tusky.components.preference.PreferencesFragment.ReadingOrder +import com.keylesspalace.tusky.components.timeline.FilterKind +import com.keylesspalace.tusky.components.timeline.FiltersRepository +import com.keylesspalace.tusky.components.timeline.TimelineKind import com.keylesspalace.tusky.components.timeline.util.ifExpected import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.entity.Filter import com.keylesspalace.tusky.entity.FilterV1 import com.keylesspalace.tusky.entity.Poll +import com.keylesspalace.tusky.entity.Status import com.keylesspalace.tusky.network.FilterModel -import com.keylesspalace.tusky.network.MastodonApi +import com.keylesspalace.tusky.settings.AccountPreferenceDataStore import com.keylesspalace.tusky.settings.PrefKeys import com.keylesspalace.tusky.usecase.TimelineCases +import com.keylesspalace.tusky.util.StatusDisplayOptions +import com.keylesspalace.tusky.util.throttleFirst import com.keylesspalace.tusky.viewdata.StatusViewData -import kotlinx.coroutines.Job +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.getAndUpdate +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import retrofit2.HttpException +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.ExperimentalTime + +data class UiState( + /** True if the FAB should be shown while scrolling */ + val showFabWhileScrolling: Boolean = true, + + /** True if media previews should be shown */ + val showMediaPreview: Boolean = true +) + +/** Preferences the UI reacts to */ +data class UiPrefs( + val showFabWhileScrolling: Boolean, + val showMediaPreview: Boolean +) { + companion object { + /** Relevant preference keys. Changes to any of these trigger a display update */ + val prefKeys = setOf( + PrefKeys.FAB_HIDE, + PrefKeys.MEDIA_PREVIEW_ENABLED + ) + } +} + +// TODO: Ui* classes are copied from NotificationsViewModel. Not yet sure whether these actions +// are "global" across all timelines (including notifications) or whether notifications are +// sufficiently different to warrant having a duplicate set. Keeping them duplicated for the +// moment. + +/** Parent class for all UI actions, fallible or infallible. */ +sealed class UiAction + +/** Actions the user can trigger from the UI. These actions may fail. */ +sealed class FallibleUiAction : UiAction() { + /* none at the moment */ +} + +/** + * Actions the user can trigger from the UI that either cannot fail, or if they do fail, + * do not show an error. + */ +sealed class InfallibleUiAction : UiAction() { + /** + * User is leaving the fragment, save the ID of the visible status. + * + * Infallible because if it fails there's nowhere to show the error, and nothing the user + * can do. + */ + data class SaveVisibleId(val visibleId: String) : InfallibleUiAction() + + /** Ignore the saved reading position, load the page with the newest items */ + // Resets the account's reading position, which can't fail, which is why this is + // infallible. Reloading the data may fail, but that's handled by the paging system / + // adapter refresh logic. + object LoadNewest : InfallibleUiAction() +} + +sealed class UiSuccess { + // These three are from menu items on the status. Currently they don't come to the + // viewModel as actions, they're noticed when events are posted. That will change, + // but for the moment we can still report them to the UI. Typically, receiving any + // of these three should trigger the UI to refresh. + + /** A user was blocked */ + object Block : UiSuccess() + + /** A user was muted */ + object Mute : UiSuccess() + + /** A conversation was muted */ + object MuteConversation : UiSuccess() + + /** A status the user wrote was successfully posted */ + data class StatusSent(val status: Status) : UiSuccess() + + /** A status the user wrote was successfully edited */ + data class StatusEdited(val status: Status) : UiSuccess() +} + +/** Actions the user can trigger on an individual status */ +sealed class StatusAction(open val statusViewData: StatusViewData) : FallibleUiAction() { + /** Set the bookmark state for a status */ + data class Bookmark(val state: Boolean, override val statusViewData: StatusViewData) : + StatusAction(statusViewData) + + /** Set the favourite state for a status */ + data class Favourite(val state: Boolean, override val statusViewData: StatusViewData) : + StatusAction(statusViewData) + + /** Set the reblog state for a status */ + data class Reblog(val state: Boolean, override val statusViewData: StatusViewData) : + StatusAction(statusViewData) + + /** Vote in a poll */ + data class VoteInPoll( + val poll: Poll, + val choices: List, + override val statusViewData: StatusViewData + ) : StatusAction(statusViewData) +} + +/** Changes to a status' visible state after API calls */ +sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess() { + data class Bookmark(override val action: StatusAction.Bookmark) : + StatusActionSuccess(action) + + data class Favourite(override val action: StatusAction.Favourite) : + StatusActionSuccess(action) + + data class Reblog(override val action: StatusAction.Reblog) : + StatusActionSuccess(action) + + data class VoteInPoll(override val action: StatusAction.VoteInPoll) : + StatusActionSuccess(action) + + companion object { + fun from(action: StatusAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(action) + is StatusAction.Favourite -> Favourite(action) + is StatusAction.Reblog -> Reblog(action) + is StatusAction.VoteInPoll -> VoteInPoll(action) + } + } +} + +// TODO: Similar to UiError in NotificationsViewModel, but without ClearNotifications, +// AcceptFollowRequest, and RejectFollowRequest. +// +// Possibly indicates the need for UiStatusError and UiNotificationError subclasses so these +// can be shared. +// +// Need to think about how that would work with sealed classes. + +/** Errors from fallible view model actions that the UI will need to show */ +sealed class UiError( + /** The throwable associated with the error */ + open val throwable: Throwable, + + /** String resource with an error message to show the user */ + @StringRes val message: Int, + + /** The action that failed. Can be resent to retry the action */ + open val action: UiAction? = null +) { + data class Bookmark( + override val throwable: Throwable, + override val action: StatusAction.Bookmark + ) : UiError(throwable, R.string.ui_error_bookmark, action) + + data class Favourite( + override val throwable: Throwable, + override val action: StatusAction.Favourite + ) : UiError(throwable, R.string.ui_error_favourite, action) + + data class Reblog( + override val throwable: Throwable, + override val action: StatusAction.Reblog + ) : UiError(throwable, R.string.ui_error_reblog, action) + + data class VoteInPoll( + override val throwable: Throwable, + override val action: StatusAction.VoteInPoll + ) : UiError(throwable, R.string.ui_error_vote, action) + + data class GetFilters( + override val throwable: Throwable + ) : UiError(throwable, R.string.ui_error_filter_v1_load, null) + + companion object { + fun make(throwable: Throwable, action: FallibleUiAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(throwable, action) + is StatusAction.Favourite -> Favourite(throwable, action) + is StatusAction.Reblog -> Reblog(throwable, action) + is StatusAction.VoteInPoll -> VoteInPoll(throwable, action) + } + } +} +@OptIn(FlowPreview::class, ExperimentalTime::class) abstract class TimelineViewModel( private val timelineCases: TimelineCases, - private val api: MastodonApi, private val eventHub: EventHub, + private val filtersRepository: FiltersRepository, protected val accountManager: AccountManager, private val sharedPreferences: SharedPreferences, + private val accountPreferenceDataStore: AccountPreferenceDataStore, private val filterModel: FilterModel ) : ViewModel() { + val uiState: StateFlow abstract val statuses: Flow> - var kind: Kind = Kind.HOME - private set - var id: String? = null - private set - var tags: List = emptyList() + /** Flow of changes to statusDisplayOptions, for use by the UI */ + val statusDisplayOptions: StateFlow + + /** Flow of user actions received from the UI */ + private val uiAction = MutableSharedFlow() + + /** Flow that can be used to trigger a full reload */ + protected val reload = MutableStateFlow(0) + + /** Flow of successful action results */ + // Note: Thisis a SharedFlow instead of a StateFlow because success state does not need to be + // retained. A message is shown once to a user and then dismissed. Re-collecting the flow + // (e.g., after a device orientation change) should not re-show the most recent success + // message, as it will be confusing to the user. + val uiSuccess = MutableSharedFlow() + + /** Channel for error results */ + // Errors are sent to a channel to ensure that any errors that occur *before* there are any + // subscribers are retained. If this was a SharedFlow any errors would be dropped, and if it + // was a StateFlow any errors would be retained, and there would need to be an explicit + // mechanism to dismiss them. + private val _uiErrorChannel = Channel() + + /** Expose UI errors as a flow */ + val uiError = _uiErrorChannel.receiveAsFlow() + + /** Accept UI actions in to actionStateFlow */ + val accept: (UiAction) -> Unit = { action -> + viewModelScope.launch { uiAction.emit(action) } + } + + var timelineKind: TimelineKind = TimelineKind.Home private set - protected var alwaysShowSensitiveMedia = false - private var alwaysOpenSpoilers = false private var filterRemoveReplies = false private var filterRemoveReblogs = false - protected var readingOrder: ReadingOrder = ReadingOrder.OLDEST_FIRST - - fun init( - kind: Kind, - id: String?, - tags: List - ) { - this.kind = kind - this.id = id - this.tags = tags - filterModel.kind = kind.toFilterKind() - - if (kind == Kind.HOME) { - // Note the variable is "true if filter" but the underlying preference/settings text is "true if show" - filterRemoveReplies = - !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) - filterRemoveReblogs = - !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) + + protected val activeAccount = accountManager.activeAccount!! + + /** The ID of the status to which the user's reading position should be restored */ + // Not part of the UiState as it's only used once in the lifespan of the fragment. + // Subclasses should set this if they support restoring the reading position. + open var readingPositionId: String? = null + protected set + + init { + viewModelScope.launch { + updateFiltersFromPreferences().collectLatest { + Log.d(TAG, "Filters updated") + } } - readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) - this.alwaysShowSensitiveMedia = accountManager.activeAccount!!.alwaysShowSensitiveMedia - this.alwaysOpenSpoilers = accountManager.activeAccount!!.alwaysOpenSpoiler + // Set initial status display options from the user's preferences. + // + // Then collect future preference changes and emit new values in to + // statusDisplayOptions if necessary. + statusDisplayOptions = MutableStateFlow( + StatusDisplayOptions.from( + sharedPreferences, + activeAccount + ) + ) viewModelScope.launch { eventHub.events - .collect { event -> handleEvent(event) } + .filterIsInstance() + .filter { StatusDisplayOptions.prefKeys.contains(it.preferenceKey) } + .map { + statusDisplayOptions.value.make( + sharedPreferences, + it.preferenceKey, + activeAccount + ) + } + .collect { + statusDisplayOptions.emit(it) + } } - reloadFilters() - } + // Handle StatusAction.* + viewModelScope.launch { + uiAction.filterIsInstance() + .throttleFirst(THROTTLE_TIMEOUT) // avoid double-taps + .collect { action -> + try { + when (action) { + is StatusAction.Bookmark -> + timelineCases.bookmark( + action.statusViewData.actionableId, + action.state + ) + is StatusAction.Favourite -> + timelineCases.favourite( + action.statusViewData.actionableId, + action.state + ) + is StatusAction.Reblog -> + timelineCases.reblog( + action.statusViewData.actionableId, + action.state + ) + is StatusAction.VoteInPoll -> + timelineCases.voteInPoll( + action.statusViewData.actionableId, + action.poll.id, + action.choices + ) + }.getOrThrow() + uiSuccess.emit(StatusActionSuccess.from(action)) + } catch (e: Exception) { + ifExpected(e) { _uiErrorChannel.send(UiError.make(e, action)) } + } + } + } - fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { - try { - timelineCases.reblog(status.actionableId, reblog).getOrThrow() - } catch (t: Exception) { - ifExpected(t) { - Log.d(TAG, "Failed to reblog status " + status.actionableId, t) + // Handle events that should refresh the list + viewModelScope.launch { + eventHub.events.collectLatest { + when (it) { + is BlockEvent -> uiSuccess.emit(UiSuccess.Block) + is MuteEvent -> uiSuccess.emit(UiSuccess.Mute) + is MuteConversationEvent -> uiSuccess.emit(UiSuccess.MuteConversation) + is StatusComposedEvent -> uiSuccess.emit(UiSuccess.StatusSent(it.status)) + is StatusEditedEvent -> uiSuccess.emit(UiSuccess.StatusEdited(it.status)) + } } } + + uiState = getUiPrefs().map { prefs -> + UiState( + showFabWhileScrolling = prefs.showFabWhileScrolling, + showMediaPreview = prefs.showMediaPreview + ) + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(stopTimeoutMillis = 5000), + initialValue = UiState() + ) } - fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { - try { - timelineCases.favourite(status.actionableId, favorite).getOrThrow() - } catch (t: Exception) { - ifExpected(t) { - Log.d(TAG, "Failed to favourite status " + status.actionableId, t) - } + /** + * @return Flow of relevant preferences that change the UI + */ + // TODO: Preferences should be in a repository + protected fun getUiPrefs() = eventHub.events + .filterIsInstance() + .onEach { println("PreferenceChangedEvent: $it") } + .filter { UiPrefs.prefKeys.contains(it.preferenceKey) } + .map { toPrefs() } + .onStart { emit(toPrefs()) } + + private fun toPrefs() = UiPrefs( + showFabWhileScrolling = !sharedPreferences.getBoolean(PrefKeys.FAB_HIDE, false), + showMediaPreview = accountPreferenceDataStore.getBoolean(PrefKeys.MEDIA_PREVIEW_ENABLED, true) + ) + + @CallSuper + open fun init(timelineKind: TimelineKind) { + this.timelineKind = timelineKind + + filterModel.kind = Filter.Kind.from(timelineKind) + + if (timelineKind is TimelineKind.Home) { + // Note the variable is "true if filter" but the underlying preference/settings text is "true if show" + filterRemoveReplies = + !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) + filterRemoveReblogs = + !sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) } - } - fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { - try { - timelineCases.bookmark(status.actionableId, bookmark).getOrThrow() - } catch (t: Exception) { - ifExpected(t) { - Log.d(TAG, "Failed to bookmark status " + status.actionableId, t) + // Save the visible status ID (if it's the home timeline) + if (timelineKind == TimelineKind.Home) { + viewModelScope.launch { + uiAction + .filterIsInstance() + .distinctUntilChanged() + .collectLatest { action -> + Log.d(TAG, "Saving Home timeline position at: ${action.visibleId}") + activeAccount.lastVisibleHomeTimelineStatusId = action.visibleId + accountManager.saveAccount(activeAccount) + } } } - } - fun voteInPoll(choices: List, status: StatusViewData.Concrete): Job = viewModelScope.launch { - val poll = status.status.actionableStatus.poll ?: run { - Log.w(TAG, "No poll on status ${status.id}") - return@launch + // Clear the saved visible ID (if necessary), and reload from the newest status. + viewModelScope.launch { + uiAction + .filterIsInstance() + .collectLatest { + if (timelineKind == TimelineKind.Home) { + activeAccount.lastVisibleHomeTimelineStatusId = null + accountManager.saveAccount(activeAccount) + } + reloadFromNewest() + } } - val votedPoll = poll.votedCopy(choices) - updatePoll(votedPoll, status) + viewModelScope.launch { + eventHub.events + .collect { event -> handleEvent(event) } + } + } - try { - timelineCases.voteInPoll(status.actionableId, poll.id, choices).getOrThrow() - } catch (t: Exception) { - ifExpected(t) { - Log.d(TAG, "Failed to vote in poll: " + status.actionableId, t) - } + fun getInitialKey(): String? { + if (timelineKind != TimelineKind.Home) { + return null } + + return activeAccount.lastVisibleHomeTimelineStatusId } - abstract fun updatePoll(newPoll: Poll, status: StatusViewData.Concrete) + abstract fun updatePoll(newPoll: Poll, status: StatusViewData) - abstract fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) + abstract fun changeExpanded(expanded: Boolean, status: StatusViewData) - abstract fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) + abstract fun changeContentShowing(isShowing: Boolean, status: StatusViewData) - abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) + abstract fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) abstract fun removeAllByAccountId(accountId: String) @@ -168,8 +491,6 @@ abstract class TimelineViewModel( abstract fun removeStatusWithId(id: String) - abstract fun loadMore(placeholderId: String) - abstract fun handleReblogEvent(reblogEvent: ReblogEvent) abstract fun handleFavEvent(favEvent: FavoriteEvent) @@ -178,18 +499,33 @@ abstract class TimelineViewModel( abstract fun handlePinEvent(pinEvent: PinEvent) - abstract fun fullReload() + /** + * Reload data for this timeline while preserving the user's reading position. + * + * Subclasses should call this, then start loading data. + */ + @CallSuper + open fun reloadKeepingReadingPosition() { + reload.getAndUpdate { it + 1 } + } - abstract fun clearWarning(status: StatusViewData.Concrete) + /** + * Load the most recent data for this timeline, ignoring the user's reading position. + * + * Subclasses should call this, then start loading data. + */ + @CallSuper + open fun reloadFromNewest() { + reload.getAndUpdate { it + 1 } + } - /** Saves the user's reading position so it can be restored later */ - abstract fun saveReadingPosition(statusId: String) + abstract fun clearWarning(status: StatusViewData) /** Triggered when currently displayed data must be reloaded. */ protected abstract suspend fun invalidate() protected fun shouldFilterStatus(statusViewData: StatusViewData): Filter.Action { - val status = statusViewData.asStatusOrNull()?.status ?: return Filter.Action.NONE + val status = statusViewData.status return if ( (status.inReplyToId != null && filterRemoveReplies) || (status.reblog != null && filterRemoveReblogs) @@ -201,37 +537,66 @@ abstract class TimelineViewModel( } } + /** Updates the current set of filters if filter-related preferences change */ + // TODO: https://github.com/tuskyapp/Tusky/issues/3546, and update if a v2 filter is + // updated as well. + private fun updateFiltersFromPreferences() = eventHub.events + .filterIsInstance() + .filter { FILTER_PREF_KEYS.contains(it.preferenceKey) } + .filter { filterContextMatchesKind(timelineKind, listOf(it.preferenceKey)) } + .distinctUntilChanged() + .map { getFilters() } + .onStart { getFilters() } + + /** + * Gets the current filters from the repository. Applies them locally if they are + * v1 filters. + * + * Whatever the filter kind, the current timeline is invalidated, so it updates with the + * most recent filters. + */ + private fun getFilters() { + viewModelScope.launch { + Log.d(TAG, "getFilters()") + try { + when (val filters = filtersRepository.getFilters()) { + is FilterKind.V1 -> { + filterModel.initWithFilters( + filters.filters.filter { + filterContextMatchesKind(timelineKind, it.context) + } + ) + invalidate() + } + + is FilterKind.V2 -> invalidate() + } + } catch (throwable: Throwable) { + Log.d(TAG, "updateFilter(): Error fetching filters: ${throwable.message}") + _uiErrorChannel.send(UiError.GetFilters(throwable)) + } + } + } + + // TODO: Update this so that the list of UIPrefs is correct private fun onPreferenceChanged(key: String) { when (key) { PrefKeys.TAB_FILTER_HOME_REPLIES -> { val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_REPLIES, true) val oldRemoveReplies = filterRemoveReplies - filterRemoveReplies = kind == Kind.HOME && !filter + filterRemoveReplies = timelineKind is TimelineKind.Home && !filter if (oldRemoveReplies != filterRemoveReplies) { - fullReload() + reloadKeepingReadingPosition() } } PrefKeys.TAB_FILTER_HOME_BOOSTS -> { val filter = sharedPreferences.getBoolean(PrefKeys.TAB_FILTER_HOME_BOOSTS, true) val oldRemoveReblogs = filterRemoveReblogs - filterRemoveReblogs = kind == Kind.HOME && !filter + filterRemoveReblogs = timelineKind is TimelineKind.Home && !filter if (oldRemoveReblogs != filterRemoveReblogs) { - fullReload() - } - } - FilterV1.HOME, FilterV1.NOTIFICATIONS, FilterV1.THREAD, FilterV1.PUBLIC, FilterV1.ACCOUNT -> { - if (filterContextMatchesKind(kind, listOf(key))) { - reloadFilters() + reloadKeepingReadingPosition() } } - PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA -> { - // it is ok if only newly loaded statuses are affected, no need to fully refresh - alwaysShowSensitiveMedia = - accountManager.activeAccount!!.alwaysShowSensitiveMedia - } - PrefKeys.READING_ORDER -> { - readingOrder = ReadingOrder.from(sharedPreferences.getString(PrefKeys.READING_ORDER, null)) - } } } @@ -241,33 +606,33 @@ abstract class TimelineViewModel( is ReblogEvent -> handleReblogEvent(event) is BookmarkEvent -> handleBookmarkEvent(event) is PinEvent -> handlePinEvent(event) - is MuteConversationEvent -> fullReload() + is MuteConversationEvent -> reloadKeepingReadingPosition() is UnfollowEvent -> { - if (kind == Kind.HOME) { + if (timelineKind is TimelineKind.Home) { val id = event.accountId removeAllByAccountId(id) } } is BlockEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + if (timelineKind !is TimelineKind.User) { val id = event.accountId removeAllByAccountId(id) } } is MuteEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + if (timelineKind !is TimelineKind.User) { val id = event.accountId removeAllByAccountId(id) } } is DomainMuteEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + if (timelineKind !is TimelineKind.User) { val instance = event.instance removeAllByInstance(instance) } } is StatusDeletedEvent -> { - if (kind != Kind.USER && kind != Kind.USER_WITH_REPLIES && kind != Kind.USER_PINNED) { + if (timelineKind !is TimelineKind.User) { removeStatusWithId(event.statusId) } } @@ -277,59 +642,24 @@ abstract class TimelineViewModel( } } - private fun reloadFilters() { - viewModelScope.launch { - api.getFilters().fold( - { - // After the filters are loaded we need to reload displayed content to apply them. - // It can happen during the usage or at startup, when we get statuses before filters. - invalidate() - }, - { throwable -> - if (throwable is HttpException && throwable.code() == 404) { - // Fallback to client-side filter code - val filters = api.getFiltersV1().getOrElse { - Log.e(TAG, "Failed to fetch filters", it) - return@launch - } - filterModel.initWithFilters( - filters.filter { - filterContextMatchesKind(kind, it.context) - } - ) - // After the filters are loaded we need to reload displayed content to apply them. - // It can happen during the usage or at startup, when we get statuses before filters. - invalidate() - } else { - Log.e(TAG, "Error getting filters", throwable) - } - } - ) - } - } - companion object { - private const val TAG = "TimelineVM" - internal const val LOAD_AT_ONCE = 30 + private const val TAG = "TimelineViewModel" + private val THROTTLE_TIMEOUT = 500.milliseconds + + /** Preference keys that, if changed, indicate that a filter preference has changed */ + private val FILTER_PREF_KEYS = setOf( + FilterV1.HOME, + FilterV1.NOTIFICATIONS, + FilterV1.THREAD, + FilterV1.PUBLIC, + FilterV1.ACCOUNT + ) fun filterContextMatchesKind( - kind: Kind, + timelineKind: TimelineKind, filterContext: List ): Boolean { - return filterContext.contains(kind.toFilterKind().kind) - } - } - - enum class Kind { - HOME, PUBLIC_LOCAL, PUBLIC_FEDERATED, TAG, USER, USER_PINNED, USER_WITH_REPLIES, FAVOURITES, LIST, BOOKMARKS; - - fun toFilterKind(): Filter.Kind { - return when (valueOf(name)) { - HOME, LIST -> Filter.Kind.HOME - PUBLIC_FEDERATED, PUBLIC_LOCAL, TAG, FAVOURITES -> Filter.Kind.PUBLIC - USER, USER_WITH_REPLIES, USER_PINNED -> Filter.Kind.ACCOUNT - else -> Filter.Kind.PUBLIC - } + return filterContext.contains(Filter.Kind.from(timelineKind).kind) } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt index 1edffcaf90..7a13d47a3e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ThreadAdapter.kt @@ -31,7 +31,7 @@ import com.keylesspalace.tusky.viewdata.StatusViewData class ThreadAdapter( private val statusDisplayOptions: StatusDisplayOptions, private val statusActionListener: StatusActionListener -) : ListAdapter(ThreadDifferCallback) { +) : ListAdapter(ThreadDifferCallback) { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): StatusBaseViewHolder { val inflater = LayoutInflater.from(parent.context) @@ -71,24 +71,24 @@ class ThreadAdapter( private const val VIEW_TYPE_STATUS_DETAILED = 1 private const val VIEW_TYPE_STATUS_FILTERED = 2 - val ThreadDifferCallback = object : DiffUtil.ItemCallback() { + val ThreadDifferCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame( - oldItem: StatusViewData.Concrete, - newItem: StatusViewData.Concrete + oldItem: StatusViewData, + newItem: StatusViewData ): Boolean { return oldItem.id == newItem.id } override fun areContentsTheSame( - oldItem: StatusViewData.Concrete, - newItem: StatusViewData.Concrete + oldItem: StatusViewData, + newItem: StatusViewData ): Boolean { return false // Items are different always. It allows to refresh timestamp on every view holder update } override fun getChangePayload( - oldItem: StatusViewData.Concrete, - newItem: StatusViewData.Concrete + oldItem: StatusViewData, + newItem: StatusViewData ): Any? { return if (oldItem == newItem) { // If items are equal - update timestamp only diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt index d7541f4286..e83e72ebb0 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadFragment.kt @@ -353,7 +353,7 @@ class ViewThreadFragment : } override fun onViewUrl(url: String) { - val status: StatusViewData.Concrete? = viewModel.detailedStatus() + val status: StatusViewData? = viewModel.detailedStatus() if (status != null && status.status.url == url) { // already viewing the status with this url // probably just a preview federated and the user is clicking again to view more -> open the browser @@ -376,10 +376,6 @@ class ViewThreadFragment : viewModel.changeContentShowing(isShowing, adapter.currentList[position]) } - override fun onLoadMore(position: Int) { - // only used in timelines - } - override fun onShowReblogs(position: Int) { val statusId = adapter.currentList[position].id val intent = newIntent(requireContext(), AccountListActivity.Type.REBLOGGED, statusId) diff --git a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt index da8a91e4d6..f66d3b0380 100644 --- a/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt +++ b/app/src/main/java/com/keylesspalace/tusky/components/viewthread/ViewThreadViewModel.kt @@ -114,7 +114,7 @@ class ViewThreadViewModel @Inject constructor( val viewData = timelineStatus.toViewData( gson, isDetailed = true - ) as StatusViewData.Concrete + ) // Return the correct status, depending on which one matched. If you do not do // this the status IDs will be different between the status that's displayed with @@ -181,7 +181,7 @@ class ViewThreadViewModel @Inject constructor( loadThread(id) } - fun detailedStatus(): StatusViewData.Concrete? { + fun detailedStatus(): StatusViewData? { return when (val uiState = _uiState.value) { is ThreadUiState.Success -> uiState.statusViewData.find { status -> status.isDetailed @@ -191,7 +191,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun reblog(reblog: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + fun reblog(reblog: Boolean, status: StatusViewData): Job = viewModelScope.launch { try { timelineCases.reblog(status.actionableId, reblog).getOrThrow() } catch (t: Exception) { @@ -201,7 +201,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun favorite(favorite: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + fun favorite(favorite: Boolean, status: StatusViewData): Job = viewModelScope.launch { try { timelineCases.favourite(status.actionableId, favorite).getOrThrow() } catch (t: Exception) { @@ -211,7 +211,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun bookmark(bookmark: Boolean, status: StatusViewData.Concrete): Job = viewModelScope.launch { + fun bookmark(bookmark: Boolean, status: StatusViewData): Job = viewModelScope.launch { try { timelineCases.bookmark(status.actionableId, bookmark).getOrThrow() } catch (t: Exception) { @@ -221,7 +221,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun voteInPoll(choices: List, status: StatusViewData.Concrete): Job = viewModelScope.launch { + fun voteInPoll(choices: List, status: StatusViewData): Job = viewModelScope.launch { val poll = status.status.actionableStatus.poll ?: run { Log.w(TAG, "No poll on status ${status.id}") return@launch @@ -241,7 +241,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun removeStatus(statusToRemove: StatusViewData.Concrete) { + fun removeStatus(statusToRemove: StatusViewData) { updateSuccess { uiState -> uiState.copy( statusViewData = uiState.statusViewData.filterNot { status -> status == statusToRemove } @@ -249,7 +249,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun changeExpanded(expanded: Boolean, status: StatusViewData.Concrete) { + fun changeExpanded(expanded: Boolean, status: StatusViewData) { updateSuccess { uiState -> val statuses = uiState.statusViewData.map { viewData -> if (viewData.id == status.id) { @@ -265,13 +265,13 @@ class ViewThreadViewModel @Inject constructor( } } - fun changeContentShowing(isShowing: Boolean, status: StatusViewData.Concrete) { + fun changeContentShowing(isShowing: Boolean, status: StatusViewData) { updateStatusViewData(status.id) { viewData -> viewData.copy(isShowingContent = isShowing) } } - fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData.Concrete) { + fun changeContentCollapsed(isCollapsed: Boolean, status: StatusViewData) { updateStatusViewData(status.id) { viewData -> viewData.copy(isCollapsed = isCollapsed) } @@ -373,7 +373,7 @@ class ViewThreadViewModel @Inject constructor( } } - private fun StatusViewData.Concrete.getRevealButtonState(): RevealButtonState { + private fun StatusViewData.getRevealButtonState(): RevealButtonState { val hasWarnings = status.spoilerText.isNotEmpty() return if (hasWarnings) { @@ -394,7 +394,7 @@ class ViewThreadViewModel @Inject constructor( * - If no status sets it to REVEAL, but at least one uses HIDE, use HIDE * - Otherwise use NO_BUTTON */ - private fun List.getRevealButtonState(): RevealButtonState { + private fun List.getRevealButtonState(): RevealButtonState { var seenHide = false forEach { @@ -448,7 +448,7 @@ class ViewThreadViewModel @Inject constructor( } } - private fun List.filter(): List { + private fun List.filter(): List { return filter { status -> if (status.isDetailed) { true @@ -461,7 +461,7 @@ class ViewThreadViewModel @Inject constructor( private fun Status.toViewData( isDetailed: Boolean = false - ): StatusViewData.Concrete { + ): StatusViewData { val oldStatus = (_uiState.value as? ThreadUiState.Success)?.statusViewData?.find { it.id == this.id } return toViewData( isShowingContent = oldStatus?.isShowingContent ?: (alwaysShowSensitiveMedia || !actionableStatus.sensitive), @@ -481,7 +481,7 @@ class ViewThreadViewModel @Inject constructor( } } - private fun updateStatusViewData(statusId: String, updater: (StatusViewData.Concrete) -> StatusViewData.Concrete) { + private fun updateStatusViewData(statusId: String, updater: (StatusViewData) -> StatusViewData) { updateSuccess { uiState -> uiState.copy( statusViewData = uiState.statusViewData.map { viewData -> @@ -503,7 +503,7 @@ class ViewThreadViewModel @Inject constructor( } } - fun clearWarning(viewData: StatusViewData.Concrete) { + fun clearWarning(viewData: StatusViewData) { updateStatus(viewData.id) { status -> status.copy(filtered = null) } @@ -520,7 +520,7 @@ sealed interface ThreadUiState { /** Loading the detailed status has completed, now loading ancestors/descendants */ data class LoadingThread( - val statusViewDatum: StatusViewData.Concrete?, + val statusViewDatum: StatusViewData?, val revealButton: RevealButtonState ) : ThreadUiState @@ -529,7 +529,7 @@ sealed interface ThreadUiState { /** Successfully loaded the full thread */ data class Success( - val statusViewData: List, + val statusViewData: List, val revealButton: RevealButtonState, val detailedStatusPosition: Int ) : ThreadUiState diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt index 9c7999fd9b..6767de993e 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/AccountManager.kt @@ -33,7 +33,7 @@ import javax.inject.Singleton private const val TAG = "AccountManager" @Singleton -class AccountManager @Inject constructor(db: AppDatabase) { +class AccountManager @Inject constructor(val db: AppDatabase) { @Volatile var activeAccount: AccountEntity? = null @@ -128,6 +128,7 @@ class AccountManager @Inject constructor(db: AppDatabase) { accounts.remove(account) accountDao.delete(account) + db.remoteKeyDao().delete(account.id) if (accounts.size > 0) { accounts[0].isActive = true diff --git a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java index d5e8c3e998..3374cd139f 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java +++ b/app/src/main/java/com/keylesspalace/tusky/db/AppDatabase.java @@ -40,9 +40,10 @@ InstanceEntity.class, TimelineStatusEntity.class, TimelineAccountEntity.class, - ConversationEntity.class + ConversationEntity.class, + RemoteKeyEntity.class }, - version = 53, + version = 54, autoMigrations = { @AutoMigration(from = 48, to = 49), @AutoMigration(from = 49, to = 50, spec = AppDatabase.MIGRATION_49_50.class), @@ -57,6 +58,7 @@ public abstract class AppDatabase extends RoomDatabase { @NonNull public abstract ConversationsDao conversationDao(); @NonNull public abstract TimelineDao timelineDao(); @NonNull public abstract DraftDao draftDao(); + @NonNull public abstract RemoteKeyDao remoteKeyDao(); public static final Migration MIGRATION_2_3 = new Migration(2, 3) { @Override @@ -685,4 +687,74 @@ public void migrate(@NonNull SupportSQLiteDatabase database) { database.execSQL("UPDATE `AccountEntity` SET `tabpreferences` = REPLACE(tabpreferences, 'Trending:', 'TrendingTags:')"); } }; + + public static final Migration MIGRATION_53_54 = new Migration(53, 54) { + @Override + public void migrate(@NonNull SupportSQLiteDatabase database) { + database.execSQL("CREATE TABLE IF NOT EXISTS `RemoteKeyEntity` (" + + "`accountId` INTEGER NOT NULL," + + "`timelineId` TEXT NOT NULL," + + "`kind` TEXT NOT NULL," + + "`key` TEXT," + + "PRIMARY KEY(`accountId`, `timelineId`, `kind`) )"); + + database.execSQL("DROP TABLE IF EXISTS `TimelineAccountEntity`"); + database.execSQL("DROP TABLE IF EXISTS `TimelineStatusEntity`"); + + database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineAccountEntity` (" + + "`serverId` TEXT NOT NULL," + + "`timelineUserId` INTEGER NOT NULL," + + "`localUsername` TEXT NOT NULL," + + "`username` TEXT NOT NULL," + + "`displayName` TEXT NOT NULL," + + "`url` TEXT NOT NULL," + + "`avatar` TEXT NOT NULL," + + "`emojis` TEXT NOT NULL," + + "`bot` INTEGER NOT NULL," + + "PRIMARY KEY(`serverId`, `timelineUserId`) )"); + + // Changing `authorServerId` from `TEXT` to `TEXT NOT NULL` + database.execSQL("CREATE TABLE IF NOT EXISTS `TimelineStatusEntity` (" + + "`serverId` TEXT NOT NULL," + + "`url` TEXT," + + "`timelineUserId` INTEGER NOT NULL," + + "`authorServerId` TEXT NOT NULL," + + "`inReplyToId` TEXT," + + "`inReplyToAccountId` TEXT," + + "`content` TEXT," + + "`createdAt` INTEGER NOT NULL," + + "`emojis` TEXT," + + "`reblogsCount` INTEGER NOT NULL," + + "`favouritesCount` INTEGER NOT NULL," + + "`reblogged` INTEGER NOT NULL," + + "`bookmarked` INTEGER NOT NULL," + + "`favourited` INTEGER NOT NULL," + + "`sensitive` INTEGER NOT NULL," + + "`spoilerText` TEXT NOT NULL," + + "`visibility` INTEGER NOT NULL," + + "`attachments` TEXT," + + "`mentions` TEXT," + + "`application` TEXT," + + "`reblogServerId` TEXT," + + "`reblogAccountId` TEXT," + + "`poll` TEXT," + + "`muted` INTEGER," + + "`expanded` INTEGER NOT NULL," + + "`contentCollapsed` INTEGER NOT NULL," + + "`contentShowing` INTEGER NOT NULL," + + "`pinned` INTEGER NOT NULL," + + "`tags` TEXT," + + "`card` TEXT," + + "`repliesCount` INTEGER NOT NULL DEFAULT 0," + + "`language` TEXT," + + "`editedAt` INTEGER," + + "`filtered` TEXT," + + "PRIMARY KEY(`serverId`, `timelineUserId`)," + + "FOREIGN KEY(`authorServerId`, `timelineUserId`) REFERENCES `TimelineAccountEntity`(`serverId`, `timelineUserId`)" + + "ON UPDATE NO ACTION ON DELETE NO ACTION )"); + + database.execSQL("CREATE INDEX IF NOT EXISTS `index_TimelineStatusEntity_authorServerId_timelineUserId`" + + "ON `TimelineStatusEntity` (`authorServerId`, `timelineUserId`)"); + } + }; } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyDao.kt new file mode 100644 index 0000000000..95578c62f9 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyDao.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface RemoteKeyDao { + // TODO(https://issuetracker.google.com/issues/243039555), switch to @Upsert + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(remoteKey: RemoteKeyEntity) + + @Query("SELECT * FROM RemoteKeyEntity WHERE accountId = :accountId AND timelineId = :timelineId AND kind = :kind") + suspend fun remoteKeyForKind(accountId: Long, timelineId: String, kind: RemoteKeyKind): RemoteKeyEntity? + + // TODO: This can be marked suspend when AccountManager.logActiveAccountOut() has a coroutine scope + @Query("DELETE FROM RemoteKeyEntity WHERE accountId = :accountId") + fun delete(accountId: Long) +} diff --git a/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyEntity.kt new file mode 100644 index 0000000000..2c35d13390 --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/db/RemoteKeyEntity.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.db + +import androidx.room.Entity + +enum class RemoteKeyKind { + /** Key to load the next (chronologically oldest) page of data for this timeline */ + NEXT, + + /** Key to load the previous (chronologically newer) page of data for this timeline */ + PREV +} + +/** + * The next and previous keys for the given timeline. + */ +@Entity( + primaryKeys = ["accountId", "timelineId", "kind"] +) +data class RemoteKeyEntity( + /** User account these keys relate to. */ + val accountId: Long, + /** + * Identifier for the timeline these keys relate to. + * + * At the moment there is only one valid value here, "home", as that + * is the only timeline that is cached. As more timelines become cacheable + * this will need to be expanded. + * + * This also needs to be extensible in the future to cover the case where + * the user might have multiple timelines from the same base timeline, but + * with different configurations. E.g., two home timelines, one with boosts + * and replies turned off, and one with boosts and replies turned on. + */ + val timelineId: String, + val kind: RemoteKeyKind, + val key: String? = null +) diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt index 0bf1267fa6..80d8ffd30c 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineDao.kt @@ -53,6 +53,21 @@ ORDER BY LENGTH(s.serverId) DESC, s.serverId DESC""" ) abstract fun getStatuses(account: Long): PagingSource + /** + * All statuses for [account] in timeline ID. Used to find the correct initialKey to restore + * the user's reading position. + * + * @see [com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel.statuses] + */ + @Query( + """ +SELECT serverId + FROM TimelineStatusEntity + WHERE timelineUserId = :account + ORDER BY LENGTH(serverId) DESC, serverId DESC""" + ) + abstract fun getStatusRowNumber(account: Long): List + @Query( """ SELECT s.serverId, s.url, s.timelineUserId, @@ -206,12 +221,6 @@ AND timelineUserId = :accountId @Query("UPDATE TimelineStatusEntity SET filtered = NULL WHERE timelineUserId = :accountId AND (serverId = :statusId OR reblogServerId = :statusId)") abstract suspend fun clearWarning(accountId: Long, statusId: String): Int - @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") - abstract suspend fun getTopId(accountId: Long): String? - - @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") - abstract suspend fun getTopPlaceholderId(accountId: Long): String? - /** * Returns the id directly above [serverId], or null if [serverId] is the id of the top status */ @@ -225,20 +234,10 @@ AND timelineUserId = :accountId @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") abstract suspend fun getIdBelow(accountId: Long, serverId: String): String? - /** - * Returns the id of the next placeholder after [serverId] - */ - @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId AND authorServerId IS NULL AND (LENGTH(:serverId) > LENGTH(serverId) OR (LENGTH(:serverId) = LENGTH(serverId) AND :serverId > serverId)) ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT 1") - abstract suspend fun getNextPlaceholderIdAfter(accountId: Long, serverId: String): String? - @Query("SELECT COUNT(*) FROM TimelineStatusEntity WHERE timelineUserId = :accountId") abstract suspend fun getStatusCount(accountId: Long): Int /** Developer tools: Find N most recent status IDs */ @Query("SELECT serverId FROM TimelineStatusEntity WHERE timelineUserId = :accountId ORDER BY LENGTH(serverId) DESC, serverId DESC LIMIT :count") abstract suspend fun getMostRecentNStatusIds(accountId: Long, count: Int): List - - /** Developer tools: Convert a status to a placeholder */ - @Query("UPDATE TimelineStatusEntity SET authorServerId = NULL WHERE serverId = :serverId") - abstract suspend fun convertStatustoPlaceholder(serverId: String) } diff --git a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt index f0f7f98ed6..cf8a3c26ac 100644 --- a/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt +++ b/app/src/main/java/com/keylesspalace/tusky/db/TimelineStatusEntity.kt @@ -54,7 +54,7 @@ data class TimelineStatusEntity( val url: String?, // our local id for the logged in user in case there are multiple accounts per instance val timelineUserId: Long, - val authorServerId: String?, + val authorServerId: String, val inReplyToId: String?, val inReplyToAccountId: String?, val content: String?, @@ -78,7 +78,6 @@ data class TimelineStatusEntity( val reblogAccountId: String?, val poll: String?, val muted: Boolean?, - /** Also used as the "loading" attribute when this TimelineStatusEntity is a placeholder */ val expanded: Boolean, val contentCollapsed: Boolean, val contentShowing: Boolean, @@ -86,10 +85,7 @@ data class TimelineStatusEntity( val card: String?, val language: String?, val filtered: List? -) { - val isPlaceholder: Boolean - get() = this.authorServerId == null -} +) @Entity( primaryKeys = ["serverId", "timelineUserId"] @@ -110,7 +106,7 @@ data class TimelineStatusWithAccount( @Embedded val status: TimelineStatusEntity, @Embedded(prefix = "a_") - val account: TimelineAccountEntity? = null, // null when placeholder + val account: TimelineAccountEntity, @Embedded(prefix = "rb_") val reblogAccount: TimelineAccountEntity? = null // null when no reblog ) diff --git a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt index 0f65d6c258..81c50c4e37 100644 --- a/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt +++ b/app/src/main/java/com/keylesspalace/tusky/di/AppModule.kt @@ -68,7 +68,9 @@ class AppModule { AppDatabase.MIGRATION_38_39, AppDatabase.MIGRATION_39_40, AppDatabase.MIGRATION_40_41, AppDatabase.MIGRATION_41_42, AppDatabase.MIGRATION_42_43, AppDatabase.MIGRATION_43_44, AppDatabase.MIGRATION_44_45, AppDatabase.MIGRATION_45_46, AppDatabase.MIGRATION_46_47, - AppDatabase.MIGRATION_47_48, AppDatabase.MIGRATION_52_53 + AppDatabase.MIGRATION_47_48, /* 48 -> 49, auto */ /* 49 -> 50, auto */ + /* 50 -> 51, auto */ /* 51 -> 52, auto */ AppDatabase.MIGRATION_52_53, + AppDatabase.MIGRATION_53_54 ) .build() } diff --git a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt index 236e216e1b..dbc5378280 100644 --- a/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt +++ b/app/src/main/java/com/keylesspalace/tusky/entity/Filter.kt @@ -2,6 +2,7 @@ package com.keylesspalace.tusky.entity import android.os.Parcelable import com.google.gson.annotations.SerializedName +import com.keylesspalace.tusky.components.timeline.TimelineKind import kotlinx.parcelize.Parcelize import java.util.Date @@ -33,6 +34,16 @@ data class Filter( companion object { fun from(kind: String): Kind = values().firstOrNull { it.kind == kind } ?: PUBLIC + + fun from(kind: TimelineKind): Kind = when (kind) { + is TimelineKind.Home, is TimelineKind.UserList -> HOME + is TimelineKind.PublicFederated, + is TimelineKind.PublicLocal, + is TimelineKind.Tag, + is TimelineKind.Favourites -> Filter.Kind.PUBLIC + is TimelineKind.User -> Filter.Kind.ACCOUNT + else -> Filter.Kind.PUBLIC + } } } diff --git a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java index e142683a16..88225b9e88 100644 --- a/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java +++ b/app/src/main/java/com/keylesspalace/tusky/interfaces/StatusActionListener.java @@ -17,11 +17,11 @@ import android.view.View; -import java.util.List; - import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import java.util.List; + public interface StatusActionListener extends LinkListener { void onReply(int position); void onReblog(final boolean reblog, final int position); @@ -38,7 +38,6 @@ public interface StatusActionListener extends LinkListener { void onOpenReblog(int position); void onExpandedChange(boolean expanded, int position); void onContentHiddenChange(boolean isShowing, int position); - void onLoadMore(int position); /** * Called when the status {@link android.widget.ToggleButton} responsible for collapsing long @@ -64,7 +63,7 @@ default void onShowFavs(int position) {} void onVoteInPoll(int position, @NonNull List choices); default void onShowEdits(int position) {} - + void clearWarningAction(int position); } diff --git a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt index c64690d1a0..4593c190f6 100644 --- a/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt +++ b/app/src/main/java/com/keylesspalace/tusky/network/MastodonApi.kt @@ -45,6 +45,7 @@ import com.keylesspalace.tusky.entity.StatusEdit import com.keylesspalace.tusky.entity.StatusSource import com.keylesspalace.tusky.entity.TimelineAccount import com.keylesspalace.tusky.entity.TrendingTag +import com.keylesspalace.tusky.util.HttpHeaderLink import io.reactivex.rxjava3.core.Single import okhttp3.MultipartBody import okhttp3.RequestBody @@ -71,6 +72,23 @@ import retrofit2.http.Query * for documentation of the Mastodon REST API see https://docs.joinmastodon.org/api/ */ +/** Models next/prev links from the "Links" header in an API response */ +data class Links(val next: String?, val prev: String?) { + companion object { + fun from(linkHeader: String?): Links { + val links = HttpHeaderLink.parse(linkHeader) + return Links( + next = HttpHeaderLink.findByRelationType(links, "next")?.uri?.getQueryParameter( + "max_id" + ), + prev = HttpHeaderLink.findByRelationType(links, "prev")?.uri?.getQueryParameter( + "min_id" + ) + ) + } + } +} + @JvmSuppressWildcards interface MastodonApi { @@ -106,6 +124,7 @@ interface MastodonApi { @Query("local") local: Boolean? = null, @Query("max_id") maxId: String? = null, @Query("since_id") sinceId: String? = null, + @Query("min_id") minId: String? = null, @Query("limit") limit: Int? = null ): Response> @@ -114,17 +133,19 @@ interface MastodonApi { @Path("hashtag") hashtag: String, @Query("any[]") any: List?, @Query("local") local: Boolean?, - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int? + @Query("max_id") maxId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("min_id") minId: String? = null, + @Query("limit") limit: Int? = null ): Response> @GET("api/v1/timelines/list/{listId}") suspend fun listTimeline( @Path("listId") listId: String, - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, - @Query("limit") limit: Int? + @Query("max_id") maxId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("min_id") minId: String? = null, + @Query("limit") limit: Int? = null ): Response> @GET("api/v1/notifications") @@ -372,6 +393,7 @@ interface MastodonApi { @Path("id") accountId: String, @Query("max_id") maxId: String? = null, @Query("since_id") sinceId: String? = null, + @Query("min_id") minId: String? = null, @Query("limit") limit: Int? = null, @Query("exclude_replies") excludeReplies: Boolean? = null, @Query("only_media") onlyMedia: Boolean? = null, @@ -471,15 +493,17 @@ interface MastodonApi { @GET("api/v1/favourites") suspend fun favourites( - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, + @Query("max_id") maxId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("min_id") minId: String? = null, @Query("limit") limit: Int? ): Response> @GET("api/v1/bookmarks") suspend fun bookmarks( - @Query("max_id") maxId: String?, - @Query("since_id") sinceId: String?, + @Query("max_id") maxId: String? = null, + @Query("since_id") sinceId: String? = null, + @Query("min_id") minId: String? = null, @Query("limit") limit: Int? ): Response> diff --git a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt index 636c1fc698..6e9063de92 100644 --- a/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt +++ b/app/src/main/java/com/keylesspalace/tusky/settings/SettingsConstants.kt @@ -41,7 +41,7 @@ enum class AppTheme(val value: String) { * * - Adding a new preference that does not change the interpretation of an existing preference */ -const val SCHEMA_VERSION = 2023072401 +const val SCHEMA_VERSION = 2023082201 object PrefKeys { // Note: not all of these keys are actually used as SharedPreferences keys but we must give @@ -53,7 +53,6 @@ object PrefKeys { const val FAB_HIDE = "fabHide" const val LANGUAGE = "language" const val STATUS_TEXT_SIZE = "statusTextSize" - const val READING_ORDER = "readingOrder" const val MAIN_NAV_POSITION = "mainNavPosition" const val HIDE_TOP_TOOLBAR = "hideTopToolbar" const val ABSOLUTE_TIME_VIEW = "absoluteTimeView" @@ -107,5 +106,6 @@ object PrefKeys { /** Keys that are no longer used (e.g., the preference has been removed */ object Deprecated { const val SHOW_NOTIFICATIONS_FILTER = "showNotificationsFilter" + const val READING_ORDER = "readingOrder" } } diff --git a/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt b/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt index 8724718a8d..cd98ed6c45 100644 --- a/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt +++ b/app/src/main/java/com/keylesspalace/tusky/usecase/DeveloperToolsUseCase.kt @@ -1,6 +1,5 @@ package com.keylesspalace.tusky.usecase -import android.util.Log import androidx.room.withTransaction import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.TimelineDao @@ -17,26 +16,19 @@ class DeveloperToolsUseCase @Inject constructor( private var timelineDao: TimelineDao = db.timelineDao() /** - * Create a gap in the home timeline to make it easier to interactively experiment with - * different "Load more" behaviours. - * - * Do this by taking the 10 most recent statuses, keeping the first 2, deleting the next 7, - * and replacing the last one with a placeholder. + * Clear the home timeline cache. */ - suspend fun createLoadMoreGap(accountId: Long) { - db.withTransaction { - val ids = timelineDao.getMostRecentNStatusIds(accountId, 10) - val maxId = ids[2] - val minId = ids[8] - val placeHolderId = ids[9] - - Log.d( - "TAG", - "createLoadMoreGap: creating gap between $minId .. $maxId (new placeholder: $placeHolderId" - ) + suspend fun clearHomeTimelineCache(accountId: Long) { + timelineDao.removeAllStatuses(accountId) + } - timelineDao.deleteRange(accountId, minId, maxId) - timelineDao.convertStatustoPlaceholder(placeHolderId) + /** + * Delete first K statuses + */ + suspend fun deleteFirstKStatuses(accountId: Long, k: Int) { + db.withTransaction { + val ids = timelineDao.getMostRecentNStatusIds(accountId, 40) + timelineDao.deleteRange(accountId, ids.last(), ids.first()) } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/BooleanExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/BooleanExtensions.kt new file mode 100644 index 0000000000..3ce757e58a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/BooleanExtensions.kt @@ -0,0 +1,19 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +/** True if the Boolean? is true, false if it is null or false */ +fun Boolean?.isTrue() = this != null && this diff --git a/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt new file mode 100644 index 0000000000..82c27b339c --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/CombinedLoadStatesExtensions.kt @@ -0,0 +1,275 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.util + +import android.util.Log +import androidx.paging.CombinedLoadStates +import androidx.paging.LoadState +import com.keylesspalace.tusky.BuildConfig +import com.keylesspalace.tusky.util.PresentationState.INITIAL +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.scan + +/** + * Each [CombinedLoadStates] state does not contain enough information to understand the actual + * state unless previous states have been observed. + * + * This tracks those states and provides a [PresentationState] that describes whether the most + * recent refresh has presented the data via the associated adapter. + */ +enum class PresentationState { + /** Initial state, nothing is known about the load state */ + INITIAL, + + /** RemoteMediator is loading the first requested page of results */ + REMOTE_LOADING, + + /** PagingSource is loading the first requested page of results */ + SOURCE_LOADING, + + /** There was an error loading the first page of results */ + ERROR, + + /** The first request page of results is visible via the adapter */ + PRESENTED; + + /** + * Take the next step in the PresentationState state machine, given [loadState] + */ + fun next(loadState: CombinedLoadStates): PresentationState { + if (loadState.mediator?.refresh is LoadState.Error) return ERROR + if (loadState.source.refresh is LoadState.Error) return ERROR + + return when (this) { + INITIAL -> when (loadState.mediator?.refresh) { + is LoadState.Loading -> REMOTE_LOADING + else -> this + } + + REMOTE_LOADING -> when (loadState.source.refresh) { + is LoadState.Loading -> SOURCE_LOADING + else -> this + } + + SOURCE_LOADING -> when (loadState.refresh) { + is LoadState.NotLoading -> PRESENTED + else -> this + } + + ERROR -> INITIAL.next(loadState) + + PRESENTED -> when (loadState.mediator?.refresh) { + is LoadState.Loading -> REMOTE_LOADING + else -> this + } + } + } +} + +/** + * @return Flow that combines the [CombinedLoadStates] with its associated [PresentationState]. + */ +fun Flow.withPresentationState(): Flow> { + val presentationStateFlow = scan(INITIAL) { state, loadState -> + state.next(loadState) + } + .distinctUntilChanged() + + return this.combine(presentationStateFlow) { loadState, presentationState -> + Pair(loadState, presentationState) + } +} + +/** + * The state of the refresh from the user's perspective. A refresh is "complete" for a user if + * the refresh has completed, **and** the first prepend triggered by that refresh has also + * completed. + * + * This means that new data has been loaded and (if the prepend found new data) the user can + * start scrolling up to see it. Any progress indicators can be removed, and the UI can scroll + * to disclose new content. + */ +enum class UserRefreshState { + /** No active refresh, waiting for one to start */ + WAITING, + + /** A refresh (and possibly the first prepend) is underway */ + ACTIVE, + + /** The refresh and the first prepend after a refresh has completed */ + COMPLETE, + + /** A refresh or prepend operation was [LoadState.Error] */ + ERROR; +} + +/** + * Each [CombinedLoadStates] state does not contain enough information to understand the actual + * state unless previous states have been observed. + * + * This tracks those states and provides a [UserRefreshState] that describes whether the most recent + * [Refresh][androidx.paging.PagingSource.LoadParams.Refresh] and its associated first + * [Prepend][androidx.paging.PagingSource.LoadParams.Prepend] operation has completed. + */ +fun Flow.asRefreshState(): Flow { + // Can't use CombinedLoadStates.refresh and .prepend on their own. In testing I've observed + // situations where: + // + // - the refresh completes before the prepend starts + // - the prepend starts before the refresh completes + // - the prepend *ends* before the refresh completes (but after the refresh starts) + // + // So you need to track the state of both the refresh and the prepend actions, and merge them + // in to a single state that answers the question "Has the refresh, and the first prepend + // started by that refresh, finished?" + // + // In addition, a prepend operation might involve both the mediator and the source, or only + // one of them. Since loadState.prepend tracks the mediator property this means a prepend that + // only modifies loadState.source will not be reflected in loadState.prepend. + // + // So the code also has to track whether the prepend transition was initiated by the mediator + // or the source property, and look for the end of the transition on the same property. + + /** The state of the "refresh" portion of the user refresh */ + var refresh = UserRefreshState.WAITING + + /** The state of the "prepend" portion of the user refresh */ + var prepend = UserRefreshState.WAITING + + /** True if the state of the prepend portion is derived from the mediator property */ + var usePrependMediator = false + + var previousLoadState: CombinedLoadStates? = null + + return map { loadState -> + // Debug helper, show the differences between successive load states. + if (BuildConfig.DEBUG) { + previousLoadState?.let { + val loadStateDiff = loadState.diff(previousLoadState) + Log.d("RefreshState", "Current state: $refresh $prepend") + if (loadStateDiff.isNotEmpty()) Log.d("RefreshState", loadStateDiff) + } + previousLoadState = loadState + } + + // Bail early on errors + if (loadState.refresh is LoadState.Error || loadState.prepend is LoadState.Error) { + refresh = UserRefreshState.WAITING + prepend = UserRefreshState.WAITING + return@map UserRefreshState.ERROR + } + + // Handling loadState.refresh is straightforward + refresh = when (loadState.refresh) { + is LoadState.Loading -> if (refresh == UserRefreshState.WAITING) UserRefreshState.ACTIVE else refresh + is LoadState.NotLoading -> if (refresh == UserRefreshState.ACTIVE) UserRefreshState.COMPLETE else refresh + else -> { throw IllegalStateException("can't happen, LoadState.Error is already handled") } + } + + // Prepend can only transition to active if there is an active or complete refresh + // (i.e., the refresh is not WAITING). + if (refresh != UserRefreshState.WAITING && prepend == UserRefreshState.WAITING) { + if (loadState.mediator?.prepend is LoadState.Loading) { + usePrependMediator = true + prepend = UserRefreshState.ACTIVE + } + if (loadState.source.prepend is LoadState.Loading) { + usePrependMediator = false + prepend = UserRefreshState.ACTIVE + } + } + + if (prepend == UserRefreshState.ACTIVE) { + if (usePrependMediator && loadState.mediator?.prepend is LoadState.NotLoading) { + prepend = UserRefreshState.COMPLETE + } + if (!usePrependMediator && loadState.source.prepend is LoadState.NotLoading) { + prepend = UserRefreshState.COMPLETE + } + } + + // Determine the new user refresh state by combining the refresh and prepend states + // + // - If refresh and prepend are identical use the refresh value + // - If refresh is WAITING then the state is WAITING (waiting for a refresh implies waiting + // for a prepend too) + // - Otherwise, one of them is active (doesn't matter which), so the state is ACTIVE + val newUserRefreshState = when (refresh) { + prepend -> refresh + UserRefreshState.WAITING -> UserRefreshState.WAITING + else -> UserRefreshState.ACTIVE + } + + // If the new state is COMPLETE reset the individual states back to WAITING, ready for + // the next user refresh. + if (newUserRefreshState == UserRefreshState.COMPLETE) { + refresh = UserRefreshState.WAITING + prepend = UserRefreshState.WAITING + } + + return@map newUserRefreshState + } + .distinctUntilChanged() +} + +/** + * Debug helper that generates a string showing the effective difference between two [CombinedLoadStates]. + * + * @param prev the value to compare against + * @return A (possibly multi-line) string showing the fields that differed + */ +fun CombinedLoadStates.diff(prev: CombinedLoadStates?): String { + prev ?: return "" + + val result = mutableListOf() + + if (prev.refresh != refresh) { + result.add(".refresh ${prev.refresh} -> $refresh") + } + if (prev.source.refresh != source.refresh) { + result.add(" .source.refresh ${prev.source.refresh} -> ${source.refresh}") + } + if (prev.mediator?.refresh != mediator?.refresh) { + result.add(" .mediator.refresh ${prev.mediator?.refresh} -> ${mediator?.refresh}") + } + + if (prev.prepend != prepend) { + result.add(".prepend ${prev.prepend} -> $prepend") + } + if (prev.source.prepend != source.prepend) { + result.add(" .source.prepend ${prev.source.prepend} -> ${source.prepend}") + } + if (prev.mediator?.prepend != mediator?.prepend) { + result.add(" .mediator.prepend ${prev.mediator?.prepend} -> ${mediator?.prepend}") + } + + if (prev.append != append) { + result.add(".append ${prev.append} -> $append") + } + if (prev.source.append != source.append) { + result.add(" .source.append ${prev.source.append} -> ${source.append}") + } + if (prev.mediator?.append != mediator?.append) { + result.add(" .mediator.append ${prev.mediator?.append} -> ${mediator?.append}") + } + + return result.joinToString("\n") +} diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt index 9402edd037..da057cbec2 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ListStatusAccessibilityDelegate.kt @@ -47,7 +47,7 @@ class ListStatusAccessibilityDelegate( val pos = recyclerView.getChildAdapterPosition(host) val status = statusProvider.getStatus(pos) ?: return - if (status is StatusViewData.Concrete) { + if (status is StatusViewData) { if (status.spoilerText.isNotEmpty()) { info.addAction(if (status.isExpanded) collapseCwAction else expandCwAction) } @@ -114,7 +114,7 @@ class ListStatusAccessibilityDelegate( R.id.action_open_profile -> { interrupt() statusActionListener.onViewAccount( - (statusProvider.getStatus(pos) as StatusViewData.Concrete).actionable.account.id + (statusProvider.getStatus(pos) as StatusViewData).actionable.account.id ) } R.id.action_open_media_1 -> { @@ -171,7 +171,7 @@ class ListStatusAccessibilityDelegate( } private fun showLinksDialog(host: View) { - val status = getStatus(host) as? StatusViewData.Concrete ?: return + val status = getStatus(host) as? StatusViewData ?: return val links = getLinks(status).toList() val textLinks = links.map { item -> item.link } AlertDialog.Builder(host.context) @@ -188,7 +188,7 @@ class ListStatusAccessibilityDelegate( } private fun showMentionsDialog(host: View) { - val status = getStatus(host) as? StatusViewData.Concrete ?: return + val status = getStatus(host) as? StatusViewData ?: return val mentions = status.actionable.mentions val stringMentions = mentions.map { it.username } AlertDialog.Builder(host.context) @@ -207,7 +207,7 @@ class ListStatusAccessibilityDelegate( } private fun showHashtagsDialog(host: View) { - val status = getStatus(host) as? StatusViewData.Concrete ?: return + val status = getStatus(host) as? StatusViewData ?: return val tags = getHashtags(status).map { it.subSequence(1, it.length) }.toList() AlertDialog.Builder(host.context) .setTitle(R.string.title_hashtags_dialog) @@ -229,7 +229,7 @@ class ListStatusAccessibilityDelegate( } } - private fun getLinks(status: StatusViewData.Concrete): Sequence { + private fun getLinks(status: StatusViewData): Sequence { val content = status.content return if (content is Spannable) { content.getSpans(0, content.length, URLSpan::class.java) @@ -247,7 +247,7 @@ class ListStatusAccessibilityDelegate( } } - private fun getHashtags(status: StatusViewData.Concrete): Sequence { + private fun getHashtags(status: StatusViewData): Sequence { val content = status.content return content.getSpans(0, content.length, Object::class.java) .asSequence() diff --git a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt index 7151f93ac9..481c1bd0b8 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/StatusDisplayOptions.kt @@ -73,6 +73,9 @@ data class StatusDisplayOptions( PrefKeys.USE_BLURHASH -> copy( useBlurhash = preferences.getBoolean(key, true) ) + PrefKeys.SHOW_CARDS_IN_TIMELINES -> copy( + cardViewMode = if (preferences.getBoolean(key, false)) CardViewMode.INDENTED else CardViewMode.NONE + ) PrefKeys.CONFIRM_FAVOURITES -> copy( confirmFavourites = preferences.getBoolean(key, false) ) @@ -91,6 +94,9 @@ data class StatusDisplayOptions( PrefKeys.ALWAYS_OPEN_SPOILER -> copy( openSpoiler = account.alwaysOpenSpoiler ) + PrefKeys.SHOW_STATS_INLINE -> copy( + showStatsInline = preferences.getBoolean(key, false) + ) else -> { this } } @@ -107,7 +113,8 @@ data class StatusDisplayOptions( PrefKeys.MEDIA_PREVIEW_ENABLED, PrefKeys.SHOW_BOT_OVERLAY, PrefKeys.USE_BLURHASH, - PrefKeys.WELLBEING_HIDE_STATS_POSTS + PrefKeys.WELLBEING_HIDE_STATS_POSTS, + PrefKeys.SHOW_STATS_INLINE ) fun from(preferences: SharedPreferences, account: AccountEntity) = StatusDisplayOptions( @@ -117,7 +124,11 @@ data class StatusDisplayOptions( useAbsoluteTime = preferences.getBoolean(PrefKeys.ABSOLUTE_TIME_VIEW, false), showBotOverlay = preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true), useBlurhash = preferences.getBoolean(PrefKeys.USE_BLURHASH, true), - cardViewMode = CardViewMode.NONE, + cardViewMode = if (preferences.getBoolean(PrefKeys.SHOW_CARDS_IN_TIMELINES, false)) { + CardViewMode.INDENTED + } else { + CardViewMode.NONE + }, confirmReblogs = preferences.getBoolean(PrefKeys.CONFIRM_REBLOGS, true), confirmFavourites = preferences.getBoolean(PrefKeys.CONFIRM_FAVOURITES, false), hideStats = preferences.getBoolean(PrefKeys.WELLBEING_HIDE_STATS_POSTS, false), diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt index 60633f7257..3651d921a3 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ThrowableExtensions.kt @@ -37,6 +37,6 @@ fun Throwable.getDrawableRes(): Int = when (this) { /** @return A string error message for this throwable */ fun Throwable.getErrorString(context: Context): String = getServerErrorMessage() ?: when (this) { - is IOException -> context.getString(R.string.error_network) - else -> context.getString(R.string.error_generic) + is IOException -> context.getString(R.string.error_network_fmt, this.message) + else -> context.getString(R.string.error_generic_fmt, this.message) } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt index f6ae9e7d79..674057844a 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ViewDataUtils.kt @@ -45,8 +45,8 @@ fun Status.toViewData( isExpanded: Boolean, isCollapsed: Boolean, isDetailed: Boolean = false -): StatusViewData.Concrete { - return StatusViewData.Concrete( +): StatusViewData { + return StatusViewData( status = this, isShowingContent = isShowingContent, isCollapsed = isCollapsed, diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt index 759d633e28..8a5fa3fe31 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/NotificationViewData.kt @@ -38,6 +38,6 @@ data class NotificationViewData( val type: Notification.Type, val id: String, val account: TimelineAccount, - var statusViewData: StatusViewData.Concrete?, + var statusViewData: StatusViewData?, val report: Report? ) diff --git a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt index 5ef3ef3730..3156f8347b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt +++ b/app/src/main/java/com/keylesspalace/tusky/viewdata/StatusViewData.kt @@ -23,87 +23,78 @@ import com.keylesspalace.tusky.util.replaceCrashingCharacters import com.keylesspalace.tusky.util.shouldTrimStatus /** - * Created by charlag on 11/07/2017. - * - * Class to represent data required to display either a notification or a placeholder. - * It is either a [StatusViewData.Concrete] or a [StatusViewData.Placeholder]. + * Data required to display a status. */ -sealed class StatusViewData { - abstract val id: String - var filterAction: Filter.Action = Filter.Action.NONE +data class StatusViewData( + var status: Status, + /** + * If the status includes a non-empty content warning ([spoilerText]), specifies whether + * just the content warning is showing (false), or the whole status content is showing (true). + * + * Ignored if there is no content warning. + */ + val isExpanded: Boolean, + val isShowingContent: Boolean, - data class Concrete( - val status: Status, - val isExpanded: Boolean, - val isShowingContent: Boolean, - /** - * Specifies whether the content of this post is currently limited in visibility to the first - * 500 characters or not. - * - * @return Whether the post is collapsed or fully expanded. - */ - val isCollapsed: Boolean, - val isDetailed: Boolean = false - ) : StatusViewData() { - override val id: String - get() = status.id - - /** - * Specifies whether the content of this post is long enough to be automatically - * collapsed or if it should show all content regardless. - * - * @return Whether the post is collapsible or never collapsed. - */ - val isCollapsible: Boolean + /** + * Specifies whether the content of this status is currently limited in visibility to the first + * 500 characters or not. + * + * @return Whether the status is collapsed or fully expanded. + */ + val isCollapsed: Boolean, + val isDetailed: Boolean = false +) { + var filterAction: Filter.Action = Filter.Action.NONE + val id: String + get() = status.id - val content: Spanned - val spoilerText: String - val username: String + /** + * Specifies whether the content of this status is long enough to be automatically + * collapsed or if it should show all content regardless. + * + * @return Whether the status is collapsible or never collapsed. + */ + val isCollapsible: Boolean - val actionable: Status - get() = status.actionableStatus + val content: Spanned - val actionableId: String - get() = status.actionableStatus.id + /** The content warning, may be the empty string */ + val spoilerText: String + val username: String - val rebloggedAvatar: String? - get() = if (status.reblog != null) { - status.account.avatar - } else { - null - } + val actionable: Status + get() = status.actionableStatus - val rebloggingStatus: Status? - get() = if (status.reblog != null) status else null + val actionableId: String + get() = status.actionableStatus.id - init { - if (Build.VERSION.SDK_INT == 23) { - // https://github.com/tuskyapp/Tusky/issues/563 - this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml()) - this.spoilerText = - replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() - this.username = - replaceCrashingCharacters(status.actionableStatus.account.username).toString() - } else { - this.content = status.actionableStatus.content.parseAsMastodonHtml() - this.spoilerText = status.actionableStatus.spoilerText - this.username = status.actionableStatus.account.username - } - this.isCollapsible = shouldTrimStatus(this.content) + val rebloggedAvatar: String? + get() = if (status.reblog != null) { + status.account.avatar + } else { + null } - /** Helper for Java */ - fun copyWithCollapsed(isCollapsed: Boolean): Concrete { - return copy(isCollapsed = isCollapsed) + val rebloggingStatus: Status? + get() = if (status.reblog != null) status else null + + init { + if (Build.VERSION.SDK_INT == 23) { + // https://github.com/tuskyapp/Tusky/issues/563 + this.content = replaceCrashingCharacters(status.actionableStatus.content.parseAsMastodonHtml()) + this.spoilerText = + replaceCrashingCharacters(status.actionableStatus.spoilerText).toString() + this.username = + replaceCrashingCharacters(status.actionableStatus.account.username).toString() + } else { + this.content = status.actionableStatus.content.parseAsMastodonHtml() + this.spoilerText = status.actionableStatus.spoilerText + this.username = status.actionableStatus.account.username } + this.isCollapsible = shouldTrimStatus(this.content) } - data class Placeholder( - override val id: String, - val isLoading: Boolean - ) : StatusViewData() - - fun asStatusOrNull() = this as? Concrete - - fun asPlaceholderOrNull() = this as? Placeholder + /** Helper for Java */ + fun copyWithCollapsed(isCollapsed: Boolean) = copy(isCollapsed = isCollapsed) } diff --git a/app/src/main/res/layout/item_notifications_load_state_footer_view.xml b/app/src/main/res/layout/item_load_state_footer_view.xml similarity index 100% rename from app/src/main/res/layout/item_notifications_load_state_footer_view.xml rename to app/src/main/res/layout/item_load_state_footer_view.xml diff --git a/app/src/main/res/layout/item_placeholder.xml b/app/src/main/res/layout/item_placeholder.xml new file mode 100644 index 0000000000..a01ff9b828 --- /dev/null +++ b/app/src/main/res/layout/item_placeholder.xml @@ -0,0 +1,24 @@ + + + + + + diff --git a/app/src/main/res/layout/item_status_placeholder.xml b/app/src/main/res/layout/item_status_placeholder.xml deleted file mode 100644 index 660c99eaa4..0000000000 --- a/app/src/main/res/layout/item_status_placeholder.xml +++ /dev/null @@ -1,51 +0,0 @@ - - - - - - - - diff --git a/app/src/main/res/menu/fragment_timeline.xml b/app/src/main/res/menu/fragment_timeline.xml index bf722917fe..fe8eec1f19 100644 --- a/app/src/main/res/menu/fragment_timeline.xml +++ b/app/src/main/res/menu/fragment_timeline.xml @@ -5,4 +5,9 @@ android:id="@+id/action_refresh" android:title="@string/action_refresh" app:showAsAction="never" /> + + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 4386af2cdc..270fa23d11 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -255,7 +255,6 @@ أظهر دائما المحتوى الحساس الوسائط ردًا على @%s - حمِّل المزيد الخطوط الزمنية العمومية المحادثات إضافة عامل تصفية @@ -656,10 +655,7 @@ فشل التحميل إظهار المسودات تخطي - ترتيب القراءة مُعطَّل - الأقدم أولاً - الأحدث أولاً الولوج باستخدام متصفح الوسوم المتداولة هناك %1$d أشخاص يتحدثون عن الوسم %2$s diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index cf658d2524..5bbe94b524 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -338,7 +338,6 @@ Адказ для @%s Публічныя стужкі Размовы - загрузіць больш Tusky — свабодная праграма з адкрытым зыходным кодам. Зроблена пад GNU General Public License Version 3. Вы можаце паглядзець ліцэнзію тут: https://www.gnu.org/licenses/gpl-3.0.en.html Справаздачы аб памылках і пажаданні: \n https://github.com/tuskyapp/Tusky/issues @@ -616,12 +615,9 @@ Увайдзіце зноў на ўсіх уліковых запісах, каб push-апавяшчэнні запрацавалі. Бязгучныя апавяшчэнні Найменшы час планавання ў Mastodon складае 5 хвілін. - Спачатку старэйшыя - Кірунак чытання Адключана <нядзейсны> <не задана> - Спачатку навейшыя Падпісацца на хэштэг #hashtag Не атрымалася запампаваць diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 677f0f7968..ce17930b12 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -161,7 +161,6 @@ Добавяне на филтър Разговори Публични емисии - зареждане на още Отговаряне на @%s Мултимедия Винаги разгъване на публикации, маркирани с предупреждения за съдържание diff --git a/app/src/main/res/values-bn-rBD/strings.xml b/app/src/main/res/values-bn-rBD/strings.xml index 445291bcb5..91c1256189 100644 --- a/app/src/main/res/values-bn-rBD/strings.xml +++ b/app/src/main/res/values-bn-rBD/strings.xml @@ -84,7 +84,6 @@ ফিল্টার যোগ করুন কথাবার্তা পাবলিক টাইমলাইন - আরো লোড কর \'@%s কে উত্তর দিচ্ছে\' মিডিয়া সর্বদা সংবেদনশীল কন্টেন্ট প্রদর্শন করুন diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml index 2e51f7447b..a5e641648a 100644 --- a/app/src/main/res/values-bn-rIN/strings.xml +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -261,7 +261,6 @@ সর্বদা সংবেদনশীল কন্টেন্ট প্রদর্শন করুন মিডিয়া \@%s কে উত্তর দিচ্ছে - আরো লোড কর পাবলিক টাইমলাইন কথাবার্তা ফিল্টার যোগ করুন diff --git a/app/src/main/res/values-ca/strings.xml b/app/src/main/res/values-ca/strings.xml index 04e6e49d0b..482e0fcc01 100644 --- a/app/src/main/res/values-ca/strings.xml +++ b/app/src/main/res/values-ca/strings.xml @@ -191,7 +191,6 @@ Mostra el contingut no apte (NSFW) Multimèdia En resposta a @%s - carrega\'n més Vota S\'ha produït un error en publicar. Pestanyes @@ -578,9 +577,6 @@ 14 dies Tot i que el vostre compte no està bloquejat, el personal de %1$s va pensar que potser voldreu revisar les sol·licituds de seguiment d\'aquests comptes manualment. Altres - Ordre de lectura - El més vell primer - El més nou primer 30 dies No s\'ha pogut eliminar el compte de la llista 60 dies diff --git a/app/src/main/res/values-ckb/strings.xml b/app/src/main/res/values-ckb/strings.xml index 3877a552e1..1077b6c5bd 100644 --- a/app/src/main/res/values-ckb/strings.xml +++ b/app/src/main/res/values-ckb/strings.xml @@ -407,7 +407,6 @@ زیادکردنی فلتەر گفتوگۆکان هێڵی کاتی گشتی - بارکردنی زیاتر وەڵام دانەوە بۆ @%s میدیا هەمیشە ئەو توتانەی کە بە ئاگادارکردنەوەکانی ناوەڕۆکەوە نیشانەکراون فراوان بکە diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index b98f4fa9b5..331342e592 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -262,7 +262,6 @@ Vždy zobrazovat citlivý obsah Média Odpověď uživateli @%s - načíst více Veřejné časové osy Konverzace Přidat filtr diff --git a/app/src/main/res/values-cy/strings.xml b/app/src/main/res/values-cy/strings.xml index eb6c33ba72..27a18e1743 100644 --- a/app/src/main/res/values-cy/strings.xml +++ b/app/src/main/res/values-cy/strings.xml @@ -227,7 +227,6 @@ Dangos cynnwys sensitif bob tro Cyfryngau Yn ymateb i @%s - llwytho rhagor Ychwanegu Cyfrif Ychwanegu Cyfrif Mastodon newydd Rhestrau @@ -645,10 +644,7 @@ Rhannu enw defnyddiwr y cyfrif Rhannu URL cyfrif i… Rhannu enw denyddiwr cyfrif i… - Enw defnyddiwr a gopïwyd - Hynaf yn gyntaf - Diweddaraf yn gyntaf - Trefn darllen + Enw defnyddiwr wedi\'i gopïo Analluogwyd <heb ei osod> <annilys> diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3f0594c225..7532c1dbcc 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -242,8 +242,7 @@ Folgt dir Mediendateien mit Inhaltswarnung immer anzeigen Medien - Antwort an @%s - mehr laden + Antworten an @%s Unterhaltungen Filter hinzufügen Filter bearbeiten @@ -582,9 +581,6 @@ Bearbeitet Thread wird geladen Benachrichtigungen stummschalten - Neueste zuerst - Älteste zuerst - Lesereihenfolge Bearbeitungen Deaktiviert <nicht gesetzt> diff --git a/app/src/main/res/values-eo/strings.xml b/app/src/main/res/values-eo/strings.xml index 79ddf59058..328b1dcdd8 100644 --- a/app/src/main/res/values-eo/strings.xml +++ b/app/src/main/res/values-eo/strings.xml @@ -257,7 +257,6 @@ Ĉiam montri tiklan enhavon Aŭdovidaĵoj Respondo al @%s - ŝarĝi pli Publikaj tempolinioj Konversacioj Aldoni filtrilon diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3ee91f0127..ed96a3d04c 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -246,7 +246,6 @@ Mostrar contenido NSFW Multimedia Respondiendo a @%s - cargar más Añadir cuenta Añadir cuenta de Mastodon Listas @@ -616,9 +615,6 @@ Iniciar sesión con el navegador Cargando hilo Silenciar notificaciones - Orden de lectura - Más antiguas primero - Más recientes primero %1$s ha editado %1$s ha editado Desactivado diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index 307c9508b9..05a775db6f 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -228,7 +228,6 @@ Eduki mingarria erakutsi Multimedia \@%s-(r)i erantzuten - Gehiago erakutsi Gehitu kontua Mastodon kontua gehitu Zerrendak diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 31570ad4f5..9fb481e8f6 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -221,7 +221,6 @@ نمایش همیشگی محتوای حساس رسانه در حال پاسخ به @%s - بار کردن بیش‌تر افزودن حساب افزودن حساب ماستودون جدید سیاهه‌ها @@ -590,8 +589,6 @@ <تنظیم نشده> از کار افتاده <نامعتبر> - ترتیب خواندن - نخست جدیدترین ویراسته: %1$s ایجاد شده: %1$s بارگذاری شکست خورد @@ -604,7 +601,6 @@ \n \nارتباط با کارساز برقرار نشد یا فرسته‌ها را رد کرد. در بیش‌تر حالت‌ها کار می‌کند. هیچ داده‌ای به دیگر کاره‌ها نشت نمی‌کند. - نخست قدیمی‌ترین ممکن است از روش‌های تأیید خویت اضافی پشتیبانی کند؛ ولی نیازمند مرورگری پشتیبانی شده است. دور انداختن تغییرات ادامهٔ ویرایش diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index fe029d9e96..4320b7a88d 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -297,7 +297,6 @@ Jaa linkki postaukseen Liitteet Media - lataa lisää Julkiset aikajanat Keskustelut Lisää suodatin diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 9fa13bffac..c478c9b081 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -262,7 +262,6 @@ Toujours afficher les contenus sensibles Média Réponse à @%s - en charger plus Fils publics Conversations Ajouter un filtre @@ -619,9 +618,6 @@ maintenant Les détails du compte n\'ont pas pu être chargés En vous connectant vous acceptez de vous tenir aux règles de %s. - Ordre de lecture - Les plus vieux en premier - Les plus récents en premier Arrêter de suivre #%s \? Échec de l\'épinglage Modification de %1$s diff --git a/app/src/main/res/values-fy/strings.xml b/app/src/main/res/values-fy/strings.xml index e30402b801..e6d4fea621 100644 --- a/app/src/main/res/values-fy/strings.xml +++ b/app/src/main/res/values-fy/strings.xml @@ -29,7 +29,6 @@ Filter oanpasse Filter tafoegje Petearen - mear lade Oan it reagearren op @%s Media Altyd gefoeliche ynhâld sjen litte diff --git a/app/src/main/res/values-ga/strings.xml b/app/src/main/res/values-ga/strings.xml index b5c65809cd..b99c307327 100644 --- a/app/src/main/res/values-ga/strings.xml +++ b/app/src/main/res/values-ga/strings.xml @@ -276,7 +276,6 @@ Leathnaigh i gcónaí postálacha atá marcáilte le rabhaidh ábhair Meáin Ag freagairt do @%s - Lódáil a thuilleadh Comhráite Cuir scagaire leis Cuir scagaire in eagar diff --git a/app/src/main/res/values-gd/strings.xml b/app/src/main/res/values-gd/strings.xml index b423bb570c..1aa20e8aad 100644 --- a/app/src/main/res/values-gd/strings.xml +++ b/app/src/main/res/values-gd/strings.xml @@ -303,7 +303,6 @@ Cuir criathrag ris Còmhraidhean Loidhnichean-ama poblach - luchdaich barrachd dheth A’ freagairt gu @%s Meadhanan Seall susbaint fhrionasach an-còmhnaidh @@ -575,9 +574,6 @@ Dh’fhàillig leis an dì-phrìneachadh A bheil thu airson a shàbhaladh ’na dhreachd\? (Thèid na ceanglachain a luchdadh suas a-rithist nuair a dh’aisigeas tu an dreuchd.) A’ luchdadh an t-snàithlein - Òrdugh an leughaidh - As sine an toiseach - As ùire an toiseach A bheil thu airson sgur de #%s a leantainn\? Mùch na brathan Bu chòir do thuairisgeul a bhith aig a’ mheadhan. diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index f74f3a9618..aaec9cc2f2 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -280,7 +280,6 @@ Engadir filtro Conversas Cronoloxías públicas - cargar máis Respondendo a @%s Multimedia Despregar sempre publicacións marcadas con avisos sobre o contido @@ -592,9 +591,6 @@ Compartir URL da conta en… Compartir identificador da conta en… Identificador copiado - Orde de lectura - Antigo primeiro - Novidades primeiro <non establecido> Desactivado <non válido> diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 7440ec91a5..b3deacd4e8 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -250,7 +250,6 @@ फ़िल्टर संपादित करें फिल्टर लगाएं सार्वजनिक टाइमलाइन - और लोड करें टाइमलाइन में लिंक प्रीव्यू दिखाएं मास्टोडन का न्यूनतम शेड्यूलिंग अंतराल 5 मिनट है। आपके पास कोई ड्राफ्ट नहीं है। diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index a15e302253..0871494014 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -231,7 +231,6 @@ Követ téged Mindig mutassa a kényes tartalmat Média - több betöltése Fiók hozzáadása Új Mastodon-fiók hozzáadása Listák @@ -587,9 +586,6 @@ Nem sikerült letölteni a kiszolgálóról az állapotforrást. Követett hashtagek Szál betöltése - Olvasási sorrend - Régebbi először - Újabb először Értesítések némítása Letiltva <nincs beállítva> diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index ba1f04ceca..d60e3b9dd7 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -274,7 +274,6 @@ Alltaf fletta út færslum sem eru með aðvörun vegna efnis Gagnaskrár Svar til @%s - hlaða inn fleiru Opinberar tímalínur Samtöl Bæta við síu @@ -579,9 +578,6 @@ Gáttin ætti að vera á milli %d og %d Mistókst að hlaða inn uppruna stöðufærslu af netþjóninum. Hleð inn þræði - Lestrarröð - Elsta fyrst - Nýjasta fyrst Þú ert með óvistaðar breytingar. Óvirkt <ekki stillt> diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 375a10d48a..7fa6e2f555 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -256,7 +256,6 @@ Mostra sempre i contenuti sensibili Media Rispondendo a @%s - carica altro Timeline pubbliche Conversazioni Aggiungi filtro @@ -618,7 +617,6 @@ Disattivato <non impostato> <non valido> - Più nuovi prima Non è stato possibile caricare il tuo post ed è stato salvato nelle bozze. \n \nNon è stato possibile contattare il server oppure il server ha rifiutato il post. @@ -628,10 +626,8 @@ Mostra bozze Chiudi Condividi nome utente dell\'account a… - Ordine di lettura Funziona nella maggior parte dei casi. Nessun dato è trasferito ad altre app. Potrebbe supportare differenti metodi di autenticazione, ma richiede un browser supportato. - Più vecchi prima Condividi link dell\'account Condividi il nome utente dell\'account Condividi URL account a… diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 55ccd8ebfa..bff7b1e5ce 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -247,7 +247,6 @@ 閲覧注意のメディアを常に表示 メディア \@%sに返信 - さらに読み込む フィルターを追加 フィルターを編集 アカウントを追加 @@ -579,9 +578,6 @@ アカウントのユーザー名を共有… ユーザー名がコピーされました スレッドの読み込み中 - 新しい順 - 読む順番 - 古い順 サムネイル画像で常に表示される中心点を設定するには、円をタップまたはドラッグして中してくだだい。 通知のミュート %1$s に参加 diff --git a/app/src/main/res/values-kab/strings.xml b/app/src/main/res/values-kab/strings.xml index d28739f46d..4f36a6a1ef 100644 --- a/app/src/main/res/values-kab/strings.xml +++ b/app/src/main/res/values-kab/strings.xml @@ -156,7 +156,6 @@ %dis aya %dus aya Tettaraḍ-as i @%s - awid ugar Idewenniyen Rgel amiḍan Yettnadi… diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 187a2bf981..2fd0a5e2b5 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -266,7 +266,6 @@ 민감한 컨텐츠 항상 보이기 미디어 \@%s에게 답장 - 더 불러오기 공개 타임라인 대화 필터 추가 diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 4845f82bf0..59ac948063 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -230,7 +230,6 @@ Sekošanas pieprasījumi Jauni ieraksti Seko tev - ielādēt vairāk Saglabāt melnrakstu\? Lejupielāde neizdevās 14 dienas @@ -468,7 +467,6 @@ pārtraukta sekošana #%s %s atcelta slēpšana Nevarēja izveidot sarakstu - Lasīšanas secība Filtrējamā frāze Atspējots <nav iestatīts> @@ -510,8 +508,6 @@ %s balss %s balsis - Vecākos vispirms - Jaunākos vispirms %1$s izveidoja %s personas diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index 6cfd04054e..31ea52d970 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -129,7 +129,6 @@ ഫോട്ടോ എടുക്കുക തിരയുക… ചിത്രങ്ങൾ - കൂടുതൽ ലഭ്യമാക്കുക സൂചനകൾ ബയോ %1$s @@ -159,4 +158,4 @@ വിഡിയോ സൂചിപ്പിക്കുക നീക്കം ചെയ്യുക - \ No newline at end of file + diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index e4e476aada..92958cfde2 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -228,7 +228,6 @@ Vis alltid sensitivt innhold Media Svarer til @%s - last mer Offentlige tidslinjer Samtaler Legg til filter @@ -579,10 +578,7 @@ Dette er Hjemmetidslinjendin. Den viser de nyeste innleggene av kontoer du følger. \n \nFor å utforske kontoer kan du enten oppdage dem i en av tidslinjene dine, for eksempel den lokale tidslinjen for instansen din [iconics gmd_group], eller du kan søke etter navnet deres [iconics gmd_search]; søk for eksempel etter Tusky før å finne Mastodonkonten vår. - Leserekkefølge - Eldste først Skriftstørrelse for brukergrensesnitt - Nyeste først Følg emneknagger #emneknagg %1$d folk snakker om emneknaggen %2$s diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index d883dc95e3..e26d151568 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -252,7 +252,6 @@ Altijd gevoelige inhoud (nsfw) tonen Media Aan het reageren op @%s - meer laden Account toevoegen Een nieuw Mastodonaccount toevoegen Lijsten @@ -554,9 +553,6 @@ Fout tijdens het volgen van #%s Fout tijdens het ontvolgen van #%s Dit ingeplande bericht verwijderen\? - Leesvolgorde - Oudste eerst - Nieuwste eerst Account toevoegen aan de lijst is mislukt Toevoegen of verwijderen van lijst Kan niet vastmaken diff --git a/app/src/main/res/values-oc/strings.xml b/app/src/main/res/values-oc/strings.xml index 135c3403bf..0b4a976c1b 100644 --- a/app/src/main/res/values-oc/strings.xml +++ b/app/src/main/res/values-oc/strings.xml @@ -219,7 +219,6 @@ Mostrar lo contengut sensible (NSFW) Mèdia En responsa a @%s - cargar mai Apondre un compte Apondre un nòu compte Mastodon Listas @@ -596,9 +595,6 @@ Téner de modificar Avètz de modificacions pas salvadas. Cargament del fil - Òrdre de lectura - Mai ancians en primièr - Mai recents primièr Desactivat <pas definit> <invalid> diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 0db98aee82..ba6f43831d 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -223,7 +223,6 @@ Zawsze wyświetlaj wrażliwą zawartość Zawartość multimedialna Odpowiadasz na wpis autorstwa @%s - załaduj więcej Dodaj konto Dodaj nowe Konto Mastodon Listy @@ -596,8 +595,6 @@ Obserwowane hashtagi Ładowanie wątku Logując się akceptujesz regulamin %s. - Najpierw najstarsze - Najpierw najnowsze Nie masz żadnych list. Wycisz powiadomienia %1$s edytował @@ -619,7 +616,6 @@ Inne Edytowano %s Usunąć ten zaplanowany wpis\? - Kolejność czytania <nieustawiony> <niepoprawny> Port powinien być pomiędzy %d a %d diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 93d1d7e370..c12fbddf3f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -240,7 +240,6 @@ Sempre mostrar mídia sensível Mídia Respondendo @%s - carregar mais Adicionar conta Adicionar nova conta Mastodon Listas @@ -550,7 +549,6 @@ Falha ao carregar detalhes da conta Carregando fio Ao entrar, você concorda com as regras de %s. - Ordem de leitura Toque ou arraste o círculo para escolher o ponto focal que estará sempre visível nas miniaturas. %s (%s) Adicionar ou remover da lista @@ -577,10 +575,8 @@ agora Falha ao enviar Mostrar rascunhos - Mais recentes primeiro Entrou em %1$s Silenciar notificações - Mais antigos primeiro Falha ao adicionar a conta à lista Funciona na maioria dos casos. Nenhum dado é vazado para outros aplicativos. Pode oferecer suporte a métodos de autenticação adicionais, mas requer um navegador compatível. diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 09e151eec2..3cd27312d2 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -307,7 +307,6 @@ Expandir sempre toots com Aviso de Conteúdo Palavra completa Conteúdo Multimédia - carregar mais Timelines públicas Conversas Criar filtro diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 16f9409de0..34f329e5c2 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -282,7 +282,6 @@ Всегда показывать чувствительный контент Медиа Ответить @%s - показать ещё Публичные ленты Разговоры Добавить фильтр @@ -536,7 +535,6 @@ Запрашивать подтверждение перед добавлением в избранное Убрать из закладок Загрузка ветки - Сначала новые Правки %1$s отредактировали %1$s создали diff --git a/app/src/main/res/values-sa/strings.xml b/app/src/main/res/values-sa/strings.xml index 988c7dc8e1..085c1f40bb 100644 --- a/app/src/main/res/values-sa/strings.xml +++ b/app/src/main/res/values-sa/strings.xml @@ -209,7 +209,6 @@ शोधकं युज्यताम् आलापाः सार्वजनिकतालिकाः - अधिकमारोप्यताम् \@%s मित्रायोत्तरम् सामग्र्यः सर्वदा विषयसतर्कतयाऽङ्कितं दौत्यं विस्तार्यताम् @@ -463,9 +462,6 @@ सकललेखाः दौत्यमाला दृश्यते %s नियमाः - पाठनक्रमः - पुरातनं प्रथमम् - नूतनं प्रथमम् अपरिमितम् केन्द्रबिन्दुं स्थाप्यताम् सूचनाः निशब्दाः करोतु diff --git a/app/src/main/res/values-si/strings.xml b/app/src/main/res/values-si/strings.xml index 68a0c636fd..cf97b61355 100644 --- a/app/src/main/res/values-si/strings.xml +++ b/app/src/main/res/values-si/strings.xml @@ -109,7 +109,6 @@ සෘජු පණිවිඩ මාධ්‍ය බාගත වෙමින් මාධ්‍ය පෙරදසුන් බාගන්න - තව පූරණය බාගන්න සඳැහුම් පිළිතුර diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 06019b9a8b..c35e14c894 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -233,7 +233,6 @@ Vedno prikaži občutljivo vsebino Mediji Odgovori @%s - naloži več Javne časovnice Pogovori Dodaj filter diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index fb4ca5673c..28052e8165 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -256,7 +256,6 @@ Visa alltid allt innehåll (inkl. känsligt) Media Svarar till @%s - ladda mer Offentliga tidslinjer Konversationer Lägg till filter @@ -622,8 +621,6 @@ \nAntingen kunde inte servern nås eller så har uppladdningen nekats. Stäng Fungerar i de flesta fallen. Ingen information läcker till andra applikationer. - Äldsta först - Nyaste först Följ hashtagg #hashtagg %1$d personer pratar om hashtagg %2$s @@ -668,7 +665,6 @@ Att knuffa inlägg misslyckades: %s Rösta i omröstning misslyckades: %s Acceptera följarförfrågan misslyckades: %s - Läsordning Att bokmärka inlägg misslyckades: %s Rensing av aviseringar misslyckades: %s Att favoritmarkera inlägg misslyckades: %s diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 3c9e945d23..144ccce7a5 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -210,7 +210,6 @@ எல்லா nsfw(உணர்ச்சிகரமான) உள்ளடக்கத்தையும் எப்போதும் காண்பி ஊடகம் \@%s -க்கு பதிலளி - மேலும் காட்டு… கணக்கை சேர்க்க புதிய Mastodon கணக்கைச் சேர்க்க பட்டியல்கள் diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index a8eceb2f26..5f5b792ed4 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -152,7 +152,6 @@ เพิ่มตัวคัดกรอง การสนทนา ไทม์ไลน์สาธารณะ - โหลดเพิ่ม ตอบกลับไป @%s สื่อ ขยาย Toot ที่มีเครื่องหมายเนื้อหาอ่อนไหวเสมอ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 124557bdd8..fcdb710506 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -236,7 +236,6 @@ Seni takip ediyor Her zaman hassas içerikleri göster Medya - daha fazlası Hesap Ekle Yeni Mastodon hesabı ekle Listeler @@ -588,10 +587,7 @@ Konu yükleniyor Bu senin ana ağ akışın. Takip ettiğin hesapların son gönderileri burada yer alacak. \n -\nTakip edebileceğin hesapları diğer ağ akışlarından keşfedebilirsin, örneğin kendi sunucunun yerel zaman tünelinden [iconics gmd_group]. Veya hesapları adlarıyla arayabilirsin [iconics gmd_search], örneğin Mastodon hesabımızı bulmak için Tusky adıyla araman yeterli. - Okuma sırası - Önce en eski - Önce en yeni +\nTakip edebileceğin hesapları diğer zaman tünellerinden keşfedebilirsin, örneğin kendi sunucunun yerel zaman tünelinden [iconics gmd_group]. Veya hesapları adlarıyla arayabilirsin [iconics gmd_search], örneğin Mastodon hesabımızı bulmak için Tusky adıyla araman yeterli. <ayarlanmadı> <geçersiz> Etkisizleştirildi diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 8c5977c193..f93a745a2c 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -293,7 +293,6 @@ Додати фільтр Розмови Загальнодоступні стрічки - завантажити ще Відповідь для @%s Завжди розгортати допис, з попередженнями про вміст Підписники @@ -615,9 +614,6 @@ Поділитися URL облікового запису через… Поділитися іменем користувача облікового запису через… Ім\'я користувача скопійовано - Спочатку новіші - Порядок читання - Спочатку давніші Вимкнено <не налаштовано> <недійсний> diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index ffc70bee05..c916a17414 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -284,7 +284,6 @@ Thêm bộ lọc Thảo luận Liên hợp - hiện những tút chưa đọc Trả lời @%s Media Hiện nội dung ẩn @@ -580,9 +579,6 @@ Chia sẻ tên người này Chia sẻ tên người này… Đã sao chép tên người này - Thứ tự đọc - Cũ nhất trước - Mới nhất trước Tắt <không đặt> <không hợp lệ> diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 61aec7198e..c5b90d95ed 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -258,7 +258,6 @@ 总是显示所有敏感媒体内容 媒体 回复 @%s - 加载更多 公共时间轴 对话 添加新的过滤器 @@ -594,12 +593,9 @@ 分享账户链接到… 分享账户用户名到… 已复制用户名 - 阅读顺序 已禁用 <未设置> <无效> - 从新到旧 - 从旧到新 用浏览器登录 多数情况下有效。没有数据泄露给其他应用。 可能支持额外的验证方法但需要受支持的浏览器。 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index ccd2ba8bb8..c4f925c7ff 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -262,7 +262,6 @@ 總是顯示所有敏感媒體內容 媒體 回覆 @%s - 載入更多 公共時間軸 對話 添加新的過濾器 diff --git a/app/src/main/res/values-zh-rMO/strings.xml b/app/src/main/res/values-zh-rMO/strings.xml index 7e22f0d654..9690e5ab77 100644 --- a/app/src/main/res/values-zh-rMO/strings.xml +++ b/app/src/main/res/values-zh-rMO/strings.xml @@ -256,7 +256,6 @@ 總是顯示所有敏感媒體內容 媒體 回覆 @%s - 載入更多 公共時間軸 對話 添加新的過濾器 diff --git a/app/src/main/res/values-zh-rSG/strings.xml b/app/src/main/res/values-zh-rSG/strings.xml index fc96f06290..ca6a223eb3 100644 --- a/app/src/main/res/values-zh-rSG/strings.xml +++ b/app/src/main/res/values-zh-rSG/strings.xml @@ -261,7 +261,6 @@ 总是显示所有敏感媒体内容 媒体 回复 @%s - 加载更多 公共时间轴 对话 添加新的过滤器 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f142210206..df8e7fbc16 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -262,7 +262,6 @@ 總是顯示所有敏感媒體內容 媒體 回覆 @%s - 載入更多 公共時間軸 對話 添加新的過濾器 diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 43bffbd403..42b46aba38 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -250,16 +250,6 @@ 604800 - - @string/pref_reading_order_oldest_first - @string/pref_reading_order_newest_first - - - - OLDEST_FIRST - NEWEST_FIRST - - warn hide diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 81d3d13e02..89d1d476a2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -18,7 +18,9 @@ An error occurred. + An error occurred: %s A network error occurred. Please check your connection and try again. + A network error occurred: %s This cannot be empty. Invalid domain entered Failed authenticating with that instance. If this persists, try "Login in Browser" from the menu. @@ -440,7 +442,6 @@ Always expand posts marked with content warnings Media Replying to @%s - load more Public timelines Conversations @@ -763,9 +764,6 @@ Mute notifications - Reading order - Oldest first - Newest first Edited: %1$s Created: %1$s @@ -789,6 +787,7 @@ Voting in poll failed: %s Accepting follow request failed: %s Rejecting follow request failed: %s + Loading filters failed: %s Follow request accepted @@ -814,6 +813,7 @@ For example the local timeline of your instance [iconics gmd_group]. Or you can search them by name [iconics gmd_search]; for example search for Tusky to find our Mastodon account. Load newest notifications + Load newest posts Copy version and device information Copied version and device information Playback failed: %s diff --git a/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt b/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt index 1498d3d73e..92ba990c97 100644 --- a/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/StatusComparisonTest.kt @@ -41,13 +41,13 @@ class StatusComparisonTest { @Test fun `two equal status view data - should be equal`() { - val viewdata1 = StatusViewData.Concrete( + val viewdata1 = StatusViewData( status = createStatus(), isExpanded = false, isShowingContent = false, isCollapsed = false ) - val viewdata2 = StatusViewData.Concrete( + val viewdata2 = StatusViewData( status = createStatus(), isExpanded = false, isShowingContent = false, @@ -58,13 +58,13 @@ class StatusComparisonTest { @Test fun `status view data with different isExpanded - should not be equal`() { - val viewdata1 = StatusViewData.Concrete( + val viewdata1 = StatusViewData( status = createStatus(), isExpanded = true, isShowingContent = false, isCollapsed = false ) - val viewdata2 = StatusViewData.Concrete( + val viewdata2 = StatusViewData( status = createStatus(), isExpanded = false, isShowingContent = false, @@ -75,13 +75,13 @@ class StatusComparisonTest { @Test fun `status view data with different statuses- should not be equal`() { - val viewdata1 = StatusViewData.Concrete( + val viewdata1 = StatusViewData( status = createStatus(content = "whatever"), isExpanded = true, isShowingContent = false, isCollapsed = false ) - val viewdata2 = StatusViewData.Concrete( + val viewdata2 = StatusViewData( status = createStatus(), isExpanded = false, isShowingContent = false, diff --git a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusAction.kt b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusAction.kt index cf7ce2dc14..0f4bf3e079 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusAction.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/notifications/NotificationsViewModelTestStatusAction.kt @@ -41,7 +41,7 @@ import org.mockito.kotlin.verify */ class NotificationsViewModelTestStatusAction : NotificationsViewModelTestBase() { private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3")) - private val statusViewData = StatusViewData.Concrete( + private val statusViewData = StatusViewData( status = status, isExpanded = true, isShowingContent = false, diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt index ad7bffd38a..51013104c1 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineRemoteMediatorTest.kt @@ -2,6 +2,7 @@ package com.keylesspalace.tusky.components.timeline import android.os.Looper.getMainLooper import androidx.paging.ExperimentalPagingApi +import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.LoadType import androidx.paging.PagingConfig import androidx.paging.PagingSource @@ -10,19 +11,24 @@ import androidx.paging.RemoteMediator import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat import com.google.gson.Gson import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineRemoteMediator +import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineRemoteMediator.Companion.TIMELINE_ID import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager import com.keylesspalace.tusky.db.AppDatabase import com.keylesspalace.tusky.db.Converters +import com.keylesspalace.tusky.db.RemoteKeyEntity +import com.keylesspalace.tusky.db.RemoteKeyKind import com.keylesspalace.tusky.db.TimelineStatusWithAccount import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import okhttp3.Headers import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -54,6 +60,8 @@ class CachedTimelineRemoteMediatorTest { private lateinit var db: AppDatabase + private lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory + @Before @ExperimentalCoroutinesApi fun setup() { @@ -63,6 +71,8 @@ class CachedTimelineRemoteMediatorTest { db = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java) .addTypeConverter(Converters(Gson())) .build() + + pagingSourceFactory = mock() } @After @@ -79,6 +89,7 @@ class CachedTimelineRemoteMediatorTest { api = mock { onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) }, + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -98,6 +109,7 @@ class CachedTimelineRemoteMediatorTest { api = mock { onBlocking { homeTimeline(anyOrNull(), anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() }, + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -114,6 +126,7 @@ class CachedTimelineRemoteMediatorTest { val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock(), + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -136,69 +149,6 @@ class CachedTimelineRemoteMediatorTest { assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) } - @Test - @ExperimentalPagingApi - fun `should refresh and insert placeholder when a whole page with no overlap to existing statuses is loaded`() { - val statusesAlreadyInDb = listOf( - mockStatusEntityWithAccount("3"), - mockStatusEntityWithAccount("2"), - mockStatusEntityWithAccount("1") - ) - - db.insert(statusesAlreadyInDb) - - val remoteMediator = CachedTimelineRemoteMediator( - accountManager = accountManager, - api = mock { - onBlocking { homeTimeline(limit = 3) } doReturn Response.success( - listOf( - mockStatus("8"), - mockStatus("7"), - mockStatus("5") - ) - ) - onBlocking { homeTimeline(maxId = "3", limit = 3) } doReturn Response.success( - listOf( - mockStatus("3"), - mockStatus("2"), - mockStatus("1") - ) - ) - }, - db = db, - gson = Gson() - ) - - val state = state( - pages = listOf( - PagingSource.LoadResult.Page( - data = statusesAlreadyInDb, - prevKey = null, - nextKey = 0 - ) - ), - pageSize = 3 - ) - - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } - - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - - db.assertStatuses( - listOf( - mockStatusEntityWithAccount("8"), - mockStatusEntityWithAccount("7"), - TimelineStatusWithAccount( - status = Placeholder("5", loading = false).toEntity(1) - ), - mockStatusEntityWithAccount("3"), - mockStatusEntityWithAccount("2"), - mockStatusEntityWithAccount("1") - ) - ) - } - @Test @ExperimentalPagingApi fun `should refresh and not insert placeholder when less than a whole page is loaded`() { @@ -228,6 +178,7 @@ class CachedTimelineRemoteMediatorTest { ) ) }, + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -288,6 +239,7 @@ class CachedTimelineRemoteMediatorTest { ) ) }, + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -333,6 +285,7 @@ class CachedTimelineRemoteMediatorTest { ) ) }, + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -361,9 +314,11 @@ class CachedTimelineRemoteMediatorTest { ) } + @OptIn(ExperimentalCoroutinesApi::class) @Test @ExperimentalPagingApi - fun `should remove deleted status from db and keep state of other cached statuses`() { + fun `should remove deleted status from db and keep state of other cached statuses`() = runTest { + // Given val statusesAlreadyInDb = listOf( mockStatusEntityWithAccount("3", expanded = true), mockStatusEntityWithAccount("2"), @@ -375,15 +330,14 @@ class CachedTimelineRemoteMediatorTest { val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, api = mock { - onBlocking { homeTimeline(limit = 20) } doReturn Response.success(emptyList()) - - onBlocking { homeTimeline(maxId = "3", limit = 20) } doReturn Response.success( + onBlocking { homeTimeline(limit = 20) } doReturn Response.success( listOf( mockStatus("3"), mockStatus("1") ) ) }, + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -398,13 +352,16 @@ class CachedTimelineRemoteMediatorTest { ) ) - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } + // When + val result = remoteMediator.load(LoadType.REFRESH, state) - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) + // Then + assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) db.assertStatuses( listOf( + // id="2" was in the database initially, but not in the results returned + // from the API, so it should have been deleted here. mockStatusEntityWithAccount("3", expanded = true), mockStatusEntityWithAccount("1", expanded = false) ) @@ -413,66 +370,7 @@ class CachedTimelineRemoteMediatorTest { @Test @ExperimentalPagingApi - fun `should not remove placeholder in timeline`() { - val statusesAlreadyInDb = listOf( - mockStatusEntityWithAccount("8"), - mockStatusEntityWithAccount("7"), - mockPlaceholderEntityWithAccount("6"), - mockStatusEntityWithAccount("1") - ) - - db.insert(statusesAlreadyInDb) - - val remoteMediator = CachedTimelineRemoteMediator( - accountManager = accountManager, - api = mock { - onBlocking { homeTimeline(sinceId = "6", limit = 20) } doReturn Response.success( - listOf( - mockStatus("9"), - mockStatus("8"), - mockStatus("7") - ) - ) - onBlocking { homeTimeline(maxId = "8", sinceId = "6", limit = 20) } doReturn Response.success( - listOf( - mockStatus("8"), - mockStatus("7") - ) - ) - }, - db = db, - gson = Gson() - ) - - val state = state( - listOf( - PagingSource.LoadResult.Page( - data = statusesAlreadyInDb, - prevKey = null, - nextKey = 0 - ) - ) - ) - - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } - - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertFalse((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - - db.assertStatuses( - listOf( - mockStatusEntityWithAccount("9"), - mockStatusEntityWithAccount("8"), - mockStatusEntityWithAccount("7"), - mockPlaceholderEntityWithAccount("6"), - mockStatusEntityWithAccount("1") - ) - ) - } - - @Test - @ExperimentalPagingApi - fun `should append statuses`() { + fun `should append statuses`() = runTest { val statusesAlreadyInDb = listOf( mockStatusEntityWithAccount("8"), mockStatusEntityWithAccount("7"), @@ -480,6 +378,8 @@ class CachedTimelineRemoteMediatorTest { ) db.insert(statusesAlreadyInDb) + db.remoteKeyDao().upsert(RemoteKeyEntity(1, TIMELINE_ID, RemoteKeyKind.PREV, "8")) + db.remoteKeyDao().upsert(RemoteKeyEntity(1, TIMELINE_ID, RemoteKeyKind.NEXT, "5")) val remoteMediator = CachedTimelineRemoteMediator( accountManager = accountManager, @@ -489,9 +389,13 @@ class CachedTimelineRemoteMediatorTest { mockStatus("3"), mockStatus("2"), mockStatus("1") - ) + ), + Headers.Builder().add( + "Link: ; rel=\"prev\", ; rel=\"next\"" + ).build() ) }, + factory = pagingSourceFactory, db = db, gson = Gson() ) @@ -537,7 +441,7 @@ class CachedTimelineRemoteMediatorTest { private fun AppDatabase.insert(statuses: List) { runBlocking { statuses.forEach { statusWithAccount -> - statusWithAccount.account?.let { account -> + statusWithAccount.account.let { account -> timelineDao().insertAccount(account) } statusWithAccount.reblogAccount?.let { account -> @@ -564,10 +468,8 @@ class CachedTimelineRemoteMediatorTest { for ((exp, prov) in expected.zip(loadedStatuses)) { assertEquals(exp.status, prov.status) - if (!exp.status.isPlaceholder) { - assertEquals(exp.account, prov.account) - assertEquals(exp.reblogAccount, prov.reblogAccount) - } + assertEquals(exp.account, prov.account) + assertEquals(exp.reblogAccount, prov.reblogAccount) } } } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestBase.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestBase.kt new file mode 100644 index 0000000000..b8da9d6cb4 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestBase.kt @@ -0,0 +1,157 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import android.content.SharedPreferences +import android.os.Looper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.gson.Gson +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.timeline.viewmodel.CachedTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.settings.AccountPreferenceDataStore +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.usecase.TimelineCases +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Before +import org.junit.Rule +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyString +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import retrofit2.HttpException +import retrofit2.Response + +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +abstract class CachedTimelineViewModelTestBase { + protected lateinit var cachedTimelineRepository: CachedTimelineRepository + protected lateinit var sharedPreferencesMap: MutableMap + protected lateinit var sharedPreferences: SharedPreferences + protected lateinit var accountPreferencesMap: MutableMap + protected lateinit var accountPreferenceDataStore: AccountPreferenceDataStore + protected lateinit var accountManager: AccountManager + protected lateinit var timelineCases: TimelineCases + protected lateinit var eventHub: EventHub + protected lateinit var filtersRepository: FiltersRepository + protected lateinit var filterModel: FilterModel + protected lateinit var viewModel: TimelineViewModel + + /** Empty success response, for API calls that return one */ + protected var emptySuccess = Response.success("".toResponseBody()) + + /** Empty error response, for API calls that return one */ + protected var emptyError: Response = Response.error(404, "".toResponseBody()) + + /** Exception to throw when testing errors */ + protected val httpException = HttpException(emptyError) + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + @Before + fun setup() { + shadowOf(Looper.getMainLooper()).idle() + + cachedTimelineRepository = mock() + + // Backing store for sharedPreferences, to allow mutation in tests + sharedPreferencesMap = mutableMapOf( + PrefKeys.ANIMATE_GIF_AVATARS to false, + PrefKeys.ANIMATE_CUSTOM_EMOJIS to false, + PrefKeys.ABSOLUTE_TIME_VIEW to false, + PrefKeys.SHOW_BOT_OVERLAY to true, + PrefKeys.USE_BLURHASH to true, + PrefKeys.CONFIRM_REBLOGS to true, + PrefKeys.CONFIRM_FAVOURITES to false, + PrefKeys.WELLBEING_HIDE_STATS_POSTS to false, + PrefKeys.SHOW_NOTIFICATIONS_FILTER to true, + PrefKeys.FAB_HIDE to false + ) + + // Any getBoolean() call looks for the result in sharedPreferencesMap + sharedPreferences = mock { + on { getBoolean(any(), any()) } doAnswer { sharedPreferencesMap[it.arguments[0]] } + } + + // Backing store for account preferences, to allow mutation in tests + accountPreferencesMap = mutableMapOf( + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA to false, + PrefKeys.ALWAYS_OPEN_SPOILER to false, + PrefKeys.MEDIA_PREVIEW_ENABLED to true + ) + + // Any getBoolean() call looks for the result in accountPreferencesMap. + // Any putBoolean() call updates the map and dispatches an event + accountPreferenceDataStore = mock { + on { getBoolean(any(), any()) } doAnswer { accountPreferencesMap[it.arguments[0]] } + on { putBoolean(anyString(), anyBoolean()) } doAnswer { + accountPreferencesMap[it.arguments[0] as String] = it.arguments[1] as Boolean + runBlocking { eventHub.dispatch(PreferenceChangedEvent(it.arguments[0] as String)) } + } + } + + accountManager = mock { + on { activeAccount } doReturn AccountEntity( + id = 1, + domain = "mastodon.test", + accessToken = "fakeToken", + clientId = "fakeId", + clientSecret = "fakeSecret", + isActive = true, + lastVisibleHomeTimelineStatusId = null, + notificationsFilter = "['follow']", + mediaPreviewEnabled = true, + alwaysShowSensitiveMedia = true, + alwaysOpenSpoiler = true + ) + } + eventHub = EventHub() + timelineCases = mock() + filtersRepository = mock() + filterModel = mock() + + viewModel = CachedTimelineViewModel( + cachedTimelineRepository, + timelineCases, + eventHub, + filtersRepository, + accountManager, + sharedPreferences, + accountPreferenceDataStore, + filterModel, + Gson() + ) + + // Initialisation with the Home timeline + viewModel.init(TimelineKind.Home) + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestStatusAction.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestStatusAction.kt new file mode 100644 index 0000000000..b39e07e5f7 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestStatusAction.kt @@ -0,0 +1,232 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import app.cash.turbine.test +import at.connyduck.calladapter.networkresult.NetworkResult +import com.google.common.truth.Truth.assertThat +import com.keylesspalace.tusky.FilterV1Test.Companion.mockStatus +import com.keylesspalace.tusky.components.timeline.viewmodel.StatusAction +import com.keylesspalace.tusky.components.timeline.viewmodel.StatusActionSuccess +import com.keylesspalace.tusky.components.timeline.viewmodel.UiError +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.stub +import org.mockito.kotlin.verify + +/** + * Verify that [StatusAction] are handled correctly on receipt: + * + * - Is the correct [UiSuccess] or [UiError] value emitted? + * - Is the correct [TimelineCases] function called, with the correct arguments? + * This is only tested in the success case; if it passed there it must also + * have passed in the error case. + */ +// TODO: With the exception of the types, this is identical to +// NotificationsViewModelTestStatusAction. +@OptIn(ExperimentalCoroutinesApi::class) +class CachedTimelineViewModelTestStatusAction : CachedTimelineViewModelTestBase() { + private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3")) + private val statusViewData = StatusViewData( + status = status, + isExpanded = true, + isShowingContent = false, + isCollapsed = false + ) + + /** Action to bookmark a status */ + private val bookmarkAction = StatusAction.Bookmark(true, statusViewData) + + /** Action to favourite a status */ + private val favouriteAction = StatusAction.Favourite(true, statusViewData) + + /** Action to reblog a status */ + private val reblogAction = StatusAction.Reblog(true, statusViewData) + + /** Action to vote in a poll */ + private val voteInPollAction = StatusAction.VoteInPoll( + poll = status.poll!!, + choices = listOf(1, 0, 0), + statusViewData + ) + + /** Captors for status ID and state arguments */ + private val id = argumentCaptor() + private val state = argumentCaptor() + + @Test + fun `bookmark succeeds && emits UiSuccess`() = runTest { + // Given + timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) } + + viewModel.uiSuccess.test { + // When + viewModel.accept(bookmarkAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(StatusActionSuccess.Bookmark::class.java) + assertThat((item as StatusActionSuccess).action).isEqualTo(bookmarkAction) + } + + // Then + verify(timelineCases).bookmark(id.capture(), state.capture()) + assertThat(id.firstValue).isEqualTo(statusViewData.status.id) + assertThat(state.firstValue).isEqualTo(true) + } + + @Test + fun `bookmark fails && emits UiError`() = runTest { + // Given + timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException } + + viewModel.uiError.test { + // When + viewModel.accept(bookmarkAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(UiError.Bookmark::class.java) + assertThat(item.action).isEqualTo(bookmarkAction) + } + } + + @Test + fun `favourite succeeds && emits UiSuccess`() = runTest { + // Given + timelineCases.stub { + onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status) + } + + viewModel.uiSuccess.test { + // When + viewModel.accept(favouriteAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(StatusActionSuccess.Favourite::class.java) + assertThat((item as StatusActionSuccess).action).isEqualTo(favouriteAction) + } + + // Then + verify(timelineCases).favourite(id.capture(), state.capture()) + assertThat(id.firstValue).isEqualTo(statusViewData.status.id) + assertThat(state.firstValue).isEqualTo(true) + } + + @Test + fun `favourite fails && emits UiError`() = runTest { + // Given + timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException } + + viewModel.uiError.test { + // When + viewModel.accept(favouriteAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(UiError.Favourite::class.java) + assertThat(item.action).isEqualTo(favouriteAction) + } + } + + @Test + fun `reblog succeeds && emits UiSuccess`() = runTest { + // Given + timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) } + + viewModel.uiSuccess.test { + // When + viewModel.accept(reblogAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(StatusActionSuccess.Reblog::class.java) + assertThat((item as StatusActionSuccess).action).isEqualTo(reblogAction) + } + + // Then + verify(timelineCases).reblog(id.capture(), state.capture()) + assertThat(id.firstValue).isEqualTo(statusViewData.status.id) + assertThat(state.firstValue).isEqualTo(true) + } + + @Test + fun `reblog fails && emits UiError`() = runTest { + // Given + timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException } + + viewModel.uiError.test { + // When + viewModel.accept(reblogAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(UiError.Reblog::class.java) + assertThat(item.action).isEqualTo(reblogAction) + } + } + + @Test + fun `voteinpoll succeeds && emits UiSuccess`() = runTest { + // Given + timelineCases.stub { + onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!) + } + + viewModel.uiSuccess.test { + // When + viewModel.accept(voteInPollAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(StatusActionSuccess.VoteInPoll::class.java) + assertThat((item as StatusActionSuccess).action).isEqualTo(voteInPollAction) + } + + // Then + val pollId = argumentCaptor() + val choices = argumentCaptor>() + verify(timelineCases).voteInPoll(id.capture(), pollId.capture(), choices.capture()) + assertThat(id.firstValue).isEqualTo(statusViewData.status.id) + assertThat(pollId.firstValue).isEqualTo(status.poll!!.id) + assertThat(choices.firstValue).isEqualTo(voteInPollAction.choices) + } + + @Test + fun `voteinpoll fails && emits UiError`() = runTest { + // Given + timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException } + + viewModel.uiError.test { + // When + viewModel.accept(voteInPollAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(UiError.VoteInPoll::class.java) + assertThat(item.action).isEqualTo(voteInPollAction) + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestStatusDisplayOptions.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestStatusDisplayOptions.kt new file mode 100644 index 0000000000..b43eccfd99 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestStatusDisplayOptions.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.CardViewMode +import com.keylesspalace.tusky.util.StatusDisplayOptions +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * Verify that [StatusDisplayOptions] are handled correctly. + * + * - Is the initial value taken from values in sharedPreferences and account? + * - Does the make() function correctly use an updated preference? + * - Is the correct update emitted when a relevant preference changes? + */ +// TODO: With the exception of the types, this is identical to +// NotificationsViewModelTestStatusDisplayOptions +@OptIn(ExperimentalCoroutinesApi::class) +class CachedTimelineViewModelTestStatusDisplayOptions : CachedTimelineViewModelTestBase() { + + private val defaultStatusDisplayOptions = StatusDisplayOptions( + animateAvatars = false, + mediaPreviewEnabled = true, // setting in NotificationsViewModelTestBase + useAbsoluteTime = false, + showBotOverlay = true, + useBlurhash = true, + cardViewMode = CardViewMode.NONE, + confirmReblogs = true, + confirmFavourites = false, + hideStats = false, + animateEmojis = false, + showStatsInline = false, + showSensitiveMedia = true, // setting in NotificationsViewModelTestBase + openSpoiler = true // setting in NotificationsViewModelTestBase + ) + + @Test + fun `initial settings are from sharedPreferences and activeAccount`() = runTest { + viewModel.statusDisplayOptions.test { + val item = awaitItem() + assertThat(item).isEqualTo(defaultStatusDisplayOptions) + } + } + + @Test + fun `make() uses updated preference`() = runTest { + // Prior, should be false + assertThat(defaultStatusDisplayOptions.animateAvatars).isFalse() + + // Given; just a change to one preferences + sharedPreferencesMap[PrefKeys.ANIMATE_GIF_AVATARS] = true + + // When + val updatedOptions = defaultStatusDisplayOptions.make( + sharedPreferences, + PrefKeys.ANIMATE_GIF_AVATARS, + accountManager.activeAccount!! + ) + + // Then, should be true + assertThat(updatedOptions.animateAvatars).isTrue() + } + + @Test + fun `PreferenceChangedEvent emits new StatusDisplayOptions`() = runTest { + // Prior, should be false + viewModel.statusDisplayOptions.test { + val item = expectMostRecentItem() + assertThat(item.animateAvatars).isFalse() + } + + // Given + sharedPreferencesMap[PrefKeys.ANIMATE_GIF_AVATARS] = true + + // When + eventHub.dispatch(PreferenceChangedEvent(PrefKeys.ANIMATE_GIF_AVATARS)) + + // Then, should be true + viewModel.statusDisplayOptions.test { + val item = expectMostRecentItem() + assertThat(item.animateAvatars).isTrue() + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestUiState.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestUiState.kt new file mode 100644 index 0000000000..47f67c7937 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestUiState.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.timeline.viewmodel.UiState +import com.keylesspalace.tusky.settings.PrefKeys +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * Verify that [UiState] is handled correctly. + * + * - Is the initial value taken from values in sharedPreferences and account? + * - Is the correct update emitted when a relevant preference changes? + */ +@OptIn(ExperimentalCoroutinesApi::class) +class CachedTimelineViewModelTestUiState : CachedTimelineViewModelTestBase() { + + private val initialUiState = UiState( + showFabWhileScrolling = true, + showMediaPreview = true + ) + + @Test + fun `should load initial UI state`() = runTest { + viewModel.uiState.test { + assertThat(expectMostRecentItem()).isEqualTo(initialUiState) + } + } + + @Test + fun `showFabWhileScrolling depends on FAB_HIDE preference`() = runTest { + // Prior + viewModel.uiState.test { + assertThat(expectMostRecentItem().showFabWhileScrolling).isTrue() + } + + // Given + sharedPreferencesMap[PrefKeys.FAB_HIDE] = true + + // When + eventHub.dispatch(PreferenceChangedEvent(PrefKeys.FAB_HIDE)) + + // Then + viewModel.uiState.test { + assertThat(expectMostRecentItem().showFabWhileScrolling).isFalse() + } + } + + @Test + fun `showMediaPreview depends on MEDIA_PREVIEW_ENABLED preference`() = runTest { + // Prior + viewModel.uiState.test { + assertThat(expectMostRecentItem().showMediaPreview).isTrue() + } + + // Given (nothing to do here, set up is in base class) + + // When + accountPreferenceDataStore.putBoolean(PrefKeys.MEDIA_PREVIEW_ENABLED, false) + + // Then + viewModel.uiState.test { + assertThat(expectMostRecentItem().showMediaPreview).isFalse() + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestVisibleId.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestVisibleId.kt new file mode 100644 index 0000000000..8e67487c63 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/CachedTimelineViewModelTestVisibleId.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import com.google.common.truth.Truth.assertThat +import com.keylesspalace.tusky.components.timeline.viewmodel.InfallibleUiAction +import com.keylesspalace.tusky.db.AccountEntity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.verify + +@OptIn(ExperimentalCoroutinesApi::class) +class CachedTimelineViewModelTestVisibleId : CachedTimelineViewModelTestBase() { + + @Test + fun `should save status ID to active account`() = runTest { + argumentCaptor().apply { + // Given + assertThat(accountManager.activeAccount?.lastVisibleHomeTimelineStatusId) + .isNull() + assertThat(viewModel.timelineKind) + .isEqualTo(TimelineKind.Home) + + // When + viewModel.accept(InfallibleUiAction.SaveVisibleId("1234")) + + // Then + // As a non-Home timline this should *not* save the account, and + // the last visible property should *not* have changed. + verify(accountManager).saveAccount(capture()) + assertThat(this.lastValue.lastVisibleHomeTimelineStatusId) + .isEqualTo("1234") + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/MainCoroutineRule.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/MainCoroutineRule.kt new file mode 100644 index 0000000000..1bda27ff8d --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/MainCoroutineRule.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +@OptIn(ExperimentalCoroutinesApi::class) +class MainCoroutineRule constructor(private val dispatcher: TestDispatcher = UnconfinedTestDispatcher()) : TestWatcher() { + override fun starting(description: Description) { + super.starting(description) + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description) { + super.finished(description) + Dispatchers.resetMain() + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt index 33215e6757..bc3462ca04 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelinePagingSourceTest.kt @@ -1,64 +1,191 @@ package com.keylesspalace.tusky.components.timeline import androidx.paging.PagingSource +import androidx.paging.PagingSource.LoadResult import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelinePagingSource -import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel -import kotlinx.coroutines.runBlocking -import org.junit.Assert.assertEquals +import com.keylesspalace.tusky.components.timeline.viewmodel.Page +import com.keylesspalace.tusky.components.timeline.viewmodel.PageCache +import com.keylesspalace.tusky.entity.Status +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock import org.robolectric.annotation.Config @Config(sdk = [28]) @RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) class NetworkTimelinePagingSourceTest { - - private val status = mockStatusViewData() - - private val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn mutableListOf(status) + @Test + fun `load() with empty pages returns empty list`() = runTest { + // Given + val pages = PageCache() + val pagingSource = NetworkTimelinePagingSource(pages) + + // When + val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh("0", 2, false)) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + data = emptyList(), + prevKey = null, + nextKey = null + ) + ) } @Test - fun `should return empty list when params are Append`() { - val pagingSource = NetworkTimelinePagingSource(timelineViewModel) - - val params = PagingSource.LoadParams.Append("132", 20, false) - - val expectedResult = PagingSource.LoadResult.Page(emptyList(), null, null) - - runBlocking { - assertEquals(expectedResult, pagingSource.load(params)) + fun `load() for an item in a page returns the page containing that item and next, prev keys`() = runTest { + // Given + val pages = PageCache().apply { + upsert(Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1")) + upsert(Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2")) + upsert(Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1")) } + val pagingSource = NetworkTimelinePagingSource(pages) + + // When + val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh("1", 2, false)) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + data = listOf(mockStatus(id = "1")), + prevKey = "2", + nextKey = "0" + ) + ) } @Test - fun `should return empty list when params are Prepend`() { - val pagingSource = NetworkTimelinePagingSource(timelineViewModel) - - val params = PagingSource.LoadParams.Prepend("132", 20, false) - - val expectedResult = PagingSource.LoadResult.Page(emptyList(), null, null) - - runBlocking { - assertEquals(expectedResult, pagingSource.load(params)) + fun `Append returns the page after`() = runTest { + // Given + val pages = PageCache().apply { + upsert(Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1")) + upsert(Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2")) + upsert(Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1")) } + val pagingSource = NetworkTimelinePagingSource(pages) + + // When + val loadResult = pagingSource.load(PagingSource.LoadParams.Append("1", 2, false)) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + data = listOf(mockStatus(id = "1")), + prevKey = "2", + nextKey = "0" + ) + ) } @Test - fun `should return full list when params are Refresh`() { - val pagingSource = NetworkTimelinePagingSource(timelineViewModel) + fun `Prepend returns the page before`() = runTest { + // Given + val pages = PageCache().apply { + upsert(Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1")) + upsert(Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2")) + upsert(Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1")) + } + val pagingSource = NetworkTimelinePagingSource(pages) + + // When + val loadResult = pagingSource.load(PagingSource.LoadParams.Prepend("1", 2, false)) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + data = listOf(mockStatus(id = "1")), + prevKey = "2", + nextKey = "0" + ) + ) + } - val params = PagingSource.LoadParams.Refresh(null, 20, false) + @Test + fun `Refresh with null key returns the latest page`() = runTest { + // Given + val pages = PageCache().apply { + upsert(Page(data = mutableListOf(mockStatus(id = "2")), nextKey = "1")) + upsert(Page(data = mutableListOf(mockStatus(id = "1")), nextKey = "0", prevKey = "2")) + upsert(Page(data = mutableListOf(mockStatus(id = "0")), prevKey = "1")) + } + val pagingSource = NetworkTimelinePagingSource(pages) + + // When + val loadResult = pagingSource.load(PagingSource.LoadParams.Refresh(null, 2, false)) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + data = listOf(mockStatus(id = "2")), + prevKey = null, + nextKey = "1" + ) + ) + } - val expectedResult = PagingSource.LoadResult.Page(listOf(status), null, null) + @Test + fun `Append with a too-old key returns empty list`() = runTest { + // Given + val pages = PageCache().apply { + upsert(Page(data = mutableListOf(mockStatus(id = "20")), nextKey = "10")) + upsert(Page(data = mutableListOf(mockStatus(id = "10")), prevKey = "20")) + } + val pagingSource = NetworkTimelinePagingSource(pages) + + // When + val loadResult = pagingSource.load(PagingSource.LoadParams.Append("9", 2, false)) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + // No page contains key="9" (oldest is key="10"), so empty list + data = emptyList(), + prevKey = null, + nextKey = null + ) + ) + } - runBlocking { - val result = pagingSource.load(params) - assertEquals(expectedResult, result) + @Test + fun `Prepend with a too-new key returns empty list`() = runTest { + // Given + val pages = PageCache().apply { + upsert(Page(data = mutableListOf(mockStatus(id = "20")), nextKey = "10")) + upsert(Page(data = mutableListOf(mockStatus(id = "10")), prevKey = "20")) } + val pagingSource = NetworkTimelinePagingSource(pages) + + // When + val loadResult = pagingSource.load(PagingSource.LoadParams.Prepend("21", 2, false)) + + // Then + assertThat(loadResult).isInstanceOf(LoadResult.Page::class.java) + assertThat((loadResult as? LoadResult.Page)) + .isEqualTo( + LoadResult.Page( + // No page contains key="9" (oldest is key="10"), so empty list + data = emptyList(), + prevKey = null, + nextKey = null + ) + ) } } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt index 9722fa5a8e..258c85ae23 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineRemoteMediatorTest.kt @@ -1,38 +1,40 @@ package com.keylesspalace.tusky.components.timeline import androidx.paging.ExperimentalPagingApi +import androidx.paging.InvalidatingPagingSourceFactory import androidx.paging.LoadType import androidx.paging.PagingConfig import androidx.paging.PagingSource import androidx.paging.PagingState import androidx.paging.RemoteMediator import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineRemoteMediator -import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.Page +import com.keylesspalace.tusky.components.timeline.viewmodel.PageCache import com.keylesspalace.tusky.db.AccountEntity import com.keylesspalace.tusky.db.AccountManager -import com.keylesspalace.tusky.viewdata.StatusViewData -import kotlinx.coroutines.runBlocking +import com.keylesspalace.tusky.entity.Status +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest import okhttp3.Headers import okhttp3.ResponseBody.Companion.toResponseBody -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue +import okio.IOException +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.doReturn -import org.mockito.kotlin.doThrow import org.mockito.kotlin.mock import org.mockito.kotlin.verify import org.robolectric.annotation.Config import retrofit2.HttpException import retrofit2.Response -import java.io.IOException @Config(sdk = [29]) @RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) class NetworkTimelineRemoteMediatorTest { - private val accountManager: AccountManager = mock { on { activeAccount } doReturn AccountEntity( id = 1, @@ -44,61 +46,77 @@ class NetworkTimelineRemoteMediatorTest { ) } + private lateinit var pagingSourceFactory: InvalidatingPagingSourceFactory + + @Before + fun setup() { + pagingSourceFactory = mock() + } + @Test @ExperimentalPagingApi - fun `should return error when network call returns error code`() { - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn mutableListOf() - onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doReturn Response.error(500, "".toResponseBody()) - } - - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + fun `should return error when network call returns error code`() = runTest { + // Given + val remoteMediator = NetworkTimelineRemoteMediator( + viewModelScope = this, + api = mock(defaultAnswer = { Response.error(500, "".toResponseBody()) }), + accountManager = accountManager, + factory = pagingSourceFactory, + pageCache = PageCache(), + timelineKind = TimelineKind.Home + ) - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } + // When + val result = remoteMediator.load(LoadType.REFRESH, state()) - assertTrue(result is RemoteMediator.MediatorResult.Error) - assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is HttpException) - assertEquals(500, (result.throwable as HttpException).code()) + // Then + assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Error::class.java) + assertThat((result as RemoteMediator.MediatorResult.Error).throwable).isInstanceOf(HttpException::class.java) + assertThat((result.throwable as HttpException).code()).isEqualTo(500) } @Test @ExperimentalPagingApi - fun `should return error when network call fails`() { - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn mutableListOf() - onBlocking { fetchStatusesForKind(anyOrNull(), anyOrNull(), anyOrNull()) } doThrow IOException() - } - - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + fun `should return error when network call fails`() = runTest { + // Given + val remoteMediator = NetworkTimelineRemoteMediator( + viewModelScope = this, + api = mock(defaultAnswer = { throw IOException() }), + accountManager, + factory = pagingSourceFactory, + pageCache = PageCache(), + timelineKind = TimelineKind.Home + ) - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state()) } + // When + val result = remoteMediator.load(LoadType.REFRESH, state()) - assertTrue(result is RemoteMediator.MediatorResult.Error) - assertTrue((result as RemoteMediator.MediatorResult.Error).throwable is IOException) + // Then + assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Error::class.java) + assertThat((result as RemoteMediator.MediatorResult.Error).throwable).isInstanceOf(IOException::class.java) } @Test @ExperimentalPagingApi - fun `should do initial loading`() { - val statuses: MutableList = mutableListOf() - - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn statuses - on { nextKey } doReturn null - onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success( - listOf( - mockStatus("7"), - mockStatus("6"), - mockStatus("5") - ), - Headers.headersOf( - "Link", - "; rel=\"next\", ; rel=\"prev\"" + fun `should do initial loading`() = runTest { + // Given + val pages = PageCache() + val remoteMediator = NetworkTimelineRemoteMediator( + viewModelScope = this, + api = mock { + onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( + listOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + Headers.headersOf( + "Link", + "; rel=\"next\", ; rel=\"prev\"" + ) ) - ) - } - - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + }, + accountManager = accountManager, + factory = pagingSourceFactory, + pageCache = pages, + timelineKind = TimelineKind.Home + ) val state = state( listOf( @@ -110,284 +128,180 @@ class NetworkTimelineRemoteMediatorTest { ) ) - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } + // When + val result = remoteMediator.load(LoadType.REFRESH, state) - val newStatusData = mutableListOf( - mockStatusViewData("7"), - mockStatusViewData("6"), - mockStatusViewData("5") - ) + // Then + val expectedPages = PageCache().apply { + upsert( + Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" + ) + ) + } + + assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) + assertThat((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached).isFalse() + assertThat(pages).containsExactlyEntriesIn(expectedPages) - verify(timelineViewModel).nextKey = "4" - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - assertEquals(newStatusData, statuses) + // Page cache was modified, so the pager should have been invalidated + verify(pagingSourceFactory).invalidate() } @Test @ExperimentalPagingApi - fun `should not prepend statuses`() { - val statuses: MutableList = mutableListOf( - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ) - - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn statuses - on { nextKey } doReturn "0" - onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success( - listOf( - mockStatus("5"), - mockStatus("4"), - mockStatus("3") + fun `should prepend statuses`() = runTest { + // Given + val pages = PageCache().apply { + upsert( + Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" ) ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator( + viewModelScope = this, + api = mock { + onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( + listOf(mockStatus("10"), mockStatus("9"), mockStatus("8")), + Headers.headersOf( + "Link", + "; rel=\"next\", ; rel=\"prev\"" + ) + ) + }, + accountManager = accountManager, + factory = pagingSourceFactory, + pageCache = pages, + timelineKind = TimelineKind.Home + ) val state = state( listOf( PagingSource.LoadResult.Page( - data = listOf( - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ), - prevKey = null, - nextKey = "0" + data = listOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" ) ) ) - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } - - val newStatusData = mutableListOf( - mockStatusViewData("5"), - mockStatusViewData("4"), - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ) - - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - assertEquals(newStatusData, statuses) - } - - @Test - @ExperimentalPagingApi - fun `should refresh and insert placeholder`() { - val statuses: MutableList = mutableListOf( - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ) + // When + val result = remoteMediator.load(LoadType.PREPEND, state) - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn statuses - on { nextKey } doReturn "0" - onBlocking { fetchStatusesForKind(null, null, 20) } doReturn Response.success( - listOf( - mockStatus("10"), - mockStatus("9"), - mockStatus("7") + // Then + val expectedPages = PageCache().apply { + upsert( + Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" ) ) - } - - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) - - val state = state( - listOf( - PagingSource.LoadResult.Page( - data = listOf( - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ), - prevKey = null, - nextKey = "0" + upsert( + Page( + data = mutableListOf(mockStatus("10"), mockStatus("9"), mockStatus("8")), + prevKey = "10", + nextKey = "8" ) ) - ) - - val result = runBlocking { remoteMediator.load(LoadType.REFRESH, state) } + } - val newStatusData = mutableListOf( - mockStatusViewData("10"), - mockStatusViewData("9"), - StatusViewData.Placeholder("7", false), - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ) + assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) + assertThat((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached).isFalse() + assertThat(pages).containsExactlyEntriesIn(expectedPages) - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - assertEquals(newStatusData, statuses) + // Page cache was modified, so the pager should have been invalidated + verify(pagingSourceFactory).invalidate() } @Test @ExperimentalPagingApi - fun `should refresh and not insert placeholders`() { - val statuses: MutableList = mutableListOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5") - ) - - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn statuses - on { nextKey } doReturn "3" - onBlocking { fetchStatusesForKind("3", null, 20) } doReturn Response.success( - listOf( - mockStatus("3"), - mockStatus("2"), - mockStatus("1") + fun `should append statuses`() = runTest { + // Given + val pages = PageCache().apply { + upsert( + Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" ) ) } - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) + val remoteMediator = NetworkTimelineRemoteMediator( + viewModelScope = this, + api = mock { + onBlocking { homeTimeline(maxId = anyOrNull(), minId = anyOrNull(), limit = anyOrNull(), sinceId = anyOrNull()) } doReturn Response.success( + listOf(mockStatus("4"), mockStatus("3"), mockStatus("2")), + Headers.headersOf( + "Link", + "; rel=\"next\", ; rel=\"prev\"" + ) + ) + }, + accountManager = accountManager, + factory = pagingSourceFactory, + pageCache = pages, + timelineKind = TimelineKind.Home + ) val state = state( listOf( PagingSource.LoadResult.Page( - data = listOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5") - ), - prevKey = null, - nextKey = "3" + data = listOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" ) ) ) - val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) } - - val newStatusData = mutableListOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5"), - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ) - - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - assertEquals(newStatusData, statuses) - } - - @Test - @ExperimentalPagingApi - fun `should append statuses`() { - val statuses: MutableList = mutableListOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5") - ) + // When + val result = remoteMediator.load(LoadType.APPEND, state) - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn statuses - on { nextKey } doReturn "3" - onBlocking { fetchStatusesForKind("3", null, 20) } doReturn Response.success( - listOf( - mockStatus("3"), - mockStatus("2"), - mockStatus("1") - ), - Headers.headersOf( - "Link", - "; rel=\"next\", ; rel=\"prev\"" + // Then + val expectedPages = PageCache().apply { + upsert( + Page( + data = mutableListOf(mockStatus("7"), mockStatus("6"), mockStatus("5")), + prevKey = "7", + nextKey = "5" ) ) - } - - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) - - val state = state( - listOf( - PagingSource.LoadResult.Page( - data = listOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5") - ), - prevKey = null, - nextKey = "3" + upsert( + Page( + data = mutableListOf(mockStatus("4"), mockStatus("3"), mockStatus("2")), + prevKey = "4", + nextKey = "2" ) ) - ) + } - val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) } + assertThat(result).isInstanceOf(RemoteMediator.MediatorResult.Success::class.java) + assertThat((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached).isFalse() + assertThat(pages).containsExactlyEntriesIn(expectedPages) - val newStatusData = mutableListOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5"), - mockStatusViewData("3"), - mockStatusViewData("2"), - mockStatusViewData("1") - ) - verify(timelineViewModel).nextKey = "0" - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertEquals(false, (result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - assertEquals(newStatusData, statuses) + // Page cache was modified, so the pager should have been invalidated + verify(pagingSourceFactory).invalidate() } - @Test - @ExperimentalPagingApi - fun `should not append statuses when pagination end has been reached`() { - val statuses: MutableList = mutableListOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5") - ) + companion object { + private const val PAGE_SIZE = 20 - val timelineViewModel: NetworkTimelineViewModel = mock { - on { statusData } doReturn statuses - on { nextKey } doReturn null - } - - val remoteMediator = NetworkTimelineRemoteMediator(accountManager, timelineViewModel) - - val state = state( - listOf( - PagingSource.LoadResult.Page( - data = listOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5") - ), - prevKey = null, - nextKey = null - ) + private fun state(pages: List> = emptyList()) = + PagingState( + pages = pages, + anchorPosition = null, + config = PagingConfig( + pageSize = PAGE_SIZE, + initialLoadSize = PAGE_SIZE + ), + leadingPlaceholderCount = 0 ) - ) - - val result = runBlocking { remoteMediator.load(LoadType.APPEND, state) } - - val newStatusData = mutableListOf( - mockStatusViewData("8"), - mockStatusViewData("7"), - mockStatusViewData("5") - ) - - assertTrue(result is RemoteMediator.MediatorResult.Success) - assertTrue((result as RemoteMediator.MediatorResult.Success).endOfPaginationReached) - assertEquals(newStatusData, statuses) } - - private fun state(pages: List> = emptyList()) = PagingState( - pages = pages, - anchorPosition = null, - config = PagingConfig( - pageSize = 20 - ), - leadingPlaceholderCount = 0 - ) } diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt new file mode 100644 index 0000000000..1f073e0c97 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestBase.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import android.content.SharedPreferences +import android.os.Looper +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.keylesspalace.tusky.appstore.EventHub +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.timeline.viewmodel.NetworkTimelineViewModel +import com.keylesspalace.tusky.components.timeline.viewmodel.TimelineViewModel +import com.keylesspalace.tusky.db.AccountEntity +import com.keylesspalace.tusky.db.AccountManager +import com.keylesspalace.tusky.network.FilterModel +import com.keylesspalace.tusky.settings.AccountPreferenceDataStore +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.usecase.TimelineCases +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import okhttp3.ResponseBody +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Before +import org.junit.Rule +import org.junit.runner.RunWith +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyString +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import retrofit2.HttpException +import retrofit2.Response + +@Config(sdk = [28]) +@RunWith(AndroidJUnit4::class) +@OptIn(ExperimentalCoroutinesApi::class) +abstract class NetworkTimelineViewModelTestBase { + protected lateinit var networkTimelineRepository: NetworkTimelineRepository + protected lateinit var sharedPreferencesMap: MutableMap + protected lateinit var sharedPreferences: SharedPreferences + protected lateinit var accountPreferencesMap: MutableMap + protected lateinit var accountPreferenceDataStore: AccountPreferenceDataStore + protected lateinit var accountManager: AccountManager + protected lateinit var timelineCases: TimelineCases + protected lateinit var eventHub: EventHub + protected lateinit var filtersRepository: FiltersRepository + protected lateinit var filterModel: FilterModel + protected lateinit var viewModel: TimelineViewModel + + /** Empty success response, for API calls that return one */ + protected var emptySuccess = Response.success("".toResponseBody()) + + /** Empty error response, for API calls that return one */ + protected var emptyError: Response = Response.error(404, "".toResponseBody()) + + /** Exception to throw when testing errors */ + protected val httpException = HttpException(emptyError) + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + @Before + fun setup() { + shadowOf(Looper.getMainLooper()).idle() + + networkTimelineRepository = mock() + + // Backing store for sharedPreferences, to allow mutation in tests + sharedPreferencesMap = mutableMapOf( + PrefKeys.ANIMATE_GIF_AVATARS to false, + PrefKeys.ANIMATE_CUSTOM_EMOJIS to false, + PrefKeys.ABSOLUTE_TIME_VIEW to false, + PrefKeys.SHOW_BOT_OVERLAY to true, + PrefKeys.USE_BLURHASH to true, + PrefKeys.CONFIRM_REBLOGS to true, + PrefKeys.CONFIRM_FAVOURITES to false, + PrefKeys.WELLBEING_HIDE_STATS_POSTS to false, + PrefKeys.SHOW_NOTIFICATIONS_FILTER to true, + PrefKeys.FAB_HIDE to false + ) + + // Any getBoolean() call looks for the result in sharedPreferencesMap + sharedPreferences = mock { + on { getBoolean(any(), any()) } doAnswer { sharedPreferencesMap[it.arguments[0]] } + } + + // Backing store for account preferences, to allow mutation in tests + accountPreferencesMap = mutableMapOf( + PrefKeys.ALWAYS_SHOW_SENSITIVE_MEDIA to false, + PrefKeys.ALWAYS_OPEN_SPOILER to false, + PrefKeys.MEDIA_PREVIEW_ENABLED to true + ) + + // Any getBoolean() call looks for the result in accountPreferencesMap. + // Any putBoolean() call updates the map and dispatches an event + accountPreferenceDataStore = mock { + on { getBoolean(any(), any()) } doAnswer { accountPreferencesMap[it.arguments[0]] } + on { putBoolean(anyString(), anyBoolean()) } doAnswer { + accountPreferencesMap[it.arguments[0] as String] = it.arguments[1] as Boolean + runBlocking { eventHub.dispatch(PreferenceChangedEvent(it.arguments[0] as String)) } + } + } + + accountManager = mock { + on { activeAccount } doReturn AccountEntity( + id = 1, + domain = "mastodon.test", + accessToken = "fakeToken", + clientId = "fakeId", + clientSecret = "fakeSecret", + isActive = true, + lastVisibleHomeTimelineStatusId = null, + notificationsFilter = "['follow']", + mediaPreviewEnabled = true, + alwaysShowSensitiveMedia = true, + alwaysOpenSpoiler = true + ) + } + eventHub = EventHub() + timelineCases = mock() + filtersRepository = mock() + filterModel = mock() + + viewModel = NetworkTimelineViewModel( + networkTimelineRepository, + timelineCases, + eventHub, + filtersRepository, + accountManager, + sharedPreferences, + accountPreferenceDataStore, + filterModel + ) + // Initialisation with any timeline kind, as long as it's not Home + // (Home uses CachedTimelineViewModel) + viewModel.init(TimelineKind.Bookmarks) + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestStatusAction.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestStatusAction.kt new file mode 100644 index 0000000000..91150bbcd9 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestStatusAction.kt @@ -0,0 +1,232 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import app.cash.turbine.test +import at.connyduck.calladapter.networkresult.NetworkResult +import com.google.common.truth.Truth.assertThat +import com.keylesspalace.tusky.FilterV1Test.Companion.mockStatus +import com.keylesspalace.tusky.components.timeline.viewmodel.StatusAction +import com.keylesspalace.tusky.components.timeline.viewmodel.StatusActionSuccess +import com.keylesspalace.tusky.components.timeline.viewmodel.UiError +import com.keylesspalace.tusky.viewdata.StatusViewData +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.doThrow +import org.mockito.kotlin.stub +import org.mockito.kotlin.verify + +/** + * Verify that [StatusAction] are handled correctly on receipt: + * + * - Is the correct [UiSuccess] or [UiError] value emitted? + * - Is the correct [TimelineCases] function called, with the correct arguments? + * This is only tested in the success case; if it passed there it must also + * have passed in the error case. + */ +// TODO: With the exception of the types, this is identical to +// NotificationsViewModelTestStatusAction. +@OptIn(ExperimentalCoroutinesApi::class) +class NetworkTimelineViewModelTestStatusAction : NetworkTimelineViewModelTestBase() { + private val status = mockStatus(pollOptions = listOf("Choice 1", "Choice 2", "Choice 3")) + private val statusViewData = StatusViewData( + status = status, + isExpanded = true, + isShowingContent = false, + isCollapsed = false + ) + + /** Action to bookmark a status */ + private val bookmarkAction = StatusAction.Bookmark(true, statusViewData) + + /** Action to favourite a status */ + private val favouriteAction = StatusAction.Favourite(true, statusViewData) + + /** Action to reblog a status */ + private val reblogAction = StatusAction.Reblog(true, statusViewData) + + /** Action to vote in a poll */ + private val voteInPollAction = StatusAction.VoteInPoll( + poll = status.poll!!, + choices = listOf(1, 0, 0), + statusViewData + ) + + /** Captors for status ID and state arguments */ + private val id = argumentCaptor() + private val state = argumentCaptor() + + @Test + fun `bookmark succeeds && emits UiSuccess`() = runTest { + // Given + timelineCases.stub { onBlocking { bookmark(any(), any()) } doReturn NetworkResult.success(status) } + + viewModel.uiSuccess.test { + // When + viewModel.accept(bookmarkAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(StatusActionSuccess.Bookmark::class.java) + assertThat((item as StatusActionSuccess).action).isEqualTo(bookmarkAction) + } + + // Then + verify(timelineCases).bookmark(id.capture(), state.capture()) + assertThat(id.firstValue).isEqualTo(statusViewData.status.id) + assertThat(state.firstValue).isEqualTo(true) + } + + @Test + fun `bookmark fails && emits UiError`() = runTest { + // Given + timelineCases.stub { onBlocking { bookmark(any(), any()) } doThrow httpException } + + viewModel.uiError.test { + // When + viewModel.accept(bookmarkAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(UiError.Bookmark::class.java) + assertThat(item.action).isEqualTo(bookmarkAction) + } + } + + @Test + fun `favourite succeeds && emits UiSuccess`() = runTest { + // Given + timelineCases.stub { + onBlocking { favourite(any(), any()) } doReturn NetworkResult.success(status) + } + + viewModel.uiSuccess.test { + // When + viewModel.accept(favouriteAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(StatusActionSuccess.Favourite::class.java) + assertThat((item as StatusActionSuccess).action).isEqualTo(favouriteAction) + } + + // Then + verify(timelineCases).favourite(id.capture(), state.capture()) + assertThat(id.firstValue).isEqualTo(statusViewData.status.id) + assertThat(state.firstValue).isEqualTo(true) + } + + @Test + fun `favourite fails && emits UiError`() = runTest { + // Given + timelineCases.stub { onBlocking { favourite(any(), any()) } doThrow httpException } + + viewModel.uiError.test { + // When + viewModel.accept(favouriteAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(UiError.Favourite::class.java) + assertThat(item.action).isEqualTo(favouriteAction) + } + } + + @Test + fun `reblog succeeds && emits UiSuccess`() = runTest { + // Given + timelineCases.stub { onBlocking { reblog(any(), any()) } doReturn NetworkResult.success(status) } + + viewModel.uiSuccess.test { + // When + viewModel.accept(reblogAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(StatusActionSuccess.Reblog::class.java) + assertThat((item as StatusActionSuccess).action).isEqualTo(reblogAction) + } + + // Then + verify(timelineCases).reblog(id.capture(), state.capture()) + assertThat(id.firstValue).isEqualTo(statusViewData.status.id) + assertThat(state.firstValue).isEqualTo(true) + } + + @Test + fun `reblog fails && emits UiError`() = runTest { + // Given + timelineCases.stub { onBlocking { reblog(any(), any()) } doThrow httpException } + + viewModel.uiError.test { + // When + viewModel.accept(reblogAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(UiError.Reblog::class.java) + assertThat(item.action).isEqualTo(reblogAction) + } + } + + @Test + fun `voteinpoll succeeds && emits UiSuccess`() = runTest { + // Given + timelineCases.stub { + onBlocking { voteInPoll(any(), any(), any()) } doReturn NetworkResult.success(status.poll!!) + } + + viewModel.uiSuccess.test { + // When + viewModel.accept(voteInPollAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(StatusActionSuccess.VoteInPoll::class.java) + assertThat((item as StatusActionSuccess).action).isEqualTo(voteInPollAction) + } + + // Then + val pollId = argumentCaptor() + val choices = argumentCaptor>() + verify(timelineCases).voteInPoll(id.capture(), pollId.capture(), choices.capture()) + assertThat(id.firstValue).isEqualTo(statusViewData.status.id) + assertThat(pollId.firstValue).isEqualTo(status.poll!!.id) + assertThat(choices.firstValue).isEqualTo(voteInPollAction.choices) + } + + @Test + fun `voteinpoll fails && emits UiError`() = runTest { + // Given + timelineCases.stub { onBlocking { voteInPoll(any(), any(), any()) } doThrow httpException } + + viewModel.uiError.test { + // When + viewModel.accept(voteInPollAction) + + // Then + val item = awaitItem() + assertThat(item).isInstanceOf(UiError.VoteInPoll::class.java) + assertThat(item.action).isEqualTo(voteInPollAction) + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestStatusDisplayOptions.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestStatusDisplayOptions.kt new file mode 100644 index 0000000000..bbdc7ed5f4 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestStatusDisplayOptions.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.settings.PrefKeys +import com.keylesspalace.tusky.util.CardViewMode +import com.keylesspalace.tusky.util.StatusDisplayOptions +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * Verify that [StatusDisplayOptions] are handled correctly. + * + * - Is the initial value taken from values in sharedPreferences and account? + * - Does the make() function correctly use an updated preference? + * - Is the correct update emitted when a relevant preference changes? + */ +// TODO: With the exception of the types, this is identical to +// NotificationsViewModelTestStatusDisplayOptions +@OptIn(ExperimentalCoroutinesApi::class) +class NetworkTimelineViewModelTestStatusDisplayOptions : NetworkTimelineViewModelTestBase() { + + private val defaultStatusDisplayOptions = StatusDisplayOptions( + animateAvatars = false, + mediaPreviewEnabled = true, // setting in NotificationsViewModelTestBase + useAbsoluteTime = false, + showBotOverlay = true, + useBlurhash = true, + cardViewMode = CardViewMode.NONE, + confirmReblogs = true, + confirmFavourites = false, + hideStats = false, + animateEmojis = false, + showStatsInline = false, + showSensitiveMedia = true, // setting in NotificationsViewModelTestBase + openSpoiler = true // setting in NotificationsViewModelTestBase + ) + + @Test + fun `initial settings are from sharedPreferences and activeAccount`() = runTest { + viewModel.statusDisplayOptions.test { + val item = awaitItem() + assertThat(item).isEqualTo(defaultStatusDisplayOptions) + } + } + + @Test + fun `make() uses updated preference`() = runTest { + // Prior, should be false + assertThat(defaultStatusDisplayOptions.animateAvatars).isFalse() + + // Given; just a change to one preferences + sharedPreferencesMap[PrefKeys.ANIMATE_GIF_AVATARS] = true + + // When + val updatedOptions = defaultStatusDisplayOptions.make( + sharedPreferences, + PrefKeys.ANIMATE_GIF_AVATARS, + accountManager.activeAccount!! + ) + + // Then, should be true + assertThat(updatedOptions.animateAvatars).isTrue() + } + + @Test + fun `PreferenceChangedEvent emits new StatusDisplayOptions`() = runTest { + // Prior, should be false + viewModel.statusDisplayOptions.test { + val item = expectMostRecentItem() + assertThat(item.animateAvatars).isFalse() + } + + // Given + sharedPreferencesMap[PrefKeys.ANIMATE_GIF_AVATARS] = true + + // When + eventHub.dispatch(PreferenceChangedEvent(PrefKeys.ANIMATE_GIF_AVATARS)) + + // Then, should be true + viewModel.statusDisplayOptions.test { + val item = expectMostRecentItem() + assertThat(item.animateAvatars).isTrue() + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestUiState.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestUiState.kt new file mode 100644 index 0000000000..1c11ab9cd8 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestUiState.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import com.keylesspalace.tusky.appstore.PreferenceChangedEvent +import com.keylesspalace.tusky.components.timeline.viewmodel.UiState +import com.keylesspalace.tusky.settings.PrefKeys +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +/** + * Verify that [UiState] is handled correctly. + * + * - Is the initial value taken from values in sharedPreferences and account? + * - Is the correct update emitted when a relevant preference changes? + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NetworkTimelineViewModelTestUiState : NetworkTimelineViewModelTestBase() { + + private val initialUiState = UiState( + showFabWhileScrolling = true, + showMediaPreview = true + ) + + @Test + fun `should load initial UI state`() = runTest { + viewModel.uiState.test { + assertThat(expectMostRecentItem()).isEqualTo(initialUiState) + } + } + + @Test + fun `showFabWhileScrolling depends on FAB_HIDE preference`() = runTest { + // Prior + viewModel.uiState.test { + assertThat(expectMostRecentItem().showFabWhileScrolling).isTrue() + } + + // Given + sharedPreferencesMap[PrefKeys.FAB_HIDE] = true + + // When + eventHub.dispatch(PreferenceChangedEvent(PrefKeys.FAB_HIDE)) + + // Then + viewModel.uiState.test { + assertThat(expectMostRecentItem().showFabWhileScrolling).isFalse() + } + } + + @Test + fun `showMediaPreview depends on MEDIA_PREVIEW_ENABLED preference`() = runTest { + // Prior + viewModel.uiState.test { + assertThat(expectMostRecentItem().showMediaPreview).isTrue() + } + + // Given (nothing to do here, set up is in base class) + + // When + accountPreferenceDataStore.putBoolean(PrefKeys.MEDIA_PREVIEW_ENABLED, false) + + // Then + viewModel.uiState.test { + assertThat(expectMostRecentItem().showMediaPreview).isFalse() + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestVisibleId.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestVisibleId.kt new file mode 100644 index 0000000000..cd31d49a44 --- /dev/null +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/NetworkTimelineViewModelTestVisibleId.kt @@ -0,0 +1,51 @@ +/* + * Copyright 2023 Tusky Contributors + * + * This file is a part of Tusky. + * + * 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. + * + * Tusky 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 Tusky; if not, + * see . + */ + +package com.keylesspalace.tusky.components.timeline + +import com.google.common.truth.Truth.assertThat +import com.keylesspalace.tusky.components.timeline.viewmodel.InfallibleUiAction +import com.keylesspalace.tusky.db.AccountEntity +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.never +import org.mockito.kotlin.verify + +@OptIn(ExperimentalCoroutinesApi::class) +class NetworkTimelineViewModelTestVisibleId : NetworkTimelineViewModelTestBase() { + + @Test + fun `should not save status ID to active account`() = runTest { + argumentCaptor().apply { + // Given + assertThat(accountManager.activeAccount?.lastVisibleHomeTimelineStatusId) + .isNull() + assertThat(viewModel.timelineKind) + .isNotEqualTo(TimelineKind.Home) + + // When + viewModel.accept(InfallibleUiAction.SaveVisibleId("1234")) + + // Then + // As a non-Home timline this should *not* save the account, and + // the last visible property should *not* have changed. + verify(accountManager, never()).saveAccount(capture()) + } + } +} diff --git a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt index 839b16dfd1..133e895f7f 100644 --- a/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt +++ b/app/src/test/java/com/keylesspalace/tusky/components/timeline/StatusMocker.kt @@ -69,7 +69,7 @@ fun mockStatusViewData( reblogged: Boolean = false, favourited: Boolean = true, bookmarked: Boolean = true -) = StatusViewData.Concrete( +) = StatusViewData( status = mockStatus( id = id, inReplyToId = inReplyToId, @@ -107,12 +107,3 @@ fun mockStatusEntityWithAccount( ) ) } - -fun mockPlaceholderEntityWithAccount( - id: String, - userId: Long = 1 -): TimelineStatusWithAccount { - return TimelineStatusWithAccount( - status = Placeholder(id, false).toEntity(userId) - ) -} diff --git a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt index ca3bcfa4ee..6975a30a0f 100644 --- a/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/db/TimelineDaoTest.kt @@ -5,13 +5,10 @@ import androidx.room.Room import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import com.google.gson.Gson -import com.keylesspalace.tusky.components.timeline.Placeholder -import com.keylesspalace.tusky.components.timeline.toEntity import com.keylesspalace.tusky.entity.Status import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -273,96 +270,6 @@ class TimelineDaoTest { assertStatuses(listOf(statusWithRedDomainOtherAccount, statusWithBlueDomainOtherAccount), statusesAccount2) } - @Test - fun `should return null as topId when db is empty`() = runBlocking { - assertNull(timelineDao.getTopId(1)) - } - - @Test - fun `should return correct topId`() = runBlocking { - val statusData = listOf( - makeStatus( - statusId = 4, - accountId = 1, - domain = "mastodon.test", - authorServerId = "1" - ), - makeStatus( - statusId = 33, - accountId = 1, - domain = "mastodon.test", - authorServerId = "2" - ), - makeStatus( - statusId = 22, - accountId = 1, - domain = "mastodon.test", - authorServerId = "2" - ) - ) - - for ((status, author, reblogAuthor) in statusData) { - timelineDao.insertAccount(author) - reblogAuthor?.let { - timelineDao.insertAccount(it) - } - timelineDao.insertStatus(status) - } - - assertEquals("33", timelineDao.getTopId(1)) - } - - @Test - fun `should return correct placeholderId after other ids`() = runBlocking { - val statusData = listOf( - makeStatus(statusId = 1000), - makePlaceholder(id = 99), - makeStatus(statusId = 97), - makeStatus(statusId = 95), - makePlaceholder(id = 94), - makeStatus(statusId = 90) - ) - - for ((status, author, reblogAuthor) in statusData) { - author?.let { - timelineDao.insertAccount(it) - } - reblogAuthor?.let { - timelineDao.insertAccount(it) - } - timelineDao.insertStatus(status) - } - - assertEquals("99", timelineDao.getNextPlaceholderIdAfter(1, "1000")) - assertEquals("94", timelineDao.getNextPlaceholderIdAfter(1, "99")) - assertNull(timelineDao.getNextPlaceholderIdAfter(1, "90")) - } - - @Test - fun `should return correct top placeholderId`() = runBlocking { - val statusData = listOf( - makeStatus(statusId = 1000), - makePlaceholder(id = 99), - makeStatus(statusId = 97), - makePlaceholder(id = 96), - makeStatus(statusId = 90), - makePlaceholder(id = 80), - makeStatus(statusId = 77) - ) - - for ((status, author, reblogAuthor) in statusData) { - author?.let { - timelineDao.insertAccount(it) - } - reblogAuthor?.let { - timelineDao.insertAccount(it) - } - timelineDao.insertStatus(status) - } - - assertEquals("99", timelineDao.getTopPlaceholderId(1)) - } - @Test fun `preview card survives roundtrip`() = runBlocking { val setOne = makeStatus(statusId = 3, cardUrl = "https://foo.bar") @@ -466,14 +373,6 @@ class TimelineDaoTest { return Triple(status, author, reblogAuthor) } - private fun makePlaceholder( - accountId: Long = 1, - id: Long - ): Triple { - val placeholder = Placeholder(id.toString(), false).toEntity(accountId) - return Triple(placeholder, null, null) - } - private fun assertStatuses( expected: List>, provided: List diff --git a/doc/ViewModelInterface.md b/doc/ViewModelInterface.md index 0d1dac489d..2a1ff95eda 100644 --- a/doc/ViewModelInterface.md +++ b/doc/ViewModelInterface.md @@ -16,7 +16,7 @@ After reading this document you should understand: Before reading this document you should: - Understand Kotlin flows -- Read [Guide to app architecture / UI layer](https://developer.android.com/topic/architecture/ui-layer) +- Read [Guide to app architecture / UI layer](https://developer.android.com/topic/architecture/ui-layer) ## Action and UiState flows @@ -43,7 +43,7 @@ something. The view model does **not** tell the fragment to do something. State always flows from right to left. The view model tells the fragment -"Here's the new state, it up to you how to display it." +"Here's the new state, it's up to you how to display it." Not shown on this diagram, but implicit, is these actions are asynchronous, and the view model may be making one or more requests to other components to @@ -108,7 +108,7 @@ fun onViewCreated(...) { } ``` -This is a good start, but it can be me significantly improved. +This is a good start, but it can be improved. ### Model actions with sealed classes @@ -138,7 +138,7 @@ the UI are: > NOTE: The user can also interact with items in the list of the > notifications. -> +> > That is handled a little differently because of how code outside > `NotificationsFragment` is currently written. It will be adjusted at > a later time. @@ -156,7 +156,7 @@ sealed class UiAction { This has multiple benefits: -- The actions the view model can act on are defined in a single place +- The actions the view model can perform are defined in a single place - Each action clearly describes the information it carries with it - Each action is strongly typed; it is impossible to create an action of the wrong type @@ -173,11 +173,11 @@ val actionFlow = MutableSharedFlow() // As before init { // ... - + handleApplyFilter() handleClearNotifications() handleSaveVisibleId() - + // ... } @@ -194,7 +194,7 @@ fun handleClearNotifications() = viewModelScope.launch { actionFlow .filterIsInstance() .distinctUntilChanged() - .collect { action -> + .collect { action -> // Clear notifications, update state } } @@ -236,7 +236,8 @@ val accept: (UiAction) -> Unit = { action -> } ``` -When the Fragment wants to send a `UiAction` to the view model it: +When the Fragment wants to send a `UiAction` to the view model it can call this +function. ```kotlin // In the Fragment @@ -293,7 +294,7 @@ sealed class NotificationAction : FallibleUiAction() { } sealed class StatusAction( - open val statusViewData: StatusViewData.Concrete + open val statusViewData: StatusViewData ) : FallibleUiAction() { // subclasses here } @@ -337,7 +338,7 @@ classDiagram StatusAction <|-- Favourite StatusAction <|-- Reblog StatusAction <|-- VoteInPoll - + ``` ### Multiple output flows @@ -378,6 +379,7 @@ to process them. - `UiSuccess` show a brief snackbar without disturbing the rest of the UI - `UiError` show a fixed snackbar with a "Retry" option + (if the operation can be retried) They also have different statefulness requirements, which makes separating them in to different flows a sensible approach. @@ -385,11 +387,44 @@ them in to different flows a sensible approach. `PagingData`, `UiState`, and `StatusDisplayOptions` are stateful -- if the Fragment disconnects from the flow and then reconnects (e.g., because of a configuration change) the Fragment should receive the most recent state of -each of these. +each of these. So they are modeled as a `StateFlow`. + +`UiSuccess` is not stateful. The success messages are transient; if one has +been shown, and there is a subsequent configuration change the user should +not see the success message again. So this is modeled as a `SharedFlow`. + +`UiError` is not stateful, but it must be hot, otherwise error messages can +be lost, so it is implemented as a channel and exposed as a flow. + +It may be easier to explain what happens if you do not do this. + +Suppose `UiError` is a `SharedFlow`. The view model initialises and performs +some fallible operations, such as loading data from a repository that involves +a network request. -`UiSuccess` and `UiError` are not stateful. The success and error messages are -transient; if one has been shown, and there is a subsequent configuration -change the user should not see the success or error message again. +This operation fails, so an error is sent to `uiError`. However, the +fragment or activity has not yet started collecting from that flow. So as a +`SharedFlow` the error is lost and not displayed to the user. + +If `UiError` was implemented as a `StateFlow` this problem would not occur. +However, the flow would need an initial value (an empty `StateFlow` is not +possible) and there would need to be a mechanism to dismiss errors from the +state. + +Instead, the error flow is backed by a private channel and exposed as a flow. +If an error is sent by the view model before the UI has collected from the +flow the error will be persisted. And once the error has been collected it +will not persist in the channel. + +The implementation looks like this: + +```kotlin +private val _uiErrorChannel = Channel() +val uiError = _uiErrorChannel.receiveAsFlow() + +// Later, to send an error +_uiErrorChannel.send(UiError.SomeError(/* ... */)) +``` ### Modelling success and failure for fallible actions @@ -413,12 +448,56 @@ Fragment saying "Here is the action I want to be performed" and the `action` in `UiSuccess` is the View Model saying "Here is the action that was carried out." +Including the original action in the successful response allows the UI to be +updated in response to the success. + Unsurprisingly, this is modelled with a `UiSuccess` class, and per-action subclasses. +This shows typical code for a success class, in this case, bookmarking a +status has succeeded. + +```kotlin +sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess () { + data class Bookmark(override val action: StatusAction.Bookmark) : + StatusActionSuccess(action) + + // ... other action successes here + + companion object { + fun from (action: StatusAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(action) + // ... other actions here + } + } +} +``` + Failures are modelled similarly, with a `UiError` class. However, details about the error are included, as well as the original action. +```kotlin +sealed class UiError( + open val throwable: Throwable, + @StringRes val message: Int, + open val action: UiAction? = null +) { + data class Bookmark( + override val throwable: Throwable, + override val action: StatusAction.Bookmark + ) : UiError(throwable, R.string.ui_error_bookmark, action) + + // ... other action errors here + + companion object { + fun make(throwable: Throwable, action: FallibleUiAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(throwable, action) + // other actions here + } + } +} +``` + So each fallible action has three associated classes; one for the action, one to represent the action succeeding, and one to represent the action failing. @@ -429,22 +508,22 @@ looks like this: ```kotlin // In the View Model sealed class StatusAction( - open val statusViewData: StatusViewData.Concrete + open val statusViewData: StatusViewData ) : FallibleUiAction() { data class Bookmark( val state: Boolean, - override val statusViewData: StatusViewData.Concrete + override val statusViewData: StatusViewData ) : StatusAction(statusViewData) - + // ... other actions here } sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess () { data class Bookmark(override val action: StatusAction.Bookmark) : StatusActionSuccess(action) - + // ... other action successes here - + companion object { fun from (action: StatusAction) = when (action) { is StatusAction.Bookmark -> Bookmark(action) @@ -454,22 +533,22 @@ sealed class StatusActionSuccess(open val action: StatusAction) : UiSuccess () { } sealed class UiError( - open val exception: Exception, + open val throwable: Throwable, @StringRes val message: Int, open val action: UiAction? = null ) { data class Bookmark( - override val exception: Exception, - override val action: StatusAction.Bookmark - ) : UiError(exception, R.string.ui_error_bookmark, action) - + override val throwable: Throwable, + override val action: StatusAction.Bookmark + ) : UiError(throwable, R.string.ui_error_bookmark, action) + // ... other action errors here companion object { - fun make(exception: Exception, action: FallibleUiAction) = when (action) { - is StatusAction.Bookmark -> Bookmark(exception, action) + fun make(throwable: Throwable, action: FallibleUiAction) = when (action) { + is StatusAction.Bookmark -> Bookmark(throwable, action) // other actions here - } + } } } ``` @@ -536,10 +615,11 @@ lifecycleScope.launch { ### Supporting "retry" semantics -This approach has an extremely helpful benefit. By including the original -action in the `UiError` response, implementing a "retry" function is as -simple as re-sending the original action (included in the error) back to -the view model. +Including the original action in the `UiError` response means implementing a +"retry" function is as simple as re-sending the original action (included in +the error) back to the view model. + +The previous code can be amended like so: ```kotlin lifecycleScope.launch { @@ -552,7 +632,9 @@ lifecycleScope.launch { getString(error.message), LENGTH_LONG ) - error.action?.let { action -> + // New code here -- add a button to the snackbar to retry + // the operation by resending the action that failed. + error.action?.let { action -> snackbar.setAction("Retry") { viewModel.accept(action) } } snackbar.show() @@ -579,7 +661,7 @@ sequenceDiagram activate ui ui->>ui: collect UiState, update UI deactivate ui - + else Update StatusDisplayOptions? vm->>vm: emit(StatusDisplayOptions(...)) vm-->>ui: StatusDisplayOption(...) @@ -607,9 +689,9 @@ sequenceDiagram deactivate ui activate vm vm->>vm: Perform action, emit response... - deactivate vm + deactivate vm end note over ui,vm: Type of UI change depends on type of object emitted
UiState, StatusDisplayOptions, UiSuccess, UiError - + ui-->>user: Observes changes -``` \ No newline at end of file +```