Merge pull request 'feat(events): the app's event screens — M13 (Phase 16b cutover, 4 of 6)' (#46) from edge into main
Some checks failed
sync-project-tree / sync (push) Successful in 24s
SonarQube / analysis (push) Failing after 16m2s

Reviewed-on: #46
Reviewed-by: Colby Whitlock <whitlocktech@gmail.com>
This commit is contained in:
2026-09-09 20:09:36 +00:00
39 changed files with 3436 additions and 144 deletions

View 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
}
}
}

View 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
}

View File

@@ -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.MarketPageDto
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.PageDto
import com.runicgateway.app.data.api.dto.PointsBoardDto
@@ -67,6 +68,18 @@ interface PublicApi {
@GET("api/v1/public/settings")
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 ───────────────────────────────────────────────────
@GET("api/v1/public/posts/{category}")
suspend fun getPosts(@Path("category") category: String): List<PostDto>

View 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(),
)

View File

@@ -20,6 +20,47 @@ data class VersionDto(
val service: String = "",
val api: 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). */

View File

@@ -29,6 +29,7 @@ class ConnectionRepository @Inject constructor(
private val sessionManager: SessionManager,
private val trustTokenStore: TrustTokenStore,
private val shardFeaturesRepository: ShardFeaturesRepository,
private val siteCapabilitiesRepository: SiteCapabilitiesRepository,
private val pushManager: com.runicgateway.app.core.push.PushManager,
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
// invalidates the cache and the new shard would inherit the old one's menu.
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()
baseUrlHolder.set(null)
}

View File

@@ -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 }
}

View File

@@ -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"
}

View File

@@ -15,6 +15,7 @@ import com.runicgateway.app.core.net.TokenAuthenticator
import com.runicgateway.app.core.net.UserAgentInterceptor
import com.runicgateway.app.data.api.AuthApi
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.AdminApi
import com.runicgateway.app.data.api.NotificationsApi
@@ -122,6 +123,15 @@ object NetworkModule {
fun providePlayerShardApi(retrofit: Retrofit): PlayerShardApi =
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. */
@Provides
@Singleton

View File

@@ -11,6 +11,7 @@ import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.appearance.SiteAppearance
import com.runicgateway.app.data.repository.ConnectionRepository
import com.runicgateway.app.data.repository.SettingsRepository
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -29,6 +30,7 @@ class AppViewModel @Inject constructor(
private val settingsRepository: SettingsRepository,
private val baseUrlHolder: BaseUrlHolder,
private val pushManager: PushManager,
private val siteCapabilitiesRepository: SiteCapabilitiesRepository,
) : ViewModel() {
sealed interface AppState {
@@ -76,6 +78,12 @@ class AppViewModel @Inject constructor(
fun refreshAppearance() {
if (_state.value !is AppState.Ready) return
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
pushManager.setNtfyUrl(settings.push.ntfyUrl)
// 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.
*/
private suspend fun loadAppearance(): SiteAppearance {
siteCapabilitiesRepository.refresh()
val settings = (settingsRepository.getSettings() as? ApiResult.Ok)?.data
pushManager.setNtfyUrl(settings?.push?.ntfyUrl)
return SiteAppearance.from(settings)

View File

@@ -64,6 +64,10 @@ import com.runicgateway.app.ui.auth.TrustedDevicesScreen
import com.runicgateway.app.ui.auth.roleLabelRes
import com.runicgateway.app.ui.components.BrandLogo
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.navigation.APP_MENU
import com.runicgateway.app.ui.navigation.NavNode
@@ -110,6 +114,10 @@ private val TOP_LEVEL_ROUTES = setOf(
// on them too (M11).
Routes.SHARD_RULES, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.ATLAS,
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.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
)
@@ -141,6 +149,9 @@ fun RunicApp(
val session by sessionViewModel.session.collectAsStateWithLifecycle()
// What this shard publishes, independently of who the caller is (§5, M11).
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),
// 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
// (§6.1, AC-3). With no stored row the merge returns APP_MENU itself.
val nav = pruneNav(buildNavTree(APP_MENU, appearance.navPublic)) {
isEntryVisible(it, session, shardFeatures)
isEntryVisible(it, session, shardFeatures, capabilities)
}
val context = LocalContext.current
@@ -478,6 +489,50 @@ private fun RunicNavHost(
) {
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) {
ShardScreen(onOpenBoard = { board ->
navController.navigate(
@@ -586,6 +641,10 @@ private fun RunicNavHost(
when (session) {
is Session.SignedIn -> InboxScreen(
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) }
}

View 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,
)
}
}

View File

@@ -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,
)
}
}
}
}
}
}
}
}
}

