Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 37a828736e | |||
| 4b22ab3756 | |||
| daf483f514 | |||
| a6677d5bf9 | |||
| a6b6c92c33 | |||
| ac2d75c3f9 | |||
| aa055469a8 | |||
| f3d90b189d | |||
| e10e1f1617 | |||
| 80441c3367 | |||
| d3bf4853de | |||
| 21b6ddc29b | |||
| d393cf022e | |||
| 21e235a07f |
@@ -1,5 +1,5 @@
|
||||
# Gate every pull request into `main` on lint + unit tests + a debug build, so a
|
||||
# broken build can't reach the deployable branch. Debug builds are auto-signed,
|
||||
# Gate every pull request into `main` or `edge` on lint + unit tests + a debug
|
||||
# build, so a broken build can't reach the deployable branch. Debug builds are auto-signed,
|
||||
# so this gate needs no secrets. The signed *release* APK + Gitea release come
|
||||
# later (release.yml, M6). See docs/android/PLAN.md §12.
|
||||
#
|
||||
@@ -18,9 +18,14 @@
|
||||
|
||||
name: PR Checks
|
||||
|
||||
# `edge` is here because a workstream that lands ten phase PRs onto it before one
|
||||
# cutover PR into `main` otherwise gets NO CI at all until the cutover — which is
|
||||
# exactly what happened to all nine M12 phase PRs, and would have happened again
|
||||
# to engagement Phase 8 (ENGAGEMENT.md §7.1 Q8). A phase should fail on its own
|
||||
# PR, not inside the cutover window with a whole workstream's diff to bisect.
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, edge]
|
||||
|
||||
concurrency:
|
||||
group: pr-checks-${{ github.ref }}
|
||||
@@ -40,8 +45,15 @@ jobs:
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# `packages: ''` is load-bearing, not tidying. The action's own default is
|
||||
# `tools` -- a package Google has REMOVED from the SDK repository -- so the
|
||||
# default makes `sdkmanager tools` exit 1 and the step fails before a line
|
||||
# of this repo is compiled. It is redundant here regardless: the next step
|
||||
# installs exactly what the build targets.
|
||||
- name: Set up Android SDK
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
packages: ''
|
||||
|
||||
# Install exactly what the build targets so it never depends on AGP's
|
||||
# build-time auto-download. `yes |` accepts any license prompts; `set
|
||||
|
||||
13
README.md
13
README.md
@@ -48,10 +48,15 @@ any shard's website — there is no compiled-in API host.
|
||||
|
||||
## CI
|
||||
|
||||
`.gitea/workflows/pr-checks.yml` gates PRs into `main` with `./gradlew lint test assembleDebug` on the
|
||||
org's self-hosted runner (JDK 17 + Android SDK). Debug builds are auto-signed, so the gate needs no
|
||||
secrets. **This pipeline is verified green end-to-end on the runner** (M0). A signed **release** APK
|
||||
attached to a Gitea release comes at M6.
|
||||
`.gitea/workflows/pr-checks.yml` gates PRs into `main` **and `edge`** with
|
||||
`./gradlew lint test assembleDebug` on the org's self-hosted runner (JDK 17 + Android SDK). Debug
|
||||
builds are auto-signed, so the gate needs no secrets. **This pipeline is verified green end-to-end on
|
||||
the runner** (M0). A signed **release** APK attached to a Gitea release comes at M6.
|
||||
|
||||
**`edge` is in the trigger deliberately**: a workstream that lands its phases on a working branch
|
||||
before one cutover PR into `main` otherwise gets no CI at all until the cutover — which is what
|
||||
happened to all nine M12 phase PRs (`docs/website/ENGAGEMENT.md` §7.1 Q8). `sonarqube.yml` is
|
||||
unaffected: it is a push-on-`main` analysis, not a PR gate.
|
||||
|
||||
The workflow carries a few runner-specific accommodations (each explained in comments in the file),
|
||||
because this self-hosted runner differs from a stock GitHub runner:
|
||||
|
||||
@@ -58,9 +58,16 @@ class MainActivity : ComponentActivity() {
|
||||
// consumed once by RunicApp which navigates to the stream's screen.
|
||||
private var pendingStream by mutableStateOf<String?>(null)
|
||||
|
||||
// The tickle's other half: an opaque ref, carried since M7 and read since
|
||||
// ENGAGEMENT.md phase 8, where a `notification:<id>` ref means the engine wrote
|
||||
// an inbox row and the tap should land there. Never rendered — it is a hint that
|
||||
// something exists, and the app pulls the real item over the authenticated API.
|
||||
private var pendingRef by mutableStateOf<String?>(null)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
pendingStream = intent?.getStringExtra(PushNotifier.EXTRA_STREAM)
|
||||
pendingRef = intent?.getStringExtra(PushNotifier.EXTRA_REF)
|
||||
handleSsoCallback(intent)
|
||||
// Dark-only app (M5): force light system-bar icons over the transparent bars so
|
||||
// they stay legible on the deep blue-black surfaces regardless of system theme.
|
||||
@@ -99,7 +106,11 @@ class MainActivity : ComponentActivity() {
|
||||
appearance = s.appearance,
|
||||
onChangeServer = appViewModel::changeServer,
|
||||
deepLinkStream = pendingStream,
|
||||
onDeepLinkConsumed = { pendingStream = null },
|
||||
deepLinkRef = pendingRef,
|
||||
onDeepLinkConsumed = {
|
||||
pendingStream = null
|
||||
pendingRef = null
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -116,7 +127,13 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let { pendingStream = it }
|
||||
intent.getStringExtra(PushNotifier.EXTRA_STREAM)?.let {
|
||||
pendingStream = it
|
||||
// Cleared alongside, not conditionally: a tickle with no ref arriving
|
||||
// after one with a ref must not inherit the earlier ref and land on the
|
||||
// inbox instead of its own screen.
|
||||
pendingRef = intent.getStringExtra(PushNotifier.EXTRA_REF)
|
||||
}
|
||||
handleSsoCallback(intent)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.inbox
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.inboxDataStore: DataStore<Preferences> by preferencesDataStore(name = "inbox")
|
||||
|
||||
/**
|
||||
* [InboxCache] over the same plain DataStore the push state uses. Not secret —
|
||||
* tokens stay in the encrypted store — but an inbox body is a person's own
|
||||
* notifications, which is why the snapshot is owner-scoped and cleared on
|
||||
* sign-out rather than left lying about.
|
||||
*/
|
||||
@Singleton
|
||||
class DataStoreInboxCache @Inject constructor(
|
||||
@param:ApplicationContext private val context: Context,
|
||||
private val json: Json,
|
||||
) : InboxCache {
|
||||
|
||||
private val store = context.inboxDataStore
|
||||
|
||||
override suspend fun read(owner: String): InboxCache.Snapshot? {
|
||||
val raw = store.data.first()[KEY_SNAPSHOT] ?: return null
|
||||
val stored = try {
|
||||
json.decodeFromString(Stored.serializer(), raw)
|
||||
} catch (_: Exception) {
|
||||
// A snapshot this build can't parse is a snapshot from an older one;
|
||||
// dropping it silently is right — it will be rewritten on the next pull.
|
||||
return null
|
||||
}
|
||||
if (stored.owner != owner) return null
|
||||
return InboxCache.Snapshot(items = stored.items, unread = stored.unread, savedAt = stored.savedAt)
|
||||
}
|
||||
|
||||
override suspend fun write(owner: String, items: List<NotificationItemDto>, unread: Int) {
|
||||
val payload = Stored(
|
||||
owner = owner,
|
||||
items = items.take(InboxCache.MAX_ITEMS),
|
||||
unread = unread,
|
||||
savedAt = System.currentTimeMillis(),
|
||||
)
|
||||
store.edit { it[KEY_SNAPSHOT] = json.encodeToString(Stored.serializer(), payload) }
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
store.edit { it.remove(KEY_SNAPSHOT) }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class Stored(
|
||||
val owner: String,
|
||||
val items: List<NotificationItemDto>,
|
||||
val unread: Int,
|
||||
val savedAt: Long,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val KEY_SNAPSHOT = stringPreferencesKey("snapshot")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.inbox
|
||||
|
||||
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||
|
||||
/**
|
||||
* The inbox's offline snapshot (ENGAGEMENT.md phase 8).
|
||||
*
|
||||
* **PLAN.md §7 decided the app ships no Room cache, and that decision stands** —
|
||||
* this is its one named exception, settled with the org lead 2026-08-31. The
|
||||
* inbox is a short, read-only, newest-first list with a server-side cursor and no
|
||||
* joins, so what "works offline" needs is the newest page and the badge, not a
|
||||
* database: one JSON blob in the DataStore the push code already uses. Nothing
|
||||
* here is a source of truth — a successful pull always replaces it, and the
|
||||
* screen says out loud when it is showing this instead.
|
||||
*
|
||||
* **The [owner] key is the security property, not a convenience.** A snapshot is
|
||||
* written under the base URL *and* the account id that produced it and is only
|
||||
* ever handed back to that exact pair, so a cache cannot survive into another
|
||||
* account or another shard — including the sign-out paths that never reach
|
||||
* [clear] at all (a dead refresh token, a server switch). Clearing on logout is
|
||||
* the tidy-up; this is what makes it safe.
|
||||
*
|
||||
* An interface for the same reason [com.runicgateway.app.core.auth.TokenStore] is
|
||||
* one: the storage needs a `Context` and the view models that use it should be
|
||||
* testable without one.
|
||||
*/
|
||||
interface InboxCache {
|
||||
|
||||
/**
|
||||
* What was cached for [owner], or null when nothing was — including when the
|
||||
* stored snapshot belongs to a different account or shard, which is the same
|
||||
* answer on purpose.
|
||||
*/
|
||||
suspend fun read(owner: String): Snapshot?
|
||||
|
||||
/**
|
||||
* Replace the snapshot with the newest page.
|
||||
*
|
||||
* Only the FIRST page is ever cached, capped at [MAX_ITEMS]: an offline inbox
|
||||
* is there so the last things you were told are still readable on a train, not
|
||||
* so the whole history is. Later pages come from the server or not at all.
|
||||
*/
|
||||
suspend fun write(owner: String, items: List<NotificationItemDto>, unread: Int)
|
||||
|
||||
/** Forget everything. Called on sign-out, alongside the push deregistration. */
|
||||
suspend fun clear()
|
||||
|
||||
/** What the screen renders from while offline, with the time it was captured. */
|
||||
data class Snapshot(
|
||||
val items: List<NotificationItemDto>,
|
||||
val unread: Int,
|
||||
val savedAt: Long,
|
||||
)
|
||||
|
||||
companion object {
|
||||
/** The server's own default page size — caching more than it sends is pointless. */
|
||||
const val MAX_ITEMS = 30
|
||||
|
||||
/** The (shard, account) a snapshot belongs to. */
|
||||
fun ownerKey(baseUrl: String?, userId: Long): String = "${baseUrl.orEmpty()}|$userId"
|
||||
}
|
||||
}
|
||||
41
app/src/main/java/com/runicgateway/app/core/time/Instants.kt
Normal file
41
app/src/main/java/com/runicgateway/app/core/time/Instants.kt
Normal file
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.core.time
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* Parse a timestamp off the wire, in either shape the backend sends.
|
||||
*
|
||||
* **Which one arrives is not the app's to decide.** Express serializes a `Date`
|
||||
* to ISO-8601 with a `Z`, but these values start life as MariaDB `DATETIME`
|
||||
* columns, and one read back as a string reaches the wire as
|
||||
* `2026-08-31 07:13:50` with no zone at all. A zoneless stamp is read as **UTC**,
|
||||
* because that is what the server stores — reading it as local time would
|
||||
* silently shift every timestamp by the device's offset, which is a bug that
|
||||
* looks right on the machine it was written on.
|
||||
*
|
||||
* Anything unparseable answers null, and every caller is expected to render
|
||||
* *something* without it: a notification with an odd date is still worth reading,
|
||||
* and an event with one is still worth listing.
|
||||
*
|
||||
* Lives here rather than beside either caller because the trap is the wire's, not
|
||||
* one screen's — the inbox found it (ENGAGEMENT.md phase 8) and the event screens
|
||||
* inherit it (EVENTS.md §I).
|
||||
*/
|
||||
fun parseWireInstant(raw: String?): Instant? {
|
||||
val text = raw?.trim().orEmpty()
|
||||
if (text.isEmpty()) return null
|
||||
return try {
|
||||
Instant.parse(text)
|
||||
} catch (_: Exception) {
|
||||
try {
|
||||
LocalDateTime.parse(text.replace(' ', 'T')).atZone(ZoneId.of("UTC")).toInstant()
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
84
app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt
Normal file
84
app/src/main/java/com/runicgateway/app/data/api/EventsApi.kt
Normal file
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||
import com.runicgateway.app.data.api.dto.EventHistoryDto
|
||||
import com.runicgateway.app.data.api.dto.EventSeriesResponse
|
||||
import com.runicgateway.app.data.api.dto.PublicEventResponse
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* The event surface (PLAN.md §9 M13, `docs/website/EVENTS.md` § API surface).
|
||||
*
|
||||
* **These are CORE routes, not a module's**, which is why they live here rather
|
||||
* than beside the shard reads in [PublicApi]: they exist on a backend
|
||||
* with no game module installed at all, and they are gated by core's own `events`
|
||||
* capability rather than by a module's. Nothing here is under `/shard`.
|
||||
*
|
||||
* The three public reads and the one player read share an interface for the same
|
||||
* reason the website mounts them in one feature: the history row's whole purpose
|
||||
* is to link back to the public page. The player call carries a bearer through
|
||||
* [com.runicgateway.app.core.net.AuthInterceptor] like every other authenticated
|
||||
* call; there is one Retrofit.
|
||||
*/
|
||||
interface EventsApi {
|
||||
|
||||
/**
|
||||
* The public calendar. Defaults to now through 31 days out when neither end
|
||||
* is named; the window may span at most 92 days and the server 400s past it.
|
||||
*
|
||||
* Rehearsals and unlisted events are absent — that filtering is in SQL, not
|
||||
* in the answer, so there is nothing here to re-check.
|
||||
*/
|
||||
@GET("api/v1/public/events")
|
||||
suspend fun getCalendar(
|
||||
@Query("from") from: String? = null,
|
||||
@Query("to") to: String? = null,
|
||||
@Query("seriesId") seriesId: Long? = null,
|
||||
): EventCalendarDto
|
||||
|
||||
/**
|
||||
* One event.
|
||||
*
|
||||
* **[run] selects which occurrence the results table is about**, and is what
|
||||
* an announcement's link carries: the page lives at the definition's slug, so
|
||||
* a weekly event has one address that survives a retitle, while every
|
||||
* `event.` trigger is about one occurrence. A run belonging to some other
|
||||
* event is ignored rather than refused, so a stale link in a months-old mail
|
||||
* still opens the page it was about.
|
||||
*
|
||||
* A draft, an archived definition and an unlisted one all answer 404,
|
||||
* indistinguishable from a slug that never existed.
|
||||
*/
|
||||
@GET("api/v1/public/events/{slug}")
|
||||
suspend fun getEvent(
|
||||
@Path("slug") slug: String,
|
||||
@Query("run") run: String? = null,
|
||||
): PublicEventResponse
|
||||
|
||||
/**
|
||||
* One arc. A series with no listed events answers 404 rather than an empty
|
||||
* page — an arc is a label on its definitions, so a page for an empty one
|
||||
* would publish that an operator has named something they have not announced.
|
||||
*/
|
||||
@GET("api/v1/public/events/series/{slug}")
|
||||
suspend fun getSeries(@Path("slug") slug: String): EventSeriesResponse
|
||||
|
||||
/**
|
||||
* The caller's own participation history. Self-scoped on the session's user
|
||||
* id server-side; there is deliberately no id parameter here, because there
|
||||
* is none on the route.
|
||||
*
|
||||
* [before] is a participation row id, not an offset — the list gains rows at
|
||||
* the top as the reader attends things.
|
||||
*/
|
||||
@GET("api/v1/player/events/history")
|
||||
suspend fun getHistory(
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("before") before: Long? = null,
|
||||
): EventHistoryDto
|
||||
}
|
||||
@@ -3,8 +3,13 @@
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationInboxDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
|
||||
import com.runicgateway.app.data.api.dto.PushDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
||||
import retrofit2.http.Body
|
||||
@@ -13,10 +18,13 @@ import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.PUT
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* The opt-in push surface under `/auth/me` (PLAN.md §11, M7 Part 2): device
|
||||
* (endpoint) registration and per-user stream subscriptions. Every call rides the
|
||||
* The notification surface under `/auth/me` (PLAN.md §11): device (endpoint)
|
||||
* registration, per-user stream subscriptions, the per-channel preferences that
|
||||
* supersede them (ENGAGEMENT.md phase 3), and the in-app **inbox** — the first
|
||||
* of these that carries content rather than a preference (phase 7/8). Every call rides the
|
||||
* main client, so [com.runicgateway.app.core.net.AuthInterceptor] attaches the
|
||||
* bearer and [com.runicgateway.app.core.net.TokenAuthenticator] refreshes on 401 —
|
||||
* registration only ever succeeds while signed in.
|
||||
@@ -40,4 +48,41 @@ interface NotificationsApi {
|
||||
|
||||
@PUT("api/v1/auth/me/notifications/subscriptions")
|
||||
suspend fun putSubscriptions(@Body body: NotificationSubscriptionsDto): NotificationSubscriptionsDto
|
||||
|
||||
// ── Per-channel preferences (ENGAGEMENT.md phase 3) ────────────────────
|
||||
//
|
||||
// The superset of the two calls above: `notification_subscriptions` is now
|
||||
// the push projection of this table and the server fans every write to
|
||||
// either one into the other, so the two cannot disagree.
|
||||
|
||||
@GET("api/v1/auth/me/notifications/channels")
|
||||
suspend fun channelPrefs(): NotificationChannelPrefsDto
|
||||
|
||||
/** SPARSE — send only the pairs that changed; everything unnamed is untouched. */
|
||||
@PUT("api/v1/auth/me/notifications/channels")
|
||||
suspend fun putChannelPrefs(
|
||||
@Body body: NotificationChannelPrefsUpdateDto,
|
||||
): NotificationChannelPrefsDto
|
||||
|
||||
// ── The inbox (ENGAGEMENT.md phase 7/8) ────────────────────────────────
|
||||
//
|
||||
// Keyset-paged on `before`, never an offset. There is no way to name another
|
||||
// user on any of these: the caller is the only account they can read or write.
|
||||
|
||||
@GET("api/v1/auth/me/notifications")
|
||||
suspend fun inbox(
|
||||
@Query("limit") limit: Int? = null,
|
||||
@Query("before") before: Long? = null,
|
||||
@Query("unread") unread: Boolean? = null,
|
||||
): NotificationInboxDto
|
||||
|
||||
@GET("api/v1/auth/me/notifications/unread-count")
|
||||
suspend fun unreadCount(): NotificationUnreadDto
|
||||
|
||||
/** Idempotent; 404 both for a missing item and for another account's. */
|
||||
@POST("api/v1/auth/me/notifications/{id}/read")
|
||||
suspend fun markRead(@Path("id") id: Long): NotificationReadResultDto
|
||||
|
||||
@POST("api/v1/auth/me/notifications/read-all")
|
||||
suspend fun markAllRead(): NotificationReadResultDto
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.data.api.dto.MarketMetaDto
|
||||
import com.runicgateway.app.data.api.dto.MarketPageDto
|
||||
import com.runicgateway.app.data.api.dto.MarketVendorDto
|
||||
import com.runicgateway.app.data.api.dto.ModulesDto
|
||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.PageDto
|
||||
import com.runicgateway.app.data.api.dto.PointsBoardDto
|
||||
@@ -67,6 +68,18 @@ interface PublicApi {
|
||||
@GET("api/v1/public/settings")
|
||||
suspend fun getSettings(): SettingsDto
|
||||
|
||||
/**
|
||||
* Which modules this backend is serving, and the capabilities each declares
|
||||
* (§5, M13). Read together with the `version` block's own `capabilities` —
|
||||
* core's list and a module's are separate lists on purpose.
|
||||
*
|
||||
* This is what lets the app tell a module that is **not installed** from a
|
||||
* lookup that failed: `/public/shard/features` 404s in both cases, and only
|
||||
* this call distinguishes them.
|
||||
*/
|
||||
@GET("api/v1/public/modules")
|
||||
suspend fun getModules(): ModulesDto
|
||||
|
||||
// ── News & content ───────────────────────────────────────────────────
|
||||
@GET("api/v1/public/posts/{category}")
|
||||
suspend fun getPosts(@Path("category") category: String): List<PostDto>
|
||||
|
||||
94
app/src/main/java/com/runicgateway/app/data/api/RustApi.kt
Normal file
94
app/src/main/java/com/runicgateway/app/data/api/RustApi.kt
Normal file
@@ -0,0 +1,94 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||
import com.runicgateway.app.data.api.dto.RustWipeListDto
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
|
||||
/**
|
||||
* `module-rust`'s public read path (PLAN.md §9 M14; `docs/modules/rust/PLAN.md`
|
||||
* §17).
|
||||
*
|
||||
* **These paths are hardcoded, and that is the contract rather than a shortcut.**
|
||||
* `MODULE_API.md` §2.9 forbids a client inferring a route from a capability, so
|
||||
* the app cannot build `/<module id>/servers` from what `GET /public/modules`
|
||||
* reports. A capability answers one question — *is the module there* — and these
|
||||
* five addresses are knowledge the app has because someone read the module's
|
||||
* router, exactly as the nine `/uo/` paths in [NavPaths] are.
|
||||
*
|
||||
* Its own interface, not a section of [PublicApi], for the reason [EventsApi] is
|
||||
* its own: these exist only where the Rust module is installed, and a backend
|
||||
* running a different game answers none of them.
|
||||
*/
|
||||
interface RustApi {
|
||||
|
||||
/**
|
||||
* Every Rust server this site follows.
|
||||
*
|
||||
* Answers from the module's own tables and never from a live call to a game
|
||||
* host, so it succeeds while every server in the fleet is off — a server
|
||||
* nobody can reach comes back `online: false, stale: true` with everything it
|
||||
* last said still attached. There is no failure case here for the game being
|
||||
* down, only for the website being down.
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers")
|
||||
suspend fun getServers(): RustServerListDto
|
||||
|
||||
/**
|
||||
* One server, or a **404**.
|
||||
*
|
||||
* The only route under `/servers/{id}` that can say a server is not there:
|
||||
* the four below answer an empty list for an id nobody configured, because an
|
||||
* unknown server genuinely has no events and nobody online. A server an
|
||||
* operator **disabled** answers the same 404 — switching one off is not
|
||||
* switching it into a refusal.
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers/{id}")
|
||||
suspend fun getServer(@Path("id") id: String): RustServerResponse
|
||||
|
||||
/**
|
||||
* The feed, newest first.
|
||||
*
|
||||
* [kind] is comma-separated and [wipe] a wipe id; both are optional, and an
|
||||
* **absent one must be absent rather than empty** — `?wipe=` asks for a wipe
|
||||
* whose id is the empty string and answers nothing, with no error to notice.
|
||||
* Retrofit drops a null `@Query` entirely, which is why these are nullable
|
||||
* and never defaulted to `""`.
|
||||
*
|
||||
* The server serves a default-deny allowlist: moderation events, login
|
||||
* attempts and anything carrying an IP address are stored and never returned
|
||||
* here, whatever is asked for.
|
||||
*/
|
||||
@GET("api/v1/public/rust/servers/{id}/events")
|
||||
suspend fun getEvents(
|
||||
@Path("id") id: String,
|
||||
@Query("kind") kind: String? = null,
|
||||
@Query("wipe") wipe: String? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
): RustEventListDto
|
||||
|
||||
/** Per wipe when [wipe] is given, all-time otherwise — the same rows summed. */
|
||||
@GET("api/v1/public/rust/servers/{id}/leaderboard")
|
||||
suspend fun getLeaderboard(
|
||||
@Path("id") id: String,
|
||||
@Query("wipe") wipe: String? = null,
|
||||
@Query("sort") sort: String? = null,
|
||||
@Query("limit") limit: Int? = null,
|
||||
): RustLeaderboardDto
|
||||
|
||||
/** Every wipe this server has had, newest first. */
|
||||
@GET("api/v1/public/rust/servers/{id}/wipes")
|
||||
suspend fun getWipes(@Path("id") id: String): RustWipeListDto
|
||||
|
||||
/** The presence board, which an unreachable server does not clear. */
|
||||
@GET("api/v1/public/rust/servers/{id}/online")
|
||||
suspend fun getOnline(@Path("id") id: String): RustOnlineDto
|
||||
}
|
||||
212
app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt
Normal file
212
app/src/main/java/com/runicgateway/app/data/api/dto/EventsDto.kt
Normal file
@@ -0,0 +1,212 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Wire shapes for the public event surface (`docs/website/EVENTS.md` §I, events
|
||||
* Phase 14a; the app's half is M13). Field names match
|
||||
* `server/src/model/events/eventPublic.model.js` exactly.
|
||||
*
|
||||
* **That model is a PROJECTION, and these DTOs must not out-grow it.** Nothing on
|
||||
* the server side is spread into a public entry — a field reaches one because a
|
||||
* line put it there — so three things are absent from every shape below and each
|
||||
* absence is a decision core made: the **spec** (phases, steps, actions and their
|
||||
* params are the operator's plan for changing a live world; a visitor gets the
|
||||
* phase LABEL while a run is live and nothing else), **health, cleanup, claims
|
||||
* and errors** (facts about the deployment's plumbing, not about the event), and
|
||||
* **`member_key`** (module-opaque, so core cannot say what publishing one would
|
||||
* disclose). Adding a field here that the server does not send would decode to a
|
||||
* default and render as a fact.
|
||||
*
|
||||
* Every DTO ignores unknown keys (NetworkModule's lenient Json), so an additive
|
||||
* backend field is safe.
|
||||
*/
|
||||
|
||||
/**
|
||||
* One calendar entry. [kind] is `run` or `projected` and the two are drawn
|
||||
* differently on purpose.
|
||||
*
|
||||
* A **run** is a materialised occurrence: a row exists, it can be cancelled, and
|
||||
* what it says is committed to. A **projected** entry is arithmetic past the
|
||||
* materialisation horizon — a forecast with nothing behind it — so the screen
|
||||
* labels it rather than drawing it as a booking. [adjusted] and [shiftMinutes]
|
||||
* only ever arrive on a projection, and say a DST shift moved it.
|
||||
*
|
||||
* [scheduledFor] is a UTC instant and [timezone] is the EVENT's own zone, never
|
||||
* the reader's. See [com.runicgateway.app.ui.events.eventTime].
|
||||
*/
|
||||
@Serializable
|
||||
data class EventCalendarEntryDto(
|
||||
val kind: String = "run",
|
||||
val title: String = "",
|
||||
val slug: String = "",
|
||||
val seriesName: String? = null,
|
||||
val seriesSlug: String? = null,
|
||||
val scheduledFor: String = "",
|
||||
val timezone: String? = null,
|
||||
val status: String = "scheduled",
|
||||
val live: Boolean = false,
|
||||
val adjusted: Boolean = false,
|
||||
val shiftMinutes: Int = 0,
|
||||
) {
|
||||
/** True for a forecast the server has committed nothing to. */
|
||||
val isProjected: Boolean get() = kind == "projected"
|
||||
}
|
||||
|
||||
/** `GET /public/events` — the calendar for a window, ascending by instant. */
|
||||
@Serializable
|
||||
data class EventCalendarDto(
|
||||
val entries: List<EventCalendarEntryDto> = emptyList(),
|
||||
/** True when the server capped the answer; the screen says so rather than lying by omission. */
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* One occurrence on an event's page.
|
||||
*
|
||||
* [phase] is the label of the phase a live run is in, resolved from the version
|
||||
* that run PINNED — so an edit since does not relabel a run in flight. It is null
|
||||
* on anything that is not live, which is why the screen only ever shows it there.
|
||||
*/
|
||||
@Serializable
|
||||
data class EventOccurrenceDto(
|
||||
val runId: Long = 0,
|
||||
val scheduledFor: String = "",
|
||||
val timezone: String? = null,
|
||||
val startedAt: String? = null,
|
||||
val endedAt: String? = null,
|
||||
val status: String = "scheduled",
|
||||
val live: Boolean = false,
|
||||
val scope: String? = null,
|
||||
val phase: String? = null,
|
||||
val resultsPublishedAt: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* One row of a published results table.
|
||||
*
|
||||
* [name] is whatever the module put in its participation `meta`, and there is
|
||||
* genuinely nothing else to render when it is absent: core has no name for a
|
||||
* character and the member key is not published, so the screen says "Unnamed"
|
||||
* rather than inventing one.
|
||||
*
|
||||
* **[score] is fractional, and it has to be.** `event_run_participants.score` is
|
||||
* `DECIMAL(18,4)`, and a module scoring by distance, time or a weighted tally
|
||||
* writes a fraction — the live walk found `318.5` in the first row it read.
|
||||
* Declaring it `Long` does not merely round: kotlinx REFUSES the body, the whole
|
||||
* response fails to decode, and the screen reports a server error for a `200`.
|
||||
* See [com.runicgateway.app.ui.events.scoreText] for how it is rendered.
|
||||
*/
|
||||
@Serializable
|
||||
data class EventParticipantDto(
|
||||
val name: String? = null,
|
||||
val score: Double = 0.0,
|
||||
val rank: Int? = null,
|
||||
)
|
||||
|
||||
/** The results table for ONE occurrence, present only once it has been published. */
|
||||
@Serializable
|
||||
data class EventResultsDto(
|
||||
val runId: Long = 0,
|
||||
val scheduledFor: String = "",
|
||||
val publishedAt: String? = null,
|
||||
val participants: List<EventParticipantDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** The arc an event belongs to, as its own page names it. */
|
||||
@Serializable
|
||||
data class EventSeriesRefDto(
|
||||
val name: String = "",
|
||||
val slug: String = "",
|
||||
)
|
||||
|
||||
/** `GET /public/events/:slug` — the event. */
|
||||
@Serializable
|
||||
data class PublicEventDto(
|
||||
val title: String = "",
|
||||
val slug: String = "",
|
||||
val summary: String? = null,
|
||||
/** Sanitized HTML, written the way a wiki page and a forum post are. */
|
||||
val body: String? = null,
|
||||
val imageUrl: String? = null,
|
||||
val timezone: String? = null,
|
||||
val series: EventSeriesRefDto? = null,
|
||||
val live: Boolean = false,
|
||||
val current: EventOccurrenceDto? = null,
|
||||
/**
|
||||
* The next occurrence — **narrower than the first of [upcoming]**, and the
|
||||
* server decides which. A cancelled occurrence still appears under what is
|
||||
* coming, because "next Friday is off" is what somebody checking a calendar
|
||||
* came to find out; it is not what "next" means.
|
||||
*/
|
||||
val next: EventOccurrenceDto? = null,
|
||||
val upcoming: List<EventOccurrenceDto> = emptyList(),
|
||||
val past: List<EventOccurrenceDto> = emptyList(),
|
||||
val results: EventResultsDto? = null,
|
||||
)
|
||||
|
||||
/** The envelope `GET /public/events/:slug` answers with. */
|
||||
@Serializable
|
||||
data class PublicEventResponse(val event: PublicEventDto = PublicEventDto())
|
||||
|
||||
/** One event as an arc lists it — the editor's order, so no dates. */
|
||||
@Serializable
|
||||
data class EventSeriesEntryDto(
|
||||
val title: String = "",
|
||||
val slug: String = "",
|
||||
val summary: String? = null,
|
||||
val imageUrl: String? = null,
|
||||
)
|
||||
|
||||
/** `GET /public/events/series/:slug` — one arc and the listed events in it. */
|
||||
@Serializable
|
||||
data class EventSeriesDto(
|
||||
val name: String = "",
|
||||
val slug: String = "",
|
||||
val description: String? = null,
|
||||
val events: List<EventSeriesEntryDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** The envelope `GET /public/events/series/:slug` answers with. */
|
||||
@Serializable
|
||||
data class EventSeriesResponse(val series: EventSeriesDto = EventSeriesDto())
|
||||
|
||||
/**
|
||||
* One row of the caller's own participation history.
|
||||
*
|
||||
* [rank] is null until `core.results.publish` ran for that occurrence, and that
|
||||
* is a real state rather than an error — the screen says "not published" rather
|
||||
* than rendering a dash that reads as a bug.
|
||||
*
|
||||
* [id] is the participation row's own id and is what the keyset page walks back
|
||||
* on: the list gains a row every time the reader attends something, so an offset
|
||||
* would skip and repeat.
|
||||
*/
|
||||
@Serializable
|
||||
data class EventHistoryEntryDto(
|
||||
val id: Long = 0,
|
||||
val runId: Long = 0,
|
||||
val title: String = "",
|
||||
val slug: String = "",
|
||||
val seriesName: String? = null,
|
||||
val seriesSlug: String? = null,
|
||||
val scheduledFor: String = "",
|
||||
val startedAt: String? = null,
|
||||
val endedAt: String? = null,
|
||||
val timezone: String? = null,
|
||||
val status: String = "scheduled",
|
||||
val joinedAt: String? = null,
|
||||
// Fractional, for the reason [EventParticipantDto.score] gives.
|
||||
val score: Double = 0.0,
|
||||
val rank: Int? = null,
|
||||
val resultsPublishedAt: String? = null,
|
||||
)
|
||||
|
||||
/** `GET /player/events/history` — self-scoped, one page. */
|
||||
@Serializable
|
||||
data class EventHistoryDto(
|
||||
val entries: List<EventHistoryEntryDto> = emptyList(),
|
||||
)
|
||||
@@ -72,3 +72,139 @@ data class NotificationStreamsDto(
|
||||
data class NotificationSubscriptionsDto(
|
||||
val streams: List<String>,
|
||||
)
|
||||
|
||||
// ── The in-app channel (ENGAGEMENT.md phase 7/8) ───────────────────────────
|
||||
//
|
||||
// The inbox is the first notification surface that carries CONTENT. Everything
|
||||
// above is a preference or a content-free tickle; these four shapes are the
|
||||
// items themselves, pulled over the authenticated API after a tickle wakes the
|
||||
// app. The wire names come from `userNotifications.db.js`'s `toItem`.
|
||||
|
||||
/**
|
||||
* One inbox item. [read] is the flag and [readAt] the stamp, sent side by side so
|
||||
* a client renders one without parsing the other.
|
||||
*
|
||||
* [url] is where the item points on the site (rendered from the template's
|
||||
* `email.button` block) and is **null on most items** — an inbox row is complete
|
||||
* on its own. [triggerId] is the event that produced it, in §7.2's ONE namespace,
|
||||
* so it is the same vocabulary a push tickle's `stream` speaks.
|
||||
*/
|
||||
@Serializable
|
||||
data class NotificationItemDto(
|
||||
val id: Long = 0,
|
||||
val triggerId: String = "",
|
||||
val title: String = "",
|
||||
val body: String? = null,
|
||||
val url: String? = null,
|
||||
val read: Boolean = false,
|
||||
val readAt: String? = null,
|
||||
val createdAt: String? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /auth/me/notifications` — one page, newest first.
|
||||
*
|
||||
* Keyset-paged: the next page is `?before=<the last item's id>`, not an offset,
|
||||
* because the list gains rows at the top while it is being read. [hasMore] comes
|
||||
* from the server's take+1, so "is there another page" costs no second query.
|
||||
* [unread] counts the WHOLE inbox, not the page — it rides along so a screen
|
||||
* rendering both a badge and a list from one response cannot show the two
|
||||
* disagreeing.
|
||||
*/
|
||||
@Serializable
|
||||
data class NotificationInboxDto(
|
||||
val items: List<NotificationItemDto> = emptyList(),
|
||||
val hasMore: Boolean = false,
|
||||
val unread: Int = 0,
|
||||
)
|
||||
|
||||
/** `GET /auth/me/notifications/unread-count` — the badge, on its own. */
|
||||
@Serializable
|
||||
data class NotificationUnreadDto(
|
||||
val unread: Int = 0,
|
||||
)
|
||||
|
||||
/**
|
||||
* What both mark-read routes answer with. [unread] is the count AFTER the write,
|
||||
* so the badge follows from the response rather than from a second call.
|
||||
*/
|
||||
@Serializable
|
||||
data class NotificationReadResultDto(
|
||||
val ok: Boolean = false,
|
||||
val changed: Int = 0,
|
||||
val unread: Int = 0,
|
||||
)
|
||||
|
||||
// ── Per-channel preferences (ENGAGEMENT.md phase 3) ────────────────────────
|
||||
|
||||
/**
|
||||
* One delivery channel from the registry. [modes] is what this channel accepts —
|
||||
* `["off","instant"]` for push and in-app, `["off","instant","digest"]` for email
|
||||
* — and the UI renders its control from THIS, never from a hardcoded set, so a
|
||||
* channel added server-side arrives without an app release.
|
||||
*
|
||||
* [carriesContent] is the tickle invariant stated on the wire: push is `false`,
|
||||
* which is why a push item's title never leaves the server.
|
||||
*/
|
||||
@Serializable
|
||||
data class NotificationChannelDto(
|
||||
val id: String = "",
|
||||
val label: String = "",
|
||||
val carriesContent: Boolean = false,
|
||||
val defaultMode: String = "off",
|
||||
val supportsDigest: Boolean = false,
|
||||
val modes: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One subscribable id, from `GET /auth/me/notifications/channels`. The list is the
|
||||
* UNION of push streams and event triggers in one namespace (§7.2), so an id may
|
||||
* be a stream, a trigger, or both.
|
||||
*
|
||||
* [channels] is which channels apply to THIS id — a trigger-only id carries no
|
||||
* `push` because nothing is registered to push it — and [modes] is the EFFECTIVE
|
||||
* mode per channel: where the user has expressed nothing the server has already
|
||||
* substituted that channel's default, and the client must not re-implement the
|
||||
* defaulting.
|
||||
*/
|
||||
@Serializable
|
||||
data class NotificationChannelItemDto(
|
||||
val id: String = "",
|
||||
val label: String = "",
|
||||
val description: String = "",
|
||||
val personal: Boolean = false,
|
||||
val requiresLinkedAccount: Boolean = false,
|
||||
val ceiling: String? = null,
|
||||
val channels: List<String> = emptyList(),
|
||||
val modes: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
/** `GET · PUT /auth/me/notifications/channels` — the whole stored truth. */
|
||||
@Serializable
|
||||
data class NotificationChannelPrefsDto(
|
||||
val channels: List<NotificationChannelDto> = emptyList(),
|
||||
val items: List<NotificationChannelItemDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** One (id, channel) → mode pair of a sparse update. */
|
||||
@Serializable
|
||||
data class NotificationChannelPrefDto(
|
||||
val id: String,
|
||||
val channel: String,
|
||||
val mode: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* `PUT /auth/me/notifications/channels` body — a SPARSE update: only the pairs
|
||||
* named are written and every other pair is left alone, so one toggle saves
|
||||
* without the screen holding the whole table.
|
||||
*
|
||||
* [prefs] has no default for the same reason [NotificationSubscriptionsDto.streams]
|
||||
* has none — kotlinx omits a property equal to its default, and the validator
|
||||
* requires the field. Unlike that DTO there is no empty-set case to get wrong
|
||||
* here: `off` is a mode, never an omission.
|
||||
*/
|
||||
@Serializable
|
||||
data class NotificationChannelPrefsUpdateDto(
|
||||
val prefs: List<NotificationChannelPrefDto>,
|
||||
)
|
||||
|
||||
@@ -20,6 +20,47 @@ data class VersionDto(
|
||||
val service: String = "",
|
||||
val api: String = "",
|
||||
val server: String = "",
|
||||
/**
|
||||
* What CORE serves beyond the baseline every backend has (events Phase 14a;
|
||||
* `MODULE_API.md` §2.9). Opaque strings, the same word a module uses on
|
||||
* `GET /public/modules` so a client feature-detects one way, and a **separate
|
||||
* list** because core is not a module.
|
||||
*
|
||||
* **The value is in what is absent**, which is why the default is empty
|
||||
* rather than something meaningful: a backend released before a capability
|
||||
* existed omits the key entirely, and that is how the app tells an older site
|
||||
* from one that simply has nothing to show. An unknown string is absent, and
|
||||
* no route may be inferred from one.
|
||||
*/
|
||||
val capabilities: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One installed, **started** module on `GET /public/modules`.
|
||||
*
|
||||
* A module that is disabled or failed to load is absent rather than listed with a
|
||||
* state — its routes and its nav are absent too, so a client renders a site
|
||||
* without that capability rather than one advertising a capability that 503s.
|
||||
*/
|
||||
@Serializable
|
||||
data class InstalledModuleDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val version: String = "",
|
||||
val capabilities: List<String> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* `GET /public/modules` — what this backend is serving beyond core.
|
||||
*
|
||||
* Database-free and never gated by site mode, so the app can feature-detect
|
||||
* during maintenance. A **500** is the one answer that is not an answer: core
|
||||
* refuses to return `[]` for a list read before its loader ran, because a caller
|
||||
* cannot tell an empty list from a mis-ordered boot.
|
||||
*/
|
||||
@Serializable
|
||||
data class ModulesDto(
|
||||
val modules: List<InstalledModuleDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** `GET /public/status` — site mode + version for the first-run probe (§3). */
|
||||
|
||||
196
app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt
Normal file
196
app/src/main/java/com/runicgateway/app/data/api/dto/RustDto.kt
Normal file
@@ -0,0 +1,196 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.dto
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
|
||||
/**
|
||||
* DTOs for `module-rust`'s public read path (`docs/modules/rust/PLAN.md` §17,
|
||||
* §18; M14).
|
||||
*
|
||||
* **Every one of these renders while the game is off**, which is the module's own
|
||||
* promise and therefore this leg's: the website never calls a game server from a
|
||||
* page, it answers from its own tables, and a server nobody can reach answers
|
||||
* `online: false` with everything it last said still attached. Nothing here has
|
||||
* an "unavailable" shape, because there is no such answer on this wire.
|
||||
*/
|
||||
|
||||
/** `GET /public/rust/servers` — every server this site follows. */
|
||||
@Serializable
|
||||
data class RustServerListDto(
|
||||
val servers: List<RustServerDto> = emptyList(),
|
||||
)
|
||||
|
||||
/** `GET /public/rust/servers/{id}` — one of them, or a 404. */
|
||||
@Serializable
|
||||
data class RustServerResponse(
|
||||
val server: RustServerDto = RustServerDto(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One Rust server and what it last reported.
|
||||
*
|
||||
* **[online] and [stale] are not the same fact and the screen needs both.**
|
||||
* `online` is what the last frame said; `stale` is whether anything has arrived
|
||||
* recently enough to believe it. The server computes `online` as *"the row says
|
||||
* up AND the row is fresh"*, so a stale row can never claim a server is up — but
|
||||
* `stale` still has to come through, because a fresh row saying "down" and a row
|
||||
* nobody has written in an hour are different things to say to a reader.
|
||||
*
|
||||
* **[lastSeenAt] is what a page means by "last reported", and [updatedAt] is
|
||||
* not.** The module shipped a defect on exactly this in phase 3 and fixed it in
|
||||
* phase 4: `updatedAt` moves on every poll including a FAILED one, so reading it
|
||||
* as "last reported" made an offline server claim it had just checked in, every
|
||||
* thirty seconds, for as long as it stayed down. Only a frame moves
|
||||
* `lastSeenAt`. The app must not repeat the mistake one tier along.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustServerDto(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val online: Boolean = false,
|
||||
val players: Int = 0,
|
||||
val maxPlayers: Int = 0,
|
||||
val hostname: String? = null,
|
||||
val level: String? = null,
|
||||
val worldSize: Int? = null,
|
||||
val seed: Long? = null,
|
||||
/** The CURRENT wipe, from the state row rather than the newest ingested wipe. */
|
||||
val wipeId: String? = null,
|
||||
val wipedAt: String? = null,
|
||||
/** When a frame last arrived. What "last reported" means. */
|
||||
val lastSeenAt: String? = null,
|
||||
/** When this module last wrote the row — a failed poll moves it too. */
|
||||
val updatedAt: String? = null,
|
||||
val stale: Boolean = false,
|
||||
)
|
||||
|
||||
/** `GET /public/rust/servers/{id}/events` — the killfeed and everything else public. */
|
||||
@Serializable
|
||||
data class RustEventListDto(
|
||||
val events: List<RustEventDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One stored frame.
|
||||
*
|
||||
* **[frame] is deliberately untyped.** The module stores the whole frame the
|
||||
* bridge plugin emitted and indexes only the columns it serves, so the fields
|
||||
* differ per [kind] and a later protocol adds more. A sealed hierarchy here would
|
||||
* have to be extended in this repo before a server running a newer plugin could
|
||||
* say anything new, and the module's own rule is the opposite: an unknown kind
|
||||
* renders as itself rather than being dropped. [RustFeed] is the one place that
|
||||
* knows the field names.
|
||||
*
|
||||
* [t] is epoch milliseconds — the stamp the plugin put on the frame, not a
|
||||
* database column, so it is a number here and an ISO string everywhere else on
|
||||
* this wire.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustEventDto(
|
||||
val id: Long = 0,
|
||||
val kind: String = "",
|
||||
val t: Long = 0,
|
||||
val wipeId: String? = null,
|
||||
val steamId: String? = null,
|
||||
val frame: JsonObject = JsonObject(emptyMap()),
|
||||
) {
|
||||
/**
|
||||
* One frame field as text, or null.
|
||||
*
|
||||
* **A JSON `null` answers null, not the four letters.** The plugin writes
|
||||
* explicit nulls — `reason` on a clean disconnect, `weapon` on a fall — and a
|
||||
* primitive's `content` is the string `"null"` for every one of them, which
|
||||
* would put the word into a killfeed line. An empty string answers null too:
|
||||
* the callers here all mean "is there something to show".
|
||||
*/
|
||||
fun str(key: String): String? = primitive(key)?.content?.takeIf { it.isNotEmpty() }
|
||||
|
||||
/** One frame field as a number, or null when it is absent, null or not one. */
|
||||
fun num(key: String): Double? = primitive(key)?.content?.toDoubleOrNull()
|
||||
|
||||
/** One frame field as a flag. Absent, null and anything non-boolean are all false. */
|
||||
fun flag(key: String): Boolean = primitive(key)?.content == "true"
|
||||
|
||||
/** The raw element, for a caller that wants to decide for itself. */
|
||||
fun raw(key: String): JsonElement? = frame[key]
|
||||
|
||||
private fun primitive(key: String): JsonPrimitive? =
|
||||
(frame[key] as? JsonPrimitive)?.takeIf { it !is JsonNull }
|
||||
}
|
||||
|
||||
/** `GET /public/rust/servers/{id}/leaderboard` — per wipe, or all-time. */
|
||||
@Serializable
|
||||
data class RustLeaderboardDto(
|
||||
val leaderboard: List<RustLeaderboardRowDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One player's standing.
|
||||
*
|
||||
* All-time is these same per-wipe rows summed rather than a second set of
|
||||
* counters, so the two can never disagree — which is why a player who appears
|
||||
* only in an older wipe **drops out** of the current one rather than reading
|
||||
* zero. The screen must not fill that gap in with zeroes.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustLeaderboardRowDto(
|
||||
val steamId: String = "",
|
||||
val name: String? = null,
|
||||
val kills: Int = 0,
|
||||
val deaths: Int = 0,
|
||||
val npcKills: Int = 0,
|
||||
val structures: Int = 0,
|
||||
val playtimeSec: Long = 0,
|
||||
val lastSeen: String? = null,
|
||||
)
|
||||
|
||||
/** `GET /public/rust/servers/{id}/wipes` — every wipe this server has had, newest first. */
|
||||
@Serializable
|
||||
data class RustWipeListDto(
|
||||
val wipes: List<RustWipeDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One wipe.
|
||||
*
|
||||
* [wipeId] is derived by the bridge plugin from the save's creation time and
|
||||
* stamped on every frame, so it is the same id the feed and the leaderboard are
|
||||
* filtered by — which is what makes the per-wipe view navigable at all.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustWipeDto(
|
||||
val wipeId: String = "",
|
||||
val saveCreatedAt: String? = null,
|
||||
val firstSeen: String? = null,
|
||||
val lastSeen: String? = null,
|
||||
)
|
||||
|
||||
/** `GET /public/rust/servers/{id}/online` — who is on right now. */
|
||||
@Serializable
|
||||
data class RustOnlineDto(
|
||||
val players: List<RustPresenceDto> = emptyList(),
|
||||
)
|
||||
|
||||
/**
|
||||
* One row of the presence board.
|
||||
*
|
||||
* Read from the board the bridge re-sends on every connect and every minute,
|
||||
* rather than counted from connect and disconnect events — so it is right even
|
||||
* after the website has missed one. **An unreachable server does not clear it**,
|
||||
* deliberately: these rows are still the best answer anybody has. Presented bare
|
||||
* they read as *who is on right now*, which is the one thing an offline server
|
||||
* cannot be saying, so the screen has to say which it is.
|
||||
*/
|
||||
@Serializable
|
||||
data class RustPresenceDto(
|
||||
val steamId: String = "",
|
||||
val name: String? = null,
|
||||
val sleeping: Boolean = false,
|
||||
val connectedAt: String? = null,
|
||||
)
|
||||
@@ -6,6 +6,7 @@ package com.runicgateway.app.data.repository
|
||||
import com.runicgateway.app.core.auth.DeviceNameProvider
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.inbox.InboxCache
|
||||
import com.runicgateway.app.core.push.PushManager
|
||||
import com.runicgateway.app.data.api.AuthApi
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
@@ -35,6 +36,7 @@ class AuthRepository @Inject constructor(
|
||||
private val ssoApi: SsoApi,
|
||||
private val sessionManager: SessionManager,
|
||||
private val pushManager: PushManager,
|
||||
private val inboxCache: InboxCache,
|
||||
private val trustTokenStore: TrustTokenStore,
|
||||
private val deviceNameProvider: DeviceNameProvider,
|
||||
private val json: Json,
|
||||
@@ -183,6 +185,18 @@ class AuthRepository @Inject constructor(
|
||||
} catch (_: Exception) {
|
||||
// Ignore — local session teardown proceeds regardless.
|
||||
}
|
||||
// Drop the cached inbox with it: those are one person's notifications, and
|
||||
// they have finished with this device. This is the tidy-up, not the
|
||||
// safeguard — InboxCache scopes every snapshot to (base URL, user id), so
|
||||
// the paths that never reach here (a dead refresh, a server switch) cannot
|
||||
// surface one account's items under another's session either.
|
||||
try {
|
||||
inboxCache.clear()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (_: Exception) {
|
||||
// Ignore — same reason.
|
||||
}
|
||||
val refreshToken = sessionManager.currentRefreshToken()
|
||||
try {
|
||||
authApi.logout(MobileLogoutRequest(refreshToken = refreshToken, all = allDevices))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.map
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
import com.runicgateway.app.data.api.EventsApi
|
||||
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
|
||||
import com.runicgateway.app.data.api.dto.EventSeriesDto
|
||||
import com.runicgateway.app.data.api.dto.PublicEventDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The event calendar, event pages, arcs and the caller's own participation
|
||||
* history (PLAN.md §6.1, §9 M13).
|
||||
*
|
||||
* The two single-object reads unwrap their envelope here rather than in a view
|
||||
* model, so a screen never holds a `…Response` whose only job was to carry one
|
||||
* field. The calendar and the history keep theirs: `truncated` is a fact about
|
||||
* the answer that the screen renders, and the history's page is a list the pager
|
||||
* appends to.
|
||||
*/
|
||||
@Singleton
|
||||
class EventsRepository @Inject constructor(
|
||||
private val api: EventsApi,
|
||||
) {
|
||||
/** The public calendar. Both ends optional; the server's default window is 31 days. */
|
||||
suspend fun calendar(
|
||||
from: String? = null,
|
||||
to: String? = null,
|
||||
seriesId: Long? = null,
|
||||
): ApiResult<EventCalendarDto> = safeApiCall { api.getCalendar(from, to, seriesId) }
|
||||
|
||||
/**
|
||||
* One event, optionally about one occurrence.
|
||||
*
|
||||
* [run] is passed through untouched — including a run that belongs to some
|
||||
* other event, which the server ignores rather than refusing. Filtering it
|
||||
* here would turn a stale link into a dead end instead of a page about the
|
||||
* thing the link was about.
|
||||
*/
|
||||
suspend fun event(slug: String, run: String? = null): ApiResult<PublicEventDto> =
|
||||
safeApiCall { api.getEvent(slug, run?.takeIf { it.isNotBlank() }) }.map { it.event }
|
||||
|
||||
/** One arc. A series with nothing listed in it answers 404, not an empty page. */
|
||||
suspend fun series(slug: String): ApiResult<EventSeriesDto> =
|
||||
safeApiCall { api.getSeries(slug) }.map { it.series }
|
||||
|
||||
/**
|
||||
* One page of the caller's own participation history, newest first.
|
||||
*
|
||||
* [before] is the id of the last row already shown — a keyset page, not an
|
||||
* offset, because the list gains rows at the top as the reader attends things.
|
||||
*/
|
||||
suspend fun history(limit: Int, before: Long? = null): ApiResult<List<EventHistoryEntryDto>> =
|
||||
safeApiCall { api.getHistory(limit, before) }.map { it.entries }
|
||||
}
|
||||
@@ -6,16 +6,23 @@ 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.NotificationsApi
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationInboxDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
|
||||
import com.runicgateway.app.data.api.dto.PushDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Device registration + per-user stream subscriptions over the opt-in push surface
|
||||
* (PLAN.md §11, M7 Part 2). Every call returns a typed [ApiResult] so the screen
|
||||
* Device registration, stream subscriptions, per-channel preferences and the
|
||||
* in-app inbox — the whole `/auth/me` notification surface (PLAN.md §11,
|
||||
* ENGAGEMENT.md phases 3 and 7/8). Every call returns a typed [ApiResult] so the screen
|
||||
* and the [com.runicgateway.app.core.push.PushManager] degrade gracefully — a `400`
|
||||
* (endpoint off the shard's allow-set) or a down backend never throws (§7).
|
||||
*/
|
||||
@@ -37,4 +44,33 @@ class NotificationsRepository @Inject constructor(
|
||||
|
||||
suspend fun setSubscriptions(streams: List<String>): ApiResult<NotificationSubscriptionsDto> =
|
||||
safeApiCall { api.putSubscriptions(NotificationSubscriptionsDto(streams)) }
|
||||
|
||||
// ── Per-channel preferences (phase 3) ──────────────────────────────────
|
||||
|
||||
suspend fun channelPrefs(): ApiResult<NotificationChannelPrefsDto> =
|
||||
safeApiCall { api.channelPrefs() }
|
||||
|
||||
/**
|
||||
* Write ONE (id, channel) → mode pair. The endpoint is sparse, so a screen
|
||||
* saving a single toggle sends a single row and cannot disturb the others —
|
||||
* including the ones it does not render.
|
||||
*/
|
||||
suspend fun setChannelMode(id: String, channel: String, mode: String): ApiResult<NotificationChannelPrefsDto> =
|
||||
safeApiCall {
|
||||
api.putChannelPrefs(
|
||||
NotificationChannelPrefsUpdateDto(listOf(NotificationChannelPrefDto(id, channel, mode))),
|
||||
)
|
||||
}
|
||||
|
||||
// ── The inbox (phase 7/8) ──────────────────────────────────────────────
|
||||
|
||||
/** One page, newest first. [before] is the previous page's last id, never an offset. */
|
||||
suspend fun inbox(before: Long? = null, unreadOnly: Boolean = false): ApiResult<NotificationInboxDto> =
|
||||
safeApiCall { api.inbox(before = before, unread = if (unreadOnly) true else null) }
|
||||
|
||||
suspend fun unreadCount(): ApiResult<NotificationUnreadDto> = safeApiCall { api.unreadCount() }
|
||||
|
||||
suspend fun markRead(id: Long): ApiResult<NotificationReadResultDto> = safeApiCall { api.markRead(id) }
|
||||
|
||||
suspend fun markAllRead(): ApiResult<NotificationReadResultDto> = safeApiCall { api.markAllRead() }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* 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.RustApi
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* The Rust module's public read path (PLAN.md §9 M14).
|
||||
*
|
||||
* Every read unwraps its envelope here rather than in a view model, so no screen
|
||||
* holds a `…Dto` whose only job was to carry one list. Nothing is cached and
|
||||
* nothing is merged: the module's tables are already the cache — the site's whole
|
||||
* premise is that it answers from what a server last said rather than from the
|
||||
* server — so a second copy in the app would only add a way for the two to
|
||||
* disagree.
|
||||
*/
|
||||
@Singleton
|
||||
class RustRepository @Inject constructor(
|
||||
private val api: RustApi,
|
||||
) {
|
||||
/** Every server this site follows, with what each last reported. */
|
||||
suspend fun servers(): ApiResult<List<RustServerDto>> =
|
||||
safeApiCall { api.getServers() }.map { it.servers }
|
||||
|
||||
/** One server. A 404 here means no such server, or one an operator disabled. */
|
||||
suspend fun server(id: String): ApiResult<RustServerDto> =
|
||||
safeApiCall { api.getServer(id) }.map { it.server }
|
||||
|
||||
/**
|
||||
* The feed.
|
||||
*
|
||||
* [kinds] is joined here rather than by a caller, so the query string this
|
||||
* app sends exists in one place — and an **empty** list is sent as no `kind`
|
||||
* parameter at all, which asks for the whole allowlist. Sending `kind=` would
|
||||
* ask for a kind named the empty string.
|
||||
*/
|
||||
suspend fun events(
|
||||
id: String,
|
||||
kinds: List<String> = emptyList(),
|
||||
wipe: String? = null,
|
||||
limit: Int? = null,
|
||||
): ApiResult<List<RustEventDto>> = safeApiCall {
|
||||
api.getEvents(
|
||||
id = id,
|
||||
kind = kinds.takeIf { it.isNotEmpty() }?.joinToString(","),
|
||||
wipe = wipe?.takeIf { it.isNotBlank() },
|
||||
limit = limit,
|
||||
)
|
||||
}.map { it.events }
|
||||
|
||||
/** The leaderboard: per wipe when [wipe] is given, all-time otherwise. */
|
||||
suspend fun leaderboard(
|
||||
id: String,
|
||||
wipe: String? = null,
|
||||
sort: String? = null,
|
||||
limit: Int? = null,
|
||||
): ApiResult<List<RustLeaderboardRowDto>> = safeApiCall {
|
||||
api.getLeaderboard(
|
||||
id = id,
|
||||
wipe = wipe?.takeIf { it.isNotBlank() },
|
||||
sort = sort?.takeIf { it.isNotBlank() },
|
||||
limit = limit,
|
||||
)
|
||||
}.map { it.leaderboard }
|
||||
|
||||
/** Every wipe this server has had, newest first. */
|
||||
suspend fun wipes(id: String): ApiResult<List<RustWipeDto>> =
|
||||
safeApiCall { api.getWipes(id) }.map { it.wipes }
|
||||
|
||||
/** The presence board. Rows survive an unreachable server, by design. */
|
||||
suspend fun online(id: String): ApiResult<List<RustPresenceDto>> =
|
||||
safeApiCall { api.getOnline(id) }.map { it.players }
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.safeApiCall
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* What this BACKEND serves — core's own capabilities and every started module's
|
||||
* (PLAN.md §5, §9 M13; `docs/website/MODULE_API.md` §2.9).
|
||||
*
|
||||
* ## Why this exists at all, and why it is not [ShardFeaturesRepository]
|
||||
*
|
||||
* The two answer different questions and neither can answer the other's:
|
||||
*
|
||||
* - **Capability — is this module installed at all?** Per HOST. It changes when
|
||||
* an operator installs or removes a module, so it is resolved beside the
|
||||
* appearance and invalidated on a server switch.
|
||||
* - **Feature — does this shard publish this surface to this viewer?** Per
|
||||
* VIEWER. It changes on sign-in, which is why it is resolved on every session
|
||||
* change.
|
||||
*
|
||||
* Without the first, the app cannot tell a module that is **not installed** from
|
||||
* a lookup that failed: `GET /public/shard/features` 404s in both cases, and
|
||||
* [ShardFeaturesRepository] maps every failure to "unknown", which [canSee]
|
||||
* treats as visible. On a site running a different game that renders every shard
|
||||
* row in the drawer and every one of them 404s when tapped.
|
||||
*
|
||||
* ## Absence of an answer is not an answer of absence
|
||||
*
|
||||
* The distinction this class exists to make, and the reason [SiteCapabilities]
|
||||
* carries no "unknown" member of its own — the *absence of the whole value* is
|
||||
* the unknown state:
|
||||
*
|
||||
* - a **successful** read that does not name a capability is an answer, and
|
||||
* [canUse] hides what needs it;
|
||||
* - a **failed** read keeps the last answer this host gave, because a moment
|
||||
* with no connectivity is not an uninstall;
|
||||
* - a host that has **never** answered leaves the value null, and [canUse]
|
||||
* passes — the drawer renders as it did before this existed rather than
|
||||
* flickering its rows in on every cold start.
|
||||
*
|
||||
* The last one is deliberately the same fail-open direction [canSee] takes, for
|
||||
* the same reason: the server gates every call regardless, so the cost of
|
||||
* guessing wrong is a link that briefly 404s.
|
||||
*/
|
||||
@Singleton
|
||||
class SiteCapabilitiesRepository @Inject constructor(
|
||||
private val api: PublicApi,
|
||||
) {
|
||||
private val _capabilities = MutableStateFlow<SiteCapabilities?>(null)
|
||||
|
||||
/** The current answer, or `null` while this host has never given one. */
|
||||
val capabilities: StateFlow<SiteCapabilities?> = _capabilities.asStateFlow()
|
||||
|
||||
// Serializes concurrent refreshes: the shell refreshes on resume and the
|
||||
// connect flow refreshes on first load, and two overlapping reads would race
|
||||
// to publish.
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* Re-resolve what this backend serves.
|
||||
*
|
||||
* **Two calls, and one failing is not the same as both failing.** Core's list
|
||||
* and a module's are separate lists (§2.9), so they are merged from separate
|
||||
* reads and each is kept only if it answered. A backend released before
|
||||
* events omits `capabilities` from its `version` block entirely, which is an
|
||||
* answer — the empty list — and not a failure.
|
||||
*/
|
||||
suspend fun refresh() = mutex.withLock {
|
||||
val status = safeApiCall { api.getStatus() }
|
||||
val modules = safeApiCall { api.getModules() }
|
||||
|
||||
// Neither call answered: keep whatever this host said last, which for a
|
||||
// host that has never answered is still null.
|
||||
if (status !is ApiResult.Ok && modules !is ApiResult.Ok) return@withLock
|
||||
|
||||
val previous = _capabilities.value
|
||||
val core = (status as? ApiResult.Ok)?.data?.version?.capabilities?.toSet()
|
||||
?: previous?.core
|
||||
?: emptySet()
|
||||
val installed = (modules as? ApiResult.Ok)?.data?.modules
|
||||
?.flatMap { it.capabilities }
|
||||
?.toSet()
|
||||
?: previous?.modules
|
||||
?: emptySet()
|
||||
|
||||
_capabilities.value = SiteCapabilities(core = core, modules = installed)
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the answer. Called on a Settings → Server switch: capabilities belong
|
||||
* to the host that reported them, and the new host must not inherit them —
|
||||
* a switch between two signed-out hosts changes no session, so nothing else
|
||||
* would invalidate this.
|
||||
*/
|
||||
fun invalidate() {
|
||||
_capabilities.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What one backend serves, as two lists rather than one.
|
||||
*
|
||||
* They are kept apart because core is not a module: merging them would leave the
|
||||
* app unable to tell *"this backend has events"* from *"a module called core
|
||||
* happens to be installed"*, which is exactly the distinction
|
||||
* `GET /public/modules` exists to make. [canUse] looks in both, because a menu
|
||||
* entry does not care which half serves it — but the halves stay separable, so a
|
||||
* future caller that does care still can.
|
||||
*/
|
||||
data class SiteCapabilities(
|
||||
/** Core's own, from the `version` block. Empty on a backend that predates them. */
|
||||
val core: Set<String>,
|
||||
/** Every started module's, flattened. Two modules may declare the same string. */
|
||||
val modules: Set<String>,
|
||||
) {
|
||||
/** True when either half names [capability]. */
|
||||
operator fun contains(capability: String): Boolean =
|
||||
capability in core || capability in modules
|
||||
}
|
||||
|
||||
/**
|
||||
* True when [capability] may be relied on — **or when this host has not answered
|
||||
* yet**.
|
||||
*
|
||||
* The null case is the fail-open one and it is not the same as the empty one: a
|
||||
* [SiteCapabilities] that names nothing is a backend that told us it serves
|
||||
* nothing extra, and that hides. See the class doc above.
|
||||
*
|
||||
* `null` [capability] means the caller declared none, which always passes.
|
||||
*/
|
||||
fun canUse(capabilities: SiteCapabilities?, capability: String?): Boolean =
|
||||
capability == null || capabilities == null || capability in capabilities
|
||||
|
||||
/**
|
||||
* The capability strings the app gates on.
|
||||
*
|
||||
* **Deliberately few.** `module-uo` declares eight, and gating each shard row on
|
||||
* its own would be a second, worse copy of what the per-viewer feature flags
|
||||
* already decide — and one that drifts, because a capability is opaque to core
|
||||
* and nothing checks the two agree. One string answers the only question a
|
||||
* capability can: is the module there.
|
||||
*/
|
||||
object Capability {
|
||||
/**
|
||||
* A game module serving a live shard. Declared by `module-uo`; a different
|
||||
* game's module that serves the same surfaces would declare it too, which is
|
||||
* the point of an opaque string.
|
||||
*/
|
||||
const val SHARD = "shard"
|
||||
|
||||
/**
|
||||
* The Rust module (`docs/modules/rust/PLAN.md` D16, phase 5).
|
||||
*
|
||||
* A second game module, and therefore a second string rather than a second
|
||||
* meaning for [SHARD]: a Rust site is a **fleet of servers** with a list
|
||||
* above them, where a shard is one place — the surfaces are not the same
|
||||
* shape and a client cannot render one as the other.
|
||||
*
|
||||
* `module-rust` also declares `servers`, `killfeed`, `leaderboard`,
|
||||
* `presence` and `wipes`, and this gates on none of them. Every one of those
|
||||
* names a SURFACE, and core flattens all modules' capabilities into one list
|
||||
* — so `servers` is a word another module could declare tomorrow, which would
|
||||
* silently reveal these rows on a site that does not run Rust. `rust` is the
|
||||
* string only that module can mean, which is the same job [SHARD] does for
|
||||
* `module-uo`.
|
||||
*/
|
||||
const val RUST = "rust"
|
||||
|
||||
/** Core's event system (events Phase 14a). Never a module's. */
|
||||
const val EVENTS = "events"
|
||||
}
|
||||
@@ -15,11 +15,13 @@ 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
|
||||
import com.runicgateway.app.data.api.PlayerShardApi
|
||||
import com.runicgateway.app.data.api.PublicApi
|
||||
import com.runicgateway.app.data.api.RustApi
|
||||
import com.runicgateway.app.data.api.SsoApi
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@@ -122,6 +124,28 @@ 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)
|
||||
|
||||
/**
|
||||
* `module-rust`'s public read path (§9 M14).
|
||||
*
|
||||
* A MODULE's routes, unlike [provideEventsApi] beside it — they exist only on
|
||||
* a backend where an operator installed the Rust module, and the drawer rows
|
||||
* that lead to them are gated on its `rust` capability. Provided
|
||||
* unconditionally all the same: a Retrofit interface costs nothing until
|
||||
* something calls it, and there is nowhere at injection time to ask.
|
||||
*/
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideRustApi(retrofit: Retrofit): RustApi = retrofit.create(RustApi::class.java)
|
||||
|
||||
/** Opt-in push devices + subscriptions (§11, M7) — bearer-authed on the main client. */
|
||||
@Provides
|
||||
@Singleton
|
||||
|
||||
@@ -11,13 +11,16 @@ import com.runicgateway.app.core.auth.TokenStore
|
||||
import com.runicgateway.app.core.auth.TrustTokenStore
|
||||
import com.runicgateway.app.core.auth.sso.EncryptedPendingSsoStore
|
||||
import com.runicgateway.app.core.auth.sso.PendingSsoStore
|
||||
import com.runicgateway.app.core.inbox.DataStoreInboxCache
|
||||
import com.runicgateway.app.core.inbox.InboxCache
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Binds the at-rest stores to their EncryptedSharedPreferences impls (§4.3). */
|
||||
/** Binds the at-rest stores to their implementations — EncryptedSharedPreferences
|
||||
* for anything secret (§4.3), plain DataStore for the inbox snapshot. */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class StorageModule {
|
||||
@@ -38,4 +41,9 @@ abstract class StorageModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindDeviceNameProvider(impl: BuildDeviceNameProvider): DeviceNameProvider
|
||||
|
||||
/** The inbox's offline snapshot — plain DataStore, not encrypted (ENGAGEMENT.md phase 8). */
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindInboxCache(impl: DataStoreInboxCache): InboxCache
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
110
app/src/main/java/com/runicgateway/app/ui/Polling.kt
Normal file
110
app/src/main/java/com/runicgateway/app/ui/Polling.kt
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
/**
|
||||
* Repeated reads of a surface that changes while somebody is looking at it
|
||||
* (`docs/modules/rust/PLAN.md` D14, and D17 for this leg).
|
||||
*
|
||||
* ## Why a refresh is not a load
|
||||
*
|
||||
* The app has had exactly one shape for a read until now: set [UiState.Loading],
|
||||
* ask, replace. That is right for opening a screen and wrong for a poll — a
|
||||
* twenty-second refresh built on it would clear the killfeed, put a spinner where
|
||||
* it was and re-fill it, three times a minute, for ever. The website hit the same
|
||||
* wall one tier along: core's `useAsync` blanks its data on every dependency
|
||||
* change, so `module-rust` bundles its own `usePolled`. This is that hook's other
|
||||
* half.
|
||||
*
|
||||
* The rule both ends keep: **a refresh is invisible when it succeeds, and keeps
|
||||
* the rows when it fails.** A site whose whole premise is "it renders while the
|
||||
* game is off" must not blank itself the first time a request does.
|
||||
*/
|
||||
|
||||
/** How often a live surface re-reads itself while somebody is looking at it (D17). */
|
||||
const val POLL_INTERVAL_MS = 20_000L
|
||||
|
||||
/**
|
||||
* What a poll produced: the state to render, and whether the last attempt failed.
|
||||
*
|
||||
* Two fields rather than a wider [UiState] because they are two facts and a
|
||||
* screen renders them in different places — the rows in the list, the failure as
|
||||
* a quiet line above it. Collapsing them would force the choice this exists to
|
||||
* avoid: show the error and lose the rows, or keep the rows and say nothing.
|
||||
*/
|
||||
data class Polled<out T>(
|
||||
val state: UiState<T> = UiState.Loading,
|
||||
/** True when the most recent refresh failed **and there were rows to keep**. */
|
||||
val refreshFailed: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Fold a refresh into what is already on screen.
|
||||
*
|
||||
* Three cases, and the middle one is the whole point:
|
||||
*
|
||||
* - **It answered.** The new data replaces the old and any previous failure
|
||||
* clears. This is the ordinary path and it is silent.
|
||||
* - **It failed, and there are rows.** The rows stay exactly as they are and the
|
||||
* failure is reported beside them. Nothing is blanked and nothing is retried
|
||||
* on the reader's behalf — the next tick is twenty seconds away.
|
||||
* - **It failed, and there is nothing yet.** There is nothing to protect, so it
|
||||
* becomes an ordinary error with a retry — which is what the first load
|
||||
* failing means.
|
||||
*
|
||||
* Pure, and takes the current state rather than reading one, so the rule is
|
||||
* tested without a dispatcher, a view model or Compose.
|
||||
*/
|
||||
fun <T> refreshInto(current: UiState<T>, result: ApiResult<T>): Polled<T> = when {
|
||||
result is ApiResult.Ok -> Polled(UiState.Success(result.data), refreshFailed = false)
|
||||
current is UiState.Success -> Polled(current, refreshFailed = true)
|
||||
else -> Polled(result.toUiState(), refreshFailed = false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run [block] now and every [intervalMs] for as long as this screen is resumed.
|
||||
*
|
||||
* `repeatOnLifecycle` is what makes this the phone's version of D14's Page
|
||||
* Visibility gate, and it gets three behaviours from one line:
|
||||
*
|
||||
* - **Nothing runs while the app is away.** The coroutine is cancelled at
|
||||
* `onPause`, so a backgrounded app makes no requests at all — not a slower
|
||||
* poll, none.
|
||||
* - **Coming back refreshes immediately.** The block is restarted from the top
|
||||
* at `onResume`, which calls [block] before the first [delay] — so the first
|
||||
* thing a returning reader sees is current, not up to twenty seconds old.
|
||||
* - **A dialog or the recents switcher pauses it**, because that is what RESUMED
|
||||
* means. The alternative, STARTED, keeps polling behind a partially
|
||||
* obscured screen, which is precisely the reader who is not reading.
|
||||
*
|
||||
* **Keyed on the lifecycle owner alone, and [block] is held through
|
||||
* `rememberUpdatedState`.** Keying on the block would restart the loop on every
|
||||
* recomposition, because a lambda is a new object each time; capturing it without
|
||||
* `rememberUpdatedState` would freeze the *first* one, so a tab change or a
|
||||
* newly chosen wipe would keep refreshing the question the reader has stopped
|
||||
* asking. The loop is stable and what it calls is current.
|
||||
*/
|
||||
@Composable
|
||||
fun PollWhileResumed(intervalMs: Long = POLL_INTERVAL_MS, block: suspend () -> Unit) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val current by rememberUpdatedState(block)
|
||||
LaunchedEffect(lifecycleOwner) {
|
||||
lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
|
||||
while (true) {
|
||||
current()
|
||||
delay(intervalMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,6 +37,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
@@ -55,6 +57,7 @@ import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.web.WebHandoff
|
||||
import com.runicgateway.app.data.api.dto.BrandDto
|
||||
import com.runicgateway.app.data.appearance.SiteAppearance
|
||||
import com.runicgateway.app.data.repository.Capability
|
||||
import com.runicgateway.app.ui.auth.AccountScreen
|
||||
import com.runicgateway.app.ui.auth.LoginScreen
|
||||
import com.runicgateway.app.ui.auth.RecoveryCodesScreen
|
||||
@@ -62,6 +65,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
|
||||
@@ -75,7 +82,9 @@ import com.runicgateway.app.ui.admin.AdminContentScreen
|
||||
import com.runicgateway.app.ui.admin.AdminDashboardScreen
|
||||
import com.runicgateway.app.ui.admin.AdminModerationScreen
|
||||
import com.runicgateway.app.ui.admin.AdminSupportScreen
|
||||
import com.runicgateway.app.ui.notifications.NotificationsScreen
|
||||
import com.runicgateway.app.ui.notifications.InboxBadgeViewModel
|
||||
import com.runicgateway.app.ui.notifications.InboxScreen
|
||||
import com.runicgateway.app.ui.notifications.NotificationSettingsScreen
|
||||
import com.runicgateway.app.ui.page.PageScreen
|
||||
import com.runicgateway.app.ui.player.CharacterSheetScreen
|
||||
import com.runicgateway.app.ui.player.CharactersScreen
|
||||
@@ -93,6 +102,9 @@ import com.runicgateway.app.ui.shard.MarketScreen
|
||||
import com.runicgateway.app.ui.shard.MarketVendorScreen
|
||||
import com.runicgateway.app.ui.shard.RulesScreen
|
||||
import com.runicgateway.app.ui.shard.ShardBoard
|
||||
import com.runicgateway.app.ui.rust.RustBadgeViewModel
|
||||
import com.runicgateway.app.ui.rust.RustServerScreen
|
||||
import com.runicgateway.app.ui.rust.RustServersScreen
|
||||
import com.runicgateway.app.ui.shard.ShardScreen
|
||||
import com.runicgateway.app.ui.theme.LocalShardStructure
|
||||
import com.runicgateway.app.ui.wiki.WikiPageScreen
|
||||
@@ -106,6 +118,13 @@ 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,
|
||||
// The Rust server list is a drawer row (M14); one server's page is a detail
|
||||
// screen and is deliberately absent — a back gesture there means "back".
|
||||
Routes.RUST,
|
||||
Routes.PLAYER_CHARACTERS, Routes.PLAYER_VENDORS, Routes.PLAYER_HOUSES,
|
||||
Routes.ADMIN_DASHBOARD, Routes.ADMIN_CONTENT, Routes.ADMIN_MODERATION, Routes.ADMIN_SUPPORT,
|
||||
)
|
||||
@@ -124,8 +143,11 @@ fun RunicApp(
|
||||
onChangeServer: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
deepLinkStream: String? = null,
|
||||
deepLinkRef: String? = null,
|
||||
onDeepLinkConsumed: () -> Unit = {},
|
||||
sessionViewModel: SessionViewModel = hiltViewModel(),
|
||||
inboxBadgeViewModel: InboxBadgeViewModel = hiltViewModel(),
|
||||
rustBadgeViewModel: RustBadgeViewModel = hiltViewModel(),
|
||||
) {
|
||||
val brand = appearance.brand
|
||||
val navController = rememberNavController()
|
||||
@@ -135,17 +157,44 @@ 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).
|
||||
LifecycleResumeEffect(Unit) {
|
||||
// Re-validate the cached role each time the app returns to the foreground (§4.3),
|
||||
// and re-read the two drawer counts with it: a tickle that arrived while the app
|
||||
// was away is exactly what brings someone back to it, and a live player count is
|
||||
// only live if it is re-read when somebody looks.
|
||||
LifecycleResumeEffect(capabilities) {
|
||||
sessionViewModel.revalidate()
|
||||
inboxBadgeViewModel.refresh()
|
||||
// The Rust count is a LIVE number, so it is re-read on the same clock the
|
||||
// unread badge is: coming back to the app is exactly when a stale one
|
||||
// would be noticed. Keyed on the capability answer as well as on resume,
|
||||
// because the very first resume happens before this host has said whether
|
||||
// the module is there — and asking then would either make a request on a
|
||||
// site that has no Rust, or never make one at all.
|
||||
capabilities?.let { rustBadgeViewModel.refresh(Capability.RUST in it) }
|
||||
onPauseOrDispose { }
|
||||
}
|
||||
|
||||
val unread by inboxBadgeViewModel.unread.collectAsStateWithLifecycle()
|
||||
// How many people are on the Rust fleet, for the drawer row's badge — the
|
||||
// phone's answer to D15's footer count (M14). Refreshed on resume, never on a
|
||||
// timer: a badge is a glance, not a feed. Gated here rather than inside the
|
||||
// view model because this is the only place that knows whether the module is
|
||||
// installed at all, and a host that has not answered yet asks nothing.
|
||||
val rustOnline by rustBadgeViewModel.online.collectAsStateWithLifecycle()
|
||||
// The badge follows the session, so signing out clears it rather than leaving
|
||||
// the previous account's count on the drawer.
|
||||
LaunchedEffect(session) { inboxBadgeViewModel.refresh() }
|
||||
|
||||
// A tapped push notification deep-links to its stream's screen (§11, item 7).
|
||||
LaunchedEffect(deepLinkStream) {
|
||||
LaunchedEffect(deepLinkStream, deepLinkRef) {
|
||||
val stream = deepLinkStream ?: return@LaunchedEffect
|
||||
navController.navigate(Routes.forStream(stream)) {
|
||||
// Both halves of the tickle: a `notification:` ref means there is an inbox
|
||||
// row waiting, and that is where the tap goes (ENGAGEMENT.md phase 8).
|
||||
navController.navigate(Routes.forTickle(stream, deepLinkRef)) {
|
||||
popUpTo(Routes.HOME) { saveState = true }
|
||||
launchSingleTop = true
|
||||
}
|
||||
@@ -162,7 +211,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
|
||||
@@ -234,12 +283,23 @@ fun RunicApp(
|
||||
),
|
||||
)
|
||||
node.items.forEach { child ->
|
||||
NavRow(child, currentRoute, drawerItemColors, indented = true) {
|
||||
openNode(child)
|
||||
}
|
||||
NavRow(
|
||||
node = child,
|
||||
currentRoute = currentRoute,
|
||||
colors = drawerItemColors,
|
||||
indented = true,
|
||||
unread = unread,
|
||||
rustOnline = rustOnline,
|
||||
) { openNode(child) }
|
||||
}
|
||||
} else {
|
||||
NavRow(node, currentRoute, drawerItemColors) { openNode(node) }
|
||||
NavRow(
|
||||
node = node,
|
||||
currentRoute = currentRoute,
|
||||
colors = drawerItemColors,
|
||||
unread = unread,
|
||||
rustOnline = rustOnline,
|
||||
) { openNode(node) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -354,6 +414,8 @@ private fun NavRow(
|
||||
currentRoute: String?,
|
||||
colors: NavigationDrawerItemColors,
|
||||
indented: Boolean = false,
|
||||
unread: Int = 0,
|
||||
rustOnline: Int = 0,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val route = when (node) {
|
||||
@@ -369,21 +431,60 @@ private fun NavRow(
|
||||
is NavNode.Section -> return
|
||||
}
|
||||
val handsOff = node is NavNode.Link && node.route == null
|
||||
// The unread count rides on whichever row leads to the inbox — including an
|
||||
// admin's own nav override pointing at it, since the badge belongs to the
|
||||
// destination, not to the bundled entry.
|
||||
val showsUnread = !handsOff && unread > 0 && route == Routes.NOTIFICATIONS
|
||||
// The live player count rides on whichever row leads to the Rust list, for the
|
||||
// same reason the unread count rides on whichever leads to the inbox — the
|
||||
// number belongs to the destination, not to the bundled entry, so an admin's
|
||||
// own nav override pointing there carries it too.
|
||||
//
|
||||
// **Zero renders nothing**, rather than a `0`: an empty fleet is not a
|
||||
// notification, and a badge that read `0` on a site whose servers are simply
|
||||
// quiet would be worse than no badge at all.
|
||||
val showsRustOnline = !handsOff && rustOnline > 0 && route == Routes.RUST
|
||||
|
||||
NavigationDrawerItem(
|
||||
label = { Text(label) },
|
||||
selected = route != null && currentRoute == route.substringBefore('?'),
|
||||
onClick = onClick,
|
||||
badge = if (!handsOff) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ExitToApp,
|
||||
contentDescription = stringResource(R.string.nav_opens_in_browser),
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
badge = when {
|
||||
handsOff -> {
|
||||
{
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ExitToApp,
|
||||
contentDescription = stringResource(R.string.nav_opens_in_browser),
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
showsUnread -> {
|
||||
{
|
||||
// Named for a screen reader: "7" beside "Notifications" reads as
|
||||
// a count to a sighted user and as a bare number to everyone else.
|
||||
val spoken = stringResource(R.string.inbox_unread_count, unread)
|
||||
Text(
|
||||
text = unread.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.semantics { contentDescription = spoken },
|
||||
)
|
||||
}
|
||||
}
|
||||
showsRustOnline -> {
|
||||
{
|
||||
// Named for a screen reader: "42" beside "Rust servers" reads
|
||||
// as a count to a sighted user and as a bare number to
|
||||
// everyone else.
|
||||
val spoken = stringResource(R.string.rust_online_badge, rustOnline)
|
||||
Text(
|
||||
text = rustOnline.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
modifier = Modifier.semantics { contentDescription = spoken },
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
},
|
||||
colors = colors,
|
||||
// Like Card's elevation, NavigationDrawerItem takes its shape as a default
|
||||
@@ -440,6 +541,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(
|
||||
@@ -480,6 +625,17 @@ private fun RunicNavHost(
|
||||
) { entry ->
|
||||
AtlasCreatureScreen(slug = entry.arguments?.getString(Routes.Args.SLUG).orEmpty())
|
||||
}
|
||||
// The Rust module's two screens (M14). Not under `shard/`: a different game,
|
||||
// a different shape — a fleet with a list above it rather than one place.
|
||||
composable(Routes.RUST) {
|
||||
RustServersScreen(onOpenServer = { id -> navController.navigate(Routes.rustServer(id)) })
|
||||
}
|
||||
composable(
|
||||
route = Routes.RUST_SERVER,
|
||||
arguments = listOf(navArgument(Routes.Args.SERVER_ID) { type = NavType.StringType }),
|
||||
) {
|
||||
RustServerScreen(onBack = { navController.navigateTopLevel(Routes.RUST) })
|
||||
}
|
||||
composable(Routes.WIKI) {
|
||||
WikiScreen(onOpenPage = { slug -> navController.navigate(Routes.wikiPage(slug)) })
|
||||
}
|
||||
@@ -541,9 +697,24 @@ private fun RunicNavHost(
|
||||
}
|
||||
composable(Routes.NOTIFICATIONS) {
|
||||
// Signed-in only; a sign-out (or demotion) sends the user home rather than
|
||||
// leaving stale settings up. The backend gates every call regardless (§5).
|
||||
// leaving another account's items up. The backend gates every call
|
||||
// regardless, and the inbox routes are role-agnostic (§5) — staff have an
|
||||
// inbox for the same reason players do, which on the web took a second
|
||||
// mount to be true.
|
||||
when (session) {
|
||||
is Session.SignedIn -> NotificationsScreen()
|
||||
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) }
|
||||
}
|
||||
}
|
||||
composable(Routes.NOTIFICATIONS_SETTINGS) {
|
||||
when (session) {
|
||||
is Session.SignedIn -> NotificationSettingsScreen()
|
||||
Session.SignedOut -> LaunchedEffect(Unit) { navController.navigateTopLevel(Routes.HOME) }
|
||||
}
|
||||
}
|
||||
|
||||
310
app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt
Normal file
310
app/src/main/java/com/runicgateway/app/ui/events/EventScreen.kt
Normal file
@@ -0,0 +1,310 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.events
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.EventOccurrenceDto
|
||||
import com.runicgateway.app.data.api.dto.EventParticipantDto
|
||||
import com.runicgateway.app.data.api.dto.PublicEventDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.HtmlText
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* One event's public page (EVENTS.md § API surface, M13).
|
||||
*
|
||||
* The storyline, its arc, what is live, what is next, what happened recently, and
|
||||
* a results table once an occurrence has published one.
|
||||
*
|
||||
* **The plan behind the event is never shown**, because the server never sends
|
||||
* it: a live run carries the LABEL of the phase it is in — resolved from the
|
||||
* version that run pinned, so an edit since does not relabel it — and nothing
|
||||
* else. Phases, steps and actions are the operator's.
|
||||
*/
|
||||
@Composable
|
||||
fun EventScreen(
|
||||
onOpenSeries: (String) -> Unit,
|
||||
onOpenRun: (String, Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: EventViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// Error before content. Phase 13 found the inverse of this one tier along: a
|
||||
// `if (loading || !form)` spinner above the error branch left a failed load
|
||||
// spinning for ever with nothing on screen naming the problem.
|
||||
when (val s = state) {
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Success -> EventBody(s.data, onOpenSeries, onOpenRun, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EventBody(
|
||||
event: PublicEventDto,
|
||||
onOpenSeries: (String) -> Unit,
|
||||
onOpenRun: (String, Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
item(key = "head") {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = event.title,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
event.summary?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
event.series?.let { series ->
|
||||
Text(
|
||||
text = stringResource(R.string.events_part_of, series.name),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable { onOpenSeries(series.slug) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The one fact a visitor came for, above the storyline rather than below
|
||||
// it: whether it is happening now, and if not, when it next is.
|
||||
item(key = "headline") { Headline(event) }
|
||||
|
||||
event.body?.takeIf { it.isNotBlank() }?.let { body ->
|
||||
item(key = "body") {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
// Sanitized on write, the treatment a wiki page and a forum
|
||||
// post already get.
|
||||
HtmlText(body, Modifier.padding(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
event.results?.let { results ->
|
||||
item(key = "results-head") {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = stringResource(R.string.events_results),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = eventDateTime(results.scheduledFor, event.timezone),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (results.participants.isEmpty()) {
|
||||
item(key = "results-empty") {
|
||||
Text(
|
||||
text = stringResource(R.string.events_results_nobody),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
items(results.participants.size, key = { "p$it" }) { index ->
|
||||
ParticipantRow(results.participants[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
occurrenceSection(
|
||||
key = "upcoming",
|
||||
titleRes = R.string.events_coming_up,
|
||||
list = event.upcoming,
|
||||
timezone = event.timezone,
|
||||
slug = event.slug,
|
||||
onOpenRun = onOpenRun,
|
||||
linkResults = false,
|
||||
)
|
||||
occurrenceSection(
|
||||
key = "past",
|
||||
titleRes = R.string.events_previously,
|
||||
list = event.past,
|
||||
timezone = event.timezone,
|
||||
slug = event.slug,
|
||||
onOpenRun = onOpenRun,
|
||||
linkResults = true,
|
||||
)
|
||||
|
||||
if (event.current == null && event.next == null && event.past.isEmpty()) {
|
||||
item(key = "unscheduled") {
|
||||
Text(
|
||||
text = stringResource(R.string.events_never_scheduled),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Headline(event: PublicEventDto) {
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val current = event.current
|
||||
when {
|
||||
event.live && current != null -> {
|
||||
StatusPill(
|
||||
text = stringResource(R.string.events_status_live),
|
||||
tone = PillTone.Success,
|
||||
)
|
||||
Text(
|
||||
// The phase LABEL, and only while it is live.
|
||||
text = current.phase?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.events_under_way),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
event.next != null -> {
|
||||
Text(
|
||||
text = stringResource(R.string.events_next),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = eventDateTime(event.next.scheduledFor, event.next.timezone ?: event.timezone),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
else -> Text(
|
||||
text = stringResource(R.string.events_nothing_scheduled),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A titled list of occurrences, or nothing at all when there are none.
|
||||
*
|
||||
* `linkResults` is what separates the two calls: only a PAST occurrence that
|
||||
* actually published results gets its own tap target, because on any other one
|
||||
* `?run=` would change nothing a reader could see.
|
||||
*/
|
||||
private fun androidx.compose.foundation.lazy.LazyListScope.occurrenceSection(
|
||||
key: String,
|
||||
titleRes: Int,
|
||||
list: List<EventOccurrenceDto>,
|
||||
timezone: String?,
|
||||
slug: String,
|
||||
onOpenRun: (String, Long) -> Unit,
|
||||
linkResults: Boolean,
|
||||
) {
|
||||
if (list.isEmpty()) return
|
||||
item(key = "$key-title") {
|
||||
Text(
|
||||
text = stringResource(titleRes),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
items(list.size, key = { "$key-${list[it].runId}" }) { index ->
|
||||
val occurrence = list[index]
|
||||
val tappable = linkResults && occurrence.resultsPublishedAt != null
|
||||
ShardCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(
|
||||
if (tappable) Modifier.clickable { onOpenRun(slug, occurrence.runId) }
|
||||
else Modifier,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = eventDateTime(occurrence.scheduledFor, occurrence.timezone ?: timezone),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(
|
||||
statusWordRes(occurrence.status, occurrence.scheduledFor),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ParticipantRow(participant: EventParticipantDto) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = participant.rank?.toString() ?: "—",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.width(32.dp),
|
||||
)
|
||||
Text(
|
||||
// A module supplies a display name in its participation meta or it does
|
||||
// not; the member key is never published, so there is genuinely nothing
|
||||
// else to render.
|
||||
text = participant.name?.takeIf { it.isNotBlank() }
|
||||
?: stringResource(R.string.events_participant_unnamed),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Text(
|
||||
text = scoreText(participant.score),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.events
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* One arc (EVENTS.md §I, M13).
|
||||
*
|
||||
* **The arc is the thing the tooling this replaces could not express at all.** A
|
||||
* calendar plugin has no series field, so "Royal Spy Mission → Risky Partner →
|
||||
* Message From the Void" existed only in a GM's head and in whatever the forum
|
||||
* post said. This screen is that continuity, in the order an editor arranged it —
|
||||
* which is why the events are numbered rather than dated: an arc has an order, and
|
||||
* its parts may be months apart or run out of sequence.
|
||||
*/
|
||||
@Composable
|
||||
fun EventSeriesScreen(
|
||||
onOpenEvent: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: EventSeriesViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// Error first, then loading — the order Phase 13 had to fix one tier along.
|
||||
when (val s = state) {
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Success -> {
|
||||
val series = s.data
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
item(key = "head") {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Text(
|
||||
text = series.name,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
series.description?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(series.events.size, key = { series.events[it].slug }) { index ->
|
||||
val entry = series.events[index]
|
||||
ShardCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onOpenEvent(entry.slug) },
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Text(
|
||||
text = (index + 1).toString(),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
textAlign = TextAlign.End,
|
||||
modifier = Modifier.width(32.dp),
|
||||
)
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = entry.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
entry.summary?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.events
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.EventSeriesDto
|
||||
import com.runicgateway.app.data.repository.EventsRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* One arc (PLAN.md §9 M13).
|
||||
*
|
||||
* A series with nothing listed in it answers 404 rather than an empty page, so
|
||||
* there is no "empty arc" state to render: the error branch is the whole of it,
|
||||
* and that is the server's decision rather than this screen's — an empty page
|
||||
* would publish that an operator has named something they have not announced.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class EventSeriesViewModel @Inject constructor(
|
||||
private val repository: EventsRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val slug: String = savedStateHandle.get<String>(Routes.Args.SLUG).orEmpty()
|
||||
|
||||
private val _state = MutableStateFlow<UiState<EventSeriesDto>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<EventSeriesDto>> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
_state.value = repository.series(slug).toUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
166
app/src/main/java/com/runicgateway/app/ui/events/EventTimes.kt
Normal file
166
app/src/main/java/com/runicgateway/app/ui/events/EventTimes.kt
Normal file
@@ -0,0 +1,166 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.events
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.time.parseWireInstant
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Rendering an event's instant and its status word (EVENTS.md §I).
|
||||
*
|
||||
* Everything here is pure and takes its clock, zone and locale as parameters, so
|
||||
* the rules below are unit-tested off-device rather than eyeballed on one.
|
||||
*
|
||||
* ## The split, which is the one thing about event times that is easy to get wrong
|
||||
*
|
||||
* The server returns UTC instants and never guesses the reader's zone. The client
|
||||
* places them, and it places the two halves differently:
|
||||
*
|
||||
* - the **day** an entry is filed under is the READER's own — "what is on this
|
||||
* month" is a question about the month the person holding the phone is living
|
||||
* in;
|
||||
* - the **time** beside it is always the EVENT's zone, carried on the entry —
|
||||
* because every listing this feature replaces is written in the shard's local
|
||||
* zone, and "8pm" means the shard's evening to everyone reading it.
|
||||
*
|
||||
* Rendering the time in the reader's zone instead is defensible and wrong here: a
|
||||
* player in Berlin told an American shard's event is at 02:00 has been told
|
||||
* something true and useless, and told it in a way that makes the shard's own
|
||||
* announcement look like a mistake.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A participation score, as a reader should see it.
|
||||
*
|
||||
* Scores are `DECIMAL(18,4)` on the wire because a module may score by distance,
|
||||
* time or a weighted tally — but most score by counting, and rendering a plain
|
||||
* tally of kills as `12.0` reads as a rounding artefact. So a whole number prints
|
||||
* whole and a fraction keeps its digits, with trailing zeros trimmed: `1420`,
|
||||
* `318.5`, `0.25`.
|
||||
*/
|
||||
fun scoreText(score: Double, locale: Locale = Locale.getDefault()): String {
|
||||
if (!score.isFinite()) return "0"
|
||||
if (score == Math.floor(score) && Math.abs(score) < 1e15) {
|
||||
return String.format(locale, "%d", score.toLong())
|
||||
}
|
||||
return String.format(locale, "%.4f", score).trimEnd('0').trimEnd('.', ',')
|
||||
}
|
||||
|
||||
/** The event's own wall clock, with the zone named so it misreads as nothing. */
|
||||
fun eventTime(
|
||||
instant: String?,
|
||||
timezone: String?,
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String {
|
||||
val at = parseWireInstant(instant) ?: return ""
|
||||
val zone = eventZone(timezone)
|
||||
val time = DateTimeFormatter.ofPattern("HH:mm", locale).withZone(zone).format(at)
|
||||
return "$time ${shortZone(timezone)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's own day and time together, for a screen showing one occurrence.
|
||||
*
|
||||
* Localized rather than patterned, because a full date's field order is the
|
||||
* locale's business; only the zone stays the event's.
|
||||
*/
|
||||
fun eventDateTime(
|
||||
instant: String?,
|
||||
timezone: String?,
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String {
|
||||
val at = parseWireInstant(instant) ?: return ""
|
||||
val zone = eventZone(timezone)
|
||||
val text = DateTimeFormatter
|
||||
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)
|
||||
.withLocale(locale)
|
||||
.withZone(zone)
|
||||
.format(at)
|
||||
return "$text ${shortZone(timezone)}"
|
||||
}
|
||||
|
||||
/** The reader's own day, for the heading an entry is filed under. */
|
||||
fun readerDayLabel(
|
||||
instant: String?,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String {
|
||||
val at = parseWireInstant(instant) ?: return ""
|
||||
return DateTimeFormatter
|
||||
.ofLocalizedDate(FormatStyle.FULL)
|
||||
.withLocale(locale)
|
||||
.withZone(zone)
|
||||
.format(at)
|
||||
}
|
||||
|
||||
/**
|
||||
* The zone as a reader recognises it: `America/New_York` → `New York`.
|
||||
*
|
||||
* Not the abbreviation (`EDT`), which is unstable across the year and unknown to
|
||||
* most readers of a shard in another country.
|
||||
*/
|
||||
fun shortZone(timezone: String?): String {
|
||||
if (timezone.isNullOrBlank()) return "UTC"
|
||||
return timezone.substringAfterLast('/').replace('_', ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* The event's zone, or UTC when its column holds something `java.time` will not
|
||||
* read.
|
||||
*
|
||||
* A typo in a definition's timezone must still render: UTC off the instant is the
|
||||
* honest answer when the zone cannot be honoured, and it is what the web client
|
||||
* falls back to for the same reason.
|
||||
*/
|
||||
private fun eventZone(timezone: String?): ZoneId = try {
|
||||
if (timezone.isNullOrBlank()) ZoneId.of("UTC") else ZoneId.of(timezone)
|
||||
} catch (_: Exception) {
|
||||
ZoneId.of("UTC")
|
||||
}
|
||||
|
||||
/**
|
||||
* The word beside an occurrence, for the four statuses the server publishes.
|
||||
*
|
||||
* **`cancelled` needs the instant, and that is the whole reason this takes one.**
|
||||
* The server publishes `failed` and `missed` as `cancelled` too — to a visitor the
|
||||
* three are one event, and the difference between them is about the deployment —
|
||||
* but the three do not share one English sentence. *Did not happen* is right for a
|
||||
* past occurrence and a plain falsehood for a future one, and a run four days out
|
||||
* that an operator has called off is exactly the common case: this is the defect
|
||||
* Phase 14a's own calendar shipped and the live walk caught, which is why it is
|
||||
* restated here rather than ported.
|
||||
*
|
||||
* So **the tense follows the clock, not the status**. A future call-off reads
|
||||
* *Cancelled*; a past one reads *Did not happen*, which is also the honest word
|
||||
* for the failed and missed runs folded in with it.
|
||||
*
|
||||
* An unrecognised status reads *Scheduled*, mirroring the server's own fallback:
|
||||
* `publicStatus()` folds anything it does not know to `scheduled`, so a word the
|
||||
* app has never seen is a contract break rather than a state, and rendering a raw
|
||||
* enum at a reader is not an improvement on it.
|
||||
*/
|
||||
@StringRes
|
||||
fun statusWordRes(status: String?, scheduledFor: String?, now: Instant = Instant.now()): Int =
|
||||
when (status) {
|
||||
"live" -> R.string.events_status_live
|
||||
"completed" -> R.string.events_status_completed
|
||||
"cancelled" -> {
|
||||
val at = parseWireInstant(scheduledFor)
|
||||
// An unreadable instant is treated as past, which is the safer of the
|
||||
// two: "did not happen" about something unplaceable in time is vague,
|
||||
// while "cancelled" about a past run implies it is still coming.
|
||||
if (at != null && at.isAfter(now)) {
|
||||
R.string.events_status_cancelled
|
||||
} else {
|
||||
R.string.events_status_did_not_happen
|
||||
}
|
||||
}
|
||||
else -> R.string.events_status_scheduled
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.events
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.PublicEventDto
|
||||
import com.runicgateway.app.data.repository.EventsRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* One event's page (PLAN.md §9 M13, EVENTS.md § API surface).
|
||||
*
|
||||
* **`run` is read from the route and passed through untouched**, because that is
|
||||
* what an announcement's link carries. The page lives at the definition's slug —
|
||||
* one stable address, so a link posted in Discord survives a retitle — and the
|
||||
* occurrence has to be in the query or a mail about last Friday's invasion would
|
||||
* open next Friday's.
|
||||
*
|
||||
* A run that belongs to some other event is **not** filtered here. The server
|
||||
* ignores it and answers with this event anyway, which turns a stale link in a
|
||||
* months-old mail into the page it was about rather than a dead end; second-
|
||||
* guessing that would undo it.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class EventViewModel @Inject constructor(
|
||||
private val repository: EventsRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val slug: String = savedStateHandle.get<String>(Routes.Args.SLUG).orEmpty()
|
||||
|
||||
/** Null unless the route carried one; never an empty string forwarded to the server. */
|
||||
private val run: String? = savedStateHandle.get<String>(Routes.Args.RUN)?.takeIf { it.isNotBlank() }
|
||||
|
||||
private val _state = MutableStateFlow<UiState<PublicEventDto>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<PublicEventDto>> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
_state.value = repository.event(slug, run).toUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
191
app/src/main/java/com/runicgateway/app/ui/events/EventsScreen.kt
Normal file
191
app/src/main/java/com/runicgateway/app/ui/events/EventsScreen.kt
Normal file
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.events
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.EventCalendarEntryDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.PillTone
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* The public event calendar (EVENTS.md §I, M13).
|
||||
*
|
||||
* **A list, not a month grid**, which is the same call the web client makes and
|
||||
* for the same reason: an operator's question is "what does this month look
|
||||
* like" — coverage, clashes, the gap on the third weekend — and a grid answers
|
||||
* it. A visitor's question is "what is on, and when is the next one", which a
|
||||
* chronological list answers in one glance and a grid answers by making them
|
||||
* count squares. On a phone the grid is not even a close second.
|
||||
*
|
||||
* **A projection is drawn differently from a run**, one tier along from the
|
||||
* operator's own reason for the distinction: past the materialisation horizon
|
||||
* there is no row, nothing is committed to, and nothing can be cancelled. Drawing
|
||||
* a forecast identically to a booking would be the screen promising something the
|
||||
* server has not.
|
||||
*/
|
||||
@Composable
|
||||
fun EventsScreen(
|
||||
onOpenEvent: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: EventsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
when (val s = state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> {
|
||||
val entries = s.data.entries
|
||||
if (entries.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.events_empty), modifier)
|
||||
} else {
|
||||
Calendar(entries, s.data.truncated, onOpenEvent, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Group by the READER's day, preserving the server's order rather than re-sorting.
|
||||
*
|
||||
* Internal + pure so the grouping — and the fact that it never reorders — is
|
||||
* unit-tested without Compose.
|
||||
*/
|
||||
internal fun groupByReaderDay(entries: List<EventCalendarEntryDto>): List<CalendarDayGroup> {
|
||||
val days = mutableListOf<CalendarDayGroup>()
|
||||
for (entry in entries) {
|
||||
val label = readerDayLabel(entry.scheduledFor)
|
||||
val last = days.lastOrNull()
|
||||
if (last != null && last.label == label) {
|
||||
last.entries.add(entry)
|
||||
} else {
|
||||
days.add(CalendarDayGroup(label, mutableListOf(entry)))
|
||||
}
|
||||
}
|
||||
return days
|
||||
}
|
||||
|
||||
/** A mutable builder shape for [groupByReaderDay]; the screen only reads it. */
|
||||
internal data class CalendarDayGroup(
|
||||
val label: String,
|
||||
val entries: MutableList<EventCalendarEntryDto>,
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun Calendar(
|
||||
entries: List<EventCalendarEntryDto>,
|
||||
truncated: Boolean,
|
||||
onOpenEvent: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val days = groupByReaderDay(entries)
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
days.forEach { day ->
|
||||
item(key = "day-${day.label}") {
|
||||
Text(
|
||||
text = day.label,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
items(
|
||||
items = day.entries,
|
||||
key = { "${it.slug}-${it.scheduledFor}-${it.kind}" },
|
||||
) { entry ->
|
||||
EntryCard(entry, onOpenEvent)
|
||||
}
|
||||
}
|
||||
if (truncated) {
|
||||
item(key = "truncated") {
|
||||
Text(
|
||||
text = stringResource(R.string.events_truncated),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EntryCard(entry: EventCalendarEntryDto, onOpenEvent: (String) -> Unit) {
|
||||
ShardCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// A projection has a page too — the definition's — so it opens like any
|
||||
// other entry. What it does not have is an occurrence to link to.
|
||||
.clickable { onOpenEvent(entry.slug) },
|
||||
) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = entry.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
StatusPill(
|
||||
text = stringResource(statusWordRes(entry.status, entry.scheduledFor)),
|
||||
tone = if (entry.live) PillTone.Success else PillTone.Neutral,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = eventTime(entry.scheduledFor, entry.timezone),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
entry.seriesName?.takeIf { it.isNotBlank() }?.let { series ->
|
||||
Text(
|
||||
text = series,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (entry.isProjected) {
|
||||
// Said in words rather than drawn as a dashed border, because a
|
||||
// phone reader skimming a list will not decode a border and the
|
||||
// distinction is worth more than the pixel it would cost.
|
||||
Text(
|
||||
text = stringResource(R.string.events_projected),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontStyle = FontStyle.Italic,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.events
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||
import com.runicgateway.app.data.repository.EventsRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* The public event calendar (PLAN.md §9 M13, EVENTS.md §I).
|
||||
*
|
||||
* **No window is asked for**, and that is the whole of this view model's design.
|
||||
* The server's default is now through 31 days out, so a client that computed a
|
||||
* window before it could ask anything would make every deep link carry two ISO
|
||||
* instants and would have to agree with the server about what "now" is. The
|
||||
* window bound and the entry cap are the server's defence on the one surface with
|
||||
* no login in front of it; there is nothing for the app to add.
|
||||
*
|
||||
* `toUiState`, not `toShardUiState`: these are CORE routes. A 404 here means the
|
||||
* backend has no events at all, not that an admin switched a shard surface off,
|
||||
* and offering "not published here" for it would name the wrong cause.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class EventsViewModel @Inject constructor(
|
||||
private val repository: EventsRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow<UiState<EventCalendarDto>>(UiState.Loading)
|
||||
val state: StateFlow<UiState<EventCalendarDto>> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = UiState.Loading
|
||||
viewModelScope.launch {
|
||||
_state.value = repository.calendar().toUiState()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.events
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.components.EmptyView
|
||||
import com.runicgateway.app.ui.components.ErrorView
|
||||
import com.runicgateway.app.ui.components.LoadingView
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
|
||||
/**
|
||||
* This account's event participation (EVENTS.md §J, M13).
|
||||
*
|
||||
* **The screen's one real design decision is what an unranked row says.** A run
|
||||
* whose participants were collected but whose results have not been published has
|
||||
* a score and no rank, and that is a real state rather than an error — it is the
|
||||
* same state the admin run console has shown since events Phase 10. Rendering a
|
||||
* dash with nothing explaining it would read as a bug; the row says the results
|
||||
* are not published, which is a fact about the event rather than about the reader.
|
||||
*
|
||||
* Reached by **one drawer row for every signed-in account**, players and staff
|
||||
* alike. The website mounts this twice only because its `RequirePlayer` guard sits
|
||||
* over `/account` and the route behind it is role-agnostic; the app has no such
|
||||
* wall, so it needs no second mount.
|
||||
*/
|
||||
@Composable
|
||||
fun MyEventsScreen(
|
||||
onOpenRun: (String, Long) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: MyEventsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
when (val items = state.items) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(items.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> if (items.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.events_history_empty), modifier)
|
||||
} else {
|
||||
androidx.compose.foundation.lazy.LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(items.data.size, key = { items.data[it].id }) { index ->
|
||||
HistoryRow(items.data[index], onOpenRun)
|
||||
}
|
||||
if (state.hasMore) {
|
||||
item(key = "more") {
|
||||
TextButton(
|
||||
onClick = viewModel::loadMore,
|
||||
enabled = !state.loadingMore,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (state.loadingMore) R.string.events_loading
|
||||
else R.string.events_show_more,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HistoryRow(entry: EventHistoryEntryDto, onOpenRun: (String, Long) -> Unit) {
|
||||
ShardCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// Straight to the occurrence the reader took part in, not to whatever
|
||||
// is next: `?run=` is what makes the event page answer about this one.
|
||||
.clickable { onOpenRun(entry.slug, entry.runId) },
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(
|
||||
text = entry.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
text = eventDateTime(entry.scheduledFor, entry.timezone),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
entry.seriesName?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Text(
|
||||
text = entry.rank
|
||||
?.let { stringResource(R.string.events_rank, it) }
|
||||
?: stringResource(R.string.events_results_unpublished),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.End,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.events_score, scoreText(entry.score)),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.events
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
|
||||
import com.runicgateway.app.data.repository.EventsRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* This account's event participation (PLAN.md §9 M13, EVENTS.md §J).
|
||||
*
|
||||
* **Self-scoped by the session and nothing else.** There is no id parameter on
|
||||
* the route and deliberately none here: one account never reads another's, and
|
||||
* there is no argument that could later grow into one.
|
||||
*
|
||||
* **Keyset-paged on the participation row's own id, never an offset** — the list
|
||||
* gains a row every time the reader attends something, so an offset page would
|
||||
* skip and repeat rows around the seam.
|
||||
*
|
||||
* ## Why this watches the session, when no other screen here does
|
||||
*
|
||||
* **A drawer route's view model outlives a sign-out.** `navigateTopLevel` saves
|
||||
* and restores back-stack state, so the `NavBackStackEntry` for this route keeps
|
||||
* its `ViewModelStore` across a sign-out and a sign-in as somebody else — and a
|
||||
* view model that loads only in `init` never runs again. The live walk found the
|
||||
* consequence: signing out of an admin account and back in as a player showed the
|
||||
* PLAYER the admin's participation history, with no request made at all.
|
||||
*
|
||||
* The public event screens have the same lifetime and do not care, because a
|
||||
* calendar is the same for everybody. This one is per-account, so the account is
|
||||
* what it keys on: the flow emits the current session immediately, which is also
|
||||
* the first load, and re-emits only when the signed-in id actually changes — a
|
||||
* resume revalidation returning the same user does not refetch.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class MyEventsViewModel @Inject constructor(
|
||||
private val repository: EventsRepository,
|
||||
sessionManager: SessionManager,
|
||||
) : ViewModel() {
|
||||
|
||||
data class State(
|
||||
val items: UiState<List<EventHistoryEntryDto>> = UiState.Loading,
|
||||
val hasMore: Boolean = false,
|
||||
val loadingMore: Boolean = false,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
sessionManager.state
|
||||
.map { (it as? Session.SignedIn)?.user?.id }
|
||||
.distinctUntilChanged()
|
||||
.collect { userId ->
|
||||
// Signed out: drop the rows rather than leave the last
|
||||
// account's on screen behind a shell that is about to
|
||||
// navigate away.
|
||||
if (userId == null) _state.value = State(items = UiState.Success(emptyList()))
|
||||
else load()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.value = State()
|
||||
viewModelScope.launch {
|
||||
val result = repository.history(PAGE)
|
||||
_state.value = State(
|
||||
items = result.toUiState(),
|
||||
// A full page means there is probably another; a short one is the
|
||||
// end. One request rather than a count the server does not send.
|
||||
hasMore = (result as? ApiResult.Ok)?.data?.size == PAGE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMore() {
|
||||
val current = _state.value
|
||||
val shown = (current.items as? UiState.Success)?.data ?: return
|
||||
val last = shown.lastOrNull() ?: return
|
||||
if (current.loadingMore || !current.hasMore) return
|
||||
|
||||
_state.value = current.copy(loadingMore = true)
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.history(PAGE, before = last.id)) {
|
||||
is ApiResult.Ok -> _state.value = State(
|
||||
items = UiState.Success(shown + result.data),
|
||||
hasMore = result.data.size == PAGE,
|
||||
)
|
||||
// A failed NEXT page keeps the pages already read rather than
|
||||
// replacing a screenful of history with an error: the reader can
|
||||
// still see what loaded, and tapping again retries.
|
||||
else -> _state.value = current.copy(loadingMore = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PAGE = 25
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,12 @@ package com.runicgateway.app.ui.navigation
|
||||
import androidx.annotation.StringRes
|
||||
import 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,95 @@ data class MenuEntry(
|
||||
val APP_MENU: List<MenuEntry> = listOf(
|
||||
MenuEntry(Routes.HOME, R.string.menu_home),
|
||||
MenuEntry(Routes.NEWS, R.string.menu_news),
|
||||
// Events are CORE's, so this row is gated on core's own capability rather than
|
||||
// a module's: a site with no game module still has a calendar. Placed here to
|
||||
// match the website's own nav, where Events is the row after News.
|
||||
MenuEntry(Routes.EVENTS, R.string.menu_events, capability = Capability.EVENTS),
|
||||
MenuEntry(Routes.WIKI, R.string.menu_wiki),
|
||||
MenuEntry(Routes.SHARD, R.string.menu_shard, feature = ShardFeature.STATUS),
|
||||
// The shard group. Every row needs the game module INSTALLED (one capability,
|
||||
// because that is the only question a capability can answer) and its own
|
||||
// feature published to this viewer (M11) — both, independently.
|
||||
MenuEntry(
|
||||
Routes.SHARD,
|
||||
R.string.menu_shard,
|
||||
feature = ShardFeature.STATUS,
|
||||
capability = Capability.SHARD,
|
||||
),
|
||||
// Protocol 3.0 shard content (M11). Each hides when the shard doesn't publish it,
|
||||
// which for a brand-new install is every one of them until the plugin has swept.
|
||||
MenuEntry(Routes.SHARD_RULES, R.string.menu_rules, feature = ShardFeature.RULESET),
|
||||
MenuEntry(Routes.ATLAS, R.string.menu_atlas, feature = ShardFeature.ATLAS),
|
||||
MenuEntry(Routes.SHARD_LEADERBOARDS, R.string.menu_leaderboards, feature = ShardFeature.LEADERBOARDS),
|
||||
MenuEntry(Routes.SHARD_MARKET, R.string.menu_market, feature = ShardFeature.MARKET),
|
||||
MenuEntry(
|
||||
Routes.SHARD_RULES,
|
||||
R.string.menu_rules,
|
||||
feature = ShardFeature.RULESET,
|
||||
capability = Capability.SHARD,
|
||||
),
|
||||
MenuEntry(
|
||||
Routes.ATLAS,
|
||||
R.string.menu_atlas,
|
||||
feature = ShardFeature.ATLAS,
|
||||
capability = Capability.SHARD,
|
||||
),
|
||||
MenuEntry(
|
||||
Routes.SHARD_LEADERBOARDS,
|
||||
R.string.menu_leaderboards,
|
||||
feature = ShardFeature.LEADERBOARDS,
|
||||
capability = Capability.SHARD,
|
||||
),
|
||||
MenuEntry(
|
||||
Routes.SHARD_MARKET,
|
||||
R.string.menu_market,
|
||||
feature = ShardFeature.MARKET,
|
||||
capability = Capability.SHARD,
|
||||
),
|
||||
// The Rust module (M14). ONE row, because the module's whole public surface is
|
||||
// one list and one page beneath it — `/rust` IS the server list, not a hub
|
||||
// above one.
|
||||
//
|
||||
// **No `feature`, and that is not an omission.** The visibility framework is
|
||||
// `module-uo`'s own (`shardVisibility`, six files under `module-uo/server/`
|
||||
// and none under core's), and §2.7 forbids a module importing another's — so
|
||||
// `module-rust` has no per-viewer visibility layer yet. Its phase 14 builds
|
||||
// one; until then these routes are public to everyone the site is public to,
|
||||
// and a `feature` here would be gating on a flag nothing publishes.
|
||||
MenuEntry(Routes.RUST, R.string.menu_rust, capability = Capability.RUST),
|
||||
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 +185,33 @@ val APP_MENU: List<MenuEntry> = listOf(
|
||||
)
|
||||
|
||||
/**
|
||||
* The entries the given [session] may see, given the shard [features] it may reach.
|
||||
* Pure + side-effect-free so the gating is unit-tested without Compose.
|
||||
* The entries the given [session] may see, on a backend with these [capabilities]
|
||||
* and this shard's [features]. Pure + side-effect-free so the gating is unit-tested
|
||||
* without Compose.
|
||||
*
|
||||
* Two independent filters, and both must pass:
|
||||
* Three independent filters, and all three must pass:
|
||||
*
|
||||
* - [MenuEntry.access] against the session — who the caller is.
|
||||
* - [MenuEntry.capability] against what this backend serves — whether the code
|
||||
* behind the row is installed at all (M13). Per host.
|
||||
* - [MenuEntry.feature] against the shard's live visibility config — what this shard
|
||||
* publishes at all (M11). `null` [features] means the answer isn't known yet and
|
||||
* every shard entry shows; see [canSee] for why that direction is deliberate.
|
||||
* publishes to this viewer (M11). Per viewer.
|
||||
*
|
||||
* **The last two both fail open on an unknown answer, but "unknown" means
|
||||
* different things to them.** A `null` [features] is unknown; so is a `null`
|
||||
* [capabilities] — but a *non-null* [capabilities] that does not name the string
|
||||
* is an ANSWER, and it hides. Without that, a site with no game module renders
|
||||
* five shard rows that each 404. See [canUse].
|
||||
*/
|
||||
fun visibleEntries(
|
||||
entries: List<MenuEntry>,
|
||||
session: Session,
|
||||
features: ShardFeatures? = null,
|
||||
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features) }
|
||||
capabilities: SiteCapabilities? = null,
|
||||
): List<MenuEntry> = entries.filter { isEntryVisible(it, session, features, capabilities) }
|
||||
|
||||
/**
|
||||
* [visibleEntries] for a single entry — the same two filters, and the same
|
||||
* [visibleEntries] for a single entry — the same three filters, and the same
|
||||
* boundary. Split out because the drawer is a tree once an admin groups rows into
|
||||
* sections (§6.3): [pruneNav] applies this predicate inside a section as well, and
|
||||
* both callers must ask exactly one question or a sectioned row could be gated by
|
||||
@@ -121,6 +221,7 @@ fun isEntryVisible(
|
||||
entry: MenuEntry,
|
||||
session: Session,
|
||||
features: ShardFeatures? = null,
|
||||
capabilities: SiteCapabilities? = null,
|
||||
): Boolean {
|
||||
val allowedByRole = when (entry.access) {
|
||||
MenuAccess.PUBLIC -> true
|
||||
@@ -129,5 +230,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))
|
||||
}
|
||||
|
||||
@@ -11,34 +11,77 @@ import com.runicgateway.app.data.repository.ContentRepository.PostCategory
|
||||
* The public nav an admin edits is keyed by **website** paths, so honoring it in
|
||||
* the app needs a translation. This is the one new piece of cross-repo coupling
|
||||
* the milestone introduces, which is why it lives in a single file with the
|
||||
* website's own array quoted right beside it — the coupling is visible and
|
||||
* website's own arrays quoted right beside it — the coupling is visible and
|
||||
* reviewable in one place rather than spread across the drawer's call sites.
|
||||
*
|
||||
* ## The nav is TWO arrays now, and that is what M13 had to correct
|
||||
*
|
||||
* This file was written when the website's public nav was one sixteen-row array.
|
||||
* Since the module-system cutover on 2026-08-12 it is **core's eight rows plus
|
||||
* every installed module's**, interleaved at render time by `withModuleNav`, and
|
||||
* a module's pages are mounted by core at `/<module id>/<path>` — so the nine
|
||||
* shard rows moved from `/site/champs` to `/uo/champs` and this table stopped
|
||||
* resolving any of them. Three things followed, all of them true of the shipped
|
||||
* app until M13: a nav override on a shard row was ignored, an added link to a
|
||||
* shard page handed off to a browser instead of opening natively, and the sort
|
||||
* key line below was a sixteen-row line against a nav numbered differently.
|
||||
*
|
||||
* **The nine `/uo/` paths are hardcoded, and they are ONE module's.** The alternative
|
||||
* — reading the installed module's id from `GET /public/modules` and building
|
||||
* `/<id>/shard` — is forbidden by `MODULE_API.md` §2.9 (*"a client must not infer
|
||||
* a route from a capability"*) and would hardcode the same path shape less
|
||||
* visibly. A site running a different game module matches none of these nine, its
|
||||
* links hand off to a Custom Tab, and that is the correct answer rather than a
|
||||
* gap: core cannot tell the app what another module calls its pages.
|
||||
*
|
||||
* Verbatim from `website/client/src/components/SiteHeader.jsx`, which is the
|
||||
* exported owner of the list (`export const NAV`, and Admin → Navigation edits
|
||||
* exported owner of core's list (`export const NAV`, and Admin → Navigation edits
|
||||
* exactly it):
|
||||
*
|
||||
* ```js
|
||||
* export const NAV = [
|
||||
* { label: 'Home', to: '/', end: true },
|
||||
* { label: 'News', to: '/site/news' },
|
||||
* { label: 'Events', to: '/site/events' },
|
||||
* { label: 'Screenshots', to: '/site/screenshots' },
|
||||
* { label: 'Five on Friday', to: '/site/five-on-friday' },
|
||||
* { label: 'Newsletter', to: '/site/newsletter' },
|
||||
* { label: 'Wiki', to: '/wiki' },
|
||||
* { label: 'Shard', to: '/site/shard', feature: 'status' },
|
||||
* { label: 'Champions', to: '/site/champs', feature: 'champs' },
|
||||
* { label: 'Guilds', to: '/site/guilds', feature: 'guilds' },
|
||||
* { label: 'Governors', to: '/site/governors', feature: 'governors' },
|
||||
* { label: 'Houses', to: '/site/houses', feature: 'houses' },
|
||||
* { label: 'Rules', to: '/site/rules', feature: 'ruleset' },
|
||||
* { label: 'Atlas', to: '/site/atlas', feature: 'atlas' },
|
||||
* { label: 'Leaderboards', to: '/site/leaderboards', feature: 'leaderboards' },
|
||||
* { label: 'Market', to: '/site/market', feature: 'market' },
|
||||
* { label: 'About', to: '/site/about' },
|
||||
* ]
|
||||
* ```
|
||||
*
|
||||
* and from `module-uo/client/src/entry.jsx`, which registers the rest:
|
||||
*
|
||||
* ```jsx
|
||||
* registry.registerNav(ID, {
|
||||
* area: 'public',
|
||||
* items: [
|
||||
* { label: 'Shard', to: '/uo/shard', feature: 'status' },
|
||||
* { label: 'Champions', to: '/uo/champs', feature: 'champs' },
|
||||
* { label: 'Guilds', to: '/uo/guilds', feature: 'guilds' },
|
||||
* { label: 'Governors', to: '/uo/governors', feature: 'governors' },
|
||||
* { label: 'Houses', to: '/uo/houses', feature: 'houses' },
|
||||
* { label: 'Rules', to: '/uo/rules', feature: 'ruleset' },
|
||||
* { label: 'Atlas', to: '/uo/atlas', feature: 'atlas' },
|
||||
* { label: 'Leaderboards', to: '/uo/leaderboards', feature: 'leaderboards' },
|
||||
* { label: 'Market', to: '/uo/market', feature: 'market' },
|
||||
* ],
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* and from `module-rust/client/src/entry.jsx`, which registers one (M14):
|
||||
*
|
||||
* ```jsx
|
||||
* registry.registerNav(ID, {
|
||||
* area: 'public',
|
||||
* items: [{ label: 'Servers', to: '/rust' }],
|
||||
* })
|
||||
* ```
|
||||
*
|
||||
* 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 +101,43 @@ data class WebNavPath(val path: String, val route: String)
|
||||
* *this* list (the admin's editor writes the position a row holds on the web), so
|
||||
* a row the admin never moved has to take its key from the same number line or
|
||||
* explicit and implicit keys would be incomparable. See `NavOverrides.kt`.
|
||||
*
|
||||
* Core's eight first, then the module's nine, because that is what `withModuleNav`
|
||||
* renders and therefore what the admin's editor numbered.
|
||||
*/
|
||||
val WEBSITE_PUBLIC_NAV: List<WebNavPath> = listOf(
|
||||
WebNavPath("/", Routes.HOME),
|
||||
WebNavPath("/site/news", Routes.NEWS),
|
||||
WebNavPath("/site/events", Routes.EVENTS),
|
||||
// The app's News screen carries all four categories as tabs, so these three
|
||||
// have a route but no drawer row of their own — see the note below.
|
||||
WebNavPath("/site/screenshots", Routes.news(PostCategory.SCREENSHOTS)),
|
||||
WebNavPath("/site/five-on-friday", Routes.news(PostCategory.FIVE_ON_FRIDAY)),
|
||||
WebNavPath("/site/newsletter", Routes.news(PostCategory.NEWSLETTER)),
|
||||
WebNavPath("/wiki", Routes.WIKI),
|
||||
WebNavPath("/site/shard", Routes.SHARD),
|
||||
// Behind the Shard hub in the app, deliberately — no drawer row either.
|
||||
WebNavPath("/site/champs", Routes.SHARD_CHAMPS),
|
||||
WebNavPath("/site/guilds", Routes.SHARD_GUILDS),
|
||||
WebNavPath("/site/governors", Routes.SHARD_GOVERNORS),
|
||||
WebNavPath("/site/houses", Routes.SHARD_HOUSES),
|
||||
WebNavPath("/site/rules", Routes.SHARD_RULES),
|
||||
WebNavPath("/site/atlas", Routes.ATLAS),
|
||||
WebNavPath("/site/leaderboards", Routes.SHARD_LEADERBOARDS),
|
||||
WebNavPath("/site/market", Routes.SHARD_MARKET),
|
||||
WebNavPath("/site/about", Routes.page("about")),
|
||||
// module-uo's rows. Mounted by core at `/<module id>/<path>`, which is why
|
||||
// every one of these is `/uo/` and not `/site/`.
|
||||
WebNavPath("/uo/shard", Routes.SHARD),
|
||||
// Behind the Shard hub in the app, deliberately — no drawer row either.
|
||||
WebNavPath("/uo/champs", Routes.SHARD_CHAMPS),
|
||||
WebNavPath("/uo/guilds", Routes.SHARD_GUILDS),
|
||||
WebNavPath("/uo/governors", Routes.SHARD_GOVERNORS),
|
||||
WebNavPath("/uo/houses", Routes.SHARD_HOUSES),
|
||||
WebNavPath("/uo/rules", Routes.SHARD_RULES),
|
||||
WebNavPath("/uo/atlas", Routes.ATLAS),
|
||||
WebNavPath("/uo/leaderboards", Routes.SHARD_LEADERBOARDS),
|
||||
WebNavPath("/uo/market", Routes.SHARD_MARKET),
|
||||
// module-rust's one row (M14). It registers `{ label: 'Servers', to: '/rust' }`
|
||||
// and nothing else — `/rust` IS the server list, because core strips the
|
||||
// trailing separator from a module route registered with `path: ''`.
|
||||
//
|
||||
// **Both modules can be installed on one backend**, and then the nav is core's
|
||||
// eight plus ten. This table is a superset by design: a path here for a module
|
||||
// an operator has NOT installed never appears in that backend's nav and so is
|
||||
// never looked up, while a path missing from it makes a link that exists hand
|
||||
// off to a browser.
|
||||
WebNavPath("/rust", Routes.RUST),
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -134,6 +193,16 @@ private fun normalizeWebPath(path: String?): String? {
|
||||
*/
|
||||
private val RESERVED_TOP_LEVEL = setOf(
|
||||
"admin", "account", "player", "site", "wiki", "invite", "preview", "api", "uploads",
|
||||
// An installed module's pages are mounted at `/<id>/…` and are not CMS pages.
|
||||
// Only ids the app knows about need listing: an unknown module's `/<id>` would
|
||||
// resolve to a CMS page that 404s, which is the same answer the browser gives
|
||||
// it, and core cannot enumerate them for us here anyway.
|
||||
//
|
||||
// `rust` is here for the opposite reason to the rest: `/rust` DOES resolve, to
|
||||
// the server list, and it does so through the nav table above — this set only
|
||||
// stops the CMS-page fallback claiming it. Without the entry a site with the
|
||||
// module absent would open a page-not-found screen instead of the browser.
|
||||
"uo", "rust",
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -153,16 +222,18 @@ private val RESERVED_TOP_LEVEL = setOf(
|
||||
* <Route path="/site/five-on-friday" element={<FiveOnFriday />} />
|
||||
* <Route path="/site/newsletter" element={<Newsletter />} />
|
||||
* <Route path="/site/newsletter/:id" element={<NewsletterIssue />} />
|
||||
* <Route path="/site/events" element={<Events />} />
|
||||
* <Route path="/site/events/series/:slug" element={<EventSeries />} />
|
||||
* <Route path="/site/events/:slug" element={<EventPage />} />
|
||||
* <Route path="/site/about" element={<About />} />
|
||||
* <Route path="/site/status" element={<Status />} />
|
||||
* <Route path="/site/shard" element={<Shard />} />
|
||||
* <Route path="/site/shard/activity" element={<ShardActivity />} />
|
||||
* ... /site/champs, /guilds, /governors, /houses, /rules, /leaderboards, /market
|
||||
* <Route path="/site/atlas" element={<Atlas />} />
|
||||
* <Route path="/site/atlas/:slug" element={<AtlasCreature />} />
|
||||
* <Route path="/site/market/vendors/:serial" element={<MarketVendor />} />
|
||||
* <Route path="/wiki" element={<Wiki />} />
|
||||
* <Route path="/wiki/:slug" element={<WikiArticle />} />
|
||||
* // Installed modules' pages, mounted at `/<module id>/<path>`:
|
||||
* // /uo/shard, /uo/shard/activity, /uo/champs, /uo/guilds, /uo/guilds/:id,
|
||||
* // /uo/governors, /uo/houses, /uo/rules, /uo/leaderboards, /uo/market,
|
||||
* // /uo/market/vendors/:serial, /uo/atlas, /uo/atlas/:slug
|
||||
* // /rust, /rust/servers/:id
|
||||
* // CMS pages: top-level /:slug, matched only after the named routes above
|
||||
* <Route path="/:slug" element={<CmsPage />} />
|
||||
* ```
|
||||
@@ -177,19 +248,33 @@ private val RESERVED_TOP_LEVEL = setOf(
|
||||
* /site/{screenshots,five-on-friday,newsletter}
|
||||
* → NEWS, that category's tab
|
||||
* /site/newsletter/<id> → POST (the site's one post-detail route)
|
||||
* /site/events → EVENTS
|
||||
* /site/events/series/<slug> → EVENT_SERIES
|
||||
* /site/events/<slug>[?run=<id>] → EVENT (the one route that takes a query)
|
||||
* /wiki → WIKI
|
||||
* /wiki/<slug> → WIKI_PAGE
|
||||
* /site/<shard surface> → the mapped shard route (§6.2)
|
||||
* /site/atlas/<slug> → ATLAS_CREATURE
|
||||
* /site/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
||||
* /uo/<shard surface> → the mapped shard route (§6.2)
|
||||
* /uo/atlas/<slug> → ATLAS_CREATURE
|
||||
* /uo/market/vendors/<serial> → SHARD_MARKET_VENDOR
|
||||
* /rust → RUST (module-rust's server list)
|
||||
* /rust/servers/<id> → RUST_SERVER
|
||||
* /site/about → PAGE("about")
|
||||
* /<slug> → PAGE(slug), unless <slug> is reserved
|
||||
* anything else → null, i.e. the Custom Tab
|
||||
* ```
|
||||
*
|
||||
* **A path carrying a query or a fragment hands off**, whatever its route part
|
||||
* says. No app route takes either, so a native match would quietly drop what the
|
||||
* admin wrote; the browser honors it exactly.
|
||||
* **A path carrying a query or a fragment hands off — with exactly one
|
||||
* exception.** The rule exists because no app route took either, so a native
|
||||
* match would quietly drop what the admin wrote while the browser honors it. The
|
||||
* event page (M13) is the first route that takes a query, and it takes one key:
|
||||
* `run`, which is what every `event.` announcement's `eventUrl` carries. So a
|
||||
* `?run=` on an event path resolves natively and **anything else in a query
|
||||
* string, any second parameter, and any fragment still hand off** — the carve-out
|
||||
* is one key on one path, not a general "parse the query".
|
||||
*
|
||||
* That narrowness is the point: an admin who writes `/site/events/x?utm=mail` gets
|
||||
* the browser, which honors `utm`, rather than an app screen that silently ignored
|
||||
* it.
|
||||
*
|
||||
* Resolving a path is not the same as being allowed to see the screen behind it.
|
||||
* A link to `/site/market` on a shard that does not publish the market lands on
|
||||
@@ -197,24 +282,77 @@ 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])
|
||||
// module-rust's one page below the list. `/rust` itself is already
|
||||
// answered by the nav table above, before this fallback is reached.
|
||||
segments[0] == MODULE_RUST && segments.size == 3 && segments[1] == "servers" ->
|
||||
Routes.rustServer(segments[2])
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `run` value of a query that consists of **exactly** `run=<something>`, or
|
||||
* null for every other query — including one that merely contains a `run` among
|
||||
* others.
|
||||
*
|
||||
* Deliberately not a query parser. A second parameter means the writer meant
|
||||
* something the app cannot honor, and the honest answer to that is the browser.
|
||||
* An empty value (`?run=`) is null too: it would reach the screen as a blank
|
||||
* string and be forwarded to the server as one.
|
||||
*/
|
||||
private fun runParam(query: String): String? {
|
||||
val value = query.removePrefix("run=")
|
||||
if (value.length == query.length || value.isEmpty()) return null
|
||||
return value.takeIf { '&' !in it && '=' !in it }
|
||||
}
|
||||
|
||||
/**
|
||||
* The module id whose public pages this table maps.
|
||||
*
|
||||
* Named once rather than spelled into four branches, so what is coupled to one
|
||||
* module is countable. It is a literal on purpose — see the file header.
|
||||
*/
|
||||
private const val MODULE_UO = "uo"
|
||||
|
||||
/** The second game module's id (M14). A literal for the same reason. */
|
||||
private const val MODULE_RUST = "rust"
|
||||
|
||||
@@ -37,8 +37,54 @@ object Routes {
|
||||
const val ACCOUNT_TRUSTED_DEVICES = "account/trusted-devices"
|
||||
const val ACCOUNT_RECOVERY_CODES = "account/recovery-codes"
|
||||
|
||||
/** Opt-in push notification settings (§11, signed-in). */
|
||||
/**
|
||||
* The in-app inbox (ENGAGEMENT.md phase 8, signed-in) and its settings.
|
||||
*
|
||||
* The bare route is the CONTENT and the named sub-route the preferences, which
|
||||
* is exactly how the web surface is laid out (`/account/notifications` and
|
||||
* `…/settings`) — and what a person means when they tap "Notifications".
|
||||
*/
|
||||
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"
|
||||
|
||||
/**
|
||||
* The Rust module's surface (§9 M14, `docs/modules/rust/PLAN.md` D12, D13).
|
||||
*
|
||||
* **[RUST] is the server list, not a hub above one.** The module registers its
|
||||
* pages with `path: ''` and core strips the trailing separator, so `/rust` on
|
||||
* the website *is* the list — there is no landing page between the drawer row
|
||||
* and the servers, and adding one here would invent a screen the website does
|
||||
* not have.
|
||||
*
|
||||
* **A different game, so a different route tree.** These are deliberately not
|
||||
* folded into [SHARD]: one shard is a place, and a Rust site is a fleet. The
|
||||
* two can be installed on the same backend, and then both trees exist at once.
|
||||
*/
|
||||
const val RUST = "rust"
|
||||
const val RUST_SERVER = "rust/servers/{serverId}"
|
||||
|
||||
/** Public shard hub (§6.2). */
|
||||
const val SHARD = "shard"
|
||||
@@ -90,6 +136,8 @@ object Routes {
|
||||
const val CATEGORY = "category"
|
||||
const val ID_OR_SLUG = "idOrSlug"
|
||||
const val SERIAL = "serial"
|
||||
const val RUN = "run"
|
||||
const val SERVER_ID = "serverId"
|
||||
}
|
||||
|
||||
fun page(slug: String) = "page/$slug"
|
||||
@@ -109,9 +157,60 @@ object Routes {
|
||||
/** One player vendor's shop, by in-game (hex) serial. */
|
||||
fun marketVendor(serial: String) = "shard/market/$serial"
|
||||
|
||||
/**
|
||||
* One Rust server's page.
|
||||
*
|
||||
* The id is a slug an operator chose, so it is encoded: nothing stops one
|
||||
* carrying a character a path would otherwise eat, and a server nobody can
|
||||
* open is a worse failure than a name nobody can read.
|
||||
*
|
||||
* **Encoded here rather than with `android.net.Uri`**, which is a stub in a
|
||||
* JVM unit test and throws "not mocked" — this object is pure and every test
|
||||
* that builds a route would have to become an instrumented one to keep it
|
||||
* that way.
|
||||
*/
|
||||
fun rustServer(id: String) = "rust/servers/${encodePathSegment(id)}"
|
||||
|
||||
/**
|
||||
* Percent-encode one path segment, allowing only the unreserved set.
|
||||
*
|
||||
* Deliberately stricter than it needs to be: encoding a character that did
|
||||
* not need it still round-trips, where missing one that did produces a route
|
||||
* NavHost matches differently from the one that was built. UTF-8 first, so a
|
||||
* non-ASCII name is encoded per byte rather than per character.
|
||||
*/
|
||||
private fun encodePathSegment(value: String): String = buildString {
|
||||
for (byte in value.toByteArray(Charsets.UTF_8)) {
|
||||
val code = byte.toInt() and 0xFF
|
||||
val char = code.toChar()
|
||||
if (code < 128 && (char.isLetterOrDigit() || char in UNRESERVED)) {
|
||||
append(char)
|
||||
} else {
|
||||
append('%').append(code.toString(16).uppercase().padStart(2, '0'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private const val UNRESERVED = "-._~"
|
||||
|
||||
/** 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;
|
||||
@@ -128,6 +227,35 @@ 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 }`.
|
||||
*
|
||||
* **A `notification:<id>` ref means the engine wrote this user an inbox row**
|
||||
* (`pushChannel.js` builds it), so the tap goes to the inbox whatever the
|
||||
* stream is — an engagement rule's stream id is a TRIGGER id in §7.2's one
|
||||
* namespace, and [forStream]'s fixed map would send most of them to Home.
|
||||
* Every other tickle keeps the route it has always had, so no shipped stream
|
||||
* changes where it lands.
|
||||
*
|
||||
* The ref is not decoded beyond that prefix and is never rendered: it is a
|
||||
* hint that a row exists, and the app's contract is wake-and-pull.
|
||||
*/
|
||||
fun forTickle(streamId: String, ref: String?): String =
|
||||
if (ref != null && ref.startsWith(INBOX_REF_PREFIX)) NOTIFICATIONS else forStream(streamId)
|
||||
|
||||
/** What `pushChannel.js` prefixes an inbox row's id with. */
|
||||
const val INBOX_REF_PREFIX = "notification:"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
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.inbox.InboxCache
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||
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 drawer's unread badge (ENGAGEMENT.md phase 8).
|
||||
*
|
||||
* Its own view model, and its own endpoint: `/notifications/unread-count` exists
|
||||
* precisely because this is the question asked most often and it should not make
|
||||
* the server assemble a page of bodies to answer with one integer. Refreshed when
|
||||
* the app resumes rather than on a timer — the tickle is what says "something
|
||||
* happened", so polling would be a second, worse copy of push.
|
||||
*
|
||||
* Falls back to the cached count while offline, for the same reason the inbox
|
||||
* does: a badge that dropped to zero because the train went into a tunnel would
|
||||
* be telling the user they have read something they have not.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class InboxBadgeViewModel @Inject constructor(
|
||||
private val notifications: NotificationsRepository,
|
||||
private val cache: InboxCache,
|
||||
private val sessionManager: SessionManager,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _unread = MutableStateFlow(0)
|
||||
val unread: StateFlow<Int> = _unread.asStateFlow()
|
||||
|
||||
/** Ask the server, falling back to the snapshot. A signed-out session is zero. */
|
||||
fun refresh() = viewModelScope.launch {
|
||||
val user = (sessionManager.state.value as? Session.SignedIn)?.user
|
||||
if (user == null) {
|
||||
_unread.value = 0
|
||||
return@launch
|
||||
}
|
||||
when (val result = notifications.unreadCount()) {
|
||||
is ApiResult.Ok -> _unread.value = result.data.unread
|
||||
else -> {
|
||||
val owner = InboxCache.ownerKey(baseUrlHolder.current?.toString(), user.id)
|
||||
cache.read(owner)?.let { _unread.value = it.unread }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
import com.runicgateway.app.core.time.parseWireInstant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.FormatStyle
|
||||
|
||||
/**
|
||||
* Render an inbox item's `createdAt` for display, in the device's own zone and
|
||||
* 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** — 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.
|
||||
*/
|
||||
fun inboxTimestamp(
|
||||
raw: String,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
formatter: DateTimeFormatter =
|
||||
DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT),
|
||||
): String? {
|
||||
val instant = parseWireInstant(raw) ?: return null
|
||||
return try {
|
||||
formatter.withZone(zone).format(instant)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
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.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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.core.web.WebHandoff
|
||||
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||
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
|
||||
|
||||
/**
|
||||
* The in-app inbox (ENGAGEMENT.md phase 8): what the engine's `inapp` channel
|
||||
* wrote for this user, newest first.
|
||||
*
|
||||
* This is the drawer's "Notifications" — the settings that used to live there are
|
||||
* one tap away behind the gear, mirroring exactly what phase 7 shipped on the web
|
||||
* (the bare path is the inbox, `…/settings` is the preferences). It is what a
|
||||
* tapped push tickle deep-links to, and the pull that follows the wake.
|
||||
*
|
||||
* **The list carries content, so it is deliberately plain text.** An item's body
|
||||
* is the server's `toText` render, never the email HTML — that markup is table
|
||||
* rows and inline hex with a light-only `color-scheme`, which in a themed app
|
||||
* would be a pale card in a dark one. It also means there is no operator markup
|
||||
* on this surface to sanitize.
|
||||
*/
|
||||
@Composable
|
||||
fun InboxScreen(
|
||||
onOpenSettings: () -> Unit,
|
||||
onOpenRoute: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: InboxViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
Column(modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 4.dp, top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = if (state.unread > 0) {
|
||||
stringResource(R.string.inbox_unread_count, state.unread)
|
||||
} else {
|
||||
stringResource(R.string.inbox_all_read)
|
||||
},
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (state.unread > 0) {
|
||||
TextButton(onClick = viewModel::markAllRead) {
|
||||
Text(stringResource(R.string.inbox_mark_all_read))
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(Icons.Filled.Settings, stringResource(R.string.inbox_open_settings))
|
||||
}
|
||||
}
|
||||
|
||||
// Showing the snapshot rather than the server's answer is said out loud: a
|
||||
// notification surface that quietly showed a stale list would be lying
|
||||
// about the one thing it exists to be — current.
|
||||
if (state.fromCache) {
|
||||
Text(
|
||||
text = stringResource(R.string.inbox_offline_cached),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
when (val items = state.items) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(items.kind, onRetry = viewModel::load)
|
||||
is UiState.Success -> if (items.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.inbox_empty))
|
||||
} else {
|
||||
InboxList(
|
||||
items = items.data,
|
||||
hasMore = state.hasMore && !state.fromCache,
|
||||
onEndReached = viewModel::loadMore,
|
||||
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.
|
||||
//
|
||||
// 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) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InboxList(
|
||||
items: List<NotificationItemDto>,
|
||||
hasMore: Boolean,
|
||||
onEndReached: () -> Unit,
|
||||
onOpen: (NotificationItemDto) -> Unit,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize().padding(horizontal = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = PaddingValues(vertical = 12.dp),
|
||||
) {
|
||||
items(items, key = { it.id }) { item -> InboxCard(item, onOpen) }
|
||||
if (hasMore) {
|
||||
item {
|
||||
// Paging by "the last row came into view" rather than a button: the
|
||||
// cursor is the last id on screen, so reaching the end IS the request.
|
||||
LaunchedEffect(items.lastOrNull()?.id) { onEndReached() }
|
||||
Box(Modifier.fillMaxWidth().padding(16.dp), contentAlignment = Alignment.Center) {
|
||||
Text(
|
||||
stringResource(R.string.inbox_loading_more),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun InboxCard(item: NotificationItemDto, onOpen: (NotificationItemDto) -> Unit) {
|
||||
ShardCard(Modifier.fillMaxWidth().clickable { onOpen(item) }) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
if (!item.read) {
|
||||
// The unread mark is a dot beside the title AND a heavier weight
|
||||
// on it: colour alone would carry the whole signal, which is not
|
||||
// a distinction everyone can see.
|
||||
Box(
|
||||
Modifier
|
||||
.padding(end = 8.dp)
|
||||
.size(8.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = if (item.read) FontWeight.Normal else FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
item.body?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 6.dp),
|
||||
)
|
||||
}
|
||||
val stamp = item.createdAt?.let { inboxTimestamp(it) }
|
||||
if (stamp != null) {
|
||||
Text(
|
||||
text = stamp,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
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.inbox.InboxCache
|
||||
import com.runicgateway.app.core.net.BaseUrlHolder
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.core.result.map
|
||||
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.navigation.resolveWebPath
|
||||
import com.runicgateway.app.ui.toUiState
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives the in-app inbox (ENGAGEMENT.md phase 8): the items the engine's `inapp`
|
||||
* channel wrote for this user, newest first, with the unread badge and the two
|
||||
* mark-read writes.
|
||||
*
|
||||
* **The tickle contract is wake-and-pull, and this is the pull.** A push tickle
|
||||
* carries `{ stream, ref }` and nothing else by design; `ref` is a HINT that an
|
||||
* inbox row exists, never content, and `pushChannel.js` says so in as many words —
|
||||
* the two rows are independent and either can be retried, so a client that
|
||||
* rendered the ref would show nothing the first time a retry reordered them. So a
|
||||
* tap deep-links here and this refreshes; the ref is not read.
|
||||
*
|
||||
* **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(
|
||||
private val notifications: NotificationsRepository,
|
||||
private val cache: InboxCache,
|
||||
private val sessionManager: SessionManager,
|
||||
private val baseUrlHolder: BaseUrlHolder,
|
||||
) : ViewModel() {
|
||||
|
||||
data class State(
|
||||
val items: UiState<List<NotificationItemDto>> = UiState.Loading,
|
||||
val unread: Int = 0,
|
||||
val hasMore: Boolean = false,
|
||||
val loadingMore: Boolean = false,
|
||||
val refreshing: Boolean = false,
|
||||
/**
|
||||
* True while what is on screen came from [InboxCache] rather than the
|
||||
* server. The screen says so — an inbox that quietly showed a stale list
|
||||
* would be a notification surface that lies about being current.
|
||||
*/
|
||||
val fromCache: Boolean = false,
|
||||
/** When that snapshot was captured; only meaningful with [fromCache]. */
|
||||
val cachedAt: Long? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
sessionManager.state
|
||||
.map { (it as? Session.SignedIn)?.user?.id }
|
||||
.distinctUntilChanged()
|
||||
.collect { userId ->
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the cached page immediately, then refresh from the server.
|
||||
*
|
||||
* The cache is painted first rather than after a failure so a cold open on a
|
||||
* slow connection shows the last known inbox instead of a spinner; a
|
||||
* successful pull replaces it, and a network failure leaves it up with
|
||||
* [State.fromCache] set. A *server* error is a different thing from being
|
||||
* offline and is not papered over with stale rows — unless there is nothing
|
||||
* else to show, in which case the error is still what the screen reports.
|
||||
*/
|
||||
fun load() = viewModelScope.launch {
|
||||
val owner = ownerKey()
|
||||
if (owner != null && _state.value.items !is UiState.Success) {
|
||||
cache.read(owner)?.let { snapshot ->
|
||||
_state.update {
|
||||
it.copy(
|
||||
items = UiState.Success(snapshot.items),
|
||||
unread = snapshot.unread,
|
||||
fromCache = true,
|
||||
cachedAt = snapshot.savedAt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
/** Pull the newest page. Keeps whatever is on screen until it succeeds. */
|
||||
fun refresh() = viewModelScope.launch {
|
||||
_state.update { it.copy(refreshing = true) }
|
||||
when (val result = notifications.inbox()) {
|
||||
is ApiResult.Ok -> {
|
||||
val page = result.data
|
||||
_state.update {
|
||||
it.copy(
|
||||
items = UiState.Success(page.items),
|
||||
unread = page.unread,
|
||||
hasMore = page.hasMore,
|
||||
refreshing = false,
|
||||
fromCache = false,
|
||||
cachedAt = null,
|
||||
)
|
||||
}
|
||||
ownerKey()?.let { cache.write(it, page.items, page.unread) }
|
||||
}
|
||||
else -> {
|
||||
// Nothing cached to fall back on → the error IS the screen. Something
|
||||
// cached → keep it up and label it, which is the whole point of §7's
|
||||
// "the app degrades, it does not fail".
|
||||
val holdCache = _state.value.items is UiState.Success && _state.value.fromCache
|
||||
_state.update {
|
||||
it.copy(
|
||||
items = if (holdCache) it.items else result.map { page -> page.items }.toUiState(),
|
||||
refreshing = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Append the next page.
|
||||
*
|
||||
* A no-op while one is in flight, when the server said there is no next page,
|
||||
* or while the list is the cached snapshot — paging a cache we know to be one
|
||||
* page long would ask the server for `before` an id it may no longer have.
|
||||
*/
|
||||
fun loadMore() = viewModelScope.launch {
|
||||
val current = _state.value
|
||||
val shown = (current.items as? UiState.Success)?.data ?: return@launch
|
||||
if (current.loadingMore || !current.hasMore || current.fromCache) return@launch
|
||||
val cursor = shown.lastOrNull()?.id ?: return@launch
|
||||
|
||||
_state.update { it.copy(loadingMore = true) }
|
||||
when (val result = notifications.inbox(before = cursor)) {
|
||||
is ApiResult.Ok -> {
|
||||
// Guard the same id arriving twice: a keyset window can shift under
|
||||
// a concurrent write, and a duplicate id in a LazyColumn key crashes.
|
||||
val seen = shown.mapTo(mutableSetOf()) { it.id }
|
||||
val appended = result.data.items.filterNot { it.id in seen }
|
||||
_state.update {
|
||||
it.copy(
|
||||
items = UiState.Success(shown + appended),
|
||||
unread = result.data.unread,
|
||||
hasMore = result.data.hasMore,
|
||||
loadingMore = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
// A failed "more" leaves the pages already read alone — losing them
|
||||
// because the fourth page timed out would be worse than stopping.
|
||||
else -> _state.update { it.copy(loadingMore = false, hasMore = false) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark one item read, optimistically.
|
||||
*
|
||||
* The row flips locally before the call so the tap feels immediate, and the
|
||||
* server's post-write `unread` replaces the local guess when it lands. A
|
||||
* failure is not rolled back: read-ness is the least consequential thing in
|
||||
* the app to get briefly wrong, and un-reading a row under the user's finger
|
||||
* looks like a bug. The next refresh corrects it.
|
||||
*/
|
||||
fun markRead(id: Long) = viewModelScope.launch {
|
||||
val shown = (_state.value.items as? UiState.Success)?.data ?: return@launch
|
||||
if (shown.firstOrNull { it.id == id }?.read != false) return@launch
|
||||
|
||||
_state.update { current ->
|
||||
current.copy(
|
||||
items = UiState.Success(shown.map { if (it.id == id) it.copy(read = true) else it }),
|
||||
unread = (current.unread - 1).coerceAtLeast(0),
|
||||
)
|
||||
}
|
||||
when (val result = notifications.markRead(id)) {
|
||||
is ApiResult.Ok -> _state.update { it.copy(unread = result.data.unread) }
|
||||
else -> Unit
|
||||
}
|
||||
cacheCurrent()
|
||||
}
|
||||
|
||||
/** Mark the whole inbox read. Same optimism, and the same reason for it. */
|
||||
fun markAllRead() = viewModelScope.launch {
|
||||
val shown = (_state.value.items as? UiState.Success)?.data ?: return@launch
|
||||
_state.update {
|
||||
it.copy(items = UiState.Success(shown.map { item -> item.copy(read = true) }), unread = 0)
|
||||
}
|
||||
notifications.markAllRead()
|
||||
cacheCurrent()
|
||||
}
|
||||
|
||||
/**
|
||||
* The absolute link for an item, or null when it has none this app can open.
|
||||
*
|
||||
* **An item's `url` is SITE-RELATIVE** — `/guilds/the-silver-anvil/forum/403`
|
||||
* is what the server writes, because it is rendered from the template's button
|
||||
* block for a browser that is already on the site. A phone is not, so it has to
|
||||
* be resolved against the configured base or every link in the inbox is dead;
|
||||
* the live rig is what caught that.
|
||||
*
|
||||
* `HttpUrl.resolve` does both jobs: it absolutises a relative path and it
|
||||
* returns null for anything that would not end up as http(s) — a `javascript:`
|
||||
* or `intent:` url in a notification body opens nothing at all.
|
||||
*/
|
||||
fun linkFor(item: NotificationItemDto): String? {
|
||||
val raw = item.url?.trim().orEmpty()
|
||||
if (raw.isEmpty()) return null
|
||||
return baseUrlHolder.current?.resolve(raw)?.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* The app route this item opens natively, or null when it has none and
|
||||
* [linkFor] should hand it to a browser (M13).
|
||||
*
|
||||
* **Why this exists at all:** events Phase 14a gave the six public `event.`
|
||||
* triggers an `eventUrl` of the form `/site/events/<slug>?run=<id>`, so an
|
||||
* inbox row about an event now has a native destination — and opening a
|
||||
* Custom Tab onto a page the app itself renders is a worse answer than it was
|
||||
* when there was no such page.
|
||||
*
|
||||
* **It reuses `resolveWebPath` rather than adding a second link-routing
|
||||
* mechanism.** That function is already the app's read of the site's own route
|
||||
* table, it already answers null for everything it does not recognise, and
|
||||
* every path it does not recognise still hands off exactly as before. Adding a
|
||||
* parser here would put the decision in two places.
|
||||
*
|
||||
* The item's url is site-relative by contract, but an absolute one on this
|
||||
* host is accepted too: the shape is the server's to change, and a link that
|
||||
* opened the browser only because it arrived fully qualified would be a
|
||||
* puzzle. An absolute url on ANOTHER host is not ours to route — the app has
|
||||
* no screen for somebody else's site — so it falls through to the browser.
|
||||
*/
|
||||
fun routeFor(item: NotificationItemDto): String? {
|
||||
val raw = item.url?.trim().orEmpty()
|
||||
if (raw.isEmpty()) return null
|
||||
val base = baseUrlHolder.current ?: return null
|
||||
val resolved = base.resolve(raw) ?: return null
|
||||
if (resolved.host != base.host) return null
|
||||
val query = resolved.query
|
||||
return resolveWebPath(resolved.encodedPath + if (query.isNullOrEmpty()) "" else "?$query")
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the snapshot in step with a local read.
|
||||
*
|
||||
* Without this, going offline right after reading everything would bring the
|
||||
* badge back on the next cold open. Only ever written for the account that
|
||||
* owns it — [InboxCache] scopes by (base URL, user id).
|
||||
*/
|
||||
private suspend fun cacheCurrent() {
|
||||
val owner = ownerKey() ?: return
|
||||
val current = _state.value
|
||||
val shown = (current.items as? UiState.Success)?.data ?: return
|
||||
cache.write(owner, shown, current.unread)
|
||||
}
|
||||
|
||||
private fun ownerKey(): String? {
|
||||
val user = (sessionManager.state.value as? Session.SignedIn)?.user ?: return null
|
||||
return InboxCache.ownerKey(baseUrlHolder.current?.toString(), user.id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
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.NotificationChannelDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||
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.SectionLabel
|
||||
|
||||
/**
|
||||
* The notification **settings** screen (PLAN.md §11, ENGAGEMENT.md phase 8): every
|
||||
* subscribable id with a control per channel that applies to it.
|
||||
*
|
||||
* It used to be the drawer's "Notifications"; that entry is the inbox now and this
|
||||
* is behind its gear, which is the arrangement phase 7 shipped on the web. What
|
||||
* changed underneath is bigger than the move: the screen asks
|
||||
* `/notifications/channels` and so can express email and on-site preferences, not
|
||||
* just whether a stream pushes.
|
||||
*
|
||||
* **A channel with two modes gets a switch and one with three gets chips**, and
|
||||
* which is which comes off the wire — `email` is the one that supports `digest`
|
||||
* today, and a fourth channel with its own modes would render correctly here
|
||||
* without an app release.
|
||||
*/
|
||||
@Composable
|
||||
fun NotificationSettingsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: NotificationSettingsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
// Ask once for POST_NOTIFICATIONS when the user first switches a push mode on
|
||||
// (API 33+). Email and in-app need no permission — only push posts anything.
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* granted or not, the preference is already saved server-side */ }
|
||||
|
||||
fun ensureNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.notifications_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.notifications_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
state.feedback?.let { fb ->
|
||||
Text(
|
||||
text = stringResource(fb.messageRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
// A shard with no push relay still has email and on-site preferences worth
|
||||
// setting, so this is a note beside the list now rather than the whole
|
||||
// screen — which is what it had to be when push was all there was.
|
||||
if (!state.supported) {
|
||||
Text(
|
||||
text = stringResource(R.string.notifications_unsupported),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontStyle = FontStyle.Italic,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
when (val prefs = state.prefs) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(kind = prefs.kind, onRetry = viewModel::load)
|
||||
is UiState.Success -> ChannelPrefsList(
|
||||
prefs = prefs.data,
|
||||
hasLinkedAccount = state.hasLinkedAccount,
|
||||
pushSupported = state.supported,
|
||||
busy = state.busy,
|
||||
onSetMode = { item, channel, mode ->
|
||||
if (channel == CHANNEL_PUSH && mode != MODE_OFF) ensureNotificationPermission()
|
||||
viewModel.setMode(item, channel, mode)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChannelPrefsList(
|
||||
prefs: NotificationChannelPrefsDto,
|
||||
hasLinkedAccount: Boolean,
|
||||
pushSupported: Boolean,
|
||||
busy: Boolean,
|
||||
onSetMode: (NotificationChannelItemDto, String, String) -> Unit,
|
||||
) {
|
||||
if (prefs.items.isEmpty()) {
|
||||
EmptyView(message = stringResource(R.string.notifications_empty))
|
||||
return
|
||||
}
|
||||
val channelsById = prefs.channels.associateBy { it.id }
|
||||
val (personal, general) = prefs.items.partition { it.personal }
|
||||
|
||||
if (general.isNotEmpty()) {
|
||||
SectionLabel(stringResource(R.string.notifications_section_general))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
general.forEach { item ->
|
||||
ItemRow(item, channelsById, hint = null, enabled = !busy, pushSupported = pushSupported, onSetMode = onSetMode)
|
||||
HorizontalDivider()
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (personal.isNotEmpty()) {
|
||||
SectionLabel(stringResource(R.string.notifications_section_personal))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
personal.forEach { item ->
|
||||
val selectable = itemSelectable(item, hasLinkedAccount)
|
||||
ItemRow(
|
||||
item = item,
|
||||
channelsById = channelsById,
|
||||
hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null,
|
||||
enabled = !busy && selectable,
|
||||
pushSupported = pushSupported,
|
||||
onSetMode = onSetMode,
|
||||
)
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ItemRow(
|
||||
item: NotificationChannelItemDto,
|
||||
channelsById: Map<String, NotificationChannelDto>,
|
||||
hint: String?,
|
||||
enabled: Boolean,
|
||||
pushSupported: Boolean,
|
||||
onSetMode: (NotificationChannelItemDto, String, String) -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxWidth().padding(vertical = 12.dp)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = hint ?: item.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal,
|
||||
)
|
||||
// The item's OWN channel list, in the registry's order. An id nothing can
|
||||
// push carries no push control at all, rather than a dead switch.
|
||||
item.channels.forEach { channelId ->
|
||||
val channel = channelsById[channelId] ?: return@forEach
|
||||
if (channelId == CHANNEL_PUSH && !pushSupported) return@forEach
|
||||
ChannelControl(
|
||||
channel = channel,
|
||||
mode = item.modes[channelId] ?: channel.defaultMode,
|
||||
enabled = enabled,
|
||||
onSetMode = { mode -> onSetMode(item, channelId, mode) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalLayoutApi::class)
|
||||
@Composable
|
||||
private fun ChannelControl(
|
||||
channel: NotificationChannelDto,
|
||||
mode: String,
|
||||
enabled: Boolean,
|
||||
onSetMode: (String) -> Unit,
|
||||
) {
|
||||
val modes = channel.modes.ifEmpty { listOf(MODE_OFF) }
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = channel.label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.weight(1f).padding(end = 12.dp),
|
||||
)
|
||||
// Two modes is a yes/no question and reads best as a switch; three is a
|
||||
// choice and needs its options named — `digest` means nothing as an
|
||||
// unlabelled third state.
|
||||
if (modes.size == 2 && modes.contains(MODE_OFF)) {
|
||||
val on = modes.first { it != MODE_OFF }
|
||||
Switch(
|
||||
checked = mode != MODE_OFF,
|
||||
onCheckedChange = { checked -> onSetMode(if (checked) on else MODE_OFF) },
|
||||
enabled = enabled,
|
||||
)
|
||||
} else {
|
||||
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
modes.forEach { candidate ->
|
||||
FilterChip(
|
||||
selected = candidate == mode,
|
||||
onClick = { if (candidate != mode) onSetMode(candidate) },
|
||||
enabled = enabled,
|
||||
label = { Text(modeLabel(candidate)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy for a delivery mode. A mode this build has never heard of is labelled with
|
||||
* its own wire name rather than hidden — the server accepts it, so a chip reading
|
||||
* `weekly` is more use to the person in front of it than a control that vanished.
|
||||
*/
|
||||
@Composable
|
||||
private fun modeLabel(mode: String): String = when (mode) {
|
||||
MODE_OFF -> stringResource(R.string.notifications_mode_off)
|
||||
"instant" -> stringResource(R.string.notifications_mode_instant)
|
||||
"digest" -> stringResource(R.string.notifications_mode_digest)
|
||||
else -> mode
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
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
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
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.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** The push channel's id — the one channel that also drives a device registration. */
|
||||
const val CHANNEL_PUSH = "push"
|
||||
|
||||
/** The mode every channel accepts, and the one that means "do not deliver". */
|
||||
const val MODE_OFF = "off"
|
||||
|
||||
/**
|
||||
* Drives the notification **settings** screen (PLAN.md §11, ENGAGEMENT.md phase 8).
|
||||
*
|
||||
* **This screen moved off `/notifications/subscriptions` onto
|
||||
* `/notifications/channels`.** The old endpoint asked one question — is push on
|
||||
* for this stream — and there are now three channels to ask it of. The server
|
||||
* keeps `notification_subscriptions` as the push projection of the new table and
|
||||
* fans every write to either into the other, so the shipped APK's screen went on
|
||||
* working the whole time and this one is not a migration anybody has to run.
|
||||
*
|
||||
* **The controls are rendered from the wire, never from a hardcoded three.** Each
|
||||
* item names the channels that apply to it — a trigger-only id carries no `push`
|
||||
* because nothing is registered to push it — and each channel names the modes it
|
||||
* accepts, which is how `email`'s `digest` reaches the app without an app release.
|
||||
* The modes the server sends are the EFFECTIVE ones (it has already substituted
|
||||
* each channel's default), so this class never re-implements the defaulting.
|
||||
*/
|
||||
@HiltViewModel
|
||||
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)
|
||||
|
||||
data class State(
|
||||
val prefs: UiState<NotificationChannelPrefsDto> = UiState.Loading,
|
||||
/** Whether the user has ≥1 linked game account — personal streams need it. */
|
||||
val hasLinkedAccount: Boolean = false,
|
||||
/** Whether this shard advertises a push relay at all (else the screen says so). */
|
||||
val supported: Boolean = true,
|
||||
val busy: Boolean = false,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
pushManager.supported.collect { supported -> _state.update { it.copy(supported = supported) } }
|
||||
}
|
||||
// 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() {
|
||||
_state.update { it.copy(prefs = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(prefs = notifications.channelPrefs().toUiState()) }
|
||||
// A linked game account gates the personal streams; failure → treat as none.
|
||||
val linked = (playerShard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
|
||||
_state.update { it.copy(hasLinkedAccount = linked) }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
/**
|
||||
* Set one (item, channel) pair.
|
||||
*
|
||||
* One pair, one sparse PUT: the endpoint writes only what it is given, so a
|
||||
* toggle cannot disturb a channel this screen is not showing — and the
|
||||
* response is the full stored truth, which is what the screen re-renders
|
||||
* from. An entry the server drops (an unknown id, an inapplicable channel)
|
||||
* therefore shows up as the control springing back, not as a silent lie.
|
||||
*/
|
||||
fun setMode(item: NotificationChannelItemDto, channel: String, mode: String) {
|
||||
val current = _state.value
|
||||
if (current.busy) return
|
||||
if (channel == CHANNEL_PUSH && !itemSelectable(item, current.hasLinkedAccount)) return
|
||||
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = notifications.setChannelMode(item.id, channel, mode)) {
|
||||
is ApiResult.Ok -> {
|
||||
_state.update { it.copy(prefs = UiState.Success(result.data)) }
|
||||
if (channel == CHANNEL_PUSH) reconcilePush(result.data) else finish(true, R.string.notifications_saved)
|
||||
}
|
||||
is ApiResult.NetworkError -> finish(false, R.string.error_network)
|
||||
is ApiResult.HttpError -> finish(false, R.string.notifications_save_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register or unregister the device to match the stored push set (PLAN.md §11).
|
||||
*
|
||||
* Read from the RESPONSE rather than from what was just sent, because the
|
||||
* server may have dropped the entry — and because "is any push mode on" is a
|
||||
* question about the whole table, not about the row that changed.
|
||||
*/
|
||||
private suspend fun reconcilePush(prefs: NotificationChannelPrefsDto) {
|
||||
val anyPushOn = prefs.items.any { item ->
|
||||
val mode = item.modes[CHANNEL_PUSH]
|
||||
mode != null && mode != MODE_OFF
|
||||
}
|
||||
if (!anyPushOn) {
|
||||
pushManager.disable()
|
||||
finish(true, R.string.notifications_all_off)
|
||||
return
|
||||
}
|
||||
when (val res = pushManager.enable()) {
|
||||
is PushManager.PushResult.Enabled -> finish(true, R.string.notifications_saved)
|
||||
is PushManager.PushResult.Unsupported -> finish(false, R.string.notifications_unsupported)
|
||||
is PushManager.PushResult.NotSignedIn -> finish(false, R.string.notifications_save_error)
|
||||
is PushManager.PushResult.Failed ->
|
||||
finish(false, if (res.status == 400) R.string.notifications_relay_error else R.string.notifications_save_error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
|
||||
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an item's controls are selectable for a user: a personal stream needs a
|
||||
* linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose.
|
||||
*/
|
||||
fun itemSelectable(item: NotificationChannelItemDto, hasLinkedAccount: Boolean): Boolean =
|
||||
!item.requiresLinkedAccount || hasLinkedAccount
|
||||
@@ -1,182 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
import android.Manifest
|
||||
import android.os.Build
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
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.platform.LocalContext
|
||||
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.NotificationStreamDto
|
||||
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.SectionLabel
|
||||
|
||||
/**
|
||||
* The Notifications settings screen (PLAN.md §11, M7 Part 2 work item 6): the
|
||||
* subscribable catalog with per-stream toggles. Personal streams are greyed until a
|
||||
* game account is linked; turning a stream on requests the POST_NOTIFICATIONS
|
||||
* permission (API 33+) and registers the device, turning them all off unregisters it.
|
||||
*/
|
||||
@Composable
|
||||
fun NotificationsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: NotificationsViewModel = hiltViewModel(),
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
|
||||
// Ask once for POST_NOTIFICATIONS when the user first enables a stream (API 33+).
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* granted or not, the subscription is already saved server-side */ }
|
||||
|
||||
fun ensureNotificationPermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.notifications_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.notifications_subtitle),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (!state.supported) {
|
||||
EmptyView(message = stringResource(R.string.notifications_unsupported))
|
||||
return@Column
|
||||
}
|
||||
|
||||
state.feedback?.let { fb ->
|
||||
Text(
|
||||
text = stringResource(fb.messageRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (fb.ok) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(bottom = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
when (val catalog = state.catalog) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(kind = catalog.kind, onRetry = viewModel::load)
|
||||
is UiState.Success -> StreamList(
|
||||
streams = catalog.data,
|
||||
subscribed = state.subscribed,
|
||||
hasLinkedAccount = state.hasLinkedAccount,
|
||||
busy = state.busy,
|
||||
onToggle = { stream, on ->
|
||||
if (on) ensureNotificationPermission()
|
||||
viewModel.setSubscribed(stream, on)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamList(
|
||||
streams: List<NotificationStreamDto>,
|
||||
subscribed: Set<String>,
|
||||
hasLinkedAccount: Boolean,
|
||||
busy: Boolean,
|
||||
onToggle: (NotificationStreamDto, Boolean) -> Unit,
|
||||
) {
|
||||
if (streams.isEmpty()) {
|
||||
EmptyView(message = stringResource(R.string.notifications_empty))
|
||||
return
|
||||
}
|
||||
val (personal, general) = streams.partition { it.personal }
|
||||
|
||||
if (general.isNotEmpty()) {
|
||||
SectionLabel(stringResource(R.string.notifications_section_general))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
general.forEach { stream ->
|
||||
StreamRow(stream, subscribed.contains(stream.id), enabled = !busy, hint = null) { on ->
|
||||
onToggle(stream, on)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
Spacer(Modifier.height(20.dp))
|
||||
}
|
||||
|
||||
if (personal.isNotEmpty()) {
|
||||
SectionLabel(stringResource(R.string.notifications_section_personal))
|
||||
Spacer(Modifier.height(8.dp))
|
||||
personal.forEach { stream ->
|
||||
val selectable = streamSelectable(stream, hasLinkedAccount)
|
||||
val hint = if (!selectable) stringResource(R.string.notifications_requires_link) else null
|
||||
StreamRow(stream, subscribed.contains(stream.id) && selectable, enabled = !busy && selectable, hint = hint) { on ->
|
||||
onToggle(stream, on)
|
||||
}
|
||||
HorizontalDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamRow(
|
||||
stream: NotificationStreamDto,
|
||||
checked: Boolean,
|
||||
enabled: Boolean,
|
||||
hint: String?,
|
||||
onToggle: (Boolean) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) {
|
||||
Text(
|
||||
text = stream.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = hint ?: stream.description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontStyle = if (hint != null) FontStyle.Italic else FontStyle.Normal,
|
||||
)
|
||||
}
|
||||
Switch(checked = checked, onCheckedChange = onToggle, enabled = enabled)
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.R
|
||||
import com.runicgateway.app.core.push.PushManager
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.api.dto.NotificationStreamDto
|
||||
import com.runicgateway.app.data.repository.NotificationsRepository
|
||||
import com.runicgateway.app.data.repository.PlayerShardRepository
|
||||
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.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Drives the Notifications settings screen (PLAN.md §11, M7 Part 2 work item 6):
|
||||
* the stream catalog with per-stream toggles bound to
|
||||
* `GET/PUT /auth/me/notifications/subscriptions`. A **personal** stream is greyed
|
||||
* until the user has a linked game account (§11), and turning the opt-in set
|
||||
* non-empty/empty drives the [PushManager] to register/unregister the device.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class NotificationsViewModel @Inject constructor(
|
||||
private val notifications: NotificationsRepository,
|
||||
private val playerShard: PlayerShardRepository,
|
||||
private val pushManager: PushManager,
|
||||
) : ViewModel() {
|
||||
|
||||
data class Feedback(val ok: Boolean, @param:StringRes val messageRes: Int)
|
||||
|
||||
data class State(
|
||||
val catalog: UiState<List<NotificationStreamDto>> = UiState.Loading,
|
||||
val subscribed: Set<String> = emptySet(),
|
||||
/** Whether the user has ≥1 linked game account — personal streams need it. */
|
||||
val hasLinkedAccount: Boolean = false,
|
||||
/** Whether this shard advertises a push relay at all (else the screen says so). */
|
||||
val supported: Boolean = true,
|
||||
val busy: Boolean = false,
|
||||
val feedback: Feedback? = null,
|
||||
)
|
||||
|
||||
private val _state = MutableStateFlow(State())
|
||||
val state: StateFlow<State> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
pushManager.supported.collect { supported -> _state.update { it.copy(supported = supported) } }
|
||||
}
|
||||
load()
|
||||
}
|
||||
|
||||
fun load() {
|
||||
_state.update { it.copy(catalog = UiState.Loading) }
|
||||
viewModelScope.launch {
|
||||
val catalog = notifications.streams().let { result ->
|
||||
when (result) {
|
||||
is ApiResult.Ok -> ApiResult.Ok(result.data.streams)
|
||||
is ApiResult.HttpError -> result
|
||||
is ApiResult.NetworkError -> result
|
||||
}
|
||||
}
|
||||
_state.update { it.copy(catalog = catalog.toUiState()) }
|
||||
|
||||
when (val subs = notifications.subscriptions()) {
|
||||
is ApiResult.Ok -> _state.update { it.copy(subscribed = subs.data.streams.toSet()) }
|
||||
else -> Unit
|
||||
}
|
||||
// A linked game account gates the personal streams; failure → treat as none.
|
||||
val linked = (playerShard.accounts() as? ApiResult.Ok)?.data?.isNotEmpty() == true
|
||||
_state.update { it.copy(hasLinkedAccount = linked) }
|
||||
}
|
||||
}
|
||||
|
||||
fun clearFeedback() = _state.update { it.copy(feedback = null) }
|
||||
|
||||
/** Toggle [stream]; refuses a personal stream with no linked account. */
|
||||
fun setSubscribed(stream: NotificationStreamDto, on: Boolean) {
|
||||
val s = _state.value
|
||||
if (s.busy) return
|
||||
if (on && !streamSelectable(stream, s.hasLinkedAccount)) return
|
||||
val next = if (on) s.subscribed + stream.id else s.subscribed - stream.id
|
||||
|
||||
_state.update { it.copy(busy = true, feedback = null) }
|
||||
viewModelScope.launch {
|
||||
when (val result = notifications.setSubscriptions(next.toList())) {
|
||||
is ApiResult.Ok -> {
|
||||
val stored = result.data.streams.toSet()
|
||||
_state.update { it.copy(subscribed = stored) }
|
||||
reconcilePush(stored)
|
||||
}
|
||||
is ApiResult.NetworkError -> finish(false, R.string.error_network)
|
||||
is ApiResult.HttpError -> finish(false, R.string.notifications_save_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register or unregister the device to match the opted-in set (PLAN.md §11:
|
||||
* register when signed-in + subscribed, unregister when the set empties).
|
||||
*/
|
||||
private suspend fun reconcilePush(subscribed: Set<String>) {
|
||||
if (subscribed.isEmpty()) {
|
||||
pushManager.disable()
|
||||
finish(true, R.string.notifications_all_off)
|
||||
return
|
||||
}
|
||||
when (val res = pushManager.enable()) {
|
||||
is PushManager.PushResult.Enabled -> finish(true, R.string.notifications_saved)
|
||||
is PushManager.PushResult.Unsupported -> finish(false, R.string.notifications_unsupported)
|
||||
is PushManager.PushResult.NotSignedIn -> finish(false, R.string.notifications_save_error)
|
||||
is PushManager.PushResult.Failed ->
|
||||
finish(false, if (res.status == 400) R.string.notifications_relay_error else R.string.notifications_save_error)
|
||||
}
|
||||
}
|
||||
|
||||
private fun finish(ok: Boolean, @StringRes messageRes: Int) =
|
||||
_state.update { it.copy(busy = false, feedback = Feedback(ok, messageRes)) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a stream's toggle is selectable for a user: a personal stream needs a
|
||||
* linked game account (PLAN.md §11). Pure so the gating is unit-tested without Compose.
|
||||
*/
|
||||
fun streamSelectable(stream: NotificationStreamDto, hasLinkedAccount: Boolean): Boolean =
|
||||
!stream.requiresLinkedAccount || hasLinkedAccount
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
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 live player count on the drawer's Rust row — the phone's answer to D15
|
||||
* (`docs/modules/rust/PLAN.md` §17.4).
|
||||
*
|
||||
* ## Why the drawer and not a footer
|
||||
*
|
||||
* D15 put "2 servers · 42 online" in core's `site.footer.status` slot, which
|
||||
* exists because every page of the website renders the same footer. The app has
|
||||
* no footer and no slot; what it has is a drawer row per surface and, already, a
|
||||
* precedent for a number beside one — the inbox's unread badge, in the same
|
||||
* `NavigationDrawerItem` badge slot, with the same screen-reader treatment. So
|
||||
* the count rides there.
|
||||
*
|
||||
* **The number is players, not servers.** A badge is one integer, and of the two
|
||||
* halves of D15's line the live one is how many people are on: a server count
|
||||
* changes when an operator edits configuration, which is not news, and is visible
|
||||
* on the page the row opens anyway.
|
||||
*
|
||||
* ## What keeps it honest
|
||||
*
|
||||
* The website's version renders nothing until it has an answer, nothing at all if
|
||||
* the request fails, and never polls — because one request per page view is a
|
||||
* cost and a timer in a footer on every page is a different kind of thing. All
|
||||
* three rules hold here:
|
||||
*
|
||||
* - **Zero renders nothing.** No badge, rather than a `0` — an empty server is
|
||||
* not a notification.
|
||||
* - **A failure leaves the last count** rather than dropping to zero. A moment
|
||||
* with no connectivity is not everybody logging off.
|
||||
* - **It refreshes on resume, with the unread badge**, and never on a timer. The
|
||||
* count is a glance, not a feed.
|
||||
*
|
||||
* It is asked for **only when the module is installed** — the caller gates on the
|
||||
* `rust` capability — so a site running a different game makes no request at all.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RustBadgeViewModel @Inject constructor(
|
||||
private val repository: RustRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _online = MutableStateFlow(0)
|
||||
|
||||
/** How many people are on across every server, or 0 when there is nothing to say. */
|
||||
val online: StateFlow<Int> = _online.asStateFlow()
|
||||
|
||||
/**
|
||||
* Ask, if the Rust module is there.
|
||||
*
|
||||
* [installed] is passed in rather than read here so this holds no opinion
|
||||
* about capabilities: the drawer already knows, and a view model that
|
||||
* re-derived it would be a second copy of a rule that lives in one place.
|
||||
* Absent — the host has not answered yet — makes no request and keeps
|
||||
* whatever is showing.
|
||||
*/
|
||||
fun refresh(installed: Boolean) {
|
||||
if (!installed) {
|
||||
_online.value = 0
|
||||
return
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
when (val result = repository.servers()) {
|
||||
// A server that is stale or unreachable already answers `online:
|
||||
// false` with `players: 0`, so summing the whole list needs no
|
||||
// second staleness rule here.
|
||||
is ApiResult.Ok -> _online.value = result.data.sumOf { it.players }
|
||||
// Keep the last number. See the class doc.
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
199
app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt
Normal file
199
app/src/main/java/com/runicgateway/app/ui/rust/RustFeed.kt
Normal file
@@ -0,0 +1,199 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
|
||||
/**
|
||||
* One stored frame as one line of a feed — the Kotlin half of `module-rust`'s
|
||||
* `client/src/lib/feed.js` (PLAN.md §9 M14).
|
||||
*
|
||||
* `GET /public/rust/servers/{id}/events` answers rows shaped
|
||||
* `{ id, kind, t, wipeId, steamId, frame }`, where `frame` is the whole frame the
|
||||
* bridge plugin emitted. Everything a killfeed line needs is in there, under the
|
||||
* names the plugin wrote, and **this file is the one place in the app that knows
|
||||
* them**.
|
||||
*
|
||||
* ## It returns parts, not a sentence
|
||||
*
|
||||
* A row wants the names emphasised and the detail muted, and a function returning
|
||||
* `"Alice killed Bob"` would force the screen to re-parse its own output to style
|
||||
* it. Parts also make this testable without Compose, which is the only way this
|
||||
* leg has real coverage of what a feed row says.
|
||||
*
|
||||
* ## The rule for an unknown kind
|
||||
*
|
||||
* **It renders as itself.** A later protocol adds kinds and an operator's module
|
||||
* may be older than their game host, so a feed that dropped what it did not
|
||||
* recognise would be a screen quietly saying less than the truth. The server's
|
||||
* allowlist has already decided the row may be seen; what is left here is
|
||||
* presentation, and the honest presentation of a kind we have no words for is its
|
||||
* own name.
|
||||
*/
|
||||
|
||||
/** The row's category, for the small colour a screen gives it — never for meaning. */
|
||||
enum class FeedTone { KILL, DEATH, JOIN, LEAVE, CHAT, SERVER, OTHER }
|
||||
|
||||
/**
|
||||
* One row, ready to render.
|
||||
*
|
||||
* [actor] and [subject] are names and are emphasised; [verb] and [detail] are
|
||||
* prose. Any of them may be null or empty.
|
||||
*
|
||||
* [join] is what goes between the actor and the verb, and it exists for exactly
|
||||
* one case: chat. "Brannock see you in september" is not a sentence anybody
|
||||
* writes, and putting the colon in the message would put presentation inside text
|
||||
* a player typed.
|
||||
*/
|
||||
data class FeedLine(
|
||||
val tone: FeedTone,
|
||||
val actor: String? = null,
|
||||
val join: String = " ",
|
||||
val verb: String = "",
|
||||
val subject: String? = null,
|
||||
val detail: String = "",
|
||||
)
|
||||
|
||||
/** One filter the feed offers, and the kinds it asks the API for. */
|
||||
data class FeedFilter(val id: String, val label: String, val kinds: List<String>)
|
||||
|
||||
/**
|
||||
* Kinds this feed asks for.
|
||||
*
|
||||
* `player.tally` is public and deliberately **not** here: it is an aggregate the
|
||||
* plugin flushes every sixty seconds per active player, so a feed including it
|
||||
* would be mostly wood counts. It is the leaderboard's input, and the leaderboard
|
||||
* is where it shows up.
|
||||
*/
|
||||
val FEED_KINDS: List<String> = listOf(
|
||||
"player.death",
|
||||
"player.connected",
|
||||
"player.disconnected",
|
||||
"player.respawned",
|
||||
"player.chat",
|
||||
"server.wipe",
|
||||
"server.initialized",
|
||||
"server.shutdown",
|
||||
)
|
||||
|
||||
/** The filters the feed offers. The first is the default and asks for everything. */
|
||||
val FEED_FILTERS: List<FeedFilter> = listOf(
|
||||
FeedFilter("all", "Everything", FEED_KINDS),
|
||||
FeedFilter("kills", "Kills", listOf("player.death")),
|
||||
FeedFilter("chat", "Chat", listOf("player.chat")),
|
||||
FeedFilter(
|
||||
"sessions",
|
||||
"Comings and goings",
|
||||
listOf("player.connected", "player.disconnected", "player.respawned"),
|
||||
),
|
||||
FeedFilter("server", "Server", listOf("server.wipe", "server.initialized", "server.shutdown")),
|
||||
)
|
||||
|
||||
/** The kinds a filter id asks for; an id nobody offers falls back to everything. */
|
||||
fun kindsFor(filterId: String): List<String> =
|
||||
(FEED_FILTERS.firstOrNull { it.id == filterId } ?: FEED_FILTERS.first()).kinds
|
||||
|
||||
/** One row as the parts a screen renders. */
|
||||
fun describe(row: RustEventDto): FeedLine {
|
||||
val name = row.str("name")
|
||||
|
||||
return when (row.kind) {
|
||||
"player.death" -> death(row, name)
|
||||
|
||||
"player.connected" ->
|
||||
FeedLine(FeedTone.JOIN, actor = name, verb = "connected")
|
||||
|
||||
"player.disconnected" -> FeedLine(
|
||||
tone = FeedTone.LEAVE,
|
||||
actor = name,
|
||||
verb = "disconnected",
|
||||
// Two optional halves, and the session is the interesting one. The
|
||||
// plugin OMITS `sessionSec` for a player who was already on when it
|
||||
// loaded, so an absent value means "unknown" and never zero — which is
|
||||
// why this reads the parsed number rather than trusting a default.
|
||||
detail = listOfNotNull(
|
||||
row.str("reason"),
|
||||
row.num("sessionSec")?.takeIf { it > 0 }?.let { "after ${playtime(it.toLong())}" },
|
||||
).joinToString(" · "),
|
||||
)
|
||||
|
||||
"player.respawned" ->
|
||||
FeedLine(FeedTone.JOIN, actor = name, verb = "respawned")
|
||||
|
||||
"player.chat" -> FeedLine(
|
||||
tone = FeedTone.CHAT,
|
||||
actor = name,
|
||||
join = ": ",
|
||||
// The message is the row, so it goes in `verb` where a screen renders
|
||||
// it unemphasised — and it is the one field on this wire whose bytes a
|
||||
// player chooses. Compose renders it as text and never as markup;
|
||||
// nothing here may ever stop doing that.
|
||||
verb = row.str("message").orEmpty(),
|
||||
detail = row.str("channel")?.takeIf { it != "Global" }.orEmpty(),
|
||||
)
|
||||
|
||||
"server.wipe" -> FeedLine(
|
||||
tone = FeedTone.SERVER,
|
||||
verb = "The map was wiped",
|
||||
detail = row.str("wipeId")?.let { "new wipe $it" }.orEmpty(),
|
||||
)
|
||||
|
||||
"server.initialized" -> FeedLine(FeedTone.SERVER, verb = "The server came up")
|
||||
|
||||
"server.shutdown" -> FeedLine(FeedTone.SERVER, verb = "The server went down")
|
||||
|
||||
else -> FeedLine(
|
||||
tone = FeedTone.OTHER,
|
||||
actor = name,
|
||||
verb = row.kind.takeIf { it.isNotBlank() } ?: "unknown",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A death, which is four different sentences.
|
||||
*
|
||||
* The plugin distinguishes `player`, `self`, `npc` and `environment` precisely so
|
||||
* a reader does not have to guess from an absent field, and collapsing any two of
|
||||
* them loses something. A killfeed reporting a fall as a kill by nobody is the
|
||||
* failure this avoids.
|
||||
*/
|
||||
private fun death(row: RustEventDto, name: String?): FeedLine {
|
||||
val where = listOfNotNull(
|
||||
row.str("weapon")?.let { "with ${prefabName(it)}" },
|
||||
row.num("distance")?.let { "${Math.round(it)}m" },
|
||||
row.str("grid"),
|
||||
if (row.flag("sleeping")) "while sleeping" else null,
|
||||
).joinToString(" · ")
|
||||
|
||||
return when (row.str("attackerType")) {
|
||||
"player" -> FeedLine(
|
||||
tone = FeedTone.KILL,
|
||||
actor = row.str("attackerName"),
|
||||
verb = "killed",
|
||||
subject = name,
|
||||
detail = where,
|
||||
)
|
||||
|
||||
"self" -> FeedLine(
|
||||
tone = FeedTone.DEATH,
|
||||
actor = name,
|
||||
verb = "died by their own hand",
|
||||
detail = where,
|
||||
)
|
||||
|
||||
"npc" -> FeedLine(
|
||||
tone = FeedTone.DEATH,
|
||||
actor = prefabName(row.str("attackerName")).ifBlank { "Something" },
|
||||
verb = "killed",
|
||||
subject = name,
|
||||
detail = where,
|
||||
)
|
||||
|
||||
// `environment` and anything else: falling, drowning, the world. The
|
||||
// plugin legitimately has no attacker on this path, so an ABSENT type is
|
||||
// this case rather than a missing field to complain about.
|
||||
else -> FeedLine(tone = FeedTone.DEATH, actor = name, verb = "died", detail = where)
|
||||
}
|
||||
}
|
||||
154
app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt
Normal file
154
app/src/main/java/com/runicgateway/app/ui/rust/RustFormat.kt
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* Formatting for the Rust screens — pure, no Compose, no Android (PLAN.md §9
|
||||
* M14).
|
||||
*
|
||||
* The Kotlin half of `module-rust`'s `client/src/lib/format.js`, and it is a
|
||||
* deliberate second implementation rather than something shared: the two clients
|
||||
* have different formatting libraries under them (`Intl` there, `java.time`
|
||||
* here), and the thing worth keeping identical is the **rules**, not the code.
|
||||
* Those rules are restated here beside each function so a reader can check them
|
||||
* against the website without opening it.
|
||||
*
|
||||
* Everything takes `now` as a parameter, so a boundary is testable rather than a
|
||||
* property of the machine the test runs on.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The stamp on a feed row.
|
||||
*
|
||||
* **Today's rows get a time; everything older gets a date as well.** The feed can
|
||||
* be filtered to a past wipe, and a row from six weeks ago rendered as `14:03`
|
||||
* reads as this afternoon — which is exactly what the website's own page walk
|
||||
* found, three events from August all apparently a few minutes old. The boundary
|
||||
* is the **calendar day**, not a duration, because that is what a reader means by
|
||||
* "what time was that".
|
||||
*/
|
||||
fun feedClock(
|
||||
value: String?,
|
||||
epochMillis: Long? = null,
|
||||
now: Instant = Instant.now(),
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String {
|
||||
val at = epochMillis?.takeIf { it > 0 }?.let(Instant::ofEpochMilli) ?: parseWireInstant(value) ?: return ""
|
||||
|
||||
val time = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
|
||||
.withLocale(locale)
|
||||
.format(at.atZone(zone))
|
||||
|
||||
val sameDay = at.atZone(zone).toLocalDate() == now.atZone(zone).toLocalDate()
|
||||
if (sameDay) return time
|
||||
|
||||
val date = DateTimeFormatter.ofPattern("d MMM", locale).format(at.atZone(zone))
|
||||
return "$date $time"
|
||||
}
|
||||
|
||||
/** A date, for a wipe: the thing people actually compare wipes by. */
|
||||
fun wipeDay(
|
||||
value: String?,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
locale: Locale = Locale.getDefault(),
|
||||
): String? {
|
||||
val at = parseWireInstant(value) ?: return null
|
||||
return DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
|
||||
.withLocale(locale)
|
||||
.format(at.atZone(zone))
|
||||
}
|
||||
|
||||
/**
|
||||
* "3 minutes ago", for a "last reported" line.
|
||||
*
|
||||
* Returns null rather than a word for an absent stamp, so the caller decides what
|
||||
* "never" looks like in its own layout — on this surface a server that has never
|
||||
* reported is a real and ordinary state, not a missing value to apologise for.
|
||||
*/
|
||||
fun rustAgo(value: String?, now: Instant = Instant.now()): String? {
|
||||
val at = parseWireInstant(value) ?: return null
|
||||
val seconds = java.time.Duration.between(at, now).seconds
|
||||
|
||||
// Under a minute in either direction, say the thing rather than "in 0 seconds".
|
||||
if (kotlin.math.abs(seconds) < 45) return "just now"
|
||||
|
||||
val future = seconds < 0
|
||||
val magnitude = kotlin.math.abs(seconds)
|
||||
val (unit, size) = AGO_UNITS.first { magnitude >= it.second }
|
||||
val amount = Math.round(magnitude.toDouble() / size)
|
||||
val plural = if (amount == 1L) unit else "${unit}s"
|
||||
|
||||
return if (future) "in $amount $plural" else "$amount $plural ago"
|
||||
}
|
||||
|
||||
private val AGO_UNITS = listOf(
|
||||
"year" to 31_536_000L,
|
||||
"month" to 2_592_000L,
|
||||
"week" to 604_800L,
|
||||
"day" to 86_400L,
|
||||
"hour" to 3_600L,
|
||||
"minute" to 60L,
|
||||
"second" to 1L,
|
||||
)
|
||||
|
||||
/**
|
||||
* A session or a playtime, as `4h 12m`.
|
||||
*
|
||||
* Seconds are dropped above a minute and kept below it: a two-hour session
|
||||
* reported to the second is noise, and a forty-second one reported as "0m" is
|
||||
* wrong.
|
||||
*/
|
||||
fun playtime(seconds: Long?): String {
|
||||
val total = seconds ?: return "—"
|
||||
if (total <= 0) return "—"
|
||||
if (total < 60) return "${total}s"
|
||||
|
||||
val hours = total / 3600
|
||||
val minutes = Math.round((total % 3600) / 60.0)
|
||||
|
||||
return when {
|
||||
hours == 0L -> "${minutes}m"
|
||||
minutes == 0L -> "${hours}h"
|
||||
else -> "${hours}h ${minutes}m"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A prefab short name as something readable — `patrolhelicopter` stays itself,
|
||||
* `rifle.ak` becomes `rifle ak`.
|
||||
*
|
||||
* Deliberately a light touch rather than a lookup table: a table mapping every
|
||||
* Rust prefab to a pretty name is a second copy of the game's item list that goes
|
||||
* stale every wipe, and the short name is what a Rust player reads on their own
|
||||
* server console anyway.
|
||||
*/
|
||||
fun prefabName(name: String?): String {
|
||||
if (name.isNullOrBlank()) return ""
|
||||
return name.replace(Regex("[_.]+"), " ").trim()
|
||||
}
|
||||
|
||||
/** A steam id, shortened for a table cell, without pretending it is a name. */
|
||||
fun shortSteamId(steamId: String?): String {
|
||||
val id = steamId.orEmpty()
|
||||
return if (id.length > 10) "…${id.takeLast(6)}" else id
|
||||
}
|
||||
|
||||
/**
|
||||
* What to call a player who has no name yet.
|
||||
*
|
||||
* The presence board and the leaderboard both carry a nullable `name`: the plugin
|
||||
* knows a steam id before it knows anything else. Showing a shortened id is
|
||||
* honest — it is not a name and does not look like one — where "Unknown" would
|
||||
* lose the only identifier there is.
|
||||
*/
|
||||
fun playerLabel(name: String?, steamId: String?): String =
|
||||
name?.takeIf { it.isNotBlank() } ?: shortSteamId(steamId)
|
||||
@@ -0,0 +1,566 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ScrollableTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
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.draw.alpha
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
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.RustEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.ui.PollWhileResumed
|
||||
import com.runicgateway.app.ui.Polled
|
||||
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.SectionLabel
|
||||
import com.runicgateway.app.ui.components.ShardCard
|
||||
import com.runicgateway.app.ui.components.StatusPill
|
||||
|
||||
/**
|
||||
* One Rust server: the feed, the leaderboard, who is on, and the wipes (D13).
|
||||
*
|
||||
* **One screen with tabs, not four destinations** — the same call the website
|
||||
* makes, and more obviously right on a phone: the four panels are four questions
|
||||
* about one thing, and a reader moving between them is not navigating.
|
||||
*
|
||||
* The phase criterion lives here. With the server unreachable this still renders
|
||||
* its map, size, seed, wipe date, killfeed, leaderboards, last known presence
|
||||
* board and wipe history, because every one of those is read from the website's
|
||||
* own tables rather than from the game.
|
||||
*/
|
||||
@Composable
|
||||
fun RustServerScreen(
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RustServerViewModel = hiltViewModel(),
|
||||
) {
|
||||
val ui by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
PollWhileResumed { viewModel.refresh() }
|
||||
|
||||
when (val s = ui.server.state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
|
||||
// **A mistyped address is not a fault and must not be dressed as one.**
|
||||
// The website's first version put its generic error panel under this
|
||||
// heading, so an unknown id read "No such server / Something went wrong"
|
||||
// and sent a reader looking for an outage. A 404 is its own answer; the
|
||||
// error panel is kept for a request that failed for a reason nobody can
|
||||
// see. A server an operator disabled answers the same 404 — switching one
|
||||
// off is not switching it into a refusal.
|
||||
is UiState.Error -> if (s.kind == ErrorKind.NOT_FOUND) {
|
||||
MissingServer(onBack, modifier)
|
||||
} else {
|
||||
ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
}
|
||||
|
||||
is UiState.Success -> ServerDetail(s.data, ui, viewModel, modifier)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MissingServer(onBack: () -> Unit, modifier: Modifier = Modifier) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxSize().padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(stringResource(R.string.rust_no_such_server), style = MaterialTheme.typography.titleLarge)
|
||||
Text(
|
||||
text = stringResource(R.string.rust_no_such_server_detail),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.rust_back_to_servers),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.clickable(onClick = onBack).padding(top = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerDetail(
|
||||
server: RustServerDto,
|
||||
ui: RustServerUi,
|
||||
viewModel: RustServerViewModel,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Column(modifier.fillMaxSize()) {
|
||||
ServerHeader(server, ui.selectedWipe, viewModel::selectWipe, ui.wipes)
|
||||
|
||||
val tabs = RustTab.entries
|
||||
ScrollableTabRow(selectedTabIndex = tabs.indexOf(ui.tab), edgePadding = 16.dp) {
|
||||
tabs.forEach { tab ->
|
||||
Tab(
|
||||
selected = tab == ui.tab,
|
||||
onClick = { viewModel.selectTab(tab) },
|
||||
text = { Text(stringResource(tabLabel(tab))) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (ui.tab) {
|
||||
RustTab.FEED -> FeedPanel(ui.feed, ui.filterId, viewModel::selectFilter, viewModel::retryFeed)
|
||||
RustTab.LEADERBOARD -> LeaderboardPanel(
|
||||
ui.leaderboard,
|
||||
ui.sort,
|
||||
viewModel::selectSort,
|
||||
viewModel::retryLeaderboard,
|
||||
)
|
||||
RustTab.ONLINE -> OnlinePanel(ui.online, server.online, viewModel::retryOnline)
|
||||
RustTab.WIPES -> WipesPanel(
|
||||
ui.wipes,
|
||||
server.wipeId,
|
||||
ui.selectedWipe,
|
||||
viewModel::openWipe,
|
||||
viewModel::retryWipes,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tabLabel(tab: RustTab): Int = when (tab) {
|
||||
RustTab.FEED -> R.string.rust_tab_feed
|
||||
RustTab.LEADERBOARD -> R.string.rust_tab_leaderboard
|
||||
RustTab.ONLINE -> R.string.rust_tab_online
|
||||
RustTab.WIPES -> R.string.rust_tab_wipes
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerHeader(
|
||||
server: RustServerDto,
|
||||
selectedWipe: String?,
|
||||
onSelectWipe: (String?) -> Unit,
|
||||
wipes: UiState<List<RustWipeDto>>,
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
|
||||
Text(server.name.ifBlank { server.id }, style = MaterialTheme.typography.headlineSmall)
|
||||
|
||||
describeWorld(server)?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
if (server.online) {
|
||||
StatusPill(
|
||||
text = stringResource(R.string.rust_online_count, server.players, server.maxPlayers),
|
||||
tone = PillTone.Success,
|
||||
)
|
||||
} else {
|
||||
StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral)
|
||||
}
|
||||
Text(
|
||||
text = lastReported(server),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
// The wipe picker sits above the tabs because it filters two of them. It
|
||||
// is absent until the wipe list has loaded — offering a filter with one
|
||||
// option would look like a server that has only ever had one wipe.
|
||||
val available = (wipes as? UiState.Success)?.data.orEmpty()
|
||||
if (available.isNotEmpty()) {
|
||||
WipeFilter(available, server.wipeId, selectedWipe, onSelectWipe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WipeFilter(
|
||||
wipes: List<RustWipeDto>,
|
||||
currentWipeId: String?,
|
||||
selected: String?,
|
||||
onSelect: (String?) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(top = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// **Null is all time, and it is the default.** It is a real choice rather
|
||||
// than an absent filter: all-time is the per-wipe rows summed, which is
|
||||
// the answer to "who plays here", where a wipe is the answer to "who is
|
||||
// winning now".
|
||||
FilterChip(
|
||||
selected = selected == null,
|
||||
onClick = { onSelect(null) },
|
||||
label = { Text(stringResource(R.string.rust_all_time)) },
|
||||
)
|
||||
wipes.forEach { wipe ->
|
||||
val label = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId
|
||||
FilterChip(
|
||||
selected = selected == wipe.wipeId,
|
||||
onClick = { onSelect(wipe.wipeId) },
|
||||
label = {
|
||||
Text(
|
||||
if (wipe.wipeId == currentWipeId) {
|
||||
stringResource(R.string.rust_wipe_current, label)
|
||||
} else {
|
||||
label
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Feed ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun FeedPanel(
|
||||
feed: Polled<List<RustEventDto>>,
|
||||
filterId: String,
|
||||
onFilter: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
FEED_FILTERS.forEach { filter ->
|
||||
FilterChip(
|
||||
selected = filter.id == filterId,
|
||||
onClick = { onFilter(filter.id) },
|
||||
label = { Text(filter.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
when (val s = feed.state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
||||
is UiState.Success -> if (s.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.rust_feed_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
if (feed.refreshFailed) {
|
||||
item { RefreshFailedLine() }
|
||||
}
|
||||
items(s.data, key = { it.id }) { FeedRow(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeedRow(row: RustEventDto) {
|
||||
val line = describe(row)
|
||||
|
||||
Row(verticalAlignment = Alignment.Top) {
|
||||
// The stamp carries a date for anything not from today — a row from six
|
||||
// weeks ago rendered as a bare time reads as this afternoon, which is
|
||||
// exactly what happens the moment the feed is filtered to a past wipe.
|
||||
Text(
|
||||
text = feedClock(value = null, epochMillis = row.t),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(end = 10.dp, top = 2.dp),
|
||||
)
|
||||
Column {
|
||||
Row {
|
||||
line.actor?.let {
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||
Text(line.join, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
// The chat message lands here, and it is the one field on this
|
||||
// wire whose bytes a player chooses. Compose renders it as text
|
||||
// and never as markup; nothing here may ever stop doing that.
|
||||
Text(line.verb, style = MaterialTheme.typography.bodyMedium)
|
||||
line.subject?.let {
|
||||
Text(" ", style = MaterialTheme.typography.bodyMedium)
|
||||
Text(it, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
||||
}
|
||||
}
|
||||
if (line.detail.isNotBlank()) {
|
||||
Text(
|
||||
text = line.detail,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Leaderboard ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* The columns, and which of them the API can sort by.
|
||||
*
|
||||
* `structures` has no sort on the wire and therefore no tap here — a header that
|
||||
* sorts by something other than what it says is worse than one that does not
|
||||
* sort.
|
||||
*/
|
||||
private data class RustColumn(val labelRes: Int, val sort: String?, val value: (RustLeaderboardRowDto) -> String)
|
||||
|
||||
/** The name's share of the row against one numeric column's. */
|
||||
private const val NAME_WEIGHT = 1.7f
|
||||
|
||||
/** How far a header that is not the current sort is faded. */
|
||||
private const val SORTED_AWAY = 0.55f
|
||||
|
||||
private val RUST_COLUMNS = listOf(
|
||||
RustColumn(R.string.rust_col_kills, RustSort.KILLS) { it.kills.toString() },
|
||||
RustColumn(R.string.rust_col_deaths, RustSort.DEATHS) { it.deaths.toString() },
|
||||
RustColumn(R.string.rust_col_npc_kills, RustSort.NPC_KILLS) { it.npcKills.toString() },
|
||||
RustColumn(R.string.rust_col_structures, null) { it.structures.toString() },
|
||||
RustColumn(R.string.rust_col_played, RustSort.PLAYTIME) { playtime(it.playtimeSec) },
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun LeaderboardPanel(
|
||||
state: UiState<List<RustLeaderboardRowDto>>,
|
||||
sort: String,
|
||||
onSort: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||
is UiState.Success -> if (state.data.isEmpty()) {
|
||||
// **Empty is a real answer here and is not "no data".** All-time is
|
||||
// the per-wipe rows summed, so a player who appears only in an older
|
||||
// wipe drops out of the current one rather than reading zero — an
|
||||
// empty board for a wipe means nobody scored on that map.
|
||||
EmptyView(stringResource(R.string.rust_leaderboard_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
item {
|
||||
Row(Modifier.fillMaxWidth()) {
|
||||
SectionLabel(
|
||||
text = stringResource(R.string.rust_col_player),
|
||||
modifier = Modifier.weight(NAME_WEIGHT),
|
||||
)
|
||||
RUST_COLUMNS.forEach { column ->
|
||||
// The ACTIVE sort is marked on the header, not on the
|
||||
// values: the header is the control, and tinting a
|
||||
// column of numbers instead says "these are special"
|
||||
// rather than "this is what the table is ordered by".
|
||||
SectionLabel(
|
||||
text = stringResource(column.labelRes),
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.then(
|
||||
if (column.sort != null) {
|
||||
Modifier.clickable { onSort(column.sort) }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.then(
|
||||
if (column.sort == sort) {
|
||||
Modifier.alpha(1f)
|
||||
} else {
|
||||
Modifier.alpha(SORTED_AWAY)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
items(state.data, key = { it.steamId }) { row ->
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
// A wider share for the name, and one line with an ellipsis.
|
||||
// Five numeric columns beside an equal-weight name column
|
||||
// left "Brannock" touching its own kill count, which the
|
||||
// walk read as one field.
|
||||
Text(
|
||||
text = playerLabel(row.name, row.steamId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(NAME_WEIGHT).padding(end = 8.dp),
|
||||
)
|
||||
RUST_COLUMNS.forEach { column ->
|
||||
Text(
|
||||
text = column.value(row),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Online ────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun OnlinePanel(
|
||||
online: Polled<List<RustPresenceDto>>,
|
||||
serverOnline: Boolean,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (val s = online.state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = onRetry)
|
||||
is UiState.Success -> if (s.data.isEmpty()) {
|
||||
EmptyView(
|
||||
stringResource(
|
||||
if (serverOnline) R.string.rust_nobody_on else R.string.rust_presence_offline,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// **The board is the last one that ARRIVED, and an unreachable
|
||||
// server does not clear it** — deliberately, because these rows
|
||||
// are still the best answer anybody has. Presented bare they read
|
||||
// as "these people are on right now", which is the one thing an
|
||||
// offline server cannot be saying. So the panel says which it is.
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(
|
||||
if (serverOnline) R.string.rust_presence_live else R.string.rust_presence_last_known,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (online.refreshFailed) {
|
||||
item { RefreshFailedLine() }
|
||||
}
|
||||
items(s.data, key = { it.steamId }) { player ->
|
||||
ShardCard(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = playerLabel(player.name, player.steamId),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (player.sleeping) {
|
||||
StatusPill(
|
||||
text = stringResource(R.string.rust_sleeping),
|
||||
tone = PillTone.Neutral,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Wipes ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun WipesPanel(
|
||||
state: UiState<List<RustWipeDto>>,
|
||||
currentWipeId: String?,
|
||||
selected: String?,
|
||||
onOpenWipe: (String) -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
when (state) {
|
||||
is UiState.Loading -> LoadingView()
|
||||
is UiState.Error -> ErrorView(state.kind, onRetry = onRetry)
|
||||
is UiState.Success -> if (state.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.rust_wipes_empty))
|
||||
} else {
|
||||
LazyColumn(
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(state.data, key = { it.wipeId }) { wipe ->
|
||||
ShardCard(
|
||||
Modifier.fillMaxWidth().clickable { onOpenWipe(wipe.wipeId) },
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = wipeDay(wipe.saveCreatedAt ?: wipe.firstSeen) ?: wipe.wipeId,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (wipe.wipeId == currentWipeId) {
|
||||
StatusPill(
|
||||
text = stringResource(R.string.rust_wipe_this_one),
|
||||
tone = PillTone.Success,
|
||||
)
|
||||
} else if (wipe.wipeId == selected) {
|
||||
StatusPill(
|
||||
text = stringResource(R.string.rust_wipe_selected),
|
||||
tone = PillTone.Info,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RefreshFailedLine() {
|
||||
Text(
|
||||
text = stringResource(R.string.rust_refresh_failed),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.ui.Polled
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import com.runicgateway.app.ui.refreshInto
|
||||
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.update
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
/** The four sections of a server's page (D13). */
|
||||
enum class RustTab { FEED, LEADERBOARD, ONLINE, WIPES }
|
||||
|
||||
/** What a leaderboard column sorts by — the API's own vocabulary, not the app's. */
|
||||
object RustSort {
|
||||
const val KILLS = "kills"
|
||||
const val DEATHS = "deaths"
|
||||
const val NPC_KILLS = "npcKills"
|
||||
const val PLAYTIME = "playtime"
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything one server's page is showing.
|
||||
*
|
||||
* One state object rather than eight flows: every panel on the page is about the
|
||||
* same server and the same selected wipe, and a screen that collected them
|
||||
* separately could render a leaderboard for one wipe beside a feed for another
|
||||
* for a frame.
|
||||
*/
|
||||
data class RustServerUi(
|
||||
val serverId: String = "",
|
||||
val server: Polled<RustServerDto> = Polled(),
|
||||
val tab: RustTab = RustTab.FEED,
|
||||
val filterId: String = "all",
|
||||
val sort: String = RustSort.KILLS,
|
||||
/** The wipe every panel is filtered to. **Null is all time**, not "unknown". */
|
||||
val selectedWipe: String? = null,
|
||||
val feed: Polled<List<RustEventDto>> = Polled(),
|
||||
val online: Polled<List<RustPresenceDto>> = Polled(),
|
||||
val leaderboard: UiState<List<RustLeaderboardRowDto>> = UiState.Loading,
|
||||
val wipes: UiState<List<RustWipeDto>> = UiState.Loading,
|
||||
)
|
||||
|
||||
/**
|
||||
* One Rust server (PLAN.md §9 M14; `docs/modules/rust/PLAN.md` D13, D14).
|
||||
*
|
||||
* ## What polls and what does not
|
||||
*
|
||||
* D14, and it is a statement about the questions rather than about cost: the
|
||||
* **feed**, **who is on** and the **server's own line** change while somebody is
|
||||
* looking at the page, and the **leaderboard** and the **wipe list** do not in
|
||||
* any way a reader would want to watch. A leaderboard that re-sorted itself under
|
||||
* a finger every twenty seconds would be worse than a stale one.
|
||||
*
|
||||
* Only the **visible** live panel is polled. The website can afford to mount the
|
||||
* one tab it is showing; here the tabs are one screen, so the refresh asks what
|
||||
* the reader is actually looking at.
|
||||
*
|
||||
* ## Changing the question versus asking it again
|
||||
*
|
||||
* A poll is the same question asked again, so it keeps what is on screen
|
||||
* ([refreshInto]). Changing the filter, the sort or the wipe is a **different
|
||||
* question**, so the panel blanks and loads — what is there is an answer to
|
||||
* something the reader has stopped asking, and leaving it up while the new one
|
||||
* arrives would show a killfeed for last wipe under a heading naming this one.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RustServerViewModel @Inject constructor(
|
||||
private val repository: RustRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
|
||||
private val serverId: String = savedStateHandle[Routes.Args.SERVER_ID] ?: ""
|
||||
|
||||
private val _state = MutableStateFlow(RustServerUi(serverId = serverId))
|
||||
val state: StateFlow<RustServerUi> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
load()
|
||||
}
|
||||
|
||||
/** A first load or a retry of the whole page. */
|
||||
fun load() {
|
||||
_state.update { it.copy(server = Polled(UiState.Loading), feed = Polled(UiState.Loading)) }
|
||||
viewModelScope.launch {
|
||||
askServer()
|
||||
askFeed()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The poll tick.
|
||||
*
|
||||
* The server line always, and then whichever live panel is on screen. A tab
|
||||
* showing the leaderboard or the wipes does no extra work — the reader is
|
||||
* looking at something that does not move.
|
||||
*/
|
||||
fun refresh() {
|
||||
viewModelScope.launch {
|
||||
askServer()
|
||||
when (_state.value.tab) {
|
||||
RustTab.FEED -> askFeed()
|
||||
RustTab.ONLINE -> askOnline()
|
||||
RustTab.LEADERBOARD, RustTab.WIPES -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a tab, loading its panel the first time it is opened.
|
||||
*
|
||||
* The two that do not poll are loaded exactly once per question: re-asking on
|
||||
* every tab switch would put a spinner over a leaderboard the reader has
|
||||
* already read, for an answer that cannot have changed while they were three
|
||||
* taps away.
|
||||
*/
|
||||
fun selectTab(tab: RustTab) {
|
||||
val already = _state.value
|
||||
_state.update { it.copy(tab = tab) }
|
||||
|
||||
viewModelScope.launch {
|
||||
when (tab) {
|
||||
RustTab.FEED -> if (already.feed.state !is UiState.Success) askFeed()
|
||||
RustTab.ONLINE -> if (already.online.state !is UiState.Success) askOnline()
|
||||
RustTab.LEADERBOARD -> if (already.leaderboard !is UiState.Success) askLeaderboard()
|
||||
RustTab.WIPES -> if (already.wipes !is UiState.Success) askWipes()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A different question for the feed: blank it and ask. */
|
||||
fun selectFilter(filterId: String) {
|
||||
if (filterId == _state.value.filterId) return
|
||||
_state.update { it.copy(filterId = filterId, feed = Polled(UiState.Loading)) }
|
||||
viewModelScope.launch { askFeed() }
|
||||
}
|
||||
|
||||
/** A different question for the leaderboard: blank it and ask. */
|
||||
fun selectSort(sort: String) {
|
||||
if (sort == _state.value.sort) return
|
||||
_state.update { it.copy(sort = sort, leaderboard = UiState.Loading) }
|
||||
viewModelScope.launch { askLeaderboard() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a wipe, or all time with null.
|
||||
*
|
||||
* It is the one selection that changes **two** panels, so both are blanked —
|
||||
* and only the loaded ones are re-asked, so choosing a wipe from the Wipes tab
|
||||
* does not fetch a leaderboard nobody has opened.
|
||||
*/
|
||||
fun selectWipe(wipeId: String?) {
|
||||
if (wipeId == _state.value.selectedWipe) return
|
||||
|
||||
val hadLeaderboard = _state.value.leaderboard is UiState.Success
|
||||
_state.update {
|
||||
it.copy(
|
||||
selectedWipe = wipeId,
|
||||
feed = Polled(UiState.Loading),
|
||||
leaderboard = if (hadLeaderboard) UiState.Loading else it.leaderboard,
|
||||
)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
askFeed()
|
||||
if (hadLeaderboard) askLeaderboard()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick a wipe from the Wipes tab, which is a navigation as much as a filter.
|
||||
*
|
||||
* The question it asks is "what happened during that map", and the answer is
|
||||
* the feed — so it lands there rather than leaving the reader on a list of
|
||||
* dates with nothing visibly changed.
|
||||
*/
|
||||
fun openWipe(wipeId: String) {
|
||||
selectWipe(wipeId)
|
||||
selectTab(RustTab.FEED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Retry one panel after its own load failed.
|
||||
*
|
||||
* Four entry points rather than one, because a failed leaderboard is not a
|
||||
* reason to re-read the feed the reader can already see — and [load] is the
|
||||
* whole page, which is right for a failed *server* read and heavy-handed for
|
||||
* anything else.
|
||||
*/
|
||||
fun retryFeed() {
|
||||
_state.update { it.copy(feed = Polled(UiState.Loading)) }
|
||||
viewModelScope.launch { askFeed() }
|
||||
}
|
||||
|
||||
fun retryLeaderboard() {
|
||||
_state.update { it.copy(leaderboard = UiState.Loading) }
|
||||
viewModelScope.launch { askLeaderboard() }
|
||||
}
|
||||
|
||||
fun retryOnline() {
|
||||
_state.update { it.copy(online = Polled(UiState.Loading)) }
|
||||
viewModelScope.launch { askOnline() }
|
||||
}
|
||||
|
||||
fun retryWipes() {
|
||||
_state.update { it.copy(wipes = UiState.Loading) }
|
||||
viewModelScope.launch { askWipes() }
|
||||
}
|
||||
|
||||
private suspend fun askServer() {
|
||||
val result = repository.server(serverId)
|
||||
_state.update { it.copy(server = refreshInto(it.server.state, result)) }
|
||||
}
|
||||
|
||||
private suspend fun askFeed() {
|
||||
val current = _state.value
|
||||
val result = repository.events(
|
||||
id = serverId,
|
||||
kinds = kindsFor(current.filterId),
|
||||
wipe = current.selectedWipe,
|
||||
limit = FEED_LIMIT,
|
||||
)
|
||||
_state.update { it.copy(feed = refreshInto(it.feed.state, result)) }
|
||||
}
|
||||
|
||||
private suspend fun askOnline() {
|
||||
val result = repository.online(serverId)
|
||||
_state.update { it.copy(online = refreshInto(it.online.state, result)) }
|
||||
}
|
||||
|
||||
private suspend fun askLeaderboard() {
|
||||
val current = _state.value
|
||||
val result = repository.leaderboard(
|
||||
id = serverId,
|
||||
wipe = current.selectedWipe,
|
||||
sort = current.sort,
|
||||
limit = LEADERBOARD_LIMIT,
|
||||
)
|
||||
_state.update { it.copy(leaderboard = result.toUiState()) }
|
||||
}
|
||||
|
||||
private suspend fun askWipes() {
|
||||
_state.update { it.copy(wipes = repository.wipes(serverId).toUiState()) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Matches the website's feed page size; the server caps at 200 regardless. */
|
||||
const val FEED_LIMIT = 100
|
||||
const val LEADERBOARD_LIMIT = 50
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
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.PaddingValues
|
||||
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.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.RustServerDto
|
||||
import com.runicgateway.app.ui.PollWhileResumed
|
||||
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
|
||||
|
||||
/**
|
||||
* Every Rust server this site follows — the module's landing page (D8, D12).
|
||||
*
|
||||
* **The phase criterion is this screen with every server off.** Nothing here is a
|
||||
* live call to a game host: the website answers from its own tables, so a fleet
|
||||
* that has been down for a week renders a week of last-known state rather than an
|
||||
* error. The one thing that can fail is the website itself.
|
||||
*/
|
||||
@Composable
|
||||
fun RustServersScreen(
|
||||
onOpenServer: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
viewModel: RustServersViewModel = hiltViewModel(),
|
||||
) {
|
||||
val polled by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
PollWhileResumed { viewModel.refresh() }
|
||||
|
||||
when (val s = polled.state) {
|
||||
is UiState.Loading -> LoadingView(modifier)
|
||||
is UiState.Error -> ErrorView(s.kind, onRetry = viewModel::load, modifier = modifier)
|
||||
is UiState.Success -> {
|
||||
if (s.data.isEmpty()) {
|
||||
EmptyView(stringResource(R.string.rust_servers_empty), modifier)
|
||||
} else {
|
||||
ServerList(s.data, polled.refreshFailed, onOpenServer, modifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerList(
|
||||
servers: List<RustServerDto>,
|
||||
refreshFailed: Boolean,
|
||||
onOpenServer: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
// A failed refresh says so and changes nothing else. The rows below it are
|
||||
// the last good answer and stay exactly as they were — blanking them is
|
||||
// the one thing a site whose premise is "it renders while the game is off"
|
||||
// must not do when a request fails.
|
||||
if (refreshFailed) {
|
||||
item {
|
||||
Text(
|
||||
text = stringResource(R.string.rust_refresh_failed),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
items(servers, key = { it.id }) { server ->
|
||||
ServerRow(server) { onOpenServer(server.id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ServerRow(server: RustServerDto, onOpen: () -> Unit) {
|
||||
ShardCard(modifier = Modifier.fillMaxWidth().clickable(onClick = onOpen)) {
|
||||
// `ShardCard` is the themed Card and nothing more — it carries no padding
|
||||
// of its own, so every caller pads its own content. Without this the text
|
||||
// sits flush against the card's edge and the first glyph of each line
|
||||
// reads as clipped, which is what the walk saw.
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = server.name.ifBlank { server.id },
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
// `online` already has staleness folded into it server-side — a row
|
||||
// nobody has written recently cannot claim a server is up — so this
|
||||
// renders the field rather than second-guessing it.
|
||||
if (server.online) {
|
||||
StatusPill(
|
||||
text = stringResource(
|
||||
R.string.rust_online_count,
|
||||
server.players,
|
||||
server.maxPlayers,
|
||||
),
|
||||
tone = PillTone.Success,
|
||||
)
|
||||
} else {
|
||||
StatusPill(text = stringResource(R.string.rust_offline), tone = PillTone.Neutral)
|
||||
}
|
||||
}
|
||||
|
||||
val world = describeWorld(server)
|
||||
if (world != null) {
|
||||
Text(
|
||||
text = world,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = lastReported(server),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 2.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The world line — the things a Rust player asks first.
|
||||
*
|
||||
* Null when the server has never described itself, so the caller leaves the line
|
||||
* out rather than printing an empty one. A server configured this morning that
|
||||
* has not connected yet is in exactly that state, and it is not an error.
|
||||
*/
|
||||
@Composable
|
||||
internal fun describeWorld(server: RustServerDto): String? {
|
||||
val parts = listOfNotNull(
|
||||
server.level,
|
||||
server.worldSize?.let { stringResource(R.string.rust_world_size, it) },
|
||||
server.seed?.let { stringResource(R.string.rust_world_seed, it) },
|
||||
wipeDay(server.wipedAt)?.let { stringResource(R.string.rust_wiped_on, it) },
|
||||
)
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
|
||||
}
|
||||
|
||||
/**
|
||||
* "last reported 3 minutes ago".
|
||||
*
|
||||
* **Reads `lastSeenAt` and never `updatedAt`.** The module shipped that exact
|
||||
* confusion and fixed it in phase 4: `updatedAt` moves on every poll including a
|
||||
* failed one, so an offline server claimed it had just checked in, every thirty
|
||||
* seconds, for as long as it stayed down. Only a frame moves `lastSeenAt`.
|
||||
*/
|
||||
@Composable
|
||||
internal fun lastReported(server: RustServerDto): String {
|
||||
val ago = rustAgo(server.lastSeenAt)
|
||||
?: return stringResource(R.string.rust_never_reported)
|
||||
|
||||
return if (server.stale) {
|
||||
stringResource(R.string.rust_last_reported_stale, ago)
|
||||
} else {
|
||||
stringResource(R.string.rust_last_reported, ago)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.ui.Polled
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.ui.refreshInto
|
||||
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 Rust server list — the module's landing page, one tier along (PLAN.md §9
|
||||
* M14; `docs/modules/rust/PLAN.md` D12).
|
||||
*
|
||||
* **`toUiState`, not `toShardUiState`.** These routes carry no `requireFeature`
|
||||
* gate, so a `404` here is a genuinely missing thing and never an admin's
|
||||
* visibility switch. Offering "this shard doesn't publish it" for one would name
|
||||
* a cause that does not exist on this surface.
|
||||
*
|
||||
* [refresh] is what the screen's poll calls and [load] is what a retry calls, and
|
||||
* the difference is the whole of [refreshInto]: a refresh keeps the rows when it
|
||||
* fails, a load is allowed to blank them because there is nothing on screen to
|
||||
* protect.
|
||||
*/
|
||||
@HiltViewModel
|
||||
class RustServersViewModel @Inject constructor(
|
||||
private val repository: RustRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(Polled<List<RustServerDto>>())
|
||||
val state: StateFlow<Polled<List<RustServerDto>>> = _state.asStateFlow()
|
||||
|
||||
/** A first load or a retry: show the spinner, then replace whatever comes back. */
|
||||
fun load() {
|
||||
_state.value = Polled(UiState.Loading)
|
||||
viewModelScope.launch { ask() }
|
||||
}
|
||||
|
||||
/** A poll: silent on success, and it keeps the rows on failure. */
|
||||
fun refresh() {
|
||||
viewModelScope.launch { ask() }
|
||||
}
|
||||
|
||||
private suspend fun ask() {
|
||||
_state.value = refreshInto(_state.value.state, repository.servers())
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.data.repository.AuthRepository
|
||||
import com.runicgateway.app.data.repository.ShardFeatures
|
||||
import com.runicgateway.app.data.repository.ShardFeaturesRepository
|
||||
import com.runicgateway.app.data.repository.SiteCapabilities
|
||||
import com.runicgateway.app.data.repository.SiteCapabilitiesRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -26,6 +28,7 @@ class SessionViewModel @Inject constructor(
|
||||
sessionManager: SessionManager,
|
||||
private val authRepository: AuthRepository,
|
||||
shardFeaturesRepository: ShardFeaturesRepository,
|
||||
siteCapabilitiesRepository: SiteCapabilitiesRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
val session: StateFlow<Session> = sessionManager.state
|
||||
@@ -38,6 +41,19 @@ class SessionViewModel @Inject constructor(
|
||||
*/
|
||||
val shardFeatures: StateFlow<ShardFeatures?> = shardFeaturesRepository.features
|
||||
|
||||
/**
|
||||
* What this BACKEND serves — core's capabilities and every installed module's
|
||||
* (M13). Exposed here for the reason [shardFeatures] is: the shared menu is
|
||||
* the consumer, and a row is filtered by both.
|
||||
*
|
||||
* **Read-only here, and deliberately not refreshed here.** This answer is per
|
||||
* HOST, not per viewer: signing in does not install a module. It is resolved
|
||||
* beside the appearance in [com.runicgateway.app.ui.AppViewModel], which is
|
||||
* what owns the host's lifecycle — first load, resume, and the Settings →
|
||||
* Server switch that invalidates it.
|
||||
*/
|
||||
val capabilities: StateFlow<SiteCapabilities?> = siteCapabilitiesRepository.capabilities
|
||||
|
||||
init {
|
||||
// The answer is per-viewer, so it is re-resolved on every session change.
|
||||
// A StateFlow conflates equal values, so a resume revalidation that returns
|
||||
|
||||
@@ -41,8 +41,10 @@
|
||||
<string name="nav_opens_in_browser">Opens in your browser</string>
|
||||
<string name="menu_home">Home</string>
|
||||
<string name="menu_news">News</string>
|
||||
<string name="menu_events">Events</string>
|
||||
<string name="menu_wiki">Wiki</string>
|
||||
<string name="menu_shard">Shard</string>
|
||||
<string name="menu_rust">Rust servers</string>
|
||||
<string name="menu_rules">Rules</string>
|
||||
<string name="menu_atlas">Atlas</string>
|
||||
<string name="menu_leaderboards">Leaderboards</string>
|
||||
@@ -50,6 +52,7 @@
|
||||
<string name="menu_about">About</string>
|
||||
<string name="menu_contact">Contact</string>
|
||||
<string name="menu_account">My account</string>
|
||||
<string name="menu_my_events">My events</string>
|
||||
<string name="menu_my_characters">My characters</string>
|
||||
<string name="menu_my_vendors">My vendors</string>
|
||||
<string name="menu_my_houses">My houses</string>
|
||||
@@ -484,7 +487,7 @@
|
||||
<!-- ── Push notifications (§11, M7 Part 2) ─────────────────────────── -->
|
||||
<string name="menu_notifications">Notifications</string>
|
||||
<string name="notifications_title">Notifications</string>
|
||||
<string name="notifications_subtitle">Choose what this shard notifies you about. Nothing is sent unless you turn it on.</string>
|
||||
<string name="notifications_subtitle">Choose what this shard notifies you about, and how it reaches you. Nothing is sent unless you turn it on.</string>
|
||||
<string name="notifications_section_general">General</string>
|
||||
<string name="notifications_section_personal">Your game account</string>
|
||||
<string name="notifications_requires_link">Link a game account to enable this.</string>
|
||||
@@ -495,6 +498,18 @@
|
||||
<string name="notifications_save_error">Couldn\'t save your notification settings. Try again.</string>
|
||||
<string name="notifications_relay_error">This shard\'s push relay isn\'t reachable right now.</string>
|
||||
|
||||
<!-- The in-app inbox and the per-channel settings (ENGAGEMENT.md phase 8). -->
|
||||
<string name="notifications_mode_off">Off</string>
|
||||
<string name="notifications_mode_instant">As it happens</string>
|
||||
<string name="notifications_mode_digest">Daily summary</string>
|
||||
<string name="inbox_empty">Nothing here yet. Notifications you\'re sent will show up here.</string>
|
||||
<string name="inbox_all_read">All caught up</string>
|
||||
<string name="inbox_unread_count">%1$d unread</string>
|
||||
<string name="inbox_mark_all_read">Mark all read</string>
|
||||
<string name="inbox_open_settings">Notification settings</string>
|
||||
<string name="inbox_loading_more">Loading more…</string>
|
||||
<string name="inbox_offline_cached">Offline — showing what was saved on this device.</string>
|
||||
|
||||
<!-- Notification channels + the ongoing foreground-service notification. -->
|
||||
<string name="push_channel_messages">Shard notifications</string>
|
||||
<string name="push_channel_messages_desc">Alerts you opted into from this shard.</string>
|
||||
@@ -513,4 +528,81 @@
|
||||
<string name="push_stream_house_idoc">Your house entered IDOC</string>
|
||||
<string name="push_stream_account_login">Login to your account</string>
|
||||
<string name="push_stream_generic">New notification</string>
|
||||
|
||||
<!-- ── Events (§9 M13, EVENTS.md §I) ─────────────────────────── -->
|
||||
<!--
|
||||
The four status words. `cancelled` has TWO, chosen by the clock rather than
|
||||
the status: "did not happen" is right for a past occurrence and false for a
|
||||
future one, and a run four days out that an operator called off is the common
|
||||
case. See EventTimes.statusWordRes.
|
||||
-->
|
||||
<string name="events_status_live">Happening now</string>
|
||||
<string name="events_status_scheduled">Scheduled</string>
|
||||
<string name="events_status_completed">Finished</string>
|
||||
<string name="events_status_cancelled">Cancelled</string>
|
||||
<string name="events_status_did_not_happen">Did not happen</string>
|
||||
|
||||
<string name="events_empty">Nothing on the calendar just yet — check back soon.</string>
|
||||
<!-- A forecast past the materialisation horizon: nothing is committed to it. -->
|
||||
<string name="events_projected">Expected — not yet confirmed</string>
|
||||
<string name="events_truncated">Showing the first part of a busy calendar.</string>
|
||||
|
||||
<string name="events_part_of">Part of %1$s</string>
|
||||
<string name="events_next">Next</string>
|
||||
<string name="events_under_way">Under way</string>
|
||||
<string name="events_nothing_scheduled">Nothing scheduled at the moment.</string>
|
||||
<string name="events_never_scheduled">This event has not been scheduled yet.</string>
|
||||
<string name="events_coming_up">Coming up</string>
|
||||
<string name="events_previously">Previously</string>
|
||||
<string name="events_results">Results</string>
|
||||
<string name="events_results_nobody">Results were published with nobody recorded.</string>
|
||||
<!-- A module puts a display name in its participation meta or it does not; the
|
||||
member key is never published, so there is nothing else to render. -->
|
||||
<string name="events_participant_unnamed">Unnamed</string>
|
||||
|
||||
<string name="events_history_empty">You have not taken part in an event yet.</string>
|
||||
<string name="events_rank">Rank %1$d</string>
|
||||
<!-- Not a dash: an unranked row is a real state, not a missing value. -->
|
||||
<string name="events_results_unpublished">Results not published</string>
|
||||
<string name="events_score">Score %1$s</string>
|
||||
<string name="events_show_more">Show more</string>
|
||||
<string name="events_loading">Loading…</string>
|
||||
|
||||
<!-- Rust module (M14, docs/modules/rust/PLAN.md §18) -->
|
||||
<string name="rust_servers_empty">No Rust servers are configured on this site yet.</string>
|
||||
<string name="rust_offline">Offline</string>
|
||||
<string name="rust_online_count">%1$d / %2$d online</string>
|
||||
<string name="rust_never_reported">has never reported</string>
|
||||
<string name="rust_last_reported">last reported %1$s</string>
|
||||
<string name="rust_last_reported_stale">last reported %1$s — out of date, so it is shown as offline</string>
|
||||
<string name="rust_world_size">size %1$d</string>
|
||||
<string name="rust_world_seed">seed %1$d</string>
|
||||
<string name="rust_wiped_on">wiped %1$s</string>
|
||||
<string name="rust_refresh_failed">Could not refresh just now. This is the last thing the site heard.</string>
|
||||
<string name="rust_no_such_server">No such server</string>
|
||||
<string name="rust_no_such_server_detail">This address does not name a server this site follows.</string>
|
||||
<string name="rust_back_to_servers">Back to the server list</string>
|
||||
<string name="rust_tab_feed">Feed</string>
|
||||
<string name="rust_tab_leaderboard">Leaderboard</string>
|
||||
<string name="rust_tab_online">Online</string>
|
||||
<string name="rust_tab_wipes">Wipes</string>
|
||||
<string name="rust_all_time">All time</string>
|
||||
<string name="rust_wipe_current">%1$s (this wipe)</string>
|
||||
<string name="rust_wipe_this_one">Current</string>
|
||||
<string name="rust_wipe_selected">Showing</string>
|
||||
<string name="rust_feed_empty">Nothing has happened on this server yet — or not during the wipe you are looking at.</string>
|
||||
<string name="rust_leaderboard_empty">Nobody has scored here yet.</string>
|
||||
<string name="rust_wipes_empty">This server has not reported a wipe yet.</string>
|
||||
<string name="rust_nobody_on">The server is up and the island is empty. Somebody has to be first.</string>
|
||||
<string name="rust_presence_offline">Presence is the one thing on this page that cannot be answered from the record — it is who is connected now, and nothing is.</string>
|
||||
<string name="rust_presence_live">On the server right now.</string>
|
||||
<string name="rust_presence_last_known">The last board this server sent. It is offline, so this is who was on then — not who is on now.</string>
|
||||
<string name="rust_sleeping">Sleeping</string>
|
||||
<string name="rust_col_player">Player</string>
|
||||
<string name="rust_col_kills">Kills</string>
|
||||
<string name="rust_col_deaths">Deaths</string>
|
||||
<string name="rust_col_npc_kills">NPC</string>
|
||||
<string name="rust_col_structures">Built</string>
|
||||
<string name="rust_col_played">Played</string>
|
||||
<string name="rust_online_badge">%1$d players online</string>
|
||||
</resources>
|
||||
|
||||
@@ -82,4 +82,71 @@ class NotificationsDtoTest {
|
||||
)
|
||||
assertNull(dto.push.ntfyUrl)
|
||||
}
|
||||
|
||||
// ── The inbox + per-channel prefs (ENGAGEMENT.md phases 3, 7/8) ────────
|
||||
|
||||
@Test fun inboxPageDecodesWithItsUnreadCount() {
|
||||
val dto = json.decodeFromString<NotificationInboxDto>(
|
||||
"""{"items":[{"id":42,"triggerId":"team.post.created","title":"New post",
|
||||
"body":"Someone posted in your team.","url":"https://shard.example/teams/1",
|
||||
"read":false,"readAt":null,"createdAt":"2026-08-31T12:30:00.000Z"}],
|
||||
"hasMore":true,"unread":3}""",
|
||||
)
|
||||
assertEquals(1, dto.items.size)
|
||||
assertEquals(42L, dto.items.first().id)
|
||||
assertEquals("team.post.created", dto.items.first().triggerId)
|
||||
assertFalse(dto.items.first().read)
|
||||
assertTrue(dto.hasMore)
|
||||
// The whole inbox, not the page — the badge and the list come from one response.
|
||||
assertEquals(3, dto.unread)
|
||||
}
|
||||
|
||||
@Test fun anItemWithNoBodyOrUrlDecodes() {
|
||||
// Most items have neither: an inbox row is complete on its own.
|
||||
val dto = json.decodeFromString<NotificationItemDto>(
|
||||
"""{"id":7,"triggerId":"news.post","title":"Patch notes","body":null,"url":null,
|
||||
"read":true,"readAt":"2026-08-31T13:00:00.000Z","createdAt":"2026-08-31T12:30:00.000Z"}""",
|
||||
)
|
||||
assertNull(dto.body)
|
||||
assertNull(dto.url)
|
||||
assertTrue(dto.read)
|
||||
}
|
||||
|
||||
@Test fun channelPrefsDecodeTheirModesAndPerItemChannels() {
|
||||
val dto = json.decodeFromString<NotificationChannelPrefsDto>(
|
||||
"""{"channels":[
|
||||
{"id":"push","label":"Push","carriesContent":false,"defaultMode":"off",
|
||||
"supportsDigest":false,"modes":["off","instant"]},
|
||||
{"id":"email","label":"Email","carriesContent":true,"defaultMode":"off",
|
||||
"supportsDigest":true,"modes":["off","instant","digest"]}],
|
||||
"items":[
|
||||
{"id":"uo.house.idoc_warning","label":"House in danger","description":"",
|
||||
"personal":true,"requiresLinkedAccount":true,"ceiling":"authenticated",
|
||||
"channels":["email","inapp"],"modes":{"email":"digest","inapp":"instant"}}]}""",
|
||||
)
|
||||
assertFalse(dto.channels.first { it.id == "push" }.carriesContent)
|
||||
assertTrue(dto.channels.first { it.id == "email" }.supportsDigest)
|
||||
val item = dto.items.single()
|
||||
// A trigger-only id carries no push facet at all — the UI renders controls
|
||||
// from THIS list, never from a hardcoded three.
|
||||
assertFalse(item.channels.contains("push"))
|
||||
assertEquals("digest", item.modes["email"])
|
||||
assertNull(item.modes["push"])
|
||||
}
|
||||
|
||||
@Test fun theSparseUpdateAlwaysCarriesItsPrefsField() {
|
||||
// Same reasoning as the subscriptions DTO: kotlinx omits a property equal
|
||||
// to its default, and the validator requires the field.
|
||||
val body = json.encodeToString(NotificationChannelPrefsUpdateDto(emptyList()))
|
||||
assertEquals("""{"prefs":[]}""", body)
|
||||
}
|
||||
|
||||
@Test fun theSparseUpdateSendsOnlyThePairItNames() {
|
||||
val body = json.encodeToString(
|
||||
NotificationChannelPrefsUpdateDto(
|
||||
listOf(NotificationChannelPrefDto(id = "news.post", channel = "email", mode = "digest")),
|
||||
),
|
||||
)
|
||||
assertEquals("""{"prefs":[{"id":"news.post","channel":"email","mode":"digest"}]}""", body)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.EventsApi
|
||||
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||
import com.runicgateway.app.data.api.dto.EventHistoryDto
|
||||
import com.runicgateway.app.data.api.dto.EventSeriesResponse
|
||||
import com.runicgateway.app.data.api.dto.PublicEventResponse
|
||||
|
||||
/**
|
||||
* A configurable fake of [EventsApi] (M13). Set the `var` a call should answer
|
||||
* with; set [error] to make every call throw.
|
||||
*
|
||||
* [lastRun] and [lastBefore] are what the tests that matter assert on: the run a
|
||||
* page was asked about, and the keyset cursor a history page walked back from.
|
||||
*/
|
||||
class FakeEventsApi : EventsApi {
|
||||
|
||||
var error: Throwable? = null
|
||||
|
||||
var calendar: EventCalendarDto = EventCalendarDto()
|
||||
var event: PublicEventResponse = PublicEventResponse()
|
||||
var series: EventSeriesResponse = EventSeriesResponse()
|
||||
var history: EventHistoryDto = EventHistoryDto()
|
||||
|
||||
/** The `run` the last event read carried, so a test can assert a blank was dropped. */
|
||||
var lastRun: String? = null
|
||||
var lastSlug: String? = null
|
||||
|
||||
/** The keyset cursor the last history page asked for; null on a first page. */
|
||||
var lastBefore: Long? = null
|
||||
var historyCalls: Int = 0
|
||||
|
||||
private fun <T> reply(value: T): T {
|
||||
error?.let { throw it }
|
||||
return value
|
||||
}
|
||||
|
||||
override suspend fun getCalendar(from: String?, to: String?, seriesId: Long?): EventCalendarDto =
|
||||
reply(calendar)
|
||||
|
||||
override suspend fun getEvent(slug: String, run: String?): PublicEventResponse {
|
||||
lastSlug = slug
|
||||
lastRun = run
|
||||
return reply(event)
|
||||
}
|
||||
|
||||
override suspend fun getSeries(slug: String): EventSeriesResponse {
|
||||
lastSlug = slug
|
||||
return reply(series)
|
||||
}
|
||||
|
||||
override suspend fun getHistory(limit: Int?, before: Long?): EventHistoryDto {
|
||||
historyCalls++
|
||||
lastBefore = before
|
||||
return reply(history)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.NotificationsApi
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsUpdateDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationInboxDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationReadResultDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationStreamsDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationSubscriptionsDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationUnreadDto
|
||||
import com.runicgateway.app.data.api.dto.PushDeviceDto
|
||||
import com.runicgateway.app.data.api.dto.RegisterDeviceRequest
|
||||
|
||||
/**
|
||||
* A configurable fake of [NotificationsApi] for the inbox and settings ViewModel
|
||||
* tests. Read endpoints return their `var`; [error] makes every call throw, which
|
||||
* is how the offline and server-error branches are driven.
|
||||
*
|
||||
* [pages] keys the inbox by its cursor — `null` is the first page — so a test can
|
||||
* describe a two-page inbox without a callback, and [lastPrefsUpdate] records the
|
||||
* body of the sparse PUT so a test can assert that ONE pair was sent.
|
||||
*/
|
||||
class FakeNotificationsApi : NotificationsApi {
|
||||
|
||||
var error: Throwable? = null
|
||||
|
||||
var streams: NotificationStreamsDto = NotificationStreamsDto()
|
||||
var subscriptions: NotificationSubscriptionsDto = NotificationSubscriptionsDto(emptyList())
|
||||
var channelPrefs: NotificationChannelPrefsDto = NotificationChannelPrefsDto()
|
||||
var pages: Map<Long?, NotificationInboxDto> = mapOf(null to NotificationInboxDto())
|
||||
var unread: NotificationUnreadDto = NotificationUnreadDto()
|
||||
var readResult: NotificationReadResultDto = NotificationReadResultDto(ok = true)
|
||||
|
||||
var lastPrefsUpdate: List<NotificationChannelPrefDto>? = null
|
||||
var markedRead: MutableList<Long> = mutableListOf()
|
||||
var markAllReadCalls: Int = 0
|
||||
var inboxCalls: MutableList<Long?> = mutableListOf()
|
||||
|
||||
private fun failIfSet() { error?.let { throw it } }
|
||||
|
||||
override suspend fun registerDevice(body: RegisterDeviceRequest): PushDeviceDto {
|
||||
failIfSet()
|
||||
return PushDeviceDto(id = 1)
|
||||
}
|
||||
|
||||
override suspend fun listDevices(): List<PushDeviceDto> {
|
||||
failIfSet()
|
||||
return emptyList()
|
||||
}
|
||||
|
||||
override suspend fun deleteDevice(id: Long) = failIfSet()
|
||||
|
||||
override suspend fun streams(): NotificationStreamsDto {
|
||||
failIfSet()
|
||||
return streams
|
||||
}
|
||||
|
||||
override suspend fun subscriptions(): NotificationSubscriptionsDto {
|
||||
failIfSet()
|
||||
return subscriptions
|
||||
}
|
||||
|
||||
override suspend fun putSubscriptions(body: NotificationSubscriptionsDto): NotificationSubscriptionsDto {
|
||||
failIfSet()
|
||||
subscriptions = body
|
||||
return body
|
||||
}
|
||||
|
||||
override suspend fun channelPrefs(): NotificationChannelPrefsDto {
|
||||
failIfSet()
|
||||
return channelPrefs
|
||||
}
|
||||
|
||||
override suspend fun putChannelPrefs(body: NotificationChannelPrefsUpdateDto): NotificationChannelPrefsDto {
|
||||
failIfSet()
|
||||
lastPrefsUpdate = body.prefs
|
||||
return channelPrefs
|
||||
}
|
||||
|
||||
override suspend fun inbox(limit: Int?, before: Long?, unread: Boolean?): NotificationInboxDto {
|
||||
failIfSet()
|
||||
inboxCalls.add(before)
|
||||
return pages[before] ?: NotificationInboxDto()
|
||||
}
|
||||
|
||||
override suspend fun unreadCount(): NotificationUnreadDto {
|
||||
failIfSet()
|
||||
return unread
|
||||
}
|
||||
|
||||
override suspend fun markRead(id: Long): NotificationReadResultDto {
|
||||
failIfSet()
|
||||
markedRead.add(id)
|
||||
return readResult
|
||||
}
|
||||
|
||||
override suspend fun markAllRead(): NotificationReadResultDto {
|
||||
failIfSet()
|
||||
markAllReadCalls++
|
||||
return readResult
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import com.runicgateway.app.data.api.dto.GovernorDto
|
||||
import com.runicgateway.app.data.api.dto.GovernorTermDto
|
||||
import com.runicgateway.app.data.api.dto.GuildDto
|
||||
import com.runicgateway.app.data.api.dto.HouseDto
|
||||
import com.runicgateway.app.data.api.dto.ModulesDto
|
||||
import com.runicgateway.app.data.api.dto.OnlineStaffDto
|
||||
import com.runicgateway.app.data.api.dto.PageDto
|
||||
import com.runicgateway.app.data.api.dto.PostDto
|
||||
@@ -67,6 +68,19 @@ class FakePublicApi : PublicApi {
|
||||
var houses: List<HouseDto> = emptyList()
|
||||
var shardFeatures: ShardFeaturesDto = ShardFeaturesDto()
|
||||
|
||||
/**
|
||||
* `GET /public/modules` (M13). Empty by default, which is a real answer: a
|
||||
* backend serving no modules at all.
|
||||
*/
|
||||
var modules: ModulesDto = ModulesDto()
|
||||
|
||||
/**
|
||||
* Per-call failures, for the one thing [error] cannot express: capability
|
||||
* resolution reads TWO routes and one failing is not the same as both.
|
||||
*/
|
||||
var statusError: Throwable? = null
|
||||
var modulesError: Throwable? = null
|
||||
|
||||
// Protocol 3.0 content (M11). `ruleset` is nullable on the wire: null means the
|
||||
// shard has never published one, which is a success, not a failure.
|
||||
var ruleset: RulesetDto? = null
|
||||
@@ -94,9 +108,17 @@ class FakePublicApi : PublicApi {
|
||||
}
|
||||
|
||||
override suspend fun probeStatus(absoluteStatusUrl: String): StatusDto = reply(status)
|
||||
override suspend fun getStatus(): StatusDto = reply(status)
|
||||
override suspend fun getStatus(): StatusDto {
|
||||
statusError?.let { throw it }
|
||||
return reply(status)
|
||||
}
|
||||
override suspend fun getSettings(): SettingsDto = reply(settings)
|
||||
|
||||
override suspend fun getModules(): ModulesDto {
|
||||
modulesError?.let { throw it }
|
||||
return reply(modules)
|
||||
}
|
||||
|
||||
override suspend fun getPosts(category: String): List<PostDto> = reply(posts)
|
||||
override suspend fun getPost(category: String, idOrSlug: String): PostDto = reply(post)
|
||||
override suspend fun getPage(slug: String): PageDto = reply(page)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.api.fake
|
||||
|
||||
import com.runicgateway.app.data.api.RustApi
|
||||
import com.runicgateway.app.data.api.dto.RustEventListDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||
import com.runicgateway.app.data.api.dto.RustWipeListDto
|
||||
|
||||
/**
|
||||
* A configurable fake of [RustApi] (M14). Set the `var` a call should answer
|
||||
* with; set [error] to make every call throw.
|
||||
*
|
||||
* **The `last…` fields are what the tests that matter assert on.** The module's
|
||||
* own API has one rule nothing about a successful response can show: an absent
|
||||
* query parameter must be **absent** rather than empty, because `?wipe=` asks for
|
||||
* a wipe whose id is the empty string and answers nothing, with no error to
|
||||
* notice. Recording what was asked is the only way to see that from here.
|
||||
*/
|
||||
class FakeRustApi : RustApi {
|
||||
|
||||
var error: Throwable? = null
|
||||
|
||||
var servers: RustServerListDto = RustServerListDto()
|
||||
var server: RustServerResponse = RustServerResponse()
|
||||
var events: RustEventListDto = RustEventListDto()
|
||||
var leaderboard: RustLeaderboardDto = RustLeaderboardDto()
|
||||
var wipes: RustWipeListDto = RustWipeListDto()
|
||||
var online: RustOnlineDto = RustOnlineDto()
|
||||
|
||||
var serversCalls: Int = 0
|
||||
var eventCalls: Int = 0
|
||||
var leaderboardCalls: Int = 0
|
||||
var onlineCalls: Int = 0
|
||||
var wipeCalls: Int = 0
|
||||
|
||||
/** The `kind` the last feed read carried — null means it sent none at all. */
|
||||
var lastKind: String? = null
|
||||
|
||||
/** The `wipe` the last feed read carried; null means all wipes. */
|
||||
var lastFeedWipe: String? = null
|
||||
var lastLeaderboardWipe: String? = null
|
||||
var lastSort: String? = null
|
||||
var lastId: String? = null
|
||||
|
||||
private fun <T> reply(value: T): T {
|
||||
error?.let { throw it }
|
||||
return value
|
||||
}
|
||||
|
||||
override suspend fun getServers(): RustServerListDto {
|
||||
serversCalls++
|
||||
return reply(servers)
|
||||
}
|
||||
|
||||
override suspend fun getServer(id: String): RustServerResponse {
|
||||
lastId = id
|
||||
return reply(server)
|
||||
}
|
||||
|
||||
override suspend fun getEvents(id: String, kind: String?, wipe: String?, limit: Int?): RustEventListDto {
|
||||
eventCalls++
|
||||
lastId = id
|
||||
lastKind = kind
|
||||
lastFeedWipe = wipe
|
||||
return reply(events)
|
||||
}
|
||||
|
||||
override suspend fun getLeaderboard(
|
||||
id: String,
|
||||
wipe: String?,
|
||||
sort: String?,
|
||||
limit: Int?,
|
||||
): RustLeaderboardDto {
|
||||
leaderboardCalls++
|
||||
lastId = id
|
||||
lastLeaderboardWipe = wipe
|
||||
lastSort = sort
|
||||
return reply(leaderboard)
|
||||
}
|
||||
|
||||
override suspend fun getWipes(id: String): RustWipeListDto {
|
||||
wipeCalls++
|
||||
lastId = id
|
||||
return reply(wipes)
|
||||
}
|
||||
|
||||
override suspend fun getOnline(id: String): RustOnlineDto {
|
||||
onlineCalls++
|
||||
lastId = id
|
||||
return reply(online)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.data.repository
|
||||
|
||||
import com.runicgateway.app.data.api.dto.InstalledModuleDto
|
||||
import com.runicgateway.app.data.api.dto.ModulesDto
|
||||
import com.runicgateway.app.data.api.dto.StatusDto
|
||||
import com.runicgateway.app.data.api.dto.VersionDto
|
||||
import com.runicgateway.app.data.api.fake.FakePublicApi
|
||||
import com.runicgateway.app.util.httpError
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* What this backend serves, and — the point of the class — the three different
|
||||
* things "we don't know" can mean (PLAN.md §9 M13).
|
||||
*
|
||||
* **Absence of an answer is not an answer of absence.** Before M13 the app
|
||||
* collapsed a 404, a dead network and "no such module" into one `null` and
|
||||
* treated all three as "show everything", which rendered five shard rows that
|
||||
* each 404 on a site running a different game.
|
||||
*/
|
||||
class SiteCapabilitiesRepositoryTest {
|
||||
|
||||
private val api = FakePublicApi()
|
||||
private val repository = SiteCapabilitiesRepository(api)
|
||||
|
||||
private fun serving(core: List<String>, moduleCaps: List<String>) {
|
||||
api.status = StatusDto(version = VersionDto(capabilities = core))
|
||||
api.modules = ModulesDto(
|
||||
modules = listOf(InstalledModuleDto(id = "uo", capabilities = moduleCaps)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun bothListsAreMergedAndStaySeparable() = runTest {
|
||||
serving(core = listOf("events"), moduleCaps = listOf("shard", "atlas"))
|
||||
repository.refresh()
|
||||
|
||||
val answer = repository.capabilities.value!!
|
||||
assertEquals(setOf("events"), answer.core)
|
||||
assertEquals(setOf("shard", "atlas"), answer.modules)
|
||||
// A menu entry does not care which half serves it.
|
||||
assertTrue("events" in answer)
|
||||
assertTrue("shard" in answer)
|
||||
assertFalse("market" in answer)
|
||||
}
|
||||
|
||||
@Test fun aBackendWithNoModulesAnswersRatherThanFailing() = runTest {
|
||||
api.status = StatusDto(version = VersionDto(capabilities = listOf("events")))
|
||||
api.modules = ModulesDto(modules = emptyList())
|
||||
repository.refresh()
|
||||
|
||||
val answer = repository.capabilities.value!!
|
||||
assertTrue("events" in answer)
|
||||
// The answer that hides the shard rows, and the whole reason for the class.
|
||||
assertFalse("shard" in answer)
|
||||
assertFalse(canUse(answer, Capability.SHARD))
|
||||
assertTrue(canUse(answer, Capability.EVENTS))
|
||||
}
|
||||
|
||||
@Test fun aBackendOlderThanEventsOmitsTheKeyAndThatIsAnAnswer() = runTest {
|
||||
// No `capabilities` in the version block at all — the value is in what is
|
||||
// absent, and it must not read as "unknown".
|
||||
api.status = StatusDto(version = VersionDto(service = "runic-gateway"))
|
||||
api.modules = ModulesDto(modules = listOf(InstalledModuleDto(id = "uo", capabilities = listOf("shard"))))
|
||||
repository.refresh()
|
||||
|
||||
val answer = repository.capabilities.value!!
|
||||
assertTrue(answer.core.isEmpty())
|
||||
assertFalse(canUse(answer, Capability.EVENTS))
|
||||
assertTrue(canUse(answer, Capability.SHARD))
|
||||
}
|
||||
|
||||
// ── The three failure directions ─────────────────────────────────────
|
||||
|
||||
@Test fun aHostThatHasNeverAnsweredLeavesTheGateOpen() = runTest {
|
||||
api.error = IOException("offline")
|
||||
repository.refresh()
|
||||
|
||||
// Null, not empty. The drawer renders as it did before this existed rather
|
||||
// than flickering its rows in on every cold start.
|
||||
assertNull(repository.capabilities.value)
|
||||
assertTrue(canUse(repository.capabilities.value, Capability.SHARD))
|
||||
assertTrue(canUse(repository.capabilities.value, Capability.EVENTS))
|
||||
}
|
||||
|
||||
@Test fun aFailedRefreshKeepsTheLastAnswer() = runTest {
|
||||
serving(core = listOf("events"), moduleCaps = listOf("shard"))
|
||||
repository.refresh()
|
||||
|
||||
api.error = IOException("offline")
|
||||
repository.refresh()
|
||||
|
||||
// A moment with no connectivity is not an uninstall.
|
||||
val answer = repository.capabilities.value!!
|
||||
assertTrue("shard" in answer)
|
||||
assertTrue("events" in answer)
|
||||
}
|
||||
|
||||
@Test fun oneCallFailingKeepsThatHalfAndUpdatesTheOther() = runTest {
|
||||
serving(core = listOf("events"), moduleCaps = listOf("shard"))
|
||||
repository.refresh()
|
||||
|
||||
// The module list answers with the game module gone; the status call is down.
|
||||
api.statusError = httpError(500)
|
||||
api.modules = ModulesDto(modules = emptyList())
|
||||
repository.refresh()
|
||||
|
||||
val answer = repository.capabilities.value!!
|
||||
// The half that answered is believed…
|
||||
assertFalse("shard" in answer)
|
||||
// …and the half that did not keeps what it last said.
|
||||
assertTrue("events" in answer)
|
||||
}
|
||||
|
||||
@Test fun aFiveHundredOnTheModuleListIsNotAnEmptyList() = runTest {
|
||||
serving(core = listOf("events"), moduleCaps = listOf("shard"))
|
||||
repository.refresh()
|
||||
|
||||
// Core answers 500 for a module list read before its loader ran, precisely
|
||||
// so a caller cannot read it as "no modules installed".
|
||||
api.modulesError = httpError(500)
|
||||
repository.refresh()
|
||||
|
||||
assertTrue("shard" in repository.capabilities.value!!)
|
||||
}
|
||||
|
||||
@Test fun aServerSwitchDropsTheAnswerEntirely() = runTest {
|
||||
serving(core = listOf("events"), moduleCaps = listOf("shard"))
|
||||
repository.refresh()
|
||||
assertNotNull(repository.capabilities.value)
|
||||
|
||||
repository.invalidate()
|
||||
|
||||
// Not "empty" — unknown. The new host has said nothing, and inheriting the
|
||||
// old one's answer would hide its shard rows until its first read lands.
|
||||
assertNull(repository.capabilities.value)
|
||||
assertTrue(canUse(repository.capabilities.value, Capability.SHARD))
|
||||
}
|
||||
|
||||
@Test fun twoModulesMayDeclareTheSameString() = runTest {
|
||||
api.status = StatusDto()
|
||||
api.modules = ModulesDto(
|
||||
modules = listOf(
|
||||
InstalledModuleDto(id = "uo", capabilities = listOf("shard")),
|
||||
InstalledModuleDto(id = "other", capabilities = listOf("shard", "cards")),
|
||||
),
|
||||
)
|
||||
repository.refresh()
|
||||
|
||||
assertEquals(setOf("shard", "cards"), repository.capabilities.value!!.modules)
|
||||
}
|
||||
}
|
||||
81
app/src/test/java/com/runicgateway/app/ui/PollingTest.kt
Normal file
81
app/src/test/java/com/runicgateway/app/ui/PollingTest.kt
Normal file
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui
|
||||
|
||||
import com.runicgateway.app.core.result.ApiResult
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The rule a poll lives on (M14, `docs/modules/rust/PLAN.md` D14).
|
||||
*
|
||||
* **A refresh is invisible when it succeeds and keeps the rows when it fails.**
|
||||
* The site's whole premise is that it renders while the game is off, and an app
|
||||
* that blanked itself the first time a request failed would break that one tier
|
||||
* along from where it was built.
|
||||
*/
|
||||
class PollingTest {
|
||||
|
||||
@Test
|
||||
fun `a successful refresh replaces the data and clears a previous failure`() {
|
||||
val after = refreshInto(UiState.Success(listOf("old")), ApiResult.Ok(listOf("new")))
|
||||
|
||||
assertEquals(UiState.Success(listOf("new")), after.state)
|
||||
assertFalse(after.refreshFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed refresh keeps the rows and reports the failure`() {
|
||||
// The case this whole file exists for. Nothing is blanked, nothing is
|
||||
// retried on the reader's behalf, and the failure is a fact the screen can
|
||||
// render beside rows that are still the best answer anybody has.
|
||||
val before = UiState.Success(listOf("old"))
|
||||
val after = refreshInto(before, ApiResult.NetworkError(RuntimeException("offline")))
|
||||
|
||||
assertEquals(before, after.state)
|
||||
assertTrue(after.refreshFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failure with nothing on screen is an ordinary error`() {
|
||||
// There is nothing to protect, so this is a first load that failed — and a
|
||||
// screen that reported "could not refresh" over a blank page would be
|
||||
// hiding the retry the reader needs.
|
||||
val after = refreshInto(UiState.Loading, ApiResult.HttpError(500, "boom"))
|
||||
|
||||
assertTrue(after.state is UiState.Error)
|
||||
assertFalse(after.refreshFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 404 on a first load keeps its own kind`() {
|
||||
val after = refreshInto(UiState.Loading, ApiResult.HttpError(404, "gone"))
|
||||
|
||||
assertEquals(UiState.Error(ErrorKind.NOT_FOUND, 404), after.state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a successful refresh over an error recovers`() {
|
||||
val after = refreshInto(
|
||||
UiState.Error(ErrorKind.NETWORK),
|
||||
ApiResult.Ok(listOf("back")),
|
||||
)
|
||||
|
||||
assertEquals(UiState.Success(listOf("back")), after.state)
|
||||
assertFalse(after.refreshFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty answer is a success, not a failure to keep the old rows through`() {
|
||||
// An empty list is an ANSWER — the feed filtered to a wipe nothing happened
|
||||
// in, a fleet with nobody on it. Treating it as "nothing came back" and
|
||||
// keeping stale rows would make an emptied board impossible to observe.
|
||||
val after = refreshInto(UiState.Success(listOf("old")), ApiResult.Ok(emptyList<String>()))
|
||||
|
||||
assertEquals(UiState.Success(emptyList<String>()), after.state)
|
||||
assertFalse(after.refreshFailed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.events
|
||||
|
||||
import com.runicgateway.app.R
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Rendering an event's instant and its status word (EVENTS.md §I).
|
||||
*
|
||||
* Two of these are regression tests for defects the WEBSITE shipped and its live
|
||||
* walk caught in events Phase 14a — restated in Kotlin because a rule that is
|
||||
* only written down in another language gets re-derived wrong.
|
||||
*/
|
||||
class EventTimesTest {
|
||||
|
||||
private val uk = Locale.UK
|
||||
|
||||
// ── The zone split: the day is the reader's, the time is the event's ──
|
||||
|
||||
@Test fun theTimeIsTheEventsZoneNotTheReaders() {
|
||||
// 2026-09-11T00:00Z is 20:00 the previous evening in New York. A shard's
|
||||
// 8pm event is 8pm to everyone reading about it; rendering the reader's
|
||||
// 02:00 would be true and useless.
|
||||
assertEquals("20:00 New York", eventTime("2026-09-11T00:00:00Z", "America/New_York", uk))
|
||||
assertEquals("02:00 Berlin", eventTime("2026-09-11T00:00:00Z", "Europe/Berlin", uk))
|
||||
}
|
||||
|
||||
@Test fun theDayHeadingIsTheReadersOwn() {
|
||||
// The same instant files under different days for two readers, which is the
|
||||
// other half of the split: "what is on this month" is about the month the
|
||||
// person holding the phone is living in.
|
||||
val instant = "2026-09-11T00:30:00Z"
|
||||
val london = readerDayLabel(instant, ZoneId.of("Europe/London"), uk)
|
||||
val newYork = readerDayLabel(instant, ZoneId.of("America/New_York"), uk)
|
||||
assertNotEquals(london, newYork)
|
||||
assertTrue(london, london.contains("11"))
|
||||
assertTrue(newYork, newYork.contains("10"))
|
||||
}
|
||||
|
||||
@Test fun anUnknownZoneFallsBackToUtcRatherThanThrowing() {
|
||||
// A typo in a definition's timezone column must still render.
|
||||
assertEquals("00:00 Nowhere", eventTime("2026-09-11T00:00:00Z", "Mars/Nowhere", uk))
|
||||
assertEquals("00:00 UTC", eventTime("2026-09-11T00:00:00Z", null, uk))
|
||||
}
|
||||
|
||||
@Test fun aZonelessStampIsReadAsUtc() {
|
||||
// MariaDB DATETIME read back as a string reaches the wire with no zone. It
|
||||
// is what the server stored, so it is UTC — reading it as local time would
|
||||
// shift every event by the device's offset.
|
||||
assertEquals("00:00 UTC", eventTime("2026-09-11 00:00:00", "UTC", uk))
|
||||
}
|
||||
|
||||
@Test fun anUnreadableInstantRendersNothingRatherThanCrashing() {
|
||||
assertEquals("", eventTime("not a date", "UTC", uk))
|
||||
assertEquals("", eventDateTime(null, "UTC", uk))
|
||||
assertEquals("", readerDayLabel("", ZoneId.of("UTC"), uk))
|
||||
}
|
||||
|
||||
@Test fun theZoneIsNamedAsAReaderRecognisesIt() {
|
||||
assertEquals("New York", shortZone("America/New_York"))
|
||||
assertEquals("Berlin", shortZone("Europe/Berlin"))
|
||||
assertEquals("UTC", shortZone(null))
|
||||
assertEquals("UTC", shortZone(" "))
|
||||
}
|
||||
|
||||
// ── Scores are fractional, and the walk is why we know ───────────────
|
||||
|
||||
@Test fun aWholeScorePrintsWhole() {
|
||||
// Most modules score by counting, and `12.0` reads as a rounding artefact.
|
||||
assertEquals("1420", scoreText(1420.0, uk))
|
||||
assertEquals("0", scoreText(0.0, uk))
|
||||
assertEquals("-5", scoreText(-5.0, uk))
|
||||
}
|
||||
|
||||
@Test fun aFractionalScoreKeepsItsDigits() {
|
||||
// The live walk's first history row was 318.5. Declaring this field `Long`
|
||||
// did not round it — kotlinx refused the whole body, and a 200 rendered as
|
||||
// "Something went wrong on the server."
|
||||
assertEquals("318.5", scoreText(318.5, uk))
|
||||
assertEquals("0.25", scoreText(0.25, uk))
|
||||
// DECIMAL(18,4): four places, and no trailing zeros past the last digit.
|
||||
assertEquals("1.0625", scoreText(1.0625, uk))
|
||||
}
|
||||
|
||||
@Test fun aNonFiniteScoreDoesNotReachTheScreen() {
|
||||
assertEquals("0", scoreText(Double.NaN, uk))
|
||||
assertEquals("0", scoreText(Double.POSITIVE_INFINITY, uk))
|
||||
}
|
||||
|
||||
// ── The status word: the tense follows the CLOCK, not the status ──────
|
||||
|
||||
@Test fun aFutureCancellationReadsCancelled() {
|
||||
// Phase 14a's own defect: the calendar told a visitor an event four days
|
||||
// away "DID NOT HAPPEN". It had been cancelled, not missed.
|
||||
val now = Instant.parse("2026-09-08T12:00:00Z")
|
||||
assertEquals(
|
||||
R.string.events_status_cancelled,
|
||||
statusWordRes("cancelled", "2026-09-12T20:00:00Z", now),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun aPastCancellationReadsDidNotHappen() {
|
||||
// Which is also the honest word for the `failed` and `missed` runs the
|
||||
// server folds into `cancelled`.
|
||||
val now = Instant.parse("2026-09-08T12:00:00Z")
|
||||
assertEquals(
|
||||
R.string.events_status_did_not_happen,
|
||||
statusWordRes("cancelled", "2026-09-01T20:00:00Z", now),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun anUnreadableInstantOnACancellationReadsPast() {
|
||||
val now = Instant.parse("2026-09-08T12:00:00Z")
|
||||
assertEquals(
|
||||
R.string.events_status_did_not_happen,
|
||||
statusWordRes("cancelled", null, now),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun theOtherThreeStatusesDoNotDependOnTheClock() {
|
||||
val past = Instant.parse("2027-01-01T00:00:00Z")
|
||||
val future = Instant.parse("2020-01-01T00:00:00Z")
|
||||
for (now in listOf(past, future)) {
|
||||
assertEquals(R.string.events_status_live, statusWordRes("live", "2026-09-12T20:00:00Z", now))
|
||||
assertEquals(R.string.events_status_completed, statusWordRes("completed", "2026-09-01T20:00:00Z", now))
|
||||
assertEquals(R.string.events_status_scheduled, statusWordRes("scheduled", "2026-09-12T20:00:00Z", now))
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun anUnknownStatusFallsBackTheWayTheServerDoes() {
|
||||
// `publicStatus()` folds anything it does not know to `scheduled`, so a word
|
||||
// the app has never seen is a contract break rather than a state — and
|
||||
// rendering a raw enum at a reader is not an improvement on it.
|
||||
assertEquals(
|
||||
R.string.events_status_scheduled,
|
||||
statusWordRes("starting", "2026-09-12T20:00:00Z", Instant.now()),
|
||||
)
|
||||
assertEquals(
|
||||
R.string.events_status_scheduled,
|
||||
statusWordRes(null, null, Instant.now()),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.events
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.runicgateway.app.core.auth.SessionManager
|
||||
import com.runicgateway.app.core.auth.StoredSession
|
||||
import com.runicgateway.app.core.auth.TokenStore
|
||||
import com.runicgateway.app.data.api.dto.SafeUserDto
|
||||
import com.runicgateway.app.data.api.dto.EventCalendarDto
|
||||
import com.runicgateway.app.data.api.dto.EventCalendarEntryDto
|
||||
import com.runicgateway.app.data.api.dto.EventHistoryDto
|
||||
import com.runicgateway.app.data.api.dto.EventHistoryEntryDto
|
||||
import com.runicgateway.app.data.api.dto.EventSeriesDto
|
||||
import com.runicgateway.app.data.api.dto.EventSeriesResponse
|
||||
import com.runicgateway.app.data.api.dto.PublicEventDto
|
||||
import com.runicgateway.app.data.api.dto.PublicEventResponse
|
||||
import com.runicgateway.app.data.api.fake.FakeEventsApi
|
||||
import com.runicgateway.app.data.repository.EventsRepository
|
||||
import com.runicgateway.app.ui.ErrorKind
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
/** The four event screens' view models (PLAN.md §9 M13). */
|
||||
class EventsViewModelsTest {
|
||||
|
||||
@get:Rule val dispatcher = MainDispatcherRule()
|
||||
|
||||
private val api = FakeEventsApi()
|
||||
private val repository = EventsRepository(api)
|
||||
|
||||
private fun entry(slug: String, at: String, kind: String = "run") =
|
||||
EventCalendarEntryDto(kind = kind, title = slug, slug = slug, scheduledFor = at)
|
||||
|
||||
// ── The calendar ─────────────────────────────────────────────────────
|
||||
|
||||
@Test fun theCalendarAsksForNoWindow() = runTest {
|
||||
api.calendar = EventCalendarDto(entries = listOf(entry("a", "2026-09-11T20:00:00Z")))
|
||||
|
||||
val state = EventsViewModel(repository).state.value
|
||||
|
||||
assertTrue(state is UiState.Success)
|
||||
assertEquals(1, (state as UiState.Success).data.entries.size)
|
||||
}
|
||||
|
||||
@Test fun aFourOhFourOnTheCalendarIsNotAFeatureBeingSwitchedOff() = runTest {
|
||||
// These are CORE routes: `toShardUiState`'s "not published here" would name
|
||||
// the wrong cause, and offer an explanation an admin cannot act on.
|
||||
api.error = httpError(404)
|
||||
|
||||
val state = EventsViewModel(repository).state.value
|
||||
|
||||
assertEquals(ErrorKind.NOT_FOUND, (state as UiState.Error).kind)
|
||||
}
|
||||
|
||||
@Test fun theServersOrderIsPreservedByTheDayGrouping() {
|
||||
// The server already sorted by instant; grouping must not re-sort. Two
|
||||
// entries on one reader-day share a heading, a third on another starts one.
|
||||
val entries = listOf(
|
||||
entry("a", "2026-09-11T20:00:00Z"),
|
||||
entry("b", "2026-09-11T21:00:00Z"),
|
||||
entry("c", "2026-09-14T20:00:00Z"),
|
||||
)
|
||||
|
||||
val days = groupByReaderDay(entries)
|
||||
|
||||
assertEquals(2, days.size)
|
||||
assertEquals(listOf("a", "b"), days[0].entries.map { it.slug })
|
||||
assertEquals(listOf("c"), days[1].entries.map { it.slug })
|
||||
}
|
||||
|
||||
@Test fun anEntrySaysWhetherItIsAForecast() {
|
||||
assertTrue(entry("a", "2026-09-11T20:00:00Z", kind = "projected").isProjected)
|
||||
assertTrue(!entry("a", "2026-09-11T20:00:00Z").isProjected)
|
||||
}
|
||||
|
||||
// ── One event ────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun theRunIsPassedThroughUntouched() = runTest {
|
||||
api.event = PublicEventResponse(PublicEventDto(slug = "yew"))
|
||||
val handle = SavedStateHandle(mapOf("slug" to "yew", "run" to "3692"))
|
||||
|
||||
EventViewModel(repository, handle)
|
||||
|
||||
assertEquals("yew", api.lastSlug)
|
||||
assertEquals("3692", api.lastRun)
|
||||
}
|
||||
|
||||
@Test fun aBlankRunIsDroppedRatherThanForwarded() = runTest {
|
||||
api.event = PublicEventResponse(PublicEventDto(slug = "yew"))
|
||||
val handle = SavedStateHandle(mapOf("slug" to "yew", "run" to " "))
|
||||
|
||||
EventViewModel(repository, handle)
|
||||
|
||||
assertNull(api.lastRun)
|
||||
}
|
||||
|
||||
@Test fun anAbsentRunIsNotSent() = runTest {
|
||||
api.event = PublicEventResponse(PublicEventDto(slug = "yew"))
|
||||
|
||||
EventViewModel(repository, SavedStateHandle(mapOf("slug" to "yew")))
|
||||
|
||||
assertNull(api.lastRun)
|
||||
}
|
||||
|
||||
@Test fun theEnvelopeIsUnwrappedForTheScreen() = runTest {
|
||||
api.event = PublicEventResponse(PublicEventDto(slug = "yew", title = "The Yew Invasion"))
|
||||
|
||||
val state = EventViewModel(repository, SavedStateHandle(mapOf("slug" to "yew"))).state.value
|
||||
|
||||
assertEquals("The Yew Invasion", (state as UiState.Success).data.title)
|
||||
}
|
||||
|
||||
// ── An arc ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun anArcWithNothingListedIsAnErrorRatherThanAnEmptyPage() = runTest {
|
||||
// The server's decision, not the screen's: a page for an empty arc would
|
||||
// publish that an operator has named something they have not announced.
|
||||
api.error = httpError(404)
|
||||
|
||||
val state = EventSeriesViewModel(repository, SavedStateHandle(mapOf("slug" to "void"))).state.value
|
||||
|
||||
assertEquals(ErrorKind.NOT_FOUND, (state as UiState.Error).kind)
|
||||
}
|
||||
|
||||
@Test fun anArcUnwrapsItsEnvelope() = runTest {
|
||||
api.series = EventSeriesResponse(EventSeriesDto(name = "The Void", slug = "void"))
|
||||
|
||||
val state = EventSeriesViewModel(repository, SavedStateHandle(mapOf("slug" to "void"))).state.value
|
||||
|
||||
assertEquals("The Void", (state as UiState.Success).data.name)
|
||||
}
|
||||
|
||||
// ── Participation history ────────────────────────────────────────────
|
||||
|
||||
private fun rows(vararg ids: Long) = EventHistoryDto(
|
||||
entries = ids.map { EventHistoryEntryDto(id = it, runId = it, slug = "e$it") },
|
||||
)
|
||||
|
||||
@Test fun aFractionalScoreDecodesRatherThanFailingTheWholeBody() {
|
||||
// The regression the live walk found: `score` is DECIMAL(18,4) on the wire
|
||||
// and a `Long` field makes kotlinx refuse the ENTIRE response, so a 200
|
||||
// reaches the screen as a server error. Decoded from real JSON so the DTO's
|
||||
// type is what is under test, not a hand-built object.
|
||||
val json = Json { ignoreUnknownKeys = true; explicitNulls = false }
|
||||
|
||||
val history = json.decodeFromString<EventHistoryDto>(
|
||||
"""{"entries":[{"id":2,"runId":3667,"title":"Midsummer Fair","slug":"mf","score":318.5,"rank":null}]}""",
|
||||
)
|
||||
assertEquals(318.5, history.entries.single().score, 0.0)
|
||||
|
||||
val event = json.decodeFromString<PublicEventResponse>(
|
||||
"""{"event":{"slug":"mf","results":{"runId":1,"participants":[{"name":"A","score":318.5}]}}}""",
|
||||
)
|
||||
assertEquals(318.5, event.event.results!!.participants.single().score, 0.0)
|
||||
}
|
||||
|
||||
// A signed-in session manager, so the history view model has an account to
|
||||
// scope to. The screen is unreachable signed out.
|
||||
private class FakeTokenStore(private var stored: StoredSession?) : TokenStore {
|
||||
override fun load(): StoredSession? = stored
|
||||
override fun save(session: StoredSession) { stored = session }
|
||||
override fun clear() { stored = null }
|
||||
}
|
||||
|
||||
private fun playerDto(userId: Long) =
|
||||
SafeUserDto(id = userId, username = "u$userId", role = "player")
|
||||
|
||||
private fun sessionFor(userId: Long) =
|
||||
SessionManager(FakeTokenStore(StoredSession("a", "r", userId, "u$userId", "player")))
|
||||
|
||||
@Test fun switchingAccountDoesNotShowThePreviousOnesHistory() {
|
||||
// **The leak the live walk found, and the suite could not.** A drawer
|
||||
// route's view model survives a sign-out: `navigateTopLevel` saves and
|
||||
// restores back-stack state, so the entry keeps its ViewModelStore and a
|
||||
// view model that loaded only in `init` never runs again. Signing out of
|
||||
// an admin and in as a player showed the player the admin's rows, with no
|
||||
// request made at all.
|
||||
val sessions = sessionFor(33)
|
||||
api.history = rows(9, 8)
|
||||
val vm = MyEventsViewModel(repository, sessions)
|
||||
assertEquals(2, (vm.state.value.items as UiState.Success).data.size)
|
||||
|
||||
api.history = rows(1)
|
||||
sessions.onSignedOut()
|
||||
// Signed out, the previous account's rows are gone rather than left up.
|
||||
assertEquals(0, (vm.state.value.items as UiState.Success).data.size)
|
||||
|
||||
sessions.onSignedIn("a", "r", playerDto(35))
|
||||
assertEquals(listOf(1L), (vm.state.value.items as UiState.Success).data.map { it.id })
|
||||
}
|
||||
|
||||
@Test fun aResumeRevalidationReturningTheSameUserDoesNotRefetch() {
|
||||
// The other half: the gate is the account, not every session emission.
|
||||
val sessions = sessionFor(33)
|
||||
api.history = rows(9, 8)
|
||||
val vm = MyEventsViewModel(repository, sessions)
|
||||
val callsAfterFirstLoad = api.historyCalls
|
||||
|
||||
sessions.onUserRefreshed(playerDto(33))
|
||||
|
||||
assertEquals(callsAfterFirstLoad, api.historyCalls)
|
||||
assertEquals(2, (vm.state.value.items as UiState.Success).data.size)
|
||||
}
|
||||
|
||||
@Test fun aShortFirstPageIsTheEnd() = runTest {
|
||||
api.history = rows(3, 2, 1)
|
||||
|
||||
val state = MyEventsViewModel(repository, sessionFor(1)).state.value
|
||||
|
||||
assertEquals(3, (state.items as UiState.Success).data.size)
|
||||
assertTrue(!state.hasMore)
|
||||
}
|
||||
|
||||
@Test fun aFullPageWalksBackOnTheLastRowsOwnId() = runTest {
|
||||
// Keyset, never an offset: the list gains rows at the top as the reader
|
||||
// attends things, so an offset page would skip and repeat around the seam.
|
||||
api.history = rows(*(1L..25L).reversed().toList().toLongArray())
|
||||
val vm = MyEventsViewModel(repository, sessionFor(1))
|
||||
assertTrue(vm.state.value.hasMore)
|
||||
|
||||
api.history = rows(0)
|
||||
vm.loadMore()
|
||||
|
||||
assertEquals(1L, api.lastBefore)
|
||||
assertEquals(26, (vm.state.value.items as UiState.Success).data.size)
|
||||
assertTrue(!vm.state.value.hasMore)
|
||||
}
|
||||
|
||||
@Test fun aFailedNextPageKeepsThePagesAlreadyRead() = runTest {
|
||||
api.history = rows(*(1L..25L).reversed().toList().toLongArray())
|
||||
val vm = MyEventsViewModel(repository, sessionFor(1))
|
||||
|
||||
api.error = IOException("offline")
|
||||
vm.loadMore()
|
||||
|
||||
// Not an error screen replacing a screenful of history.
|
||||
assertEquals(25, (vm.state.value.items as UiState.Success).data.size)
|
||||
assertTrue(!vm.state.value.loadingMore)
|
||||
}
|
||||
|
||||
@Test fun loadMoreDoesNothingWithoutAFullFirstPage() = runTest {
|
||||
api.history = rows(2, 1)
|
||||
val vm = MyEventsViewModel(repository, sessionFor(1))
|
||||
val callsAfterLoad = api.historyCalls
|
||||
|
||||
vm.loadMore()
|
||||
|
||||
assertEquals(callsAfterLoad, api.historyCalls)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.core.auth.Role
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.core.auth.SessionUser
|
||||
import com.runicgateway.app.data.repository.Capability
|
||||
import com.runicgateway.app.data.repository.ShardFeature
|
||||
import com.runicgateway.app.data.repository.ShardFeatures
|
||||
import com.runicgateway.app.data.repository.SiteCapabilities
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The third gate on a drawer row (PLAN.md §5, §9 M13): whether the code behind it
|
||||
* is installed on this backend at all.
|
||||
*
|
||||
* **A different question from the feature flag, which is why it is a third
|
||||
* filter.** Capability is per HOST — it changes when an operator installs or
|
||||
* removes a module. A feature is per VIEWER — it changes on sign-in. The two also
|
||||
* fail differently, and the difference is the bug this milestone fixed.
|
||||
*/
|
||||
class MenuCapabilityGatingTest {
|
||||
|
||||
private fun signedIn(role: Role) =
|
||||
Session.SignedIn(SessionUser(id = 1, username = "u", role = role))
|
||||
|
||||
private fun serving(vararg caps: String) =
|
||||
SiteCapabilities(core = emptySet(), modules = caps.toSet())
|
||||
|
||||
private val shardEntry = MenuEntry(
|
||||
"shard",
|
||||
0,
|
||||
MenuAccess.PUBLIC,
|
||||
feature = ShardFeature.STATUS,
|
||||
capability = Capability.SHARD,
|
||||
)
|
||||
private val eventsEntry = MenuEntry("events", 0, MenuAccess.PUBLIC, capability = Capability.EVENTS)
|
||||
private val plainEntry = MenuEntry("news", 0, MenuAccess.PUBLIC)
|
||||
|
||||
private fun everyFeature() = ShardFeatures(level = "anonymous", visible = setOf(ShardFeature.STATUS))
|
||||
|
||||
@Test fun aRowHidesWhenTheBackendSaysItsModuleIsNotInstalled() {
|
||||
// The whole point. On a site with no game module `/public/shard/features`
|
||||
// 404s, so the FEATURE answer is unknown and fails open — and before M13
|
||||
// that was the only answer the app had, so the row rendered and 404'd.
|
||||
val entries = listOf(plainEntry, shardEntry, eventsEntry)
|
||||
|
||||
val visible = visibleEntries(
|
||||
entries,
|
||||
Session.SignedOut,
|
||||
features = null,
|
||||
capabilities = serving("events"),
|
||||
).map { it.route }
|
||||
|
||||
assertEquals(listOf("news", "events"), visible)
|
||||
}
|
||||
|
||||
@Test fun anUnknownCapabilityAnswerLeavesEveryRowShowing() {
|
||||
// A host that has never answered. Same fail-open direction the feature gate
|
||||
// takes, and for the same reason: the server gates every call regardless.
|
||||
val entries = listOf(plainEntry, shardEntry, eventsEntry)
|
||||
|
||||
val visible = visibleEntries(
|
||||
entries,
|
||||
Session.SignedOut,
|
||||
features = everyFeature(),
|
||||
capabilities = null,
|
||||
).map { it.route }
|
||||
|
||||
assertEquals(listOf("news", "shard", "events"), visible)
|
||||
}
|
||||
|
||||
@Test fun anEmptyAnswerIsNotAnUnknownAnswer() {
|
||||
// The distinction the whole milestone rests on, as one assertion.
|
||||
assertTrue(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), null))
|
||||
assertFalse(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), serving()))
|
||||
}
|
||||
|
||||
@Test fun bothGatesMustPassAndNeitherCanOverrideTheOther() {
|
||||
val installed = serving(Capability.SHARD)
|
||||
|
||||
// Installed but not published to this viewer: hidden.
|
||||
assertFalse(
|
||||
isEntryVisible(shardEntry, Session.SignedOut, ShardFeatures("anonymous", emptySet()), installed),
|
||||
)
|
||||
// Published but the module is gone: hidden. (Not a state a real backend
|
||||
// reaches, and the gate must not depend on that.)
|
||||
assertFalse(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), serving()))
|
||||
// Both: shown.
|
||||
assertTrue(isEntryVisible(shardEntry, Session.SignedOut, everyFeature(), installed))
|
||||
}
|
||||
|
||||
@Test fun theRoleGateStillOutranksBoth() {
|
||||
// An admin row is an admin row on a backend that serves everything.
|
||||
val adminEntry = MenuEntry("admin/dashboard", 0, MenuAccess.STAFF)
|
||||
assertFalse(
|
||||
isEntryVisible(adminEntry, Session.SignedOut, everyFeature(), serving(Capability.SHARD)),
|
||||
)
|
||||
assertTrue(
|
||||
isEntryVisible(adminEntry, signedIn(Role.ADMIN), everyFeature(), serving(Capability.SHARD)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun aRowWithNoCapabilityIsNeverGatedByOne() {
|
||||
// Every row that predates M13 keeps the behaviour it had.
|
||||
assertTrue(isEntryVisible(plainEntry, Session.SignedOut, null, serving()))
|
||||
assertTrue(isEntryVisible(plainEntry, Session.SignedOut, null, null))
|
||||
}
|
||||
|
||||
// ── The shipped menu, as coded ───────────────────────────────────────
|
||||
|
||||
@Test fun everyRowOnAModulePathDeclaresTheShardCapability() {
|
||||
// **Defined by ROUTE, not by "has a feature", and the live walk is why.**
|
||||
// The first cut of this test asked whether every row with a `feature`
|
||||
// declared the capability — which is true and insufficient: the three
|
||||
// player game-data rows read `/player/shard/*`, the same module's player
|
||||
// mount, and carry no feature at all because they are gated by ownership
|
||||
// rather than by the visibility framework. They rendered on a backend with
|
||||
// no module installed and answered "This content couldn't be found",
|
||||
// through a green suite.
|
||||
val onAModulePath = APP_MENU.filter {
|
||||
it.route.startsWith("shard") || it.route.startsWith("player/") || it.route == Routes.ATLAS
|
||||
}
|
||||
assertEquals(8, onAModulePath.size)
|
||||
assertTrue(
|
||||
onAModulePath.filter { it.capability != Capability.SHARD }.map { it.route }.toString(),
|
||||
onAModulePath.all { it.capability == Capability.SHARD },
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun aModuleLessBackendShowsNoModuleRowToAnybody() {
|
||||
// The walk's assertion, as a test: every rung, and not one module row.
|
||||
val core = SiteCapabilities(core = setOf(Capability.EVENTS), modules = emptySet())
|
||||
for (session in listOf(
|
||||
Session.SignedOut,
|
||||
signedIn(Role.PLAYER),
|
||||
signedIn(Role.ADMIN),
|
||||
)) {
|
||||
val visible = visibleEntries(APP_MENU, session, everyFeature(), core).map { it.route }
|
||||
assertTrue(
|
||||
visible.toString(),
|
||||
visible.none {
|
||||
it.startsWith("shard") || it.startsWith("player/") || it == Routes.ATLAS
|
||||
},
|
||||
)
|
||||
// …and the core rows are all still there.
|
||||
assertTrue(Routes.EVENTS in visible)
|
||||
assertTrue(Routes.NEWS in visible)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun bothEventRowsDeclareCoresCapabilityAndNoFeature() {
|
||||
// Events are core's. A `feature` on one of them would gate a core screen on
|
||||
// a module's visibility config, which is the coupling this separation exists
|
||||
// to prevent.
|
||||
val eventRows = APP_MENU.filter { it.capability == Capability.EVENTS }
|
||||
assertEquals(listOf(Routes.EVENTS, Routes.MY_EVENTS), eventRows.map { it.route })
|
||||
assertTrue(eventRows.all { it.feature == null })
|
||||
}
|
||||
|
||||
@Test fun myEventsIsSignedInRatherThanPlayer() {
|
||||
// The route is `requireAuth` alone and self-scoped; staff attend events too,
|
||||
// and the website needed two mounts only because of its own /account guard.
|
||||
val row = APP_MENU.first { it.route == Routes.MY_EVENTS }
|
||||
assertEquals(MenuAccess.SIGNED_IN, row.access)
|
||||
assertTrue(isEntryVisible(row, signedIn(Role.ADMIN), null, serving(Capability.EVENTS)))
|
||||
assertTrue(isEntryVisible(row, signedIn(Role.PLAYER), null, serving(Capability.EVENTS)))
|
||||
assertFalse(isEntryVisible(row, Session.SignedOut, null, serving(Capability.EVENTS)))
|
||||
}
|
||||
}
|
||||
@@ -44,12 +44,30 @@ 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.
|
||||
*
|
||||
* **The Rust row is last, at 17** (M14). These tests carry no capability
|
||||
* answer, which fails open, so both game modules' rows appear here — a state
|
||||
* no real backend is in and exactly the one this merge has to be correct for,
|
||||
* since the sort key is the website's number line and not what happens to be
|
||||
* installed.
|
||||
*/
|
||||
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, Routes.RUST,
|
||||
)
|
||||
|
||||
/** 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 +89,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 +103,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 +111,16 @@ class NavOverridesTest {
|
||||
// ── Labels ───────────────────────────────────────────────────────────
|
||||
|
||||
@Test fun aLabelOverridesTheBundledString() {
|
||||
val merged = applyNavOverrides(APP_MENU, nav("/site/shard" to entry(label = " The Realm ")))
|
||||
// `/uo/shard`, not `/site/shard`: the row belongs to module-uo and core
|
||||
// mounts a module's pages at `/<id>/<path>` (M13).
|
||||
val merged = applyNavOverrides(APP_MENU, nav("/uo/shard" to entry(label = " The Realm ")))
|
||||
|
||||
val shard = merged.first { it.route == Routes.SHARD }
|
||||
assertEquals("The Realm", shard.label)
|
||||
// The override lands on `label` and nothing else — the gates are untouched.
|
||||
assertEquals(ShardFeature.STATUS, shard.feature)
|
||||
assertEquals(MenuAccess.PUBLIC, shard.access)
|
||||
assertEquals(codedPublic, merged.take(9).map { it.route })
|
||||
assertEquals(mergedPublic, merged.take(publicBlock).map { it.route })
|
||||
}
|
||||
|
||||
@Test fun aNonStringLabelIsIgnored() {
|
||||
@@ -112,7 +132,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 +148,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 +160,51 @@ 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, Market is 16, and the Rust row —
|
||||
// the last of all, now that a second game module's row appends after the
|
||||
// first's nine — is 17. 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 = 18),
|
||||
),
|
||||
)
|
||||
|
||||
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.RUST, 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 +251,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(
|
||||
|
||||
@@ -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, module-uo's nine and module-rust's one, all quoted in
|
||||
// NavPaths.kt. If any of the three 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(18, WEBSITE_PUBLIC_NAV.size)
|
||||
assertEquals(WEBSITE_PUBLIC_NAV.size, WEB_PATH_TO_ROUTE.size)
|
||||
}
|
||||
|
||||
@Test fun theShardRowsAreTheModulesPathsNotCores() {
|
||||
// The defect M13 fixed, kept as an assertion: these nine belong to
|
||||
// module-uo and core mounts a module's pages at `/<id>/<path>`. A `/site/`
|
||||
// spelling here is the stale table coming back.
|
||||
val shard = WEBSITE_PUBLIC_NAV.map { it.path }.filter { it.startsWith("/uo/") }
|
||||
assertEquals(9, shard.size)
|
||||
assertTrue(WEBSITE_PUBLIC_NAV.none { it.path.startsWith("/site/shard") })
|
||||
assertTrue(WEBSITE_PUBLIC_NAV.none { it.path == "/site/champs" })
|
||||
assertNull(appRouteForWebPath("/site/champs"))
|
||||
assertEquals(Routes.SHARD_CHAMPS, appRouteForWebPath("/uo/champs"))
|
||||
}
|
||||
|
||||
@Test fun everyMappedRouteIsDistinct() {
|
||||
// WEB_ROUTE_ORDER is keyed by route, so a duplicate would silently drop a
|
||||
// row's position from the sort.
|
||||
@@ -34,24 +48,36 @@ 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
|
||||
// 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).
|
||||
@Test fun theDrawerRowsAreTheIntersectionWithAppMenu() {
|
||||
// Eleven of the eighteen 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).
|
||||
//
|
||||
// **Both game modules appear here, and no backend serves both lists.** The
|
||||
// table is a superset on purpose: a path for a module an operator has not
|
||||
// installed never appears in that backend's nav and is never looked up,
|
||||
// while a path MISSING from it makes a link that does exist hand off to a
|
||||
// browser.
|
||||
val coded = APP_MENU.map { it.route }.toSet()
|
||||
val surfaced = WEBSITE_PUBLIC_NAV.filter { it.route in coded }.map { it.path }
|
||||
|
||||
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",
|
||||
"/rust",
|
||||
),
|
||||
surfaced,
|
||||
)
|
||||
@@ -62,7 +88,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 +139,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 +158,71 @@ class NavPathsTest {
|
||||
// Read off website/client/src/App.jsx. Note what is NOT here: the site has
|
||||
// no /site/news/<id> route — its one post-detail route is the newsletter's.
|
||||
assertEquals(Routes.wikiPage("smithing"), resolveWebPath("/wiki/smithing"))
|
||||
assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/site/atlas/dragon"))
|
||||
assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/site/market/vendors/0x24C"))
|
||||
assertEquals(Routes.atlasCreature("dragon"), resolveWebPath("/uo/atlas/dragon"))
|
||||
assertEquals(Routes.marketVendor("0x24C"), resolveWebPath("/uo/market/vendors/0x24C"))
|
||||
assertEquals(Routes.post("newsletter", "12"), resolveWebPath("/site/newsletter/12"))
|
||||
// The module's detail routes are the module's; the old /site/ spelling is
|
||||
// not a second address for them.
|
||||
assertNull(resolveWebPath("/site/atlas/dragon"))
|
||||
assertNull(resolveWebPath("/site/market/vendors/0x24C"))
|
||||
}
|
||||
|
||||
// ── Events (M13) ───────────────────────────────────────
|
||||
|
||||
@Test fun theEventPagesResolve() {
|
||||
assertEquals(Routes.EVENTS, resolveWebPath("/site/events"))
|
||||
assertEquals(Routes.event("the-yew-invasion"), resolveWebPath("/site/events/the-yew-invasion"))
|
||||
assertEquals(
|
||||
Routes.eventSeries("the-void"),
|
||||
resolveWebPath("/site/events/series/the-void"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun anEventUrlsRunIsCarriedThrough() {
|
||||
// The one exception to "a query hands off", and the whole reason for it:
|
||||
// this is the exact shape events Phase 14a's `eventUrl` writes into every
|
||||
// announcement. Dropping the run would open next Friday's occurrence from a
|
||||
// mail about last Friday's.
|
||||
assertEquals(
|
||||
Routes.event("the-yew-invasion", "3692"),
|
||||
resolveWebPath("/site/events/the-yew-invasion?run=3692"),
|
||||
)
|
||||
assertEquals("events/the-yew-invasion?run=3692", Routes.event("the-yew-invasion", "3692"))
|
||||
}
|
||||
|
||||
@Test fun theRunCarveOutIsOneKeyOnOnePath() {
|
||||
// Narrow on purpose. Anything the app cannot honor natively hands off, so
|
||||
// the browser gets the parameter the author actually wrote.
|
||||
assertNull(resolveWebPath("/site/events/x?utm=mail"))
|
||||
assertNull(resolveWebPath("/site/events/x?run=3&utm=mail"))
|
||||
assertNull(resolveWebPath("/site/events/x?run="))
|
||||
assertNull(resolveWebPath("/site/events/x#results"))
|
||||
assertNull(resolveWebPath("/site/events?seriesId=3"))
|
||||
assertNull(resolveWebPath("/site/events/series/the-void?run=3"))
|
||||
// And no OTHER path gained a query: the rule is one path's, not general.
|
||||
assertNull(resolveWebPath("/wiki/smithing?x=1"))
|
||||
}
|
||||
|
||||
@Test fun anEventRouteWithNoRunCarriesNoEmptyArgument() {
|
||||
// `events/x?run=` would reach the screen as a blank string and be forwarded
|
||||
// to the server as one.
|
||||
assertEquals("events/x", Routes.event("x"))
|
||||
assertEquals("events/x", Routes.event("x", null))
|
||||
assertEquals("events/x", Routes.event("x", " "))
|
||||
}
|
||||
|
||||
@Test fun theEventRoutePatternStripsToTheTopLevelRoute() {
|
||||
// Same rule the News hub needs: `destination.route` is the pattern, and the
|
||||
// drawer compares on the part before the query.
|
||||
assertEquals(Routes.EVENTS, Routes.EVENT_ROUTE.substringBefore('?').substringBefore('/'))
|
||||
assertEquals("events/{slug}", Routes.EVENT_ROUTE.substringBefore('?'))
|
||||
}
|
||||
|
||||
@Test fun myEventsHasNoDynamicSibling() {
|
||||
// `events/mine` would race `events/{slug}` — both two segments — which is
|
||||
// the static-versus-argument bug events Phase 13 shipped one tier along.
|
||||
assertTrue(Routes.MY_EVENTS.startsWith("account/"))
|
||||
assertNull(resolveWebPath("/account/events"))
|
||||
}
|
||||
|
||||
@Test fun aTopLevelSlugIsACmsPage() {
|
||||
@@ -157,15 +245,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"))
|
||||
|
||||
@@ -166,15 +166,17 @@ class NavTreeTest {
|
||||
|
||||
val shape = tree(row).shape()
|
||||
|
||||
// Eight public rows are left at the top level (Wiki moved into the section),
|
||||
// then the section, then the app's own rows.
|
||||
assertEquals("section:lore", shape[8])
|
||||
assertEquals(Routes.CONTACT, shape[9])
|
||||
// Ten public rows are left at the top level (Wiki moved into the section),
|
||||
// then the section, then the app's own rows. The tenth is the Rust row:
|
||||
// these tests carry no capability answer, which fails open, so both game
|
||||
// modules' rows are present — see NavOverridesTest's `mergedPublic`.
|
||||
assertEquals("section:lore", shape[10])
|
||||
assertEquals(Routes.CONTACT, shape[11])
|
||||
}
|
||||
|
||||
@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 +254,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 +326,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])
|
||||
// The Rust row, not About: both modules' rows append after core's eight on
|
||||
// the website's number line, and Rust's is last at 17.
|
||||
assertEquals(Routes.RUST, shape[shape.indexOf("link:a") - 1])
|
||||
}
|
||||
|
||||
@Test fun aLinksOrderPlacesItAmongTheCodedRows() {
|
||||
@@ -364,7 +368,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 +384,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 +404,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 +422,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")),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.navigation
|
||||
|
||||
import com.runicgateway.app.core.auth.Session
|
||||
import com.runicgateway.app.data.repository.Capability
|
||||
import com.runicgateway.app.data.repository.SiteCapabilities
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The Rust row, its routes, and the two ways a Rust link can reach the app
|
||||
* (M14, `docs/modules/rust/PLAN.md` §18).
|
||||
*
|
||||
* The question this file really asks is the phase criterion's other half: **a UO
|
||||
* site is unchanged.** Two game modules can be installed on one backend, and
|
||||
* neither one's rows may appear on a site running only the other.
|
||||
*/
|
||||
class RustNavigationTest {
|
||||
|
||||
private fun serving(vararg caps: String) =
|
||||
SiteCapabilities(core = emptySet(), modules = caps.toSet())
|
||||
|
||||
private fun routesFor(capabilities: SiteCapabilities?) =
|
||||
visibleEntries(APP_MENU, Session.SignedOut, features = null, capabilities = capabilities)
|
||||
.map { it.route }
|
||||
|
||||
@Test fun aRustSiteShowsTheRustRowAndNoShardRows() {
|
||||
val routes = routesFor(serving(Capability.RUST))
|
||||
|
||||
assertTrue(Routes.RUST in routes)
|
||||
assertFalse("a Rust site has no shard", Routes.SHARD in routes)
|
||||
assertFalse(Routes.ATLAS in routes)
|
||||
}
|
||||
|
||||
@Test fun aUoSiteIsExactlyAsItWas() {
|
||||
// The other half of the criterion. Adding a second game module must not
|
||||
// put a row on a site that does not run it.
|
||||
val routes = routesFor(serving(Capability.SHARD))
|
||||
|
||||
assertFalse("a UO site has no Rust row", Routes.RUST in routes)
|
||||
assertTrue(Routes.SHARD in routes)
|
||||
}
|
||||
|
||||
@Test fun bothModulesInstalledShowsBothTrees() {
|
||||
val routes = routesFor(serving(Capability.SHARD, Capability.RUST))
|
||||
|
||||
assertTrue(Routes.SHARD in routes)
|
||||
assertTrue(Routes.RUST in routes)
|
||||
}
|
||||
|
||||
@Test fun theSurfaceCapabilitiesDoNotRevealTheRow() {
|
||||
// `module-rust` declares `servers`, `killfeed`, `leaderboard`, `presence`
|
||||
// and `wipes` as well, and the app gates on NONE of them — every one names
|
||||
// a surface, and core flattens all modules' capabilities into one list, so
|
||||
// another module declaring `servers` would otherwise reveal these screens
|
||||
// on a site with no Rust at all.
|
||||
val routes = routesFor(serving("servers", "killfeed", "leaderboard", "presence", "wipes"))
|
||||
|
||||
assertFalse(Routes.RUST in routes)
|
||||
}
|
||||
|
||||
@Test fun aHostThatHasNeverAnsweredStillShowsEverything() {
|
||||
// Fail-open on an UNKNOWN answer, which is not the same as an empty one.
|
||||
// The server gates every call regardless, so the cost of guessing wrong is
|
||||
// a link that briefly 404s.
|
||||
assertTrue(Routes.RUST in routesFor(null))
|
||||
}
|
||||
|
||||
@Test fun aServerIdIsEncodedIntoItsRoute() {
|
||||
assertEquals("rust/servers/main", Routes.rustServer("main"))
|
||||
assertEquals("rust/servers/eu-main", Routes.rustServer("eu-main"))
|
||||
// An operator names these, and nothing stops one carrying a character a
|
||||
// path would otherwise eat.
|
||||
assertEquals("rust/servers/a%2Fb", Routes.rustServer("a/b"))
|
||||
assertEquals("rust/servers/two%20words", Routes.rustServer("two words"))
|
||||
}
|
||||
|
||||
@Test fun theWebsiteNavRowOpensNatively() {
|
||||
// `module-rust` registers exactly one nav item, `{ label: 'Servers', to:
|
||||
// '/rust' }`. Without this mapping an admin's nav override on that row —
|
||||
// or an added link to it — hands off to a browser instead.
|
||||
assertEquals(Routes.RUST, appRouteForWebPath("/rust"))
|
||||
assertEquals(Routes.RUST, appRouteForWebPath("/rust/"))
|
||||
}
|
||||
|
||||
@Test fun anAddedLinkToOneServerResolves() {
|
||||
assertEquals(Routes.rustServer("main"), resolveWebPath("/rust/servers/main"))
|
||||
}
|
||||
|
||||
@Test fun theRustPrefixIsNotMistakenForACmsPage() {
|
||||
// The site serves CMS pages from a top-level `/<slug>`, and `/rust` would
|
||||
// otherwise fall into that rule and open a page-not-found screen. It is
|
||||
// reserved so the nav table above answers it — and so that a site WITHOUT
|
||||
// the module hands off to the browser, which gives the same answer the web
|
||||
// would.
|
||||
assertEquals(Routes.RUST, resolveWebPath("/rust"))
|
||||
assertNull("a deeper unknown Rust path hands off", resolveWebPath("/rust/servers/main/extra"))
|
||||
}
|
||||
|
||||
@Test fun aRustPathWithAQueryHandsOff() {
|
||||
// The website keeps tab, filter, wipe and sort in the URL; the app keeps
|
||||
// them in a view model. Resolving `?tab=wipes` natively would silently drop
|
||||
// what the admin wrote, so it goes to the browser, which honors it.
|
||||
assertNull(resolveWebPath("/rust?tab=wipes"))
|
||||
assertNull(resolveWebPath("/rust/servers/main?wipe=w1"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* The inbox timestamp (ENGAGEMENT.md phase 8). Both wire shapes have to be read,
|
||||
* and the zoneless one has to be read as UTC — reading it as local time would
|
||||
* shift every stamp by the device's offset and nobody would notice until they
|
||||
* travelled.
|
||||
*/
|
||||
class InboxFormattingTest {
|
||||
|
||||
private val zone = ZoneId.of("America/New_York")
|
||||
private val format: DateTimeFormatter =
|
||||
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm", Locale.US)
|
||||
|
||||
@Test fun readsAnIsoStampWithAZone() {
|
||||
// 12:30 UTC is 08:30 in New York on that date (EDT).
|
||||
assertEquals("2026-08-31 08:30", inboxTimestamp("2026-08-31T12:30:00.000Z", zone, format))
|
||||
}
|
||||
|
||||
@Test fun readsAZonelessStampAsUtc() {
|
||||
assertEquals("2026-08-31 08:30", inboxTimestamp("2026-08-31 12:30:00", zone, format))
|
||||
}
|
||||
|
||||
@Test fun readsAZonelessStampWithATSeparator() {
|
||||
assertEquals("2026-08-31 08:30", inboxTimestamp("2026-08-31T12:30:00", zone, format))
|
||||
}
|
||||
|
||||
@Test fun anUnparseableStampShowsNothingRatherThanFailing() {
|
||||
assertNull(inboxTimestamp("sometime last week", zone, format))
|
||||
assertNull(inboxTimestamp("", zone, format))
|
||||
assertNull(inboxTimestamp(" ", zone, format))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
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.core.inbox.InboxCache
|
||||
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
|
||||
import com.runicgateway.app.util.FakeInboxCache
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import com.runicgateway.app.util.httpError
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* The inbox view model (ENGAGEMENT.md phase 8): the pull behind the tickle, the
|
||||
* keyset paging, the optimistic reads, and the offline snapshot — including the
|
||||
* one property that makes caching a person's notifications safe at all, that a
|
||||
* snapshot never crosses an account.
|
||||
*/
|
||||
class InboxViewModelTest {
|
||||
|
||||
@get:Rule val mainDispatcher = MainDispatcherRule()
|
||||
|
||||
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 val api = FakeNotificationsApi()
|
||||
private val cache = FakeInboxCache()
|
||||
private val baseUrl = BaseUrlHolder().apply { set("https://shard.example/".toHttpUrl()) }
|
||||
|
||||
private fun session(userId: Long = 7) =
|
||||
SessionManager(FakeTokenStore(StoredSession("access", "refresh", userId, "alice", "player")))
|
||||
|
||||
private fun viewModel(sessionManager: SessionManager = session()) =
|
||||
InboxViewModel(NotificationsRepository(api), cache, sessionManager, baseUrl)
|
||||
|
||||
private fun item(id: Long, read: Boolean = false) =
|
||||
NotificationItemDto(id = id, triggerId = "team.post.created", title = "Post $id", read = read)
|
||||
|
||||
private fun shown(vm: InboxViewModel) = (vm.state.value.items as? UiState.Success)?.data
|
||||
|
||||
@Test fun loadsTheFirstPageAndCachesIt() {
|
||||
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2))
|
||||
val vm = viewModel()
|
||||
|
||||
assertEquals(listOf(2L, 1L), shown(vm)?.map { it.id })
|
||||
assertEquals(2, vm.state.value.unread)
|
||||
assertFalse(vm.state.value.fromCache)
|
||||
assertEquals(1, cache.writes)
|
||||
}
|
||||
|
||||
@Test fun offlineFallsBackToTheSnapshotAndSaysSo() {
|
||||
runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) }
|
||||
api.error = IOException("offline")
|
||||
val vm = viewModel()
|
||||
|
||||
assertEquals(listOf(9L), shown(vm)?.map { it.id })
|
||||
assertEquals(1, vm.state.value.unread)
|
||||
assertTrue(vm.state.value.fromCache)
|
||||
}
|
||||
|
||||
@Test fun aSnapshotIsNeverShownToAnotherAccount() {
|
||||
// The property the whole cache design rests on: user 7's items must not
|
||||
// appear under user 8's session, on a device both have signed into.
|
||||
runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) }
|
||||
api.error = IOException("offline")
|
||||
val vm = viewModel(session(userId = 8))
|
||||
|
||||
assertNull(shown(vm))
|
||||
assertTrue(vm.state.value.items is UiState.Error)
|
||||
assertEquals(0, vm.state.value.unread)
|
||||
}
|
||||
|
||||
@Test fun aServerErrorWithNothingCachedIsTheScreen() {
|
||||
api.error = httpError(500)
|
||||
val vm = viewModel()
|
||||
|
||||
assertTrue(vm.state.value.items is UiState.Error)
|
||||
assertFalse(vm.state.value.fromCache)
|
||||
}
|
||||
|
||||
@Test fun loadMorePagesOnTheLastIdNotAnOffset() {
|
||||
api.pages = mapOf(
|
||||
null to NotificationInboxDto(items = listOf(item(9), item(8)), hasMore = true, unread = 2),
|
||||
8L to NotificationInboxDto(items = listOf(item(7)), hasMore = false, unread = 2),
|
||||
)
|
||||
val vm = viewModel()
|
||||
vm.loadMore()
|
||||
|
||||
assertEquals(listOf(null, 8L), api.inboxCalls)
|
||||
assertEquals(listOf(9L, 8L, 7L), shown(vm)?.map { it.id })
|
||||
assertFalse(vm.state.value.hasMore)
|
||||
}
|
||||
|
||||
@Test fun loadMoreDropsAnIdAlreadyOnScreen() {
|
||||
// A keyset window can shift under a concurrent write; a duplicate id in a
|
||||
// LazyColumn key is a crash, not a cosmetic problem.
|
||||
api.pages = mapOf(
|
||||
null to NotificationInboxDto(items = listOf(item(9), item(8)), hasMore = true),
|
||||
8L to NotificationInboxDto(items = listOf(item(8), item(7))),
|
||||
)
|
||||
val vm = viewModel()
|
||||
vm.loadMore()
|
||||
|
||||
assertEquals(listOf(9L, 8L, 7L), shown(vm)?.map { it.id })
|
||||
}
|
||||
|
||||
@Test fun loadMoreDoesNothingWhileShowingTheCache() {
|
||||
runBlocking { cache.seed(InboxCache.ownerKey("https://shard.example/", 7), listOf(item(9)), unread = 1) }
|
||||
api.error = IOException("offline")
|
||||
val vm = viewModel()
|
||||
api.inboxCalls.clear()
|
||||
vm.loadMore()
|
||||
|
||||
assertTrue(api.inboxCalls.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun markReadFlipsTheRowAndTakesTheServersCount() {
|
||||
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2))
|
||||
api.readResult = NotificationReadResultDto(ok = true, unread = 1)
|
||||
val vm = viewModel()
|
||||
vm.markRead(2)
|
||||
|
||||
assertEquals(listOf(2L), api.markedRead)
|
||||
assertTrue(shown(vm)!!.first { it.id == 2L }.read)
|
||||
assertEquals(1, vm.state.value.unread)
|
||||
}
|
||||
|
||||
@Test fun markReadIsNotSentTwiceForAnItemAlreadyRead() {
|
||||
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2, read = true)), unread = 0))
|
||||
val vm = viewModel()
|
||||
vm.markRead(2)
|
||||
|
||||
assertTrue(api.markedRead.isEmpty())
|
||||
}
|
||||
|
||||
@Test fun markAllReadEmptiesTheBadgeAndUpdatesTheSnapshot() {
|
||||
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(2), item(1)), unread = 2))
|
||||
val vm = viewModel()
|
||||
val writesAfterLoad = cache.writes
|
||||
vm.markAllRead()
|
||||
|
||||
assertEquals(1, api.markAllReadCalls)
|
||||
assertEquals(0, vm.state.value.unread)
|
||||
assertTrue(shown(vm)!!.all { it.read })
|
||||
// Without this write, going offline right after reading everything would
|
||||
// bring the badge back on the next cold open.
|
||||
assertEquals(writesAfterLoad + 1, cache.writes)
|
||||
}
|
||||
|
||||
// ── The item link (found by the live rig, not by a test) ──────────────
|
||||
|
||||
@Test fun aSiteRelativeUrlIsResolvedAgainstTheShard() {
|
||||
// What the server actually writes: the template's button block renders a
|
||||
// path, because on the web the reader is already on the site.
|
||||
val vm = viewModel()
|
||||
val item = item(1).copy(url = "/guilds/the-silver-anvil/forum/403")
|
||||
assertEquals("https://shard.example/guilds/the-silver-anvil/forum/403", vm.linkFor(item))
|
||||
}
|
||||
|
||||
@Test fun anAbsoluteUrlIsLeftAlone() {
|
||||
val vm = viewModel()
|
||||
assertEquals("https://elsewhere.example/x", vm.linkFor(item(1).copy(url = "https://elsewhere.example/x")))
|
||||
}
|
||||
|
||||
@Test fun anItemWithNoUrlHasNoLink() {
|
||||
val vm = viewModel()
|
||||
assertNull(vm.linkFor(item(1)))
|
||||
assertNull(vm.linkFor(item(1).copy(url = " ")))
|
||||
}
|
||||
|
||||
@Test fun aUrlThatCouldNotBeOpenedSafelyResolvesToNothing() {
|
||||
val vm = viewModel()
|
||||
assertNull(vm.linkFor(item(1).copy(url = "javascript:alert(1)")))
|
||||
assertNull(vm.linkFor(item(1).copy(url = "intent://evil#Intent;end")))
|
||||
}
|
||||
|
||||
// ── The inbox belongs to ONE account ─────────────────────────────
|
||||
|
||||
@Test fun switchingAccountDoesNotShowThePreviousOnesInbox() {
|
||||
// **Found on the emulator, not by a test.** A drawer route's view model
|
||||
// outlives a sign-out: `navigateTopLevel` saves and restores back-stack
|
||||
// state, so the entry keeps its ViewModelStore and a view model that
|
||||
// loaded only in `init` never runs again. Signing out and back in as
|
||||
// somebody else showed the second account the FIRST account's inbox —
|
||||
// titles and body text written for another person — with no request made
|
||||
// at all, while the badge beside it showed the new account's real count.
|
||||
val sessions = session(userId = 7)
|
||||
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(1), item(2)), unread = 2))
|
||||
val vm = InboxViewModel(NotificationsRepository(api), cache, sessions, baseUrl)
|
||||
assertEquals(listOf(1L, 2L), shown(vm)!!.map { it.id })
|
||||
|
||||
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(9)), unread = 1))
|
||||
sessions.onSignedOut()
|
||||
// Signed out, the previous account's rows are gone rather than left
|
||||
// addressable behind a shell that is navigating away.
|
||||
assertEquals(emptyList<Long>(), shown(vm)!!.map { it.id })
|
||||
|
||||
sessions.onSignedIn("a", "r", SafeUserDto(id = 8, username = "bob", role = "player"))
|
||||
assertEquals(listOf(9L), shown(vm)!!.map { it.id })
|
||||
}
|
||||
|
||||
@Test fun aResumeRevalidationReturningTheSameUserDoesNotRefetch() {
|
||||
// The gate is the account, not every session emission — the app
|
||||
// re-validates its role on every resume.
|
||||
val sessions = session(userId = 7)
|
||||
api.pages = mapOf(null to NotificationInboxDto(items = listOf(item(1)), unread = 1))
|
||||
val vm = InboxViewModel(NotificationsRepository(api), cache, sessions, baseUrl)
|
||||
val callsAfterFirstLoad = api.inboxCalls.size
|
||||
|
||||
sessions.onUserRefreshed(SafeUserDto(id = 7, username = "alice", role = "admin"))
|
||||
|
||||
assertEquals(callsAfterFirstLoad, api.inboxCalls.size)
|
||||
assertEquals(listOf(1L), shown(vm)!!.map { it.id })
|
||||
}
|
||||
|
||||
// ── Opening an item in the app rather than a browser (M13) ─────────
|
||||
|
||||
@Test fun anEventAnnouncementOpensNativelyAndKeepsItsRun() {
|
||||
// The exact shape events Phase 14a writes into every announcement. Before
|
||||
// M13 this opened a Custom Tab onto a page the app now renders itself.
|
||||
val vm = viewModel()
|
||||
val item = item(1).copy(url = "/site/events/the-yew-invasion?run=3692")
|
||||
|
||||
assertEquals("events/the-yew-invasion?run=3692", vm.routeFor(item))
|
||||
}
|
||||
|
||||
@Test fun anAbsoluteUrlOnThisHostOpensNativelyToo() {
|
||||
// The url's shape is the server's to change; a link that reached the browser
|
||||
// only because it arrived fully qualified would be a puzzle.
|
||||
val vm = viewModel()
|
||||
val item = item(1).copy(url = "https://shard.example/site/events/yew?run=7")
|
||||
|
||||
assertEquals("events/yew?run=7", vm.routeFor(item))
|
||||
}
|
||||
|
||||
@Test fun aLinkToAnotherHostIsNotOursToRoute() {
|
||||
val vm = viewModel()
|
||||
val item = item(1).copy(url = "https://elsewhere.example/site/events/yew")
|
||||
|
||||
assertNull(vm.routeFor(item))
|
||||
// …and still opens, in the browser, exactly as it did before.
|
||||
assertEquals("https://elsewhere.example/site/events/yew", vm.linkFor(item))
|
||||
}
|
||||
|
||||
@Test fun everyOtherLinkStillHandsOff() {
|
||||
// The change is additive: a path the app has no screen for behaves exactly
|
||||
// as it did, and `linkFor` is still what opens it.
|
||||
val vm = viewModel()
|
||||
val forum = item(1).copy(url = "/guilds/the-silver-anvil/forum/403")
|
||||
|
||||
assertNull(vm.routeFor(forum))
|
||||
assertEquals("https://shard.example/guilds/the-silver-anvil/forum/403", vm.linkFor(forum))
|
||||
}
|
||||
|
||||
@Test fun anItemWithNoUrlHasNoRoute() {
|
||||
val vm = viewModel()
|
||||
assertNull(vm.routeFor(item(1)))
|
||||
assertNull(vm.routeFor(item(1).copy(url = " ")))
|
||||
assertNull(vm.routeFor(item(1).copy(url = "javascript:alert(1)")))
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
import com.runicgateway.app.core.push.PushStreams
|
||||
import com.runicgateway.app.data.api.dto.NotificationStreamDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
|
||||
import com.runicgateway.app.ui.navigation.Routes
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -12,8 +12,9 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests the pure push helpers: the stream → deep-link route map (PLAN.md §11 work
|
||||
* item 7) and the personal-stream gating (a personal stream needs a linked account).
|
||||
* Tests the pure notification helpers: the stream → deep-link route map (PLAN.md
|
||||
* §11 work item 7), the tickle routing that ENGAGEMENT.md phase 8 layered over it,
|
||||
* and the personal-item gating (a personal id needs a linked game account).
|
||||
*/
|
||||
class NotificationRoutingTest {
|
||||
|
||||
@@ -32,14 +33,41 @@ class NotificationRoutingTest {
|
||||
assertEquals(Routes.HOME, Routes.forStream("something.new"))
|
||||
}
|
||||
|
||||
@Test fun personalStreamNeedsLinkedAccount() {
|
||||
val personal = NotificationStreamDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true)
|
||||
assertFalse(streamSelectable(personal, hasLinkedAccount = false))
|
||||
assertTrue(streamSelectable(personal, hasLinkedAccount = true))
|
||||
@Test fun personalItemNeedsLinkedAccount() {
|
||||
val personal = NotificationChannelItemDto(id = "vendor.sale", personal = true, requiresLinkedAccount = true)
|
||||
assertFalse(itemSelectable(personal, hasLinkedAccount = false))
|
||||
assertTrue(itemSelectable(personal, hasLinkedAccount = true))
|
||||
}
|
||||
|
||||
@Test fun generalStreamIsAlwaysSelectable() {
|
||||
val general = NotificationStreamDto(id = "news.post", personal = false, requiresLinkedAccount = false)
|
||||
assertTrue(streamSelectable(general, hasLinkedAccount = false))
|
||||
@Test fun generalItemIsAlwaysSelectable() {
|
||||
val general = NotificationChannelItemDto(id = "news.post", personal = false, requiresLinkedAccount = false)
|
||||
assertTrue(itemSelectable(general, hasLinkedAccount = false))
|
||||
}
|
||||
|
||||
// ── The tickle → destination map (ENGAGEMENT.md phase 8) ───────────────
|
||||
|
||||
@Test fun inboxRefLandsOnTheInboxWhateverTheStream() {
|
||||
// The engine's stream id is a TRIGGER id in the one namespace, so most of
|
||||
// them are strangers to `forStream` — and every one of those would have
|
||||
// dropped the user on Home if the ref were not read.
|
||||
assertEquals(Routes.NOTIFICATIONS, Routes.forTickle("team.post.created", "notification:42"))
|
||||
assertEquals(Routes.NOTIFICATIONS, Routes.forTickle("uo.house.idoc_warning", "notification:7"))
|
||||
}
|
||||
|
||||
@Test fun anInboxRefWinsOverAStreamThatHasItsOwnScreen() {
|
||||
assertEquals(Routes.NOTIFICATIONS, Routes.forTickle(PushStreams.NEWS_POST, "notification:1"))
|
||||
}
|
||||
|
||||
@Test fun everyOtherTickleKeepsTheRouteItAlwaysHad() {
|
||||
assertEquals(Routes.NEWS, Routes.forTickle(PushStreams.NEWS_POST, null))
|
||||
assertEquals(Routes.SHARD, Routes.forTickle(PushStreams.CHAMP_START, "0x40001234"))
|
||||
assertEquals(Routes.PLAYER_HOUSES, Routes.forTickle(PushStreams.HOUSE_IDOC, "britain-2026-08-31"))
|
||||
assertEquals(Routes.HOME, Routes.forTickle("something.new", null))
|
||||
}
|
||||
|
||||
@Test fun aRefThatMerelyMentionsNotificationIsNotAnInboxRef() {
|
||||
// Prefix, not `contains`: a ref is opaque and another producer's could
|
||||
// easily carry the word without being a row id.
|
||||
assertEquals(Routes.NEWS, Routes.forTickle(PushStreams.NEWS_POST, "post-notification:3"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.notifications
|
||||
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelItemDto
|
||||
import com.runicgateway.app.data.api.dto.NotificationChannelPrefsDto
|
||||
import com.runicgateway.app.data.api.fake.FakeNotificationsApi
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The per-channel preferences the settings screen renders (ENGAGEMENT.md phases 3
|
||||
* and 8). These are the wire-shape properties the UI is built ON rather than
|
||||
* around, so they are asserted here rather than trusted: the controls come from
|
||||
* each item's own `channels`, and the modes from each channel's own `modes`.
|
||||
*
|
||||
* The view model itself needs a [com.runicgateway.app.core.push.PushManager],
|
||||
* which owns a foreground service and a `Context`; the sparse-PUT shape it sends
|
||||
* is asserted through the repository instead, which is the part that could be
|
||||
* wrong on the wire.
|
||||
*/
|
||||
class NotificationSettingsViewModelTest {
|
||||
|
||||
private val push = NotificationChannelDto(
|
||||
id = "push", label = "Push", carriesContent = false,
|
||||
defaultMode = "off", supportsDigest = false, modes = listOf("off", "instant"),
|
||||
)
|
||||
private val email = NotificationChannelDto(
|
||||
id = "email", label = "Email", carriesContent = true,
|
||||
defaultMode = "off", supportsDigest = true, modes = listOf("off", "instant", "digest"),
|
||||
)
|
||||
|
||||
private fun prefs() = NotificationChannelPrefsDto(
|
||||
channels = listOf(push, email),
|
||||
items = listOf(
|
||||
NotificationChannelItemDto(
|
||||
id = "news.post", label = "News posts",
|
||||
channels = listOf("push", "email"),
|
||||
modes = mapOf("push" to "instant", "email" to "off"),
|
||||
),
|
||||
NotificationChannelItemDto(
|
||||
id = "uo.house.idoc_warning", label = "House in danger",
|
||||
personal = true, requiresLinkedAccount = true,
|
||||
// A trigger-only id: nothing is registered to push it, so it carries
|
||||
// no push key at all — the screen must render no push control rather
|
||||
// than a dead switch.
|
||||
channels = listOf("email"),
|
||||
modes = mapOf("email" to "digest"),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@Test fun aTriggerOnlyIdOffersNoPushControl() {
|
||||
val item = prefs().items.first { it.id == "uo.house.idoc_warning" }
|
||||
assertEquals(listOf("email"), item.channels)
|
||||
assertNull(item.modes[CHANNEL_PUSH])
|
||||
}
|
||||
|
||||
@Test fun emailIsTheChannelThatCarriesDigest() {
|
||||
assertEquals(listOf("off", "instant", "digest"), email.modes)
|
||||
assertEquals(listOf("off", "instant"), push.modes)
|
||||
}
|
||||
|
||||
@Test fun personalItemsStillNeedALinkedAccount() {
|
||||
val personal = prefs().items.first { it.personal }
|
||||
assertEquals(false, itemSelectable(personal, hasLinkedAccount = false))
|
||||
assertEquals(true, itemSelectable(personal, hasLinkedAccount = true))
|
||||
}
|
||||
|
||||
@Test fun oneToggleSendsExactlyOnePair() = kotlinx.coroutines.runBlocking {
|
||||
// The sparse PUT is the whole reason this screen can save a single control
|
||||
// without holding the table: anything more in the body could clobber a
|
||||
// channel it is not showing.
|
||||
val api = FakeNotificationsApi()
|
||||
api.channelPrefs = prefs()
|
||||
val repo = com.runicgateway.app.data.repository.NotificationsRepository(api)
|
||||
|
||||
repo.setChannelMode("news.post", "email", "digest")
|
||||
|
||||
assertEquals(1, api.lastPrefsUpdate?.size)
|
||||
assertEquals("news.post", api.lastPrefsUpdate?.first()?.id)
|
||||
assertEquals("email", api.lastPrefsUpdate?.first()?.channel)
|
||||
assertEquals("digest", api.lastPrefsUpdate?.first()?.mode)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import com.runicgateway.app.data.api.fake.FakeRustApi
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import java.io.IOException
|
||||
|
||||
/** The drawer row's live count — the phone's answer to D15 (M14). */
|
||||
class RustBadgeViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val dispatcherRule = MainDispatcherRule()
|
||||
|
||||
private val api = FakeRustApi()
|
||||
private fun viewModel() = RustBadgeViewModel(RustRepository(api))
|
||||
|
||||
@Test
|
||||
fun `it sums the people on every server`() {
|
||||
api.servers = RustServerListDto(
|
||||
listOf(
|
||||
RustServerDto(id = "a", online = true, players = 12),
|
||||
RustServerDto(id = "b", online = true, players = 30),
|
||||
),
|
||||
)
|
||||
|
||||
val vm = viewModel()
|
||||
vm.refresh(installed = true)
|
||||
|
||||
assertEquals(42, vm.online.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a site without the module is never asked`() {
|
||||
// The gate is the caller's — the drawer already knows, from the capability
|
||||
// answer. A site running a different game makes no request at all.
|
||||
val vm = viewModel()
|
||||
vm.refresh(installed = false)
|
||||
|
||||
assertEquals(0, api.serversCalls)
|
||||
assertEquals(0, vm.online.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unreachable server contributes nothing on its own`() {
|
||||
// A stale or offline row already answers `online: false` with `players: 0`
|
||||
// server-side, so there is no second staleness rule to keep in step here.
|
||||
api.servers = RustServerListDto(
|
||||
listOf(
|
||||
RustServerDto(id = "a", online = true, players = 5),
|
||||
RustServerDto(id = "b", online = false, players = 0, stale = true),
|
||||
),
|
||||
)
|
||||
|
||||
val vm = viewModel()
|
||||
vm.refresh(installed = true)
|
||||
|
||||
assertEquals(5, vm.online.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed read keeps the last number rather than dropping to zero`() {
|
||||
// A moment with no connectivity is not everybody logging off.
|
||||
api.servers = RustServerListDto(listOf(RustServerDto(id = "a", online = true, players = 7)))
|
||||
val vm = viewModel()
|
||||
vm.refresh(installed = true)
|
||||
|
||||
api.error = IOException("offline")
|
||||
vm.refresh(installed = true)
|
||||
|
||||
assertEquals(7, vm.online.value)
|
||||
}
|
||||
}
|
||||
105
app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt
Normal file
105
app/src/test/java/com/runicgateway/app/ui/rust/RustDtoTest.kt
Normal file
@@ -0,0 +1,105 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerListDto
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Decoding what the module actually answers with (M14).
|
||||
*
|
||||
* The JSON here is copied from `module-rust`'s own shape functions rather than
|
||||
* invented, because the only thing worth testing about a DTO is whether it agrees
|
||||
* with the other end.
|
||||
*/
|
||||
class RustDtoTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun `an unreachable server decodes with everything it last said`() {
|
||||
// The phase criterion in one object: `online: false`, `stale: true`, and
|
||||
// every descriptive field still populated. This is what makes a page that
|
||||
// renders while the game is off possible at all.
|
||||
val list = json.decodeFromString<RustServerListDto>(
|
||||
"""
|
||||
{"servers":[{
|
||||
"id":"main","name":"Main","online":false,"players":0,"maxPlayers":100,
|
||||
"hostname":"Main | Vanilla","level":"Procedural Map","worldSize":4000,
|
||||
"seed":1934567,"wipeId":"w-2026-09-04","wipedAt":"2026-09-04T18:00:00.000Z",
|
||||
"lastSeenAt":"2026-09-14T10:12:00.000Z","updatedAt":"2026-09-16T13:59:30.000Z",
|
||||
"stale":true
|
||||
}]}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val server = list.servers.single()
|
||||
assertFalse(server.online)
|
||||
assertTrue(server.stale)
|
||||
assertEquals("Procedural Map", server.level)
|
||||
assertEquals(4000, server.worldSize)
|
||||
// The two timestamps are two facts and both survive the wire.
|
||||
assertEquals("2026-09-14T10:12:00.000Z", server.lastSeenAt)
|
||||
assertEquals("2026-09-16T13:59:30.000Z", server.updatedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server that has never connected decodes with nulls, not zeroes`() {
|
||||
val list = json.decodeFromString<RustServerListDto>(
|
||||
"""{"servers":[{"id":"new","name":"New","online":false,"players":0,"maxPlayers":0,
|
||||
"hostname":null,"level":null,"worldSize":null,"seed":null,"wipeId":null,
|
||||
"wipedAt":null,"lastSeenAt":null,"updatedAt":null,"stale":true}]}""",
|
||||
)
|
||||
|
||||
val server = list.servers.single()
|
||||
assertNull(server.level)
|
||||
assertNull(server.worldSize)
|
||||
assertNull(server.lastSeenAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown field does not break decoding`() {
|
||||
// Protocol 2 is not the last one. A later plugin adds fields to a frame's
|
||||
// envelope and an older app must keep reading the rest.
|
||||
val list = json.decodeFromString<RustServerListDto>(
|
||||
"""{"servers":[{"id":"main","name":"Main","somethingNew":{"a":1}}],"alsoNew":7}""",
|
||||
)
|
||||
|
||||
assertEquals("main", list.servers.single().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame keeps whatever the plugin wrote`() {
|
||||
val event = json.decodeFromString<RustEventDto>(
|
||||
"""{"id":9,"kind":"player.death","t":1789574400000,"wipeId":"w1","steamId":"765",
|
||||
"frame":{"name":"Bob","attackerType":"player","attackerName":"Alice","distance":42.4}}""",
|
||||
)
|
||||
|
||||
assertEquals("player.death", event.kind)
|
||||
assertEquals(1789574400000L, event.t)
|
||||
assertEquals("Alice", event.str("attackerName"))
|
||||
assertEquals(42.4, event.num("distance")!!, 0.001)
|
||||
assertNull("an absent field is null, not empty", event.str("weapon"))
|
||||
assertFalse(event.flag("sleeping"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a frame with no fields at all still decodes`() {
|
||||
// `server.initialized` carries an envelope and nothing else, and the model
|
||||
// answers an EMPTY frame for a stored row whose JSON will not parse —
|
||||
// deliberately, so one bad row does not fail a whole page.
|
||||
val event = json.decodeFromString<RustEventDto>(
|
||||
"""{"id":1,"kind":"server.initialized","t":1,"frame":{}}""",
|
||||
)
|
||||
|
||||
assertNull(event.str("name"))
|
||||
assertEquals(FeedTone.SERVER, describe(event).tone)
|
||||
}
|
||||
}
|
||||
140
app/src/test/java/com/runicgateway/app/ui/rust/RustFeedTest.kt
Normal file
140
app/src/test/java/com/runicgateway/app/ui/rust/RustFeedTest.kt
Normal file
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import com.runicgateway.app.data.api.dto.RustEventDto
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* What a feed row says (M14).
|
||||
*
|
||||
* The frames here are the shapes the bridge plugin actually emits, written as
|
||||
* JSON rather than built with a DTO constructor — the whole point of the untyped
|
||||
* `frame` is that the app reads names off the wire, and a test that bypassed the
|
||||
* parse would prove nothing about the names.
|
||||
*/
|
||||
class RustFeedTest {
|
||||
|
||||
private fun row(kind: String, frame: String = "{}", t: Long = 1_000): RustEventDto =
|
||||
RustEventDto(id = 1, kind = kind, t = t, frame = Json.parseToJsonElement(frame) as JsonObject)
|
||||
|
||||
@Test
|
||||
fun `a player kill names the killer, the victim and the weapon`() {
|
||||
val line = describe(
|
||||
row(
|
||||
"player.death",
|
||||
"""{"name":"Bob","attackerType":"player","attackerName":"Alice","weapon":"rifle.ak","distance":42.4}""",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(FeedTone.KILL, line.tone)
|
||||
assertEquals("Alice", line.actor)
|
||||
assertEquals("killed", line.verb)
|
||||
assertEquals("Bob", line.subject)
|
||||
assertTrue(line.detail.contains("with rifle ak"))
|
||||
assertTrue(line.detail.contains("42m"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a fall is a death by nobody, not a kill`() {
|
||||
// The failure this distinction exists to avoid: `HitInfo` is legitimately
|
||||
// null when the world kills somebody, so an ABSENT attacker type is the
|
||||
// environment case rather than a missing field. Reporting it as a kill by
|
||||
// nobody is the bug.
|
||||
val line = describe(row("player.death", """{"name":"Bob"}"""))
|
||||
|
||||
assertEquals(FeedTone.DEATH, line.tone)
|
||||
assertEquals("Bob", line.actor)
|
||||
assertEquals("died", line.verb)
|
||||
assertNull(line.subject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an NPC kill reads the prefab as words`() {
|
||||
val line = describe(
|
||||
row("player.death", """{"name":"Bob","attackerType":"npc","attackerName":"patrolhelicopter"}"""),
|
||||
)
|
||||
|
||||
assertEquals("patrolhelicopter", line.actor)
|
||||
assertEquals("Bob", line.subject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a suicide names one person once`() {
|
||||
val line = describe(row("player.death", """{"name":"Bob","attackerType":"self"}"""))
|
||||
|
||||
assertEquals("Bob", line.actor)
|
||||
assertNull(line.subject)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `chat puts the colon in the join, never in the message`() {
|
||||
val line = describe(row("player.chat", """{"name":"Bob","message":"see you in september"}"""))
|
||||
|
||||
assertEquals(": ", line.join)
|
||||
assertEquals("see you in september", line.verb)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a disconnect with no session reports only the reason`() {
|
||||
// The plugin OMITS `sessionSec` for a player who was already connected when
|
||||
// it loaded, so an absent value means "unknown" and must not become "after
|
||||
// 0s" — which is what a DTO default of zero would produce if it were read
|
||||
// without this guard.
|
||||
val line = describe(row("player.disconnected", """{"name":"Bob","reason":"Disconnected"}"""))
|
||||
|
||||
assertEquals("Disconnected", line.detail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a JSON null field does not become the word null`() {
|
||||
// A primitive's `content` is literally "null" for a JSON null, and the
|
||||
// plugin writes explicit nulls — so a naive read puts the four letters
|
||||
// into a killfeed line.
|
||||
val line = describe(row("player.disconnected", """{"name":"Bob","reason":null,"sessionSec":null}"""))
|
||||
|
||||
assertEquals("", line.detail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown kind renders as itself rather than vanishing`() {
|
||||
// A later protocol adds kinds and an operator's module may be older than
|
||||
// their game host. The server's allowlist has already decided the row may
|
||||
// be seen; dropping it here would be the screen quietly saying less than
|
||||
// the truth.
|
||||
val line = describe(row("player.teleported", """{"name":"Bob"}"""))
|
||||
|
||||
assertEquals(FeedTone.OTHER, line.tone)
|
||||
assertEquals("player.teleported", line.verb)
|
||||
assertEquals("Bob", line.actor)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the tally kind is not in the feed's own list`() {
|
||||
// It is an aggregate the plugin flushes every sixty seconds per active
|
||||
// player, so a feed carrying it would be mostly wood counts. It is the
|
||||
// leaderboard's input.
|
||||
assertTrue("player.tally" !in FEED_KINDS)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every filter asks for kinds the feed knows`() {
|
||||
for (filter in FEED_FILTERS) {
|
||||
assertTrue(
|
||||
"${filter.id} asks for a kind the feed does not list",
|
||||
FEED_KINDS.containsAll(filter.kinds),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unknown filter id falls back to everything`() {
|
||||
assertEquals(FEED_KINDS, kindsFor("nonsense"))
|
||||
}
|
||||
}
|
||||
134
app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt
Normal file
134
app/src/test/java/com/runicgateway/app/ui/rust/RustFormatTest.kt
Normal file
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.util.Locale
|
||||
|
||||
/** Formatting rules the Rust screens depend on (M14). */
|
||||
class RustFormatTest {
|
||||
|
||||
private val utc = ZoneId.of("UTC")
|
||||
private val uk = Locale.UK
|
||||
private val now = Instant.parse("2026-09-16T14:00:00Z")
|
||||
|
||||
@Test
|
||||
fun `a row from today is a bare time`() {
|
||||
val stamp = Instant.parse("2026-09-16T09:05:00Z").toEpochMilli()
|
||||
val text = feedClock(value = null, epochMillis = stamp, now = now, zone = utc, locale = uk)
|
||||
|
||||
assertTrue(text, ':' in text)
|
||||
assertTrue("today's row should carry no date: $text", "Sep" !in text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a row from another day carries its date`() {
|
||||
// The defect this rule exists for: the feed can be filtered to a past
|
||||
// wipe, and three events from six weeks ago all rendered as `02:03 PM`
|
||||
// read as this afternoon. The boundary is the CALENDAR day, not a
|
||||
// duration, because that is what a reader means by "what time was that".
|
||||
val stamp = Instant.parse("2026-08-05T09:05:00Z").toEpochMilli()
|
||||
val text = feedClock(value = null, epochMillis = stamp, now = now, zone = utc, locale = uk)
|
||||
|
||||
assertTrue("an older row should carry a date: $text", "Aug" in text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yesterday is another day even when it is minutes ago`() {
|
||||
val justBeforeMidnight = Instant.parse("2026-09-15T23:58:00Z").toEpochMilli()
|
||||
val shortlyAfter = Instant.parse("2026-09-16T00:02:00Z")
|
||||
val text = feedClock(
|
||||
value = null,
|
||||
epochMillis = justBeforeMidnight,
|
||||
now = shortlyAfter,
|
||||
zone = utc,
|
||||
locale = uk,
|
||||
)
|
||||
|
||||
assertTrue("four minutes ago but a different day: $text", "Sep" in text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unparseable stamp is empty rather than a guess`() {
|
||||
assertEquals("", feedClock(value = null, epochMillis = null, now = now, zone = utc, locale = uk))
|
||||
assertEquals("", feedClock(value = "not a date", now = now, zone = utc, locale = uk))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a zoneless DATETIME is read as UTC`() {
|
||||
// Express serializes a Date to ISO with a `Z`, but these values start life
|
||||
// as MariaDB DATETIME columns and one read back as a string reaches the
|
||||
// wire with no zone at all. Reading it as local time silently shifts every
|
||||
// timestamp by the device's offset — a bug that looks right on the machine
|
||||
// it was written on.
|
||||
val text = feedClock(
|
||||
value = "2026-08-05 09:05:00",
|
||||
now = now,
|
||||
zone = utc,
|
||||
locale = uk,
|
||||
)
|
||||
|
||||
assertTrue(text, "Aug" in text)
|
||||
assertTrue(text, "09:05" in text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a server that has never reported has no ago line at all`() {
|
||||
// Null rather than the word "never", so the caller decides what that looks
|
||||
// like — on this surface a server that has never reported is a real and
|
||||
// ordinary state, not a missing value to apologise for.
|
||||
assertNull(rustAgo(null, now))
|
||||
assertNull(rustAgo("", now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `under a minute says just now rather than in zero seconds`() {
|
||||
assertEquals("just now", rustAgo("2026-09-16T13:59:40Z", now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ago picks the largest unit that fits`() {
|
||||
assertEquals("3 minutes ago", rustAgo("2026-09-16T13:57:00Z", now))
|
||||
assertEquals("2 hours ago", rustAgo("2026-09-16T12:00:00Z", now))
|
||||
assertEquals("1 day ago", rustAgo("2026-09-15T14:00:00Z", now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `playtime drops seconds above a minute and keeps them below`() {
|
||||
assertEquals("—", playtime(null))
|
||||
assertEquals("—", playtime(0))
|
||||
assertEquals("40s", playtime(40))
|
||||
assertEquals("12m", playtime(12 * 60))
|
||||
assertEquals("4h 12m", playtime(4 * 3600 + 12 * 60))
|
||||
assertEquals("2h", playtime(2 * 3600))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a prefab reads as words without a lookup table`() {
|
||||
assertEquals("rifle ak", prefabName("rifle.ak"))
|
||||
assertEquals("patrolhelicopter", prefabName("patrolhelicopter"))
|
||||
assertEquals("", prefabName(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a nameless player shows a shortened id, not the word unknown`() {
|
||||
// A steam id is not a name and does not look like one, which is the point:
|
||||
// the plugin knows an id before it knows anything else, and "Unknown" would
|
||||
// lose the only identifier there is.
|
||||
assertEquals("…345678", playerLabel(null, "76561198012345678"))
|
||||
assertEquals("Bob", playerLabel("Bob", "76561198012345678"))
|
||||
assertEquals("…345678", playerLabel(" ", "76561198012345678"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a short id is left whole`() {
|
||||
assertEquals("1234", shortSteamId("1234"))
|
||||
assertEquals("", shortSteamId(null))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.ui.rust
|
||||
|
||||
import androidx.lifecycle.SavedStateHandle
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardDto
|
||||
import com.runicgateway.app.data.api.dto.RustLeaderboardRowDto
|
||||
import com.runicgateway.app.data.api.dto.RustOnlineDto
|
||||
import com.runicgateway.app.data.api.dto.RustPresenceDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerDto
|
||||
import com.runicgateway.app.data.api.dto.RustServerResponse
|
||||
import com.runicgateway.app.data.api.dto.RustWipeDto
|
||||
import com.runicgateway.app.data.api.dto.RustWipeListDto
|
||||
import com.runicgateway.app.data.api.fake.FakeRustApi
|
||||
import com.runicgateway.app.data.repository.RustRepository
|
||||
import com.runicgateway.app.ui.UiState
|
||||
import com.runicgateway.app.util.MainDispatcherRule
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import java.io.IOException
|
||||
|
||||
/** One server's page: what it asks for, and when (M14). */
|
||||
class RustServerViewModelTest {
|
||||
|
||||
@get:Rule
|
||||
val dispatcherRule = MainDispatcherRule()
|
||||
|
||||
private val api = FakeRustApi()
|
||||
private val repository = RustRepository(api)
|
||||
|
||||
private fun viewModel(id: String = "main") = RustServerViewModel(
|
||||
repository,
|
||||
SavedStateHandle(mapOf("serverId" to id)),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `it opens on the feed and asks for nothing else`() {
|
||||
api.server = RustServerResponse(RustServerDto(id = "main", name = "Main"))
|
||||
|
||||
val vm = viewModel()
|
||||
|
||||
assertEquals(RustTab.FEED, vm.state.value.tab)
|
||||
assertEquals(1, api.eventCalls)
|
||||
assertEquals(0, api.leaderboardCalls)
|
||||
assertEquals(0, api.onlineCalls)
|
||||
assertEquals(0, api.wipeCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `all time sends no wipe parameter at all`() {
|
||||
// `?wipe=` asks for a wipe whose id is the empty string and answers
|
||||
// nothing, with no error to notice. An absent parameter must be ABSENT.
|
||||
viewModel()
|
||||
|
||||
assertNull(api.lastFeedWipe)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `everything sends the whole kind list, and kills sends one`() {
|
||||
val vm = viewModel()
|
||||
assertEquals(FEED_KINDS.joinToString(","), api.lastKind)
|
||||
|
||||
vm.selectFilter("kills")
|
||||
assertEquals("player.death", api.lastKind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a tab is loaded once, not on every visit`() {
|
||||
// Re-asking on every tab switch would put a spinner over a leaderboard the
|
||||
// reader has already read, for an answer that cannot have changed while
|
||||
// they were three taps away.
|
||||
api.leaderboard = RustLeaderboardDto(listOf(RustLeaderboardRowDto(steamId = "1", kills = 3)))
|
||||
val vm = viewModel()
|
||||
|
||||
vm.selectTab(RustTab.LEADERBOARD)
|
||||
vm.selectTab(RustTab.FEED)
|
||||
vm.selectTab(RustTab.LEADERBOARD)
|
||||
|
||||
assertEquals(1, api.leaderboardCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the poll asks only for the panel on screen`() {
|
||||
api.online = RustOnlineDto(listOf(RustPresenceDto(steamId = "1")))
|
||||
val vm = viewModel()
|
||||
val feedBefore = api.eventCalls
|
||||
|
||||
vm.selectTab(RustTab.ONLINE)
|
||||
val onlineBefore = api.onlineCalls
|
||||
vm.refresh()
|
||||
|
||||
assertEquals("the feed is not on screen", feedBefore, api.eventCalls)
|
||||
assertEquals(onlineBefore + 1, api.onlineCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a poll on a still panel asks for nothing but the server line`() {
|
||||
api.wipes = RustWipeListDto(listOf(RustWipeDto(wipeId = "w1")))
|
||||
val vm = viewModel()
|
||||
|
||||
vm.selectTab(RustTab.WIPES)
|
||||
val wipesBefore = api.wipeCalls
|
||||
val feedBefore = api.eventCalls
|
||||
vm.refresh()
|
||||
|
||||
assertEquals(wipesBefore, api.wipeCalls)
|
||||
assertEquals(feedBefore, api.eventCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `choosing a wipe re-asks the feed and, if it is loaded, the leaderboard`() {
|
||||
api.leaderboard = RustLeaderboardDto(listOf(RustLeaderboardRowDto(steamId = "1")))
|
||||
val vm = viewModel()
|
||||
|
||||
vm.selectWipe("wipe-1")
|
||||
assertEquals("wipe-1", api.lastFeedWipe)
|
||||
// Nobody has opened the leaderboard, so nothing was fetched for it.
|
||||
assertEquals(0, api.leaderboardCalls)
|
||||
|
||||
vm.selectTab(RustTab.LEADERBOARD)
|
||||
vm.selectWipe("wipe-2")
|
||||
assertEquals("wipe-2", api.lastLeaderboardWipe)
|
||||
assertEquals("wipe-2", api.lastFeedWipe)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `picking the same wipe twice asks nothing`() {
|
||||
val vm = viewModel()
|
||||
vm.selectWipe("wipe-1")
|
||||
val calls = api.eventCalls
|
||||
|
||||
vm.selectWipe("wipe-1")
|
||||
|
||||
assertEquals(calls, api.eventCalls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `opening a wipe from the wipes tab lands on the feed`() {
|
||||
// Picking a wipe there is a navigation as much as a filter: the question is
|
||||
// "what happened during that map", and the answer is the feed.
|
||||
val vm = viewModel()
|
||||
vm.selectTab(RustTab.WIPES)
|
||||
|
||||
vm.openWipe("wipe-1")
|
||||
|
||||
assertEquals(RustTab.FEED, vm.state.value.tab)
|
||||
assertEquals("wipe-1", vm.state.value.selectedWipe)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a 404 on the server read is its own state, not a generic error`() {
|
||||
// A mistyped address is not a fault. The screen reads NOT_FOUND and says
|
||||
// "no such server" rather than dressing it as an outage.
|
||||
api.error = HttpException(Response.error<Any>(404, "".toResponseBody(null)))
|
||||
|
||||
val vm = viewModel("typo")
|
||||
|
||||
val state = vm.state.value.server.state
|
||||
assertTrue(state is UiState.Error)
|
||||
assertEquals(404, (state as UiState.Error).httpStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed poll keeps the server line that was there`() {
|
||||
api.server = RustServerResponse(RustServerDto(id = "main", name = "Main"))
|
||||
val vm = viewModel()
|
||||
|
||||
api.error = IOException("offline")
|
||||
vm.refresh()
|
||||
|
||||
val state = vm.state.value.server.state
|
||||
assertTrue(state is UiState.Success)
|
||||
assertEquals("Main", (state as UiState.Success).data.name)
|
||||
assertTrue(vm.state.value.server.refreshFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sorting sends the API's own vocabulary`() {
|
||||
val vm = viewModel()
|
||||
vm.selectTab(RustTab.LEADERBOARD)
|
||||
|
||||
vm.selectSort(RustSort.PLAYTIME)
|
||||
|
||||
assertEquals("playtime", api.lastSort)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
package com.runicgateway.app.util
|
||||
|
||||
import com.runicgateway.app.core.inbox.InboxCache
|
||||
import com.runicgateway.app.data.api.dto.NotificationItemDto
|
||||
|
||||
/**
|
||||
* In-memory [InboxCache] for the inbox view-model tests — the same shape of stand-in
|
||||
* `SessionManagerTest` uses for the encrypted token store.
|
||||
*
|
||||
* It keeps the real implementation's ONE load-bearing rule: a snapshot is handed
|
||||
* back only to the owner that wrote it. A fake that ignored the key would let the
|
||||
* cross-account test pass against a cache that leaks.
|
||||
*/
|
||||
class FakeInboxCache : InboxCache {
|
||||
|
||||
private var owner: String? = null
|
||||
private var snapshot: InboxCache.Snapshot? = null
|
||||
|
||||
var writes: Int = 0
|
||||
var cleared: Int = 0
|
||||
|
||||
override suspend fun read(owner: String): InboxCache.Snapshot? =
|
||||
if (this.owner == owner) snapshot else null
|
||||
|
||||
override suspend fun write(owner: String, items: List<NotificationItemDto>, unread: Int) {
|
||||
writes++
|
||||
this.owner = owner
|
||||
snapshot = InboxCache.Snapshot(
|
||||
items = items.take(InboxCache.MAX_ITEMS),
|
||||
unread = unread,
|
||||
savedAt = 1_700_000_000_000,
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun clear() {
|
||||
cleared++
|
||||
owner = null
|
||||
snapshot = null
|
||||
}
|
||||
|
||||
/** Seed a snapshot as if a previous session had pulled one. */
|
||||
suspend fun seed(owner: String, items: List<NotificationItemDto>, unread: Int) {
|
||||
write(owner, items, unread)
|
||||
writes = 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user