diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 03d93a3..53c2f14 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -29,6 +29,13 @@ destinations (shared list/document tokens, password reset, email verification, and the confirm/undo halves of an email change). autoVerify enables Android App Links verification. + + The site root is registered for the tag feed: the web links every + tag on a message card to "/?tag=" (there is no /tag/ + path — it 404s), and an intent filter cannot match on a query + string. So "/" is the narrowest filter that can catch a tag link. + A root link with no tag simply opens the app's normal start + destination, which shows the same feed the homepage does. --> @@ -37,6 +44,7 @@ + @@ -50,6 +58,7 @@ + diff --git a/app/src/main/java/com/interlinedlist/android/MainActivity.kt b/app/src/main/java/com/interlinedlist/android/MainActivity.kt index 3a58463..1ebf09d 100644 --- a/app/src/main/java/com/interlinedlist/android/MainActivity.kt +++ b/app/src/main/java/com/interlinedlist/android/MainActivity.kt @@ -13,6 +13,7 @@ import com.interlinedlist.android.core.datastore.ThemeMode import com.interlinedlist.android.core.datastore.ThemeSettingsStore import com.interlinedlist.android.core.designsystem.theme.InterlinedListTheme import com.interlinedlist.android.feature.auth.nav.AuthRoutes +import com.interlinedlist.android.feature.messages.navigation.MessagesDestinations import com.interlinedlist.android.navigation.InterlinedListNavHost import com.interlinedlist.android.navigation.NotificationLaunch import dagger.hilt.android.AndroidEntryPoint @@ -40,6 +41,10 @@ class MainActivity : ComponentActivity() { // the VIEW intent's data. Both endpoints behind it are unauthenticated, so the // route resolves regardless of whether a session exists. val emailChangeRoute = AuthRoutes.routeForEmailChangeLink(intent?.dataString) + // A tapped tag link (`https://interlinedlist.com/?tag=…`, the URL the web's + // own tag chips point at) resolves to the tag-filtered feed. Resolved here + // rather than by implicit nav matching so the rule is unit-testable. + val tagFeedRoute = MessagesDestinations.routeForTagLink(intent?.dataString) enableEdgeToEdge() setContent { val themeMode by themeSettingsStore.themeMode.collectAsStateWithLifecycle() @@ -53,6 +58,7 @@ class MainActivity : ComponentActivity() { startLoggedIn = startLoggedIn, notificationRoute = notificationRoute, emailChangeRoute = emailChangeRoute, + tagFeedRoute = tagFeedRoute, ) } } diff --git a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt index 4d805dc..71a7967 100644 --- a/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt +++ b/app/src/main/java/com/interlinedlist/android/navigation/InterlinedListNavHost.kt @@ -59,6 +59,7 @@ import com.interlinedlist.android.feature.lists.ui.share.ShareRoute import com.interlinedlist.android.feature.lists.ui.share.SharedListRoute import com.interlinedlist.android.feature.lists.ui.share.SharedWithMeRoute import com.interlinedlist.android.feature.lists.ui.watchers.WatchersRoute +import com.interlinedlist.android.feature.messages.navigation.MessagesDestinations import com.interlinedlist.android.feature.messages.ui.detail.MessageDetailRoute import com.interlinedlist.android.feature.messages.ui.feed.MessagesRoute import com.interlinedlist.android.feature.messages.ui.scheduled.ScheduledMessagesRoute @@ -193,6 +194,7 @@ fun InterlinedListNavHost( startLoggedIn: Boolean, notificationRoute: String? = null, emailChangeRoute: String? = null, + tagFeedRoute: String? = null, ) { val navController = rememberNavController() NavHost( @@ -215,7 +217,8 @@ fun InterlinedListNavHost( composable(Routes.MAIN) { val context = LocalContext.current MainShell( - notificationRoute = notificationRoute, + // A launch is either a notification tap or a link tap, never both. + pendingRoute = notificationRoute ?: tagFeedRoute, onLoggedOut = { // Stop background sync/poll for the signed-out session. Cancellation // must never crash the sign-out flow, so any failure is swallowed. @@ -244,7 +247,7 @@ fun InterlinedListNavHost( */ @Composable private fun MainShell( - notificationRoute: String? = null, + pendingRoute: String? = null, onLoggedOut: () -> Unit, ) { val tabNav = rememberNavController() @@ -278,10 +281,11 @@ private fun MainShell( val pushRegistration: PushRegistrationViewModel = hiltViewModel() LaunchedEffect(Unit) { pushRegistration.runForSession() } - // Route straight to a tapped notification's destination once, when present. - val pendingRoute by rememberUpdatedState(notificationRoute) + // Route straight to the launch's destination once, when present: a tapped + // notification, or a tapped link (a tag feed) resolved in MainActivity. + val launchRoute by rememberUpdatedState(pendingRoute) LaunchedEffect(Unit) { - pendingRoute?.let { route -> + launchRoute?.let { route -> runCatching { tabNav.navigate(route) } } } @@ -321,6 +325,22 @@ private fun MainShell( MessagesRoute( onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }, onOpenScheduled = { tabNav.navigate(Routes.MESSAGES_SCHEDULED) }, + onOpenTag = { tag -> tabNav.navigate(MessagesDestinations.tagFeedRoute(tag)) }, + ) + } + // The same feed screen, filtered to one tag. The tag arrives as a nav + // argument, so paging and the view switcher are shared, not forked. + composable( + MessagesDestinations.TAG_FEED, + arguments = listOf( + navArgument(MessagesDestinations.ARG_TAG) { type = NavType.StringType }, + ), + ) { + MessagesRoute( + onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }, + onOpenScheduled = {}, + onOpenTag = { tag -> tabNav.navigate(MessagesDestinations.tagFeedRoute(tag)) }, + onBack = { tabNav.popBackStack() }, ) } composable( @@ -330,6 +350,7 @@ private fun MainShell( MessageDetailRoute( onBack = { tabNav.popBackStack() }, onOpenMessage = { id -> tabNav.navigate(Routes.messageDetail(id)) }, + onOpenTag = { tag -> tabNav.navigate(MessagesDestinations.tagFeedRoute(tag)) }, ) } composable(Routes.MESSAGES_SCHEDULED) { 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 37dcb09..6596700 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 @@ -79,6 +79,8 @@ class MessagesFeedScreenTest { onPush: (Message) -> Unit = {}, onQuote: (Message) -> Unit = {}, onSelectTagSuggestion: (TagSuggestion) -> Unit = {}, + onOpenTag: ((String) -> Unit)? = null, + onBack: () -> Unit = {}, ) { composeRule.setContent { var state by mutableStateOf(initial) @@ -137,6 +139,8 @@ class MessagesFeedScreenTest { state = state.copy(isComposeOpen = true, quoteTarget = quoted) onQuote(quoted) }, + onOpenTag = onOpenTag, + onBack = onBack, ) } } @@ -593,6 +597,67 @@ class MessagesFeedScreenTest { composeRule.onNodeWithText(tag).assertIsDisplayed() } + @Test + fun tappingATag_opensThatTagsFeed() { + val opened = mutableListOf() + setFeed( + MessagesFeedUiState(messages = listOf(message("1", "tagged post", tags = listOf("lists")))), + onOpenTag = { opened += it }, + ) + + composeRule.onNodeWithTag(MessageCardTags.tagTag("lists")).performClick() + + assertThat(opened).containsExactly("lists") + } + + @Test + fun tappingATagWithSpacesAndPunctuation_passesItWhole() { + val tag = "life is short, o brave girl" + val opened = mutableListOf() + setFeed( + MessagesFeedUiState(messages = listOf(message("1", "tagged", tags = listOf(tag)))), + onOpenTag = { opened += it }, + ) + + composeRule.onNodeWithTag(MessageCardTags.tagTag(tag)).performClick() + + // Exactly the tag the card carried: not trimmed, split or lowercased. + assertThat(opened).containsExactly(tag) + } + + @Test + fun tags_areInert_whereTheHostWiresNoTagDestination() { + setFeed(MessagesFeedUiState(messages = listOf(message("1", "tagged", tags = listOf("lists"))))) + composeRule.onNodeWithTag(MessageCardTags.tagTag("lists")).assertHasNoClickAction() + } + + @Test + fun tagFeed_showsTheTagAndABackArrow_insteadOfTheComposerAndScheduled() { + val backs = mutableListOf() + setFeed( + MessagesFeedUiState( + tag = "lists", + messages = listOf(message("1", "tagged", tags = listOf("lists"))), + ), + onBack = { backs += Unit }, + ) + + composeRule.onNodeWithTag(MessagesFeedTags.TAG_TITLE).assertIsDisplayed() + // Composing here would post an untagged message into a feed it cannot join. + composeRule.onNodeWithTag(MessagesFeedTags.FAB).assertDoesNotExist() + composeRule.onNodeWithTag(MessagesFeedTags.SCHEDULED_ACTION).assertDoesNotExist() + + composeRule.onNodeWithTag(MessagesFeedTags.BACK).performClick() + assertThat(backs).hasSize(1) + } + + @Test + fun tagFeed_keepsTheViewPreferenceSwitcher() { + // The tag feed is a normal feed: the account's view preference still applies. + setFeed(MessagesFeedUiState(tag = "lists", messages = listOf(message("1", "tagged")))) + composeRule.onNodeWithTag(MessagesFeedTags.VIEW_PREFERENCES).assertIsDisplayed() + } + @Test fun noTagRow_isShown_forAnUntaggedMessage() { setFeed(MessagesFeedUiState(messages = listOf(message("1", "plain")))) 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 bf7ce67..8a04592 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 @@ -4,12 +4,16 @@ 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.FeedEntryEntity +import com.interlinedlist.android.feature.messages.data.local.MAIN_FEED_KEY import com.interlinedlist.android.feature.messages.data.local.MessageDao +import com.interlinedlist.android.feature.messages.data.local.feedKeyFor import com.interlinedlist.android.feature.messages.data.local.toDomain import com.interlinedlist.android.feature.messages.data.local.toEntity import com.interlinedlist.android.feature.messages.data.remote.MessagesApi import com.interlinedlist.android.feature.messages.data.remote.dto.CreateMessageRequest import com.interlinedlist.android.feature.messages.data.remote.dto.EditMessageRequest +import com.interlinedlist.android.feature.messages.data.remote.dto.MessageDto import com.interlinedlist.android.feature.messages.data.remote.dto.PaginationDto import com.interlinedlist.android.feature.messages.data.remote.dto.ReportRequest import com.interlinedlist.android.feature.messages.data.remote.dto.UserReportRequest @@ -48,8 +52,8 @@ class DefaultMessagesRepository @Inject constructor( private val dispatchers: DispatcherProvider, ) : MessagesRepository { - override fun observeFeed(): Flow> = - messageDao.observeFeed().map { rows -> rows.map { it.toDomain() } } + override fun observeFeed(tag: String?): Flow> = + messageDao.observeFeed(feedKeyFor(tag)).map { rows -> rows.map { it.toDomain() } } override fun observeReplies(messageId: String): Flow> = messageDao.observeReplies(messageId).map { rows -> rows.map { it.toDomain() } } @@ -61,51 +65,58 @@ class DefaultMessagesRepository @Inject constructor( messageDao.observeScheduled().map { rows -> rows.map { it.toDomain() } } /** - * Loads the head of the feed (no cursor) and replaces the cached feed with it, + * Loads the head of the feed (no cursor) and replaces that feed's cached rows, * restarting keyset pagination. Returns the next page's opaque cursor. + * + * A non-null [tag] runs exactly the same request with `tag=` added and + * replaces only *that* tag feed's membership — the message rows themselves are + * shared, so the main feed keeps its own ordering and loses nothing. */ - 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) - } - is ApiResult.Failure -> result + override suspend fun refreshFeed( + preference: ViewingPreference, + tag: String?, + ): ApiResult = withContext(dispatchers.io) { + when (val result = safeCall { + api.getMessages( + limit = PaginationDto.DEFAULT_LIMIT, + onlyMine = preference.onlyMine, + // Raw: Retrofit percent-encodes the tag exactly once. + tag = tag, + ) + }) { + is ApiResult.Success -> { + val page = result.data + messageDao.clearFeed(feedKeyFor(tag)) + cacheFeedPage(page.rows, tag = tag, firstPosition = 0L) + ApiResult.Success(page.nextCursor) } + is ApiResult.Failure -> result } + } /** * Appends the page following [cursor] to the tail of the cached feed. The - * cursor is opaque: it goes back to the API exactly as it arrived. Rows are - * keyed by id, so a row the server happens to repeat updates in place rather - * than duplicating. + * cursor is opaque: it goes back to the API exactly as it arrived, alongside + * the same [tag] the chain started under. Rows are keyed by id, so a row the + * server happens to repeat updates in place rather than duplicating. */ override suspend fun loadMoreFeed( cursor: String, preference: ViewingPreference, + tag: String?, ): ApiResult = withContext(dispatchers.io) { when (val result = safeCall { api.getMessages( limit = PaginationDto.DEFAULT_LIMIT, cursor = cursor, onlyMine = preference.onlyMine, + tag = tag, ) }) { is ApiResult.Success -> { val page = result.data - val base = (messageDao.maxFeedOrder() ?: -1L) + 1L - val entities = page.rows.mapIndexed { index, dto -> - dto.toDomain(currentUserId()).toEntity(feedOrder = base + index) - } - messageDao.insertAll(entities) + val base = (messageDao.maxFeedPosition(feedKeyFor(tag)) ?: -1L) + 1L + cacheFeedPage(page.rows, tag = tag, firstPosition = base) ApiResult.Success(page.nextCursor) } is ApiResult.Failure -> result @@ -158,9 +169,20 @@ class DefaultMessagesRepository @Inject constructor( // Scheduled messages are cached in the scheduled view, not the feed. messageDao.upsert(message.toEntity(feedOrder = 0L)) } else { - // Insert at the very top of the feed. - val topOrder = (messageDao.maxFeedOrder() ?: 0L) - messageDao.upsert(message.toEntity(feedOrder = topOrder - 1L)) + // Insert at the very top of the main feed. A tag feed is a + // filtered view of the server's own answer, so a just-posted + // message only joins one once that feed is reloaded. + val head = (messageDao.minFeedPosition(MAIN_FEED_KEY) ?: 0L) - 1L + messageDao.upsert(message.toEntity(feedOrder = head)) + messageDao.upsertFeedEntries( + listOf( + FeedEntryEntity( + feedKey = MAIN_FEED_KEY, + messageId = message.id, + position = head, + ), + ), + ) } val crossPosts = result.data.crossPosts.mapNotNull { it.toDomainOrNull() } ApiResult.Success(CreatedMessage(message = message, crossPosts = crossPosts)) @@ -245,7 +267,7 @@ class DefaultMessagesRepository @Inject constructor( }) { is ApiResult.Success -> { val reply = result.data.data.toDomain(currentUserId()).copy(parentId = parentId) - val base = (messageDao.maxFeedOrder() ?: 0L) + 1L + val base = (messageDao.maxMessageOrder() ?: 0L) + 1L messageDao.upsert(reply.toEntity(feedOrder = base)) // Reflect the new reply count on the parent if it is cached. bumpReplyCount(parentId, delta = 1) @@ -458,6 +480,35 @@ class DefaultMessagesRepository @Inject constructor( private val ViewingPreference.onlyMine: Boolean? get() = true.takeIf { this == ViewingPreference.MINE } + /** + * Writes one page of a feed: the message rows (shared by every feed, so a dig + * or an edit made anywhere shows up everywhere), then this feed's membership + * starting at [firstPosition]. The two halves are what keep feeds independent + * — appending to a tag feed adds entries under its own key and never rewrites + * another feed's positions. + */ + private suspend fun cacheFeedPage( + rows: List, + tag: String?, + firstPosition: Long, + ) { + val messages = rows.map { it.toDomain(currentUserId()) } + messageDao.insertAll( + messages.mapIndexed { index, message -> + message.toEntity(feedOrder = firstPosition + index) + }, + ) + messageDao.upsertFeedEntries( + messages.mapIndexed { index, message -> + FeedEntryEntity( + feedKey = feedKeyFor(tag), + messageId = message.id, + position = firstPosition + index, + ) + }, + ) + } + private suspend fun safeCall(block: suspend () -> T): ApiResult = safeApiCall(json, block) @@ -513,7 +564,7 @@ class DefaultMessagesRepository @Inject constructor( private suspend fun currentEntity(id: String) = messageDao.observeMessage(id).first() private suspend fun existingOrderOrTop(id: String): Long = - currentEntity(id)?.feedOrder ?: ((messageDao.maxFeedOrder() ?: 0L) + 1L) + currentEntity(id)?.feedOrder ?: ((messageDao.maxMessageOrder() ?: 0L) + 1L) private suspend fun bumpReplyCount(parentId: String, delta: Int) { val parent = currentEntity(parentId) ?: return 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 43df63a..7f48c3f 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 @@ -18,8 +18,16 @@ import kotlinx.coroutines.flow.Flow */ interface MessagesRepository { - /** The cached top-level feed, newest-first, re-emitting on every change. */ - fun observeFeed(): Flow> + /** + * The cached top-level feed, newest-first, re-emitting on every change. + * + * [tag] selects *which* feed: null is the account's main feed, a non-null tag + * is the feed filtered to that tag (`GET /api/messages?tag=`). Both are the + * same feed in every other respect — same cursor paging, same view preference, + * same cached message rows — they differ only in which rows belong to them, so + * loading a tag feed never disturbs the main one. + */ + fun observeFeed(tag: String? = null): Flow> /** Cached replies to [messageId], re-emitting on every change. */ fun observeReplies(messageId: String): Flow> @@ -40,21 +48,28 @@ interface MessagesRepository { * 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. + * + * [tag] scopes the request (and the cache it replaces) to one tag's feed; null + * refreshes the main feed. Refreshing a tag feed leaves the main feed's cached + * rows exactly where they were. */ suspend fun refreshFeed( preference: ViewingPreference = ViewingPreference.DEFAULT, + tag: String? = null, ): 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. - * [preference] must match the one the page chain started under. Returns the - * cursor for the page after this one, or null at the end. + * [preference] and [tag] must match the ones the page chain started under — + * the cursor is only meaningful within the query that produced it. + * Returns the cursor for the page after this one, or null at the end. */ suspend fun loadMoreFeed( cursor: String, preference: ViewingPreference = ViewingPreference.DEFAULT, + tag: String? = null, ): ApiResult /** diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/FeedEntryEntity.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/FeedEntryEntity.kt new file mode 100644 index 0000000..e995109 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/FeedEntryEntity.kt @@ -0,0 +1,53 @@ +package com.interlinedlist.android.feature.messages.data.local + +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +/** + * Membership of one cached message in one feed, with its position in that feed. + * + * Feeds are *lists over* the shared [MessageEntity] rows, not copies of them: the + * main feed and a tag-filtered feed routinely contain the same message, and both + * must see the same dig count, edits and deletions. Keeping membership in its own + * table is what lets a tag feed be cached at all without evicting the main feed — + * a message row can only hold one position, so storing the position on the message + * would mean the second feed to load silently stole rows from the first. + * + * [feedKey] identifies the feed: [MAIN_FEED_KEY] (the empty string) for the main + * feed, and the tag itself for a tag feed. Tags are never blank, so the two spaces + * cannot collide. + * + * The foreign key cascades, so deleting a message (own-message delete, block, mute) + * drops it from every feed it appeared in without extra bookkeeping. That cascade + * is also why message rows are written with an **upsert** rather than + * `@Insert(REPLACE)` — see [MessageDao.insertAll]. + */ +@Entity( + tableName = "feed_entry", + primaryKeys = ["feedKey", "messageId"], + foreignKeys = [ + ForeignKey( + entity = MessageEntity::class, + parentColumns = ["id"], + childColumns = ["messageId"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [Index("messageId")], +) +data class FeedEntryEntity( + val feedKey: String, + val messageId: String, + /** Server-relative position within this feed, captured at fetch time. */ + val position: Long, +) + +/** Feed key of the account's main (unfiltered) feed. */ +const val MAIN_FEED_KEY: String = "" + +/** + * The [FeedEntryEntity.feedKey] a feed scoped to [tag] stores its rows under; a + * null or blank tag is the main feed. + */ +fun feedKeyFor(tag: String?): String = tag?.takeIf { it.isNotBlank() } ?: MAIN_FEED_KEY diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt index a19a63a..b8e2282 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageDao.kt @@ -1,8 +1,6 @@ package com.interlinedlist.android.feature.messages.data.local import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy import androidx.room.Query import androidx.room.Upsert import kotlinx.coroutines.flow.Flow @@ -11,12 +9,22 @@ import kotlinx.coroutines.flow.Flow interface MessageDao { /** - * Top-level feed messages in server order; re-emits on every change. - * Scheduled (not-yet-published) messages are excluded — they live in their - * own view, not the public feed. + * Top-level messages of the feed [feedKey] identifies, in server order; + * re-emits on every change. Scheduled (not-yet-published) messages are + * excluded — they live in their own view, not a feed. + * + * Membership comes from `feed_entry`, so the main feed and a tag feed share + * the underlying message rows without either owning them. */ - @Query("SELECT * FROM message WHERE parentId IS NULL AND scheduledAt IS NULL ORDER BY feedOrder ASC") - fun observeFeed(): Flow> + @Query( + """ + SELECT m.* FROM message AS m + INNER JOIN feed_entry AS f ON f.messageId = m.id + WHERE f.feedKey = :feedKey AND m.parentId IS NULL AND m.scheduledAt IS NULL + ORDER BY f.position ASC + """, + ) + fun observeFeed(feedKey: String): Flow> /** Direct replies to a message in server order. */ @Query("SELECT * FROM message WHERE parentId = :parentId ORDER BY feedOrder ASC") @@ -30,31 +38,61 @@ interface MessageDao { @Query("SELECT * FROM message WHERE scheduledAt IS NOT NULL ORDER BY scheduledAt ASC") fun observeScheduled(): Flow> - @Insert(onConflict = OnConflictStrategy.REPLACE) + /** + * Writes message rows, updating the ones already cached. + * + * Deliberately an upsert and **not** `@Insert(REPLACE)`: REPLACE deletes the + * conflicting row before re-inserting it, which would fire `feed_entry`'s + * ON DELETE CASCADE and silently drop that message out of every feed it was + * already in — so re-fetching a message in a tag feed would evict it from the + * main feed. An upsert updates in place and leaves memberships alone. + */ + @Upsert suspend fun insertAll(messages: List) @Upsert suspend fun upsert(message: MessageEntity) + /** Places (or moves) messages within a feed. Message rows must exist first. */ + @Upsert + suspend fun upsertFeedEntries(entries: List) + @Query("DELETE FROM message WHERE id = :id") suspend fun deleteById(id: String) /** * Removes every cached message authored by [username] (used to hide a blocked - * or muted author's messages from the local feed/replies immediately). + * or muted author's messages from the local feed/replies immediately). Feed + * memberships cascade away with the rows. */ @Query("DELETE FROM message WHERE authorUsername = :username") suspend fun deleteByAuthorUsername(username: String) - /** Clears the top-level feed (used before writing a fresh refresh page). */ - @Query("DELETE FROM message WHERE parentId IS NULL AND scheduledAt IS NULL") - suspend fun clearFeed() + /** + * Empties the feed [feedKey] identifies, before writing a fresh refresh page. + * Only that feed's membership is dropped: the message rows stay, so another + * feed holding the same messages is untouched. + */ + @Query("DELETE FROM feed_entry WHERE feedKey = :feedKey") + suspend fun clearFeed(feedKey: String) /** Clears the cached scheduled messages (used before a fresh refresh). */ @Query("DELETE FROM message WHERE scheduledAt IS NOT NULL") suspend fun clearScheduled() - /** Largest feed-order position currently stored (for append/load-more). */ - @Query("SELECT MAX(feedOrder) FROM message WHERE parentId IS NULL AND scheduledAt IS NULL") - suspend fun maxFeedOrder(): Long? + /** Last position currently stored in a feed (for append/load-more). */ + @Query("SELECT MAX(position) FROM feed_entry WHERE feedKey = :feedKey") + suspend fun maxFeedPosition(feedKey: String): Long? + + /** First position currently stored in a feed (for inserting at the head). */ + @Query("SELECT MIN(position) FROM feed_entry WHERE feedKey = :feedKey") + suspend fun minFeedPosition(feedKey: String): Long? + + /** + * Largest sort position stored on any cached message row. Used to park a + * message the app fetched outside a feed (a reply, a detail-screen fetch) + * after everything already cached, rather than in front of it. + */ + @Query("SELECT MAX(feedOrder) FROM message") + suspend fun maxMessageOrder(): Long? } diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt index b720b77..d1845e8 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessageEntity.kt @@ -25,7 +25,12 @@ data class MessageEntity( val dugByMe: Boolean, val parentId: String?, val mine: Boolean, - /** Server-relative ordering position captured at fetch time (feed order). */ + /** + * Server-relative ordering position captured at fetch time, used to order a + * message's **replies**. A message's place in a *feed* is not stored here — + * the same message can sit in the main feed and in any number of tag feeds at + * once — but in [FeedEntryEntity]. + */ val feedOrder: Long, /** Attached image URLs, stored via [MessageConverters]. */ val imageUrls: List = emptyList(), diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt index edc3eef..7854884 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/data/local/MessagesDatabase.kt @@ -9,8 +9,8 @@ import androidx.room.TypeConverters * `InterlinedListDatabase`. A disposable cache during early development. */ @Database( - entities = [MessageEntity::class], - version = 5, + entities = [MessageEntity::class, FeedEntryEntity::class], + version = 6, exportSchema = false, ) @TypeConverters(MessageConverters::class) 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 37c4c8b..827ff16 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 @@ -43,12 +43,19 @@ interface MessagesApi { * `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. + * + * [tag] filters the feed to messages carrying that tag. Tags are free-form and + * routinely contain spaces and punctuation (`life is short, o brave girl` is a + * real one), so the value is passed **raw** and Retrofit percent-encodes it + * exactly once — pre-encoding here would double-encode it and match nothing. + * Null omits the parameter, which is the unfiltered feed. */ @GET("api/messages") suspend fun getMessages( @Query("limit") limit: Int, @Query("cursor") cursor: String? = null, @Query("onlyMine") onlyMine: Boolean? = null, + @Query("tag") tag: String? = 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/navigation/MessagesDestinations.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/navigation/MessagesDestinations.kt new file mode 100644 index 0000000..3d4fd95 --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/navigation/MessagesDestinations.kt @@ -0,0 +1,40 @@ +package com.interlinedlist.android.feature.messages.navigation + +import java.net.URLEncoder + +/** + * Route keys this module owns, kept next to the screens that consume them so the + * app can host them without hand-building route strings. + */ +object MessagesDestinations { + + /** Nav argument carrying the tag a feed is filtered to. */ + const val ARG_TAG = "tag" + + /** Feed filtered to one tag; build a concrete route with [tagFeedRoute]. */ + const val TAG_FEED = "messages/tag/{$ARG_TAG}" + + /** + * Route for the feed of [tag] — the single entry point for opening a tag feed, + * whether from a tag on a message card, a trending list, or a deep link. + * + * The tag is percent-encoded into the path segment (with `%20` rather than + * `+`, which Navigation would hand back literally), because a tag is free-form + * and may contain spaces, commas and even slashes. + */ + fun tagFeedRoute(tag: String): String = "messages/tag/${encode(tag)}" + + /** + * Maps a tapped tag URL onto an in-app route, or null when the URI is not one. + * + * The app resolves the launch intent through here — the same way a tapped + * notification goes through `NotificationLaunch` and an email-change link goes + * through `AuthRoutes.routeForEmailChangeLink` — rather than relying on + * implicit `navDeepLink` matching, so the behaviour is covered by plain unit + * tests instead of only firing on a real device. + */ + fun routeForTagLink(uri: String?): String? = TagFeedLink.parse(uri)?.let(::tagFeedRoute) + + private fun encode(value: String): String = + URLEncoder.encode(value, Charsets.UTF_8.name()).replace("+", "%20") +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/navigation/TagFeedLink.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/navigation/TagFeedLink.kt new file mode 100644 index 0000000..3a8c10b --- /dev/null +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/navigation/TagFeedLink.kt @@ -0,0 +1,90 @@ +package com.interlinedlist.android.feature.messages.navigation + +import java.net.URI +import java.net.URLDecoder + +/** + * A tapped "show me this tag" link. + * + * The web renders every tag on a message card as a link to the home feed with a + * `tag` query parameter — `` — so the + * canonical tag URL is `https://interlinedlist.com/?tag=` and nothing else: + * `/tag/` and `/tags/` both 404 on the live site. + * + * Kept as plain JVM code (no `android.net.Uri`) so the parsing rules are covered by + * fast unit tests, exactly like the auth module's `EmailChangeLink`. + */ +object TagFeedLink { + + /** Query parameter carrying the tag, on both the web and app-scheme links. */ + const val TAG_PARAM = "tag" + + /** Host of the web links; also matched with a `www.` prefix. */ + const val WEB_HOST = "interlinedlist.com" + + /** Custom scheme the app registers for the same link. */ + const val APP_SCHEME = "interlinedlist" + + /** Authority of the custom-scheme form, `interlinedlist://tag?tag=…`. */ + const val APP_AUTHORITY = "tag" + + private val WEB_SCHEMES = setOf("https", "http") + + /** + * Parses [uri] into the tag it selects, or returns null when it is not a tag + * link. + * + * Recognised shapes (scheme/host case-insensitive, extra query parameters + * tolerated): + * - `https://interlinedlist.com/?tag=…` (and without the trailing slash) + * - `interlinedlist://tag?tag=…` + * + * The tag is returned **percent-decoded and verbatim otherwise**: tags are + * free-form and legitimately contain spaces, commas and case + * ("life is short, o brave girl" is a real one), so nothing here trims, + * lowercases or splits them. + * + * A link to any other path, a foreign host, a missing or blank tag, or a + * string that is not a URI at all yields null rather than an exception — a + * deep link must never crash the launch. + */ + fun parse(uri: String?): String? { + val trimmed = uri?.trim().orEmpty() + if (trimmed.isEmpty()) return null + val parsed = runCatching { URI(trimmed) }.getOrNull() ?: return null + if (!parsed.isTagTarget()) return null + return parsed.rawQuery.queryParam(TAG_PARAM)?.takeIf { it.isNotBlank() } + } + + /** True when this URI addresses the tag surface (and not some other page). */ + private fun URI.isTagTarget(): Boolean { + val scheme = scheme?.lowercase() ?: return false + return when { + scheme in WEB_SCHEMES -> { + val host = host?.lowercase()?.removePrefix("www.") ?: return false + // The tag feed is the site *root* with a `tag` query, so any other + // path (a shared list, a profile) is deliberately not a tag link. + host == WEB_HOST && path.orEmpty().trim('/').isEmpty() + } + scheme == APP_SCHEME -> + (host ?: authority).orEmpty().lowercase() == APP_AUTHORITY && + path.orEmpty().trim('/').isEmpty() + else -> false + } + } + + /** Reads a single percent-decoded query parameter out of a raw query string. */ + private fun String?.queryParam(name: String): String? = this + ?.split('&') + ?.firstNotNullOfOrNull { pair -> + val separator = pair.indexOf('=') + if (separator <= 0) return@firstNotNullOfOrNull null + val key = pair.substring(0, separator).decodeOrNull() + if (!key.equals(name, ignoreCase = true)) return@firstNotNullOfOrNull null + pair.substring(separator + 1).decodeOrNull() + } + + /** Percent-decodes a query component, falling back to the raw text. */ + private fun String.decodeOrNull(): String? = + runCatching { URLDecoder.decode(this, Charsets.UTF_8.name()) }.getOrDefault(this) +} diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt index 9ef7ab6..df63f65 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/components/MessageCard.kt @@ -92,6 +92,9 @@ object MessageCardTags { * * [onPush] / [onQuote] are null where the host screen does not wire the actions; * they are also withheld for a message [Message.canBePushed] rules out. + * + * [onTagClick] opens the tag-filtered feed for a tapped tag; it is null on screens + * that have nowhere to send the user, and the tags then render as plain labels. */ @Composable fun MessageCard( @@ -109,6 +112,7 @@ fun MessageCard( onPush: (() -> Unit)? = null, onQuote: (() -> Unit)? = null, onOpenPushedMessage: (String) -> Unit = {}, + onTagClick: ((String) -> Unit)? = null, ) { Column( modifier = modifier @@ -184,7 +188,7 @@ fun MessageCard( } if (message.hasTags) { Spacer(Modifier.size(8.dp)) - TagRow(tags = message.tags) + TagRow(tags = message.tags, onTagClick = onTagClick) } Spacer(Modifier.size(8.dp)) EngagementRow( @@ -204,10 +208,14 @@ fun MessageCard( * extra fetch. Rendered as plain labels: a tag is a free-form string that may * contain spaces and punctuation, so it is shown exactly as stored rather than * being prettified into a hashtag. + * + * Each label opens that tag's feed when [onTagClick] is wired. The click sits + * outside the chip's padding so the whole chip is the target, and adds no layout + * of its own. */ @OptIn(ExperimentalLayoutApi::class) @Composable -private fun TagRow(tags: List) { +private fun TagRow(tags: List, onTagClick: ((String) -> Unit)? = null) { FlowRow( horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp), @@ -223,6 +231,13 @@ private fun TagRow(tags: List) { modifier = Modifier .clip(MaterialTheme.shapes.small) .background(MaterialTheme.colorScheme.surfaceVariant) + .then( + if (onTagClick != null) { + Modifier.clickable { onTagClick(tag) } + } else { + Modifier + }, + ) .padding(horizontal = 8.dp, vertical = 2.dp) .testTag(MessageCardTags.tagTag(tag)), ) diff --git a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt index 9ad4177..681f05c 100644 --- a/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt +++ b/feature/messages/src/main/kotlin/com/interlinedlist/android/feature/messages/ui/detail/MessageDetailScreen.kt @@ -61,12 +61,14 @@ object MessageDetailTags { * * @param onBack pops the detail screen off the back stack. * @param onOpenMessage navigates into a reply (which is itself a message). + * @param onOpenTag opens the feed filtered to a tapped tag; null leaves tags inert. */ @Composable fun MessageDetailRoute( onBack: () -> Unit, onOpenMessage: (String) -> Unit, modifier: Modifier = Modifier, + onOpenTag: ((String) -> Unit)? = null, viewModel: MessageDetailViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -74,6 +76,7 @@ fun MessageDetailRoute( state = state, onBack = onBack, onOpenMessage = onOpenMessage, + onOpenTag = onOpenTag, onDig = viewModel::onDig, onReplyTextChange = viewModel::onReplyTextChange, onPostReply = viewModel::postReply, @@ -113,6 +116,7 @@ fun MessageDetailScreen( onMuteUser: (Message) -> Unit = {}, onReportUser: (Message) -> Unit = {}, onFetchMetadata: (Message) -> Unit = {}, + onOpenTag: ((String) -> Unit)? = null, onDismissReport: () -> Unit = {}, onSubmitReport: (ReportReason, String) -> Unit = { _, _ -> }, onEditTextChange: (String) -> Unit = {}, @@ -154,6 +158,7 @@ fun MessageDetailScreen( onMuteUser = onMuteUser, onReportUser = onReportUser, onFetchMetadata = onFetchMetadata, + onOpenTag = onOpenTag, ) } } @@ -201,6 +206,7 @@ private fun Content( onMuteUser: (Message) -> Unit, onReportUser: (Message) -> Unit, onFetchMetadata: (Message) -> Unit, + onOpenTag: ((String) -> Unit)?, ) { val message = state.message Column( @@ -228,6 +234,7 @@ private fun Content( onOpenLink = { onFetchMetadata(message) }, // A push/quote here still opens the original it re-shares. onOpenPushedMessage = onOpenMessage, + onTagClick = onOpenTag, ) HorizontalDivider(thickness = 2.dp, color = MaterialTheme.colorScheme.outlineVariant) Text( @@ -250,6 +257,7 @@ private fun Content( onReportUser = { onReportUser(reply) }, onOpenLink = { onFetchMetadata(reply) }, onOpenPushedMessage = onOpenMessage, + onTagClick = onOpenTag, ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } 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 86514ef..806d73f 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 @@ -27,6 +27,7 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Image @@ -131,6 +132,11 @@ object MessagesFeedTags { const val TAG_SUGGESTIONS = "messagesComposeTagSuggestions" const val TAG_SUGGESTION_PREFIX = "messagesComposeTagSuggestion_" + /** Back arrow shown instead of the tab bar when the feed is filtered to a tag. */ + const val BACK = "messagesFeedBack" + /** Title shown while the feed is filtered to a tag. */ + const val TAG_TITLE = "messagesFeedTagTitle" + /** The quoted message attached to the composer, and its always-public banner. */ const val QUOTE_ATTACHED = "messagesComposeQuoteAttached" const val QUOTE_PUBLIC_BANNER = "messagesComposeQuoteBanner" @@ -146,16 +152,23 @@ object MessagesFeedTags { } /** - * Hilt-wired feed entry point. The app's NavHost hosts this as the Messages tab. + * Hilt-wired feed entry point, hosted twice: as the Messages tab, and as the + * tag-filtered feed (`MessagesDestinations.TAG_FEED`). Which one it is comes from + * the ViewModel's nav arguments, so both get identical paging, view-preference and + * moderation behaviour from the same code. * * @param onOpenMessage navigates to the detail screen for the given message id. * @param onOpenScheduled navigates to the Scheduled messages screen. + * @param onOpenTag opens the feed filtered to a tapped tag. + * @param onBack pops the tag feed; ignored on the tab root, which has no back arrow. */ @Composable fun MessagesRoute( onOpenMessage: (String) -> Unit, onOpenScheduled: () -> Unit, modifier: Modifier = Modifier, + onOpenTag: ((String) -> Unit)? = null, + onBack: () -> Unit = {}, viewModel: MessagesFeedViewModel = hiltViewModel(), ) { val state by viewModel.uiState.collectAsStateWithLifecycle() @@ -167,6 +180,8 @@ fun MessagesRoute( onViewingPreferenceChange = viewModel::onViewingPreferenceChange, onOpenMessage = onOpenMessage, onOpenScheduled = onOpenScheduled, + onOpenTag = onOpenTag, + onBack = onBack, onDig = viewModel::onDig, onDelete = viewModel::onDelete, onPush = viewModel::onPush, @@ -225,6 +240,8 @@ fun MessagesFeedScreen( modifier: Modifier = Modifier, onViewingPreferenceChange: (ViewingPreference) -> Unit = {}, onOpenScheduled: () -> Unit = {}, + onOpenTag: ((String) -> Unit)? = null, + onBack: () -> Unit = {}, onPush: (Message) -> Unit = {}, onQuote: (Message) -> Unit = {}, onReport: (Message) -> Unit = {}, @@ -255,19 +272,49 @@ fun MessagesFeedScreen( modifier = modifier.fillMaxSize(), topBar = { TopAppBar( - title = { Text("Messages") }, + title = { + if (state.tag != null) { + Text( + text = state.tag, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag(MessagesFeedTags.TAG_TITLE), + ) + } else { + Text("Messages") + } + }, + navigationIcon = { + // The tag feed is pushed on top of a tab, so it carries its own + // back affordance; the tab root does not. + if (state.isTagFeed) { + IconButton( + onClick = onBack, + modifier = Modifier.testTag(MessagesFeedTags.BACK), + ) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + } + }, actions = { - IconButton( - onClick = onOpenScheduled, - modifier = Modifier.testTag(MessagesFeedTags.SCHEDULED_ACTION), - ) { - Icon(Icons.Filled.Schedule, contentDescription = "Scheduled messages") + if (!state.isTagFeed) { + IconButton( + onClick = onOpenScheduled, + modifier = Modifier.testTag(MessagesFeedTags.SCHEDULED_ACTION), + ) { + Icon(Icons.Filled.Schedule, contentDescription = "Scheduled messages") + } } }, ) }, floatingActionButton = { - if (!state.subscriptionRequired) { + // Composing from a tag feed would post an untagged message into a feed + // it cannot appear in, so the composer stays on the main feed. + if (!state.subscriptionRequired && !state.isTagFeed) { FloatingActionButton( onClick = onOpenCompose, modifier = Modifier.testTag(MessagesFeedTags.FAB), @@ -290,6 +337,7 @@ fun MessagesFeedScreen( onRefresh = onRefresh, onLoadMore = onLoadMore, onOpenMessage = onOpenMessage, + onOpenTag = onOpenTag, onDig = onDig, onDelete = onDelete, onPush = onPush, @@ -407,6 +455,7 @@ private fun FeedContent( onRefresh: () -> Unit, onLoadMore: () -> Unit, onOpenMessage: (String) -> Unit, + onOpenTag: ((String) -> Unit)?, onDig: (Message) -> Unit, onDelete: (Message) -> Unit, onPush: (Message) -> Unit, @@ -426,11 +475,12 @@ private fun FeedContent( when { state.isEmpty && state.isRefreshing -> LoadingState() state.isEmpty && state.errorMessage != null -> ErrorState(state.errorMessage, onRefresh) - state.isEmpty -> EmptyState() + state.isEmpty -> EmptyState(tag = state.tag) else -> FeedList( state = state, onLoadMore = onLoadMore, onOpenMessage = onOpenMessage, + onOpenTag = onOpenTag, onDig = onDig, onDelete = onDelete, onPush = onPush, @@ -451,6 +501,7 @@ private fun FeedList( state: MessagesFeedUiState, onLoadMore: () -> Unit, onOpenMessage: (String) -> Unit, + onOpenTag: ((String) -> Unit)?, onDig: (Message) -> Unit, onDelete: (Message) -> Unit, onPush: (Message) -> Unit, @@ -494,6 +545,7 @@ private fun FeedList( onQuote = { onQuote(message) }, // The embedded original opens on its own page. onOpenPushedMessage = onOpenMessage, + onTagClick = onOpenTag, ) HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) } @@ -515,10 +567,16 @@ private fun LoadingState() { } @Composable -private fun EmptyState() { +private fun EmptyState(tag: String? = null) { Box(Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { Text( - text = "No messages yet. Be the first to post.", + // The tag feed hides the composer, so "be the first to post" would be + // an invitation the screen cannot honour. + text = if (tag != null) { + "Nothing tagged \u201C$tag\u201D yet." + } else { + "No messages yet. Be the first to post." + }, style = MaterialTheme.typography.bodyLarge, textAlign = TextAlign.Center, color = MaterialTheme.colorScheme.onSurfaceVariant, 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 305cffe..d02bc9f 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 @@ -1,5 +1,6 @@ package com.interlinedlist.android.feature.messages.ui.feed +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.interlinedlist.android.core.common.result.ApiResult @@ -13,6 +14,7 @@ import com.interlinedlist.android.feature.messages.domain.Message import com.interlinedlist.android.feature.messages.domain.MessageVisibility import com.interlinedlist.android.feature.messages.domain.ReportReason import com.interlinedlist.android.feature.messages.domain.TagSuggestion +import com.interlinedlist.android.feature.messages.navigation.MessagesDestinations import com.interlinedlist.android.feature.messages.ui.isSubscriptionGate import com.interlinedlist.android.feature.messages.ui.toUserMessage import dagger.hilt.android.lifecycle.HiltViewModel @@ -39,6 +41,11 @@ data class PendingAttachment( /** Feed screen state: the cached messages plus transient network/compose flags. */ data class MessagesFeedUiState( val messages: List = emptyList(), + /** + * The tag this feed is filtered to (`GET /api/messages?tag=`), or null for the + * account's main feed. Everything else on this screen behaves identically. + */ + val tag: String? = null, val isRefreshing: Boolean = false, val isLoadingMore: Boolean = false, val canLoadMore: Boolean = false, @@ -95,6 +102,9 @@ data class MessagesFeedUiState( ) { val isEmpty: Boolean get() = messages.isEmpty() + /** True when the feed is filtered to a single tag. */ + val isTagFeed: Boolean get() = tag != null + /** True while the composer is writing a quote of [quoteTarget]. */ val isQuoting: Boolean get() = quoteTarget != null @@ -212,11 +222,25 @@ private data class FeedTransientState( } } +/** + * The one feed ViewModel, used for both the account's main feed and a tag-filtered + * feed. The tag arrives as the [MessagesDestinations.ARG_TAG] nav argument and is + * the *only* difference between the two: the opaque cursor paging, the + * view-preference switcher, digs, edits and moderation are all the same code + * running under a different query. Forking this class for tags would have to + * re-implement every one of them, and would drift. The composer is the one part a + * tag feed leaves out, since a new post cannot be filed into someone else's tag. + */ @HiltViewModel class MessagesFeedViewModel @Inject constructor( private val repository: MessagesRepository, + savedStateHandle: SavedStateHandle = SavedStateHandle(), ) : ViewModel() { + /** Tag this feed is filtered to, or null when it is the main feed. */ + private val tag: String? = + savedStateHandle.get(MessagesDestinations.ARG_TAG)?.takeIf { it.isNotBlank() } + private val transient = MutableStateFlow(FeedTransientState()) /** @@ -231,9 +255,10 @@ class MessagesFeedViewModel @Inject constructor( * combined with transient flags into a single [MessagesFeedUiState]. */ val uiState: StateFlow = - combine(repository.observeFeed(), transient) { messages, t -> + combine(repository.observeFeed(tag), transient) { messages, t -> MessagesFeedUiState( messages = messages, + tag = tag, isRefreshing = t.isRefreshing, isLoadingMore = t.isLoadingMore, canLoadMore = t.canLoadMore, @@ -271,8 +296,11 @@ class MessagesFeedViewModel @Inject constructor( init { loadViewingPreferenceThenRefresh() - loadLinkedNetworks() - loadDefaultVisibility() + // A tag feed has no composer, so it does not pay for the composer's setup. + if (tag == null) { + loadLinkedNetworks() + loadDefaultVisibility() + } } /** @@ -381,7 +409,7 @@ class MessagesFeedViewModel @Inject constructor( ) } viewModelScope.launch { - when (val result = repository.refreshFeed(transient.value.viewingPreference)) { + when (val result = repository.refreshFeed(transient.value.viewingPreference, tag)) { is ApiResult.Success -> transient.update { it.copy(isRefreshing = false, nextCursor = result.data) } @@ -399,7 +427,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, current.viewingPreference)) { + when (val result = repository.loadMoreFeed(cursor, current.viewingPreference, tag)) { 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/FakeMessageDao.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt index d395836..1b67ebb 100644 --- a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/FakeMessageDao.kt @@ -1,26 +1,34 @@ package com.interlinedlist.android.feature.messages.data +import com.interlinedlist.android.feature.messages.data.local.FeedEntryEntity +import com.interlinedlist.android.feature.messages.data.local.MAIN_FEED_KEY import com.interlinedlist.android.feature.messages.data.local.MessageDao import com.interlinedlist.android.feature.messages.data.local.MessageEntity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map /** * In-memory stand-in for the Room [MessageDao] so repository/ViewModel logic can * be unit-tested on the JVM without an Android runtime. Mirrors the query - * semantics of the real DAO (feed = parentId null, ordered by feedOrder). + * semantics of the real DAO: message rows are shared, and a feed is the ordered + * `feed_entry` membership over them (with the foreign key's cascade on delete). */ class FakeMessageDao : MessageDao { private val rows = MutableStateFlow>(emptyMap()) - private fun sorted(predicate: (MessageEntity) -> Boolean): List = - rows.value.values.filter(predicate).sortedBy { it.feedOrder } + /** Feed membership, keyed by `feedKey to messageId` like the real primary key. */ + private val entries = MutableStateFlow, FeedEntryEntity>>(emptyMap()) - override fun observeFeed(): Flow> = - rows.map { map -> - map.values.filter { it.parentId == null && it.scheduledAt == null }.sortedBy { it.feedOrder } + override fun observeFeed(feedKey: String): Flow> = + combine(rows, entries) { messages, membership -> + membership.values + .filter { it.feedKey == feedKey } + .sortedBy { it.position } + .mapNotNull { messages[it.messageId] } + .filter { it.parentId == null && it.scheduledAt == null } } override fun observeReplies(parentId: String): Flow> = @@ -42,25 +50,49 @@ class FakeMessageDao : MessageDao { rows.value = rows.value.toMutableMap().apply { put(message.id, message) } } + override suspend fun upsertFeedEntries(entries: List) { + this.entries.value = this.entries.value.toMutableMap().apply { + entries.forEach { put(it.feedKey to it.messageId, it) } + } + } + override suspend fun deleteById(id: String) { rows.value = rows.value.toMutableMap().apply { remove(id) } + cascade() } override suspend fun deleteByAuthorUsername(username: String) { rows.value = rows.value.filterValues { it.authorUsername != username } + cascade() } - override suspend fun clearFeed() { - rows.value = rows.value.filterValues { it.parentId != null || it.scheduledAt != null } + override suspend fun clearFeed(feedKey: String) { + entries.value = entries.value.filterValues { it.feedKey != feedKey } } override suspend fun clearScheduled() { rows.value = rows.value.filterValues { it.scheduledAt == null } + cascade() } - override suspend fun maxFeedOrder(): Long? = - sorted { it.parentId == null && it.scheduledAt == null }.maxOfOrNull { it.feedOrder } + override suspend fun maxFeedPosition(feedKey: String): Long? = + entries.value.values.filter { it.feedKey == feedKey }.maxOfOrNull { it.position } + + override suspend fun minFeedPosition(feedKey: String): Long? = + entries.value.values.filter { it.feedKey == feedKey }.minOfOrNull { it.position } + + override suspend fun maxMessageOrder(): Long? = rows.value.values.maxOfOrNull { it.feedOrder } + + /** Mirrors the `feed_entry -> message` foreign key's ON DELETE CASCADE. */ + private fun cascade() { + val live = rows.value.keys + entries.value = entries.value.filterValues { it.messageId in live } + } - /** Test helper: current feed snapshot. */ - fun feedSnapshot(): List = sorted { it.parentId == null && it.scheduledAt == null } + /** Test helper: current snapshot of a feed, main feed by default. */ + fun feedSnapshot(feedKey: String = MAIN_FEED_KEY): List = + entries.value.values + .filter { it.feedKey == feedKey } + .sortedBy { it.position } + .mapNotNull { rows.value[it.messageId] } } diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TagFeedTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TagFeedTest.kt new file mode 100644 index 0000000..b2bd96b --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/data/TagFeedTest.kt @@ -0,0 +1,246 @@ +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.flow.first +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 + +/** + * The tag-filtered feed (`GET /api/messages?tag=`). + * + * It is the *same* feed: same endpoint, same opaque keyset cursor, same + * `onlyMine` view-preference mechanism, with one extra query parameter. These + * tests pin the three things that could quietly go wrong when a second feed + * shares one implementation: the tag reaching the wire intact (encoded exactly + * once), paging still being cursor-based on the filtered feed, and the tag feed + * leaving the main feed's cache alone. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TagFeedTest { + + 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 + + /** A real tag from the live site: spaces *and* a comma, in one label. */ + private val awkwardTag = "life is short, o brave girl" + + /** An opaque, base64-padded token: nothing in the app may interpret it. */ + private val cursorAfterT2 = "MjAyNi0wOS0xM1QxMjowMDowMC4wMDBafHQy=" + + @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 page(ids: List, nextCursor: String? = null): String { + val rows = ids.joinToString(",") { """{ "id": "$it", "content": "$it" }""" } + val cursor = nextCursor?.let { "\"$it\"" } ?: "null" + return """ + { "messages": [$rows], + "pagination": { "limit": 20, "hasMore": ${nextCursor != null}, "nextCursor": $cursor } } + """.trimIndent() + } + + private fun enqueue(body: String) { + server.enqueue(MockResponse().setResponseCode(200).setBody(body)) + } + + // ---- the tag on the wire ---------------------------------------------- + + @Test + fun `a tag feed sends the tag it was asked for, and the main feed sends none`() = + runTest(dispatcher) { + enqueue(page(listOf("t1"))) + enqueue(page(listOf("m1"))) + val repo = repository() + + repo.refreshFeed(tag = "lists") + repo.refreshFeed() + + assertThat(server.takeRequest().requestUrl!!.queryParameter("tag")).isEqualTo("lists") + assertThat(server.takeRequest().requestUrl!!.queryParameter("tag")).isNull() + } + + @Test + fun `a tag with a space and a comma is encoded exactly once`() = runTest(dispatcher) { + enqueue(page(listOf("t1"))) + + repository().refreshFeed(tag = awkwardTag) + + val url = server.takeRequest().requestUrl!! + // Decoded, the server sees the tag whole. + assertThat(url.queryParameter("tag")).isEqualTo(awkwardTag) + // And on the wire it is percent-encoded once: a second pass would have + // turned "%20" into "%2520" and matched nothing. + val encoded = url.encodedQuery!! + assertThat(encoded).doesNotContain("%25") + assertThat(encoded).contains("%2C") + } + + @Test + fun `the tag rides alongside the view preference, not instead of it`() = runTest(dispatcher) { + enqueue(page(listOf("t1"))) + + repository().refreshFeed(preference = ViewingPreference.MINE, tag = "lists") + + val url = server.takeRequest().requestUrl!! + assertThat(url.queryParameter("tag")).isEqualTo("lists") + assertThat(url.queryParameter("onlyMine")).isEqualTo("true") + } + + // ---- paging on the filtered feed -------------------------------------- + + @Test + fun `the filtered feed pages by cursor, handed back verbatim and with no offset`() = + runTest(dispatcher) { + enqueue(page(listOf("t3", "t2"), nextCursor = cursorAfterT2)) + enqueue(page(listOf("t1"))) + val repo = repository() + + val cursor = (repo.refreshFeed(tag = awkwardTag) as ApiResult.Success).data!! + val next = repo.loadMoreFeed(cursor, tag = awkwardTag) + + assertThat(cursor).isEqualTo(cursorAfterT2) + assertThat((next as ApiResult.Success).data).isNull() // end of the feed + + val first = server.takeRequest().requestUrl!! + assertThat(first.queryParameter("cursor")).isNull() + assertThat(first.queryParameter("offset")).isNull() + + val second = server.takeRequest().requestUrl!! + // Verbatim: the padded token survives the round trip untouched... + assertThat(second.queryParameter("cursor")).isEqualTo(cursorAfterT2) + // ...still carrying the same tag, and still with no offset. + assertThat(second.queryParameter("tag")).isEqualTo(awkwardTag) + assertThat(second.queryParameter("offset")).isNull() + } + + @Test + fun `paging the filtered feed appends without duplicating or skipping rows`() = + runTest(dispatcher) { + enqueue(page(listOf("t3", "t2"), nextCursor = cursorAfterT2)) + enqueue(page(listOf("t1"))) + val repo = repository() + + val cursor = (repo.refreshFeed(tag = "lists") as ApiResult.Success).data!! + repo.loadMoreFeed(cursor, tag = "lists") + + val ids = repo.observeFeed(tag = "lists").first().map { it.id } + assertThat(ids).containsExactly("t3", "t2", "t1").inOrder() + assertThat(ids).containsNoDuplicates() + } + + // ---- the main feed's cache -------------------------------------------- + + @Test + fun `loading a tag feed leaves the main feed's cached rows untouched`() = runTest(dispatcher) { + enqueue(page(listOf("m3", "m2", "m1"))) + // The tag feed overlaps the main feed (m2) and brings rows of its own. + enqueue(page(listOf("m2", "t9"))) + val repo = repository() + + repo.refreshFeed() + repo.refreshFeed(tag = "lists") + + // The shared row belongs to both feeds at once, in each feed's own order. + assertThat(repo.observeFeed().first().map { it.id }) + .containsExactly("m3", "m2", "m1").inOrder() + assertThat(repo.observeFeed(tag = "lists").first().map { it.id }) + .containsExactly("m2", "t9").inOrder() + } + + @Test + fun `refreshing a tag feed evicts only that tag's rows`() = runTest(dispatcher) { + enqueue(page(listOf("m1"))) + enqueue(page(listOf("t1"))) + enqueue(page(listOf("t2"))) + val repo = repository() + + repo.refreshFeed() + repo.refreshFeed(tag = "lists") + repo.refreshFeed(tag = "lego") + + // Each feed stands alone: neither tag refresh cleared the other, or the main one. + assertThat(repo.observeFeed().first().map { it.id }).containsExactly("m1") + assertThat(repo.observeFeed(tag = "lists").first().map { it.id }).containsExactly("t1") + assertThat(repo.observeFeed(tag = "lego").first().map { it.id }).containsExactly("t2") + } + + @Test + fun `a dig made in a tag feed shows up in the main feed too`() = runTest(dispatcher) { + enqueue(page(listOf("shared"))) + enqueue(page(listOf("shared"))) + server.enqueue(MockResponse().setResponseCode(201)) + val repo = repository() + + repo.refreshFeed() + repo.refreshFeed(tag = "lists") + repo.setDug("shared", dug = true) + + // Feeds are lists *over* the cached messages, not copies of them. + assertThat(repo.observeFeed().first().single().dugByMe).isTrue() + assertThat(repo.observeFeed(tag = "lists").first().single().dugByMe).isTrue() + } + + @Test + fun `deleting a message drops it from every feed it appeared in`() = runTest(dispatcher) { + enqueue(page(listOf("gone", "stays"))) + enqueue(page(listOf("gone"))) + server.enqueue(MockResponse().setResponseCode(200)) + val repo = repository() + + repo.refreshFeed() + repo.refreshFeed(tag = "lists") + repo.deleteMessage("gone") + + assertThat(repo.observeFeed().first().map { it.id }).containsExactly("stays") + assertThat(repo.observeFeed(tag = "lists").first()).isEmpty() + } +} diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/navigation/TagFeedLinkTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/navigation/TagFeedLinkTest.kt new file mode 100644 index 0000000..29b3198 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/navigation/TagFeedLinkTest.kt @@ -0,0 +1,118 @@ +package com.interlinedlist.android.feature.messages.navigation + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * The tag deep link. + * + * The canonical URL was read off the live site rather than guessed: the web's + * message card renders each tag as + * ``, and + * `https://interlinedlist.com/?tag=lists` really does serve a tag-filtered feed + * (`/tag/lists` and `/tags/lists` both 404). These tests pin that shape, so the + * intent filter and the resolver cannot quietly drift apart from it. + */ +class TagFeedLinkTest { + + @Test + fun `the web tag URL yields its tag`() { + assertThat(TagFeedLink.parse("https://interlinedlist.com/?tag=lists")).isEqualTo("lists") + } + + @Test + fun `the root without a trailing slash is still a tag URL`() { + assertThat(TagFeedLink.parse("https://interlinedlist.com?tag=lists")).isEqualTo("lists") + } + + @Test + fun `a percent-encoded tag comes back decoded, exactly once`() { + // The web encodes with encodeURIComponent, so a space is %20 and a comma + // is %2C. Decoding must restore the tag whole — not half-decoded, and not + // split on the comma or the spaces. + val decoded = TagFeedLink.parse( + "https://interlinedlist.com/?tag=life%20is%20short%2C%20o%20brave%20girl", + ) + assertThat(decoded).isEqualTo("life is short, o brave girl") + } + + @Test + fun `case and punctuation in the tag survive untouched`() { + assertThat(TagFeedLink.parse("https://interlinedlist.com/?tag=Hanabie.")).isEqualTo("Hanabie.") + } + + @Test + fun `www, http and extra query parameters are tolerated`() { + assertThat(TagFeedLink.parse("http://www.interlinedlist.com/?ref=x&tag=lego")) + .isEqualTo("lego") + } + + @Test + fun `the custom-scheme form resolves the same way`() { + assertThat(TagFeedLink.parse("interlinedlist://tag?tag=lists")).isEqualTo("lists") + } + + @Test + fun `a link to any other page is not a tag link`() { + // Only the site root carries the tag feed; a `tag` query hung off another + // page must not hijack that page's own deep link. + assertThat(TagFeedLink.parse("https://interlinedlist.com/lists/shared/xyz?tag=lists")).isNull() + assertThat(TagFeedLink.parse("https://interlinedlist.com/messages?tag=lists")).isNull() + assertThat(TagFeedLink.parse("https://interlinedlist.com/verify-email?token=abc")).isNull() + } + + @Test + fun `a foreign host is not a tag link`() { + assertThat(TagFeedLink.parse("https://example.com/?tag=lists")).isNull() + } + + @Test + fun `a root link with no usable tag is not a tag link`() { + assertThat(TagFeedLink.parse("https://interlinedlist.com/")).isNull() + assertThat(TagFeedLink.parse("https://interlinedlist.com/?tag=")).isNull() + assertThat(TagFeedLink.parse("https://interlinedlist.com/?tag=%20")).isNull() + } + + @Test + fun `garbage input returns null instead of throwing`() { + assertThat(TagFeedLink.parse("not a uri at all")).isNull() + assertThat(TagFeedLink.parse("")).isNull() + assertThat(TagFeedLink.parse(null)).isNull() + } + + // ---- route mapping ----------------------------------------------------- + + @Test + fun `routeForTagLink maps a tag URL onto the in-app tag feed route`() { + assertThat(MessagesDestinations.routeForTagLink("https://interlinedlist.com/?tag=lists")) + .isEqualTo("messages/tag/lists") + } + + @Test + fun `routeForTagLink percent-encodes a tag with spaces and punctuation`() { + // %20 rather than +: Navigation decodes the path segment with Uri.decode, + // which would hand a "+" straight back as a plus sign. + assertThat( + MessagesDestinations.routeForTagLink( + "https://interlinedlist.com/?tag=life%20is%20short%2C%20o%20brave%20girl", + ), + ).isEqualTo("messages/tag/life%20is%20short%2C%20o%20brave%20girl") + } + + @Test + fun `routeForTagLink ignores links it does not own`() { + assertThat(MessagesDestinations.routeForTagLink("https://interlinedlist.com/lists/shared/xyz")) + .isNull() + assertThat(MessagesDestinations.routeForTagLink(null)).isNull() + } + + @Test + fun `the tag feed route pattern and a built route share one path shape`() { + // A tag containing a slash must not split into two path segments, or the + // route would stop matching the pattern. + val route = MessagesDestinations.tagFeedRoute("a/b") + assertThat(route).isEqualTo("messages/tag/a%2Fb") + assertThat(route.count { it == '/' }) + .isEqualTo(MessagesDestinations.TAG_FEED.count { it == '/' }) + } +} 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 2e3578d..e32d58f 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 @@ -29,6 +29,8 @@ import kotlin.coroutines.cancellation.CancellationException class FakeMessagesRepository : MessagesRepository { private val feed = MutableStateFlow>(emptyList()) + /** Per-tag feeds, so a tag feed can be observed independently of the main one. */ + private val tagFeeds = MutableStateFlow>>(emptyMap()) private val replies = MutableStateFlow>>(emptyMap()) private val single = MutableStateFlow>(emptyMap()) private val scheduled = MutableStateFlow>(emptyList()) @@ -77,6 +79,15 @@ class FakeMessagesRepository : MessagesRepository { val refreshPreferences = mutableListOf() /** The preference each [loadMoreFeed] ran under, in order. */ val loadMorePreferences = mutableListOf() + /** The tag each [refreshFeed] ran under (null = main feed), in order. */ + val refreshTags = mutableListOf() + /** The tag each [loadMoreFeed] ran under (null = main feed), in order. */ + val loadMoreTags = mutableListOf() + /** Every tag [observeFeed] was subscribed to, in order. */ + val observedTags = mutableListOf() + /** How many times the composer-only lookups were made. */ + var linkedNetworksCalls = 0 + var defaultVisibilityCalls = 0 /** Every preference [setViewingPreference] was asked to PATCH, in order. */ val savedViewingPreferences = mutableListOf() var lastSetDug: Pair? = null @@ -140,13 +151,19 @@ class FakeMessagesRepository : MessagesRepository { data class ReportUserArgs(val username: String, val reason: ReportReason, val detail: String?) fun emitFeed(messages: List) { feed.value = messages } + fun emitTagFeed(tag: String, messages: List) { + tagFeeds.value = tagFeeds.value + (tag to messages) + } fun emitReplies(parentId: String, messages: List) { replies.value = replies.value + (parentId to messages) } fun emitMessage(message: Message) { single.value = single.value + (message.id to message) } fun emitScheduled(messages: List) { scheduled.value = messages } - override fun observeFeed(): Flow> = feed + override fun observeFeed(tag: String?): Flow> { + observedTags += tag + return if (tag == null) feed else tagFeeds.map { it[tag].orEmpty() } + } override fun observeReplies(messageId: String): Flow> = replies.map { it[messageId].orEmpty() } @@ -156,16 +173,22 @@ class FakeMessagesRepository : MessagesRepository { override fun observeScheduled(): Flow> = scheduled - override suspend fun refreshFeed(preference: ViewingPreference): ApiResult { + override suspend fun refreshFeed(preference: ViewingPreference, tag: String?): ApiResult { refreshCount++ refreshPreferences += preference + refreshTags += tag return refreshResult } - override suspend fun loadMoreFeed(cursor: String, preference: ViewingPreference): ApiResult { + override suspend fun loadMoreFeed( + cursor: String, + preference: ViewingPreference, + tag: String?, + ): ApiResult { loadMoreCount++ loadMoreCursors += cursor loadMorePreferences += preference + loadMoreTags += tag return loadMoreResult } @@ -207,9 +230,15 @@ class FakeMessagesRepository : MessagesRepository { } } - override suspend fun getLinkedNetworks(): ApiResult> = linkedNetworksResult + override suspend fun getLinkedNetworks(): ApiResult> { + linkedNetworksCalls++ + return linkedNetworksResult + } - override suspend fun getDefaultVisibility(): ApiResult = defaultVisibilityResult + override suspend fun getDefaultVisibility(): ApiResult { + defaultVisibilityCalls++ + return defaultVisibilityResult + } override suspend fun uploadImage(bytes: ByteArray, fileName: String, mimeType: String): ApiResult { uploadedImages++ diff --git a/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/TagFeedViewModelTest.kt b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/TagFeedViewModelTest.kt new file mode 100644 index 0000000..fe757f2 --- /dev/null +++ b/feature/messages/src/test/kotlin/com/interlinedlist/android/feature/messages/ui/feed/TagFeedViewModelTest.kt @@ -0,0 +1,150 @@ +package com.interlinedlist.android.feature.messages.ui.feed + +import androidx.lifecycle.SavedStateHandle +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.model.ViewingPreference +import com.interlinedlist.android.feature.messages.navigation.MessagesDestinations +import com.interlinedlist.android.feature.messages.ui.FakeMessagesRepository +import com.interlinedlist.android.feature.messages.ui.sampleMessage +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +/** + * The tag feed is the *same* ViewModel as the main feed, told which tag it is + * showing through its nav argument. These tests pin that the tag reaches every + * feed call — including the second page — and that the main feed is unaffected + * when there is no tag argument at all. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TagFeedViewModelTest { + + private val dispatcher = StandardTestDispatcher() + + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + /** A real tag from the live site: spaces *and* a comma, in one label. */ + private val awkwardTag = "life is short, o brave girl" + + private fun handle(tag: String) = + SavedStateHandle(mapOf(MessagesDestinations.ARG_TAG to tag)) + + @Test + fun `a tag feed refreshes under its tag, exactly as given`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo, handle(awkwardTag)) + + vm.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem().tag).isEqualTo(awkwardTag) + } + // Not trimmed, lowercased or split: the tag the card carried is the tag + // the query runs under. + assertThat(repo.refreshTags).containsExactly(awkwardTag) + } + + @Test + fun `the main feed asks for no tag`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo) + + vm.uiState.test { + advanceUntilIdle() + val state = expectMostRecentItem() + assertThat(state.tag).isNull() + assertThat(state.isTagFeed).isFalse() + } + assertThat(repo.refreshTags).containsExactly(null) + } + + @Test + fun `the tag feed shows the tag's own cached rows, not the main feed's`() = + runTest(dispatcher) { + val repo = FakeMessagesRepository() + repo.emitFeed(listOf(sampleMessage(id = "main"))) + repo.emitTagFeed("lists", listOf(sampleMessage(id = "tagged", tags = listOf("lists")))) + val vm = MessagesFeedViewModel(repo, handle("lists")) + + vm.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem().messages.map { it.id }).containsExactly("tagged") + } + } + + @Test + fun `paging a tag feed carries the cursor and the tag together`() = runTest(dispatcher) { + val repo = FakeMessagesRepository().apply { + refreshResult = ApiResult.Success("opaque-cursor") + loadMoreResult = ApiResult.Success(null) + } + val vm = MessagesFeedViewModel(repo, handle("lists")) + + vm.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem().canLoadMore).isTrue() + vm.loadMore() + advanceUntilIdle() + assertThat(expectMostRecentItem().canLoadMore).isFalse() + } + + assertThat(repo.loadMoreCursors).containsExactly("opaque-cursor") + assertThat(repo.loadMoreTags).containsExactly("lists") + } + + @Test + fun `switching the view preference on a tag feed reloads it under the same tag`() = + runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo, handle("lists")) + + vm.uiState.test { + advanceUntilIdle() + vm.onViewingPreferenceChange(ViewingPreference.MINE) + advanceUntilIdle() + assertThat(expectMostRecentItem().viewingPreference).isEqualTo(ViewingPreference.MINE) + } + + // The view switcher is shared with the main feed, so the tag feed keeps + // honouring it — and never loses its tag doing so. + assertThat(repo.savedViewingPreferences).containsExactly(ViewingPreference.MINE) + assertThat(repo.refreshTags).containsExactly("lists", "lists").inOrder() + assertThat(repo.refreshPreferences.last()).isEqualTo(ViewingPreference.MINE) + } + + @Test + fun `a blank tag argument is treated as the main feed`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo, handle(" ")) + + vm.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem().isTagFeed).isFalse() + } + assertThat(repo.refreshTags).containsExactly(null) + } + + @Test + fun `a tag feed does not set up the composer it never shows`() = runTest(dispatcher) { + val repo = FakeMessagesRepository() + val vm = MessagesFeedViewModel(repo, handle("lists")) + + vm.uiState.test { + advanceUntilIdle() + assertThat(expectMostRecentItem().linkedNetworks).isEmpty() + } + // The cross-post destinations and default visibility are composer-only + // lookups; a screen with no composer should not pay for them. + assertThat(repo.linkedNetworksCalls).isEqualTo(0) + assertThat(repo.defaultVisibilityCalls).isEqualTo(0) + } +}