View File

@@ -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()
}
}
}

View 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
}

View File

@@ -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()
}
}
}

View 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,
)
}
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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,
)
}
}
}
}

View File

@@ -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
}
}

View File

@@ -6,9 +6,12 @@ package com.runicgateway.app.ui.navigation
import androidx.annotation.StringRes
import com.runicgateway.app.R
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.ShardFeatures
import com.runicgateway.app.data.repository.SiteCapabilities
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
@@ -52,6 +55,20 @@ data class MenuEntry(
* isn't shard-derived and only [access] applies.
*/
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
* (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(
MenuEntry(Routes.HOME, R.string.menu_home),
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.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,
// 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(Routes.ATLAS, R.string.menu_atlas, feature = ShardFeature.ATLAS),
MenuEntry(Routes.SHARD_LEADERBOARDS, R.string.menu_leaderboards, feature = ShardFeature.LEADERBOARDS),
MenuEntry(Routes.SHARD_MARKET, R.string.menu_market, feature = ShardFeature.MARKET),
MenuEntry(
Routes.SHARD_RULES,
R.string.menu_rules,
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.CONTACT, R.string.menu_contact),
MenuEntry(Routes.ACCOUNT, R.string.menu_account, MenuAccess.SIGNED_IN),
MenuEntry(Routes.NOTIFICATIONS, R.string.menu_notifications, MenuAccess.SIGNED_IN),
MenuEntry(Routes.PLAYER_CHARACTERS, R.string.menu_my_characters, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_VENDORS, R.string.menu_my_vendors, MenuAccess.PLAYER),
MenuEntry(Routes.PLAYER_HOUSES, R.string.menu_my_houses, MenuAccess.PLAYER),
// Participation history: SIGNED_IN, not PLAYER. The route is `requireAuth`
// alone and self-scoped on the caller's own id, and the website needed two
// 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.
MenuEntry(Routes.ADMIN_DASHBOARD, R.string.menu_admin_dashboard, 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.
* Pure + side-effect-free so the gating is unit-tested without Compose.
* The entries the given [session] may see, on a backend with these [capabilities]
* 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.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
* publishes at all (M11). `null` [features] means the answer isn't known yet and
* every shard entry shows; see [canSee] for why that direction is deliberate.
* publishes to this viewer (M11). Per viewer.
*
* **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(
entries: List<MenuEntry>,
session: Session,
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
* 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
@@ -121,6 +210,7 @@ fun isEntryVisible(
entry: MenuEntry,
session: Session,
features: ShardFeatures? = null,
capabilities: SiteCapabilities? = null,
): Boolean {
val allowedByRole = when (entry.access) {
MenuAccess.PUBLIC -> true
@@ -129,5 +219,7 @@ fun isEntryVisible(
MenuAccess.STAFF -> session is Session.SignedIn && session.user.isStaff
MenuAccess.MODERATOR -> session is Session.SignedIn && session.user.isModerator
}
return allowedByRole && (entry.feature == null || canSee(features, entry.feature))
return allowedByRole &&
canUse(capabilities, entry.capability) &&
(entry.feature == null || canSee(features, entry.feature))
}

View File

@@ -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 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
* 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.
*
* ## 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
* 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):
*
* ```js
* export const NAV = [
* { label: 'Home', to: '/', end: true },
* { label: 'News', to: '/site/news' },
* { label: 'Events', to: '/site/events' },
* { label: 'Screenshots', to: '/site/screenshots' },
* { label: 'Five on Friday', to: '/site/five-on-friday' },
* { label: 'Newsletter', to: '/site/newsletter' },
* { 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' },
* ]
* ```
*
* 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
* 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
@@ -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
* 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`.
*
* 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(
WebNavPath("/", Routes.HOME),
WebNavPath("/site/news", Routes.NEWS),
WebNavPath("/site/events", Routes.EVENTS),
// 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.
WebNavPath("/site/screenshots", Routes.news(PostCategory.SCREENSHOTS)),
WebNavPath("/site/five-on-friday", Routes.news(PostCategory.FIVE_ON_FRIDAY)),
WebNavPath("/site/newsletter", Routes.news(PostCategory.NEWSLETTER)),
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")),
// 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(
"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/newsletter" element={<Newsletter />} />
* <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/status" element={<Status />} />
* <Route path="/site/shard" element={<Shard />} />
* <Route path="/site/shard/activity" element={<ShardActivity />} />
* ... /site/champs, /guilds, /governors, /houses, /rules, /leaderboards, /market
* <Route path="/site/atlas" element={<Atlas />} />
* <Route path="/site/atlas/:slug" element={<AtlasCreature />} />
* <Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
* <Route path="/wiki" element={<Wiki />} />
* <Route path="/wiki/:slug" element={<WikiArticle />} />
* // 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
* <Route path="/:slug" element={<CmsPage />} />
* ```
@@ -177,19 +223,31 @@ private val RESERVED_TOP_LEVEL = setOf(
* /site/{screenshots,five-on-friday,newsletter}
* → NEWS, that category's tab
* /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/<slug> → WIKI_PAGE
* /site/<shard surface> → the mapped shard route (§6.2)
* /site/atlas/<slug> → ATLAS_CREATURE
* /site/market/vendors/<serial> → SHARD_MARKET_VENDOR
* /uo/<shard surface> → the mapped shard route (§6.2)
* /uo/atlas/<slug> → ATLAS_CREATURE
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
* /site/about → PAGE("about")
* /<slug> → PAGE(slug), unless <slug> is reserved
* anything else → null, i.e. the Custom Tab
* ```
*
* **A path carrying a query or a fragment hands off**, whatever its route part
* says. No app route takes either, so a native match would quietly drop what the
* admin wrote; the browser honors it exactly.
* **A path carrying a query or a fragment hands off — with exactly one
* exception.** The rule exists because no app route took either, so a native
* 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.
* 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).
*/
fun resolveWebPath(path: String?): String? {
val normalized = normalizeWebPath(path) ?: return null
if (normalized.any { it == '?' || it == '#' }) return null
WEB_PATH_TO_ROUTE[normalized]?.let { return it }
val raw = path?.trim().orEmpty()
// A fragment is never honored natively: no app route has one to put it in.
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
// Blank segments ("/site//news") mean a malformed path, not a slug.
val segments = normalized.removePrefix("/").split('/')
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 {
segments.size == 1 -> segments[0].takeIf { it !in RESERVED_TOP_LEVEL }?.let(Routes::page)
segments[0] == "wiki" && segments.size == 2 -> Routes.wikiPage(segments[1])
segments[0] != "site" -> null
segments.size == 3 && segments[1] == "newsletter" ->
segments[0] == "site" && segments.size == 3 && segments[1] == "newsletter" ->
Routes.post(PostCategory.NEWSLETTER.urlSlug, segments[2])
segments.size == 3 && segments[1] == "atlas" -> Routes.atlasCreature(segments[2])
segments.size == 4 && segments[1] == "market" && segments[2] == "vendors" ->
Routes.marketVendor(segments[3])
segments[0] == "site" && segments.size == 4 && segments[1] == "events" &&
segments[2] == "series" -> Routes.eventSeries(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
}
}
/**
* 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"

View File

@@ -47,6 +47,29 @@ object Routes {
const val NOTIFICATIONS = "notifications"
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). */
const val SHARD = "shard"
@@ -97,6 +120,7 @@ object Routes {
const val CATEGORY = "category"
const val ID_OR_SLUG = "idOrSlug"
const val SERIAL = "serial"
const val RUN = "run"
}
fun page(slug: String) = "page/$slug"
@@ -119,6 +143,21 @@ object Routes {
/** One creature's atlas page, by 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
* 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.HOUSE_IDOC -> PLAYER_HOUSES
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 }`.
*

View File

@@ -3,8 +3,7 @@
*/
package com.runicgateway.app.ui.notifications
import java.time.Instant
import java.time.LocalDateTime
import com.runicgateway.app.core.time.parseWireInstant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
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.
*
* **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
* starts life as a MariaDB `DATETIME`, and a column 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 — that is what the server stores — rather than as local time, which
* would silently shift every timestamp by the device's offset.
* decide** — see [parseWireInstant], which owns that trap for every screen that
* reads a timestamp, this one and the event screens (M13).
*
* Anything unparseable returns null and the row simply shows no stamp: a
* notification with an odd date is still worth reading.
@@ -29,25 +25,10 @@ fun inboxTimestamp(
formatter: DateTimeFormatter =
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT),
): String? {
val instant = parseInstant(raw) ?: return null
val instant = parseWireInstant(raw) ?: return null
return try {
formatter.withZone(zone).format(instant)
} catch (_: Exception) {
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
}
}
}

View File

@@ -63,6 +63,7 @@ import com.runicgateway.app.ui.components.ShardCard
@Composable
fun InboxScreen(
onOpenSettings: () -> Unit,
onOpenRoute: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: InboxViewModel = hiltViewModel(),
) {
@@ -119,10 +120,18 @@ fun InboxScreen(
onOpen = { item ->
viewModel.markRead(item.id)
// 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,
// so the view model resolves it against the configured shard
// before anything is opened.
viewModel.linkFor(item)?.let { WebHandoff.open(context, it) }
// its own — and the ones that do carry a SITE-RELATIVE path.
//
// A path the app has a screen for opens natively (M13): an
// 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) }
}
},
)
}

View File

@@ -14,11 +14,14 @@ import com.runicgateway.app.core.result.map
import com.runicgateway.app.data.api.dto.NotificationItemDto
import com.runicgateway.app.data.repository.NotificationsRepository
import com.runicgateway.app.ui.UiState
import com.runicgateway.app.ui.navigation.resolveWebPath
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.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -38,6 +41,19 @@ import javax.inject.Inject
* **Paging is keyset, not offset.** The next page is `before = the last id on
* screen`, because the list gains rows at the top while it is being read and an
* offset would show the same item twice or skip one.
*
* **It reloads when the ACCOUNT changes, not merely when it is created.** A
* drawer route's view model outlives a sign-out: `navigateTopLevel` saves and
* restores back-stack state, so the `NavBackStackEntry` keeps its
* `ViewModelStore` and a view model that loaded only in `init` never runs again.
* Signing out and back in as somebody else showed the second account the FIRST
* account's inbox — titles and body text written for another person — with no
* request made at all, while the badge beside it showed the new account's real
* count, because the shell refreshes that one on every session change.
*
* [InboxCache] was never the hole: it is keyed by `(base URL, user id)` and a
* snapshot has never crossed an account. The hole was the in-memory state, which
* nothing invalidated.
*/
@HiltViewModel
class InboxViewModel @Inject constructor(
@@ -67,7 +83,25 @@ class InboxViewModel @Inject constructor(
val state: StateFlow<State> = _state.asStateFlow()
init {
load()
viewModelScope.launch {
sessionManager.state
.map { (it as? Session.SignedIn)?.user?.id }
.distinctUntilChanged()
.collect { userId ->
if (userId == null) {
// Signed out. The shell is already navigating away; drop the
// rows rather than leave them addressable behind it.
_state.value = State(items = UiState.Success(emptyList()))
} else {
// Reset BEFORE loading, not after: `load()` paints the cache
// only when there is no `Success` on screen, so the previous
// account's rows would otherwise stay up — and stay up for
// the whole round trip.
_state.value = State()
load()
}
}
}
}
/**
@@ -220,6 +254,38 @@ class InboxViewModel @Inject constructor(
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.
*

View File

@@ -7,6 +7,8 @@ import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.runicgateway.app.R
import com.runicgateway.app.core.auth.Session
import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.core.push.PushManager
import com.runicgateway.app.core.result.ApiResult
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
@@ -19,6 +21,8 @@ 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.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -51,6 +55,7 @@ class NotificationSettingsViewModel @Inject constructor(
private val notifications: NotificationsRepository,
private val playerShard: PlayerShardRepository,
private val pushManager: PushManager,
sessionManager: SessionManager,
) : ViewModel() {
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
@@ -72,7 +77,17 @@ class NotificationSettingsViewModel @Inject constructor(
viewModelScope.launch {
pushManager.supported.collect { supported -> _state.update { it.copy(supported = supported) } }
}
load()
// Reloaded on an account change for the reason the inbox is, and one
// reason more: these controls are WRITTEN from. A screen still rendering
// the previous account's preferences would send this account's PUT built
// out of them, so a stale render here corrupts rather than merely
// discloses.
viewModelScope.launch {
sessionManager.state
.map { (it as? Session.SignedIn)?.user?.id }
.distinctUntilChanged()
.collect { userId -> if (userId != null) load() }
}
}
fun load() {

View File

@@ -10,6 +10,8 @@ import com.runicgateway.app.core.auth.SessionManager
import com.runicgateway.app.data.repository.AuthRepository
import com.runicgateway.app.data.repository.ShardFeatures
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 kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -26,6 +28,7 @@ class SessionViewModel @Inject constructor(
sessionManager: SessionManager,
private val authRepository: AuthRepository,
shardFeaturesRepository: ShardFeaturesRepository,
siteCapabilitiesRepository: SiteCapabilitiesRepository,
) : ViewModel() {
val session: StateFlow<Session> = sessionManager.state
@@ -38,6 +41,19 @@ class SessionViewModel @Inject constructor(
*/
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 {
// 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

View File

@@ -41,6 +41,7 @@
<string name="nav_opens_in_browser">Opens in your browser</string>
<string name="menu_home">Home</string>
<string name="menu_news">News</string>
<string name="menu_events">Events</string>
<string name="menu_wiki">Wiki</string>
<string name="menu_shard">Shard</string>
<string name="menu_rules">Rules</string>
@@ -50,6 +51,7 @@
<string name="menu_about">About</string>
<string name="menu_contact">Contact</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_vendors">My vendors</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_account_login">Login to your account</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>

View File

@@ -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)
}
}

View File

@@ -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.GuildDto
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.PageDto
import com.runicgateway.app.data.api.dto.PostDto
@@ -67,6 +68,19 @@ class FakePublicApi : PublicApi {
var houses: List<HouseDto> = emptyList()
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
// shard has never published one, which is a success, not a failure.
var ruleset: RulesetDto? = null
@@ -94,9 +108,17 @@ class FakePublicApi : PublicApi {
}
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 getModules(): ModulesDto {
modulesError?.let { throw it }
return reply(modules)
}
override suspend fun getPosts(category: String): List<PostDto> = reply(posts)
override suspend fun getPost(category: String, idOrSlug: String): PostDto = reply(post)
override suspend fun getPage(slug: String): PageDto = reply(page)

View File

@@ -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)
}
}

View File

@@ -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()),
)
}
}

