Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -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=<tag>" (there is no /tag/<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.
-->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
Expand All @@ -37,6 +44,7 @@
<data android:scheme="https" android:host="interlinedlist.com" />
<data android:pathPrefix="/lists/shared/" />
<data android:pathPrefix="/documents/shared/" />
<data android:path="/" />
<data android:path="/reset-password" />
<data android:path="/verify-email" />
<data android:path="/verify-email-change" />
Expand All @@ -50,6 +58,7 @@
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="interlinedlist" android:host="lists" />
<data android:scheme="interlinedlist" android:host="documents" />
<data android:scheme="interlinedlist" android:host="tag" />
<data android:scheme="interlinedlist" android:host="reset-password" />
<data android:scheme="interlinedlist" android:host="verify-email" />
<data android:scheme="interlinedlist" android:host="verify-email-change" />
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/com/interlinedlist/android/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand All @@ -53,6 +58,7 @@ class MainActivity : ComponentActivity() {
startLoggedIn = startLoggedIn,
notificationRoute = notificationRoute,
emailChangeRoute = emailChangeRoute,
tagFeedRoute = tagFeedRoute,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -193,6 +194,7 @@ fun InterlinedListNavHost(
startLoggedIn: Boolean,
notificationRoute: String? = null,
emailChangeRoute: String? = null,
tagFeedRoute: String? = null,
) {
val navController = rememberNavController()
NavHost(
Expand All @@ -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.
Expand Down Expand Up @@ -244,7 +247,7 @@ fun InterlinedListNavHost(
*/
@Composable
private fun MainShell(
notificationRoute: String? = null,
pendingRoute: String? = null,
onLoggedOut: () -> Unit,
) {
val tabNav = rememberNavController()
Expand Down Expand Up @@ -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) }
}
}
Expand Down Expand Up @@ -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(
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -137,6 +139,8 @@ class MessagesFeedScreenTest {
state = state.copy(isComposeOpen = true, quoteTarget = quoted)
onQuote(quoted)
},
onOpenTag = onOpenTag,
onBack = onBack,
)
}
}
Expand Down Expand Up @@ -593,6 +597,67 @@ class MessagesFeedScreenTest {
composeRule.onNodeWithText(tag).assertIsDisplayed()
}

@Test
fun tappingATag_opensThatTagsFeed() {
val opened = mutableListOf<String>()
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<String>()
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<Unit>()
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"))))
Expand Down
Loading