feat(nav): group the drawer into the shard's sections and honor its added links (M12 phase 6)
Phase 6 of M12 (docs/android/THEMING_AND_NAV.md §6.3): the drawer gains the sections an admin grouped rows into and the links they added of their own, the last of the public nav the website publishes. buildNavTree ports the web's buildPublicNav and pruneNav; a link's path is validated by the website's own read rule and resolved through resolveWebPath, which the app has to answer for any page on the site rather than the nav's sixteen. A link the app can open natively does; one it cannot hands off to a Custom Tab, absolute against the configured base URL. Phase 6 does not re-implement phase 5: with no sections and no links stored, buildNavTree hands straight to applyNavOverrides, so an untouched instance still gets APP_MENU back by identity and AC-1's proof is unchanged. visibleEntries is split into isEntryVisible so pruneNav can apply the same predicate inside a section, and drop one the gates leave empty. 476 unit tests green (442 + 34); lintDebug and assembleDebug clean. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material3.DrawerValue
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
@@ -21,6 +23,7 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalDrawerSheet
|
||||
import androidx.compose.material3.ModalNavigationDrawer
|
||||
import androidx.compose.material3.NavigationDrawerItem
|
||||
import androidx.compose.material3.NavigationDrawerItemColors
|
||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
@@ -32,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -48,6 +52,7 @@ import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.web.WebHandoff
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
import com.runicgateway.app.ui.auth.AccountScreen
|
||||
@@ -59,9 +64,11 @@ import com.runicgateway.app.ui.components.BrandLogo
|
||||
import com.runicgateway.app.ui.contact.ContactScreen
|
||||
import com.runicgateway.app.ui.home.HomeScreen
|
||||
import com.runicgateway.app.ui.navigation.APP_MENU
|
||||
import com.runicgateway.app.ui.navigation.NavNode
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.navigation.applyNavOverrides
|
||||
import com.runicgateway.app.ui.navigation.visibleEntries
|
||||
import com.runicgateway.app.ui.navigation.buildNavTree
|
||||
import com.runicgateway.app.ui.navigation.isEntryVisible
|
||||
import com.runicgateway.app.ui.navigation.pruneNav
|
||||
import com.runicgateway.app.ui.news.NewsScreen
|
||||
import com.runicgateway.app.ui.news.PostScreen
|
||||
import com.runicgateway.app.ui.admin.AdminContentScreen
|
||||
@@ -150,14 +157,32 @@ fun RunicApp(
|
||||
val currentRoute = backStackEntry?.destination?.route?.substringBefore('?')
|
||||
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||
// The admin's nav overrides, then the gates — never the other way round. An
|
||||
// override is presentation only: it may relabel, reorder and hide, so
|
||||
// `visibleEntries` still decides what this caller may see and remains the
|
||||
// boundary (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
|
||||
val entries = visibleEntries(
|
||||
applyNavOverrides(APP_MENU, appearance.navPublic),
|
||||
session,
|
||||
shardFeatures,
|
||||
)
|
||||
// override is presentation only: it may relabel, reorder, group and hide, so
|
||||
// `pruneNav` still decides what this caller may see and remains the boundary
|
||||
// (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
|
||||
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
|
||||
isEntryVisible(it, session, shardFeatures)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
// An added link's path is site-relative; a hand-off needs it absolute against
|
||||
// the configured base URL, which is exactly what the asset resolver does (§6.3).
|
||||
val resolveUrl = LocalAssetResolver.current
|
||||
val openNode: (NavNode) -> Unit = { node ->
|
||||
scope.launch { drawerState.close() }
|
||||
when (node) {
|
||||
is NavNode.Item -> navController.navigateTopLevel(node.entry.route)
|
||||
// A link the app resolved opens like any other drawer row, detail screen
|
||||
// or not: one rule, and back-press lands on Home as it does from every
|
||||
// row. One it could not resolve goes to the browser, absolute against
|
||||
// the site's base URL (§6.3).
|
||||
is NavNode.Link -> node.route
|
||||
?.let { navController.navigateTopLevel(it) }
|
||||
?: resolveUrl(node.path)?.let { WebHandoff.open(context, it) }
|
||||
// Section headers aren't clickable — the group is always open (§6.3).
|
||||
is NavNode.Section -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
ModalNavigationDrawer(
|
||||
drawerState = drawerState,
|
||||
@@ -191,19 +216,30 @@ fun RunicApp(
|
||||
)
|
||||
HorizontalDivider()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
entries.forEach { entry ->
|
||||
NavigationDrawerItem(
|
||||
// An admin's label wins over the bundled one, and is the
|
||||
// same string in every locale — see MenuEntry.label.
|
||||
label = { Text(entry.label ?: stringResource(entry.labelRes)) },
|
||||
selected = currentRoute == entry.route,
|
||||
onClick = {
|
||||
scope.launch { drawerState.close() }
|
||||
navController.navigateTopLevel(entry.route)
|
||||
},
|
||||
colors = drawerItemColors,
|
||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
||||
)
|
||||
nav.forEach { node ->
|
||||
if (node is NavNode.Section) {
|
||||
// A group the admin created: its label as a header, its rows
|
||||
// beneath it. Always open — a drawer is already a vertical
|
||||
// list, so the website's dropdown does not translate (§6.3).
|
||||
Text(
|
||||
text = node.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(
|
||||
start = 28.dp,
|
||||
end = 28.dp,
|
||||
top = 12.dp,
|
||||
bottom = 4.dp,
|
||||
),
|
||||
)
|
||||
node.items.forEach { child ->
|
||||
NavRow(child, currentRoute, drawerItemColors, indented = true) {
|
||||
openNode(child)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
NavRow(node, currentRoute, drawerItemColors) { openNode(node) }
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalDivider(Modifier.padding(vertical = 8.dp))
|
||||
@@ -302,6 +338,57 @@ fun RunicApp(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One drawer row: a coded entry, or an admin's added link (§6.3).
|
||||
*
|
||||
* A link that the app can open natively is deliberately indistinguishable from a
|
||||
* coded row — that is the point of resolving it. One that hands off to the browser
|
||||
* carries a trailing icon, so leaving the app is never a surprise.
|
||||
*/
|
||||
@Composable
|
||||
private fun NavRow(
|
||||
node: NavNode,
|
||||
currentRoute: String?,
|
||||
colors: NavigationDrawerItemColors,
|
||||
indented: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val route = when (node) {
|
||||
is NavNode.Item -> node.entry.route
|
||||
is NavNode.Link -> node.route
|
||||
is NavNode.Section -> null
|
||||
}
|
||||
val label = when (node) {
|
||||
// An admin's label wins over the bundled one, and is the same string in
|
||||
// every locale — see MenuEntry.label.
|
||||
is NavNode.Item -> node.entry.label ?: stringResource(node.entry.labelRes)
|
||||
is NavNode.Link -> node.label
|
||||
is NavNode.Section -> return
|
||||
}
|
||||
val handsOff = node is NavNode.Link && node.route == null
|
||||
|
||||
NavigationDrawerItem(
|
||||
label = { Text(label) },
|
||||
selected = route != null && currentRoute == route.substringBefore('?'),
|
||||
onClick = onClick,
|
||||
badge = if (!handsOff) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ExitToApp,
|
||||
contentDescription = stringResource(R.string.nav_opens_in_browser),
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
},
|
||||
colors = colors,
|
||||
modifier = Modifier
|
||||
.padding(NavigationDrawerItemDefaults.ItemPadding)
|
||||
.padding(start = if (indented) 16.dp else 0.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RunicNavHost(
|
||||
navController: NavHostController,
|
||||
|
||||
@@ -108,14 +108,26 @@ fun visibleEntries(
|
||||
entries: List<MenuEntry>,
|
||||
session: Session,
|
||||
features: ShardFeatures? = null,
|
||||
): List<MenuEntry> =
|
||||
entries.filter { entry ->
|
||||
val allowedByRole = when (entry.access) {
|
||||
MenuAccess.PUBLIC -> true
|
||||
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
||||
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
|
||||
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
||||
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
||||
}
|
||||
allowedByRole && (entry.feature == null || canSee(features, entry.feature))
|
||||
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features) }
|
||||
|
||||
/**
|
||||
* [visibleEntries] for a single entry — the same two filters, and the same
|
||||
* boundary. Split out because the drawer is a tree once an admin groups rows into
|
||||
* sections (§6.3): [pruneNav] applies this predicate inside a section as well, and
|
||||
* both callers must ask exactly one question or a sectioned row could be gated by
|
||||
* a rule its top-level twin is not.
|
||||
*/
|
||||
fun isEntryVisible(
|
||||
entry: MenuEntry,
|
||||
session: Session,
|
||||
features: ShardFeatures? = null,
|
||||
): Boolean {
|
||||
val allowedByRole = when (entry.access) {
|
||||
MenuAccess.PUBLIC -> true
|
||||
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
||||
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
|
||||
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
||||
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
||||
}
|
||||
return allowedByRole && (entry.feature == null || canSee(features, entry.feature))
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
@@ -26,11 +27,24 @@ import kotlinx.serialization.json.doubleOrNull
|
||||
*/
|
||||
|
||||
/** A usable override for one menu row. Absent fields mean "as coded". */
|
||||
private data class NavOverride(
|
||||
internal data class NavOverride(
|
||||
val label: String? = null,
|
||||
val order: Double? = null,
|
||||
val hidden: Boolean = false,
|
||||
/**
|
||||
* The id of the section this row was dropped into, or null for a top-level
|
||||
* row. Read here but honored only by the tree build (`NavTree.kt`) — the flat
|
||||
* [applyNavOverrides] has nowhere to put it. Not validated against the stored
|
||||
* sections here; that is the tree's job, since only it knows them.
|
||||
*/
|
||||
val section: String? = null,
|
||||
) {
|
||||
/**
|
||||
* Nothing a **flat** list can express. [section] is deliberately not part of
|
||||
* this: to [applyNavOverrides] a section-only override says nothing, so an
|
||||
* instance that only ever grouped rows still gets its coded list back by
|
||||
* identity. The tree build adds its own check.
|
||||
*/
|
||||
val isEmpty: Boolean get() = label == null && order == null && !hidden
|
||||
}
|
||||
|
||||
@@ -42,24 +56,41 @@ private data class NavOverride(
|
||||
* bare map of path → override. A bare map is unambiguous — every key is a path,
|
||||
* so a key can never be the string `items`.
|
||||
*
|
||||
* `sections` and `links` are deliberately not read here; they are phase 6's.
|
||||
* `sections` and `links` come out of the same wrapper, and only ever out of the
|
||||
* wrapped shape — see [sectionsOf] and [linksOf].
|
||||
*/
|
||||
private fun itemsOf(navPublic: JsonObject?): Map<String, JsonObject> {
|
||||
internal fun itemsOf(navPublic: JsonObject?): Map<String, JsonObject> {
|
||||
if (navPublic == null) return emptyMap()
|
||||
val wrapped = navPublic["items"] as? JsonObject
|
||||
val items = wrapped ?: navPublic
|
||||
val items = wrapperOf(navPublic)?.get("items") as? JsonObject ?: navPublic
|
||||
return items.entries
|
||||
.mapNotNull { (key, value) -> (value as? JsonObject)?.let { key to it } }
|
||||
.toMap()
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored value as the wrapped `{items, sections, links}` shape, or null when
|
||||
* it is the bare items map phases 6-8 wrote. The discriminator is the web's: an
|
||||
* `items` **object**, which a bare map can never carry because every key in one is
|
||||
* a path.
|
||||
*/
|
||||
private fun wrapperOf(navPublic: JsonObject?): JsonObject? =
|
||||
navPublic?.takeIf { it["items"] is JsonObject }
|
||||
|
||||
internal fun sectionsOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"sections")
|
||||
|
||||
internal fun linksOf(navPublic: JsonObject?): List<JsonObject> = jsonObjectsAt(navPublic,"links")
|
||||
|
||||
private fun jsonObjectsAt(navPublic: JsonObject?, key: String): List<JsonObject> =
|
||||
(wrapperOf(navPublic)?.get(key) as? JsonArray)
|
||||
?.mapNotNull { it as? JsonObject }
|
||||
.orEmpty()
|
||||
|
||||
// Field by field, like every other read in M12: a bad `label` must not discard a
|
||||
// good `order` beside it.
|
||||
//
|
||||
// `group` and `section` are ignored. The app renders no sections in this phase
|
||||
// (phase 6) and never renders the admin sidebar's groups at all, and a value it
|
||||
// cannot honor is better dropped than half-applied.
|
||||
private fun cleanOverride(raw: JsonObject): NavOverride {
|
||||
// `group` is ignored — it names a section of the *admin sidebar*, a nav the app
|
||||
// never renders, and a value it cannot honor is better dropped than half-applied.
|
||||
internal fun cleanOverride(raw: JsonObject): NavOverride {
|
||||
val label = (raw["label"] as? JsonPrimitive)
|
||||
?.takeIf { it.isString }
|
||||
?.content
|
||||
@@ -72,7 +103,11 @@ private fun cleanOverride(raw: JsonObject): NavOverride {
|
||||
val hidden = (raw["hidden"] as? JsonPrimitive)
|
||||
?.takeIf { !it.isString }
|
||||
?.booleanOrNull == true
|
||||
return NavOverride(label = label, order = order, hidden = hidden)
|
||||
val section = (raw["section"] as? JsonPrimitive)
|
||||
?.takeIf { it.isString }
|
||||
?.content
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
return NavOverride(label = label, order = order, hidden = hidden, section = section)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -113,9 +113,108 @@ internal val WEB_ROUTE_ORDER: Map<String, Int> =
|
||||
* it. A trailing slash is tolerated (`/wiki/` is `/wiki`) since a hand-edited
|
||||
* settings row may carry one; the root path is left alone.
|
||||
*/
|
||||
fun appRouteForWebPath(path: String?): String? {
|
||||
fun appRouteForWebPath(path: String?): String? = WEB_PATH_TO_ROUTE[normalizeWebPath(path)]
|
||||
|
||||
/** `/wiki/` → `/wiki`, blank → null, and `/` left alone. */
|
||||
private fun normalizeWebPath(path: String?): String? {
|
||||
val trimmed = path?.trim().orEmpty()
|
||||
if (trimmed.isEmpty()) return null
|
||||
val normalized = if (trimmed.length > 1) trimmed.trimEnd('/') else trimmed
|
||||
return WEB_PATH_TO_ROUTE[normalized.ifEmpty { "/" }]
|
||||
return normalized.ifEmpty { "/" }
|
||||
}
|
||||
|
||||
/**
|
||||
* The website's top-level paths that are **not** CMS pages.
|
||||
*
|
||||
* The site serves its CMS pages from a top-level `/<slug>` (React Router ranks its
|
||||
* static routes above that dynamic one), which is what lets [resolveWebPath]'s
|
||||
* last rule open an admin-authored page natively. These are the segments that rule
|
||||
* must not swallow: the SPA's own sections, and the two server mounts. A link to
|
||||
* one of them hands off to the browser, which is where they actually live.
|
||||
*/
|
||||
private val RESERVED_TOP_LEVEL = setOf(
|
||||
"admin", "account", "player", "site", "wiki", "invite", "preview", "api", "uploads",
|
||||
)
|
||||
|
||||
/**
|
||||
* The app route an **arbitrary** website path opens, or null when the app has no
|
||||
* screen for it and the link must hand off to a Custom Tab (§6.3).
|
||||
*
|
||||
* [appRouteForWebPath] answers for the sixteen paths the *nav* is built from; this
|
||||
* answers for a path an admin typed into an added link, which may name any page on
|
||||
* the site. It is the app's read of the site's own route table, and like the table
|
||||
* above it is cross-repo coupling kept in one file — quoted here for the same
|
||||
* reason, from `website/client/src/App.jsx`:
|
||||
*
|
||||
* ```jsx
|
||||
* <Route path="/" element={<Portal />} />
|
||||
* <Route path="/site/news" element={<News />} />
|
||||
* <Route path="/site/screenshots" element={<Screenshots />} />
|
||||
* <Route path="/site/five-on-friday" element={<FiveOnFriday />} />
|
||||
* <Route path="/site/newsletter" element={<Newsletter />} />
|
||||
* <Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||
* <Route path="/site/about" element={<About />} />
|
||||
* <Route path="/site/status" element={<Status />} />
|
||||
* <Route path="/site/shard" element={<Shard />} />
|
||||
* <Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||
* ... /site/champs, /guilds, /governors, /houses, /rules, /leaderboards, /market
|
||||
* <Route path="/site/atlas" element={<Atlas />} />
|
||||
* <Route path="/site/atlas/:slug" element={<AtlasCreature />} />
|
||||
* <Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
|
||||
* <Route path="/wiki" element={<Wiki />} />
|
||||
* <Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
* // CMS pages: top-level /:slug, matched only after the named routes above
|
||||
* <Route path="/:slug" element={<CmsPage />} />
|
||||
* ```
|
||||
*
|
||||
* Note what is *not* in it: no `/site/news/<id>` (a news item renders on its
|
||||
* category page; the newsletter's is the site's one post-detail route), no
|
||||
* `/page/<slug>`, and no `/contact` — the app's contact form is app-only (§6.2).
|
||||
*
|
||||
* ```
|
||||
* / → HOME
|
||||
* /site/news → NEWS
|
||||
* /site/{screenshots,five-on-friday,newsletter}
|
||||
* → NEWS, that category's tab
|
||||
* /site/newsletter/<id> → POST (the site's one post-detail route)
|
||||
* /wiki → WIKI
|
||||
* /wiki/<slug> → WIKI_PAGE
|
||||
* /site/<shard surface> → the mapped shard route (§6.2)
|
||||
* /site/atlas/<slug> → ATLAS_CREATURE
|
||||
* /site/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
||||
* /site/about → PAGE("about")
|
||||
* /<slug> → PAGE(slug), unless <slug> is reserved
|
||||
* anything else → null, i.e. the Custom Tab
|
||||
* ```
|
||||
*
|
||||
* **A path carrying a query or a fragment hands off**, whatever its route part
|
||||
* says. No app route takes either, so a native match would quietly drop what the
|
||||
* admin wrote; the browser honors it exactly.
|
||||
*
|
||||
* Resolving a path is not the same as being allowed to see the screen behind it.
|
||||
* A link to `/site/market` on a shard that does not publish the market lands on
|
||||
* the Market screen's honest "not published here" state, which is what typing the
|
||||
* URL on the web does too (§6.3).
|
||||
*/
|
||||
fun resolveWebPath(path: String?): String? {
|
||||
val normalized = normalizeWebPath(path) ?: return null
|
||||
if (normalized.any { it == '?' || it == '#' }) return null
|
||||
WEB_PATH_TO_ROUTE[normalized]?.let { return it }
|
||||
if (!normalized.startsWith("/")) return null
|
||||
|
||||
// Blank segments ("/site//news") mean a malformed path, not a slug.
|
||||
val segments = normalized.removePrefix("/").split('/')
|
||||
if (segments.any { it.isBlank() }) return null
|
||||
|
||||
return when {
|
||||
segments.size == 1 -> segments[0].takeIf { it !in RESERVED_TOP_LEVEL }?.let(Routes::page)
|
||||
segments[0] == "wiki" && segments.size == 2 -> Routes.wikiPage(segments[1])
|
||||
segments[0] != "site" -> null
|
||||
segments.size == 3 && segments[1] == "newsletter" ->
|
||||
Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2])
|
||||
segments.size == 3 && segments[1] == "atlas" -> Routes.atlasCreature(segments[2])
|
||||
segments.size == 4 && segments[1] == "market" && segments[2] == "vendors" ->
|
||||
Routes.marketVendor(segments[3])
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
239
app/src/main/java/com/runicgateway/app/ui/navigation/NavTree.kt
Normal file
239
app/src/main/java/com/runicgateway/app/ui/navigation/NavTree.kt
Normal file
@@ -0,0 +1,239 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.doubleOrNull
|
||||
|
||||
/**
|
||||
* The drawer as a one-level tree: the coded menu, plus the **sections** an admin
|
||||
* grouped rows into and the **links** they added of their own (THEMING_AND_NAV.md
|
||||
* §6.3). The Kotlin counterpart of the website's `buildPublicNav` + `pruneNav`.
|
||||
*
|
||||
* The public nav is the one nav an admin can restructure rather than only reorder,
|
||||
* and §6.1's invariant survives that structurally rather than by vigilance: a
|
||||
* coded row is still keyed by a website path the app's own table declares, so an
|
||||
* override still cannot invent a destination or touch a gate, while everything
|
||||
* that *can* name an arbitrary path lives in [NavNode.Link], where the path rule
|
||||
* is applied and the result is resolved through [resolveWebPath].
|
||||
*
|
||||
* An added link carries no gate and needs none — the screen behind it enforces its
|
||||
* own access, so a link to somewhere this caller cannot reach lands on that
|
||||
* screen's own honest state, exactly as typing the URL on the web does.
|
||||
*/
|
||||
sealed interface NavNode {
|
||||
|
||||
/** A coded [MenuEntry], relabeled/reordered by the merge but never re-gated. */
|
||||
data class Item(val entry: MenuEntry) : NavNode
|
||||
|
||||
/**
|
||||
* An admin-authored link to a page on this site.
|
||||
*
|
||||
* @param path the stored website path, already validated — this is what a
|
||||
* Custom Tab opens, resolved against the site's base URL
|
||||
* @param route the app route [path] maps to, or null when the app has no
|
||||
* screen for it and the link must hand off (§6.3)
|
||||
*/
|
||||
data class Link(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val path: String,
|
||||
val route: String?,
|
||||
) : NavNode
|
||||
|
||||
/**
|
||||
* A drawer group: its [label] as a header, its [items] beneath it.
|
||||
*
|
||||
* The website renders these as click-to-open dropdowns; a drawer is already a
|
||||
* vertical list, so the app renders the group open (§6.3). Never empty — see
|
||||
* [pruneNav].
|
||||
*/
|
||||
data class Section(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val items: List<NavNode>,
|
||||
) : NavNode
|
||||
}
|
||||
|
||||
/** A usable `sections` entry. */
|
||||
private data class SectionSpec(val id: String, val label: String, val order: Double?)
|
||||
|
||||
/** A usable `links` entry, with its section already checked against the stored ones. */
|
||||
private data class LinkSpec(
|
||||
val id: String,
|
||||
val label: String,
|
||||
val to: String,
|
||||
val order: Double?,
|
||||
val section: String?,
|
||||
)
|
||||
|
||||
/** One node waiting to be placed: its sort key, and whether that key was stored. */
|
||||
private data class Placed(val node: NavNode, val section: String?, val key: Double, val explicit: Boolean)
|
||||
|
||||
/**
|
||||
* Characters that must never appear in a stored link path. The same rule the
|
||||
* website applies on read: a value that would leave the origin, or carry markup
|
||||
* into a link, is dropped rather than rendered.
|
||||
*/
|
||||
private val FORBIDDEN_IN_PATH = Regex("""[\s<>"'\\]""")
|
||||
|
||||
// Forgiving, like every other read in M12: an entry that is not usable is dropped
|
||||
// and its neighbours kept. A repeated id is dropped too — the first wins, since
|
||||
// the id is what a link's identity in the drawer is.
|
||||
private fun readSections(raw: List<JsonObject>): List<SectionSpec> {
|
||||
val seen = mutableSetOf<String>()
|
||||
return raw.mapNotNull { section ->
|
||||
val id = section.stringOrNull("id") ?: return@mapNotNull null
|
||||
val label = section.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
if (!seen.add(id)) return@mapNotNull null
|
||||
SectionSpec(id = id, label = label, order = section.orderOrNull())
|
||||
}
|
||||
}
|
||||
|
||||
private fun readLinks(raw: List<JsonObject>, knownSections: Set<String>): List<LinkSpec> {
|
||||
val seen = mutableSetOf<String>()
|
||||
return raw.mapNotNull { link ->
|
||||
val id = link.stringOrNull("id") ?: return@mapNotNull null
|
||||
val label = link.stringOrNull("label")?.trim()?.takeIf { it.isNotEmpty() } ?: return@mapNotNull null
|
||||
val to = link.stringOrNull("to") ?: return@mapNotNull null
|
||||
if (!to.startsWith("/") || to.startsWith("//") || FORBIDDEN_IN_PATH.containsMatchIn(to)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
if (!seen.add(id)) return@mapNotNull null
|
||||
LinkSpec(
|
||||
id = id,
|
||||
label = label,
|
||||
to = to,
|
||||
order = link.orderOrNull(),
|
||||
// A link naming a section that does not exist is a top-level link, not
|
||||
// a dropped one: the admin's destination is still good.
|
||||
section = link.stringOrNull("section")?.takeIf { it in knownSections },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun JsonObject.stringOrNull(key: String): String? =
|
||||
(this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||
|
||||
private fun JsonObject.orderOrNull(): Double? =
|
||||
(this["order"] as? JsonPrimitive)?.takeIf { !it.isString }?.doubleOrNull?.takeIf { it.isFinite() }
|
||||
|
||||
// Two tie-breaks, the web's and phase 5's: an explicit order beats a coincidental
|
||||
// index (the admin said "first", so first), and two explicit orders keep
|
||||
// declaration order, because the sort is stable.
|
||||
private fun List<Placed>.place(): List<NavNode> =
|
||||
sortedWith(compareBy<Placed> { it.key }.thenByDescending { it.explicit }).map { it.node }
|
||||
|
||||
/**
|
||||
* The coded menu with the admin's `nav_public` applied in full: relabeled,
|
||||
* reordered and hidden as phase 5 already did, plus grouped into sections and
|
||||
* joined by added links.
|
||||
*
|
||||
* With no sections and no links this **is** phase 5 — [applyNavOverrides] answers,
|
||||
* so an untouched instance still gets [APP_MENU] back by identity and AC-1's proof
|
||||
* is unchanged (§2). The tree build only runs when the admin actually created
|
||||
* structure.
|
||||
*
|
||||
* @param base the coded menu — the only source of `route`, `access` and `feature`
|
||||
* @param navPublic the parsed `nav_public` row, or null when the admin never
|
||||
* edited the nav
|
||||
*/
|
||||
fun buildNavTree(base: List<MenuEntry>, navPublic: JsonObject?): List<NavNode> {
|
||||
val sections = readSections(sectionsOf(navPublic))
|
||||
val links = readLinks(linksOf(navPublic), sections.map { it.id }.toSet())
|
||||
if (sections.isEmpty() && links.isEmpty()) {
|
||||
return applyNavOverrides(base, navPublic).map { NavNode.Item(it) }
|
||||
}
|
||||
|
||||
val knownSections = sections.map { it.id }.toSet()
|
||||
val coded = base.map { it.route }.toSet()
|
||||
// Keyed by app route, and only for a route the coded menu declares — the same
|
||||
// narrowing as the flat merge, so an override for a path the app maps but does
|
||||
// not surface (a news category tab, a Shard hub board) is dropped here too.
|
||||
val overrides = buildMap {
|
||||
for ((path, raw) in itemsOf(navPublic)) {
|
||||
val route = appRouteForWebPath(path) ?: continue
|
||||
if (route !in coded) continue
|
||||
val override = cleanOverride(raw)
|
||||
// A section the stored value never declares is no section at all.
|
||||
val section = override.section?.takeIf { it in knownSections }
|
||||
if (!override.isEmpty || section != null) put(route, override.copy(section = section))
|
||||
}
|
||||
}
|
||||
|
||||
// The app's own surfaces (Contact, Account, the player groups, the staff rows)
|
||||
// have no website counterpart to be reordered against or grouped under, so they
|
||||
// keep their coded order after the public block — where they already sit (§6.2).
|
||||
val (mapped, appOnly) = base.partition { it.route in WEB_ROUTE_ORDER }
|
||||
|
||||
val placed = mutableListOf<Placed>()
|
||||
for (entry in mapped) {
|
||||
val override = overrides[entry.route]
|
||||
if (override?.hidden == true) continue
|
||||
placed += Placed(
|
||||
node = NavNode.Item(override?.label?.let { entry.copy(label = it) } ?: entry),
|
||||
section = override?.section,
|
||||
// An untouched row's key is its index in the WEBSITE's nav, so stored
|
||||
// and implicit keys sit on one number line (phase 5).
|
||||
key = override?.order ?: WEB_ROUTE_ORDER.getValue(entry.route).toDouble(),
|
||||
explicit = override?.order != null,
|
||||
)
|
||||
}
|
||||
// An admin-created entity with no stored order appends after the coded rows, in
|
||||
// creation order, rather than jumping to the front on a 0 default.
|
||||
var next = WEBSITE_PUBLIC_NAV.size
|
||||
for (section in sections) {
|
||||
placed += Placed(
|
||||
node = NavNode.Section(section.id, section.label, emptyList()),
|
||||
section = null,
|
||||
key = section.order ?: (next++).toDouble(),
|
||||
explicit = section.order != null,
|
||||
)
|
||||
}
|
||||
for (link in links) {
|
||||
placed += Placed(
|
||||
node = NavNode.Link(link.id, link.label, link.to, resolveWebPath(link.to)),
|
||||
section = link.section,
|
||||
key = link.order ?: (next++).toDouble(),
|
||||
explicit = link.order != null,
|
||||
)
|
||||
}
|
||||
|
||||
val top = placed.filter { it.node is NavNode.Section || it.section == null }.place()
|
||||
return top.map { node ->
|
||||
if (node !is NavNode.Section) {
|
||||
node
|
||||
} else {
|
||||
node.copy(items = placed.filter { it.section == node.id }.place())
|
||||
}
|
||||
} + appOnly.map { NavNode.Item(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The tree with this caller's gates applied — and a section they empty dropped.
|
||||
*
|
||||
* This is the boundary, and it runs **after** [buildNavTree], never before: an
|
||||
* override is presentation, so a row it relabels, moves or marks `hidden: false`
|
||||
* is still shown only if [isVisible] says so (§6.1, AC-3).
|
||||
*
|
||||
* The empty-section case is the one with real correctness risk and the reason the
|
||||
* rule is ported rather than left to the drawer: a group whose every member is
|
||||
* withheld by the caller's role or by the shard's visibility config must not draw
|
||||
* as a header with nothing under it.
|
||||
*
|
||||
* Links are not gated — see [NavNode].
|
||||
*
|
||||
* @param isVisible the caller's own predicate, applied to coded items only, so
|
||||
* this file stays ignorant of sessions and shard features
|
||||
*/
|
||||
fun pruneNav(tree: List<NavNode>, isVisible: (MenuEntry) -> Boolean): List<NavNode> {
|
||||
fun keep(node: NavNode) = node !is NavNode.Item || isVisible(node.entry)
|
||||
return tree.mapNotNull { node ->
|
||||
when (node) {
|
||||
is NavNode.Section -> node.copy(items = node.items.filter(::keep)).takeIf { it.items.isNotEmpty() }
|
||||
else -> node.takeIf { keep(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@
|
||||
|
||||
<!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
|
||||
<string name="nav_open_menu">Open navigation menu</string>
|
||||
<!-- On an admin-added link the app has no screen for; it opens in a browser (§6.3). -->
|
||||
<string name="nav_opens_in_browser">Opens in your browser</string>
|
||||
<string name="menu_home">Home</string>
|
||||
<string name="menu_news">News</string>
|
||||
<string name="menu_wiki">Wiki</string>
|
||||
|
||||
Reference in New Issue
Block a user