View File

@@ -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)
}
}

View File

@@ -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)))
}
}

View File

@@ -44,12 +44,24 @@ class NavOverridesTest {
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(
Routes.HOME, Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.page("about"),
/**
* The public block once a stored row has made the merge sort it — the website's
* number line, not the app's coded order.
*
* **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 ─────────────────────────────────────
@Test fun noStoredRowReturnsTheCodedMenuItself() {
@@ -71,7 +83,7 @@ class NavOverridesTest {
"/site/news" to entry(hidden = false),
"/admin/appearance" to entry(label = "Nope"),
"/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))
@@ -85,7 +97,7 @@ class NavOverridesTest {
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)
assertNull(merged.first { it.route == Routes.NEWS }.label)
}
@@ -93,14 +105,16 @@ class NavOverridesTest {
// ── Labels ───────────────────────────────────────────────────────────
@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 }
assertEquals("The Realm", shard.label)
// The override lands on `label` and nothing else — the gates are untouched.
assertEquals(ShardFeature.STATUS, shard.feature)
assertEquals(MenuAccess.PUBLIC, shard.access)
assertEquals(codedPublic, merged.take(9).map { it.route })
assertEquals(mergedPublic, merged.take(publicBlock).map { it.route })
}
@Test fun aNonStringLabelIsIgnored() {
@@ -112,7 +126,7 @@ class NavOverridesTest {
// ── Hidden ───────────────────────────────────────────────────────────
@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)
assertEquals(APP_MENU.size - 1, routes.size)
@@ -128,7 +142,7 @@ class NavOverridesTest {
}
@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() {
@@ -140,44 +154,50 @@ class NavOverridesTest {
// ── Order ────────────────────────────────────────────────────────────
@Test fun anExplicitOrderMovesTheRowWithinThePublicBlock() {
// The website's own indices: About is 15 and Home is 0, so swapping them
// is what an admin dragging About to the top writes.
// The website's own indices: About is 7, and Market — the last row of all,
// 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(
nav(
"/site/about" to entry(order = 0),
"/" to entry(order = 15),
"/" to entry(order = 17),
),
)
assertEquals(
listOf(
Routes.page("about"), Routes.NEWS, Routes.WIKI, Routes.SHARD, Routes.SHARD_RULES,
Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET, Routes.HOME,
Routes.page("about"), Routes.NEWS, Routes.EVENTS, Routes.WIKI, Routes.SHARD,
Routes.SHARD_RULES, Routes.ATLAS, Routes.SHARD_LEADERBOARDS, Routes.SHARD_MARKET,
Routes.HOME,
),
routes.take(9),
routes.take(publicBlock),
)
}
@Test fun anUntouchedRowKeepsItsPlaceOnTheWebsitesNumberLine() {
// 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
// the three news categories sit between News and Wiki). Explicit wins.
val routes = routes(nav("/site/about" to entry(order = 5)))
// explicit 6 meets Wiki's implicit 6 (its index in the site's nav, where
// Events and the three news categories sit between News and Wiki). Explicit
// 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(
listOf(Routes.HOME, Routes.NEWS, Routes.page("about"), Routes.WIKI),
routes.take(4),
listOf(Routes.HOME, Routes.NEWS, Routes.EVENTS, Routes.page("about"), Routes.WIKI),
routes.take(5),
)
}
@Test fun theAppsOwnRowsKeepTheirCodedOrderAfterThePublicBlock() {
// Contact, Account, Notifications, the three player groups and the four
// staff rows have no website counterpart to be reordered against (§6.2).
val tail = APP_MENU.drop(9).map { it.route }
// Contact, Account, Notifications, My Events, the three player groups and
// the four staff rows have no website counterpart to be reordered against
// (§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)))
assertEquals(tail, merged.drop(9))
assertEquals(tail, merged.drop(publicBlock))
}
@Test fun reorderingAndHidingCompose() {
@@ -224,7 +244,7 @@ class NavOverridesTest {
@Test fun anOverrideCannotUnhideAFeatureGatedRow() {
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(

View File

@@ -19,13 +19,27 @@ import org.junit.Test
class NavPathsTest {
@Test fun everyWebsiteNavPathIsMapped() {
// The sixteen rows of SiteHeader.jsx's NAV, quoted in NavPaths.kt. If the
// site adds one, this is the test that says so — a path with no mapping is
// silently unresolvable in phase 6's link handling.
assertEquals(16, WEBSITE_PUBLIC_NAV.size)
// Core's eight rows plus module-uo's nine, both quoted in NavPaths.kt. If
// either side adds one, this is the test that says so — a path with no
// mapping is silently unresolvable in phase 6's link handling, which is
// 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)
}
@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() {
// WEB_ROUTE_ORDER is keyed by route, so a duplicate would silently drop a
// row's position from the sort.
@@ -34,15 +48,20 @@ class NavPathsTest {
}
@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(1, WEB_ROUTE_ORDER[Routes.NEWS])
assertEquals(5, WEB_ROUTE_ORDER[Routes.WIKI])
assertEquals(15, WEB_ROUTE_ORDER[Routes.page("about")])
assertEquals(2, WEB_ROUTE_ORDER[Routes.EVENTS])
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() {
// Nine of the sixteen have a drawer row. The other seven are mapped but not
@Test fun theDrawerRowsAreTheIntersectionWithAppMenu() {
// 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
// an override for one of them is ignored rather than obeyed (§6.2).
val coded = APP_MENU.map { it.route }.toSet()
@@ -50,8 +69,8 @@ class NavPathsTest {
assertEquals(
listOf(
"/", "/site/news", "/wiki", "/site/shard", "/site/rules",
"/site/atlas", "/site/leaderboards", "/site/market", "/site/about",
"/", "/site/news", "/site/events", "/wiki", "/site/about",
"/uo/shard", "/uo/rules", "/uo/atlas", "/uo/leaderboards", "/uo/market",
),
surfaced,
)
@@ -62,7 +81,7 @@ class NavPathsTest {
// tab or a hub board is a perfectly good destination.
val unsurfaced = listOf(
"/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 })
@@ -113,13 +132,13 @@ class NavPathsTest {
@Test fun aTrailingSlashIsTolerated() {
// A hand-edited settings row may carry one; the root is left alone.
assertEquals(Routes.WIKI, appRouteForWebPath("/wiki/"))
assertEquals(Routes.SHARD, appRouteForWebPath(" /site/shard/ "))
assertEquals(Routes.SHARD, appRouteForWebPath(" /uo/shard/ "))
assertEquals(Routes.HOME, appRouteForWebPath("/"))
}
// ── 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
// does, or the same destination would behave differently depending on how
// 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
// no /site/news/<id> route — its one post-detail route is the newsletter's.
assertEquals(Routes.wikiPage("smithing"), resolveWebPath("/wiki/smithing"))
assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/site/atlas/dragon"))
assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/site/market/vendors/0x24C"))
assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/uo/atlas/dragon"))
assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/uo/market/vendors/0x24C"))
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() {
@@ -157,15 +238,18 @@ class NavPathsTest {
@Test fun aPathTheAppHasNoScreenForHandsOff() {
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("/admin/navigation"))
assertNull(resolveWebPath("/site/atlas/dragon/extra"))
}
@Test fun aQueryOrFragmentHandsOff() {
// No app route takes either, so a native match would quietly drop what the
// admin wrote. The browser honors it exactly.
// No app route but the event page takes either, so a native match would
// quietly drop what the admin wrote. The browser honors it exactly.
assertNull(resolveWebPath("/site/news?tag=patch"))
assertNull(resolveWebPath("/donate#tiers"))
assertEquals(Routes.NEWS, resolveWebPath("/site/news"))

View File

@@ -166,15 +166,15 @@ class NavTreeTest {
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.
assertEquals("section:lore", shape[8])
assertEquals(Routes.CONTACT, shape[9])
assertEquals("section:lore", shape[9])
assertEquals(Routes.CONTACT, shape[10])
}
@Test fun aSectionsOrderPlacesItAmongTheCodedRows() {
// 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(
items = items("/wiki" to item(section = "lore")),
sections = listOf(section("lore", order = 0)),
@@ -252,7 +252,7 @@ class NavTreeTest {
// deliberately, and grouping is no more an invitation to surface one than
// relabelling was (§6.2).
val row = stored(
items = items("/site/champs" to item(section = "lore", label = "Champs")),
items = items("/uo/champs" to item(section = "lore", label = "Champs")),
sections = listOf(section("lore")),
)
@@ -324,7 +324,9 @@ class NavTreeTest {
val shape = tree(row).shape()
assertEquals(listOf("link:a", "link:b"), shape.filter { it.startsWith("link:") })
assertEquals(Routes.page("about"), shape[shape.indexOf("link:a") - 1])
// 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() {
@@ -364,7 +366,7 @@ class NavTreeTest {
// The case the rule exists for: a group whose every member is withheld by
// the shard's visibility config must not draw as a header over nothing.
val row = stored(
items = items("/site/market" to item(section = "lore")),
items = items("/uo/market" to item(section = "lore")),
sections = listOf(section("lore")),
)
@@ -380,7 +382,7 @@ class NavTreeTest {
@Test fun aSectionKeepsTheMembersThisCallerMaySee() {
val row = stored(
items = items(
"/site/market" to item(section = "lore"),
"/uo/market" to item(section = "lore"),
"/wiki" to item(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
// publish the market and an admin does not outrank that.
val row = stored(
items = items("/site/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")),
items = items("/uo/market" to item(label = "Bazaar", order = 0, hidden = false, section = "lore")),
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
// section holding one is never emptied by the caller's role.
val row = stored(
items = items("/site/market" to item(section = "lore")),
items = items("/uo/market" to item(section = "lore")),
sections = listOf(section("lore")),
links = listOf(link(section = "lore")),
)

View File

@@ -11,6 +11,7 @@ import com.runicgateway.app.core.net.BaseUrlHolder
import com.runicgateway.app.data.api.dto.NotificationInboxDto
import com.runicgateway.app.data.api.dto.NotificationItemDto
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
import com.runicgateway.app.data.api.dto.SafeUserDto
import com.runicgateway.app.data.api.fake.FakeNotificationsApi
import com.runicgateway.app.data.repository.NotificationsRepository
import com.runicgateway.app.ui.UiState
@@ -193,4 +194,89 @@ class InboxViewModelTest {
assertNull(vm.linkFor(item(1).copy(url = "javascript:alert(1)")))
assertNull(vm.linkFor(item(1).copy(url = "intent://evil#Intent;end")))
}
// ── The inbox belongs to ONE account ─────────────────────────────
@Test fun switchingAccountDoesNotShowThePreviousOnesInbox() {
// **Found on the emulator, not by a test.** A drawer route's view model
// outlives 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 and back in as
// somebody else showed the second account the FIRST account's inbox —
// titles and body text written for another person — with no request made
// at all, while the badge beside it showed the new account's real count.
val sessions = session(userId = 7)
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(1), item(2)), unread = 2))
val vm = InboxViewModel(NotificationsRepository(api), cache, sessions, baseUrl)
assertEquals(listOf(1L, 2L), shown(vm)!!.map { it.id })
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(9)), unread = 1))
sessions.onSignedOut()
// Signed out, the previous account's rows are gone rather than left
// addressable behind a shell that is navigating away.
assertEquals(emptyList<Long>(), shown(vm)!!.map { it.id })
sessions.onSignedIn("a", "r", SafeUserDto(id = 8, username = "bob", role = "player"))
assertEquals(listOf(9L), shown(vm)!!.map { it.id })
}
@Test fun aResumeRevalidationReturningTheSameUserDoesNotRefetch() {
// The gate is the account, not every session emission — the app
// re-validates its role on every resume.
val sessions = session(userId = 7)
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(1)), unread = 1))
val vm = InboxViewModel(NotificationsRepository(api), cache, sessions, baseUrl)
val callsAfterFirstLoad = api.inboxCalls.size
sessions.onUserRefreshed(SafeUserDto(id = 7, username = "alice", role = "admin"))
assertEquals(callsAfterFirstLoad, api.inboxCalls.size)
assertEquals(listOf(1L), shown(vm)!!.map { it.id })
}
// ── 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)")))
}
}