From e10e1f1617ad323ea2ad3d41f013ec1a8e570d6f Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 8 Sep 2026 13:08:43 -0500 Subject: [PATCH 1/2] feat(events): the app's events screens, and the module rows that were never gated (Phase 14b) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Events Phase 14b, the app half — recorded as M13 in docs/android/PLAN.md. Four screens on the four routes Phase 14a shipped: the public calendar, an event page carrying `?run=`, an arc, and participation history. One drawer row for the history, at SIGNED_IN rather than PLAYER: the route is `requireAuth` alone and self-scoped, and the website needed two mounts for it only because `RequirePlayer` guards `/account` there. The prerequisite fix is the larger half. The app read `/public/modules` nowhere and mapped every `/public/shard/features` failure to "unknown", which `canSee` treats as visible — so on a site with no `uo` module every shard row rendered and every one of them 404'd. Absence of an answer is not an answer of absence: a successful module list that omits `shard` hides the rows, a failed read keeps the last answer the host gave, and a host that has never answered leaves the gate open. Capability and feature compose as two gates and answer different questions: whether the module is installed (per host) and whether this shard publishes the surface to this viewer (per viewer). Also corrects the website path → route table, wrong since the module-system cutover on 2026-08-12: core's NAV is eight rows, not sixteen, and the nine shard rows moved to `/uo/*`. A nav override on any shard row was ignored, an added link to one handed off to a browser, and the sort-key line was wrong. Two existing tests had been passing vacuously since that day. An inbox link to an event now opens the app rather than a Custom Tab, through `resolveWebPath` rather than a second mechanism — so its "a query hands off" rule gains exactly one exception, `run` on an event page. The emulator walk found three defects that 563 green tests did not: - the three player game-data rows read `/player/shard/*` and were not gated, so they rendered and 404'd; the test meant to catch that asked whether every row *with a feature* declared the capability, and those three have none. It now asks by route. - `score` is DECIMAL(18,4) and was declared an integer, so one `318.5` made kotlinx refuse the entire body and a 200 rendered as a server error — latent on the public results table for every visitor. - a drawer route's view model outlives a sign-out, so signing in as a second account showed it the first account's participation history with no request made at all. 570 tests, 0 failures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- .../runicgateway/app/core/time/Instants.kt | 41 +++ .../runicgateway/app/data/api/EventsApi.kt | 84 +++++ .../runicgateway/app/data/api/PublicApi.kt | 13 + .../app/data/api/dto/EventsDto.kt | 212 ++++++++++++ .../app/data/api/dto/PublicDto.kt | 41 +++ .../data/repository/ConnectionRepository.kt | 5 + .../app/data/repository/EventsRepository.kt | 61 ++++ .../repository/SiteCapabilitiesRepository.kt | 164 +++++++++ .../com/runicgateway/app/di/NetworkModule.kt | 10 + .../com/runicgateway/app/ui/AppViewModel.kt | 9 + .../java/com/runicgateway/app/ui/RunicApp.kt | 61 +++- .../runicgateway/app/ui/events/EventScreen.kt | 310 ++++++++++++++++++ .../app/ui/events/EventSeriesScreen.kt | 113 +++++++ .../app/ui/events/EventSeriesViewModel.kt | 50 +++ .../runicgateway/app/ui/events/EventTimes.kt | 166 ++++++++++ .../app/ui/events/EventViewModel.kt | 59 ++++ .../app/ui/events/EventsScreen.kt | 191 +++++++++++ .../app/ui/events/EventsViewModel.kt | 51 +++ .../app/ui/events/MyEventsScreen.kt | 142 ++++++++ .../app/ui/events/MyEventsViewModel.kt | 117 +++++++ .../runicgateway/app/ui/navigation/Menu.kt | 124 ++++++- .../app/ui/navigation/NavPaths.kt | 186 ++++++++--- .../runicgateway/app/ui/navigation/Routes.kt | 51 ++- .../app/ui/notifications/InboxFormatting.kt | 27 +- .../app/ui/notifications/InboxScreen.kt | 17 +- .../app/ui/notifications/InboxViewModel.kt | 33 ++ .../app/ui/session/SessionViewModel.kt | 16 + app/src/main/res/values/strings.xml | 41 +++ .../app/data/api/fake/FakeEventsApi.kt | 60 ++++ .../app/data/api/fake/FakePublicApi.kt | 24 +- .../SiteCapabilitiesRepositoryTest.kt | 160 +++++++++ .../app/ui/events/EventTimesTest.kt | 151 +++++++++ .../app/ui/events/EventsViewModelsTest.kt | 261 +++++++++++++++ .../ui/navigation/MenuCapabilityGatingTest.kt | 175 ++++++++++ .../app/ui/navigation/NavOverridesTest.kt | 72 ++-- .../app/ui/navigation/NavPathsTest.kt | 122 +++++-- .../app/ui/navigation/NavTreeTest.kt | 22 +- .../ui/notifications/InboxViewModelTest.kt | 46 +++ 38 files changed, 3346 insertions(+), 142 deletions(-) create mode 100644 app/src/main/java/com/runicgateway/app/core/time/Instants.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/repository/EventsRepository.kt create mode 100644 app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/events/EventSeriesScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/events/EventSeriesViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/events/EventTimes.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/events/EventsScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/events/EventsViewModel.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/events/MyEventsScreen.kt create mode 100644 app/src/main/java/com/runicgateway/app/ui/events/MyEventsViewModel.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt create mode 100644 app/src/test/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepositoryTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/events/EventTimesTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/events/EventsViewModelsTest.kt create mode 100644 app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt diff --git a/app/src/main/java/com/runicgateway/app/core/time/Instants.kt b/app/src/main/java/com/runicgateway/app/core/time/Instants.kt new file mode 100644 index 0000000..af4494d --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/core/time/Instants.kt @@ -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 + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt b/app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt new file mode 100644 index 0000000..ed49068 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt @@ -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 +} diff --git a/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt b/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt index ef1d654..808b44f 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/PublicApi.kt @@ -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 diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt new file mode 100644 index 0000000..25e5d6b --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt @@ -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 = 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 = 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 = emptyList(), + val past: List = 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 = 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 = emptyList(), +) diff --git a/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt b/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt index 3d69e7a..cdcde5c 100644 --- a/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt +++ b/app/src/main/java/com/runicgateway/app/data/api/dto/PublicDto.kt @@ -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 = 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 = 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 = emptyList(), ) /** `GET /public/status` — site mode + version for the first-run probe (§3). */ diff --git a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt index 104c49d..e97f35f 100644 --- a/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt +++ b/app/src/main/java/com/runicgateway/app/data/repository/ConnectionRepository.kt @@ -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) } diff --git a/app/src/main/java/com/runicgateway/app/data/repository/EventsRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/EventsRepository.kt new file mode 100644 index 0000000..0ada5d4 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/EventsRepository.kt @@ -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 = 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 = + 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 = + 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> = + safeApiCall { api.getHistory(limit, before) }.map { it.entries } +} diff --git a/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt b/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt new file mode 100644 index 0000000..a5417a8 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepository.kt @@ -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(null) + + /** The current answer, or `null` while this host has never given one. */ + val capabilities: StateFlow = _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, + /** Every started module's, flattened. Two modules may declare the same string. */ + val modules: Set, +) { + /** 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" +} diff --git a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt index ecbb819..2e76578 100644 --- a/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt +++ b/app/src/main/java/com/runicgateway/app/di/NetworkModule.kt @@ -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 diff --git a/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt index ca6d7f9..991a4e2 100644 --- a/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/AppViewModel.kt @@ -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) diff --git a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt index 6fa808f..a90403e 100644 --- a/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt +++ b/app/src/main/java/com/runicgateway/app/ui/RunicApp.kt @@ -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) } } diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt new file mode 100644 index 0000000..c9b0469 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt @@ -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, + 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, + ) + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventSeriesScreen.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventSeriesScreen.kt new file mode 100644 index 0000000..f605ca5 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventSeriesScreen.kt @@ -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, + ) + } + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventSeriesViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventSeriesViewModel.kt new file mode 100644 index 0000000..b5d6781 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventSeriesViewModel.kt @@ -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(Routes.Args.SLUG).orEmpty() + + private val _state = MutableStateFlow>(UiState.Loading) + val state: StateFlow> = _state.asStateFlow() + + init { + load() + } + + fun load() { + _state.value = UiState.Loading + viewModelScope.launch { + _state.value = repository.series(slug).toUiState() + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventTimes.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventTimes.kt new file mode 100644 index 0000000..0f80c95 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventTimes.kt @@ -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 + } diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt new file mode 100644 index 0000000..0fa6d07 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventViewModel.kt @@ -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(Routes.Args.SLUG).orEmpty() + + /** Null unless the route carried one; never an empty string forwarded to the server. */ + private val run: String? = savedStateHandle.get(Routes.Args.RUN)?.takeIf { it.isNotBlank() } + + private val _state = MutableStateFlow>(UiState.Loading) + val state: StateFlow> = _state.asStateFlow() + + init { + load() + } + + fun load() { + _state.value = UiState.Loading + viewModelScope.launch { + _state.value = repository.event(slug, run).toUiState() + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventsScreen.kt new file mode 100644 index 0000000..a298618 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventsScreen.kt @@ -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): List { + val days = mutableListOf() + 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, +) + +@Composable +private fun Calendar( + entries: List, + 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, + ) + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/events/EventsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/events/EventsViewModel.kt new file mode 100644 index 0000000..c01b040 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/EventsViewModel.kt @@ -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.Loading) + val state: StateFlow> = _state.asStateFlow() + + init { + load() + } + + fun load() { + _state.value = UiState.Loading + viewModelScope.launch { + _state.value = repository.calendar().toUiState() + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/events/MyEventsScreen.kt b/app/src/main/java/com/runicgateway/app/ui/events/MyEventsScreen.kt new file mode 100644 index 0000000..8d13be1 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/MyEventsScreen.kt @@ -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, + ) + } + } + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/events/MyEventsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/events/MyEventsViewModel.kt new file mode 100644 index 0000000..c42ca75 --- /dev/null +++ b/app/src/main/java/com/runicgateway/app/ui/events/MyEventsViewModel.kt @@ -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> = UiState.Loading, + val hasMore: Boolean = false, + val loadingMore: Boolean = false, + ) + + private val _state = MutableStateFlow(State()) + val state: StateFlow = _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 + } +} diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt index 56a9071..2c0056c 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Menu.kt @@ -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 = 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 = 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, session: Session, features: ShardFeatures? = null, -): List = entries.filter { isEntryVisible(it, session, features) } + capabilities: SiteCapabilities? = null, +): List = 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)) } diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt index 381d02a..69766cf 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/NavPaths.kt @@ -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 `//` — 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 + * `//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 = 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 `//`, 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 `//…` and are not CMS pages. + // Only ids the app knows about need listing: an unknown module's `/` 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( * } /> * } /> * } /> + * } /> + * } /> + * } /> * } /> * } /> - * } /> - * } /> - * ... /site/champs, /guilds, /governors, /houses, /rules, /leaderboards, /market - * } /> - * } /> - * } /> * } /> * } /> + * // Installed modules' pages, mounted at `//`: + * // /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 * } /> * ``` @@ -177,19 +223,31 @@ private val RESERVED_TOP_LEVEL = setOf( * /site/{screenshots,five-on-friday,newsletter} * → NEWS, that category's tab * /site/newsletter/ → POST (the site's one post-detail route) + * /site/events → EVENTS + * /site/events/series/ → EVENT_SERIES + * /site/events/[?run=] → EVENT (the one route that takes a query) * /wiki → WIKI * /wiki/ → WIKI_PAGE - * /site/ → the mapped shard route (§6.2) - * /site/atlas/ → ATLAS_CREATURE - * /site/market/vendors/ → SHARD_MARKET_VENDOR + * /uo/ → the mapped shard route (§6.2) + * /uo/atlas/ → ATLAS_CREATURE + * /uo/market/vendors/ → SHARD_MARKET_VENDOR * /site/about → PAGE("about") * / → PAGE(slug), unless is reserved * anything else → null, i.e. the Custom Tab * ``` * - * **A path carrying a query or a fragment hands off**, whatever its route part - * says. No app route takes either, so a native match would quietly drop what the - * admin wrote; the browser honors it exactly. + * **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=`, 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" diff --git a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt index 5f4e8b4..c21ad96 100644 --- a/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt +++ b/app/src/main/java/com/runicgateway/app/ui/navigation/Routes.kt @@ -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 }`. * diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxFormatting.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxFormatting.kt index 63156eb..e357508 100644 --- a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxFormatting.kt +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxFormatting.kt @@ -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 - } - } -} diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt index 8679694..7ef54f8 100644 --- a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxScreen.kt @@ -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) } + } }, ) } diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt index 7bee565..a8e7d49 100644 --- a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt @@ -14,6 +14,7 @@ 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 @@ -220,6 +221,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/?run=`, 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. * diff --git a/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt index 4b10175..0f0679d 100644 --- a/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/session/SessionViewModel.kt @@ -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 = sessionManager.state @@ -38,6 +41,19 @@ class SessionViewModel @Inject constructor( */ val shardFeatures: StateFlow = 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 = 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 710cd54..ace8450 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,6 +41,7 @@ Opens in your browser Home News + Events Wiki Shard Rules @@ -50,6 +51,7 @@ About Contact My account + My events My characters My vendors My houses @@ -525,4 +527,43 @@ Your house entered IDOC Login to your account New notification + + + + Happening now + Scheduled + Finished + Cancelled + Did not happen + + Nothing on the calendar just yet — check back soon. + + Expected — not yet confirmed + Showing the first part of a busy calendar. + + Part of %1$s + Next + Under way + Nothing scheduled at the moment. + This event has not been scheduled yet. + Coming up + Previously + Results + Results were published with nobody recorded. + + Unnamed + + You have not taken part in an event yet. + Rank %1$d + + Results not published + Score %1$s + Show more + Loading… diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt new file mode 100644 index 0000000..2003373 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakeEventsApi.kt @@ -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 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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt index 6cb7479..9019190 100644 --- a/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt +++ b/app/src/test/java/com/runicgateway/app/data/api/fake/FakePublicApi.kt @@ -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 = 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 = reply(posts) override suspend fun getPost(category: String, idOrSlug: String): PostDto = reply(post) override suspend fun getPage(slug: String): PageDto = reply(page) diff --git a/app/src/test/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepositoryTest.kt b/app/src/test/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepositoryTest.kt new file mode 100644 index 0000000..c0cb08e --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/data/repository/SiteCapabilitiesRepositoryTest.kt @@ -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, moduleCaps: List) { + 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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/events/EventTimesTest.kt b/app/src/test/java/com/runicgateway/app/ui/events/EventTimesTest.kt new file mode 100644 index 0000000..0571583 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/events/EventTimesTest.kt @@ -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()), + ) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/events/EventsViewModelsTest.kt b/app/src/test/java/com/runicgateway/app/ui/events/EventsViewModelsTest.kt new file mode 100644 index 0000000..cd820d7 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/events/EventsViewModelsTest.kt @@ -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( + """{"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( + """{"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) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt new file mode 100644 index 0000000..aa1e757 --- /dev/null +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/MenuCapabilityGatingTest.kt @@ -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))) + } +} diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt index 9c12aa3..ace878d 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavOverridesTest.kt @@ -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 `//` (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( diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt index d4cc3a2..37c0d3e 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavPathsTest.kt @@ -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 `//`. 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/ 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")) diff --git a/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt b/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt index 43745ef..b60465c 100644 --- a/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/navigation/NavTreeTest.kt @@ -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")), ) diff --git a/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt index 4353c12..2e6c461 100644 --- a/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt @@ -193,4 +193,50 @@ class InboxViewModelTest { assertNull(vm.linkFor(item(1).copy(url = "javascript:alert(1)"))) assertNull(vm.linkFor(item(1).copy(url = "intent://evil#Intent;end"))) } + + // ── Opening an item in the app rather than a browser (M13) ───────── + + @Test fun anEventAnnouncementOpensNativelyAndKeepsItsRun() { + // The exact shape events Phase 14a writes into every announcement. Before + // M13 this opened a Custom Tab onto a page the app now renders itself. + val vm = viewModel() + val item = item(1).copy(url = "/site/events/the-yew-invasion?run=3692") + + assertEquals("events/the-yew-invasion?run=3692", vm.routeFor(item)) + } + + @Test fun anAbsoluteUrlOnThisHostOpensNativelyToo() { + // The url's shape is the server's to change; a link that reached the browser + // only because it arrived fully qualified would be a puzzle. + val vm = viewModel() + val item = item(1).copy(url = "https://shard.example/site/events/yew?run=7") + + assertEquals("events/yew?run=7", vm.routeFor(item)) + } + + @Test fun aLinkToAnotherHostIsNotOursToRoute() { + val vm = viewModel() + val item = item(1).copy(url = "https://elsewhere.example/site/events/yew") + + assertNull(vm.routeFor(item)) + // …and still opens, in the browser, exactly as it did before. + assertEquals("https://elsewhere.example/site/events/yew", vm.linkFor(item)) + } + + @Test fun everyOtherLinkStillHandsOff() { + // The change is additive: a path the app has no screen for behaves exactly + // as it did, and `linkFor` is still what opens it. + val vm = viewModel() + val forum = item(1).copy(url = "/guilds/the-silver-anvil/forum/403") + + assertNull(vm.routeFor(forum)) + assertEquals("https://shard.example/guilds/the-silver-anvil/forum/403", vm.linkFor(forum)) + } + + @Test fun anItemWithNoUrlHasNoRoute() { + val vm = viewModel() + assertNull(vm.routeFor(item(1))) + assertNull(vm.routeFor(item(1).copy(url = " "))) + assertNull(vm.routeFor(item(1).copy(url = "javascript:alert(1)"))) + } } From aa055469a8fb48d9eca21f77c066ba06829b1ad8 Mon Sep 17 00:00:00 2001 From: wtclaude Date: Tue, 8 Sep 2026 17:13:53 -0500 Subject: [PATCH 2/2] fix(notifications): reload the inbox and its settings when the account changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The defect Phase 14b found in `MyEventsViewModel` and flagged next door: the notifications surface has the identical shape, and it leaks the same way. A drawer route's view model outlives a sign-out. `navigateTopLevel` uses `popUpTo(HOME) { saveState = true }` with `restoreState = true`, 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 above the list showed the new account's real unread count, because the shell refreshes that 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. Both view models now key on the signed-in account id, so a resume revalidation that returns the same user does not refetch. The inbox resets its state *before* loading rather than after, because `load()` paints the cache only when there is no `Success` on screen — otherwise the previous account's rows stay up for the whole round trip. The settings screen behind the inbox's gear is fixed with it, and there the stale render is worse than disclosure: those controls are written from, so a screen still showing the previous account's preferences would send this account's PUT built out of them. Walked on the emulator against a local website, before and after: two accounts with deliberately different inboxes, signed out and in within one process. Before, the second account saw the first's rows and the server logged no inbox fetch; after, it logs the fetch and shows its own. 572 tests, 0 failures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4 --- .../app/ui/notifications/InboxViewModel.kt | 35 +++++++++++++++- .../NotificationSettingsViewModel.kt | 17 +++++++- .../ui/notifications/InboxViewModelTest.kt | 40 +++++++++++++++++++ 3 files changed, 90 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt index a8e7d49..6881f1f 100644 --- a/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/InboxViewModel.kt @@ -20,6 +20,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 @@ -39,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( @@ -68,7 +83,25 @@ class InboxViewModel @Inject constructor( val state: StateFlow = _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() + } + } + } } /** diff --git a/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModel.kt b/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModel.kt index c522809..b4dc15a 100644 --- a/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModel.kt +++ b/app/src/main/java/com/runicgateway/app/ui/notifications/NotificationSettingsViewModel.kt @@ -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() { diff --git a/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt b/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt index 2e6c461..2ec5b24 100644 --- a/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt +++ b/app/src/test/java/com/runicgateway/app/ui/notifications/InboxViewModelTest.kt @@ -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 @@ -194,6 +195,45 @@ class InboxViewModelTest { 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(), 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() {