feat(nav): group the drawer into the shard's sections and honor its added links (M12 phase 6) #39
@@ -7,10 +7,12 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
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.material.icons.filled.Menu
|
||||||
import androidx.compose.material3.DrawerValue
|
import androidx.compose.material3.DrawerValue
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -21,6 +23,7 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.ModalDrawerSheet
|
import androidx.compose.material3.ModalDrawerSheet
|
||||||
import androidx.compose.material3.ModalNavigationDrawer
|
import androidx.compose.material3.ModalNavigationDrawer
|
||||||
import androidx.compose.material3.NavigationDrawerItem
|
import androidx.compose.material3.NavigationDrawerItem
|
||||||
|
import androidx.compose.material3.NavigationDrawerItemColors
|
||||||
import androidx.compose.material3.NavigationDrawerItemDefaults
|
import androidx.compose.material3.NavigationDrawerItemDefaults
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
@@ -32,6 +35,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
@@ -48,6 +52,7 @@ import androidx.navigation.compose.rememberNavController
|
|||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.runicgateway.app.R
|
import com.runicgateway.app.R
|
||||||
import com.runicgateway.app.core.auth.Session
|
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.api.dto.BrandDto
|
||||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||||
import com.runicgateway.app.ui.auth.AccountScreen
|
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.contact.ContactScreen
|
||||||
import com.runicgateway.app.ui.home.HomeScreen
|
import com.runicgateway.app.ui.home.HomeScreen
|
||||||
import com.runicgateway.app.ui.navigation.APP_MENU
|
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.Routes
|
||||||
import com.runicgateway.app.ui.navigation.applyNavOverrides
|
import com.runicgateway.app.ui.navigation.buildNavTree
|
||||||
import com.runicgateway.app.ui.navigation.visibleEntries
|
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.NewsScreen
|
||||||
import com.runicgateway.app.ui.news.PostScreen
|
import com.runicgateway.app.ui.news.PostScreen
|
||||||
import com.runicgateway.app.ui.admin.AdminContentScreen
|
import com.runicgateway.app.ui.admin.AdminContentScreen
|
||||||
@@ -150,14 +157,32 @@ fun RunicApp(
|
|||||||
val currentRoute = backStackEntry?.destination?.route?.substringBefore('?')
|
val currentRoute = backStackEntry?.destination?.route?.substringBefore('?')
|
||||||
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
val isTopLevel = currentRoute in TOP_LEVEL_ROUTES
|
||||||
// The admin's nav overrides, then the gates — never the other way round. An
|
// 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
|
// override is presentation only: it may relabel, reorder, group and hide, so
|
||||||
// `visibleEntries` still decides what this caller may see and remains the
|
// `pruneNav` still decides what this caller may see and remains the boundary
|
||||||
// boundary (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
|
// (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
|
||||||
val entries = visibleEntries(
|
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
|
||||||
applyNavOverrides(APP_MENU, appearance.navPublic),
|
isEntryVisible(it, session, shardFeatures)
|
||||||
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(
|
ModalNavigationDrawer(
|
||||||
drawerState = drawerState,
|
drawerState = drawerState,
|
||||||
@@ -191,19 +216,30 @@ fun RunicApp(
|
|||||||
)
|
)
|
||||||
HorizontalDivider()
|
HorizontalDivider()
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
entries.forEach { entry ->
|
nav.forEach { node ->
|
||||||
NavigationDrawerItem(
|
if (node is NavNode.Section) {
|
||||||
// An admin's label wins over the bundled one, and is the
|
// A group the admin created: its label as a header, its rows
|
||||||
// same string in every locale — see MenuEntry.label.
|
// beneath it. Always open — a drawer is already a vertical
|
||||||
label = { Text(entry.label ?: stringResource(entry.labelRes)) },
|
// list, so the website's dropdown does not translate (§6.3).
|
||||||
selected = currentRoute == entry.route,
|
Text(
|
||||||
onClick = {
|
text = node.label,
|
||||||
scope.launch { drawerState.close() }
|
style = MaterialTheme.typography.labelLarge,
|
||||||
navController.navigateTopLevel(entry.route)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
},
|
modifier = Modifier.padding(
|
||||||
colors = drawerItemColors,
|
start = 28.dp,
|
||||||
modifier = Modifier.padding(NavigationDrawerItemDefaults.ItemPadding),
|
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))
|
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
|
@Composable
|
||||||
private fun RunicNavHost(
|
private fun RunicNavHost(
|
||||||
navController: NavHostController,
|
navController: NavHostController,
|
||||||
|
|||||||
@@ -108,14 +108,26 @@ fun visibleEntries(
|
|||||||
entries: List<MenuEntry>,
|
entries: List<MenuEntry>,
|
||||||
session: Session,
|
session: Session,
|
||||||
features: ShardFeatures? = null,
|
features: ShardFeatures? = null,
|
||||||
): List<MenuEntry> =
|
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features) }
|
||||||
entries.filter { entry ->
|
|
||||||
val allowedByRole = when (entry.access) {
|
/**
|
||||||
MenuAccess.PUBLIC -> true
|
* [visibleEntries] for a single entry — the same two filters, and the same
|
||||||
MenuAccess.SIGNED_IN -> session is Session.SignedIn
|
* boundary. Split out because the drawer is a tree once an admin groups rows into
|
||||||
MenuAccess.PLAYER -> session is Session.SignedIn && (session.user.isPlayer || session.user.isStaff)
|
* sections (§6.3): [pruneNav] applies this predicate inside a section as well, and
|
||||||
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
* both callers must ask exactly one question or a sectioned row could be gated by
|
||||||
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
* a rule its top-level twin is not.
|
||||||
}
|
*/
|
||||||
allowedByRole && (entry.feature == null || canSee(features, entry.feature))
|
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
|
package com.runicgateway.app.ui.navigation
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.booleanOrNull
|
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". */
|
/** 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 label: String? = null,
|
||||||
val order: Double? = null,
|
val order: Double? = null,
|
||||||
val hidden: Boolean = false,
|
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
|
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,
|
* bare map of path → override. A bare map is unambiguous — every key is a path,
|
||||||
* so a key can never be the string `items`.
|
* 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()
|
if (navPublic == null) return emptyMap()
|
||||||
val wrapped = navPublic["items"] as? JsonObject
|
val items = wrapperOf(navPublic)?.get("items") as? JsonObject ?: navPublic
|
||||||
val items = wrapped ?: navPublic
|
|
||||||
return items.entries
|
return items.entries
|
||||||
.mapNotNull { (key, value) -> (value as? JsonObject)?.let { key to it } }
|
.mapNotNull { (key, value) -> (value as? JsonObject)?.let { key to it } }
|
||||||
.toMap()
|
.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
|
// Field by field, like every other read in M12: a bad `label` must not discard a
|
||||||
// good `order` beside it.
|
// good `order` beside it.
|
||||||
//
|
//
|
||||||
// `group` and `section` are ignored. The app renders no sections in this phase
|
// `group` is ignored — it names a section of the *admin sidebar*, a nav the app
|
||||||
// (phase 6) and never renders the admin sidebar's groups at all, and a value it
|
// never renders, and a value it cannot honor is better dropped than half-applied.
|
||||||
// cannot honor is better dropped than half-applied.
|
internal fun cleanOverride(raw: JsonObject): NavOverride {
|
||||||
private fun cleanOverride(raw: JsonObject): NavOverride {
|
|
||||||
val label = (raw["label"] as? JsonPrimitive)
|
val label = (raw["label"] as? JsonPrimitive)
|
||||||
?.takeIf { it.isString }
|
?.takeIf { it.isString }
|
||||||
?.content
|
?.content
|
||||||
@@ -72,7 +103,11 @@ private fun cleanOverride(raw: JsonObject): NavOverride {
|
|||||||
val hidden = (raw["hidden"] as? JsonPrimitive)
|
val hidden = (raw["hidden"] as? JsonPrimitive)
|
||||||
?.takeIf { !it.isString }
|
?.takeIf { !it.isString }
|
||||||
?.booleanOrNull == true
|
?.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
|
* it. A trailing slash is tolerated (`/wiki/` is `/wiki`) since a hand-edited
|
||||||
* settings row may carry one; the root path is left alone.
|
* 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()
|
val trimmed = path?.trim().orEmpty()
|
||||||
if (trimmed.isEmpty()) return null
|
if (trimmed.isEmpty()) return null
|
||||||
val normalized = if (trimmed.length > 1) trimmed.trimEnd('/') else trimmed
|
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) ────────────────────────────────────────── -->
|
<!-- ── Navigation menu (§5) ────────────────────────────────────────── -->
|
||||||
<string name="nav_open_menu">Open navigation menu</string>
|
<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_home">Home</string>
|
||||||
<string name="menu_news">News</string>
|
<string name="menu_news">News</string>
|
||||||
<string name="menu_wiki">Wiki</string>
|
<string name="menu_wiki">Wiki</string>
|
||||||
|
|||||||
@@ -208,10 +208,10 @@ class NavOverridesTest {
|
|||||||
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
|
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun sectionsAndLinksAreIgnoredInThisPhase() {
|
@Test fun sectionsAndLinksDoNotDisturbTheItemsMerge() {
|
||||||
// Phase 6 renders them. Until then their presence must not disturb the
|
// This merge is items-only; `buildNavTree` is what renders the structure
|
||||||
// items merge — and an `items` map that says nothing still returns the
|
// around them (§6.3), and it leans on this staying true — an `items` map
|
||||||
// coded menu even when sections exist beside it.
|
// that says nothing still returns the coded menu itself.
|
||||||
val stored = buildJsonObject {
|
val stored = buildJsonObject {
|
||||||
put("items", buildJsonObject { })
|
put("items", buildJsonObject { })
|
||||||
put("sections", buildJsonObject { put("id", "lore") })
|
put("sections", buildJsonObject { put("id", "lore") })
|
||||||
|
|||||||
@@ -116,4 +116,65 @@ class NavPathsTest {
|
|||||||
assertEquals(Routes.SHARD, appRouteForWebPath(" /site/shard/ "))
|
assertEquals(Routes.SHARD, appRouteForWebPath(" /site/shard/ "))
|
||||||
assertEquals(Routes.HOME, appRouteForWebPath("/"))
|
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/<id> 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 /<slug>, 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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<JsonObject> = emptyList(),
|
||||||
|
links: List<JsonObject> = 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<String, JsonObject>): 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:<id>`. */
|
||||||
|
private fun List<NavNode>.shape(): List<String> = map {
|
||||||
|
when (it) {
|
||||||
|
is NavNode.Item -> it.entry.route
|
||||||
|
is NavNode.Link -> "link:${it.id}"
|
||||||
|
is NavNode.Section -> "section:${it.id}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun List<NavNode>.section(id: String): NavNode.Section =
|
||||||
|
filterIsInstance<NavNode.Section>().first { it.id == id }
|
||||||
|
|
||||||
|
private fun List<NavNode>.link(id: String): NavNode.Link =
|
||||||
|
filterIsInstance<NavNode.Link>().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<NavNode.Section>().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<NavNode.Section>()
|
||||||
|
|
||||||
|
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<NavNode.Item>().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<ate", "/don\\ate",
|
||||||
|
)
|
||||||
|
|
||||||
|
for (to in bad) {
|
||||||
|
assertTrue(to, tree(stored(links = listOf(link(to = to)))).filterIsInstance<NavNode.Link>().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<NavNode.Link>().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<NavNode>, 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<NavNode.Section>().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<NavNode.Section>().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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user