docs(engagement): the in-app channel on Android, as built (Phase 8)

Records Phase 8 and answers §7.1 Q8, leaving Q9 as the only open question.

ENGAGEMENT.md gains the phase's as-built section: the four decisions settled
first, the relative-url finding the live rig caught (Phase 7's contract is
relative-only, and the client half of that contract was never written down), the
ref-not-stream routing rule, the (base URL, user id) snapshot scoping that is the
actual security property, and the on-device walk. Its Status paragraph was six
phases stale and now names every phase that has landed.

android/PLAN.md §7's "no offline caching, no Room in v1" decision STANDS and now
names its one exception, with the limit it comes with: the snapshot serves a
running app, not a cold start, because the shell gates the whole app on loading
the site's appearance. Widening Phase 8 into the shell's startup model is the
org lead's call, so it is written up rather than done. §11 gains the inbox and
the per-channel screen as built.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-08-31 09:28:07 -05:00
parent feeb2cac11
commit b1851ad8c1
2 changed files with 154 additions and 20 deletions

View File

@@ -759,6 +759,18 @@ Mirrors the website's "degrade gracefully" invariant:
loading/error/retry states; it does **not** ship a Room cache in v1. Cached read-only content can be
added later without reworking the repository layer (its typed results already isolate the UI from the
data source). No `Room` dependency in the initial build.
- **Amended 2026-08-31 (engagement Phase 8): one named exception, and still no Room.** The in-app
inbox keeps an offline snapshot - `core/inbox/InboxCache`, one JSON blob in the DataStore the push
code already uses, capped at the server's own default page size. An 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. **Every snapshot is scoped to (base URL, user id)** and
handed back only to that pair, which is what stops one account's notifications surfacing under
another's session on the teardown paths that never reach a logout (a dead refresh token, a server
switch); the clear-on-logout beside the push deregistration is the tidy-up, not the safeguard.
**The known limit: this serves a running app, not a cold start.** `MainActivity` gates the whole of
`RunicApp` on loading the site's appearance, so an offline launch still shows the shell's "Can't
reach the site / Retry" and never reaches the drawer. Changing that is a change to the shell's
startup model, and it was left for the org lead rather than widened into Phase 8.
---
@@ -1307,9 +1319,48 @@ Four properties the UI should be built on rather than around:
`ceiling` is `staff` is not offered to a non-staff caller — it can never reach them, and listing it
would disclose that the event exists. `GET /notifications/streams` is unfiltered and unchanged.
**Phase 8** (`ENGAGEMENT.md`) is where the app grows the in-app inbox and this screen gains the
per-channel toggles. Until then the existing per-stream screen keeps working against
`/notifications/subscriptions` unmodified.
### The inbox and the per-channel screen - as built (engagement Phase 8, 2026-08-31)
**The drawer's "Notifications" is the INBOX now**, and the preferences are one tap away behind its
gear - the arrangement Phase 7 shipped on the web (`/account/notifications` is the content,
`.../settings` the preferences), and what a person means when they tap the word. `Routes.NOTIFICATIONS`
is unchanged and `Routes.NOTIFICATIONS_SETTINGS` is new, so an admin's nav override pointing at the
old route still lands somewhere sensible.
**The inbox** (`ui/notifications/InboxScreen` + `InboxViewModel`) reads the four routes Phase 7
shipped: a keyset page on `before` (never an offset - the list gains rows at the top while it is being
read), the unread count that rides along on every page, and the two mark-read writes. Reads are
optimistic and deliberately not rolled back on failure; a local read also rewrites the snapshot, or
going offline right after reading everything would bring the badge back on the next cold open. The
drawer badge has its own view model on `/notifications/unread-count`, refreshed on resume rather than
on a timer - the tickle is what says "something happened", so polling would be a second, worse copy
of push.
**Two things the app has to do that the backend contract does not state:**
- **Resolve the item's `url`.** Phase 7 specifies it is **relative-only** (`/guilds/.../forum/403`),
which is right for a browser already on the site and a dead link on a phone.
`InboxViewModel.linkFor` resolves it against the configured base with OkHttp's `HttpUrl.resolve`,
which absolutises the path *and* returns null for anything that would not end up http(s) - so a
`javascript:` or `intent:` url in a notification body opens nothing. The live rig is what caught
this: the first cut only opened `http(s)`-prefixed strings, so every link in the inbox did nothing
at all.
- **Route the tickle on its `ref`, not its stream.** An engagement rule's tickle carries the TRIGGER
id as `stream` (ENGAGEMENT.md section 7.2's one namespace) and `PushStreams` knows only the eight
push streams, so `team.forum.post` would have landed on Home. `Routes.forTickle(stream, ref)` sends
anything whose ref starts with `notification:` to the inbox and leaves every other tickle on the
route it has always had. The ref is never decoded past that prefix and never rendered - it is a hint
that a row exists, and the contract stays wake-and-pull.
**The settings screen** (`NotificationSettingsScreen` + `NotificationSettingsViewModel`) moved off
`/notifications/subscriptions` onto `/notifications/channels`. Controls are rendered from the wire:
one row per subscribable id, a control per channel in **that item's** `channels`, and its shape from
**that channel's** `modes` - a switch for two modes, chips for three, so email's `digest` reaches the
app and a fourth channel would too, without a release. A trigger-only id shows no push control rather
than a dead switch, and on a shard with no push relay the push controls are absent with the reason in
a note beside the list (email and on-site preferences are still worth setting there). Each change is
one sparse PUT of one pair, and the screen re-renders from the response, so an entry the server drops
shows up as the control springing back.
## 12. Build & CI (Gitea Actions)