feat(events): the app's events screens, and the module rows that were never gated (Phase 14b)
All checks were successful
PR Checks / android-build (pull_request) Successful in 12m7s
All checks were successful
PR Checks / android-build (pull_request) Successful in 12m7s
Events Phase 14b, the app half — recorded as M13 in docs/android/PLAN.md. Four screens on the four routes Phase 14a shipped: the public calendar, an event page carrying `?run=`, an arc, and participation history. One drawer row for the history, at SIGNED_IN rather than PLAYER: the route is `requireAuth` alone and self-scoped, and the website needed two mounts for it only because `RequirePlayer` guards `/account` there. The prerequisite fix is the larger half. The app read `/public/modules` nowhere and mapped every `/public/shard/features` failure to "unknown", which `canSee` treats as visible — so on a site with no `uo` module every shard row rendered and every one of them 404'd. Absence of an answer is not an answer of absence: a successful module list that omits `shard` hides the rows, a failed read keeps the last answer the host gave, and a host that has never answered leaves the gate open. Capability and feature compose as two gates and answer different questions: whether the module is installed (per host) and whether this shard publishes the surface to this viewer (per viewer). Also corrects the website path → route table, wrong since the module-system cutover on 2026-08-12: core's NAV is eight rows, not sixteen, and the nine shard rows moved to `/uo/*`. A nav override on any shard row was ignored, an added link to one handed off to a browser, and the sort-key line was wrong. Two existing tests had been passing vacuously since that day. An inbox link to an event now opens the app rather than a Custom Tab, through `resolveWebPath` rather than a second mechanism — so its "a query hands off" rule gains exactly one exception, `run` on an event page. The emulator walk found three defects that 563 green tests did not: - the three player game-data rows read `/player/shard/*` and were not gated, so they rendered and 404'd; the test meant to catch that asked whether every row *with a feature* declared the capability, and those three have none. It now asks by route. - `score` is DECIMAL(18,4) and was declared an integer, so one `318.5` made kotlinx refuse the entire body and a 200 rendered as a server error — latent on the public results table for every visitor. - a drawer route's view model outlives a sign-out, so signing in as a second account showed it the first account's participation history with no request made at all. 570 tests, 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
This commit is contained in:
41
app/src/main/java/com/runicgateway/app/core/time/Instants.kt
Normal file
41
app/src/main/java/com/runicgateway/app/core/time/Instants.kt
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.core.time
|
||||||
|
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a timestamp off the wire, in either shape the backend sends.
|
||||||
|
*
|
||||||
|
* **Which one arrives is not the app's to decide.** Express serializes a `Date`
|
||||||
|
* to ISO-8601 with a `Z`, but these values start life as MariaDB `DATETIME`
|
||||||
|
* columns, and one read back as a string reaches the wire as
|
||||||
|
* `2026-08-31 07:13:50` with no zone at all. A zoneless stamp is read as **UTC**,
|
||||||
|
* because that is what the server stores — reading it as local time would
|
||||||
|
* silently shift every timestamp by the device's offset, which is a bug that
|
||||||
|
* looks right on the machine it was written on.
|
||||||
|
*
|
||||||
|
* Anything unparseable answers null, and every caller is expected to render
|
||||||
|
* *something* without it: a notification with an odd date is still worth reading,
|
||||||
|
* and an event with one is still worth listing.
|
||||||
|
*
|
||||||
|
* Lives here rather than beside either caller because the trap is the wire's, not
|
||||||
|
* one screen's — the inbox found it (ENGAGEMENT.md phase 8) and the event screens
|
||||||
|
* inherit it (EVENTS.md §I).
|
||||||
|
*/
|
||||||
|
fun parseWireInstant(raw: String?): Instant? {
|
||||||
|
val text = raw?.trim().orEmpty()
|
||||||
|
if (text.isEmpty()) return null
|
||||||
|
return try {
|
||||||
|
Instant.parse(text)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
try {
|
||||||
|
LocalDateTime.parse(text.replace(' ', 'T')).atZone(ZoneId.of("UTC")).toInstant()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
84
app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt
Normal file
84
app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventHistoryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventSeriesResponse
|
||||||
|
import com.runicgateway.app.data.api.dto.PublicEventResponse
|
||||||
|
import retrofit2.http.GET
|
||||||
|
import retrofit2.http.Path
|
||||||
|
import retrofit2.http.Query
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event surface (PLAN.md §9 M13, `docs/website/EVENTS.md` § API surface).
|
||||||
|
*
|
||||||
|
* **These are CORE routes, not a module's**, which is why they live here rather
|
||||||
|
* than beside the shard reads in [PublicApi]: they exist on a backend
|
||||||
|
* with no game module installed at all, and they are gated by core's own `events`
|
||||||
|
* capability rather than by a module's. Nothing here is under `/shard`.
|
||||||
|
*
|
||||||
|
* The three public reads and the one player read share an interface for the same
|
||||||
|
* reason the website mounts them in one feature: the history row's whole purpose
|
||||||
|
* is to link back to the public page. The player call carries a bearer through
|
||||||
|
* [com.runicgateway.app.core.net.AuthInterceptor] like every other authenticated
|
||||||
|
* call; there is one Retrofit.
|
||||||
|
*/
|
||||||
|
interface EventsApi {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public calendar. Defaults to now through 31 days out when neither end
|
||||||
|
* is named; the window may span at most 92 days and the server 400s past it.
|
||||||
|
*
|
||||||
|
* Rehearsals and unlisted events are absent — that filtering is in SQL, not
|
||||||
|
* in the answer, so there is nothing here to re-check.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/events")
|
||||||
|
suspend fun getCalendar(
|
||||||
|
@Query("from") from: String? = null,
|
||||||
|
@Query("to") to: String? = null,
|
||||||
|
@Query("seriesId") seriesId: Long? = null,
|
||||||
|
): EventCalendarDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event.
|
||||||
|
*
|
||||||
|
* **[run] selects which occurrence the results table is about**, and is what
|
||||||
|
* an announcement's link carries: the page lives at the definition's slug, so
|
||||||
|
* a weekly event has one address that survives a retitle, while every
|
||||||
|
* `event.` trigger is about one occurrence. A run belonging to some other
|
||||||
|
* event is ignored rather than refused, so a stale link in a months-old mail
|
||||||
|
* still opens the page it was about.
|
||||||
|
*
|
||||||
|
* A draft, an archived definition and an unlisted one all answer 404,
|
||||||
|
* indistinguishable from a slug that never existed.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/events/{slug}")
|
||||||
|
suspend fun getEvent(
|
||||||
|
@Path("slug") slug: String,
|
||||||
|
@Query("run") run: String? = null,
|
||||||
|
): PublicEventResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One arc. A series with no listed events answers 404 rather than an empty
|
||||||
|
* page — an arc is a label on its definitions, so a page for an empty one
|
||||||
|
* would publish that an operator has named something they have not announced.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/events/series/{slug}")
|
||||||
|
suspend fun getSeries(@Path("slug") slug: String): EventSeriesResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller's own participation history. Self-scoped on the session's user
|
||||||
|
* id server-side; there is deliberately no id parameter here, because there
|
||||||
|
* is none on the route.
|
||||||
|
*
|
||||||
|
* [before] is a participation row id, not an offset — the list gains rows at
|
||||||
|
* the top as the reader attends things.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/player/events/history")
|
||||||
|
suspend fun getHistory(
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
@Query("before") before: Long? = null,
|
||||||
|
): EventHistoryDto
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import com.runicgateway.app.data.api.dto.HouseDto
|
|||||||
import com.runicgateway.app.data.api.dto.MarketMetaDto
|
import com.runicgateway.app.data.api.dto.MarketMetaDto
|
||||||
import com.runicgateway.app.data.api.dto.MarketPageDto
|
import com.runicgateway.app.data.api.dto.MarketPageDto
|
||||||
import com.runicgateway.app.data.api.dto.MarketVendorDto
|
import com.runicgateway.app.data.api.dto.MarketVendorDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ModulesDto
|
||||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||||
import com.runicgateway.app.data.api.dto.PageDto
|
import com.runicgateway.app.data.api.dto.PageDto
|
||||||
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||||
@@ -67,6 +68,18 @@ interface PublicApi {
|
|||||||
@GET("api/v1/public/settings")
|
@GET("api/v1/public/settings")
|
||||||
suspend fun getSettings(): SettingsDto
|
suspend fun getSettings(): SettingsDto
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which modules this backend is serving, and the capabilities each declares
|
||||||
|
* (§5, M13). Read together with the `version` block's own `capabilities` —
|
||||||
|
* core's list and a module's are separate lists on purpose.
|
||||||
|
*
|
||||||
|
* This is what lets the app tell a module that is **not installed** from a
|
||||||
|
* lookup that failed: `/public/shard/features` 404s in both cases, and only
|
||||||
|
* this call distinguishes them.
|
||||||
|
*/
|
||||||
|
@GET("api/v1/public/modules")
|
||||||
|
suspend fun getModules(): ModulesDto
|
||||||
|
|
||||||
// ── News & content ───────────────────────────────────────────────────
|
// ── News & content ───────────────────────────────────────────────────
|
||||||
@GET("api/v1/public/posts/{category}")
|
@GET("api/v1/public/posts/{category}")
|
||||||
suspend fun getPosts(@Path("category") category: String): List<PostDto>
|
suspend fun getPosts(@Path("category") category: String): List<PostDto>
|
||||||
|
|||||||
212
app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt
Normal file
212
app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.dto
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire shapes for the public event surface (`docs/website/EVENTS.md` §I, events
|
||||||
|
* Phase 14a; the app's half is M13). Field names match
|
||||||
|
* `server/src/model/events/eventPublic.model.js` exactly.
|
||||||
|
*
|
||||||
|
* **That model is a PROJECTION, and these DTOs must not out-grow it.** Nothing on
|
||||||
|
* the server side is spread into a public entry — a field reaches one because a
|
||||||
|
* line put it there — so three things are absent from every shape below and each
|
||||||
|
* absence is a decision core made: the **spec** (phases, steps, actions and their
|
||||||
|
* params are the operator's plan for changing a live world; a visitor gets the
|
||||||
|
* phase LABEL while a run is live and nothing else), **health, cleanup, claims
|
||||||
|
* and errors** (facts about the deployment's plumbing, not about the event), and
|
||||||
|
* **`member_key`** (module-opaque, so core cannot say what publishing one would
|
||||||
|
* disclose). Adding a field here that the server does not send would decode to a
|
||||||
|
* default and render as a fact.
|
||||||
|
*
|
||||||
|
* Every DTO ignores unknown keys (NetworkModule's lenient Json), so an additive
|
||||||
|
* backend field is safe.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One calendar entry. [kind] is `run` or `projected` and the two are drawn
|
||||||
|
* differently on purpose.
|
||||||
|
*
|
||||||
|
* A **run** is a materialised occurrence: a row exists, it can be cancelled, and
|
||||||
|
* what it says is committed to. A **projected** entry is arithmetic past the
|
||||||
|
* materialisation horizon — a forecast with nothing behind it — so the screen
|
||||||
|
* labels it rather than drawing it as a booking. [adjusted] and [shiftMinutes]
|
||||||
|
* only ever arrive on a projection, and say a DST shift moved it.
|
||||||
|
*
|
||||||
|
* [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never
|
||||||
|
* the reader's. See [com.runicgateway.app.ui.events.eventTime].
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EventCalendarEntryDto(
|
||||||
|
val kind: String = "run",
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val seriesName: String? = null,
|
||||||
|
val seriesSlug: String? = null,
|
||||||
|
val scheduledFor: String = "",
|
||||||
|
val timezone: String? = null,
|
||||||
|
val status: String = "scheduled",
|
||||||
|
val live: Boolean = false,
|
||||||
|
val adjusted: Boolean = false,
|
||||||
|
val shiftMinutes: Int = 0,
|
||||||
|
) {
|
||||||
|
/** True for a forecast the server has committed nothing to. */
|
||||||
|
val isProjected: Boolean get() = kind == "projected"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `GET /public/events` — the calendar for a window, ascending by instant. */
|
||||||
|
@Serializable
|
||||||
|
data class EventCalendarDto(
|
||||||
|
val entries: List<EventCalendarEntryDto> = emptyList(),
|
||||||
|
/** True when the server capped the answer; the screen says so rather than lying by omission. */
|
||||||
|
val truncated: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One occurrence on an event's page.
|
||||||
|
*
|
||||||
|
* [phase] is the label of the phase a live run is in, resolved from the version
|
||||||
|
* that run PINNED — so an edit since does not relabel a run in flight. It is null
|
||||||
|
* on anything that is not live, which is why the screen only ever shows it there.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EventOccurrenceDto(
|
||||||
|
val runId: Long = 0,
|
||||||
|
val scheduledFor: String = "",
|
||||||
|
val timezone: String? = null,
|
||||||
|
val startedAt: String? = null,
|
||||||
|
val endedAt: String? = null,
|
||||||
|
val status: String = "scheduled",
|
||||||
|
val live: Boolean = false,
|
||||||
|
val scope: String? = null,
|
||||||
|
val phase: String? = null,
|
||||||
|
val resultsPublishedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of a published results table.
|
||||||
|
*
|
||||||
|
* [name] is whatever the module put in its participation `meta`, and there is
|
||||||
|
* genuinely nothing else to render when it is absent: core has no name for a
|
||||||
|
* character and the member key is not published, so the screen says "Unnamed"
|
||||||
|
* rather than inventing one.
|
||||||
|
*
|
||||||
|
* **[score] is fractional, and it has to be.** `event_run_participants.score` is
|
||||||
|
* `DECIMAL(18,4)`, and a module scoring by distance, time or a weighted tally
|
||||||
|
* writes a fraction — the live walk found `318.5` in the first row it read.
|
||||||
|
* Declaring it `Long` does not merely round: kotlinx REFUSES the body, the whole
|
||||||
|
* response fails to decode, and the screen reports a server error for a `200`.
|
||||||
|
* See [com.runicgateway.app.ui.events.scoreText] for how it is rendered.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EventParticipantDto(
|
||||||
|
val name: String? = null,
|
||||||
|
val score: Double = 0.0,
|
||||||
|
val rank: Int? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The results table for ONE occurrence, present only once it has been published. */
|
||||||
|
@Serializable
|
||||||
|
data class EventResultsDto(
|
||||||
|
val runId: Long = 0,
|
||||||
|
val scheduledFor: String = "",
|
||||||
|
val publishedAt: String? = null,
|
||||||
|
val participants: List<EventParticipantDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The arc an event belongs to, as its own page names it. */
|
||||||
|
@Serializable
|
||||||
|
data class EventSeriesRefDto(
|
||||||
|
val name: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/events/:slug` — the event. */
|
||||||
|
@Serializable
|
||||||
|
data class PublicEventDto(
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val summary: String? = null,
|
||||||
|
/** Sanitized HTML, written the way a wiki page and a forum post are. */
|
||||||
|
val body: String? = null,
|
||||||
|
val imageUrl: String? = null,
|
||||||
|
val timezone: String? = null,
|
||||||
|
val series: EventSeriesRefDto? = null,
|
||||||
|
val live: Boolean = false,
|
||||||
|
val current: EventOccurrenceDto? = null,
|
||||||
|
/**
|
||||||
|
* The next occurrence — **narrower than the first of [upcoming]**, and the
|
||||||
|
* server decides which. A cancelled occurrence still appears under what is
|
||||||
|
* coming, because "next Friday is off" is what somebody checking a calendar
|
||||||
|
* came to find out; it is not what "next" means.
|
||||||
|
*/
|
||||||
|
val next: EventOccurrenceDto? = null,
|
||||||
|
val upcoming: List<EventOccurrenceDto> = emptyList(),
|
||||||
|
val past: List<EventOccurrenceDto> = emptyList(),
|
||||||
|
val results: EventResultsDto? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The envelope `GET /public/events/:slug` answers with. */
|
||||||
|
@Serializable
|
||||||
|
data class PublicEventResponse(val event: PublicEventDto = PublicEventDto())
|
||||||
|
|
||||||
|
/** One event as an arc lists it — the editor's order, so no dates. */
|
||||||
|
@Serializable
|
||||||
|
data class EventSeriesEntryDto(
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val summary: String? = null,
|
||||||
|
val imageUrl: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /public/events/series/:slug` — one arc and the listed events in it. */
|
||||||
|
@Serializable
|
||||||
|
data class EventSeriesDto(
|
||||||
|
val name: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val description: String? = null,
|
||||||
|
val events: List<EventSeriesEntryDto> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/** The envelope `GET /public/events/series/:slug` answers with. */
|
||||||
|
@Serializable
|
||||||
|
data class EventSeriesResponse(val series: EventSeriesDto = EventSeriesDto())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the caller's own participation history.
|
||||||
|
*
|
||||||
|
* [rank] is null until `core.results.publish` ran for that occurrence, and that
|
||||||
|
* is a real state rather than an error — the screen says "not published" rather
|
||||||
|
* than rendering a dash that reads as a bug.
|
||||||
|
*
|
||||||
|
* [id] is the participation row's own id and is what the keyset page walks back
|
||||||
|
* on: the list gains a row every time the reader attends something, so an offset
|
||||||
|
* would skip and repeat.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class EventHistoryEntryDto(
|
||||||
|
val id: Long = 0,
|
||||||
|
val runId: Long = 0,
|
||||||
|
val title: String = "",
|
||||||
|
val slug: String = "",
|
||||||
|
val seriesName: String? = null,
|
||||||
|
val seriesSlug: String? = null,
|
||||||
|
val scheduledFor: String = "",
|
||||||
|
val startedAt: String? = null,
|
||||||
|
val endedAt: String? = null,
|
||||||
|
val timezone: String? = null,
|
||||||
|
val status: String = "scheduled",
|
||||||
|
val joinedAt: String? = null,
|
||||||
|
// Fractional, for the reason [EventParticipantDto.score] gives.
|
||||||
|
val score: Double = 0.0,
|
||||||
|
val rank: Int? = null,
|
||||||
|
val resultsPublishedAt: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
/** `GET /player/events/history` — self-scoped, one page. */
|
||||||
|
@Serializable
|
||||||
|
data class EventHistoryDto(
|
||||||
|
val entries: List<EventHistoryEntryDto> = emptyList(),
|
||||||
|
)
|
||||||
@@ -20,6 +20,47 @@ data class VersionDto(
|
|||||||
val service: String = "",
|
val service: String = "",
|
||||||
val api: String = "",
|
val api: String = "",
|
||||||
val server: String = "",
|
val server: String = "",
|
||||||
|
/**
|
||||||
|
* What CORE serves beyond the baseline every backend has (events Phase 14a;
|
||||||
|
* `MODULE_API.md` §2.9). Opaque strings, the same word a module uses on
|
||||||
|
* `GET /public/modules` so a client feature-detects one way, and a **separate
|
||||||
|
* list** because core is not a module.
|
||||||
|
*
|
||||||
|
* **The value is in what is absent**, which is why the default is empty
|
||||||
|
* rather than something meaningful: a backend released before a capability
|
||||||
|
* existed omits the key entirely, and that is how the app tells an older site
|
||||||
|
* from one that simply has nothing to show. An unknown string is absent, and
|
||||||
|
* no route may be inferred from one.
|
||||||
|
*/
|
||||||
|
val capabilities: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One installed, **started** module on `GET /public/modules`.
|
||||||
|
*
|
||||||
|
* A module that is disabled or failed to load is absent rather than listed with a
|
||||||
|
* state — its routes and its nav are absent too, so a client renders a site
|
||||||
|
* without that capability rather than one advertising a capability that 503s.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class InstalledModuleDto(
|
||||||
|
val id: String = "",
|
||||||
|
val name: String = "",
|
||||||
|
val version: String = "",
|
||||||
|
val capabilities: List<String> = emptyList(),
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/modules` — what this backend is serving beyond core.
|
||||||
|
*
|
||||||
|
* Database-free and never gated by site mode, so the app can feature-detect
|
||||||
|
* during maintenance. A **500** is the one answer that is not an answer: core
|
||||||
|
* refuses to return `[]` for a list read before its loader ran, because a caller
|
||||||
|
* cannot tell an empty list from a mis-ordered boot.
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ModulesDto(
|
||||||
|
val modules: List<InstalledModuleDto> = emptyList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
/** `GET /public/status` — site mode + version for the first-run probe (§3). */
|
/** `GET /public/status` — site mode + version for the first-run probe (§3). */
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ class ConnectionRepository @Inject constructor(
|
|||||||
private val sessionManager: SessionManager,
|
private val sessionManager: SessionManager,
|
||||||
private val trustTokenStore: TrustTokenStore,
|
private val trustTokenStore: TrustTokenStore,
|
||||||
private val shardFeaturesRepository: ShardFeaturesRepository,
|
private val shardFeaturesRepository: ShardFeaturesRepository,
|
||||||
|
private val siteCapabilitiesRepository: SiteCapabilitiesRepository,
|
||||||
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
private val pushManager: com.runicgateway.app.core.push.PushManager,
|
||||||
private val config: com.runicgateway.app.core.AppConfig,
|
private val config: com.runicgateway.app.core.AppConfig,
|
||||||
) {
|
) {
|
||||||
@@ -116,6 +117,10 @@ class ConnectionRepository @Inject constructor(
|
|||||||
// a switch between two signed-out hosts changes no session, so nothing else
|
// a switch between two signed-out hosts changes no session, so nothing else
|
||||||
// invalidates the cache and the new shard would inherit the old one's menu.
|
// invalidates the cache and the new shard would inherit the old one's menu.
|
||||||
shardFeaturesRepository.invalidate()
|
shardFeaturesRepository.invalidate()
|
||||||
|
// Same argument, one layer up: what the OLD host served says nothing about
|
||||||
|
// the new one, and a stale "this backend has no game module" would hide the
|
||||||
|
// new host's shard rows until its first successful read.
|
||||||
|
siteCapabilitiesRepository.invalidate()
|
||||||
prefs.clear()
|
prefs.clear()
|
||||||
baseUrlHolder.set(null)
|
baseUrlHolder.set(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.map
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.EventsApi
|
||||||
|
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventSeriesDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PublicEventDto
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event calendar, event pages, arcs and the caller's own participation
|
||||||
|
* history (PLAN.md §6.1, §9 M13).
|
||||||
|
*
|
||||||
|
* The two single-object reads unwrap their envelope here rather than in a view
|
||||||
|
* model, so a screen never holds a `…Response` whose only job was to carry one
|
||||||
|
* field. The calendar and the history keep theirs: `truncated` is a fact about
|
||||||
|
* the answer that the screen renders, and the history's page is a list the pager
|
||||||
|
* appends to.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class EventsRepository @Inject constructor(
|
||||||
|
private val api: EventsApi,
|
||||||
|
) {
|
||||||
|
/** The public calendar. Both ends optional; the server's default window is 31 days. */
|
||||||
|
suspend fun calendar(
|
||||||
|
from: String? = null,
|
||||||
|
to: String? = null,
|
||||||
|
seriesId: Long? = null,
|
||||||
|
): ApiResult<EventCalendarDto> = safeApiCall { api.getCalendar(from, to, seriesId) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event, optionally about one occurrence.
|
||||||
|
*
|
||||||
|
* [run] is passed through untouched — including a run that belongs to some
|
||||||
|
* other event, which the server ignores rather than refusing. Filtering it
|
||||||
|
* here would turn a stale link into a dead end instead of a page about the
|
||||||
|
* thing the link was about.
|
||||||
|
*/
|
||||||
|
suspend fun event(slug: String, run: String? = null): ApiResult<PublicEventDto> =
|
||||||
|
safeApiCall { api.getEvent(slug, run?.takeIf { it.isNotBlank() }) }.map { it.event }
|
||||||
|
|
||||||
|
/** One arc. A series with nothing listed in it answers 404, not an empty page. */
|
||||||
|
suspend fun series(slug: String): ApiResult<EventSeriesDto> =
|
||||||
|
safeApiCall { api.getSeries(slug) }.map { it.series }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One page of the caller's own participation history, newest first.
|
||||||
|
*
|
||||||
|
* [before] is the id of the last row already shown — a keyset page, not an
|
||||||
|
* offset, because the list gains rows at the top as the reader attends things.
|
||||||
|
*/
|
||||||
|
suspend fun history(limit: Int, before: Long? = null): ApiResult<List<EventHistoryEntryDto>> =
|
||||||
|
safeApiCall { api.getHistory(limit, before) }.map { it.entries }
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.core.result.safeApiCall
|
||||||
|
import com.runicgateway.app.data.api.PublicApi
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this BACKEND serves — core's own capabilities and every started module's
|
||||||
|
* (PLAN.md §5, §9 M13; `docs/website/MODULE_API.md` §2.9).
|
||||||
|
*
|
||||||
|
* ## Why this exists at all, and why it is not [ShardFeaturesRepository]
|
||||||
|
*
|
||||||
|
* The two answer different questions and neither can answer the other's:
|
||||||
|
*
|
||||||
|
* - **Capability — is this module installed at all?** Per HOST. It changes when
|
||||||
|
* an operator installs or removes a module, so it is resolved beside the
|
||||||
|
* appearance and invalidated on a server switch.
|
||||||
|
* - **Feature — does this shard publish this surface to this viewer?** Per
|
||||||
|
* VIEWER. It changes on sign-in, which is why it is resolved on every session
|
||||||
|
* change.
|
||||||
|
*
|
||||||
|
* Without the first, the app cannot tell a module that is **not installed** from
|
||||||
|
* a lookup that failed: `GET /public/shard/features` 404s in both cases, and
|
||||||
|
* [ShardFeaturesRepository] maps every failure to "unknown", which [canSee]
|
||||||
|
* treats as visible. On a site running a different game that renders every shard
|
||||||
|
* row in the drawer and every one of them 404s when tapped.
|
||||||
|
*
|
||||||
|
* ## Absence of an answer is not an answer of absence
|
||||||
|
*
|
||||||
|
* The distinction this class exists to make, and the reason [SiteCapabilities]
|
||||||
|
* carries no "unknown" member of its own — the *absence of the whole value* is
|
||||||
|
* the unknown state:
|
||||||
|
*
|
||||||
|
* - a **successful** read that does not name a capability is an answer, and
|
||||||
|
* [canUse] hides what needs it;
|
||||||
|
* - a **failed** read keeps the last answer this host gave, because a moment
|
||||||
|
* with no connectivity is not an uninstall;
|
||||||
|
* - a host that has **never** answered leaves the value null, and [canUse]
|
||||||
|
* passes — the drawer renders as it did before this existed rather than
|
||||||
|
* flickering its rows in on every cold start.
|
||||||
|
*
|
||||||
|
* The last one is deliberately the same fail-open direction [canSee] takes, for
|
||||||
|
* the same reason: the server gates every call regardless, so the cost of
|
||||||
|
* guessing wrong is a link that briefly 404s.
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class SiteCapabilitiesRepository @Inject constructor(
|
||||||
|
private val api: PublicApi,
|
||||||
|
) {
|
||||||
|
private val _capabilities = MutableStateFlow<SiteCapabilities?>(null)
|
||||||
|
|
||||||
|
/** The current answer, or `null` while this host has never given one. */
|
||||||
|
val capabilities: StateFlow<SiteCapabilities?> = _capabilities.asStateFlow()
|
||||||
|
|
||||||
|
// Serializes concurrent refreshes: the shell refreshes on resume and the
|
||||||
|
// connect flow refreshes on first load, and two overlapping reads would race
|
||||||
|
// to publish.
|
||||||
|
private val mutex = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-resolve what this backend serves.
|
||||||
|
*
|
||||||
|
* **Two calls, and one failing is not the same as both failing.** Core's list
|
||||||
|
* and a module's are separate lists (§2.9), so they are merged from separate
|
||||||
|
* reads and each is kept only if it answered. A backend released before
|
||||||
|
* events omits `capabilities` from its `version` block entirely, which is an
|
||||||
|
* answer — the empty list — and not a failure.
|
||||||
|
*/
|
||||||
|
suspend fun refresh() = mutex.withLock {
|
||||||
|
val status = safeApiCall { api.getStatus() }
|
||||||
|
val modules = safeApiCall { api.getModules() }
|
||||||
|
|
||||||
|
// Neither call answered: keep whatever this host said last, which for a
|
||||||
|
// host that has never answered is still null.
|
||||||
|
if (status !is ApiResult.Ok && modules !is ApiResult.Ok) return@withLock
|
||||||
|
|
||||||
|
val previous = _capabilities.value
|
||||||
|
val core = (status as? ApiResult.Ok)?.data?.version?.capabilities?.toSet()
|
||||||
|
?: previous?.core
|
||||||
|
?: emptySet()
|
||||||
|
val installed = (modules as? ApiResult.Ok)?.data?.modules
|
||||||
|
?.flatMap { it.capabilities }
|
||||||
|
?.toSet()
|
||||||
|
?: previous?.modules
|
||||||
|
?: emptySet()
|
||||||
|
|
||||||
|
_capabilities.value = SiteCapabilities(core = core, modules = installed)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the answer. Called on a Settings → Server switch: capabilities belong
|
||||||
|
* to the host that reported them, and the new host must not inherit them —
|
||||||
|
* a switch between two signed-out hosts changes no session, so nothing else
|
||||||
|
* would invalidate this.
|
||||||
|
*/
|
||||||
|
fun invalidate() {
|
||||||
|
_capabilities.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What one backend serves, as two lists rather than one.
|
||||||
|
*
|
||||||
|
* They are kept apart because core is not a module: merging them would leave the
|
||||||
|
* app unable to tell *"this backend has events"* from *"a module called core
|
||||||
|
* happens to be installed"*, which is exactly the distinction
|
||||||
|
* `GET /public/modules` exists to make. [canUse] looks in both, because a menu
|
||||||
|
* entry does not care which half serves it — but the halves stay separable, so a
|
||||||
|
* future caller that does care still can.
|
||||||
|
*/
|
||||||
|
data class SiteCapabilities(
|
||||||
|
/** Core's own, from the `version` block. Empty on a backend that predates them. */
|
||||||
|
val core: Set<String>,
|
||||||
|
/** Every started module's, flattened. Two modules may declare the same string. */
|
||||||
|
val modules: Set<String>,
|
||||||
|
) {
|
||||||
|
/** True when either half names [capability]. */
|
||||||
|
operator fun contains(capability: String): Boolean =
|
||||||
|
capability in core || capability in modules
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [capability] may be relied on — **or when this host has not answered
|
||||||
|
* yet**.
|
||||||
|
*
|
||||||
|
* The null case is the fail-open one and it is not the same as the empty one: a
|
||||||
|
* [SiteCapabilities] that names nothing is a backend that told us it serves
|
||||||
|
* nothing extra, and that hides. See the class doc above.
|
||||||
|
*
|
||||||
|
* `null` [capability] means the caller declared none, which always passes.
|
||||||
|
*/
|
||||||
|
fun canUse(capabilities: SiteCapabilities?, capability: String?): Boolean =
|
||||||
|
capability == null || capabilities == null || capability in capabilities
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The capability strings the app gates on.
|
||||||
|
*
|
||||||
|
* **Deliberately few.** `module-uo` declares eight, and gating each shard row on
|
||||||
|
* its own would be a second, worse copy of what the per-viewer feature flags
|
||||||
|
* already decide — and one that drifts, because a capability is opaque to core
|
||||||
|
* and nothing checks the two agree. One string answers the only question a
|
||||||
|
* capability can: is the module there.
|
||||||
|
*/
|
||||||
|
object Capability {
|
||||||
|
/**
|
||||||
|
* A game module serving a live shard. Declared by `module-uo`; a different
|
||||||
|
* game's module that serves the same surfaces would declare it too, which is
|
||||||
|
* the point of an opaque string.
|
||||||
|
*/
|
||||||
|
const val SHARD = "shard"
|
||||||
|
|
||||||
|
/** Core's event system (events Phase 14a). Never a module's. */
|
||||||
|
const val EVENTS = "events"
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import com.runicgateway.app.core.net.TokenAuthenticator
|
|||||||
import com.runicgateway.app.core.net.UserAgentInterceptor
|
import com.runicgateway.app.core.net.UserAgentInterceptor
|
||||||
import com.runicgateway.app.data.api.AuthApi
|
import com.runicgateway.app.data.api.AuthApi
|
||||||
import com.runicgateway.app.data.api.AuthRefreshApi
|
import com.runicgateway.app.data.api.AuthRefreshApi
|
||||||
|
import com.runicgateway.app.data.api.EventsApi
|
||||||
import com.runicgateway.app.data.api.MeApi
|
import com.runicgateway.app.data.api.MeApi
|
||||||
import com.runicgateway.app.data.api.AdminApi
|
import com.runicgateway.app.data.api.AdminApi
|
||||||
import com.runicgateway.app.data.api.NotificationsApi
|
import com.runicgateway.app.data.api.NotificationsApi
|
||||||
@@ -122,6 +123,15 @@ object NetworkModule {
|
|||||||
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
|
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
|
||||||
retrofit.create(PlayerShardApi::class.java)
|
retrofit.create(PlayerShardApi::class.java)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event surface (§9 M13). Three public reads and one bearer-authed player
|
||||||
|
* read on one interface — they are all CORE routes, so none of them is a
|
||||||
|
* module path and none is under `/shard`.
|
||||||
|
*/
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideEventsApi(retrofit: Retrofit): EventsApi = retrofit.create(EventsApi::class.java)
|
||||||
|
|
||||||
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
|
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import com.runicgateway.app.core.result.ApiResult
|
|||||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||||
import com.runicgateway.app.data.repository.ConnectionRepository
|
import com.runicgateway.app.data.repository.ConnectionRepository
|
||||||
import com.runicgateway.app.data.repository.SettingsRepository
|
import com.runicgateway.app.data.repository.SettingsRepository
|
||||||
|
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
@@ -29,6 +30,7 @@ class AppViewModel @Inject constructor(
|
|||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val baseUrlHolder: BaseUrlHolder,
|
private val baseUrlHolder: BaseUrlHolder,
|
||||||
private val pushManager: PushManager,
|
private val pushManager: PushManager,
|
||||||
|
private val siteCapabilitiesRepository: SiteCapabilitiesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
sealed interface AppState {
|
sealed interface AppState {
|
||||||
@@ -76,6 +78,12 @@ class AppViewModel @Inject constructor(
|
|||||||
fun refreshAppearance() {
|
fun refreshAppearance() {
|
||||||
if (_state.value !is AppState.Ready) return
|
if (_state.value !is AppState.Ready) return
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
// What the backend SERVES is a per-host fact and refreshes on the same
|
||||||
|
// clock as the appearance: an operator who installs a module while the
|
||||||
|
// app is backgrounded should see its rows on the next resume. Done
|
||||||
|
// before the early return below, because a failed settings read is no
|
||||||
|
// reason to skip it — they are separate calls to separate routes.
|
||||||
|
siteCapabilitiesRepository.refresh()
|
||||||
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
|
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data ?: return@launch
|
||||||
pushManager.setNtfyUrl(settings.push.ntfyUrl)
|
pushManager.setNtfyUrl(settings.push.ntfyUrl)
|
||||||
// changeServer() may have raced us back to the connect screen while the
|
// changeServer() may have raced us back to the connect screen while the
|
||||||
@@ -100,6 +108,7 @@ class AppViewModel @Inject constructor(
|
|||||||
* or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded.
|
* or sign-in. Returns [SiteAppearance.NONE] if settings couldn't be loaded.
|
||||||
*/
|
*/
|
||||||
private suspend fun loadAppearance(): SiteAppearance {
|
private suspend fun loadAppearance(): SiteAppearance {
|
||||||
|
siteCapabilitiesRepository.refresh()
|
||||||
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
|
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
|
||||||
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
|
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
|
||||||
return SiteAppearance.from(settings)
|
return SiteAppearance.from(settings)
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ import com.runicgateway.app.ui.auth.TrustedDevicesScreen
|
|||||||
import com.runicgateway.app.ui.auth.roleLabelRes
|
import com.runicgateway.app.ui.auth.roleLabelRes
|
||||||
import com.runicgateway.app.ui.components.BrandLogo
|
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.events.EventScreen
|
||||||
|
import com.runicgateway.app.ui.events.EventSeriesScreen
|
||||||
|
import com.runicgateway.app.ui.events.EventsScreen
|
||||||
|
import com.runicgateway.app.ui.events.MyEventsScreen
|
||||||
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.NavNode
|
||||||
@@ -110,6 +114,10 @@ private val TOP_LEVEL_ROUTES = setOf(
|
|||||||
// on them too (M11).
|
// on them too (M11).
|
||||||
Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS,
|
Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS,
|
||||||
Routes.NOTIFICATIONS,
|
Routes.NOTIFICATIONS,
|
||||||
|
// Events (M13): the calendar and the history are drawer rows, so the drawer
|
||||||
|
// gesture works on them. The event page and an arc are detail screens and are
|
||||||
|
// deliberately absent — a back gesture there means "back", not "open the menu".
|
||||||
|
Routes.EVENTS, Routes.MY_EVENTS,
|
||||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||||
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||||
)
|
)
|
||||||
@@ -141,6 +149,9 @@ fun RunicApp(
|
|||||||
val session by sessionViewModel.session.collectAsStateWithLifecycle()
|
val session by sessionViewModel.session.collectAsStateWithLifecycle()
|
||||||
// What this shard publishes, independently of who the caller is (§5, M11).
|
// What this shard publishes, independently of who the caller is (§5, M11).
|
||||||
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
|
val shardFeatures by sessionViewModel.shardFeatures.collectAsStateWithLifecycle()
|
||||||
|
// What this BACKEND serves at all, independently of both (§5, M13). A different
|
||||||
|
// question from the line above and gated separately — see `isEntryVisible`.
|
||||||
|
val capabilities by sessionViewModel.capabilities.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
// Re-validate the cached role each time the app returns to the foreground (§4.3),
|
// Re-validate the cached role each time the app returns to the foreground (§4.3),
|
||||||
// and re-read the unread count with it: a tickle that arrived while the app was
|
// and re-read the unread count with it: a tickle that arrived while the app was
|
||||||
@@ -178,7 +189,7 @@ fun RunicApp(
|
|||||||
// `pruneNav` still decides what this caller may see and remains the boundary
|
// `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.
|
// (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
|
||||||
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
|
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
|
||||||
isEntryVisible(it, session, shardFeatures)
|
isEntryVisible(it, session, shardFeatures, capabilities)
|
||||||
}
|
}
|
||||||
|
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
@@ -478,6 +489,50 @@ private fun RunicNavHost(
|
|||||||
) {
|
) {
|
||||||
PostScreen()
|
PostScreen()
|
||||||
}
|
}
|
||||||
|
// Events (M13). CORE's routes, so these screens are reachable on a backend
|
||||||
|
// with no game module at all — which is why they sit above the shard block
|
||||||
|
// rather than inside it.
|
||||||
|
composable(Routes.EVENTS) {
|
||||||
|
EventsScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) })
|
||||||
|
}
|
||||||
|
// The app's one route with a query argument. `run` is optional and nullable:
|
||||||
|
// navigating to Routes.event(slug) with no run matches this pattern with no
|
||||||
|
// argument, which is every route in except an announcement's link.
|
||||||
|
composable(
|
||||||
|
route = Routes.EVENT_ROUTE,
|
||||||
|
arguments = listOf(
|
||||||
|
navArgument(Routes.Args.SLUG) { type = NavType.StringType },
|
||||||
|
navArgument(Routes.Args.RUN) {
|
||||||
|
type = NavType.StringType
|
||||||
|
nullable = true
|
||||||
|
defaultValue = null
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
EventScreen(
|
||||||
|
onOpenSeries = { slug -> navController.navigate(Routes.eventSeries(slug)) },
|
||||||
|
onOpenRun = { slug, runId ->
|
||||||
|
navController.navigate(Routes.event(slug, runId.toString()))
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
composable(
|
||||||
|
route = Routes.EVENT_SERIES,
|
||||||
|
arguments = listOf(navArgument(Routes.Args.SLUG) { type = NavType.StringType }),
|
||||||
|
) {
|
||||||
|
EventSeriesScreen(onOpenEvent = { slug -> navController.navigate(Routes.event(slug)) })
|
||||||
|
}
|
||||||
|
composable(Routes.MY_EVENTS) {
|
||||||
|
// Signed out, this route is not in the drawer — but a saved back-stack
|
||||||
|
// entry can still be restored onto it, so the shell says where to go
|
||||||
|
// rather than letting the screen ask the server and render a 401.
|
||||||
|
when (session) {
|
||||||
|
is Session.SignedIn -> MyEventsScreen(onOpenRun = { slug, runId ->
|
||||||
|
navController.navigate(Routes.event(slug, runId.toString()))
|
||||||
|
})
|
||||||
|
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||||
|
}
|
||||||
|
}
|
||||||
composable(Routes.SHARD) {
|
composable(Routes.SHARD) {
|
||||||
ShardScreen(onOpenBoard = { board ->
|
ShardScreen(onOpenBoard = { board ->
|
||||||
navController.navigate(
|
navController.navigate(
|
||||||
@@ -586,6 +641,10 @@ private fun RunicNavHost(
|
|||||||
when (session) {
|
when (session) {
|
||||||
is Session.SignedIn -> InboxScreen(
|
is Session.SignedIn -> InboxScreen(
|
||||||
onOpenSettings = { navController.navigate(Routes.NOTIFICATIONS_SETTINGS) },
|
onOpenSettings = { navController.navigate(Routes.NOTIFICATIONS_SETTINGS) },
|
||||||
|
// A notification whose link the app can render opens in the app.
|
||||||
|
// `navigate`, not `navigateTopLevel`: the inbox is where the
|
||||||
|
// reader came from and back should return there.
|
||||||
|
onOpenRoute = { route -> navController.navigate(route) },
|
||||||
)
|
)
|
||||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||||
}
|
}
|
||||||
|
|||||||
310
app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt
Normal file
310
app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.EventOccurrenceDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventParticipantDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PublicEventDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.HtmlText
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event's public page (EVENTS.md § API surface, M13).
|
||||||
|
*
|
||||||
|
* The storyline, its arc, what is live, what is next, what happened recently, and
|
||||||
|
* a results table once an occurrence has published one.
|
||||||
|
*
|
||||||
|
* **The plan behind the event is never shown**, because the server never sends
|
||||||
|
* it: a live run carries the LABEL of the phase it is in — resolved from the
|
||||||
|
* version that run pinned, so an edit since does not relabel it — and nothing
|
||||||
|
* else. Phases, steps and actions are the operator's.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EventScreen(
|
||||||
|
onOpenSeries: (String) -> Unit,
|
||||||
|
onOpenRun: (String, Long) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: EventViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Error before content. Phase 13 found the inverse of this one tier along: a
|
||||||
|
// `if (loading || !form)` spinner above the error branch left a failed load
|
||||||
|
// spinning for ever with nothing on screen naming the problem.
|
||||||
|
when (val s = state) {
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Success -> EventBody(s.data, onOpenSeries, onOpenRun, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EventBody(
|
||||||
|
event: PublicEventDto,
|
||||||
|
onOpenSeries: (String) -> Unit,
|
||||||
|
onOpenRun: (String, Long) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
item(key = "head") {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(
|
||||||
|
text = event.title,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
event.summary?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
event.series?.let { series ->
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_part_of, series.name),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.clickable { onOpenSeries(series.slug) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The one fact a visitor came for, above the storyline rather than below
|
||||||
|
// it: whether it is happening now, and if not, when it next is.
|
||||||
|
item(key = "headline") { Headline(event) }
|
||||||
|
|
||||||
|
event.body?.takeIf { it.isNotBlank() }?.let { body ->
|
||||||
|
item(key = "body") {
|
||||||
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
|
// Sanitized on write, the treatment a wiki page and a forum
|
||||||
|
// post already get.
|
||||||
|
HtmlText(body, Modifier.padding(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
event.results?.let { results ->
|
||||||
|
item(key = "results-head") {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_results),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = eventDateTime(results.scheduledFor, event.timezone),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (results.participants.isEmpty()) {
|
||||||
|
item(key = "results-empty") {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_results_nobody),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(results.participants.size, key = { "p$it" }) { index ->
|
||||||
|
ParticipantRow(results.participants[index])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
occurrenceSection(
|
||||||
|
key = "upcoming",
|
||||||
|
titleRes = R.string.events_coming_up,
|
||||||
|
list = event.upcoming,
|
||||||
|
timezone = event.timezone,
|
||||||
|
slug = event.slug,
|
||||||
|
onOpenRun = onOpenRun,
|
||||||
|
linkResults = false,
|
||||||
|
)
|
||||||
|
occurrenceSection(
|
||||||
|
key = "past",
|
||||||
|
titleRes = R.string.events_previously,
|
||||||
|
list = event.past,
|
||||||
|
timezone = event.timezone,
|
||||||
|
slug = event.slug,
|
||||||
|
onOpenRun = onOpenRun,
|
||||||
|
linkResults = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (event.current == null && event.next == null && event.past.isEmpty()) {
|
||||||
|
item(key = "unscheduled") {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_never_scheduled),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Headline(event: PublicEventDto) {
|
||||||
|
ShardCard(Modifier.fillMaxWidth()) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
val current = event.current
|
||||||
|
when {
|
||||||
|
event.live && current != null -> {
|
||||||
|
StatusPill(
|
||||||
|
text = stringResource(R.string.events_status_live),
|
||||||
|
tone = PillTone.Success,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
// The phase LABEL, and only while it is live.
|
||||||
|
text = current.phase?.takeIf { it.isNotBlank() }
|
||||||
|
?: stringResource(R.string.events_under_way),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
event.next != null -> {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_next),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = eventDateTime(event.next.scheduledFor, event.next.timezone ?: event.timezone),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> Text(
|
||||||
|
text = stringResource(R.string.events_nothing_scheduled),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A titled list of occurrences, or nothing at all when there are none.
|
||||||
|
*
|
||||||
|
* `linkResults` is what separates the two calls: only a PAST occurrence that
|
||||||
|
* actually published results gets its own tap target, because on any other one
|
||||||
|
* `?run=` would change nothing a reader could see.
|
||||||
|
*/
|
||||||
|
private fun androidx.compose.foundation.lazy.LazyListScope.occurrenceSection(
|
||||||
|
key: String,
|
||||||
|
titleRes: Int,
|
||||||
|
list: List<EventOccurrenceDto>,
|
||||||
|
timezone: String?,
|
||||||
|
slug: String,
|
||||||
|
onOpenRun: (String, Long) -> Unit,
|
||||||
|
linkResults: Boolean,
|
||||||
|
) {
|
||||||
|
if (list.isEmpty()) return
|
||||||
|
item(key = "$key-title") {
|
||||||
|
Text(
|
||||||
|
text = stringResource(titleRes),
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(list.size, key = { "$key-${list[it].runId}" }) { index ->
|
||||||
|
val occurrence = list[index]
|
||||||
|
val tappable = linkResults && occurrence.resultsPublishedAt != null
|
||||||
|
ShardCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(
|
||||||
|
if (tappable) Modifier.clickable { onOpenRun(slug, occurrence.runId) }
|
||||||
|
else Modifier,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = eventDateTime(occurrence.scheduledFor, occurrence.timezone ?: timezone),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(
|
||||||
|
statusWordRes(occurrence.status, occurrence.scheduledFor),
|
||||||
|
),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ParticipantRow(participant: EventParticipantDto) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = participant.rank?.toString() ?: "—",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
modifier = Modifier.width(32.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
// A module supplies a display name in its participation meta or it does
|
||||||
|
// not; the member key is never published, so there is genuinely nothing
|
||||||
|
// else to render.
|
||||||
|
text = participant.name?.takeIf { it.isNotBlank() }
|
||||||
|
?: stringResource(R.string.events_participant_unnamed),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = scoreText(participant.score),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One arc (EVENTS.md §I, M13).
|
||||||
|
*
|
||||||
|
* **The arc is the thing the tooling this replaces could not express at all.** A
|
||||||
|
* calendar plugin has no series field, so "Royal Spy Mission → Risky Partner →
|
||||||
|
* Message From the Void" existed only in a GM's head and in whatever the forum
|
||||||
|
* post said. This screen is that continuity, in the order an editor arranged it —
|
||||||
|
* which is why the events are numbered rather than dated: an arc has an order, and
|
||||||
|
* its parts may be months apart or run out of sequence.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EventSeriesScreen(
|
||||||
|
onOpenEvent: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: EventSeriesViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
// Error first, then loading — the order Phase 13 had to fix one tier along.
|
||||||
|
when (val s = state) {
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Success -> {
|
||||||
|
val series = s.data
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
item(key = "head") {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Text(
|
||||||
|
text = series.name,
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
series.description?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items(series.events.size, key = { series.events[it].slug }) { index ->
|
||||||
|
val entry = series.events[index]
|
||||||
|
ShardCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable { onOpenEvent(entry.slug) },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = (index + 1).toString(),
|
||||||
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
modifier = Modifier.width(32.dp),
|
||||||
|
)
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = entry.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
entry.summary?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.EventSeriesDto
|
||||||
|
import com.runicgateway.app.data.repository.EventsRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One arc (PLAN.md §9 M13).
|
||||||
|
*
|
||||||
|
* A series with nothing listed in it answers 404 rather than an empty page, so
|
||||||
|
* there is no "empty arc" state to render: the error branch is the whole of it,
|
||||||
|
* and that is the server's decision rather than this screen's — an empty page
|
||||||
|
* would publish that an operator has named something they have not announced.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class EventSeriesViewModel @Inject constructor(
|
||||||
|
private val repository: EventsRepository,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val slug: String = savedStateHandle.get<String>(Routes.Args.SLUG).orEmpty()
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<EventSeriesDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<EventSeriesDto>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = repository.series(slug).toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
app/src/main/java/com/runicgateway/app/ui/events/EventTimes.kt
Normal file
166
app/src/main/java/com/runicgateway/app/ui/events/EventTimes.kt
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.annotation.StringRes
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.core.time.parseWireInstant
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.FormatStyle
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendering an event's instant and its status word (EVENTS.md §I).
|
||||||
|
*
|
||||||
|
* Everything here is pure and takes its clock, zone and locale as parameters, so
|
||||||
|
* the rules below are unit-tested off-device rather than eyeballed on one.
|
||||||
|
*
|
||||||
|
* ## The split, which is the one thing about event times that is easy to get wrong
|
||||||
|
*
|
||||||
|
* The server returns UTC instants and never guesses the reader's zone. The client
|
||||||
|
* places them, and it places the two halves differently:
|
||||||
|
*
|
||||||
|
* - the **day** an entry is filed under is the READER's own — "what is on this
|
||||||
|
* month" is a question about the month the person holding the phone is living
|
||||||
|
* in;
|
||||||
|
* - the **time** beside it is always the EVENT's zone, carried on the entry —
|
||||||
|
* because every listing this feature replaces is written in the shard's local
|
||||||
|
* zone, and "8pm" means the shard's evening to everyone reading it.
|
||||||
|
*
|
||||||
|
* Rendering the time in the reader's zone instead is defensible and wrong here: a
|
||||||
|
* player in Berlin told an American shard's event is at 02:00 has been told
|
||||||
|
* something true and useless, and told it in a way that makes the shard's own
|
||||||
|
* announcement look like a mistake.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A participation score, as a reader should see it.
|
||||||
|
*
|
||||||
|
* Scores are `DECIMAL(18,4)` on the wire because a module may score by distance,
|
||||||
|
* time or a weighted tally — but most score by counting, and rendering a plain
|
||||||
|
* tally of kills as `12.0` reads as a rounding artefact. So a whole number prints
|
||||||
|
* whole and a fraction keeps its digits, with trailing zeros trimmed: `1420`,
|
||||||
|
* `318.5`, `0.25`.
|
||||||
|
*/
|
||||||
|
fun scoreText(score: Double, locale: Locale = Locale.getDefault()): String {
|
||||||
|
if (!score.isFinite()) return "0"
|
||||||
|
if (score == Math.floor(score) && Math.abs(score) < 1e15) {
|
||||||
|
return String.format(locale, "%d", score.toLong())
|
||||||
|
}
|
||||||
|
return String.format(locale, "%.4f", score).trimEnd('0').trimEnd('.', ',')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The event's own wall clock, with the zone named so it misreads as nothing. */
|
||||||
|
fun eventTime(
|
||||||
|
instant: String?,
|
||||||
|
timezone: String?,
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
): String {
|
||||||
|
val at = parseWireInstant(instant) ?: return ""
|
||||||
|
val zone = eventZone(timezone)
|
||||||
|
val time = DateTimeFormatter.ofPattern("HH:mm", locale).withZone(zone).format(at)
|
||||||
|
return "$time ${shortZone(timezone)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event's own day and time together, for a screen showing one occurrence.
|
||||||
|
*
|
||||||
|
* Localized rather than patterned, because a full date's field order is the
|
||||||
|
* locale's business; only the zone stays the event's.
|
||||||
|
*/
|
||||||
|
fun eventDateTime(
|
||||||
|
instant: String?,
|
||||||
|
timezone: String?,
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
): String {
|
||||||
|
val at = parseWireInstant(instant) ?: return ""
|
||||||
|
val zone = eventZone(timezone)
|
||||||
|
val text = DateTimeFormatter
|
||||||
|
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
||||||
|
.withLocale(locale)
|
||||||
|
.withZone(zone)
|
||||||
|
.format(at)
|
||||||
|
return "$text ${shortZone(timezone)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The reader's own day, for the heading an entry is filed under. */
|
||||||
|
fun readerDayLabel(
|
||||||
|
instant: String?,
|
||||||
|
zone: ZoneId = ZoneId.systemDefault(),
|
||||||
|
locale: Locale = Locale.getDefault(),
|
||||||
|
): String {
|
||||||
|
val at = parseWireInstant(instant) ?: return ""
|
||||||
|
return DateTimeFormatter
|
||||||
|
.ofLocalizedDate(FormatStyle.FULL)
|
||||||
|
.withLocale(locale)
|
||||||
|
.withZone(zone)
|
||||||
|
.format(at)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The zone as a reader recognises it: `America/New_York` → `New York`.
|
||||||
|
*
|
||||||
|
* Not the abbreviation (`EDT`), which is unstable across the year and unknown to
|
||||||
|
* most readers of a shard in another country.
|
||||||
|
*/
|
||||||
|
fun shortZone(timezone: String?): String {
|
||||||
|
if (timezone.isNullOrBlank()) return "UTC"
|
||||||
|
return timezone.substringAfterLast('/').replace('_', ' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The event's zone, or UTC when its column holds something `java.time` will not
|
||||||
|
* read.
|
||||||
|
*
|
||||||
|
* A typo in a definition's timezone must still render: UTC off the instant is the
|
||||||
|
* honest answer when the zone cannot be honoured, and it is what the web client
|
||||||
|
* falls back to for the same reason.
|
||||||
|
*/
|
||||||
|
private fun eventZone(timezone: String?): ZoneId = try {
|
||||||
|
if (timezone.isNullOrBlank()) ZoneId.of("UTC") else ZoneId.of(timezone)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
ZoneId.of("UTC")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The word beside an occurrence, for the four statuses the server publishes.
|
||||||
|
*
|
||||||
|
* **`cancelled` needs the instant, and that is the whole reason this takes one.**
|
||||||
|
* The server publishes `failed` and `missed` as `cancelled` too — to a visitor the
|
||||||
|
* three are one event, and the difference between them is about the deployment —
|
||||||
|
* but the three do not share one English sentence. *Did not happen* is right for a
|
||||||
|
* past occurrence and a plain falsehood for a future one, and a run four days out
|
||||||
|
* that an operator has called off is exactly the common case: this is the defect
|
||||||
|
* Phase 14a's own calendar shipped and the live walk caught, which is why it is
|
||||||
|
* restated here rather than ported.
|
||||||
|
*
|
||||||
|
* So **the tense follows the clock, not the status**. A future call-off reads
|
||||||
|
* *Cancelled*; a past one reads *Did not happen*, which is also the honest word
|
||||||
|
* for the failed and missed runs folded in with it.
|
||||||
|
*
|
||||||
|
* An unrecognised status reads *Scheduled*, mirroring the server's own fallback:
|
||||||
|
* `publicStatus()` folds anything it does not know to `scheduled`, so a word the
|
||||||
|
* app has never seen is a contract break rather than a state, and rendering a raw
|
||||||
|
* enum at a reader is not an improvement on it.
|
||||||
|
*/
|
||||||
|
@StringRes
|
||||||
|
fun statusWordRes(status: String?, scheduledFor: String?, now: Instant = Instant.now()): Int =
|
||||||
|
when (status) {
|
||||||
|
"live" -> R.string.events_status_live
|
||||||
|
"completed" -> R.string.events_status_completed
|
||||||
|
"cancelled" -> {
|
||||||
|
val at = parseWireInstant(scheduledFor)
|
||||||
|
// An unreadable instant is treated as past, which is the safer of the
|
||||||
|
// two: "did not happen" about something unplaceable in time is vague,
|
||||||
|
// while "cancelled" about a past run implies it is still coming.
|
||||||
|
if (at != null && at.isAfter(now)) {
|
||||||
|
R.string.events_status_cancelled
|
||||||
|
} else {
|
||||||
|
R.string.events_status_did_not_happen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> R.string.events_status_scheduled
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.PublicEventDto
|
||||||
|
import com.runicgateway.app.data.repository.EventsRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.navigation.Routes
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event's page (PLAN.md §9 M13, EVENTS.md § API surface).
|
||||||
|
*
|
||||||
|
* **`run` is read from the route and passed through untouched**, because that is
|
||||||
|
* what an announcement's link carries. The page lives at the definition's slug —
|
||||||
|
* one stable address, so a link posted in Discord survives a retitle — and the
|
||||||
|
* occurrence has to be in the query or a mail about last Friday's invasion would
|
||||||
|
* open next Friday's.
|
||||||
|
*
|
||||||
|
* A run that belongs to some other event is **not** filtered here. The server
|
||||||
|
* ignores it and answers with this event anyway, which turns a stale link in a
|
||||||
|
* months-old mail into the page it was about rather than a dead end; second-
|
||||||
|
* guessing that would undo it.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class EventViewModel @Inject constructor(
|
||||||
|
private val repository: EventsRepository,
|
||||||
|
savedStateHandle: SavedStateHandle,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val slug: String = savedStateHandle.get<String>(Routes.Args.SLUG).orEmpty()
|
||||||
|
|
||||||
|
/** Null unless the route carried one; never an empty string forwarded to the server. */
|
||||||
|
private val run: String? = savedStateHandle.get<String>(Routes.Args.RUN)?.takeIf { it.isNotBlank() }
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<PublicEventDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<PublicEventDto>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = repository.event(slug, run).toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
191
app/src/main/java/com/runicgateway/app/ui/events/EventsScreen.kt
Normal file
191
app/src/main/java/com/runicgateway/app/ui/events/EventsScreen.kt
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.EventCalendarEntryDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.PillTone
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
import com.runicgateway.app.ui.components.StatusPill
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public event calendar (EVENTS.md §I, M13).
|
||||||
|
*
|
||||||
|
* **A list, not a month grid**, which is the same call the web client makes and
|
||||||
|
* for the same reason: an operator's question is "what does this month look
|
||||||
|
* like" — coverage, clashes, the gap on the third weekend — and a grid answers
|
||||||
|
* it. A visitor's question is "what is on, and when is the next one", which a
|
||||||
|
* chronological list answers in one glance and a grid answers by making them
|
||||||
|
* count squares. On a phone the grid is not even a close second.
|
||||||
|
*
|
||||||
|
* **A projection is drawn differently from a run**, one tier along from the
|
||||||
|
* operator's own reason for the distinction: past the materialisation horizon
|
||||||
|
* there is no row, nothing is committed to, and nothing can be cancelled. Drawing
|
||||||
|
* a forecast identically to a booking would be the screen promising something the
|
||||||
|
* server has not.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun EventsScreen(
|
||||||
|
onOpenEvent: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: EventsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
when (val s = state) {
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Success -> {
|
||||||
|
val entries = s.data.entries
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.events_empty), modifier)
|
||||||
|
} else {
|
||||||
|
Calendar(entries, s.data.truncated, onOpenEvent, modifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group by the READER's day, preserving the server's order rather than re-sorting.
|
||||||
|
*
|
||||||
|
* Internal + pure so the grouping — and the fact that it never reorders — is
|
||||||
|
* unit-tested without Compose.
|
||||||
|
*/
|
||||||
|
internal fun groupByReaderDay(entries: List<EventCalendarEntryDto>): List<CalendarDayGroup> {
|
||||||
|
val days = mutableListOf<CalendarDayGroup>()
|
||||||
|
for (entry in entries) {
|
||||||
|
val label = readerDayLabel(entry.scheduledFor)
|
||||||
|
val last = days.lastOrNull()
|
||||||
|
if (last != null && last.label == label) {
|
||||||
|
last.entries.add(entry)
|
||||||
|
} else {
|
||||||
|
days.add(CalendarDayGroup(label, mutableListOf(entry)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A mutable builder shape for [groupByReaderDay]; the screen only reads it. */
|
||||||
|
internal data class CalendarDayGroup(
|
||||||
|
val label: String,
|
||||||
|
val entries: MutableList<EventCalendarEntryDto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Calendar(
|
||||||
|
entries: List<EventCalendarEntryDto>,
|
||||||
|
truncated: Boolean,
|
||||||
|
onOpenEvent: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val days = groupByReaderDay(entries)
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
) {
|
||||||
|
days.forEach { day ->
|
||||||
|
item(key = "day-${day.label}") {
|
||||||
|
Text(
|
||||||
|
text = day.label,
|
||||||
|
style = MaterialTheme.typography.labelLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(
|
||||||
|
items = day.entries,
|
||||||
|
key = { "${it.slug}-${it.scheduledFor}-${it.kind}" },
|
||||||
|
) { entry ->
|
||||||
|
EntryCard(entry, onOpenEvent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (truncated) {
|
||||||
|
item(key = "truncated") {
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_truncated),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun EntryCard(entry: EventCalendarEntryDto, onOpenEvent: (String) -> Unit) {
|
||||||
|
ShardCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// A projection has a page too — the definition's — so it opens like any
|
||||||
|
// other entry. What it does not have is an occurrence to link to.
|
||||||
|
.clickable { onOpenEvent(entry.slug) },
|
||||||
|
) {
|
||||||
|
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = entry.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
StatusPill(
|
||||||
|
text = stringResource(statusWordRes(entry.status, entry.scheduledFor)),
|
||||||
|
tone = if (entry.live) PillTone.Success else PillTone.Neutral,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = eventTime(entry.scheduledFor, entry.timezone),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
entry.seriesName?.takeIf { it.isNotBlank() }?.let { series ->
|
||||||
|
Text(
|
||||||
|
text = series,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (entry.isProjected) {
|
||||||
|
// Said in words rather than drawn as a dashed border, because a
|
||||||
|
// phone reader skimming a list will not decode a border and the
|
||||||
|
// distinction is worth more than the pixel it would cost.
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_projected),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontStyle = FontStyle.Italic,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||||
|
import com.runicgateway.app.data.repository.EventsRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The public event calendar (PLAN.md §9 M13, EVENTS.md §I).
|
||||||
|
*
|
||||||
|
* **No window is asked for**, and that is the whole of this view model's design.
|
||||||
|
* The server's default is now through 31 days out, so a client that computed a
|
||||||
|
* window before it could ask anything would make every deep link carry two ISO
|
||||||
|
* instants and would have to agree with the server about what "now" is. The
|
||||||
|
* window bound and the entry cap are the server's defence on the one surface with
|
||||||
|
* no login in front of it; there is nothing for the app to add.
|
||||||
|
*
|
||||||
|
* `toUiState`, not `toShardUiState`: these are CORE routes. A 404 here means the
|
||||||
|
* backend has no events at all, not that an admin switched a shard surface off,
|
||||||
|
* and offering "not published here" for it would name the wrong cause.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class EventsViewModel @Inject constructor(
|
||||||
|
private val repository: EventsRepository,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow<UiState<EventCalendarDto>>(UiState.Loading)
|
||||||
|
val state: StateFlow<UiState<EventCalendarDto>> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
load()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = UiState.Loading
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.value = repository.calendar().toUiState()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.hilt.navigation.compose.hiltViewModel
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.components.EmptyView
|
||||||
|
import com.runicgateway.app.ui.components.ErrorView
|
||||||
|
import com.runicgateway.app.ui.components.LoadingView
|
||||||
|
import com.runicgateway.app.ui.components.ShardCard
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This account's event participation (EVENTS.md §J, M13).
|
||||||
|
*
|
||||||
|
* **The screen's one real design decision is what an unranked row says.** A run
|
||||||
|
* whose participants were collected but whose results have not been published has
|
||||||
|
* a score and no rank, and that is a real state rather than an error — it is the
|
||||||
|
* same state the admin run console has shown since events Phase 10. Rendering a
|
||||||
|
* dash with nothing explaining it would read as a bug; the row says the results
|
||||||
|
* are not published, which is a fact about the event rather than about the reader.
|
||||||
|
*
|
||||||
|
* Reached by **one drawer row for every signed-in account**, players and staff
|
||||||
|
* alike. The website mounts this twice only because its `RequirePlayer` guard sits
|
||||||
|
* over `/account` and the route behind it is role-agnostic; the app has no such
|
||||||
|
* wall, so it needs no second mount.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun MyEventsScreen(
|
||||||
|
onOpenRun: (String, Long) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
viewModel: MyEventsViewModel = hiltViewModel(),
|
||||||
|
) {
|
||||||
|
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
|
when (val items = state.items) {
|
||||||
|
is UiState.Loading -> LoadingView(modifier)
|
||||||
|
is UiState.Error -> ErrorView(items.kind, onRetry = viewModel::load, modifier = modifier)
|
||||||
|
is UiState.Success -> if (items.data.isEmpty()) {
|
||||||
|
EmptyView(stringResource(R.string.events_history_empty), modifier)
|
||||||
|
} else {
|
||||||
|
androidx.compose.foundation.lazy.LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
items(items.data.size, key = { items.data[it].id }) { index ->
|
||||||
|
HistoryRow(items.data[index], onOpenRun)
|
||||||
|
}
|
||||||
|
if (state.hasMore) {
|
||||||
|
item(key = "more") {
|
||||||
|
TextButton(
|
||||||
|
onClick = viewModel::loadMore,
|
||||||
|
enabled = !state.loadingMore,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (state.loadingMore) R.string.events_loading
|
||||||
|
else R.string.events_show_more,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun HistoryRow(entry: EventHistoryEntryDto, onOpenRun: (String, Long) -> Unit) {
|
||||||
|
ShardCard(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
// Straight to the occurrence the reader took part in, not to whatever
|
||||||
|
// is next: `?run=` is what makes the event page answer about this one.
|
||||||
|
.clickable { onOpenRun(entry.slug, entry.runId) },
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
Modifier.fillMaxWidth().padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
Text(
|
||||||
|
text = entry.title,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = eventDateTime(entry.scheduledFor, entry.timezone),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
entry.seriesName?.takeIf { it.isNotBlank() }?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Column(horizontalAlignment = Alignment.End) {
|
||||||
|
Text(
|
||||||
|
text = entry.rank
|
||||||
|
?.let { stringResource(R.string.events_rank, it) }
|
||||||
|
?: stringResource(R.string.events_results_unpublished),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = stringResource(R.string.events_score, scoreText(entry.score)),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.runicgateway.app.core.auth.Session
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.core.result.ApiResult
|
||||||
|
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
|
||||||
|
import com.runicgateway.app.data.repository.EventsRepository
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.toUiState
|
||||||
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This account's event participation (PLAN.md §9 M13, EVENTS.md §J).
|
||||||
|
*
|
||||||
|
* **Self-scoped by the session and nothing else.** There is no id parameter on
|
||||||
|
* the route and deliberately none here: one account never reads another's, and
|
||||||
|
* there is no argument that could later grow into one.
|
||||||
|
*
|
||||||
|
* **Keyset-paged on the participation row's own id, never an offset** — the list
|
||||||
|
* gains a row every time the reader attends something, so an offset page would
|
||||||
|
* skip and repeat rows around the seam.
|
||||||
|
*
|
||||||
|
* ## Why this watches the session, when no other screen here does
|
||||||
|
*
|
||||||
|
* **A drawer route's view model outlives a sign-out.** `navigateTopLevel` saves
|
||||||
|
* and restores back-stack state, so the `NavBackStackEntry` for this route keeps
|
||||||
|
* its `ViewModelStore` across a sign-out and a sign-in as somebody else — and a
|
||||||
|
* view model that loads only in `init` never runs again. The live walk found the
|
||||||
|
* consequence: signing out of an admin account and back in as a player showed the
|
||||||
|
* PLAYER the admin's participation history, with no request made at all.
|
||||||
|
*
|
||||||
|
* The public event screens have the same lifetime and do not care, because a
|
||||||
|
* calendar is the same for everybody. This one is per-account, so the account is
|
||||||
|
* what it keys on: the flow emits the current session immediately, which is also
|
||||||
|
* the first load, and re-emits only when the signed-in id actually changes — a
|
||||||
|
* resume revalidation returning the same user does not refetch.
|
||||||
|
*/
|
||||||
|
@HiltViewModel
|
||||||
|
class MyEventsViewModel @Inject constructor(
|
||||||
|
private val repository: EventsRepository,
|
||||||
|
sessionManager: SessionManager,
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
data class State(
|
||||||
|
val items: UiState<List<EventHistoryEntryDto>> = UiState.Loading,
|
||||||
|
val hasMore: Boolean = false,
|
||||||
|
val loadingMore: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val _state = MutableStateFlow(State())
|
||||||
|
val state: StateFlow<State> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
viewModelScope.launch {
|
||||||
|
sessionManager.state
|
||||||
|
.map { (it as? Session.SignedIn)?.user?.id }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.collect { userId ->
|
||||||
|
// Signed out: drop the rows rather than leave the last
|
||||||
|
// account's on screen behind a shell that is about to
|
||||||
|
// navigate away.
|
||||||
|
if (userId == null) _state.value = State(items = UiState.Success(emptyList()))
|
||||||
|
else load()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load() {
|
||||||
|
_state.value = State()
|
||||||
|
viewModelScope.launch {
|
||||||
|
val result = repository.history(PAGE)
|
||||||
|
_state.value = State(
|
||||||
|
items = result.toUiState(),
|
||||||
|
// A full page means there is probably another; a short one is the
|
||||||
|
// end. One request rather than a count the server does not send.
|
||||||
|
hasMore = (result as? ApiResult.Ok)?.data?.size == PAGE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadMore() {
|
||||||
|
val current = _state.value
|
||||||
|
val shown = (current.items as? UiState.Success)?.data ?: return
|
||||||
|
val last = shown.lastOrNull() ?: return
|
||||||
|
if (current.loadingMore || !current.hasMore) return
|
||||||
|
|
||||||
|
_state.value = current.copy(loadingMore = true)
|
||||||
|
viewModelScope.launch {
|
||||||
|
when (val result = repository.history(PAGE, before = last.id)) {
|
||||||
|
is ApiResult.Ok -> _state.value = State(
|
||||||
|
items = UiState.Success(shown + result.data),
|
||||||
|
hasMore = result.data.size == PAGE,
|
||||||
|
)
|
||||||
|
// A failed NEXT page keeps the pages already read rather than
|
||||||
|
// replacing a screenful of history with an error: the reader can
|
||||||
|
// still see what loaded, and tapping again retries.
|
||||||
|
else -> _state.value = current.copy(loadingMore = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val PAGE = 25
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,9 +6,12 @@ package com.runicgateway.app.ui.navigation
|
|||||||
import androidx.annotation.StringRes
|
import androidx.annotation.StringRes
|
||||||
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.data.repository.Capability
|
||||||
import com.runicgateway.app.data.repository.ShardFeature
|
import com.runicgateway.app.data.repository.ShardFeature
|
||||||
import com.runicgateway.app.data.repository.ShardFeatures
|
import com.runicgateway.app.data.repository.ShardFeatures
|
||||||
|
import com.runicgateway.app.data.repository.SiteCapabilities
|
||||||
import com.runicgateway.app.data.repository.canSee
|
import com.runicgateway.app.data.repository.canSee
|
||||||
|
import com.runicgateway.app.data.repository.canUse
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
|
* One shared, declarative, access-level navigation definition (PLAN.md §5): a
|
||||||
@@ -52,6 +55,20 @@ data class MenuEntry(
|
|||||||
* isn't shard-derived and only [access] applies.
|
* isn't shard-derived and only [access] applies.
|
||||||
*/
|
*/
|
||||||
val feature: String? = null,
|
val feature: String? = null,
|
||||||
|
/**
|
||||||
|
* The backend capability this row needs, or null when it needs none (M13).
|
||||||
|
*
|
||||||
|
* **A different question from [feature], which is why it is a second field
|
||||||
|
* and not a wider one.** This asks whether the code behind the row is
|
||||||
|
* *installed at all* — a per-HOST fact, from `GET /public/modules` and core's
|
||||||
|
* own list — while [feature] asks whether this shard publishes that surface
|
||||||
|
* to *this viewer*, which is per-viewer and admin-configurable. A site with no
|
||||||
|
* game module has no `shard` capability and no shard rows, whoever is looking;
|
||||||
|
* a site with one may still hide its market from anonymous visitors.
|
||||||
|
*
|
||||||
|
* The two also fail differently, and [canUse] is where that lives.
|
||||||
|
*/
|
||||||
|
val capability: String? = null,
|
||||||
/**
|
/**
|
||||||
* An admin's own label for this row, from the shard's `nav_public` override
|
* An admin's own label for this row, from the shard's `nav_public` override
|
||||||
* (THEMING_AND_NAV.md §6). Null — always, as coded — means [labelRes] stands.
|
* (THEMING_AND_NAV.md §6). Null — always, as coded — means [labelRes] stands.
|
||||||
@@ -71,21 +88,84 @@ data class MenuEntry(
|
|||||||
val APP_MENU: List<MenuEntry> = listOf(
|
val APP_MENU: List<MenuEntry> = listOf(
|
||||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||||
MenuEntry(Routes.NEWS, R.string.menu_news),
|
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||||
|
// Events are CORE's, so this row is gated on core's own capability rather than
|
||||||
|
// a module's: a site with no game module still has a calendar. Placed here to
|
||||||
|
// match the website's own nav, where Events is the row after News.
|
||||||
|
MenuEntry(Routes.EVENTS, R.string.menu_events, capability = Capability.EVENTS),
|
||||||
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||||
MenuEntry(Routes.SHARD, R.string.menu_shard, feature = ShardFeature.STATUS),
|
// The shard group. Every row needs the game module INSTALLED (one capability,
|
||||||
|
// because that is the only question a capability can answer) and its own
|
||||||
|
// feature published to this viewer (M11) — both, independently.
|
||||||
|
MenuEntry(
|
||||||
|
Routes.SHARD,
|
||||||
|
R.string.menu_shard,
|
||||||
|
feature = ShardFeature.STATUS,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
// Protocol 3.0 shard content (M11). Each hides when the shard doesn't publish it,
|
// Protocol 3.0 shard content (M11). Each hides when the shard doesn't publish it,
|
||||||
// which for a brand-new install is every one of them until the plugin has swept.
|
// which for a brand-new install is every one of them until the plugin has swept.
|
||||||
MenuEntry(Routes.SHARD_RULES, R.string.menu_rules, feature = ShardFeature.RULESET),
|
MenuEntry(
|
||||||
MenuEntry(Routes.ATLAS, R.string.menu_atlas, feature = ShardFeature.ATLAS),
|
Routes.SHARD_RULES,
|
||||||
MenuEntry(Routes.SHARD_LEADERBOARDS, R.string.menu_leaderboards, feature = ShardFeature.LEADERBOARDS),
|
R.string.menu_rules,
|
||||||
MenuEntry(Routes.SHARD_MARKET, R.string.menu_market, feature = ShardFeature.MARKET),
|
feature = ShardFeature.RULESET,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
|
MenuEntry(
|
||||||
|
Routes.ATLAS,
|
||||||
|
R.string.menu_atlas,
|
||||||
|
feature = ShardFeature.ATLAS,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
|
MenuEntry(
|
||||||
|
Routes.SHARD_LEADERBOARDS,
|
||||||
|
R.string.menu_leaderboards,
|
||||||
|
feature = ShardFeature.LEADERBOARDS,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
|
MenuEntry(
|
||||||
|
Routes.SHARD_MARKET,
|
||||||
|
R.string.menu_market,
|
||||||
|
feature = ShardFeature.MARKET,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
MenuEntry(Routes.page("about"), R.string.menu_about),
|
MenuEntry(Routes.page("about"), R.string.menu_about),
|
||||||
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
MenuEntry(Routes.CONTACT, R.string.menu_contact),
|
||||||
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
|
||||||
MenuEntry(Routes.NOTIFICATIONS, R.string.menu_notifications, MenuAccess.SIGNED_IN),
|
MenuEntry(Routes.NOTIFICATIONS, R.string.menu_notifications, MenuAccess.SIGNED_IN),
|
||||||
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
|
// Participation history: SIGNED_IN, not PLAYER. The route is `requireAuth`
|
||||||
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
|
// alone and self-scoped on the caller's own id, and the website needed two
|
||||||
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
|
// mounts for it only because `RequirePlayer` guards `/account` there. Staff
|
||||||
|
// attend events too, and event history is not game-linked data.
|
||||||
|
MenuEntry(
|
||||||
|
Routes.MY_EVENTS,
|
||||||
|
R.string.menu_my_events,
|
||||||
|
MenuAccess.SIGNED_IN,
|
||||||
|
capability = Capability.EVENTS,
|
||||||
|
),
|
||||||
|
// These three read `/player/shard/*`, which is the SAME module's player mount —
|
||||||
|
// so they need the capability for the same reason the public rows do. They
|
||||||
|
// carry no `feature`, because the visibility framework covers the public
|
||||||
|
// surfaces and these are self-service, gated by role and ownership instead.
|
||||||
|
// That asymmetry is exactly why the live walk found them and the suite did
|
||||||
|
// not: "a shard row" had been defined as "a row with a feature".
|
||||||
|
MenuEntry(
|
||||||
|
Routes.PLAYER_CHARACTERS,
|
||||||
|
R.string.menu_my_characters,
|
||||||
|
MenuAccess.PLAYER,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
|
MenuEntry(
|
||||||
|
Routes.PLAYER_VENDORS,
|
||||||
|
R.string.menu_my_vendors,
|
||||||
|
MenuAccess.PLAYER,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
|
MenuEntry(
|
||||||
|
Routes.PLAYER_HOUSES,
|
||||||
|
R.string.menu_my_houses,
|
||||||
|
MenuAccess.PLAYER,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
),
|
||||||
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
|
// Staff operations (§1, M10) — revealed for staff roles; the backend re-checks every call.
|
||||||
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
|
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, MenuAccess.STAFF),
|
||||||
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
|
MenuEntry(Routes.ADMIN_CONTENT, R.string.menu_admin_content, MenuAccess.STAFF),
|
||||||
@@ -94,24 +174,33 @@ val APP_MENU: List<MenuEntry> = listOf(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The entries the given [session] may see, given the shard [features] it may reach.
|
* The entries the given [session] may see, on a backend with these [capabilities]
|
||||||
* Pure + side-effect-free so the gating is unit-tested without Compose.
|
* and this shard's [features]. Pure + side-effect-free so the gating is unit-tested
|
||||||
|
* without Compose.
|
||||||
*
|
*
|
||||||
* Two independent filters, and both must pass:
|
* Three independent filters, and all three must pass:
|
||||||
*
|
*
|
||||||
* - [MenuEntry.access] against the session — who the caller is.
|
* - [MenuEntry.access] against the session — who the caller is.
|
||||||
|
* - [MenuEntry.capability] against what this backend serves — whether the code
|
||||||
|
* behind the row is installed at all (M13). Per host.
|
||||||
* - [MenuEntry.feature] against the shard's live visibility config — what this shard
|
* - [MenuEntry.feature] against the shard's live visibility config — what this shard
|
||||||
* publishes at all (M11). `null` [features] means the answer isn't known yet and
|
* publishes to this viewer (M11). Per viewer.
|
||||||
* every shard entry shows; see [canSee] for why that direction is deliberate.
|
*
|
||||||
|
* **The last two both fail open on an unknown answer, but "unknown" means
|
||||||
|
* different things to them.** A `null` [features] is unknown; so is a `null`
|
||||||
|
* [capabilities] — but a *non-null* [capabilities] that does not name the string
|
||||||
|
* is an ANSWER, and it hides. Without that, a site with no game module renders
|
||||||
|
* five shard rows that each 404. See [canUse].
|
||||||
*/
|
*/
|
||||||
fun visibleEntries(
|
fun visibleEntries(
|
||||||
entries: List<MenuEntry>,
|
entries: List<MenuEntry>,
|
||||||
session: Session,
|
session: Session,
|
||||||
features: ShardFeatures? = null,
|
features: ShardFeatures? = null,
|
||||||
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features) }
|
capabilities: SiteCapabilities? = null,
|
||||||
|
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features, capabilities) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [visibleEntries] for a single entry — the same two filters, and the same
|
* [visibleEntries] for a single entry — the same three filters, and the same
|
||||||
* boundary. Split out because the drawer is a tree once an admin groups rows into
|
* 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
|
* 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
|
* both callers must ask exactly one question or a sectioned row could be gated by
|
||||||
@@ -121,6 +210,7 @@ fun isEntryVisible(
|
|||||||
entry: MenuEntry,
|
entry: MenuEntry,
|
||||||
session: Session,
|
session: Session,
|
||||||
features: ShardFeatures? = null,
|
features: ShardFeatures? = null,
|
||||||
|
capabilities: SiteCapabilities? = null,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val allowedByRole = when (entry.access) {
|
val allowedByRole = when (entry.access) {
|
||||||
MenuAccess.PUBLIC -> true
|
MenuAccess.PUBLIC -> true
|
||||||
@@ -129,5 +219,7 @@ fun isEntryVisible(
|
|||||||
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
|
||||||
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
|
||||||
}
|
}
|
||||||
return allowedByRole && (entry.feature == null || canSee(features, entry.feature))
|
return allowedByRole &&
|
||||||
|
canUse(capabilities, entry.capability) &&
|
||||||
|
(entry.feature == null || canSee(features, entry.feature))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,34 +11,68 @@ import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
|||||||
* The public nav an admin edits is keyed by **website** paths, so honoring it in
|
* The public nav an admin edits is keyed by **website** paths, so honoring it in
|
||||||
* the app needs a translation. This is the one new piece of cross-repo coupling
|
* the app needs a translation. This is the one new piece of cross-repo coupling
|
||||||
* the milestone introduces, which is why it lives in a single file with the
|
* the milestone introduces, which is why it lives in a single file with the
|
||||||
* website's own array quoted right beside it — the coupling is visible and
|
* website's own arrays quoted right beside it — the coupling is visible and
|
||||||
* reviewable in one place rather than spread across the drawer's call sites.
|
* reviewable in one place rather than spread across the drawer's call sites.
|
||||||
*
|
*
|
||||||
|
* ## The nav is TWO arrays now, and that is what M13 had to correct
|
||||||
|
*
|
||||||
|
* This file was written when the website's public nav was one sixteen-row array.
|
||||||
|
* Since the module-system cutover on 2026-08-12 it is **core's eight rows plus
|
||||||
|
* every installed module's**, interleaved at render time by `withModuleNav`, and
|
||||||
|
* a module's pages are mounted by core at `/<module id>/<path>` — so the nine
|
||||||
|
* shard rows moved from `/site/champs` to `/uo/champs` and this table stopped
|
||||||
|
* resolving any of them. Three things followed, all of them true of the shipped
|
||||||
|
* app until M13: a nav override on a shard row was ignored, an added link to a
|
||||||
|
* shard page handed off to a browser instead of opening natively, and the sort
|
||||||
|
* key line below was a sixteen-row line against a nav numbered differently.
|
||||||
|
*
|
||||||
|
* **The nine `/uo/` paths are hardcoded, and they are ONE module's.** The alternative
|
||||||
|
* — reading the installed module's id from `GET /public/modules` and building
|
||||||
|
* `/<id>/shard` — is forbidden by `MODULE_API.md` §2.9 (*"a client must not infer
|
||||||
|
* a route from a capability"*) and would hardcode the same path shape less
|
||||||
|
* visibly. A site running a different game module matches none of these nine, its
|
||||||
|
* links hand off to a Custom Tab, and that is the correct answer rather than a
|
||||||
|
* gap: core cannot tell the app what another module calls its pages.
|
||||||
|
*
|
||||||
* Verbatim from `website/client/src/components/SiteHeader.jsx`, which is the
|
* Verbatim from `website/client/src/components/SiteHeader.jsx`, which is the
|
||||||
* exported owner of the list (`export const NAV`, and Admin → Navigation edits
|
* exported owner of core's list (`export const NAV`, and Admin → Navigation edits
|
||||||
* exactly it):
|
* exactly it):
|
||||||
*
|
*
|
||||||
* ```js
|
* ```js
|
||||||
* export const NAV = [
|
* export const NAV = [
|
||||||
* { label: 'Home', to: '/', end: true },
|
* { label: 'Home', to: '/', end: true },
|
||||||
* { label: 'News', to: '/site/news' },
|
* { label: 'News', to: '/site/news' },
|
||||||
|
* { label: 'Events', to: '/site/events' },
|
||||||
* { label: 'Screenshots', to: '/site/screenshots' },
|
* { label: 'Screenshots', to: '/site/screenshots' },
|
||||||
* { label: 'Five on Friday', to: '/site/five-on-friday' },
|
* { label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||||
* { label: 'Newsletter', to: '/site/newsletter' },
|
* { label: 'Newsletter', to: '/site/newsletter' },
|
||||||
* { label: 'Wiki', to: '/wiki' },
|
* { label: 'Wiki', to: '/wiki' },
|
||||||
* { label: 'Shard', to: '/site/shard', feature: 'status' },
|
|
||||||
* { label: 'Champions', to: '/site/champs', feature: 'champs' },
|
|
||||||
* { label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
|
||||||
* { label: 'Governors', to: '/site/governors', feature: 'governors' },
|
|
||||||
* { label: 'Houses', to: '/site/houses', feature: 'houses' },
|
|
||||||
* { label: 'Rules', to: '/site/rules', feature: 'ruleset' },
|
|
||||||
* { label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
|
|
||||||
* { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
|
|
||||||
* { label: 'Market', to: '/site/market', feature: 'market' },
|
|
||||||
* { label: 'About', to: '/site/about' },
|
* { label: 'About', to: '/site/about' },
|
||||||
* ]
|
* ]
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
|
* and from `module-uo/client/src/entry.jsx`, which registers the rest:
|
||||||
|
*
|
||||||
|
* ```jsx
|
||||||
|
* registry.registerNav(ID, {
|
||||||
|
* area: 'public',
|
||||||
|
* items: [
|
||||||
|
* { label: 'Shard', to: '/uo/shard', feature: 'status' },
|
||||||
|
* { label: 'Champions', to: '/uo/champs', feature: 'champs' },
|
||||||
|
* { label: 'Guilds', to: '/uo/guilds', feature: 'guilds' },
|
||||||
|
* { label: 'Governors', to: '/uo/governors', feature: 'governors' },
|
||||||
|
* { label: 'Houses', to: '/uo/houses', feature: 'houses' },
|
||||||
|
* { label: 'Rules', to: '/uo/rules', feature: 'ruleset' },
|
||||||
|
* { label: 'Atlas', to: '/uo/atlas', feature: 'atlas' },
|
||||||
|
* { label: 'Leaderboards', to: '/uo/leaderboards', feature: 'leaderboards' },
|
||||||
|
* { label: 'Market', to: '/uo/market', feature: 'market' },
|
||||||
|
* ],
|
||||||
|
* })
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* None of those nine declares an `order`, so `mergeFlat` appends them after core's
|
||||||
|
* rows in registration order — which is the order they are listed in below.
|
||||||
|
*
|
||||||
* The `feature` values are **not** mirrored here on purpose. [APP_MENU] is the
|
* The `feature` values are **not** mirrored here on purpose. [APP_MENU] is the
|
||||||
* app's own source of truth for gating, and a second copy of a security-relevant
|
* app's own source of truth for gating, and a second copy of a security-relevant
|
||||||
* value that drifts silently is worth more than it costs. This table carries the
|
* value that drifts silently is worth more than it costs. This table carries the
|
||||||
@@ -58,27 +92,33 @@ data class WebNavPath(val path: String, val route: String)
|
|||||||
* *this* list (the admin's editor writes the position a row holds on the web), so
|
* *this* list (the admin's editor writes the position a row holds on the web), so
|
||||||
* a row the admin never moved has to take its key from the same number line or
|
* a row the admin never moved has to take its key from the same number line or
|
||||||
* explicit and implicit keys would be incomparable. See `NavOverrides.kt`.
|
* explicit and implicit keys would be incomparable. See `NavOverrides.kt`.
|
||||||
|
*
|
||||||
|
* Core's eight first, then the module's nine, because that is what `withModuleNav`
|
||||||
|
* renders and therefore what the admin's editor numbered.
|
||||||
*/
|
*/
|
||||||
val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
|
val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
|
||||||
WebNavPath("/", Routes.HOME),
|
WebNavPath("/", Routes.HOME),
|
||||||
WebNavPath("/site/news", Routes.NEWS),
|
WebNavPath("/site/news", Routes.NEWS),
|
||||||
|
WebNavPath("/site/events", Routes.EVENTS),
|
||||||
// The app's News screen carries all four categories as tabs, so these three
|
// The app's News screen carries all four categories as tabs, so these three
|
||||||
// have a route but no drawer row of their own — see the note below.
|
// have a route but no drawer row of their own — see the note below.
|
||||||
WebNavPath("/site/screenshots", Routes.news(PostCategory.SCREENSHOTS)),
|
WebNavPath("/site/screenshots", Routes.news(PostCategory.SCREENSHOTS)),
|
||||||
WebNavPath("/site/five-on-friday", Routes.news(PostCategory.FIVE_ON_FRIDAY)),
|
WebNavPath("/site/five-on-friday", Routes.news(PostCategory.FIVE_ON_FRIDAY)),
|
||||||
WebNavPath("/site/newsletter", Routes.news(PostCategory.NEWSLETTER)),
|
WebNavPath("/site/newsletter", Routes.news(PostCategory.NEWSLETTER)),
|
||||||
WebNavPath("/wiki", Routes.WIKI),
|
WebNavPath("/wiki", Routes.WIKI),
|
||||||
WebNavPath("/site/shard", Routes.SHARD),
|
|
||||||
// Behind the Shard hub in the app, deliberately — no drawer row either.
|
|
||||||
WebNavPath("/site/champs", Routes.SHARD_CHAMPS),
|
|
||||||
WebNavPath("/site/guilds", Routes.SHARD_GUILDS),
|
|
||||||
WebNavPath("/site/governors", Routes.SHARD_GOVERNORS),
|
|
||||||
WebNavPath("/site/houses", Routes.SHARD_HOUSES),
|
|
||||||
WebNavPath("/site/rules", Routes.SHARD_RULES),
|
|
||||||
WebNavPath("/site/atlas", Routes.ATLAS),
|
|
||||||
WebNavPath("/site/leaderboards", Routes.SHARD_LEADERBOARDS),
|
|
||||||
WebNavPath("/site/market", Routes.SHARD_MARKET),
|
|
||||||
WebNavPath("/site/about", Routes.page("about")),
|
WebNavPath("/site/about", Routes.page("about")),
|
||||||
|
// module-uo's rows. Mounted by core at `/<module id>/<path>`, which is why
|
||||||
|
// every one of these is `/uo/` and not `/site/`.
|
||||||
|
WebNavPath("/uo/shard", Routes.SHARD),
|
||||||
|
// Behind the Shard hub in the app, deliberately — no drawer row either.
|
||||||
|
WebNavPath("/uo/champs", Routes.SHARD_CHAMPS),
|
||||||
|
WebNavPath("/uo/guilds", Routes.SHARD_GUILDS),
|
||||||
|
WebNavPath("/uo/governors", Routes.SHARD_GOVERNORS),
|
||||||
|
WebNavPath("/uo/houses", Routes.SHARD_HOUSES),
|
||||||
|
WebNavPath("/uo/rules", Routes.SHARD_RULES),
|
||||||
|
WebNavPath("/uo/atlas", Routes.ATLAS),
|
||||||
|
WebNavPath("/uo/leaderboards", Routes.SHARD_LEADERBOARDS),
|
||||||
|
WebNavPath("/uo/market", Routes.SHARD_MARKET),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -134,6 +174,11 @@ private fun normalizeWebPath(path: String?): String? {
|
|||||||
*/
|
*/
|
||||||
private val RESERVED_TOP_LEVEL = setOf(
|
private val RESERVED_TOP_LEVEL = setOf(
|
||||||
"admin", "account", "player", "site", "wiki", "invite", "preview", "api", "uploads",
|
"admin", "account", "player", "site", "wiki", "invite", "preview", "api", "uploads",
|
||||||
|
// An installed module's pages are mounted at `/<id>/…` and are not CMS pages.
|
||||||
|
// Only ids the app knows about need listing: an unknown module's `/<id>` would
|
||||||
|
// resolve to a CMS page that 404s, which is the same answer the browser gives
|
||||||
|
// it, and core cannot enumerate them for us here anyway.
|
||||||
|
"uo",
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,16 +198,17 @@ private val RESERVED_TOP_LEVEL = setOf(
|
|||||||
* <Route path="/site/five-on-friday" element={<FiveOnFriday />} />
|
* <Route path="/site/five-on-friday" element={<FiveOnFriday />} />
|
||||||
* <Route path="/site/newsletter" element={<Newsletter />} />
|
* <Route path="/site/newsletter" element={<Newsletter />} />
|
||||||
* <Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
* <Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||||
|
* <Route path="/site/events" element={<Events />} />
|
||||||
|
* <Route path="/site/events/series/:slug" element={<EventSeries />} />
|
||||||
|
* <Route path="/site/events/:slug" element={<EventPage />} />
|
||||||
* <Route path="/site/about" element={<About />} />
|
* <Route path="/site/about" element={<About />} />
|
||||||
* <Route path="/site/status" element={<Status />} />
|
* <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" element={<Wiki />} />
|
||||||
* <Route path="/wiki/:slug" element={<WikiArticle />} />
|
* <Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||||
|
* // Installed modules' pages, mounted at `/<module id>/<path>`:
|
||||||
|
* // /uo/shard, /uo/shard/activity, /uo/champs, /uo/guilds, /uo/guilds/:id,
|
||||||
|
* // /uo/governors, /uo/houses, /uo/rules, /uo/leaderboards, /uo/market,
|
||||||
|
* // /uo/market/vendors/:serial, /uo/atlas, /uo/atlas/:slug
|
||||||
* // CMS pages: top-level /:slug, matched only after the named routes above
|
* // CMS pages: top-level /:slug, matched only after the named routes above
|
||||||
* <Route path="/:slug" element={<CmsPage />} />
|
* <Route path="/:slug" element={<CmsPage />} />
|
||||||
* ```
|
* ```
|
||||||
@@ -177,19 +223,31 @@ private val RESERVED_TOP_LEVEL = setOf(
|
|||||||
* /site/{screenshots,five-on-friday,newsletter}
|
* /site/{screenshots,five-on-friday,newsletter}
|
||||||
* → NEWS, that category's tab
|
* → NEWS, that category's tab
|
||||||
* /site/newsletter/<id> → POST (the site's one post-detail route)
|
* /site/newsletter/<id> → POST (the site's one post-detail route)
|
||||||
|
* /site/events → EVENTS
|
||||||
|
* /site/events/series/<slug> → EVENT_SERIES
|
||||||
|
* /site/events/<slug>[?run=<id>] → EVENT (the one route that takes a query)
|
||||||
* /wiki → WIKI
|
* /wiki → WIKI
|
||||||
* /wiki/<slug> → WIKI_PAGE
|
* /wiki/<slug> → WIKI_PAGE
|
||||||
* /site/<shard surface> → the mapped shard route (§6.2)
|
* /uo/<shard surface> → the mapped shard route (§6.2)
|
||||||
* /site/atlas/<slug> → ATLAS_CREATURE
|
* /uo/atlas/<slug> → ATLAS_CREATURE
|
||||||
* /site/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
||||||
* /site/about → PAGE("about")
|
* /site/about → PAGE("about")
|
||||||
* /<slug> → PAGE(slug), unless <slug> is reserved
|
* /<slug> → PAGE(slug), unless <slug> is reserved
|
||||||
* anything else → null, i.e. the Custom Tab
|
* anything else → null, i.e. the Custom Tab
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* **A path carrying a query or a fragment hands off**, whatever its route part
|
* **A path carrying a query or a fragment hands off — with exactly one
|
||||||
* says. No app route takes either, so a native match would quietly drop what the
|
* exception.** The rule exists because no app route took either, so a native
|
||||||
* admin wrote; the browser honors it exactly.
|
* match would quietly drop what the admin wrote while the browser honors it. The
|
||||||
|
* event page (M13) is the first route that takes a query, and it takes one key:
|
||||||
|
* `run`, which is what every `event.` announcement's `eventUrl` carries. So a
|
||||||
|
* `?run=` on an event path resolves natively and **anything else in a query
|
||||||
|
* string, any second parameter, and any fragment still hand off** — the carve-out
|
||||||
|
* is one key on one path, not a general "parse the query".
|
||||||
|
*
|
||||||
|
* That narrowness is the point: an admin who writes `/site/events/x?utm=mail` gets
|
||||||
|
* the browser, which honors `utm`, rather than an app screen that silently ignored
|
||||||
|
* it.
|
||||||
*
|
*
|
||||||
* Resolving a path is not the same as being allowed to see the screen behind it.
|
* 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
|
* A link to `/site/market` on a shard that does not publish the market lands on
|
||||||
@@ -197,24 +255,70 @@ private val RESERVED_TOP_LEVEL = setOf(
|
|||||||
* URL on the web does too (§6.3).
|
* URL on the web does too (§6.3).
|
||||||
*/
|
*/
|
||||||
fun resolveWebPath(path: String?): String? {
|
fun resolveWebPath(path: String?): String? {
|
||||||
val normalized = normalizeWebPath(path) ?: return null
|
val raw = path?.trim().orEmpty()
|
||||||
if (normalized.any { it == '?' || it == '#' }) return null
|
// A fragment is never honored natively: no app route has one to put it in.
|
||||||
WEB_PATH_TO_ROUTE[normalized]?.let { return it }
|
if (raw.isEmpty() || '#' in raw) return null
|
||||||
|
|
||||||
|
val queryAt = raw.indexOf('?')
|
||||||
|
val query = if (queryAt >= 0) raw.substring(queryAt + 1) else ""
|
||||||
|
val normalized = normalizeWebPath(if (queryAt >= 0) raw.substring(0, queryAt) else raw)
|
||||||
|
?: return null
|
||||||
|
|
||||||
|
if (query.isEmpty()) WEB_PATH_TO_ROUTE[normalized]?.let { return it }
|
||||||
if (!normalized.startsWith("/")) return null
|
if (!normalized.startsWith("/")) return null
|
||||||
|
|
||||||
// Blank segments ("/site//news") mean a malformed path, not a slug.
|
// Blank segments ("/site//news") mean a malformed path, not a slug.
|
||||||
val segments = normalized.removePrefix("/").split('/')
|
val segments = normalized.removePrefix("/").split('/')
|
||||||
if (segments.any { it.isBlank() }) return null
|
if (segments.any { it.isBlank() }) return null
|
||||||
|
|
||||||
|
// The one path that may carry a query, and the one key it may carry. Checked
|
||||||
|
// before the general "a query hands off" rule below, and nowhere else.
|
||||||
|
if (segments.size == 3 && segments[0] == "site" && segments[1] == "events" &&
|
||||||
|
segments[2] != "series"
|
||||||
|
) {
|
||||||
|
// No query is the ordinary case — a link to the event rather than to one
|
||||||
|
// of its occurrences. A query is honored only when it is exactly the run.
|
||||||
|
if (query.isEmpty()) return Routes.event(segments[2])
|
||||||
|
val run = runParam(query) ?: return null
|
||||||
|
return Routes.event(segments[2], run)
|
||||||
|
}
|
||||||
|
if (query.isNotEmpty()) return null
|
||||||
|
|
||||||
return when {
|
return when {
|
||||||
segments.size == 1 -> segments[0].takeIf { it !in RESERVED_TOP_LEVEL }?.let(Routes::page)
|
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] == "wiki" && segments.size == 2 -> Routes.wikiPage(segments[1])
|
||||||
segments[0] != "site" -> null
|
segments[0] == "site" && segments.size == 3 && segments[1] == "newsletter" ->
|
||||||
segments.size == 3 && segments[1] == "newsletter" ->
|
|
||||||
Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2])
|
Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2])
|
||||||
segments.size == 3 && segments[1] == "atlas" -> Routes.atlasCreature(segments[2])
|
segments[0] == "site" && segments.size == 4 && segments[1] == "events" &&
|
||||||
segments.size == 4 && segments[1] == "market" && segments[2] == "vendors" ->
|
segments[2] == "series" -> Routes.eventSeries(segments[3])
|
||||||
Routes.marketVendor(segments[3])
|
segments[0] == MODULE_UO && segments.size == 3 && segments[1] == "atlas" ->
|
||||||
|
Routes.atlasCreature(segments[2])
|
||||||
|
segments[0] == MODULE_UO && segments.size == 4 && segments[1] == "market" &&
|
||||||
|
segments[2] == "vendors" -> Routes.marketVendor(segments[3])
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `run` value of a query that consists of **exactly** `run=<something>`, or
|
||||||
|
* null for every other query — including one that merely contains a `run` among
|
||||||
|
* others.
|
||||||
|
*
|
||||||
|
* Deliberately not a query parser. A second parameter means the writer meant
|
||||||
|
* something the app cannot honor, and the honest answer to that is the browser.
|
||||||
|
* An empty value (`?run=`) is null too: it would reach the screen as a blank
|
||||||
|
* string and be forwarded to the server as one.
|
||||||
|
*/
|
||||||
|
private fun runParam(query: String): String? {
|
||||||
|
val value = query.removePrefix("run=")
|
||||||
|
if (value.length == query.length || value.isEmpty()) return null
|
||||||
|
return value.takeIf { '&' !in it && '=' !in it }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The module id whose public pages this table maps.
|
||||||
|
*
|
||||||
|
* Named once rather than spelled into four branches, so what is coupled to one
|
||||||
|
* module is countable. It is a literal on purpose — see the file header.
|
||||||
|
*/
|
||||||
|
private const val MODULE_UO = "uo"
|
||||||
|
|||||||
@@ -47,6 +47,29 @@ object Routes {
|
|||||||
const val NOTIFICATIONS = "notifications"
|
const val NOTIFICATIONS = "notifications"
|
||||||
const val NOTIFICATIONS_SETTINGS = "notifications/settings"
|
const val NOTIFICATIONS_SETTINGS = "notifications/settings"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Events (§9 M13) — CORE's, not a module's: these screens exist on a backend
|
||||||
|
* with no game module at all, which is why they are not under `shard/`.
|
||||||
|
*
|
||||||
|
* **[EVENT_ROUTE] is the app's first route that takes a query**, and it takes
|
||||||
|
* exactly one: `run`, naming which occurrence a results table is about. The
|
||||||
|
* page lives at the definition's slug so a weekly event has one address that
|
||||||
|
* survives a retitle, and the occurrence has to live somewhere else. See
|
||||||
|
* [resolveWebPath], whose "a query hands off" rule this is the one exception
|
||||||
|
* to.
|
||||||
|
*
|
||||||
|
* **[MY_EVENTS] is `account/events` and not `events/mine`**, which is not
|
||||||
|
* cosmetic: `events/mine` and `events/{slug}` are both two segments, and a
|
||||||
|
* static-versus-argument race between two NavHost patterns is exactly the bug
|
||||||
|
* events Phase 13 shipped one tier along, where a static `events/new` outranked
|
||||||
|
* `events/:id` in React Router and made creating an event impossible for seven
|
||||||
|
* phases. Under `account/` there is no dynamic sibling and no race to lose.
|
||||||
|
*/
|
||||||
|
const val EVENTS = "events"
|
||||||
|
const val EVENT_ROUTE = "events/{slug}?run={run}"
|
||||||
|
const val EVENT_SERIES = "events/series/{slug}"
|
||||||
|
const val MY_EVENTS = "account/events"
|
||||||
|
|
||||||
/** Public shard hub (§6.2). */
|
/** Public shard hub (§6.2). */
|
||||||
const val SHARD = "shard"
|
const val SHARD = "shard"
|
||||||
|
|
||||||
@@ -97,6 +120,7 @@ object Routes {
|
|||||||
const val CATEGORY = "category"
|
const val CATEGORY = "category"
|
||||||
const val ID_OR_SLUG = "idOrSlug"
|
const val ID_OR_SLUG = "idOrSlug"
|
||||||
const val SERIAL = "serial"
|
const val SERIAL = "serial"
|
||||||
|
const val RUN = "run"
|
||||||
}
|
}
|
||||||
|
|
||||||
fun page(slug: String) = "page/$slug"
|
fun page(slug: String) = "page/$slug"
|
||||||
@@ -119,6 +143,21 @@ object Routes {
|
|||||||
/** One creature's atlas page, by slug. */
|
/** One creature's atlas page, by slug. */
|
||||||
fun atlasCreature(slug: String) = "atlas/$slug"
|
fun atlasCreature(slug: String) = "atlas/$slug"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One event's page, optionally about one occurrence.
|
||||||
|
*
|
||||||
|
* [runId] is what an announcement's link carries, and it is dropped when
|
||||||
|
* absent rather than sent as an empty argument — `events/x?run=` would reach
|
||||||
|
* the screen as a blank string and be forwarded to the server as one.
|
||||||
|
*/
|
||||||
|
fun event(slug: String, runId: String? = null): String {
|
||||||
|
val base = "events/$slug"
|
||||||
|
return if (runId.isNullOrBlank()) base else "$base?run=$runId"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One arc, by slug. */
|
||||||
|
fun eventSeries(slug: String) = "events/series/$slug"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The in-app destination a tapped push notification deep-links to (§11, M7
|
* The in-app destination a tapped push notification deep-links to (§11, M7
|
||||||
* Part 2 work item 7). Maps a stream id to the screen that shows its content;
|
* Part 2 work item 7). Maps a stream id to the screen that shows its content;
|
||||||
@@ -135,9 +174,19 @@ object Routes {
|
|||||||
com.runicgateway.app.core.push.PushStreams.VENDOR_SALE -> PLAYER_VENDORS
|
com.runicgateway.app.core.push.PushStreams.VENDOR_SALE -> PLAYER_VENDORS
|
||||||
com.runicgateway.app.core.push.PushStreams.HOUSE_IDOC -> PLAYER_HOUSES
|
com.runicgateway.app.core.push.PushStreams.HOUSE_IDOC -> PLAYER_HOUSES
|
||||||
com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT
|
com.runicgateway.app.core.push.PushStreams.ACCOUNT_LOGIN -> ACCOUNT
|
||||||
else -> HOME
|
// An engagement rule's tickle carries the TRIGGER id as its stream
|
||||||
|
// (ENGAGEMENT.md §7.2's one namespace), and `event.run.started` is the only
|
||||||
|
// event trigger that is also a push stream. The calendar is the honest
|
||||||
|
// destination when there is no inbox row to send it to — the tickle names
|
||||||
|
// no occurrence, so there is no page to open. A row, when there is one,
|
||||||
|
// wins via [forTickle] and carries the link that does.
|
||||||
|
else -> if (streamId.startsWith(EVENT_STREAM_PREFIX)) EVENTS else HOME
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What every core `event.` trigger id begins with (EVENTS.md §J). */
|
||||||
|
private const val EVENT_STREAM_PREFIX = "event."
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Where a tapped tickle lands, given both halves of `{ stream, ref }`.
|
* Where a tapped tickle lands, given both halves of `{ stream, ref }`.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.runicgateway.app.ui.notifications
|
package com.runicgateway.app.ui.notifications
|
||||||
|
|
||||||
import java.time.Instant
|
import com.runicgateway.app.core.time.parseWireInstant
|
||||||
import java.time.LocalDateTime
|
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.time.format.FormatStyle
|
import java.time.format.FormatStyle
|
||||||
@@ -14,11 +13,8 @@ import java.time.format.FormatStyle
|
|||||||
* locale (ENGAGEMENT.md phase 8). Pure, so it is unit-testable off-device.
|
* locale (ENGAGEMENT.md phase 8). Pure, so it is unit-testable off-device.
|
||||||
*
|
*
|
||||||
* **Two shapes have to be accepted, and which one arrives is not the app's to
|
* **Two shapes have to be accepted, and which one arrives is not the app's to
|
||||||
* decide.** Express serializes a `Date` to ISO-8601 with a `Z`, but the value
|
* decide** — see [parseWireInstant], which owns that trap for every screen that
|
||||||
* starts life as a MariaDB `DATETIME`, and a column read back as a string reaches
|
* reads a timestamp, this one and the event screens (M13).
|
||||||
* the wire as `2026-08-31 07:13:50` with no zone at all. A zoneless stamp is read
|
|
||||||
* as UTC — that is what the server stores — rather than as local time, which
|
|
||||||
* would silently shift every timestamp by the device's offset.
|
|
||||||
*
|
*
|
||||||
* Anything unparseable returns null and the row simply shows no stamp: a
|
* Anything unparseable returns null and the row simply shows no stamp: a
|
||||||
* notification with an odd date is still worth reading.
|
* notification with an odd date is still worth reading.
|
||||||
@@ -29,25 +25,10 @@ fun inboxTimestamp(
|
|||||||
formatter: DateTimeFormatter =
|
formatter: DateTimeFormatter =
|
||||||
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT),
|
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT),
|
||||||
): String? {
|
): String? {
|
||||||
val instant = parseInstant(raw) ?: return null
|
val instant = parseWireInstant(raw) ?: return null
|
||||||
return try {
|
return try {
|
||||||
formatter.withZone(zone).format(instant)
|
formatter.withZone(zone).format(instant)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseInstant(raw: String): Instant? {
|
|
||||||
val text = raw.trim()
|
|
||||||
if (text.isEmpty()) return null
|
|
||||||
return try {
|
|
||||||
Instant.parse(text)
|
|
||||||
} catch (_: Exception) {
|
|
||||||
try {
|
|
||||||
// No zone on the wire → UTC, because that is what the server stored.
|
|
||||||
LocalDateTime.parse(text.replace(' ', 'T')).atZone(ZoneId.of("UTC")).toInstant()
|
|
||||||
} catch (_: Exception) {
|
|
||||||
null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ import com.runicgateway.app.ui.components.ShardCard
|
|||||||
@Composable
|
@Composable
|
||||||
fun InboxScreen(
|
fun InboxScreen(
|
||||||
onOpenSettings: () -> Unit,
|
onOpenSettings: () -> Unit,
|
||||||
|
onOpenRoute: (String) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
viewModel: InboxViewModel = hiltViewModel(),
|
viewModel: InboxViewModel = hiltViewModel(),
|
||||||
) {
|
) {
|
||||||
@@ -119,10 +120,18 @@ fun InboxScreen(
|
|||||||
onOpen = { item ->
|
onOpen = { item ->
|
||||||
viewModel.markRead(item.id)
|
viewModel.markRead(item.id)
|
||||||
// Most items have no url at all — an inbox row is complete on
|
// Most items have no url at all — an inbox row is complete on
|
||||||
// its own — and the ones that do carry a SITE-RELATIVE path,
|
// its own — and the ones that do carry a SITE-RELATIVE path.
|
||||||
// so the view model resolves it against the configured shard
|
//
|
||||||
// before anything is opened.
|
// A path the app has a screen for opens natively (M13): an
|
||||||
viewModel.linkFor(item)?.let { WebHandoff.open(context, it) }
|
// event announcement's link is the case that made this worth
|
||||||
|
// doing. Everything else resolves against the configured
|
||||||
|
// shard and goes to the browser, exactly as before.
|
||||||
|
val route = viewModel.routeFor(item)
|
||||||
|
if (route != null) {
|
||||||
|
onOpenRoute(route)
|
||||||
|
} else {
|
||||||
|
viewModel.linkFor(item)?.let { WebHandoff.open(context, it) }
|
||||||
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.runicgateway.app.core.result.map
|
|||||||
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||||
import com.runicgateway.app.ui.UiState
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.ui.navigation.resolveWebPath
|
||||||
import com.runicgateway.app.ui.toUiState
|
import com.runicgateway.app.ui.toUiState
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@@ -220,6 +221,38 @@ class InboxViewModel @Inject constructor(
|
|||||||
return baseUrlHolder.current?.resolve(raw)?.toString()
|
return baseUrlHolder.current?.resolve(raw)?.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The app route this item opens natively, or null when it has none and
|
||||||
|
* [linkFor] should hand it to a browser (M13).
|
||||||
|
*
|
||||||
|
* **Why this exists at all:** events Phase 14a gave the six public `event.`
|
||||||
|
* triggers an `eventUrl` of the form `/site/events/<slug>?run=<id>`, so an
|
||||||
|
* inbox row about an event now has a native destination — and opening a
|
||||||
|
* Custom Tab onto a page the app itself renders is a worse answer than it was
|
||||||
|
* when there was no such page.
|
||||||
|
*
|
||||||
|
* **It reuses `resolveWebPath` rather than adding a second link-routing
|
||||||
|
* mechanism.** That function is already the app's read of the site's own route
|
||||||
|
* table, it already answers null for everything it does not recognise, and
|
||||||
|
* every path it does not recognise still hands off exactly as before. Adding a
|
||||||
|
* parser here would put the decision in two places.
|
||||||
|
*
|
||||||
|
* The item's url is site-relative by contract, but an absolute one on this
|
||||||
|
* host is accepted too: the shape is the server's to change, and a link that
|
||||||
|
* opened the browser only because it arrived fully qualified would be a
|
||||||
|
* puzzle. An absolute url on ANOTHER host is not ours to route — the app has
|
||||||
|
* no screen for somebody else's site — so it falls through to the browser.
|
||||||
|
*/
|
||||||
|
fun routeFor(item: NotificationItemDto): String? {
|
||||||
|
val raw = item.url?.trim().orEmpty()
|
||||||
|
if (raw.isEmpty()) return null
|
||||||
|
val base = baseUrlHolder.current ?: return null
|
||||||
|
val resolved = base.resolve(raw) ?: return null
|
||||||
|
if (resolved.host != base.host) return null
|
||||||
|
val query = resolved.query
|
||||||
|
return resolveWebPath(resolved.encodedPath + if (query.isNullOrEmpty()) "" else "?$query")
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Keep the snapshot in step with a local read.
|
* Keep the snapshot in step with a local read.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import com.runicgateway.app.core.auth.SessionManager
|
|||||||
import com.runicgateway.app.data.repository.AuthRepository
|
import com.runicgateway.app.data.repository.AuthRepository
|
||||||
import com.runicgateway.app.data.repository.ShardFeatures
|
import com.runicgateway.app.data.repository.ShardFeatures
|
||||||
import com.runicgateway.app.data.repository.ShardFeaturesRepository
|
import com.runicgateway.app.data.repository.ShardFeaturesRepository
|
||||||
|
import com.runicgateway.app.data.repository.SiteCapabilities
|
||||||
|
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -26,6 +28,7 @@ class SessionViewModel @Inject constructor(
|
|||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
private val authRepository: AuthRepository,
|
private val authRepository: AuthRepository,
|
||||||
shardFeaturesRepository: ShardFeaturesRepository,
|
shardFeaturesRepository: ShardFeaturesRepository,
|
||||||
|
siteCapabilitiesRepository: SiteCapabilitiesRepository,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
val session: StateFlow<Session> = sessionManager.state
|
val session: StateFlow<Session> = sessionManager.state
|
||||||
@@ -38,6 +41,19 @@ class SessionViewModel @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
|
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this BACKEND serves — core's capabilities and every installed module's
|
||||||
|
* (M13). Exposed here for the reason [shardFeatures] is: the shared menu is
|
||||||
|
* the consumer, and a row is filtered by both.
|
||||||
|
*
|
||||||
|
* **Read-only here, and deliberately not refreshed here.** This answer is per
|
||||||
|
* HOST, not per viewer: signing in does not install a module. It is resolved
|
||||||
|
* beside the appearance in [com.runicgateway.app.ui.AppViewModel], which is
|
||||||
|
* what owns the host's lifecycle — first load, resume, and the Settings →
|
||||||
|
* Server switch that invalidates it.
|
||||||
|
*/
|
||||||
|
val capabilities: StateFlow<SiteCapabilities?> = siteCapabilitiesRepository.capabilities
|
||||||
|
|
||||||
init {
|
init {
|
||||||
// The answer is per-viewer, so it is re-resolved on every session change.
|
// The answer is per-viewer, so it is re-resolved on every session change.
|
||||||
// A StateFlow conflates equal values, so a resume revalidation that returns
|
// A StateFlow conflates equal values, so a resume revalidation that returns
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
<string name="nav_opens_in_browser">Opens in your browser</string>
|
<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_events">Events</string>
|
||||||
<string name="menu_wiki">Wiki</string>
|
<string name="menu_wiki">Wiki</string>
|
||||||
<string name="menu_shard">Shard</string>
|
<string name="menu_shard">Shard</string>
|
||||||
<string name="menu_rules">Rules</string>
|
<string name="menu_rules">Rules</string>
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
<string name="menu_about">About</string>
|
<string name="menu_about">About</string>
|
||||||
<string name="menu_contact">Contact</string>
|
<string name="menu_contact">Contact</string>
|
||||||
<string name="menu_account">My account</string>
|
<string name="menu_account">My account</string>
|
||||||
|
<string name="menu_my_events">My events</string>
|
||||||
<string name="menu_my_characters">My characters</string>
|
<string name="menu_my_characters">My characters</string>
|
||||||
<string name="menu_my_vendors">My vendors</string>
|
<string name="menu_my_vendors">My vendors</string>
|
||||||
<string name="menu_my_houses">My houses</string>
|
<string name="menu_my_houses">My houses</string>
|
||||||
@@ -525,4 +527,43 @@
|
|||||||
<string name="push_stream_house_idoc">Your house entered IDOC</string>
|
<string name="push_stream_house_idoc">Your house entered IDOC</string>
|
||||||
<string name="push_stream_account_login">Login to your account</string>
|
<string name="push_stream_account_login">Login to your account</string>
|
||||||
<string name="push_stream_generic">New notification</string>
|
<string name="push_stream_generic">New notification</string>
|
||||||
|
|
||||||
|
<!-- ── Events (§9 M13, EVENTS.md §I) ─────────────────────────── -->
|
||||||
|
<!--
|
||||||
|
The four status words. `cancelled` has TWO, chosen by the clock rather than
|
||||||
|
the status: "did not happen" is right for a past occurrence and false for a
|
||||||
|
future one, and a run four days out that an operator called off is the common
|
||||||
|
case. See EventTimes.statusWordRes.
|
||||||
|
-->
|
||||||
|
<string name="events_status_live">Happening now</string>
|
||||||
|
<string name="events_status_scheduled">Scheduled</string>
|
||||||
|
<string name="events_status_completed">Finished</string>
|
||||||
|
<string name="events_status_cancelled">Cancelled</string>
|
||||||
|
<string name="events_status_did_not_happen">Did not happen</string>
|
||||||
|
|
||||||
|
<string name="events_empty">Nothing on the calendar just yet — check back soon.</string>
|
||||||
|
<!-- A forecast past the materialisation horizon: nothing is committed to it. -->
|
||||||
|
<string name="events_projected">Expected — not yet confirmed</string>
|
||||||
|
<string name="events_truncated">Showing the first part of a busy calendar.</string>
|
||||||
|
|
||||||
|
<string name="events_part_of">Part of %1$s</string>
|
||||||
|
<string name="events_next">Next</string>
|
||||||
|
<string name="events_under_way">Under way</string>
|
||||||
|
<string name="events_nothing_scheduled">Nothing scheduled at the moment.</string>
|
||||||
|
<string name="events_never_scheduled">This event has not been scheduled yet.</string>
|
||||||
|
<string name="events_coming_up">Coming up</string>
|
||||||
|
<string name="events_previously">Previously</string>
|
||||||
|
<string name="events_results">Results</string>
|
||||||
|
<string name="events_results_nobody">Results were published with nobody recorded.</string>
|
||||||
|
<!-- A module puts a display name in its participation meta or it does not; the
|
||||||
|
member key is never published, so there is nothing else to render. -->
|
||||||
|
<string name="events_participant_unnamed">Unnamed</string>
|
||||||
|
|
||||||
|
<string name="events_history_empty">You have not taken part in an event yet.</string>
|
||||||
|
<string name="events_rank">Rank %1$d</string>
|
||||||
|
<!-- Not a dash: an unranked row is a real state, not a missing value. -->
|
||||||
|
<string name="events_results_unpublished">Results not published</string>
|
||||||
|
<string name="events_score">Score %1$s</string>
|
||||||
|
<string name="events_show_more">Show more</string>
|
||||||
|
<string name="events_loading">Loading…</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.api.fake
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.EventsApi
|
||||||
|
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventHistoryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventSeriesResponse
|
||||||
|
import com.runicgateway.app.data.api.dto.PublicEventResponse
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A configurable fake of [EventsApi] (M13). Set the `var` a call should answer
|
||||||
|
* with; set [error] to make every call throw.
|
||||||
|
*
|
||||||
|
* [lastRun] and [lastBefore] are what the tests that matter assert on: the run a
|
||||||
|
* page was asked about, and the keyset cursor a history page walked back from.
|
||||||
|
*/
|
||||||
|
class FakeEventsApi : EventsApi {
|
||||||
|
|
||||||
|
var error: Throwable? = null
|
||||||
|
|
||||||
|
var calendar: EventCalendarDto = EventCalendarDto()
|
||||||
|
var event: PublicEventResponse = PublicEventResponse()
|
||||||
|
var series: EventSeriesResponse = EventSeriesResponse()
|
||||||
|
var history: EventHistoryDto = EventHistoryDto()
|
||||||
|
|
||||||
|
/** The `run` the last event read carried, so a test can assert a blank was dropped. */
|
||||||
|
var lastRun: String? = null
|
||||||
|
var lastSlug: String? = null
|
||||||
|
|
||||||
|
/** The keyset cursor the last history page asked for; null on a first page. */
|
||||||
|
var lastBefore: Long? = null
|
||||||
|
var historyCalls: Int = 0
|
||||||
|
|
||||||
|
private fun <T> reply(value: T): T {
|
||||||
|
error?.let { throw it }
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto =
|
||||||
|
reply(calendar)
|
||||||
|
|
||||||
|
override suspend fun getEvent(slug: String, run: String?): PublicEventResponse {
|
||||||
|
lastSlug = slug
|
||||||
|
lastRun = run
|
||||||
|
return reply(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getSeries(slug: String): EventSeriesResponse {
|
||||||
|
lastSlug = slug
|
||||||
|
return reply(series)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getHistory(limit: Int?, before: Long?): EventHistoryDto {
|
||||||
|
historyCalls++
|
||||||
|
lastBefore = before
|
||||||
|
return reply(history)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ import com.runicgateway.app.data.api.dto.GovernorDto
|
|||||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||||
import com.runicgateway.app.data.api.dto.GuildDto
|
import com.runicgateway.app.data.api.dto.GuildDto
|
||||||
import com.runicgateway.app.data.api.dto.HouseDto
|
import com.runicgateway.app.data.api.dto.HouseDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ModulesDto
|
||||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||||
import com.runicgateway.app.data.api.dto.PageDto
|
import com.runicgateway.app.data.api.dto.PageDto
|
||||||
import com.runicgateway.app.data.api.dto.PostDto
|
import com.runicgateway.app.data.api.dto.PostDto
|
||||||
@@ -67,6 +68,19 @@ class FakePublicApi : PublicApi {
|
|||||||
var houses: List<HouseDto> = emptyList()
|
var houses: List<HouseDto> = emptyList()
|
||||||
var shardFeatures: ShardFeaturesDto = ShardFeaturesDto()
|
var shardFeatures: ShardFeaturesDto = ShardFeaturesDto()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /public/modules` (M13). Empty by default, which is a real answer: a
|
||||||
|
* backend serving no modules at all.
|
||||||
|
*/
|
||||||
|
var modules: ModulesDto = ModulesDto()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-call failures, for the one thing [error] cannot express: capability
|
||||||
|
* resolution reads TWO routes and one failing is not the same as both.
|
||||||
|
*/
|
||||||
|
var statusError: Throwable? = null
|
||||||
|
var modulesError: Throwable? = null
|
||||||
|
|
||||||
// Protocol 3.0 content (M11). `ruleset` is nullable on the wire: null means the
|
// Protocol 3.0 content (M11). `ruleset` is nullable on the wire: null means the
|
||||||
// shard has never published one, which is a success, not a failure.
|
// shard has never published one, which is a success, not a failure.
|
||||||
var ruleset: RulesetDto? = null
|
var ruleset: RulesetDto? = null
|
||||||
@@ -94,9 +108,17 @@ class FakePublicApi : PublicApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun probeStatus(absoluteStatusUrl: String): StatusDto = reply(status)
|
override suspend fun probeStatus(absoluteStatusUrl: String): StatusDto = reply(status)
|
||||||
override suspend fun getStatus(): StatusDto = reply(status)
|
override suspend fun getStatus(): StatusDto {
|
||||||
|
statusError?.let { throw it }
|
||||||
|
return reply(status)
|
||||||
|
}
|
||||||
override suspend fun getSettings(): SettingsDto = reply(settings)
|
override suspend fun getSettings(): SettingsDto = reply(settings)
|
||||||
|
|
||||||
|
override suspend fun getModules(): ModulesDto {
|
||||||
|
modulesError?.let { throw it }
|
||||||
|
return reply(modules)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getPosts(category: String): List<PostDto> = reply(posts)
|
override suspend fun getPosts(category: String): List<PostDto> = reply(posts)
|
||||||
override suspend fun getPost(category: String, idOrSlug: String): PostDto = reply(post)
|
override suspend fun getPost(category: String, idOrSlug: String): PostDto = reply(post)
|
||||||
override suspend fun getPage(slug: String): PageDto = reply(page)
|
override suspend fun getPage(slug: String): PageDto = reply(page)
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.data.repository
|
||||||
|
|
||||||
|
import com.runicgateway.app.data.api.dto.InstalledModuleDto
|
||||||
|
import com.runicgateway.app.data.api.dto.ModulesDto
|
||||||
|
import com.runicgateway.app.data.api.dto.StatusDto
|
||||||
|
import com.runicgateway.app.data.api.dto.VersionDto
|
||||||
|
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||||
|
import com.runicgateway.app.util.httpError
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNotNull
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What this backend serves, and — the point of the class — the three different
|
||||||
|
* things "we don't know" can mean (PLAN.md §9 M13).
|
||||||
|
*
|
||||||
|
* **Absence of an answer is not an answer of absence.** Before M13 the app
|
||||||
|
* collapsed a 404, a dead network and "no such module" into one `null` and
|
||||||
|
* treated all three as "show everything", which rendered five shard rows that
|
||||||
|
* each 404 on a site running a different game.
|
||||||
|
*/
|
||||||
|
class SiteCapabilitiesRepositoryTest {
|
||||||
|
|
||||||
|
private val api = FakePublicApi()
|
||||||
|
private val repository = SiteCapabilitiesRepository(api)
|
||||||
|
|
||||||
|
private fun serving(core: List<String>, moduleCaps: List<String>) {
|
||||||
|
api.status = StatusDto(version = VersionDto(capabilities = core))
|
||||||
|
api.modules = ModulesDto(
|
||||||
|
modules = listOf(InstalledModuleDto(id = "uo", capabilities = moduleCaps)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun bothListsAreMergedAndStaySeparable() = runTest {
|
||||||
|
serving(core = listOf("events"), moduleCaps = listOf("shard", "atlas"))
|
||||||
|
repository.refresh()
|
||||||
|
|
||||||
|
val answer = repository.capabilities.value!!
|
||||||
|
assertEquals(setOf("events"), answer.core)
|
||||||
|
assertEquals(setOf("shard", "atlas"), answer.modules)
|
||||||
|
// A menu entry does not care which half serves it.
|
||||||
|
assertTrue("events" in answer)
|
||||||
|
assertTrue("shard" in answer)
|
||||||
|
assertFalse("market" in answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aBackendWithNoModulesAnswersRatherThanFailing() = runTest {
|
||||||
|
api.status = StatusDto(version = VersionDto(capabilities = listOf("events")))
|
||||||
|
api.modules = ModulesDto(modules = emptyList())
|
||||||
|
repository.refresh()
|
||||||
|
|
||||||
|
val answer = repository.capabilities.value!!
|
||||||
|
assertTrue("events" in answer)
|
||||||
|
// The answer that hides the shard rows, and the whole reason for the class.
|
||||||
|
assertFalse("shard" in answer)
|
||||||
|
assertFalse(canUse(answer, Capability.SHARD))
|
||||||
|
assertTrue(canUse(answer, Capability.EVENTS))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aBackendOlderThanEventsOmitsTheKeyAndThatIsAnAnswer() = runTest {
|
||||||
|
// No `capabilities` in the version block at all — the value is in what is
|
||||||
|
// absent, and it must not read as "unknown".
|
||||||
|
api.status = StatusDto(version = VersionDto(service = "runic-gateway"))
|
||||||
|
api.modules = ModulesDto(modules = listOf(InstalledModuleDto(id = "uo", capabilities = listOf("shard"))))
|
||||||
|
repository.refresh()
|
||||||
|
|
||||||
|
val answer = repository.capabilities.value!!
|
||||||
|
assertTrue(answer.core.isEmpty())
|
||||||
|
assertFalse(canUse(answer, Capability.EVENTS))
|
||||||
|
assertTrue(canUse(answer, Capability.SHARD))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The three failure directions ─────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun aHostThatHasNeverAnsweredLeavesTheGateOpen() = runTest {
|
||||||
|
api.error = IOException("offline")
|
||||||
|
repository.refresh()
|
||||||
|
|
||||||
|
// Null, not empty. The drawer renders as it did before this existed rather
|
||||||
|
// than flickering its rows in on every cold start.
|
||||||
|
assertNull(repository.capabilities.value)
|
||||||
|
assertTrue(canUse(repository.capabilities.value, Capability.SHARD))
|
||||||
|
assertTrue(canUse(repository.capabilities.value, Capability.EVENTS))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aFailedRefreshKeepsTheLastAnswer() = runTest {
|
||||||
|
serving(core = listOf("events"), moduleCaps = listOf("shard"))
|
||||||
|
repository.refresh()
|
||||||
|
|
||||||
|
api.error = IOException("offline")
|
||||||
|
repository.refresh()
|
||||||
|
|
||||||
|
// A moment with no connectivity is not an uninstall.
|
||||||
|
val answer = repository.capabilities.value!!
|
||||||
|
assertTrue("shard" in answer)
|
||||||
|
assertTrue("events" in answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun oneCallFailingKeepsThatHalfAndUpdatesTheOther() = runTest {
|
||||||
|
serving(core = listOf("events"), moduleCaps = listOf("shard"))
|
||||||
|
repository.refresh()
|
||||||
|
|
||||||
|
// The module list answers with the game module gone; the status call is down.
|
||||||
|
api.statusError = httpError(500)
|
||||||
|
api.modules = ModulesDto(modules = emptyList())
|
||||||
|
repository.refresh()
|
||||||
|
|
||||||
|
val answer = repository.capabilities.value!!
|
||||||
|
// The half that answered is believed…
|
||||||
|
assertFalse("shard" in answer)
|
||||||
|
// …and the half that did not keeps what it last said.
|
||||||
|
assertTrue("events" in answer)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aFiveHundredOnTheModuleListIsNotAnEmptyList() = runTest {
|
||||||
|
serving(core = listOf("events"), moduleCaps = listOf("shard"))
|
||||||
|
repository.refresh()
|
||||||
|
|
||||||
|
// Core answers 500 for a module list read before its loader ran, precisely
|
||||||
|
// so a caller cannot read it as "no modules installed".
|
||||||
|
api.modulesError = httpError(500)
|
||||||
|
repository.refresh()
|
||||||
|
|
||||||
|
assertTrue("shard" in repository.capabilities.value!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aServerSwitchDropsTheAnswerEntirely() = runTest {
|
||||||
|
serving(core = listOf("events"), moduleCaps = listOf("shard"))
|
||||||
|
repository.refresh()
|
||||||
|
assertNotNull(repository.capabilities.value)
|
||||||
|
|
||||||
|
repository.invalidate()
|
||||||
|
|
||||||
|
// Not "empty" — unknown. The new host has said nothing, and inheriting the
|
||||||
|
// old one's answer would hide its shard rows until its first read lands.
|
||||||
|
assertNull(repository.capabilities.value)
|
||||||
|
assertTrue(canUse(repository.capabilities.value, Capability.SHARD))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun twoModulesMayDeclareTheSameString() = runTest {
|
||||||
|
api.status = StatusDto()
|
||||||
|
api.modules = ModulesDto(
|
||||||
|
modules = listOf(
|
||||||
|
InstalledModuleDto(id = "uo", capabilities = listOf("shard")),
|
||||||
|
InstalledModuleDto(id = "other", capabilities = listOf("shard", "cards")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
repository.refresh()
|
||||||
|
|
||||||
|
assertEquals(setOf("shard", "cards"), repository.capabilities.value!!.modules)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import com.runicgateway.app.R
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNotEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rendering an event's instant and its status word (EVENTS.md §I).
|
||||||
|
*
|
||||||
|
* Two of these are regression tests for defects the WEBSITE shipped and its live
|
||||||
|
* walk caught in events Phase 14a — restated in Kotlin because a rule that is
|
||||||
|
* only written down in another language gets re-derived wrong.
|
||||||
|
*/
|
||||||
|
class EventTimesTest {
|
||||||
|
|
||||||
|
private val uk = Locale.UK
|
||||||
|
|
||||||
|
// ── The zone split: the day is the reader's, the time is the event's ──
|
||||||
|
|
||||||
|
@Test fun theTimeIsTheEventsZoneNotTheReaders() {
|
||||||
|
// 2026-09-11T00:00Z is 20:00 the previous evening in New York. A shard's
|
||||||
|
// 8pm event is 8pm to everyone reading about it; rendering the reader's
|
||||||
|
// 02:00 would be true and useless.
|
||||||
|
assertEquals("20:00 New York", eventTime("2026-09-11T00:00:00Z", "America/New_York", uk))
|
||||||
|
assertEquals("02:00 Berlin", eventTime("2026-09-11T00:00:00Z", "Europe/Berlin", uk))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theDayHeadingIsTheReadersOwn() {
|
||||||
|
// The same instant files under different days for two readers, which is the
|
||||||
|
// other half of the split: "what is on this month" is about the month the
|
||||||
|
// person holding the phone is living in.
|
||||||
|
val instant = "2026-09-11T00:30:00Z"
|
||||||
|
val london = readerDayLabel(instant, ZoneId.of("Europe/London"), uk)
|
||||||
|
val newYork = readerDayLabel(instant, ZoneId.of("America/New_York"), uk)
|
||||||
|
assertNotEquals(london, newYork)
|
||||||
|
assertTrue(london, london.contains("11"))
|
||||||
|
assertTrue(newYork, newYork.contains("10"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anUnknownZoneFallsBackToUtcRatherThanThrowing() {
|
||||||
|
// A typo in a definition's timezone column must still render.
|
||||||
|
assertEquals("00:00 Nowhere", eventTime("2026-09-11T00:00:00Z", "Mars/Nowhere", uk))
|
||||||
|
assertEquals("00:00 UTC", eventTime("2026-09-11T00:00:00Z", null, uk))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aZonelessStampIsReadAsUtc() {
|
||||||
|
// MariaDB DATETIME read back as a string reaches the wire with no zone. It
|
||||||
|
// is what the server stored, so it is UTC — reading it as local time would
|
||||||
|
// shift every event by the device's offset.
|
||||||
|
assertEquals("00:00 UTC", eventTime("2026-09-11 00:00:00", "UTC", uk))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anUnreadableInstantRendersNothingRatherThanCrashing() {
|
||||||
|
assertEquals("", eventTime("not a date", "UTC", uk))
|
||||||
|
assertEquals("", eventDateTime(null, "UTC", uk))
|
||||||
|
assertEquals("", readerDayLabel("", ZoneId.of("UTC"), uk))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theZoneIsNamedAsAReaderRecognisesIt() {
|
||||||
|
assertEquals("New York", shortZone("America/New_York"))
|
||||||
|
assertEquals("Berlin", shortZone("Europe/Berlin"))
|
||||||
|
assertEquals("UTC", shortZone(null))
|
||||||
|
assertEquals("UTC", shortZone(" "))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scores are fractional, and the walk is why we know ───────────────
|
||||||
|
|
||||||
|
@Test fun aWholeScorePrintsWhole() {
|
||||||
|
// Most modules score by counting, and `12.0` reads as a rounding artefact.
|
||||||
|
assertEquals("1420", scoreText(1420.0, uk))
|
||||||
|
assertEquals("0", scoreText(0.0, uk))
|
||||||
|
assertEquals("-5", scoreText(-5.0, uk))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aFractionalScoreKeepsItsDigits() {
|
||||||
|
// The live walk's first history row was 318.5. Declaring this field `Long`
|
||||||
|
// did not round it — kotlinx refused the whole body, and a 200 rendered as
|
||||||
|
// "Something went wrong on the server."
|
||||||
|
assertEquals("318.5", scoreText(318.5, uk))
|
||||||
|
assertEquals("0.25", scoreText(0.25, uk))
|
||||||
|
// DECIMAL(18,4): four places, and no trailing zeros past the last digit.
|
||||||
|
assertEquals("1.0625", scoreText(1.0625, uk))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aNonFiniteScoreDoesNotReachTheScreen() {
|
||||||
|
assertEquals("0", scoreText(Double.NaN, uk))
|
||||||
|
assertEquals("0", scoreText(Double.POSITIVE_INFINITY, uk))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The status word: the tense follows the CLOCK, not the status ──────
|
||||||
|
|
||||||
|
@Test fun aFutureCancellationReadsCancelled() {
|
||||||
|
// Phase 14a's own defect: the calendar told a visitor an event four days
|
||||||
|
// away "DID NOT HAPPEN". It had been cancelled, not missed.
|
||||||
|
val now = Instant.parse("2026-09-08T12:00:00Z")
|
||||||
|
assertEquals(
|
||||||
|
R.string.events_status_cancelled,
|
||||||
|
statusWordRes("cancelled", "2026-09-12T20:00:00Z", now),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aPastCancellationReadsDidNotHappen() {
|
||||||
|
// Which is also the honest word for the `failed` and `missed` runs the
|
||||||
|
// server folds into `cancelled`.
|
||||||
|
val now = Instant.parse("2026-09-08T12:00:00Z")
|
||||||
|
assertEquals(
|
||||||
|
R.string.events_status_did_not_happen,
|
||||||
|
statusWordRes("cancelled", "2026-09-01T20:00:00Z", now),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anUnreadableInstantOnACancellationReadsPast() {
|
||||||
|
val now = Instant.parse("2026-09-08T12:00:00Z")
|
||||||
|
assertEquals(
|
||||||
|
R.string.events_status_did_not_happen,
|
||||||
|
statusWordRes("cancelled", null, now),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theOtherThreeStatusesDoNotDependOnTheClock() {
|
||||||
|
val past = Instant.parse("2027-01-01T00:00:00Z")
|
||||||
|
val future = Instant.parse("2020-01-01T00:00:00Z")
|
||||||
|
for (now in listOf(past, future)) {
|
||||||
|
assertEquals(R.string.events_status_live, statusWordRes("live", "2026-09-12T20:00:00Z", now))
|
||||||
|
assertEquals(R.string.events_status_completed, statusWordRes("completed", "2026-09-01T20:00:00Z", now))
|
||||||
|
assertEquals(R.string.events_status_scheduled, statusWordRes("scheduled", "2026-09-12T20:00:00Z", now))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anUnknownStatusFallsBackTheWayTheServerDoes() {
|
||||||
|
// `publicStatus()` folds anything it does not know to `scheduled`, so a word
|
||||||
|
// the app has never seen is a contract break rather than a state — and
|
||||||
|
// rendering a raw enum at a reader is not an improvement on it.
|
||||||
|
assertEquals(
|
||||||
|
R.string.events_status_scheduled,
|
||||||
|
statusWordRes("starting", "2026-09-12T20:00:00Z", Instant.now()),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
R.string.events_status_scheduled,
|
||||||
|
statusWordRes(null, null, Instant.now()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
/*
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
package com.runicgateway.app.ui.events
|
||||||
|
|
||||||
|
import androidx.lifecycle.SavedStateHandle
|
||||||
|
import com.runicgateway.app.core.auth.SessionManager
|
||||||
|
import com.runicgateway.app.core.auth.StoredSession
|
||||||
|
import com.runicgateway.app.core.auth.TokenStore
|
||||||
|
import com.runicgateway.app.data.api.dto.SafeUserDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventCalendarEntryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventHistoryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventSeriesDto
|
||||||
|
import com.runicgateway.app.data.api.dto.EventSeriesResponse
|
||||||
|
import com.runicgateway.app.data.api.dto.PublicEventDto
|
||||||
|
import com.runicgateway.app.data.api.dto.PublicEventResponse
|
||||||
|
import com.runicgateway.app.data.api.fake.FakeEventsApi
|
||||||
|
import com.runicgateway.app.data.repository.EventsRepository
|
||||||
|
import com.runicgateway.app.ui.ErrorKind
|
||||||
|
import com.runicgateway.app.ui.UiState
|
||||||
|
import com.runicgateway.app.util.MainDispatcherRule
|
||||||
|
import com.runicgateway.app.util.httpError
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import java.io.IOException
|
||||||
|
|
||||||
|
/** The four event screens' view models (PLAN.md §9 M13). */
|
||||||
|
class EventsViewModelsTest {
|
||||||
|
|
||||||
|
@get:Rule val dispatcher = MainDispatcherRule()
|
||||||
|
|
||||||
|
private val api = FakeEventsApi()
|
||||||
|
private val repository = EventsRepository(api)
|
||||||
|
|
||||||
|
private fun entry(slug: String, at: String, kind: String = "run") =
|
||||||
|
EventCalendarEntryDto(kind = kind, title = slug, slug = slug, scheduledFor = at)
|
||||||
|
|
||||||
|
// ── The calendar ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun theCalendarAsksForNoWindow() = runTest {
|
||||||
|
api.calendar = EventCalendarDto(entries = listOf(entry("a", "2026-09-11T20:00:00Z")))
|
||||||
|
|
||||||
|
val state = EventsViewModel(repository).state.value
|
||||||
|
|
||||||
|
assertTrue(state is UiState.Success)
|
||||||
|
assertEquals(1, (state as UiState.Success).data.entries.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aFourOhFourOnTheCalendarIsNotAFeatureBeingSwitchedOff() = runTest {
|
||||||
|
// These are CORE routes: `toShardUiState`'s "not published here" would name
|
||||||
|
// the wrong cause, and offer an explanation an admin cannot act on.
|
||||||
|
api.error = httpError(404)
|
||||||
|
|
||||||
|
val state = EventsViewModel(repository).state.value
|
||||||
|
|
||||||
|
assertEquals(ErrorKind.NOT_FOUND, (state as UiState.Error).kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theServersOrderIsPreservedByTheDayGrouping() {
|
||||||
|
// The server already sorted by instant; grouping must not re-sort. Two
|
||||||
|
// entries on one reader-day share a heading, a third on another starts one.
|
||||||
|
val entries = listOf(
|
||||||
|
entry("a", "2026-09-11T20:00:00Z"),
|
||||||
|
entry("b", "2026-09-11T21:00:00Z"),
|
||||||
|
entry("c", "2026-09-14T20:00:00Z"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val days = groupByReaderDay(entries)
|
||||||
|
|
||||||
|
assertEquals(2, days.size)
|
||||||
|
assertEquals(listOf("a", "b"), days[0].entries.map { it.slug })
|
||||||
|
assertEquals(listOf("c"), days[1].entries.map { it.slug })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anEntrySaysWhetherItIsAForecast() {
|
||||||
|
assertTrue(entry("a", "2026-09-11T20:00:00Z", kind = "projected").isProjected)
|
||||||
|
assertTrue(!entry("a", "2026-09-11T20:00:00Z").isProjected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── One event ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun theRunIsPassedThroughUntouched() = runTest {
|
||||||
|
api.event = PublicEventResponse(PublicEventDto(slug = "yew"))
|
||||||
|
val handle = SavedStateHandle(mapOf("slug" to "yew", "run" to "3692"))
|
||||||
|
|
||||||
|
EventViewModel(repository, handle)
|
||||||
|
|
||||||
|
assertEquals("yew", api.lastSlug)
|
||||||
|
assertEquals("3692", api.lastRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aBlankRunIsDroppedRatherThanForwarded() = runTest {
|
||||||
|
api.event = PublicEventResponse(PublicEventDto(slug = "yew"))
|
||||||
|
val handle = SavedStateHandle(mapOf("slug" to "yew", "run" to " "))
|
||||||
|
|
||||||
|
EventViewModel(repository, handle)
|
||||||
|
|
||||||
|
assertNull(api.lastRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anAbsentRunIsNotSent() = runTest {
|
||||||
|
api.event = PublicEventResponse(PublicEventDto(slug = "yew"))
|
||||||
|
|
||||||
|
EventViewModel(repository, SavedStateHandle(mapOf("slug" to "yew")))
|
||||||
|
|
||||||
|
assertNull(api.lastRun)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theEnvelopeIsUnwrappedForTheScreen() = runTest {
|
||||||
|
api.event = PublicEventResponse(PublicEventDto(slug = "yew", title = "The Yew Invasion"))
|
||||||
|
|
||||||
|
val state = EventViewModel(repository, SavedStateHandle(mapOf("slug" to "yew"))).state.value
|
||||||
|
|
||||||
|
assertEquals("The Yew Invasion", (state as UiState.Success).data.title)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── An arc ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun anArcWithNothingListedIsAnErrorRatherThanAnEmptyPage() = runTest {
|
||||||
|
// The server's decision, not the screen's: a page for an empty arc would
|
||||||
|
// publish that an operator has named something they have not announced.
|
||||||
|
api.error = httpError(404)
|
||||||
|
|
||||||
|
val state = EventSeriesViewModel(repository, SavedStateHandle(mapOf("slug" to "void"))).state.value
|
||||||
|
|
||||||
|
assertEquals(ErrorKind.NOT_FOUND, (state as UiState.Error).kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anArcUnwrapsItsEnvelope() = runTest {
|
||||||
|
api.series = EventSeriesResponse(EventSeriesDto(name = "The Void", slug = "void"))
|
||||||
|
|
||||||
|
val state = EventSeriesViewModel(repository, SavedStateHandle(mapOf("slug" to "void"))).state.value
|
||||||
|
|
||||||
|
assertEquals("The Void", (state as UiState.Success).data.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Participation history ────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun rows(vararg ids: Long) = EventHistoryDto(
|
||||||
|
entries = ids.map { EventHistoryEntryDto(id = it, runId = it, slug = "e$it") },
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test fun aFractionalScoreDecodesRatherThanFailingTheWholeBody() {
|
||||||
|
// The regression the live walk found: `score` is DECIMAL(18,4) on the wire
|
||||||
|
// and a `Long` field makes kotlinx refuse the ENTIRE response, so a 200
|
||||||
|
// reaches the screen as a server error. Decoded from real JSON so the DTO's
|
||||||
|
// type is what is under test, not a hand-built object.
|
||||||
|
val json = Json { ignoreUnknownKeys = true; explicitNulls = false }
|
||||||
|
|
||||||
|
val history = json.decodeFromString<EventHistoryDto>(
|
||||||
|
"""{"entries":[{"id":2,"runId":3667,"title":"Midsummer Fair","slug":"mf","score":318.5,"rank":null}]}""",
|
||||||
|
)
|
||||||
|
assertEquals(318.5, history.entries.single().score, 0.0)
|
||||||
|
|
||||||
|
val event = json.decodeFromString<PublicEventResponse>(
|
||||||
|
"""{"event":{"slug":"mf","results":{"runId":1,"participants":[{"name":"A","score":318.5}]}}}""",
|
||||||
|
)
|
||||||
|
assertEquals(318.5, event.event.results!!.participants.single().score, 0.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A signed-in session manager, so the history view model has an account to
|
||||||
|
// scope to. The screen is unreachable signed out.
|
||||||
|
private class FakeTokenStore(private var stored: StoredSession?) : TokenStore {
|
||||||
|
override fun load(): StoredSession? = stored
|
||||||
|
override fun save(session: StoredSession) { stored = session }
|
||||||
|
override fun clear() { stored = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun playerDto(userId: Long) =
|
||||||
|
SafeUserDto(id = userId, username = "u$userId", role = "player")
|
||||||
|
|
||||||
|
private fun sessionFor(userId: Long) =
|
||||||
|
SessionManager(FakeTokenStore(StoredSession("a", "r", userId, "u$userId", "player")))
|
||||||
|
|
||||||
|
@Test fun switchingAccountDoesNotShowThePreviousOnesHistory() {
|
||||||
|
// **The leak the live walk found, and the suite could not.** A drawer
|
||||||
|
// route's view model survives a sign-out: `navigateTopLevel` saves and
|
||||||
|
// restores back-stack state, so the entry keeps its ViewModelStore and a
|
||||||
|
// view model that loaded only in `init` never runs again. Signing out of
|
||||||
|
// an admin and in as a player showed the player the admin's rows, with no
|
||||||
|
// request made at all.
|
||||||
|
val sessions = sessionFor(33)
|
||||||
|
api.history = rows(9, 8)
|
||||||
|
val vm = MyEventsViewModel(repository, sessions)
|
||||||
|
assertEquals(2, (vm.state.value.items as UiState.Success).data.size)
|
||||||
|
|
||||||
|
api.history = rows(1)
|
||||||
|
sessions.onSignedOut()
|
||||||
|
// Signed out, the previous account's rows are gone rather than left up.
|
||||||
|
assertEquals(0, (vm.state.value.items as UiState.Success).data.size)
|
||||||
|
|
||||||
|
sessions.onSignedIn("a", "r", playerDto(35))
|
||||||
|
assertEquals(listOf(1L), (vm.state.value.items as UiState.Success).data.map { it.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aResumeRevalidationReturningTheSameUserDoesNotRefetch() {
|
||||||
|
// The other half: the gate is the account, not every session emission.
|
||||||
|
val sessions = sessionFor(33)
|
||||||
|
api.history = rows(9, 8)
|
||||||
|
val vm = MyEventsViewModel(repository, sessions)
|
||||||
|
val callsAfterFirstLoad = api.historyCalls
|
||||||
|
|
||||||
|
sessions.onUserRefreshed(playerDto(33))
|
||||||
|
|
||||||
|
assertEquals(callsAfterFirstLoad, api.historyCalls)
|
||||||
|
assertEquals(2, (vm.state.value.items as UiState.Success).data.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aShortFirstPageIsTheEnd() = runTest {
|
||||||
|
api.history = rows(3, 2, 1)
|
||||||
|
|
||||||
|
val state = MyEventsViewModel(repository, sessionFor(1)).state.value
|
||||||
|
|
||||||
|
assertEquals(3, (state.items as UiState.Success).data.size)
|
||||||
|
assertTrue(!state.hasMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aFullPageWalksBackOnTheLastRowsOwnId() = runTest {
|
||||||
|
// Keyset, never an offset: the list gains rows at the top as the reader
|
||||||
|
// attends things, so an offset page would skip and repeat around the seam.
|
||||||
|
api.history = rows(*(1L..25L).reversed().toList().toLongArray())
|
||||||
|
val vm = MyEventsViewModel(repository, sessionFor(1))
|
||||||
|
assertTrue(vm.state.value.hasMore)
|
||||||
|
|
||||||
|
api.history = rows(0)
|
||||||
|
vm.loadMore()
|
||||||
|
|
||||||
|
assertEquals(1L, api.lastBefore)
|
||||||
|
assertEquals(26, (vm.state.value.items as UiState.Success).data.size)
|
||||||
|
assertTrue(!vm.state.value.hasMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aFailedNextPageKeepsThePagesAlreadyRead() = runTest {
|
||||||
|
api.history = rows(*(1L..25L).reversed().toList().toLongArray())
|
||||||
|
val vm = MyEventsViewModel(repository, sessionFor(1))
|
||||||
|
|
||||||
|
api.error = IOException("offline")
|
||||||
|
vm.loadMore()
|
||||||
|
|
||||||
|
// Not an error screen replacing a screenful of history.
|
||||||
|
assertEquals(25, (vm.state.value.items as UiState.Success).data.size)
|
||||||
|
assertTrue(!vm.state.value.loadingMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun loadMoreDoesNothingWithoutAFullFirstPage() = runTest {
|
||||||
|
api.history = rows(2, 1)
|
||||||
|
val vm = MyEventsViewModel(repository, sessionFor(1))
|
||||||
|
val callsAfterLoad = api.historyCalls
|
||||||
|
|
||||||
|
vm.loadMore()
|
||||||
|
|
||||||
|
assertEquals(callsAfterLoad, api.historyCalls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/*
|
||||||
|
* 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.Capability
|
||||||
|
import com.runicgateway.app.data.repository.ShardFeature
|
||||||
|
import com.runicgateway.app.data.repository.ShardFeatures
|
||||||
|
import com.runicgateway.app.data.repository.SiteCapabilities
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The third gate on a drawer row (PLAN.md §5, §9 M13): whether the code behind it
|
||||||
|
* is installed on this backend at all.
|
||||||
|
*
|
||||||
|
* **A different question from the feature flag, which is why it is a third
|
||||||
|
* filter.** Capability is per HOST — it changes when an operator installs or
|
||||||
|
* removes a module. A feature is per VIEWER — it changes on sign-in. The two also
|
||||||
|
* fail differently, and the difference is the bug this milestone fixed.
|
||||||
|
*/
|
||||||
|
class MenuCapabilityGatingTest {
|
||||||
|
|
||||||
|
private fun signedIn(role: Role) =
|
||||||
|
Session.SignedIn(SessionUser(id = 1, username = "u", role = role))
|
||||||
|
|
||||||
|
private fun serving(vararg caps: String) =
|
||||||
|
SiteCapabilities(core = emptySet(), modules = caps.toSet())
|
||||||
|
|
||||||
|
private val shardEntry = MenuEntry(
|
||||||
|
"shard",
|
||||||
|
0,
|
||||||
|
MenuAccess.PUBLIC,
|
||||||
|
feature = ShardFeature.STATUS,
|
||||||
|
capability = Capability.SHARD,
|
||||||
|
)
|
||||||
|
private val eventsEntry = MenuEntry("events", 0, MenuAccess.PUBLIC, capability = Capability.EVENTS)
|
||||||
|
private val plainEntry = MenuEntry("news", 0, MenuAccess.PUBLIC)
|
||||||
|
|
||||||
|
private fun everyFeature() = ShardFeatures(level = "anonymous", visible = setOf(ShardFeature.STATUS))
|
||||||
|
|
||||||
|
@Test fun aRowHidesWhenTheBackendSaysItsModuleIsNotInstalled() {
|
||||||
|
// The whole point. On a site with no game module `/public/shard/features`
|
||||||
|
// 404s, so the FEATURE answer is unknown and fails open — and before M13
|
||||||
|
// that was the only answer the app had, so the row rendered and 404'd.
|
||||||
|
val entries = listOf(plainEntry, shardEntry, eventsEntry)
|
||||||
|
|
||||||
|
val visible = visibleEntries(
|
||||||
|
entries,
|
||||||
|
Session.SignedOut,
|
||||||
|
features = null,
|
||||||
|
capabilities = serving("events"),
|
||||||
|
).map { it.route }
|
||||||
|
|
||||||
|
assertEquals(listOf("news", "events"), visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anUnknownCapabilityAnswerLeavesEveryRowShowing() {
|
||||||
|
// A host that has never answered. Same fail-open direction the feature gate
|
||||||
|
// takes, and for the same reason: the server gates every call regardless.
|
||||||
|
val entries = listOf(plainEntry, shardEntry, eventsEntry)
|
||||||
|
|
||||||
|
val visible = visibleEntries(
|
||||||
|
entries,
|
||||||
|
Session.SignedOut,
|
||||||
|
features = everyFeature(),
|
||||||
|
capabilities = null,
|
||||||
|
).map { it.route }
|
||||||
|
|
||||||
|
assertEquals(listOf("news", "shard", "events"), visible)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anEmptyAnswerIsNotAnUnknownAnswer() {
|
||||||
|
// The distinction the whole milestone rests on, as one assertion.
|
||||||
|
assertTrue(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), null))
|
||||||
|
assertFalse(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), serving()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun bothGatesMustPassAndNeitherCanOverrideTheOther() {
|
||||||
|
val installed = serving(Capability.SHARD)
|
||||||
|
|
||||||
|
// Installed but not published to this viewer: hidden.
|
||||||
|
assertFalse(
|
||||||
|
isEntryVisible(shardEntry, Session.SignedOut, ShardFeatures("anonymous", emptySet()), installed),
|
||||||
|
)
|
||||||
|
// Published but the module is gone: hidden. (Not a state a real backend
|
||||||
|
// reaches, and the gate must not depend on that.)
|
||||||
|
assertFalse(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), serving()))
|
||||||
|
// Both: shown.
|
||||||
|
assertTrue(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), installed))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theRoleGateStillOutranksBoth() {
|
||||||
|
// An admin row is an admin row on a backend that serves everything.
|
||||||
|
val adminEntry = MenuEntry("admin/dashboard", 0, MenuAccess.STAFF)
|
||||||
|
assertFalse(
|
||||||
|
isEntryVisible(adminEntry, Session.SignedOut, everyFeature(), serving(Capability.SHARD)),
|
||||||
|
)
|
||||||
|
assertTrue(
|
||||||
|
isEntryVisible(adminEntry, signedIn(Role.ADMIN), everyFeature(), serving(Capability.SHARD)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aRowWithNoCapabilityIsNeverGatedByOne() {
|
||||||
|
// Every row that predates M13 keeps the behaviour it had.
|
||||||
|
assertTrue(isEntryVisible(plainEntry, Session.SignedOut, null, serving()))
|
||||||
|
assertTrue(isEntryVisible(plainEntry, Session.SignedOut, null, null))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The shipped menu, as coded ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun everyRowOnAModulePathDeclaresTheShardCapability() {
|
||||||
|
// **Defined by ROUTE, not by "has a feature", and the live walk is why.**
|
||||||
|
// The first cut of this test asked whether every row with a `feature`
|
||||||
|
// declared the capability — which is true and insufficient: the three
|
||||||
|
// player game-data rows read `/player/shard/*`, the same module's player
|
||||||
|
// mount, and carry no feature at all because they are gated by ownership
|
||||||
|
// rather than by the visibility framework. They rendered on a backend with
|
||||||
|
// no module installed and answered "This content couldn't be found",
|
||||||
|
// through a green suite.
|
||||||
|
val onAModulePath = APP_MENU.filter {
|
||||||
|
it.route.startsWith("shard") || it.route.startsWith("player/") || it.route == Routes.ATLAS
|
||||||
|
}
|
||||||
|
assertEquals(8, onAModulePath.size)
|
||||||
|
assertTrue(
|
||||||
|
onAModulePath.filter { it.capability != Capability.SHARD }.map { it.route }.toString(),
|
||||||
|
onAModulePath.all { it.capability == Capability.SHARD },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aModuleLessBackendShowsNoModuleRowToAnybody() {
|
||||||
|
// The walk's assertion, as a test: every rung, and not one module row.
|
||||||
|
val core = SiteCapabilities(core = setOf(Capability.EVENTS), modules = emptySet())
|
||||||
|
for (session in listOf(
|
||||||
|
Session.SignedOut,
|
||||||
|
signedIn(Role.PLAYER),
|
||||||
|
signedIn(Role.ADMIN),
|
||||||
|
)) {
|
||||||
|
val visible = visibleEntries(APP_MENU, session, everyFeature(), core).map { it.route }
|
||||||
|
assertTrue(
|
||||||
|
visible.toString(),
|
||||||
|
visible.none {
|
||||||
|
it.startsWith("shard") || it.startsWith("player/") || it == Routes.ATLAS
|
||||||
|
},
|
||||||
|
)
|
||||||
|
// …and the core rows are all still there.
|
||||||
|
assertTrue(Routes.EVENTS in visible)
|
||||||
|
assertTrue(Routes.NEWS in visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun bothEventRowsDeclareCoresCapabilityAndNoFeature() {
|
||||||
|
// Events are core's. A `feature` on one of them would gate a core screen on
|
||||||
|
// a module's visibility config, which is the coupling this separation exists
|
||||||
|
// to prevent.
|
||||||
|
val eventRows = APP_MENU.filter { it.capability == Capability.EVENTS }
|
||||||
|
assertEquals(listOf(Routes.EVENTS, Routes.MY_EVENTS), eventRows.map { it.route })
|
||||||
|
assertTrue(eventRows.all { it.feature == null })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun myEventsIsSignedInRatherThanPlayer() {
|
||||||
|
// The route is `requireAuth` alone and self-scoped; staff attend events too,
|
||||||
|
// and the website needed two mounts only because of its own /account guard.
|
||||||
|
val row = APP_MENU.first { it.route == Routes.MY_EVENTS }
|
||||||
|
assertEquals(MenuAccess.SIGNED_IN, row.access)
|
||||||
|
assertTrue(isEntryVisible(row, signedIn(Role.ADMIN), null, serving(Capability.EVENTS)))
|
||||||
|
assertTrue(isEntryVisible(row, signedIn(Role.PLAYER), null, serving(Capability.EVENTS)))
|
||||||
|
assertFalse(isEntryVisible(row, Session.SignedOut, null, serving(Capability.EVENTS)))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,12 +44,24 @@ class NavOverridesTest {
|
|||||||
|
|
||||||
private fun routes(nav: JsonObject?) = applyNavOverrides(APP_MENU, nav).map { it.route }
|
private fun routes(nav: JsonObject?) = applyNavOverrides(APP_MENU, nav).map { it.route }
|
||||||
|
|
||||||
/** The public block's routes, in coded order — the first nine of APP_MENU. */
|
/**
|
||||||
private val codedPublic = listOf(
|
* The public block once a stored row has made the merge sort it — the website's
|
||||||
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
|
* number line, not the app's coded order.
|
||||||
Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.page("about"),
|
*
|
||||||
|
* **About sits above the shard rows here, and that is the corrected table
|
||||||
|
* showing through** (M13): About is core's last nav row at index 7 and the
|
||||||
|
* module's nine append after it at 8-16. Under the stale sixteen-row table
|
||||||
|
* About was index 15 and came last, which is what these assertions used to say.
|
||||||
|
*/
|
||||||
|
private val mergedPublic = listOf(
|
||||||
|
Routes.HOME, Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.page("about"),
|
||||||
|
Routes.SHARD, Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS,
|
||||||
|
Routes.SHARD_MARKET,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/** How many rows that block holds, so the take/drop below say why. */
|
||||||
|
private val publicBlock = mergedPublic.size
|
||||||
|
|
||||||
// ── AC-1: the untouched instance ─────────────────────────────────────
|
// ── AC-1: the untouched instance ─────────────────────────────────────
|
||||||
|
|
||||||
@Test fun noStoredRowReturnsTheCodedMenuItself() {
|
@Test fun noStoredRowReturnsTheCodedMenuItself() {
|
||||||
@@ -71,7 +83,7 @@ class NavOverridesTest {
|
|||||||
"/site/news" to entry(hidden = false),
|
"/site/news" to entry(hidden = false),
|
||||||
"/admin/appearance" to entry(label = "Nope"),
|
"/admin/appearance" to entry(label = "Nope"),
|
||||||
"/site/screenshots" to entry(label = "Shots", order = 0),
|
"/site/screenshots" to entry(label = "Shots", order = 0),
|
||||||
"/site/champs" to entry(hidden = true),
|
"/uo/champs" to entry(hidden = true),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
|
assertSame(APP_MENU, applyNavOverrides(APP_MENU, stored))
|
||||||
@@ -85,7 +97,7 @@ class NavOverridesTest {
|
|||||||
|
|
||||||
val merged = applyNavOverrides(APP_MENU, stored)
|
val merged = applyNavOverrides(APP_MENU, stored)
|
||||||
|
|
||||||
assertEquals(codedPublic, merged.take(9).map { it.route })
|
assertEquals(mergedPublic, merged.take(publicBlock).map { it.route })
|
||||||
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
|
assertEquals("Codex", merged.first { it.route == Routes.WIKI }.label)
|
||||||
assertNull(merged.first { it.route == Routes.NEWS }.label)
|
assertNull(merged.first { it.route == Routes.NEWS }.label)
|
||||||
}
|
}
|
||||||
@@ -93,14 +105,16 @@ class NavOverridesTest {
|
|||||||
// ── Labels ───────────────────────────────────────────────────────────
|
// ── Labels ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test fun aLabelOverridesTheBundledString() {
|
@Test fun aLabelOverridesTheBundledString() {
|
||||||
val merged = applyNavOverrides(APP_MENU, nav("/site/shard" to entry(label = " The Realm ")))
|
// `/uo/shard`, not `/site/shard`: the row belongs to module-uo and core
|
||||||
|
// mounts a module's pages at `/<id>/<path>` (M13).
|
||||||
|
val merged = applyNavOverrides(APP_MENU, nav("/uo/shard" to entry(label = " The Realm ")))
|
||||||
|
|
||||||
val shard = merged.first { it.route == Routes.SHARD }
|
val shard = merged.first { it.route == Routes.SHARD }
|
||||||
assertEquals("The Realm", shard.label)
|
assertEquals("The Realm", shard.label)
|
||||||
// The override lands on `label` and nothing else — the gates are untouched.
|
// The override lands on `label` and nothing else — the gates are untouched.
|
||||||
assertEquals(ShardFeature.STATUS, shard.feature)
|
assertEquals(ShardFeature.STATUS, shard.feature)
|
||||||
assertEquals(MenuAccess.PUBLIC, shard.access)
|
assertEquals(MenuAccess.PUBLIC, shard.access)
|
||||||
assertEquals(codedPublic, merged.take(9).map { it.route })
|
assertEquals(mergedPublic, merged.take(publicBlock).map { it.route })
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun aNonStringLabelIsIgnored() {
|
@Test fun aNonStringLabelIsIgnored() {
|
||||||
@@ -112,7 +126,7 @@ class NavOverridesTest {
|
|||||||
// ── Hidden ───────────────────────────────────────────────────────────
|
// ── Hidden ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test fun hiddenDropsTheRow() {
|
@Test fun hiddenDropsTheRow() {
|
||||||
val routes = routes(nav("/site/market" to entry(hidden = true)))
|
val routes = routes(nav("/uo/market" to entry(hidden = true)))
|
||||||
|
|
||||||
assertTrue(Routes.SHARD_MARKET !in routes)
|
assertTrue(Routes.SHARD_MARKET !in routes)
|
||||||
assertEquals(APP_MENU.size - 1, routes.size)
|
assertEquals(APP_MENU.size - 1, routes.size)
|
||||||
@@ -128,7 +142,7 @@ class NavOverridesTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test fun hiddenFalseHidesNothing() {
|
@Test fun hiddenFalseHidesNothing() {
|
||||||
assertSame(APP_MENU, applyNavOverrides(APP_MENU, nav("/site/market" to entry(hidden = false))))
|
assertSame(APP_MENU, applyNavOverrides(APP_MENU, nav("/uo/market" to entry(hidden = false))))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun hiddenWinsOverALabelOnTheSameRow() {
|
@Test fun hiddenWinsOverALabelOnTheSameRow() {
|
||||||
@@ -140,44 +154,50 @@ class NavOverridesTest {
|
|||||||
// ── Order ────────────────────────────────────────────────────────────
|
// ── Order ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() {
|
@Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() {
|
||||||
// The website's own indices: About is 15 and Home is 0, so swapping them
|
// The website's own indices: About is 7, and Market — the last row of all,
|
||||||
// is what an admin dragging About to the top writes.
|
// now that the module's nine append after core's eight — is 16. Dragging
|
||||||
|
// About to the top and Home past the end writes exactly this.
|
||||||
val routes = routes(
|
val routes = routes(
|
||||||
nav(
|
nav(
|
||||||
"/site/about" to entry(order = 0),
|
"/site/about" to entry(order = 0),
|
||||||
"/" to entry(order = 15),
|
"/" to entry(order = 17),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf(
|
listOf(
|
||||||
Routes.page("about"), Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
|
Routes.page("about"), Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.SHARD,
|
||||||
Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.HOME,
|
Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET,
|
||||||
|
Routes.HOME,
|
||||||
),
|
),
|
||||||
routes.take(9),
|
routes.take(publicBlock),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun anUntouchedRowKeepsItsPlaceOnTheWebsitesNumberLine() {
|
@Test fun anUntouchedRowKeepsItsPlaceOnTheWebsitesNumberLine() {
|
||||||
// The tie-break that needs the website's order rather than the app's: an
|
// The tie-break that needs the website's order rather than the app's: an
|
||||||
// explicit 5 meets Wiki's implicit 5 (its index in the site's nav, where
|
// explicit 6 meets Wiki's implicit 6 (its index in the site's nav, where
|
||||||
// the three news categories sit between News and Wiki). Explicit wins.
|
// Events and the three news categories sit between News and Wiki). Explicit
|
||||||
val routes = routes(nav("/site/about" to entry(order = 5)))
|
// wins. That the number moved from 5 to 6 when the site gained a row is the
|
||||||
|
// whole reason this table has to track the site's nav rather than the app's.
|
||||||
|
val routes = routes(nav("/site/about" to entry(order = 6)))
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf(Routes.HOME, Routes.NEWS, Routes.page("about"), Routes.WIKI),
|
listOf(Routes.HOME, Routes.NEWS, Routes.EVENTS, Routes.page("about"), Routes.WIKI),
|
||||||
routes.take(4),
|
routes.take(5),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun theAppsOwnRowsKeepTheirCodedOrderAfterThePublicBlock() {
|
@Test fun theAppsOwnRowsKeepTheirCodedOrderAfterThePublicBlock() {
|
||||||
// Contact, Account, Notifications, the three player groups and the four
|
// Contact, Account, Notifications, My Events, the three player groups and
|
||||||
// staff rows have no website counterpart to be reordered against (§6.2).
|
// the four staff rows have no website counterpart to be reordered against
|
||||||
val tail = APP_MENU.drop(9).map { it.route }
|
// (§6.2) — My Events because `/account/events` is behind the site's own
|
||||||
|
// auth guard and is not on its public nav at all.
|
||||||
|
val tail = APP_MENU.drop(publicBlock).map { it.route }
|
||||||
|
|
||||||
val merged = routes(nav("/site/about" to entry(order = 0)))
|
val merged = routes(nav("/site/about" to entry(order = 0)))
|
||||||
|
|
||||||
assertEquals(tail, merged.drop(9))
|
assertEquals(tail, merged.drop(publicBlock))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun reorderingAndHidingCompose() {
|
@Test fun reorderingAndHidingCompose() {
|
||||||
@@ -224,7 +244,7 @@ class NavOverridesTest {
|
|||||||
|
|
||||||
@Test fun anOverrideCannotUnhideAFeatureGatedRow() {
|
@Test fun anOverrideCannotUnhideAFeatureGatedRow() {
|
||||||
val stored = nav(
|
val stored = nav(
|
||||||
"/site/market" to entry(label = "Bazaar", hidden = false, order = 0),
|
"/uo/market" to entry(label = "Bazaar", hidden = false, order = 0),
|
||||||
)
|
)
|
||||||
|
|
||||||
val visible = visibleEntries(
|
val visible = visibleEntries(
|
||||||
|
|||||||
@@ -19,13 +19,27 @@ import org.junit.Test
|
|||||||
class NavPathsTest {
|
class NavPathsTest {
|
||||||
|
|
||||||
@Test fun everyWebsiteNavPathIsMapped() {
|
@Test fun everyWebsiteNavPathIsMapped() {
|
||||||
// The sixteen rows of SiteHeader.jsx's NAV, quoted in NavPaths.kt. If the
|
// Core's eight rows plus module-uo's nine, both quoted in NavPaths.kt. If
|
||||||
// site adds one, this is the test that says so — a path with no mapping is
|
// either side adds one, this is the test that says so — a path with no
|
||||||
// silently unresolvable in phase 6's link handling.
|
// mapping is silently unresolvable in phase 6's link handling, which is
|
||||||
assertEquals(16, WEBSITE_PUBLIC_NAV.size)
|
// exactly how the nine shard rows went stale for a month after the
|
||||||
|
// module-system cutover moved them from /site/ to /uo/ (M13).
|
||||||
|
assertEquals(17, WEBSITE_PUBLIC_NAV.size)
|
||||||
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size)
|
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test fun theShardRowsAreTheModulesPathsNotCores() {
|
||||||
|
// The defect M13 fixed, kept as an assertion: these nine belong to
|
||||||
|
// module-uo and core mounts a module's pages at `/<id>/<path>`. A `/site/`
|
||||||
|
// spelling here is the stale table coming back.
|
||||||
|
val shard = WEBSITE_PUBLIC_NAV.map { it.path }.filter { it.startsWith("/uo/") }
|
||||||
|
assertEquals(9, shard.size)
|
||||||
|
assertTrue(WEBSITE_PUBLIC_NAV.none { it.path.startsWith("/site/shard") })
|
||||||
|
assertTrue(WEBSITE_PUBLIC_NAV.none { it.path == "/site/champs" })
|
||||||
|
assertNull(appRouteForWebPath("/site/champs"))
|
||||||
|
assertEquals(Routes.SHARD_CHAMPS, appRouteForWebPath("/uo/champs"))
|
||||||
|
}
|
||||||
|
|
||||||
@Test fun everyMappedRouteIsDistinct() {
|
@Test fun everyMappedRouteIsDistinct() {
|
||||||
// WEB_ROUTE_ORDER is keyed by route, so a duplicate would silently drop a
|
// WEB_ROUTE_ORDER is keyed by route, so a duplicate would silently drop a
|
||||||
// row's position from the sort.
|
// row's position from the sort.
|
||||||
@@ -34,15 +48,20 @@ class NavPathsTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test fun theWebsitesOrderIsPreserved() {
|
@Test fun theWebsitesOrderIsPreserved() {
|
||||||
// Load-bearing: a stored `order` is an index into this list.
|
// Load-bearing: a stored `order` is an index into this list. Core numbers
|
||||||
|
// 0-7 and `mergeFlat` appends the module's rows after them, none of which
|
||||||
|
// declares an `order` of its own.
|
||||||
assertEquals(0, WEB_ROUTE_ORDER[Routes.HOME])
|
assertEquals(0, WEB_ROUTE_ORDER[Routes.HOME])
|
||||||
assertEquals(1, WEB_ROUTE_ORDER[Routes.NEWS])
|
assertEquals(1, WEB_ROUTE_ORDER[Routes.NEWS])
|
||||||
assertEquals(5, WEB_ROUTE_ORDER[Routes.WIKI])
|
assertEquals(2, WEB_ROUTE_ORDER[Routes.EVENTS])
|
||||||
assertEquals(15, WEB_ROUTE_ORDER[Routes.page("about")])
|
assertEquals(6, WEB_ROUTE_ORDER[Routes.WIKI])
|
||||||
|
assertEquals(7, WEB_ROUTE_ORDER[Routes.page("about")])
|
||||||
|
assertEquals(8, WEB_ROUTE_ORDER[Routes.SHARD])
|
||||||
|
assertEquals(16, WEB_ROUTE_ORDER[Routes.SHARD_MARKET])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun theNineDrawerRowsAreTheIntersectionWithAppMenu() {
|
@Test fun theDrawerRowsAreTheIntersectionWithAppMenu() {
|
||||||
// Nine of the sixteen have a drawer row. The other seven are mapped but not
|
// Ten of the seventeen have a drawer row. The other seven are mapped but not
|
||||||
// surfaced — three news category tabs and the four Shard hub boards — and
|
// surfaced — three news category tabs and the four Shard hub boards — and
|
||||||
// an override for one of them is ignored rather than obeyed (§6.2).
|
// an override for one of them is ignored rather than obeyed (§6.2).
|
||||||
val coded = APP_MENU.map { it.route }.toSet()
|
val coded = APP_MENU.map { it.route }.toSet()
|
||||||
@@ -50,8 +69,8 @@ class NavPathsTest {
|
|||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
listOf(
|
listOf(
|
||||||
"/", "/site/news", "/wiki", "/site/shard", "/site/rules",
|
"/", "/site/news", "/site/events", "/wiki", "/site/about",
|
||||||
"/site/atlas", "/site/leaderboards", "/site/market", "/site/about",
|
"/uo/shard", "/uo/rules", "/uo/atlas", "/uo/leaderboards", "/uo/market",
|
||||||
),
|
),
|
||||||
surfaced,
|
surfaced,
|
||||||
)
|
)
|
||||||
@@ -62,7 +81,7 @@ class NavPathsTest {
|
|||||||
// tab or a hub board is a perfectly good destination.
|
// tab or a hub board is a perfectly good destination.
|
||||||
val unsurfaced = listOf(
|
val unsurfaced = listOf(
|
||||||
"/site/screenshots", "/site/five-on-friday", "/site/newsletter",
|
"/site/screenshots", "/site/five-on-friday", "/site/newsletter",
|
||||||
"/site/champs", "/site/guilds", "/site/governors", "/site/houses",
|
"/uo/champs", "/uo/guilds", "/uo/governors", "/uo/houses",
|
||||||
)
|
)
|
||||||
|
|
||||||
assertTrue(unsurfaced.all { appRouteForWebPath(it) != null })
|
assertTrue(unsurfaced.all { appRouteForWebPath(it) != null })
|
||||||
@@ -113,13 +132,13 @@ class NavPathsTest {
|
|||||||
@Test fun aTrailingSlashIsTolerated() {
|
@Test fun aTrailingSlashIsTolerated() {
|
||||||
// A hand-edited settings row may carry one; the root is left alone.
|
// A hand-edited settings row may carry one; the root is left alone.
|
||||||
assertEquals(Routes.WIKI, appRouteForWebPath("/wiki/"))
|
assertEquals(Routes.WIKI, appRouteForWebPath("/wiki/"))
|
||||||
assertEquals(Routes.SHARD, appRouteForWebPath(" /site/shard/ "))
|
assertEquals(Routes.SHARD, appRouteForWebPath(" /uo/shard/ "))
|
||||||
assertEquals(Routes.HOME, appRouteForWebPath("/"))
|
assertEquals(Routes.HOME, appRouteForWebPath("/"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── resolveWebPath: an added link may name any page on the site (§6.3) ──
|
// ── resolveWebPath: an added link may name any page on the site (§6.3) ──
|
||||||
|
|
||||||
@Test fun theNavTablesSixteenPathsResolveTheSameWay() {
|
@Test fun theNavTablesPathsResolveTheSameWay() {
|
||||||
// An added link to a path the nav already knows must land where the nav row
|
// 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
|
// does, or the same destination would behave differently depending on how
|
||||||
// the admin reached it.
|
// the admin reached it.
|
||||||
@@ -132,9 +151,71 @@ class NavPathsTest {
|
|||||||
// Read off website/client/src/App.jsx. Note what is NOT here: the site has
|
// 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.
|
// no /site/news/<id> route — its one post-detail route is the newsletter's.
|
||||||
assertEquals(Routes.wikiPage("smithing"), resolveWebPath("/wiki/smithing"))
|
assertEquals(Routes.wikiPage("smithing"), resolveWebPath("/wiki/smithing"))
|
||||||
assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/site/atlas/dragon"))
|
assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/uo/atlas/dragon"))
|
||||||
assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/site/market/vendors/0x24C"))
|
assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/uo/market/vendors/0x24C"))
|
||||||
assertEquals(Routes.post("newsletter", "12"), resolveWebPath("/site/newsletter/12"))
|
assertEquals(Routes.post("newsletter", "12"), resolveWebPath("/site/newsletter/12"))
|
||||||
|
// The module's detail routes are the module's; the old /site/ spelling is
|
||||||
|
// not a second address for them.
|
||||||
|
assertNull(resolveWebPath("/site/atlas/dragon"))
|
||||||
|
assertNull(resolveWebPath("/site/market/vendors/0x24C"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Events (M13) ───────────────────────────────────────
|
||||||
|
|
||||||
|
@Test fun theEventPagesResolve() {
|
||||||
|
assertEquals(Routes.EVENTS, resolveWebPath("/site/events"))
|
||||||
|
assertEquals(Routes.event("the-yew-invasion"), resolveWebPath("/site/events/the-yew-invasion"))
|
||||||
|
assertEquals(
|
||||||
|
Routes.eventSeries("the-void"),
|
||||||
|
resolveWebPath("/site/events/series/the-void"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anEventUrlsRunIsCarriedThrough() {
|
||||||
|
// The one exception to "a query hands off", and the whole reason for it:
|
||||||
|
// this is the exact shape events Phase 14a's `eventUrl` writes into every
|
||||||
|
// announcement. Dropping the run would open next Friday's occurrence from a
|
||||||
|
// mail about last Friday's.
|
||||||
|
assertEquals(
|
||||||
|
Routes.event("the-yew-invasion", "3692"),
|
||||||
|
resolveWebPath("/site/events/the-yew-invasion?run=3692"),
|
||||||
|
)
|
||||||
|
assertEquals("events/the-yew-invasion?run=3692", Routes.event("the-yew-invasion", "3692"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theRunCarveOutIsOneKeyOnOnePath() {
|
||||||
|
// Narrow on purpose. Anything the app cannot honor natively hands off, so
|
||||||
|
// the browser gets the parameter the author actually wrote.
|
||||||
|
assertNull(resolveWebPath("/site/events/x?utm=mail"))
|
||||||
|
assertNull(resolveWebPath("/site/events/x?run=3&utm=mail"))
|
||||||
|
assertNull(resolveWebPath("/site/events/x?run="))
|
||||||
|
assertNull(resolveWebPath("/site/events/x#results"))
|
||||||
|
assertNull(resolveWebPath("/site/events?seriesId=3"))
|
||||||
|
assertNull(resolveWebPath("/site/events/series/the-void?run=3"))
|
||||||
|
// And no OTHER path gained a query: the rule is one path's, not general.
|
||||||
|
assertNull(resolveWebPath("/wiki/smithing?x=1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anEventRouteWithNoRunCarriesNoEmptyArgument() {
|
||||||
|
// `events/x?run=` would reach the screen as a blank string and be forwarded
|
||||||
|
// to the server as one.
|
||||||
|
assertEquals("events/x", Routes.event("x"))
|
||||||
|
assertEquals("events/x", Routes.event("x", null))
|
||||||
|
assertEquals("events/x", Routes.event("x", " "))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun theEventRoutePatternStripsToTheTopLevelRoute() {
|
||||||
|
// Same rule the News hub needs: `destination.route` is the pattern, and the
|
||||||
|
// drawer compares on the part before the query.
|
||||||
|
assertEquals(Routes.EVENTS, Routes.EVENT_ROUTE.substringBefore('?').substringBefore('/'))
|
||||||
|
assertEquals("events/{slug}", Routes.EVENT_ROUTE.substringBefore('?'))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun myEventsHasNoDynamicSibling() {
|
||||||
|
// `events/mine` would race `events/{slug}` — both two segments — which is
|
||||||
|
// the static-versus-argument bug events Phase 13 shipped one tier along.
|
||||||
|
assertTrue(Routes.MY_EVENTS.startsWith("account/"))
|
||||||
|
assertNull(resolveWebPath("/account/events"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun aTopLevelSlugIsACmsPage() {
|
@Test fun aTopLevelSlugIsACmsPage() {
|
||||||
@@ -157,15 +238,18 @@ class NavPathsTest {
|
|||||||
|
|
||||||
@Test fun aPathTheAppHasNoScreenForHandsOff() {
|
@Test fun aPathTheAppHasNoScreenForHandsOff() {
|
||||||
assertNull(resolveWebPath("/site/status"))
|
assertNull(resolveWebPath("/site/status"))
|
||||||
assertNull(resolveWebPath("/site/shard/activity"))
|
assertNull(resolveWebPath("/uo/shard/activity"))
|
||||||
|
assertNull(resolveWebPath("/uo/guilds/12"))
|
||||||
|
// `/uo` is a module's namespace, not a CMS page slug.
|
||||||
|
assertNull(resolveWebPath("/uo"))
|
||||||
assertNull(resolveWebPath("/account/login"))
|
assertNull(resolveWebPath("/account/login"))
|
||||||
assertNull(resolveWebPath("/admin/navigation"))
|
assertNull(resolveWebPath("/admin/navigation"))
|
||||||
assertNull(resolveWebPath("/site/atlas/dragon/extra"))
|
assertNull(resolveWebPath("/site/atlas/dragon/extra"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun aQueryOrFragmentHandsOff() {
|
@Test fun aQueryOrFragmentHandsOff() {
|
||||||
// No app route takes either, so a native match would quietly drop what the
|
// No app route but the event page takes either, so a native match would
|
||||||
// admin wrote. The browser honors it exactly.
|
// quietly drop what the admin wrote. The browser honors it exactly.
|
||||||
assertNull(resolveWebPath("/site/news?tag=patch"))
|
assertNull(resolveWebPath("/site/news?tag=patch"))
|
||||||
assertNull(resolveWebPath("/donate#tiers"))
|
assertNull(resolveWebPath("/donate#tiers"))
|
||||||
assertEquals(Routes.NEWS, resolveWebPath("/site/news"))
|
assertEquals(Routes.NEWS, resolveWebPath("/site/news"))
|
||||||
|
|||||||
@@ -166,15 +166,15 @@ class NavTreeTest {
|
|||||||
|
|
||||||
val shape = tree(row).shape()
|
val shape = tree(row).shape()
|
||||||
|
|
||||||
// Eight public rows are left at the top level (Wiki moved into the section),
|
// Nine public rows are left at the top level (Wiki moved into the section),
|
||||||
// then the section, then the app's own rows.
|
// then the section, then the app's own rows.
|
||||||
assertEquals("section:lore", shape[8])
|
assertEquals("section:lore", shape[9])
|
||||||
assertEquals(Routes.CONTACT, shape[9])
|
assertEquals(Routes.CONTACT, shape[10])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun aSectionsOrderPlacesItAmongTheCodedRows() {
|
@Test fun aSectionsOrderPlacesItAmongTheCodedRows() {
|
||||||
// Sections sort on the same number line as everything else: the website's
|
// Sections sort on the same number line as everything else: the website's
|
||||||
// sixteen indices, then admin-created entities after them.
|
// seventeen indices, then admin-created entities after them.
|
||||||
val row = stored(
|
val row = stored(
|
||||||
items = items("/wiki" to item(section = "lore")),
|
items = items("/wiki" to item(section = "lore")),
|
||||||
sections = listOf(section("lore", order = 0)),
|
sections = listOf(section("lore", order = 0)),
|
||||||
@@ -252,7 +252,7 @@ class NavTreeTest {
|
|||||||
// deliberately, and grouping is no more an invitation to surface one than
|
// deliberately, and grouping is no more an invitation to surface one than
|
||||||
// relabelling was (§6.2).
|
// relabelling was (§6.2).
|
||||||
val row = stored(
|
val row = stored(
|
||||||
items = items("/site/champs" to item(section = "lore", label = "Champs")),
|
items = items("/uo/champs" to item(section = "lore", label = "Champs")),
|
||||||
sections = listOf(section("lore")),
|
sections = listOf(section("lore")),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -324,7 +324,9 @@ class NavTreeTest {
|
|||||||
val shape = tree(row).shape()
|
val shape = tree(row).shape()
|
||||||
|
|
||||||
assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") })
|
assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") })
|
||||||
assertEquals(Routes.page("about"), shape[shape.indexOf("link:a") - 1])
|
// Market, not About: the module's nine rows append after core's eight on the
|
||||||
|
// website's number line, so Market is the last coded row rather than About.
|
||||||
|
assertEquals(Routes.SHARD_MARKET, shape[shape.indexOf("link:a") - 1])
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test fun aLinksOrderPlacesItAmongTheCodedRows() {
|
@Test fun aLinksOrderPlacesItAmongTheCodedRows() {
|
||||||
@@ -364,7 +366,7 @@ class NavTreeTest {
|
|||||||
// The case the rule exists for: a group whose every member is withheld by
|
// 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.
|
// the shard's visibility config must not draw as a header over nothing.
|
||||||
val row = stored(
|
val row = stored(
|
||||||
items = items("/site/market" to item(section = "lore")),
|
items = items("/uo/market" to item(section = "lore")),
|
||||||
sections = listOf(section("lore")),
|
sections = listOf(section("lore")),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -380,7 +382,7 @@ class NavTreeTest {
|
|||||||
@Test fun aSectionKeepsTheMembersThisCallerMaySee() {
|
@Test fun aSectionKeepsTheMembersThisCallerMaySee() {
|
||||||
val row = stored(
|
val row = stored(
|
||||||
items = items(
|
items = items(
|
||||||
"/site/market" to item(section = "lore"),
|
"/uo/market" to item(section = "lore"),
|
||||||
"/wiki" to item(section = "lore"),
|
"/wiki" to item(section = "lore"),
|
||||||
),
|
),
|
||||||
sections = listOf(section("lore")),
|
sections = listOf(section("lore")),
|
||||||
@@ -400,7 +402,7 @@ class NavTreeTest {
|
|||||||
// section of its own — and still not shown, because the shard does not
|
// section of its own — and still not shown, because the shard does not
|
||||||
// publish the market and an admin does not outrank that.
|
// publish the market and an admin does not outrank that.
|
||||||
val row = stored(
|
val row = stored(
|
||||||
items = items("/site/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")),
|
items = items("/uo/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")),
|
||||||
sections = listOf(section("lore", order = 0)),
|
sections = listOf(section("lore", order = 0)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -418,7 +420,7 @@ class NavTreeTest {
|
|||||||
// Links carry no gate — the page behind one enforces its own access — so a
|
// 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.
|
// section holding one is never emptied by the caller's role.
|
||||||
val row = stored(
|
val row = stored(
|
||||||
items = items("/site/market" to item(section = "lore")),
|
items = items("/uo/market" to item(section = "lore")),
|
||||||
sections = listOf(section("lore")),
|
sections = listOf(section("lore")),
|
||||||
links = listOf(link(section = "lore")),
|
links = listOf(link(section = "lore")),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -193,4 +193,50 @@ class InboxViewModelTest {
|
|||||||
assertNull(vm.linkFor(item(1).copy(url = "javascript:alert(1)")))
|
assertNull(vm.linkFor(item(1).copy(url = "javascript:alert(1)")))
|
||||||
assertNull(vm.linkFor(item(1).copy(url = "intent://evil#Intent;end")))
|
assertNull(vm.linkFor(item(1).copy(url = "intent://evil#Intent;end")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Opening an item in the app rather than a browser (M13) ─────────
|
||||||
|
|
||||||
|
@Test fun anEventAnnouncementOpensNativelyAndKeepsItsRun() {
|
||||||
|
// The exact shape events Phase 14a writes into every announcement. Before
|
||||||
|
// M13 this opened a Custom Tab onto a page the app now renders itself.
|
||||||
|
val vm = viewModel()
|
||||||
|
val item = item(1).copy(url = "/site/events/the-yew-invasion?run=3692")
|
||||||
|
|
||||||
|
assertEquals("events/the-yew-invasion?run=3692", vm.routeFor(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anAbsoluteUrlOnThisHostOpensNativelyToo() {
|
||||||
|
// The url's shape is the server's to change; a link that reached the browser
|
||||||
|
// only because it arrived fully qualified would be a puzzle.
|
||||||
|
val vm = viewModel()
|
||||||
|
val item = item(1).copy(url = "https://shard.example/site/events/yew?run=7")
|
||||||
|
|
||||||
|
assertEquals("events/yew?run=7", vm.routeFor(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun aLinkToAnotherHostIsNotOursToRoute() {
|
||||||
|
val vm = viewModel()
|
||||||
|
val item = item(1).copy(url = "https://elsewhere.example/site/events/yew")
|
||||||
|
|
||||||
|
assertNull(vm.routeFor(item))
|
||||||
|
// …and still opens, in the browser, exactly as it did before.
|
||||||
|
assertEquals("https://elsewhere.example/site/events/yew", vm.linkFor(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun everyOtherLinkStillHandsOff() {
|
||||||
|
// The change is additive: a path the app has no screen for behaves exactly
|
||||||
|
// as it did, and `linkFor` is still what opens it.
|
||||||
|
val vm = viewModel()
|
||||||
|
val forum = item(1).copy(url = "/guilds/the-silver-anvil/forum/403")
|
||||||
|
|
||||||
|
assertNull(vm.routeFor(forum))
|
||||||
|
assertEquals("https://shard.example/guilds/the-silver-anvil/forum/403", vm.linkFor(forum))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun anItemWithNoUrlHasNoRoute() {
|
||||||
|
val vm = viewModel()
|
||||||
|
assertNull(vm.routeFor(item(1)))
|
||||||
|
assertNull(vm.routeFor(item(1).copy(url = " ")))
|
||||||
|
assertNull(vm.routeFor(item(1).copy(url = "javascript:alert(1)")))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user