The inbox opens what it is about in the app, not the website (refs #5) fbc95ee8777c
Andy (2026-10-25): whatever is tapped in the inbox should open natively. A notification's hub page now opens the app's own screen when it has one: an issue, proposal, run, change, file, history, board or repository; the browser only for pages the app does not show (a release, the admin pages) or other sites.
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key c73efeec6512
- When
- · 2026-10-25 22:49 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
fbc95ee8777c22fa22e2bc6f55a6dd8cd88da22ddcf76acc598edd213799b2ae- Depends on
- 144cbf7bf2b5 4aee5293461d
- Depended on by
- 62429abe5869
- In bookmarks
- main
- In tags
- v0.1.0 v0.1.1 v0.1.2 v0.1.3 v0.1.4 v0.1.5 v0.1.6 v0.1.7 v0.1.8 v0.1.9
- Edits
- 10 atoms, 2 tree operations, 4 files
Browse the tree at this change
added app/src/main/java/io/oxygenhub/android/ui/nav/Links.kt +39 −0
| 1 | + | package io.oxygenhub.android.ui.nav |
|
| 2 | + | ||
| 3 | + | import java.net.URI |
|
| 4 | + | ||
| 5 | + | /** |
|
| 6 | + | * The app's own screen for a page of the hub (Andy, 2026-10-25: whatever |
|
| 7 | + | * is tapped in the inbox opens natively): an issue, proposal, run, change, |
|
| 8 | + | * file, history, board or repository. Null for a page the app does not |
|
| 9 | + | * show itself, or another site's, which the browser opens. |
|
| 10 | + | */ |
|
| 11 | + | fun routeFor(url: String, hub: String): Any? { |
|
| 12 | + | val u = runCatching { URI(url) }.getOrNull() ?: return null |
|
| 13 | + | val home = runCatching { URI(hub) }.getOrNull() ?: return null |
|
| 14 | + | if (u.host != null && !u.host.equals(home.host, ignoreCase = true)) return null |
|
| 15 | + | val parts = (u.rawPath ?: "").split('/').filter { it.isNotEmpty() }.map { java.net.URLDecoder.decode(it, "UTF-8") } |
|
| 16 | + | if (parts.size >= 3 && parts[0] == "projects") { |
|
| 17 | + | return parts[2].toLongOrNull()?.let { BoardRoute(parts[1], it) } |
|
| 18 | + | } |
|
| 19 | + | if (parts.size < 2 || parts[0] in HUB_PAGES) return null |
|
| 20 | + | val (owner, name) = parts[0] to parts[1] |
|
| 21 | + | val rest = parts.drop(2) |
|
| 22 | + | return when (rest.firstOrNull()) { |
|
| 23 | + | null -> IssuesRoute(owner, name) |
|
| 24 | + | "issue" -> rest.getOrNull(1)?.toLongOrNull()?.let { IssueRoute(owner, name, it) } |
|
| 25 | + | "proposal" -> rest.getOrNull(1)?.toLongOrNull()?.let { ProposalRoute(owner, name, it) } |
|
| 26 | + | "flow" -> rest.getOrNull(1)?.toLongOrNull()?.let { RunRoute(owner, name, it) } |
|
| 27 | + | "change" -> rest.getOrNull(1)?.let { ChangeRoute(owner, name, it) } |
|
| 28 | + | "file" -> if (rest.size >= 3) FileRoute(owner, name, rest[1], rest.drop(2).joinToString("/")) else null |
|
| 29 | + | "changes" -> rest.getOrNull(1)?.let { HistoryRoute(owner, name, it) } |
|
| 30 | + | else -> null |
|
| 31 | + | } |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | /** The hub's own top-level pages: never an owner's name. */ |
|
| 35 | + | private val HUB_PAGES = setOf( |
|
| 36 | + | "about", "admin", "api", "apt", "avatar", "device", "drops", "explore", "health", "help", "insights", "live", "log", |
|
| 37 | + | "login", "logout", "metrics", "new", "notifications", "orgs", "register", "search", "security-contact", "settings", |
|
| 38 | + | "sky", "static", "verify", ".well-known", |
|
| 39 | + | ) |
added app/src/test/java/io/oxygenhub/android/ui/LinksTest.kt +39 −0
| 1 | + | package io.oxygenhub.android.ui |
|
| 2 | + | ||
| 3 | + | import io.oxygenhub.android.ui.nav.BoardRoute |
|
| 4 | + | import io.oxygenhub.android.ui.nav.ChangeRoute |
|
| 5 | + | import io.oxygenhub.android.ui.nav.FileRoute |
|
| 6 | + | import io.oxygenhub.android.ui.nav.HistoryRoute |
|
| 7 | + | import io.oxygenhub.android.ui.nav.IssueRoute |
|
| 8 | + | import io.oxygenhub.android.ui.nav.IssuesRoute |
|
| 9 | + | import io.oxygenhub.android.ui.nav.ProposalRoute |
|
| 10 | + | import io.oxygenhub.android.ui.nav.RunRoute |
|
| 11 | + | import io.oxygenhub.android.ui.nav.routeFor |
|
| 12 | + | import org.junit.Assert.assertEquals |
|
| 13 | + | import org.junit.Assert.assertNull |
|
| 14 | + | import org.junit.Test |
|
| 15 | + | ||
| 16 | + | class LinksTest { |
|
| 17 | + | private val hub = "https://oxygenhub.io" |
|
| 18 | + | private fun r(path: String) = routeFor("https://oxygenhub.io$path", hub) |
|
| 19 | + | ||
| 20 | + | @Test |
|
| 21 | + | fun hubPagesOpenTheirOwnScreens() { |
|
| 22 | + | assertEquals(IssueRoute("doomsdayone", "supermarioland2", 317), r("/doomsdayone/supermarioland2/issue/317#c-12")) |
|
| 23 | + | assertEquals(ProposalRoute("andymccall", "oxygenhub", 314), r("/andymccall/oxygenhub/proposal/314")) |
|
| 24 | + | assertEquals(RunRoute("andymccall", "phone-runner-demo", 56), r("/andymccall/phone-runner-demo/flow/56")) |
|
| 25 | + | assertEquals(ChangeRoute("andymccall", "oxygen", "4f07c5a0"), r("/andymccall/oxygen/change/4f07c5a0")) |
|
| 26 | + | assertEquals(FileRoute("andymccall", "oxygen", "main", "crates/oxy-core/src/strata.rs"), r("/andymccall/oxygen/file/main/crates/oxy-core/src/strata.rs")) |
|
| 27 | + | assertEquals(HistoryRoute("andymccall", "oxygen", "main"), r("/andymccall/oxygen/changes/main")) |
|
| 28 | + | assertEquals(BoardRoute("doomsdayone", 4), r("/projects/doomsdayone/4")) |
|
| 29 | + | assertEquals(IssuesRoute("andymccall", "oxygen"), r("/andymccall/oxygen")) |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | @Test |
|
| 33 | + | fun otherPagesAndSitesGoToTheBrowser() { |
|
| 34 | + | assertNull(r("/andymccall/oxygenhub/releases/tag/v0.9.4")) |
|
| 35 | + | assertNull(r("/admin/runners")) |
|
| 36 | + | assertNull(routeFor("https://example.com/andymccall/oxygen/issue/1", hub)) |
|
| 37 | + | assertNull(r("/andymccall/oxygen/issue/not-a-number")) |
|
| 38 | + | } |
|
| 39 | + | } |
modified app/src/main/java/io/oxygenhub/android/ui/inbox/InboxScreen.kt +7 −5
| … | 33 unchanged lines |
||
| 34 | 34 | import androidx.compose.ui.Alignment |
|
| 35 | 35 | import androidx.compose.ui.Modifier |
|
| 36 | 36 | import androidx.compose.ui.graphics.vector.ImageVector |
|
| 37 | - | import androidx.compose.ui.platform.LocalUriHandler |
|
| 38 | 37 | import androidx.compose.ui.text.font.FontWeight |
|
| 39 | 38 | import androidx.compose.ui.text.style.TextOverflow |
|
| 40 | 39 | import androidx.compose.ui.unit.dp |
|
| … | 4 unchanged lines |
||
| 45 | 44 | import io.oxygenhub.android.ui.common.LoadContent |
|
| 46 | 45 | import io.oxygenhub.android.ui.common.relativeTime |
|
| 47 | 46 | ||
| 48 | - | /** The inbox. A notification opens its page on the hub (in-app pages come with #4 and #5). */ |
|
| 47 | + | /** |
|
| 48 | + | * The inbox. A notification opens what it is about on its own screen in |
|
| 49 | + | * the app ([onOpen]): an issue, proposal, run, change, file or board; the |
|
| 50 | + | * browser only for a page the app does not show. |
|
| 51 | + | */ |
|
| 49 | 52 | @Composable |
|
| 50 | - | fun InboxScreen(vm: InboxViewModel) { |
|
| 53 | + | fun InboxScreen(vm: InboxViewModel, onOpen: (url: String) -> Unit) { |
|
| 51 | 54 | val state by vm.state.collectAsStateWithLifecycle() |
|
| 52 | 55 | val message by vm.message.collectAsStateWithLifecycle() |
|
| 53 | 56 | val snackbar = remember { SnackbarHostState() } |
|
| 54 | - | val uri = LocalUriHandler.current |
|
| 55 | 57 | LaunchedEffect(message) { |
|
| 56 | 58 | message?.let { |
|
| 57 | 59 | snackbar.showSnackbar(it) |
|
| … | 19 unchanged lines |
||
| 77 | 79 | items(inbox.items, key = { it.id }) { n -> |
|
| 78 | 80 | NotificationRow(n, onClick = { |
|
| 79 | 81 | vm.markRead(n) |
|
| 80 | - | if (n.url.isNotBlank()) uri.openUri(n.url) |
|
| 82 | + | if (n.url.isNotBlank()) onOpen(n.url) |
|
| 81 | 83 | }) |
|
| 82 | 84 | HorizontalDivider() |
|
| 83 | 85 | } |
|
| … | 63 unchanged lines |
modified app/src/main/java/io/oxygenhub/android/ui/nav/AppNavigation.kt +5 −1
| … | 16 unchanged lines |
||
| 17 | 17 | import androidx.compose.runtime.Composable |
|
| 18 | 18 | import androidx.compose.runtime.getValue |
|
| 19 | 19 | import androidx.compose.ui.graphics.vector.ImageVector |
|
| 20 | + | import androidx.compose.ui.platform.LocalUriHandler |
|
| 20 | 21 | import androidx.lifecycle.ViewModel |
|
| 21 | 22 | import androidx.lifecycle.compose.collectAsStateWithLifecycle |
|
| 22 | 23 | import androidx.lifecycle.viewmodel.compose.viewModel |
|
| … | 76 unchanged lines |
||
| 99 | 100 | val inboxState by inbox.state.collectAsStateWithLifecycle() |
|
| 100 | 101 | val unread = inboxState.valueOrNull?.unread ?: 0 |
|
| 101 | 102 | val entry by nav.currentBackStackEntryAsState() |
|
| 103 | + | // A hub page opens on its own screen when the app has one, else in the browser. |
|
| 104 | + | val uri = LocalUriHandler.current |
|
| 105 | + | val openLink: (String) -> Unit = { url -> routeFor(url, session.hub)?.let { nav.navigate(it) } ?: uri.openUri(url) } |
|
| 102 | 106 | val destination = entry?.destination |
|
| 103 | 107 | ||
| 104 | 108 | NavigationSuiteScaffold( |
|
| … | 62 unchanged lines |
||
| 167 | 171 | onSignInAgain = { scope.launch { onSignOut() } }, |
|
| 168 | 172 | ) |
|
| 169 | 173 | } |
|
| 170 | - | composable<InboxRoute> { InboxScreen(inbox) } |
|
| 174 | + | composable<InboxRoute> { InboxScreen(inbox, onOpen = openLink) } |
|
| 171 | 175 | composable<AccountRoute> { AccountScreen(session, rememberAvatar(api, session.username), api, nativeCore, onSignOut) } |
|
| 172 | 176 | } |
|
| 173 | 177 | } |
|
| … | 10 unchanged lines |