diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index f0caed5..d6e438e 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -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, diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt index da255b6..56a9071 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -108,14 +108,26 @@ fun visibleEntries( entries: List, session: Session, features: ShardFeatures? = null, -): List = - 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 = 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)) +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/NavOverrides.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/NavOverrides.kt index bae3d69..5ba3a89 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/NavOverrides.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/NavOverrides.kt @@ -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 { +internal fun itemsOf(navPublic: JsonObject?): Map { 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 = jsonObjectsAt(navPublic,"sections") + +internal fun linksOf(navPublic: JsonObject?): List = jsonObjectsAt(navPublic,"links") + +private fun jsonObjectsAt(navPublic: JsonObject?, key: String): List = + (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) } /** diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt index bba1a95..381d02a 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt @@ -113,9 +113,108 @@ internal val WEB_ROUTE_ORDER: Map = * 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 `/` (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 + * } /> + * } /> + * } /> + * } /> + * } /> + * } /> + * } /> + * } /> + * } /> + * } /> + * ... /site/champs, /guilds, /governors, /houses, /rules, /leaderboards, /market + * } /> + * } /> + * } /> + * } /> + * } /> + * // CMS pages: top-level /:slug, matched only after the named routes above + * } /> + * ``` + * + * Note what is *not* in it: no `/site/news/` (a news item renders on its + * category page; the newsletter's is the site's one post-detail route), no + * `/page/`, 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/ → POST (the site's one post-detail route) + * /wiki → WIKI + * /wiki/ → WIKI_PAGE + * /site/ → the mapped shard route (§6.2) + * /site/atlas/ → ATLAS_CREATURE + * /site/market/vendors/ → SHARD_MARKET_VENDOR + * /site/about → PAGE("about") + * / → PAGE(slug), unless 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 + } } diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/NavTree.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/NavTree.kt new file mode 100644 index 0000000..c889aa6 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/NavTree.kt @@ -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 +} + +/** 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): List { + val seen = mutableSetOf() + 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, knownSections: Set): List { + val seen = mutableSetOf() + 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.place(): List = + sortedWith(compareBy { 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, navPublic: JsonObject?): List { + 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() + 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, isVisible: (MenuEntry) -> Boolean): List { + 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) } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5317643..6f04826 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -37,6 +37,8 @@ Open navigation menu + + Opens in your browser Home News Wiki diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt index 1fcd70e..9c12aa3 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt @@ -208,10 +208,10 @@ class NavOverridesTest { assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label) } - @Test fun sectionsAndLinksAreIgnoredInThisPhase() { - // Phase 6 renders them. Until then their presence must not disturb the - // items merge — and an `items` map that says nothing still returns the - // coded menu even when sections exist beside it. + @Test fun sectionsAndLinksDoNotDisturbTheItemsMerge() { + // This merge is items-only; `buildNavTree` is what renders the structure + // around them (§6.3), and it leans on this staying true — an `items` map + // that says nothing still returns the coded menu itself. val stored = buildJsonObject { put("items", buildJsonObject { }) put("sections", buildJsonObject { put("id", "lore") }) diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt index 31a91d3..d4cc3a2 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt @@ -116,4 +116,65 @@ class NavPathsTest { assertEquals(Routes.SHARD, appRouteForWebPath(" /site/shard/ ")) assertEquals(Routes.HOME, appRouteForWebPath("/")) } + + // ── resolveWebPath: an added link may name any page on the site (§6.3) ── + + @Test fun theNavTablesSixteenPathsResolveTheSameWay() { + // An added link to a path the nav already knows must land where the nav row + // does, or the same destination would behave differently depending on how + // the admin reached it. + for (row in WEBSITE_PUBLIC_NAV) { + assertEquals(row.route, resolveWebPath(row.path)) + } + } + + @Test fun theSitesDetailRoutesResolve() { + // Read off website/client/src/App.jsx. Note what is NOT here: the site has + // no /site/news/ route — its one post-detail route is the newsletter's. + assertEquals(Routes.wikiPage("smithing"), resolveWebPath("/wiki/smithing")) + assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/site/atlas/dragon")) + assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/site/market/vendors/0x24C")) + assertEquals(Routes.post("newsletter", "12"), resolveWebPath("/site/newsletter/12")) + } + + @Test fun aTopLevelSlugIsACmsPage() { + // The site serves CMS pages from a top-level /, so this is the rule + // that opens an admin's own page natively rather than in a browser. + assertEquals(Routes.page("donate"), resolveWebPath("/donate")) + assertEquals(Routes.page("about"), resolveWebPath("/site/about")) + } + + @Test fun theSitesOwnSectionsAreNotCmsPages() { + // React Router ranks its static routes above /:slug, and so must the app — + // otherwise a link to the admin panel would open a 404 CMS page in-app + // instead of the real thing in a browser. + for (path in listOf("/admin", "/account", "/player", "/site", "/invite", "/preview", "/api", "/uploads")) { + assertNull(path, resolveWebPath(path)) + } + // /wiki is reserved from the catch-all but mapped by the table above it. + assertEquals(Routes.WIKI, resolveWebPath("/wiki")) + } + + @Test fun aPathTheAppHasNoScreenForHandsOff() { + assertNull(resolveWebPath("/site/status")) + assertNull(resolveWebPath("/site/shard/activity")) + assertNull(resolveWebPath("/account/login")) + assertNull(resolveWebPath("/admin/navigation")) + assertNull(resolveWebPath("/site/atlas/dragon/extra")) + } + + @Test fun aQueryOrFragmentHandsOff() { + // No app route takes either, so a native match would quietly drop what the + // admin wrote. The browser honors it exactly. + assertNull(resolveWebPath("/site/news?tag=patch")) + assertNull(resolveWebPath("/donate#tiers")) + assertEquals(Routes.NEWS, resolveWebPath("/site/news")) + } + + @Test fun aMalformedPathResolvesToNothing() { + assertNull(resolveWebPath(null)) + assertNull(resolveWebPath("")) + assertNull(resolveWebPath("/site//news")) + assertNull(resolveWebPath("https://elsewhere.example/donate")) + } } diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt new file mode 100644 index 0000000..43745ef --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt @@ -0,0 +1,455 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.runicgateway.app.ui.navigation + +import com.runicgateway.app.core.auth.Role +import com.runicgateway.app.core.auth.Session +import com.runicgateway.app.core.auth.SessionUser +import com.runicgateway.app.data.repository.ShardFeature +import com.runicgateway.app.data.repository.ShardFeatures +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Drawer sections and added links (THEMING_AND_NAV.md §6.3) — the tree build and + * the gate that prunes it. + * + * Three things these tests are really about. **AC-1**: an admin who created no + * structure gets phase 5 back untouched, and an untouched instance gets the coded + * [APP_MENU] entries themselves. **AC-3**: [pruneNav] runs after the build and + * remains the boundary — including inside a section, and including the case where + * it empties one. And the link path rule, which is what keeps "an override may + * never introduce navigation" true of a feature whose whole job is to add entries: + * a link may name any page **on this site**, and nothing else. + */ +class NavTreeTest { + + // ── Fixtures ───────────────────────────────────────────────────────── + + private fun stored( + items: JsonObject = buildJsonObject { }, + sections: List = emptyList(), + links: List = emptyList(), + ): JsonObject = buildJsonObject { + put("items", items) + put("sections", buildJsonArray { sections.forEach { add(it) } }) + put("links", buildJsonArray { links.forEach { add(it) } }) + } + + private fun items(vararg entries: Pair): JsonObject = + buildJsonObject { for ((path, entry) in entries) put(path, entry) } + + private fun item( + label: String? = null, + order: Int? = null, + hidden: Boolean? = null, + section: String? = null, + ): JsonObject = buildJsonObject { + label?.let { put("label", it) } + order?.let { put("order", it) } + hidden?.let { put("hidden", it) } + section?.let { put("section", it) } + } + + private fun section(id: String, label: String? = "Lore", order: Int? = null): JsonObject = + buildJsonObject { + put("id", id) + label?.let { put("label", it) } + order?.let { put("order", it) } + } + + private fun link( + id: String = "l1", + label: String? = "Donate", + to: String? = "/donate", + order: Int? = null, + section: String? = null, + ): JsonObject = buildJsonObject { + put("id", id) + label?.let { put("label", it) } + to?.let { put("to", it) } + order?.let { put("order", it) } + section?.let { put("section", it) } + } + + private fun tree(navPublic: JsonObject?) = buildNavTree(APP_MENU, navPublic) + + /** Top-level routes, with a section standing in as `section:`. */ + private fun List.shape(): List = map { + when (it) { + is NavNode.Item -> it.entry.route + is NavNode.Link -> "link:${it.id}" + is NavNode.Section -> "section:${it.id}" + } + } + + private fun List.section(id: String): NavNode.Section = + filterIsInstance().first { it.id == id } + + private fun List.link(id: String): NavNode.Link = + filterIsInstance().first { it.id == id } + + // ── AC-1: no structure means phase 5, unchanged ────────────────────── + + @Test fun noStoredRowIsTheCodedMenu() { + val nodes = tree(null) + + assertEquals(APP_MENU.size, nodes.size) + // The entries themselves, not copies: with nothing stored, nothing was + // rebuilt to arrive at the drawer the app shipped with. + APP_MENU.forEachIndexed { index, entry -> + assertSame(entry, (nodes[index] as NavNode.Item).entry) + } + } + + @Test fun withoutSectionsOrLinksTheBuildIsTheFlatMerge() { + // Phase 6 adds structure; it does not re-implement phase 5. An items-only + // row must give exactly what applyNavOverrides gives. + val row = stored(items = items("/site/about" to item(order = 0))) + + assertEquals( + applyNavOverrides(APP_MENU, row).map { it.route }, + tree(row).shape(), + ) + } + + @Test fun malformedSectionsAndLinksAreNotStructure() { + // Wrong kinds where the arrays should be — a hand-edited row, or the bare + // items map phases 6-8 stored. Neither is structure, so neither may cost + // the coded menu. + val row = buildJsonObject { + put("items", buildJsonObject { }) + put("sections", buildJsonObject { put("id", "lore") }) + put("links", "nope") + } + + assertEquals(APP_MENU.map { it.route }, tree(row).shape()) + } + + // ── Sections ───────────────────────────────────────────────────────── + + @Test fun aSectionCollectsItsMembersBeneathIt() { + val row = stored( + items = items( + "/wiki" to item(section = "lore"), + "/site/about" to item(section = "lore"), + ), + sections = listOf(section("lore", label = " The Realm ")), + ) + + val nodes = tree(row) + + assertTrue(Routes.WIKI !in nodes.shape()) + assertEquals("The Realm", nodes.section("lore").label) + assertEquals( + listOf(Routes.WIKI, Routes.page("about")), + nodes.section("lore").items.shape(), + ) + } + + @Test fun aSectionWithNoOrderAppendsAfterTheCodedRows() { + // An admin-created entity with no stored order appends in creation order + // rather than jumping to the front on a 0 default. The app's own rows stay + // behind it, where they already sit (§6.2). + val row = stored( + items = items("/wiki" to item(section = "lore")), + sections = listOf(section("lore")), + ) + + val shape = tree(row).shape() + + // Eight public rows are left at the top level (Wiki moved into the section), + // then the section, then the app's own rows. + assertEquals("section:lore", shape[8]) + assertEquals(Routes.CONTACT, shape[9]) + } + + @Test fun aSectionsOrderPlacesItAmongTheCodedRows() { + // Sections sort on the same number line as everything else: the website's + // sixteen indices, then admin-created entities after them. + val row = stored( + items = items("/wiki" to item(section = "lore")), + sections = listOf(section("lore", order = 0)), + ) + + assertEquals("section:lore", tree(row).shape().first()) + } + + @Test fun aSectionWithoutAUsableLabelIsDroppedAndItsMembersStayPut() { + val row = stored( + items = items("/wiki" to item(section = "lore")), + sections = listOf(section("lore", label = " ")), + ) + + val nodes = tree(row) + + assertTrue(nodes.filterIsInstance().isEmpty()) + // The section never existed, so the reference to it is dangling and the row + // is an ordinary top-level one — not a row that vanished with its section. + assertTrue(Routes.WIKI in nodes.shape()) + } + + @Test fun aRepeatedSectionIdKeepsTheFirst() { + val row = stored( + items = items("/wiki" to item(section = "lore")), + sections = listOf(section("lore", label = "First"), section("lore", label = "Second")), + ) + + val sections = tree(row).filterIsInstance() + + assertEquals(1, sections.size) + assertEquals("First", sections.single().label) + } + + @Test fun anItemNamingAnUnknownSectionStaysTopLevel() { + val row = stored( + items = items("/wiki" to item(section = "nope")), + sections = listOf(section("lore")), + ) + + val nodes = tree(row) + + assertTrue(Routes.WIKI in nodes.shape()) + assertTrue(nodes.section("lore").items.isEmpty()) + } + + @Test fun aHiddenItemIsDroppedEvenInsideASection() { + val row = stored( + items = items("/wiki" to item(hidden = true, section = "lore")), + sections = listOf(section("lore")), + ) + + val nodes = tree(row) + + assertTrue(Routes.WIKI !in nodes.shape()) + assertTrue(nodes.section("lore").items.isEmpty()) + } + + @Test fun aLabelStillLandsOnASectionedRow() { + val row = stored( + items = items("/wiki" to item(label = "Codex", section = "lore")), + sections = listOf(section("lore")), + ) + + val wiki = tree(row).section("lore").items.filterIsInstance().single() + + assertEquals("Codex", wiki.entry.label) + // The override lands on the label and nothing else — the gates are untouched. + assertEquals(MenuAccess.PUBLIC, wiki.entry.access) + assertNull(wiki.entry.feature) + } + + @Test fun aSectionRequestForARowTheDrawerDoesNotSurfaceIsIgnored() { + // Same rule as phase 5's: the app puts the hub boards behind the Shard hub + // deliberately, and grouping is no more an invitation to surface one than + // relabelling was (§6.2). + val row = stored( + items = items("/site/champs" to item(section = "lore", label = "Champs")), + sections = listOf(section("lore")), + ) + + val nodes = tree(row) + + assertTrue(nodes.section("lore").items.isEmpty()) + assertTrue(Routes.SHARD_CHAMPS !in nodes.shape()) + } + + // ── Added links ────────────────────────────────────────────────────── + + @Test fun aLinkTheAppCanResolveCarriesItsRoute() { + val row = stored(links = listOf(link(to = "/wiki/smithing"))) + + assertEquals(Routes.wikiPage("smithing"), tree(row).link("l1").route) + } + + @Test fun aLinkTheAppCannotResolveHandsOff() { + // A null route is the Custom Tab; the path is kept verbatim so the browser + // gets exactly what the admin wrote. + val row = stored(links = listOf(link(to = "/site/status"))) + + val node = tree(row).link("l1") + + assertNull(node.route) + assertEquals("/site/status", node.path) + } + + @Test fun aLinkThatWouldLeaveTheOriginIsDropped() { + // The website's own read rule, ported: a stored value that is not a + // single-slash site path is dropped rather than rendered, so a hand-edited + // row cannot put an off-site link in the drawer. + val bad = listOf( + "//evil.example/x", "https://evil.example", "donate", "/don ate", + "/don\"ate", "/don'ate", "/don().isEmpty()) + } + } + + @Test fun aLinkWithoutAnIdLabelOrPathIsDropped() { + val row = stored( + links = listOf( + buildJsonObject { + put("label", "No id") + put("to", "/a") + }, + link(id = "no-label", label = null), + link(id = "no-to", to = null), + link(id = "blank-label", label = " "), + link(id = "good"), + ), + ) + + assertEquals(listOf("good"), tree(row).filterIsInstance().map { it.id }) + } + + @Test fun aRepeatedLinkIdKeepsTheFirst() { + val row = stored(links = listOf(link(id = "l1", label = "First"), link(id = "l1", label = "Second"))) + + assertEquals("First", tree(row).link("l1").label) + } + + @Test fun linksAppendAfterTheCodedRowsInCreationOrder() { + val row = stored(links = listOf(link(id = "a"), link(id = "b"))) + + val shape = tree(row).shape() + + assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") }) + assertEquals(Routes.page("about"), shape[shape.indexOf("link:a") - 1]) + } + + @Test fun aLinksOrderPlacesItAmongTheCodedRows() { + val row = stored(links = listOf(link(order = 0))) + + assertEquals("link:l1", tree(row).shape().first()) + } + + @Test fun aLinkCanSitInsideASection() { + val row = stored( + items = items("/wiki" to item(section = "lore")), + sections = listOf(section("lore")), + links = listOf(link(section = "lore"), link(id = "top")), + ) + + val nodes = tree(row) + + assertEquals(listOf(Routes.WIKI, "link:l1"), nodes.section("lore").items.shape()) + assertTrue("link:top" in nodes.shape()) + } + + @Test fun aLinkNamingAnUnknownSectionStaysTopLevel() { + // Its destination is still good; only the grouping was wrong. + val row = stored(links = listOf(link(section = "nope"))) + + assertTrue("link:l1" in tree(row).shape()) + } + + // ── AC-3: the gates run after the build, and empty a section honestly ── + + private val admin = Session.SignedIn(SessionUser(id = 1, username = "u", role = Role.ADMIN)) + + private fun prune(nodes: List, session: Session, features: ShardFeatures?) = + pruneNav(nodes) { isEntryVisible(it, session, features) } + + @Test fun aSectionEmptiedByTheGatesIsDropped() { + // The case the rule exists for: a group whose every member is withheld by + // the shard's visibility config must not draw as a header over nothing. + val row = stored( + items = items("/site/market" to item(section = "lore")), + sections = listOf(section("lore")), + ) + + val pruned = prune( + tree(row), + admin, + ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)), + ) + + assertTrue(pruned.filterIsInstance().isEmpty()) + } + + @Test fun aSectionKeepsTheMembersThisCallerMaySee() { + val row = stored( + items = items( + "/site/market" to item(section = "lore"), + "/wiki" to item(section = "lore"), + ), + sections = listOf(section("lore")), + ) + + val pruned = prune( + tree(row), + admin, + ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)), + ) + + assertEquals(listOf(Routes.WIKI), pruned.section("lore").items.shape()) + } + + @Test fun anOverrideCannotUnhideAGatedRowByGroupingIt() { + // Relabelled, moved to the front, marked `hidden: false` and tucked into a + // section of its own — and still not shown, because the shard does not + // publish the market and an admin does not outrank that. + val row = stored( + items = items("/site/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")), + sections = listOf(section("lore", order = 0)), + ) + + val pruned = prune( + tree(row), + admin, + ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS)), + ) + + assertTrue(pruned.filterIsInstance().isEmpty()) + assertTrue(pruned.none { it is NavNode.Item && it.entry.route == Routes.SHARD_MARKET }) + } + + @Test fun aSectionSurvivesOnALinkAlone() { + // Links carry no gate — the page behind one enforces its own access — so a + // section holding one is never emptied by the caller's role. + val row = stored( + items = items("/site/market" to item(section = "lore")), + sections = listOf(section("lore")), + links = listOf(link(section = "lore")), + ) + + val pruned = prune(tree(row), Session.SignedOut, ShardFeatures(level = "anonymous", visible = emptySet())) + + assertEquals(listOf("link:l1"), pruned.section("lore").items.shape()) + } + + @Test fun theAppsOwnRowsAreStillGatedInTheTree() { + val row = stored( + items = items("/wiki" to item(section = "lore")), + sections = listOf(section("lore")), + ) + + val shape = prune(tree(row), Session.SignedOut, features = null).shape() + + assertTrue(Routes.ACCOUNT !in shape) + assertTrue(Routes.ADMIN_DASHBOARD !in shape) + assertTrue(Routes.PLAYER_CHARACTERS !in shape) + assertTrue(Routes.CONTACT in shape) + } + + @Test fun pruningAnUntouchedTreeIsTheCodedMenusVisibleEntries() { + // The two paths through the drawer have to agree: prune(tree) for a caller + // is exactly visibleEntries of the coded menu for that caller. + val features = ShardFeatures(level = "admin", visible = setOf(ShardFeature.STATUS, ShardFeature.MARKET)) + + assertEquals( + visibleEntries(APP_MENU, admin, features).map { it.route }, + prune(tree(null), admin, features).shape(), + ) + } +}