From 30303dd7838688ce31b5fdc094099804576345a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 13:13:21 -0700 Subject: [PATCH] feat(messages): apply and switch the account's feed view preference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feed ignored `viewingPreference` entirely and always asked for the whole feed. It now opens on whatever the account has saved and offers the same four choices as Settings -> View Preferences on the web. `GET /api/messages` takes only `limit`, `offset`, `onlyMine` and `tag` (/help/api/messages) — there is no following/followers parameter, and search is documented as "scoped to your feed visibility (honors your viewingPreference)". So the server scopes the feed from the saved preference: switching PATCHes the account first and only then reloads the feed from the top, with `onlyMine=true` remaining the request-level mechanism for My Messages. The switcher updates optimistically but rolls back and surfaces the error if the PATCH fails, so it can never show a view the account never stored. Switching resets the keyset cursor, so paging restarts under the new view. `viewingPreference` is shared through `:core:model` plus a small `ViewingPreferenceStore` in `:core:network` rather than by depending on `:feature:profile` — no feature module depends on another here. That leaves `:feature:profile`'s `SettingsRepository` and this store as two paths to the same field; they should be consolidated behind one owner. Closes #19 --- .../interlinedlist/android/core/model/User.kt | 6 + .../android/core/model/ViewingPreference.kt | 55 ++++++ .../core/network/api/InterlinedListApi.kt | 11 ++ .../android/core/network/dto/UpdateUserDto.kt | 36 ++++ .../android/core/network/dto/UserDto.kt | 9 + .../preferences/ViewingPreferenceStore.kt | 58 ++++++ .../preferences/ViewingPreferenceStoreTest.kt | 158 ++++++++++++++++ .../ui/feed/MessagesFeedScreenTest.kt | 71 +++++++ .../data/DefaultMessagesRepository.kt | 56 ++++-- .../messages/data/MessagesRepository.kt | 34 +++- .../messages/data/remote/MessagesApi.kt | 7 + .../messages/ui/feed/MessagesFeedScreen.kt | 101 +++++++--- .../messages/ui/feed/MessagesFeedViewModel.kt | 78 +++++++- .../data/DefaultMessagesRepositoryTest.kt | 2 + .../data/MessagesFeedCursorPagingTest.kt | 2 + .../data/MessagesFeedViewPreferenceTest.kt | 176 ++++++++++++++++++ .../messages/ui/FakeMessagesRepository.kt | 26 ++- .../ui/feed/MessagesFeedViewModelTest.kt | 139 ++++++++++++++ 18 files changed, 980 insertions(+), 45 deletions(-) create mode 100644 core/model/src/main/kotlin/com/interlinedlist/android/core/model/ViewingPreference.kt create mode 100644 core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/UpdateUserDto.kt create mode 100644 core/network/src/main/kotlin/com/interlinedlist/android/core/network/preferences/ViewingPreferenceStore.kt create mode 100644 core/network/src/test/kotlin/com/interlinedlist/android/core/network/preferences/ViewingPreferenceStoreTest.kt create mode 100644 feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/MessagesFeedViewPreferenceTest.kt diff --git a/core/model/src/main/kotlin/com/interlinedlist/android/core/model/User.kt b/core/model/src/main/kotlin/com/interlinedlist/android/core/model/User.kt index a72d9fc..3a0f1bc 100644 --- a/core/model/src/main/kotlin/com/interlinedlist/android/core/model/User.kt +++ b/core/model/src/main/kotlin/com/interlinedlist/android/core/model/User.kt @@ -21,4 +21,10 @@ data class User( * overrides it. Defaults to public, matching the server default. */ val defaultPubliclyVisible: Boolean = true, + /** + * Which messages the Home feed shows (Settings -> View Preferences on the + * web). The server applies it when it builds the feed; the client saves it and + * reloads. Defaults to [ViewingPreference.ALL], matching the server default. + */ + val viewingPreference: ViewingPreference = ViewingPreference.DEFAULT, ) diff --git a/core/model/src/main/kotlin/com/interlinedlist/android/core/model/ViewingPreference.kt b/core/model/src/main/kotlin/com/interlinedlist/android/core/model/ViewingPreference.kt new file mode 100644 index 0000000..3ff23dd --- /dev/null +++ b/core/model/src/main/kotlin/com/interlinedlist/android/core/model/ViewingPreference.kt @@ -0,0 +1,55 @@ +package com.interlinedlist.android.core.model + +/** + * Which messages the Home feed shows — the `viewingPreference` field of + * `GET /api/user`, written back with `PATCH /api/user/update`. + * + * The wire values are not in the OpenAPI spec (the field is typed as a bare + * `string`); they come from the server's own 400 on a bogus value: + * `viewingPreference must be one of: my_messages, all_messages, followers_only, + * following_only`. Nothing else is accepted. + * + * The **server** applies this preference when it builds the feed: + * `GET /api/messages` takes only `limit`, `offset`, `onlyMine` and `tag` + * (`/help/api/messages`), and search is documented as "scoped to your feed + * visibility (honors your `viewingPreference`)". So a client changes the feed by + * saving the preference and reloading, not by sending a filter parameter. + * + * [fromWire] is deliberately tolerant — casing, separators and the obvious + * shorthands all resolve — so an unexpected spelling degrades to the right + * selection instead of silently resetting the user's feed. + */ +enum class ViewingPreference(val wire: String) { + /** Your messages plus all public messages. */ + ALL("all_messages"), + + /** Only your own messages. */ + MINE("my_messages"), + + /** Messages from people you follow, plus your own. */ + FOLLOWING("following_only"), + + /** Messages from people who follow you, plus your own. */ + FOLLOWERS("followers_only"), + ; + + companion object { + /** The API default for a new account, and the fallback for an unknown value. */ + val DEFAULT = ALL + + /** Parses a wire value, returning null when it matches no known option. */ + fun fromWire(value: String?): ViewingPreference? { + val normalised = value?.lowercase()?.filter { it.isLetter() } ?: return null + return when (normalised) { + "allmessages", "all", "everyone" -> ALL + "mymessages", "mine", "my", "onlymine", "me" -> MINE + "followingonly", "following" -> FOLLOWING + "followersonly", "followers" -> FOLLOWERS + else -> null + } + } + + /** Parses a wire value, falling back to [DEFAULT] when missing or unknown. */ + fun fromWireOrDefault(value: String?): ViewingPreference = fromWire(value) ?: DEFAULT + } +} diff --git a/core/network/src/main/kotlin/com/interlinedlist/android/core/network/api/InterlinedListApi.kt b/core/network/src/main/kotlin/com/interlinedlist/android/core/network/api/InterlinedListApi.kt index c220436..d81799d 100644 --- a/core/network/src/main/kotlin/com/interlinedlist/android/core/network/api/InterlinedListApi.kt +++ b/core/network/src/main/kotlin/com/interlinedlist/android/core/network/api/InterlinedListApi.kt @@ -3,8 +3,11 @@ package com.interlinedlist.android.core.network.api import com.interlinedlist.android.core.network.dto.CurrentUserResponse import com.interlinedlist.android.core.network.dto.SyncTokenRequest import com.interlinedlist.android.core.network.dto.SyncTokenResponse +import com.interlinedlist.android.core.network.dto.UpdateUserRequest +import com.interlinedlist.android.core.network.dto.UpdateUserResponse import retrofit2.http.Body import retrofit2.http.GET +import retrofit2.http.PATCH import retrofit2.http.POST /** @@ -23,4 +26,12 @@ interface InterlinedListApi { /** Returns the authenticated user, wrapped as `{ "user": ... }`, including `customerStatus`. */ @GET("api/user") suspend fun getCurrentUser(): CurrentUserResponse + + /** + * Applies a **partial** update to the current user's account fields and returns + * the updated user. Fields left null in [body] are omitted from the request, so + * one preference can be changed without touching the rest. + */ + @PATCH("api/user/update") + suspend fun updateUser(@Body body: UpdateUserRequest): UpdateUserResponse } diff --git a/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/UpdateUserDto.kt b/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/UpdateUserDto.kt new file mode 100644 index 0000000..d12f55b --- /dev/null +++ b/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/UpdateUserDto.kt @@ -0,0 +1,36 @@ +package com.interlinedlist.android.core.network.dto + +import kotlinx.serialization.Serializable + +/** + * A **partial** body for `PATCH /api/user/update`. Every field is optional and the + * shared Json uses `explicitNulls = false`, so an untouched field is omitted from + * the request entirely and can never clobber another preference. + * + * Only the fields a `:core:network` consumer actually needs are modelled — today + * that is `viewingPreference`, which the messages feed writes when the user picks a + * view. `:feature:profile` owns the full fifteen-field settings surface; see + * [com.interlinedlist.android.core.network.preferences.ViewingPreferenceStore] for + * why the two coexist. + */ +@Serializable +data class UpdateUserRequest( + val viewingPreference: String? = null, +) + +/** + * Response to `PATCH /api/user/update`: the updated user, "same shape as + * `GET /api/user`" per `/help/api/users-and-profile`. + * + * Live, `GET /api/user` wraps the object as `{ "user": … }` while the help centre's + * example shows it inlined at the top level, so both are tolerated. Every field is + * optional: a thin acknowledgement body must not fail the call. + */ +@Serializable +data class UpdateUserResponse( + val user: UserDto? = null, + val viewingPreference: String? = null, +) { + /** The saved `viewingPreference`, wrapped or inlined, or null if not echoed. */ + val savedViewingPreference: String? get() = user?.viewingPreference ?: viewingPreference +} diff --git a/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/UserDto.kt b/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/UserDto.kt index f73009e..e160003 100644 --- a/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/UserDto.kt +++ b/core/network/src/main/kotlin/com/interlinedlist/android/core/network/dto/UserDto.kt @@ -2,6 +2,7 @@ package com.interlinedlist.android.core.network.dto import com.interlinedlist.android.core.model.CustomerStatus import com.interlinedlist.android.core.model.User +import com.interlinedlist.android.core.model.ViewingPreference import kotlinx.serialization.Serializable /** Wire model for the user object returned by the auth/user endpoints. */ @@ -17,6 +18,13 @@ data class UserDto( val customerStatus: String? = null, /** The account's default post visibility preference; public when absent. */ val defaultPubliclyVisible: Boolean = true, + /** + * Raw `viewingPreference` wire value (`all_messages`, `my_messages`, + * `following_only`, `followers_only`). Kept as a String here so an unknown + * server value deserialises rather than failing; [ViewingPreference.fromWireOrDefault] + * resolves it. + */ + val viewingPreference: String? = null, ) /** Maps the wire model into the domain [User]. */ @@ -30,4 +38,5 @@ fun UserDto.toDomain(): User = User( emailVerified = emailVerified, customerStatus = CustomerStatus.fromApiValue(customerStatus), defaultPubliclyVisible = defaultPubliclyVisible, + viewingPreference = ViewingPreference.fromWireOrDefault(viewingPreference), ) diff --git a/core/network/src/main/kotlin/com/interlinedlist/android/core/network/preferences/ViewingPreferenceStore.kt b/core/network/src/main/kotlin/com/interlinedlist/android/core/network/preferences/ViewingPreferenceStore.kt new file mode 100644 index 0000000..6c7a66c --- /dev/null +++ b/core/network/src/main/kotlin/com/interlinedlist/android/core/network/preferences/ViewingPreferenceStore.kt @@ -0,0 +1,58 @@ +package com.interlinedlist.android.core.network.preferences + +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.model.ViewingPreference +import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.interlinedlist.android.core.network.dto.UpdateUserRequest +import com.interlinedlist.android.core.network.error.safeApiCall +import kotlinx.serialization.json.Json +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Reads and writes the account's [ViewingPreference] — the one account field the + * messages feed has to own, because the feed is what the preference controls. + * + * It lives in `:core:network` rather than in a feature module because two features + * need it and **no feature module in this repo depends on another feature module**: + * `:feature:messages` reads and writes it from the in-feed switcher, while + * `:feature:profile`'s Settings screen writes it (among fourteen other fields) + * through its own `SettingsRepository`. + * + * Consequence to be aware of: `:feature:profile`'s `SettingsRepository` and this + * store are **two paths to the same `viewingPreference` field**, each with its own + * in-flight state. They should be consolidated into one owner (most likely a shared + * account-preferences module) once both surfaces have settled. + */ +@Singleton +class ViewingPreferenceStore @Inject constructor( + private val api: InterlinedListApi, + private val json: Json, +) { + + /** + * The preference currently saved on the account, from `GET /api/user`. An + * absent or unrecognised value resolves to [ViewingPreference.DEFAULT] rather + * than failing — the feed always needs something to load with. + */ + suspend fun read(): ApiResult = safeApiCall(json) { + ViewingPreference.fromWireOrDefault(api.getCurrentUser().user.viewingPreference) + } + + /** + * Saves [preference] with a partial `PATCH /api/user/update`, so the web and + * Android agree on what the feed shows. Returns the value the server reports as + * saved (falling back to [preference] when the response does not echo it), so a + * server-side normalisation wins over what the caller asked for. + */ + suspend fun write(preference: ViewingPreference): ApiResult { + val request = UpdateUserRequest(viewingPreference = preference.wire) + return when (val result = safeApiCall(json) { api.updateUser(request) }) { + is ApiResult.Success -> + ApiResult.Success( + ViewingPreference.fromWire(result.data.savedViewingPreference) ?: preference, + ) + is ApiResult.Failure -> result + } + } +} diff --git a/core/network/src/test/kotlin/com/interlinedlist/android/core/network/preferences/ViewingPreferenceStoreTest.kt b/core/network/src/test/kotlin/com/interlinedlist/android/core/network/preferences/ViewingPreferenceStoreTest.kt new file mode 100644 index 0000000..226884d --- /dev/null +++ b/core/network/src/test/kotlin/com/interlinedlist/android/core/network/preferences/ViewingPreferenceStoreTest.kt @@ -0,0 +1,158 @@ +package com.interlinedlist.android.core.network.preferences + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.model.ViewingPreference +import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * The shared `viewingPreference` accessor: reads the account preference from + * `GET /api/user` and saves it with a **partial** `PATCH /api/user/update`. + * + * The four wire values come from the server's own 400 (`viewingPreference must be + * one of: my_messages, all_messages, followers_only, following_only`) and the help + * centre's API reference (`/help/api/users-and-profile`), which lists + * `viewingPreference` among the fields `PATCH /api/user/update` accepts. + */ +class ViewingPreferenceStoreTest { + + private lateinit var server: MockWebServer + private lateinit var store: ViewingPreferenceStore + + // Mirrors the production Json (see NetworkModule). + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val api = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + .create(InterlinedListApi::class.java) + store = ViewingPreferenceStore(api, json) + } + + @After + fun tearDown() = server.shutdown() + + private fun enqueueUser(viewingPreference: String?) { + val field = viewingPreference?.let { """, "viewingPreference": "$it"""" } ?: "" + server.enqueue( + MockResponse().setBody("""{ "user": { "id": "u1", "username": "me"$field } }"""), + ) + } + + @Test + fun `read returns the account preference from GET api user`() = runBlocking { + enqueueUser("following_only") + + val result = store.read() + + assertThat((result as ApiResult.Success).data).isEqualTo(ViewingPreference.FOLLOWING) + assertThat(server.takeRequest().path).isEqualTo("/api/user") + } + + @Test + fun `read maps every wire value the server accepts`() = runBlocking { + val expected = mapOf( + "all_messages" to ViewingPreference.ALL, + "my_messages" to ViewingPreference.MINE, + "following_only" to ViewingPreference.FOLLOWING, + "followers_only" to ViewingPreference.FOLLOWERS, + ) + expected.forEach { (wire, preference) -> + enqueueUser(wire) + assertThat((store.read() as ApiResult.Success).data).isEqualTo(preference) + } + } + + @Test + fun `an absent or unknown preference falls back to all messages`() = runBlocking { + enqueueUser(null) + assertThat((store.read() as ApiResult.Success).data).isEqualTo(ViewingPreference.ALL) + + enqueueUser("something_new") + assertThat((store.read() as ApiResult.Success).data).isEqualTo(ViewingPreference.ALL) + } + + @Test + fun `write PATCHes only the viewingPreference field`() = runBlocking { + enqueueUser("followers_only") + + val result = store.write(ViewingPreference.FOLLOWERS) + + assertThat((result as ApiResult.Success).data).isEqualTo(ViewingPreference.FOLLOWERS) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("PATCH") + assertThat(request.path).isEqualTo("/api/user/update") + // Partial update: nothing but the one field, so no other preference is clobbered. + assertThat(request.body.readUtf8()).isEqualTo("""{"viewingPreference":"followers_only"}""") + } + + @Test + fun `write sends the wire value of each preference`() = runBlocking { + val expected = mapOf( + ViewingPreference.ALL to "all_messages", + ViewingPreference.MINE to "my_messages", + ViewingPreference.FOLLOWING to "following_only", + ViewingPreference.FOLLOWERS to "followers_only", + ) + expected.forEach { (preference, wire) -> + enqueueUser(wire) + store.write(preference) + assertThat(server.takeRequest().body.readUtf8()) + .isEqualTo("""{"viewingPreference":"$wire"}""") + } + } + + @Test + fun `write trusts the value the server echoes back`() = runBlocking { + // The server normalised the request to something else; server truth wins. + enqueueUser("all_messages") + + val result = store.write(ViewingPreference.FOLLOWING) + + assertThat((result as ApiResult.Success).data).isEqualTo(ViewingPreference.ALL) + } + + @Test + fun `write falls back to the requested value when the echo omits it`() = runBlocking { + server.enqueue(MockResponse().setBody("""{ "user": { "id": "u1", "username": "me" } }""")) + + val result = store.write(ViewingPreference.MINE) + + assertThat((result as ApiResult.Success).data).isEqualTo(ViewingPreference.MINE) + } + + @Test + fun `a rejected value surfaces the server error`() = runBlocking { + server.enqueue( + MockResponse().setResponseCode(400).setBody( + """{ "error": "viewingPreference must be one of: my_messages, all_messages, followers_only, following_only", "code": "bad_request" }""", + ), + ) + + val result = store.write(ViewingPreference.FOLLOWING) + + val error = (result as ApiResult.Failure).error + assertThat(error).isInstanceOf(AppError.Unknown::class.java) + assertThat(error.message).contains("viewingPreference must be one of") + } +} diff --git a/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt index d416bfd..1071507 100644 --- a/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt +++ b/feature/messages/src/androidTest/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreenTest.kt @@ -10,7 +10,9 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.core.model.ViewingPreference import com.interlinedlist.android.feature.messages.domain.LinkedNetwork import com.interlinedlist.android.feature.messages.domain.Message import com.interlinedlist.android.feature.messages.domain.MessageVisibility @@ -52,6 +54,7 @@ class MessagesFeedScreenTest { onBlockUser: (Message) -> Unit = {}, onMuteUser: (Message) -> Unit = {}, onReportUser: (Message) -> Unit = {}, + onViewingPreferenceChange: ((ViewingPreference) -> Unit)? = null, ) { composeRule.setContent { var state by mutableStateOf(initial) @@ -76,6 +79,10 @@ class MessagesFeedScreenTest { state = state.copy(selectedNetworkIds = selected) }, onVisibilityChange = { state = state.copy(composeVisibility = it) }, + onViewingPreferenceChange = { preference -> + onViewingPreferenceChange?.invoke(preference) + ?: run { state = state.copy(viewingPreference = preference) } + }, onReport = onReport, onEdit = onEdit, onBlockUser = onBlockUser, @@ -301,4 +308,68 @@ class MessagesFeedScreenTest { composeRule.onNodeWithTag(MessagesFeedTags.VISIBILITY_PRIVATE).assertIsSelected() composeRule.onNodeWithTag(MessagesFeedTags.VISIBILITY_HINT).assertIsDisplayed() } + + @Test + fun viewPreferenceSwitcher_isShown_andReflectsTheSavedPreference() { + setFeed( + MessagesFeedUiState( + messages = listOf(message("1", "hello")), + viewingPreference = ViewingPreference.FOLLOWING, + ), + ) + + composeRule.onNodeWithTag(MessagesFeedTags.VIEW_PREFERENCES).assertIsDisplayed() + composeRule + .onNodeWithTag(MessagesFeedTags.viewPreferenceTag(ViewingPreference.FOLLOWING)) + .assertIsSelected() + composeRule.onNodeWithText("All Messages").assertIsDisplayed() + } + + @Test + fun viewPreferenceSwitcher_reportsTheTappedPreference() { + val tapped = mutableListOf() + setFeed( + MessagesFeedUiState(messages = listOf(message("1", "hello"))), + onViewingPreferenceChange = { tapped += it }, + ) + + composeRule + .onNodeWithTag(MessagesFeedTags.viewPreferenceTag(ViewingPreference.MINE)) + .performClick() + + assertThat(tapped).containsExactly(ViewingPreference.MINE) + } + + @Test + fun viewPreferenceSwitcher_isDisabled_whileTheChoiceIsBeingSaved() { + val tapped = mutableListOf() + setFeed( + MessagesFeedUiState( + messages = listOf(message("1", "hello")), + isChangingViewingPreference = true, + ), + onViewingPreferenceChange = { tapped += it }, + ) + + composeRule + .onNodeWithTag(MessagesFeedTags.viewPreferenceTag(ViewingPreference.FOLLOWERS)) + .performClick() + + // No second request while one is in flight. + assertThat(tapped).isEmpty() + } + + @Test + fun viewPreferenceSwitcher_staysAvailable_whenTheFeedIsGated() { + setFeed( + MessagesFeedUiState( + subscriptionRequired = true, + errorMessage = "Subscribers only", + ), + ) + + // The user must still be able to switch away from a view they cannot see. + composeRule.onNodeWithTag(MessagesFeedTags.VIEW_PREFERENCES).assertIsDisplayed() + composeRule.onNodeWithTag(MessagesFeedTags.LOCKED).assertIsDisplayed() + } } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt index 08b75e9..d370e16 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepository.kt @@ -3,6 +3,7 @@ package com.interlinedlist.android.feature.messages.data import com.interlinedlist.android.core.common.dispatcher.DispatcherProvider import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.datastore.SessionStore +import com.interlinedlist.android.core.model.ViewingPreference import com.interlinedlist.android.feature.messages.data.local.MessageDao import com.interlinedlist.android.feature.messages.data.local.toDomain import com.interlinedlist.android.feature.messages.data.local.toEntity @@ -16,6 +17,7 @@ import com.interlinedlist.android.feature.messages.data.remote.dto.toDomain import com.interlinedlist.android.core.network.api.InterlinedListApi import com.interlinedlist.android.core.network.dto.toDomain import com.interlinedlist.android.core.network.error.safeApiCall +import com.interlinedlist.android.core.network.preferences.ViewingPreferenceStore import com.interlinedlist.android.feature.messages.domain.CreatedMessage import com.interlinedlist.android.feature.messages.domain.CrossPostSelection import com.interlinedlist.android.feature.messages.domain.LinkedNetwork @@ -36,6 +38,8 @@ class DefaultMessagesRepository @Inject constructor( private val api: MessagesApi, /** The shared current-user endpoint; supplies the default-visibility preference. */ private val userApi: InterlinedListApi, + /** The shared accessor for the account's feed view preference. */ + private val viewingPreferenceStore: ViewingPreferenceStore, private val messageDao: MessageDao, private val sessionStore: SessionStore, private val json: Json, @@ -58,20 +62,23 @@ class DefaultMessagesRepository @Inject constructor( * Loads the head of the feed (no cursor) and replaces the cached feed with it, * restarting keyset pagination. Returns the next page's opaque cursor. */ - override suspend fun refreshFeed(): ApiResult = withContext(dispatchers.io) { - when (val result = safeCall { api.getMessages(limit = PaginationDto.DEFAULT_LIMIT) }) { - is ApiResult.Success -> { - val page = result.data - val entities = page.rows.mapIndexed { index, dto -> - dto.toDomain(currentUserId()).toEntity(feedOrder = index.toLong()) + override suspend fun refreshFeed(preference: ViewingPreference): ApiResult = + withContext(dispatchers.io) { + when (val result = safeCall { + api.getMessages(limit = PaginationDto.DEFAULT_LIMIT, onlyMine = preference.onlyMine) + }) { + is ApiResult.Success -> { + val page = result.data + val entities = page.rows.mapIndexed { index, dto -> + dto.toDomain(currentUserId()).toEntity(feedOrder = index.toLong()) + } + messageDao.clearFeed() + messageDao.insertAll(entities) + ApiResult.Success(page.nextCursor) } - messageDao.clearFeed() - messageDao.insertAll(entities) - ApiResult.Success(page.nextCursor) + is ApiResult.Failure -> result } - is ApiResult.Failure -> result } - } /** * Appends the page following [cursor] to the tail of the cached feed. The @@ -79,9 +86,16 @@ class DefaultMessagesRepository @Inject constructor( * keyed by id, so a row the server happens to repeat updates in place rather * than duplicating. */ - override suspend fun loadMoreFeed(cursor: String): ApiResult = withContext(dispatchers.io) { + override suspend fun loadMoreFeed( + cursor: String, + preference: ViewingPreference, + ): ApiResult = withContext(dispatchers.io) { when (val result = safeCall { - api.getMessages(limit = PaginationDto.DEFAULT_LIMIT, cursor = cursor) + api.getMessages( + limit = PaginationDto.DEFAULT_LIMIT, + cursor = cursor, + onlyMine = preference.onlyMine, + ) }) { is ApiResult.Success -> { val page = result.data @@ -145,6 +159,14 @@ class DefaultMessagesRepository @Inject constructor( } } + override suspend fun getViewingPreference(): ApiResult = + withContext(dispatchers.io) { viewingPreferenceStore.read() } + + override suspend fun setViewingPreference( + preference: ViewingPreference, + ): ApiResult = + withContext(dispatchers.io) { viewingPreferenceStore.write(preference) } + override suspend fun getLinkedNetworks(): ApiResult> = withContext(dispatchers.io) { when (val result = safeCall { api.getIdentities() }) { is ApiResult.Success -> ApiResult.Success(result.data.identities.map { it.toDomain() }) @@ -391,6 +413,14 @@ class DefaultMessagesRepository @Inject constructor( // --- helpers ----------------------------------------------------------- + /** + * `onlyMine=true` is the feed's only request-level scoping mechanism, and it + * only applies to My Messages. For every other view the parameter is omitted + * and the server scopes the feed from the saved `viewingPreference`. + */ + private val ViewingPreference.onlyMine: Boolean? + get() = true.takeIf { this == ViewingPreference.MINE } + private suspend fun safeCall(block: suspend () -> T): ApiResult = safeApiCall(json, block) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt index 6ce4cd2..68a03af 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/MessagesRepository.kt @@ -1,6 +1,7 @@ package com.interlinedlist.android.feature.messages.data import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.model.ViewingPreference import com.interlinedlist.android.feature.messages.domain.CreatedMessage import com.interlinedlist.android.feature.messages.domain.CrossPostSelection import com.interlinedlist.android.feature.messages.domain.LinkedNetwork @@ -32,16 +33,43 @@ interface MessagesRepository { * Refreshes the first page of the feed from the API and replaces the cached * feed, restarting keyset pagination from the top. Returns the opaque cursor * for the next page, or null when the feed ends here. + * + * [preference] is the account's current view selection. Only + * [ViewingPreference.MINE] has a request-level mechanism (`onlyMine=true`); + * the following/followers scopes are applied by the server from the saved + * preference, which is why [setViewingPreference] must succeed before a + * refresh can show a different view. */ - suspend fun refreshFeed(): ApiResult + suspend fun refreshFeed( + preference: ViewingPreference = ViewingPreference.DEFAULT, + ): ApiResult /** * Fetches the page that follows [cursor] and appends it to the cached feed. * [cursor] is the opaque token a previous [refreshFeed]/[loadMoreFeed] * returned and is handed to the API verbatim — never construct or parse one. - * Returns the cursor for the page after this one, or null at the end. + * [preference] must match the one the page chain started under. Returns the + * cursor for the page after this one, or null at the end. + */ + suspend fun loadMoreFeed( + cursor: String, + preference: ViewingPreference = ViewingPreference.DEFAULT, + ): ApiResult + + /** + * The account's saved feed view preference, read from `viewingPreference` on + * `GET /api/user`. Seeds the in-feed switcher so Android opens on whatever the + * web was last set to. + */ + suspend fun getViewingPreference(): ApiResult + + /** + * Saves [preference] to the account with a partial `PATCH /api/user/update`, + * so the choice persists and the web agrees. Returns the value the server + * reports as saved. Callers must reload the feed from the top afterwards: the + * server applies this preference when it builds the feed. */ - suspend fun loadMoreFeed(cursor: String): ApiResult + suspend fun setViewingPreference(preference: ViewingPreference): ApiResult /** * Creates a new top-level message and caches it. Optionally attaches already diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt index bc22521..7318082 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/remote/MessagesApi.kt @@ -36,11 +36,18 @@ interface MessagesApi { * is opaque and must never be constructed, parsed or modified. A null * [cursor] loads the first page (Retrofit omits the query parameter). The * endpoint has no `offset` parameter. + * + * [onlyMine] restricts the feed to the caller's own messages. It is the only + * feed-scoping parameter the endpoint has (`/help/api/messages` lists `limit`, + * `offset`, `onlyMine` and `tag`): there is no following/followers parameter, + * because the server scopes the feed by the account's saved + * `viewingPreference`. Null omits the parameter. */ @GET("api/messages") suspend fun getMessages( @Query("limit") limit: Int, @Query("cursor") cursor: String? = null, + @Query("onlyMine") onlyMine: Boolean? = null, ): MessagesResponse /** Creates a new message (or a reply when `parentId` is set). The created diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt index 8ef985c..7924864 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedScreen.kt @@ -3,10 +3,10 @@ package com.interlinedlist.android.feature.messages.ui.feed import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close @@ -62,6 +63,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme +import com.interlinedlist.android.core.model.ViewingPreference import com.interlinedlist.android.feature.messages.domain.CrossPostStatus import com.interlinedlist.android.feature.messages.domain.LinkedNetwork import com.interlinedlist.android.feature.messages.domain.Message @@ -90,6 +92,11 @@ object MessagesFeedTags { const val COMPOSE_SCHEDULE = "messagesComposeSchedule" const val SCHEDULED_ACTION = "messagesFeedScheduledAction" + /** The in-feed All / My / Following / Followers switcher. */ + const val VIEW_PREFERENCES = "messagesFeedViewPreferences" + /** Prefix for one switcher chip; suffixed with the preference's wire value. */ + const val VIEW_PREFERENCE_PREFIX = "messagesFeedViewPreference_" + /** The always-on InterlinedList destination chip. */ const val DESTINATION_IL = "messagesComposeDestinationInterlinedList" /** Prefix for a per-network destination chip; suffixed with the network id. */ @@ -105,6 +112,9 @@ object MessagesFeedTags { const val VISIBILITY_HINT = "messagesComposeVisibilityHint" fun destinationTag(networkId: String): String = DESTINATION_PREFIX + networkId + + fun viewPreferenceTag(preference: ViewingPreference): String = + VIEW_PREFERENCE_PREFIX + preference.wire } /** @@ -126,6 +136,7 @@ fun MessagesRoute( state = state, onRefresh = viewModel::refresh, onLoadMore = viewModel::loadMore, + onViewingPreferenceChange = viewModel::onViewingPreferenceChange, onOpenMessage = onOpenMessage, onOpenScheduled = onOpenScheduled, onDig = viewModel::onDig, @@ -178,6 +189,7 @@ fun MessagesFeedScreen( onComposeTextChange: (String) -> Unit, onPost: () -> Unit, modifier: Modifier = Modifier, + onViewingPreferenceChange: (ViewingPreference) -> Unit = {}, onOpenScheduled: () -> Unit = {}, onReport: (Message) -> Unit = {}, onEdit: (Message) -> Unit = {}, @@ -225,26 +237,29 @@ fun MessagesFeedScreen( } }, ) { padding -> - when { - state.subscriptionRequired -> LockedState( - message = state.errorMessage, - modifier = Modifier.padding(padding), - ) - else -> FeedContent( - state = state, - contentPadding = padding, - onRefresh = onRefresh, - onLoadMore = onLoadMore, - onOpenMessage = onOpenMessage, - onDig = onDig, - onDelete = onDelete, - onReport = onReport, - onEdit = onEdit, - onBlockUser = onBlockUser, - onMuteUser = onMuteUser, - onReportUser = onReportUser, - onFetchMetadata = onFetchMetadata, + Column(Modifier.fillMaxSize().padding(padding)) { + ViewPreferenceSwitcher( + selected = state.viewingPreference, + enabled = !state.isChangingViewingPreference, + onSelect = onViewingPreferenceChange, ) + when { + state.subscriptionRequired -> LockedState(message = state.errorMessage) + else -> FeedContent( + state = state, + onRefresh = onRefresh, + onLoadMore = onLoadMore, + onOpenMessage = onOpenMessage, + onDig = onDig, + onDelete = onDelete, + onReport = onReport, + onEdit = onEdit, + onBlockUser = onBlockUser, + onMuteUser = onMuteUser, + onReportUser = onReportUser, + onFetchMetadata = onFetchMetadata, + ) + } } } @@ -298,11 +313,51 @@ fun MessagesFeedScreen( } } +/** + * The in-feed view switcher: the same four choices as Settings -> View Preferences + * on the web. Selecting one saves it to the account (so the web agrees) and reloads + * the feed, which is why the row is disabled while a save is in flight. + */ +@Composable +private fun ViewPreferenceSwitcher( + selected: ViewingPreference, + enabled: Boolean, + onSelect: (ViewingPreference) -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 8.dp) + .testTag(MessagesFeedTags.VIEW_PREFERENCES), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + ViewingPreference.entries.forEach { preference -> + FilterChip( + selected = preference == selected, + onClick = { onSelect(preference) }, + enabled = enabled, + label = { Text(preference.label) }, + modifier = Modifier.testTag(MessagesFeedTags.viewPreferenceTag(preference)), + ) + } + } + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) +} + +/** The switcher wording, matching Settings -> View Preferences on the web. */ +private val ViewingPreference.label: String + get() = when (this) { + ViewingPreference.ALL -> "All Messages" + ViewingPreference.MINE -> "My Messages" + ViewingPreference.FOLLOWING -> "Following Only" + ViewingPreference.FOLLOWERS -> "Followers Only" + } + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun FeedContent( state: MessagesFeedUiState, - contentPadding: PaddingValues, onRefresh: () -> Unit, onLoadMore: () -> Unit, onOpenMessage: (String) -> Unit, @@ -318,9 +373,7 @@ private fun FeedContent( PullToRefreshBox( isRefreshing = state.isRefreshing, onRefresh = onRefresh, - modifier = Modifier - .fillMaxSize() - .padding(contentPadding), + modifier = Modifier.fillMaxSize(), ) { when { state.isEmpty && state.isRefreshing -> LoadingState() diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt index 80e1e12..c78f82a 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.model.ViewingPreference import com.interlinedlist.android.feature.messages.data.MessagesRepository import com.interlinedlist.android.feature.messages.domain.CrossPostSelection import com.interlinedlist.android.feature.messages.domain.CrossPostStatus @@ -41,6 +42,10 @@ data class MessagesFeedUiState( val errorMessage: String? = null, /** True when the failure is a subscription gate — render an upsell instead. */ val subscriptionRequired: Boolean = false, + /** Which messages the feed is showing; drives the in-feed switcher. */ + val viewingPreference: ViewingPreference = ViewingPreference.DEFAULT, + /** True while a switcher choice is being saved to the account. */ + val isChangingViewingPreference: Boolean = false, val isComposeOpen: Boolean = false, val composeText: String = "", val isPosting: Boolean = false, @@ -112,6 +117,13 @@ private data class FeedTransientState( val nextCursor: String? = null, val errorMessage: String? = null, val subscriptionRequired: Boolean = false, + /** + * The account's feed view preference. Seeded from `GET /api/user` and changed + * by the switcher; the server scopes the feed by it, so it is also the value + * every feed request runs under. + */ + val viewingPreference: ViewingPreference = ViewingPreference.DEFAULT, + val isChangingViewingPreference: Boolean = false, val isComposeOpen: Boolean = false, val composeText: String = "", val isPosting: Boolean = false, @@ -159,6 +171,8 @@ class MessagesFeedViewModel @Inject constructor( canLoadMore = t.canLoadMore, errorMessage = t.errorMessage, subscriptionRequired = t.subscriptionRequired, + viewingPreference = t.viewingPreference, + isChangingViewingPreference = t.isChangingViewingPreference, isComposeOpen = t.isComposeOpen, composeText = t.composeText, isPosting = t.isPosting, @@ -183,11 +197,69 @@ class MessagesFeedViewModel @Inject constructor( ) init { - refresh() + loadViewingPreferenceThenRefresh() loadLinkedNetworks() loadDefaultVisibility() } + /** + * Reads the account's saved view preference and only then loads the feed, so + * the first request already runs under the right view instead of briefly + * showing All Messages. A failed read leaves the default (All Messages) in + * place and still loads the feed — an unreadable preference must not leave the + * user staring at an empty screen. + */ + private fun loadViewingPreferenceThenRefresh() { + // Set up front so the feed shows its loading state, not its empty state, + // while the preference is being read. + transient.update { it.copy(isRefreshing = true) } + viewModelScope.launch { + val result = repository.getViewingPreference() + if (result is ApiResult.Success) { + transient.update { it.copy(viewingPreference = result.data) } + } + refresh() + } + } + + /** + * Switches the feed to [preference]: saves it to the account first (so the + * choice persists and the web agrees), then reloads the feed from the top. + * + * The selection updates immediately for responsiveness but is **rolled back** + * if the save fails — the switcher must never show a view the account never + * stored. The feed is only reloaded once the save succeeded, because the + * server scopes the feed from the saved preference. + */ + fun onViewingPreferenceChange(preference: ViewingPreference) { + val current = transient.value + if (preference == current.viewingPreference || current.isChangingViewingPreference) return + val previous = current.viewingPreference + transient.update { + it.copy( + viewingPreference = preference, + isChangingViewingPreference = true, + errorMessage = null, + subscriptionRequired = false, + ) + } + viewModelScope.launch { + when (val result = repository.setViewingPreference(preference)) { + is ApiResult.Success -> { + // Server truth wins over the tapped value. + transient.update { + it.copy(viewingPreference = result.data, isChangingViewingPreference = false) + } + refresh() + } + is ApiResult.Failure -> transient.update { + it.copy(viewingPreference = previous, isChangingViewingPreference = false) + .withError(result.error) + } + } + } + } + /** * Loads the account's default post visibility so the composer opens on the * user's preference. Best-effort: a failure leaves the default at public and @@ -236,7 +308,7 @@ class MessagesFeedViewModel @Inject constructor( ) } viewModelScope.launch { - when (val result = repository.refreshFeed()) { + when (val result = repository.refreshFeed(transient.value.viewingPreference)) { is ApiResult.Success -> transient.update { it.copy(isRefreshing = false, nextCursor = result.data) } @@ -254,7 +326,7 @@ class MessagesFeedViewModel @Inject constructor( if (current.isLoadingMore || current.isRefreshing) return transient.update { it.copy(isLoadingMore = true) } viewModelScope.launch { - when (val result = repository.loadMoreFeed(cursor)) { + when (val result = repository.loadMoreFeed(cursor, current.viewingPreference)) { is ApiResult.Success -> transient.update { it.copy(isLoadingMore = false, nextCursor = result.data) } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt index 248a939..4f470dd 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/DefaultMessagesRepositoryTest.kt @@ -4,6 +4,7 @@ import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.interlinedlist.android.core.network.preferences.ViewingPreferenceStore import com.interlinedlist.android.feature.messages.data.remote.MessagesApi import com.interlinedlist.android.feature.messages.domain.CrossPostSelection import com.interlinedlist.android.feature.messages.domain.MessageVisibility @@ -60,6 +61,7 @@ class DefaultMessagesRepositoryTest { private fun repository(currentUserId: String? = "me") = DefaultMessagesRepository( api = api, userApi = userApi, + viewingPreferenceStore = ViewingPreferenceStore(userApi, json), messageDao = dao, sessionStore = fakeSessionStore(currentUserId), json = json, diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/MessagesFeedCursorPagingTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/MessagesFeedCursorPagingTest.kt index f9b8f2f..9c0e114 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/MessagesFeedCursorPagingTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/MessagesFeedCursorPagingTest.kt @@ -3,6 +3,7 @@ package com.interlinedlist.android.feature.messages.data import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.interlinedlist.android.core.network.preferences.ViewingPreferenceStore import com.interlinedlist.android.feature.messages.data.remote.MessagesApi import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -70,6 +71,7 @@ class MessagesFeedCursorPagingTest { private fun repository() = DefaultMessagesRepository( api = api, userApi = userApi, + viewingPreferenceStore = ViewingPreferenceStore(userApi, json), messageDao = dao, sessionStore = fakeSessionStore("me"), json = json, diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/MessagesFeedViewPreferenceTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/MessagesFeedViewPreferenceTest.kt new file mode 100644 index 0000000..95421d6 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/MessagesFeedViewPreferenceTest.kt @@ -0,0 +1,176 @@ +package com.interlinedlist.android.feature.messages.data + +import com.google.common.truth.Truth.assertThat +import com.interlinedlist.android.core.common.result.ApiResult +import com.interlinedlist.android.core.model.ViewingPreference +import com.interlinedlist.android.core.network.api.InterlinedListApi +import com.interlinedlist.android.core.network.preferences.ViewingPreferenceStore +import com.interlinedlist.android.feature.messages.data.remote.MessagesApi +import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Before +import org.junit.Test +import retrofit2.Retrofit + +/** + * How the account's `viewingPreference` reaches `GET /api/messages`. + * + * The help centre's API reference (`/help/api/messages`) documents the feed's only + * query parameters as `limit`, `offset`, `onlyMine` and `tag` — there is no + * following/followers parameter. It also states that search is "scoped to your feed + * visibility (honors your `viewingPreference`)", i.e. **the server applies the stored + * preference itself**. So the client's job is: persist the preference, then refresh; + * `onlyMine=true` stays the per-request mechanism for My Messages. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class MessagesFeedViewPreferenceTest { + + private val dispatcher = StandardTestDispatcher() + + // Mirrors the production Json (see core:network NetworkModule). + private val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + coerceInputValues = true + } + + private lateinit var server: MockWebServer + private lateinit var api: MessagesApi + private lateinit var userApi: InterlinedListApi + private lateinit var dao: FakeMessageDao + + @Before + fun setUp() { + server = MockWebServer() + server.start() + val retrofit = Retrofit.Builder() + .baseUrl(server.url("/")) + .addConverterFactory(json.asConverterFactory("application/json".toMediaType())) + .build() + api = retrofit.create(MessagesApi::class.java) + userApi = retrofit.create(InterlinedListApi::class.java) + dao = FakeMessageDao() + } + + @After + fun tearDown() = server.shutdown() + + private fun repository() = DefaultMessagesRepository( + api = api, + userApi = userApi, + viewingPreferenceStore = ViewingPreferenceStore(userApi, json), + messageDao = dao, + sessionStore = fakeSessionStore("me"), + json = json, + dispatchers = TestDispatcherProvider(dispatcher), + ) + + private fun enqueuePage(nextCursor: String? = null) { + val cursor = nextCursor?.let { "\"$it\"" } ?: "null" + server.enqueue( + MockResponse().setBody( + """{ "messages": [ { "id": "m1", "content": "hi" } ], + "pagination": { "limit": 20, "hasMore": ${nextCursor != null}, "nextCursor": $cursor } }""", + ), + ) + } + + @Test + fun `All Messages asks for the whole feed with no onlyMine filter`() = runTest(dispatcher) { + enqueuePage() + + repository().refreshFeed(ViewingPreference.ALL) + + val url = server.takeRequest().requestUrl!! + assertThat(url.encodedPath).isEqualTo("/api/messages") + assertThat(url.queryParameter("onlyMine")).isNull() + assertThat(url.queryParameter("limit")).isEqualTo("20") + } + + @Test + fun `My Messages sends onlyMine true`() = runTest(dispatcher) { + enqueuePage() + + repository().refreshFeed(ViewingPreference.MINE) + + assertThat(server.takeRequest().requestUrl!!.queryParameter("onlyMine")).isEqualTo("true") + } + + @Test + fun `Following Only leaves the filtering to the server-side preference`() = runTest(dispatcher) { + enqueuePage() + + repository().refreshFeed(ViewingPreference.FOLLOWING) + + // No such query parameter exists; the saved preference scopes the feed. + val url = server.takeRequest().requestUrl!! + assertThat(url.queryParameter("onlyMine")).isNull() + assertThat(url.queryParameter("following")).isNull() + assertThat(url.querySize).isEqualTo(1) + } + + @Test + fun `Followers Only leaves the filtering to the server-side preference`() = runTest(dispatcher) { + enqueuePage() + + repository().refreshFeed(ViewingPreference.FOLLOWERS) + + val url = server.takeRequest().requestUrl!! + assertThat(url.queryParameter("onlyMine")).isNull() + assertThat(url.queryParameter("followers")).isNull() + assertThat(url.querySize).isEqualTo(1) + } + + @Test + fun `paging past the first page keeps the preference and the cursor`() = runTest(dispatcher) { + enqueuePage(nextCursor = "cursor-2") + enqueuePage() + val repo = repository() + + val cursor = (repo.refreshFeed(ViewingPreference.MINE) as ApiResult.Success).data!! + repo.loadMoreFeed(cursor, ViewingPreference.MINE) + + server.takeRequest() + val url = server.takeRequest().requestUrl!! + assertThat(url.queryParameter("onlyMine")).isEqualTo("true") + assertThat(url.queryParameter("cursor")).isEqualTo("cursor-2") + } + + @Test + fun `the feed reads the account preference from GET api user`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """{ "user": { "id": "u1", "username": "me", "viewingPreference": "following_only" } }""", + ), + ) + + val result = repository().getViewingPreference() + + assertThat((result as ApiResult.Success).data).isEqualTo(ViewingPreference.FOLLOWING) + assertThat(server.takeRequest().path).isEqualTo("/api/user") + } + + @Test + fun `saving the preference PATCHes the account so the web agrees`() = runTest(dispatcher) { + server.enqueue( + MockResponse().setBody( + """{ "user": { "id": "u1", "username": "me", "viewingPreference": "my_messages" } }""", + ), + ) + + val result = repository().setViewingPreference(ViewingPreference.MINE) + + assertThat((result as ApiResult.Success).data).isEqualTo(ViewingPreference.MINE) + val request = server.takeRequest() + assertThat(request.method).isEqualTo("PATCH") + assertThat(request.path).isEqualTo("/api/user/update") + assertThat(request.body.readUtf8()).isEqualTo("""{"viewingPreference":"my_messages"}""") + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt index bf5ad5d..bdefebe 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/FakeMessagesRepository.kt @@ -2,6 +2,7 @@ package com.interlinedlist.android.feature.messages.ui import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.model.ViewingPreference import com.interlinedlist.android.feature.messages.data.MessagesRepository import com.interlinedlist.android.feature.messages.domain.CreatedMessage import com.interlinedlist.android.feature.messages.domain.CrossPostSelection @@ -52,11 +53,21 @@ class FakeMessagesRepository : MessagesRepository { var cancelScheduledResult: ApiResult = ApiResult.Success(Unit) var reportResult: ApiResult = ApiResult.Success(Unit) var metadataResult: ApiResult? = null + /** The account's saved feed preference, as read from `GET /api/user`. */ + var viewingPreferenceResult: ApiResult = ApiResult.Success(ViewingPreference.ALL) + /** What the `PATCH /api/user/update` of the preference answers with. */ + var setViewingPreferenceResult: ApiResult? = null var refreshCount = 0 var loadMoreCount = 0 /** Every cursor handed to [loadMoreFeed], in order. */ val loadMoreCursors = mutableListOf() + /** The preference each [refreshFeed] ran under, in order. */ + val refreshPreferences = mutableListOf() + /** The preference each [loadMoreFeed] ran under, in order. */ + val loadMorePreferences = mutableListOf() + /** Every preference [setViewingPreference] was asked to PATCH, in order. */ + val savedViewingPreferences = mutableListOf() var lastSetDug: Pair? = null var deletedIds = mutableListOf() var lastCreate: CreateArgs? = null @@ -104,17 +115,28 @@ class FakeMessagesRepository : MessagesRepository { override fun observeScheduled(): Flow> = scheduled - override suspend fun refreshFeed(): ApiResult { + override suspend fun refreshFeed(preference: ViewingPreference): ApiResult { refreshCount++ + refreshPreferences += preference return refreshResult } - override suspend fun loadMoreFeed(cursor: String): ApiResult { + override suspend fun loadMoreFeed(cursor: String, preference: ViewingPreference): ApiResult { loadMoreCount++ loadMoreCursors += cursor + loadMorePreferences += preference return loadMoreResult } + override suspend fun getViewingPreference(): ApiResult = viewingPreferenceResult + + override suspend fun setViewingPreference( + preference: ViewingPreference, + ): ApiResult { + savedViewingPreferences += preference + return setViewingPreferenceResult ?: ApiResult.Success(preference) + } + override suspend fun createMessage( content: String, imageUrls: List, diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt index 37a0c47..6fd04c6 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/MessagesFeedViewModelTest.kt @@ -4,6 +4,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import com.interlinedlist.android.core.common.result.ApiResult import com.interlinedlist.android.core.common.result.AppError +import com.interlinedlist.android.core.model.ViewingPreference import com.interlinedlist.android.feature.messages.domain.CrossPostStatus import com.interlinedlist.android.feature.messages.domain.MessageVisibility import com.interlinedlist.android.feature.messages.domain.ReportReason @@ -737,4 +738,142 @@ class MessagesFeedViewModelTest { assertThat(repo.lastCreate?.visibility).isEqualTo(MessageVisibility.PRIVATE) assertThat(vm.uiState.value.composeVisibility).isEqualTo(MessageVisibility.PUBLIC) } + + // --- view preferences (All / My / Following / Followers) ---------------- + + @Test + fun `the feed opens on the account's saved viewing preference`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + viewingPreferenceResult = ApiResult.Success(ViewingPreference.FOLLOWING) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + assertThat(vm.uiState.value.viewingPreference).isEqualTo(ViewingPreference.FOLLOWING) + // The very first feed request already carries the saved preference: the feed + // never briefly shows All Messages before correcting itself. + assertThat(repo.refreshPreferences).containsExactly(ViewingPreference.FOLLOWING) + } + + @Test + fun `an unreadable account preference leaves the feed on All Messages`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + viewingPreferenceResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + assertThat(vm.uiState.value.viewingPreference).isEqualTo(ViewingPreference.ALL) + assertThat(repo.refreshPreferences).containsExactly(ViewingPreference.ALL) + } + + @Test + fun `switching saves the preference to the account and refreshes the feed`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onViewingPreferenceChange(ViewingPreference.FOLLOWERS) + advanceUntilIdle() + + // Persisted, so the web and Android agree on the next load. + assertThat(repo.savedViewingPreferences).containsExactly(ViewingPreference.FOLLOWERS) + assertThat(vm.uiState.value.viewingPreference).isEqualTo(ViewingPreference.FOLLOWERS) + // And the feed reloaded under the new preference. + assertThat(repo.refreshCount).isEqualTo(2) + assertThat(repo.refreshPreferences.last()).isEqualTo(ViewingPreference.FOLLOWERS) + } + + @Test + fun `switching restarts paging from the top`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { refreshResult = ApiResult.Success("cursor-all") } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + // The refresh triggered by the switch returns a different head-of-feed cursor. + repo.refreshResult = ApiResult.Success("cursor-mine") + vm.onViewingPreferenceChange(ViewingPreference.MINE) + advanceUntilIdle() + vm.loadMore() + advanceUntilIdle() + + // The stale cursor from the previous preference was discarded, not reused. + assertThat(repo.loadMoreCursors).containsExactly("cursor-mine") + assertThat(repo.loadMorePreferences).containsExactly(ViewingPreference.MINE) + } + + @Test + fun `a failed save rolls the selection back and reports the error`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + viewingPreferenceResult = ApiResult.Success(ViewingPreference.ALL) + setViewingPreferenceResult = ApiResult.Failure(AppError.Network("offline")) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onViewingPreferenceChange(ViewingPreference.FOLLOWING) + advanceUntilIdle() + + assertThat(repo.savedViewingPreferences).containsExactly(ViewingPreference.FOLLOWING) + // Never leave a selection showing that the account did not actually save. + assertThat(vm.uiState.value.viewingPreference).isEqualTo(ViewingPreference.ALL) + assertThat(vm.uiState.value.errorMessage) + .isEqualTo("No connection. Check your network and try again.") + // And no feed reload under a preference the server rejected. + assertThat(repo.refreshCount).isEqualTo(1) + assertThat(vm.uiState.value.isChangingViewingPreference).isFalse() + } + + @Test + fun `the server's own normalised value wins over the tapped one`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + setViewingPreferenceResult = ApiResult.Success(ViewingPreference.ALL) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onViewingPreferenceChange(ViewingPreference.FOLLOWING) + advanceUntilIdle() + + assertThat(vm.uiState.value.viewingPreference).isEqualTo(ViewingPreference.ALL) + assertThat(repo.refreshPreferences.last()).isEqualTo(ViewingPreference.ALL) + } + + @Test + fun `re-tapping the current preference does nothing`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + viewingPreferenceResult = ApiResult.Success(ViewingPreference.MINE) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.onViewingPreferenceChange(ViewingPreference.MINE) + advanceUntilIdle() + + assertThat(repo.savedViewingPreferences).isEmpty() + assertThat(repo.refreshCount).isEqualTo(1) + } + + @Test + fun `manual refresh keeps the selected preference`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + viewingPreferenceResult = ApiResult.Success(ViewingPreference.FOLLOWERS) + } + val vm = MessagesFeedViewModel(repo) + backgroundScope.launch { vm.uiState.collect {} } + advanceUntilIdle() + + vm.refresh() + advanceUntilIdle() + + assertThat(repo.refreshPreferences) + .containsExactly(ViewingPreference.FOLLOWERS, ViewingPreference.FOLLOWERS) + } }