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:
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user