forked from retoor/devplacepy
Make the presence roster the single source of truth for online status
Online status had three server-side candidate populations and two
client-side deciders, so the feed roster and the /messages indicators
could legitimately disagree.
PresenceRelayService built its online set from whichever topics happened
to be subscribed on a given tick: the roster candidates on /feed, only
the per-uid dot rows on /messages. Different populations meant a
different hysteresis baseline, so the same user could be online in one
place and offline in the other. On top of that, PresenceManager re-derived
online status client-side from a frozen data-presence-last-seen with a
strict timeout and no hysteresis, re-evaluated every 20s, so any element
whose relay frame was missed drifted grey after the timeout and stayed
there. AppChat carried a third renderer with its own PubSubClient that
only ever wrote online/offline, plus hand-built dot markup duplicating
_presence_dot.html.
The relay now collapses to one set on one topic. Each tick it reads the
online population in a single indexed query (online_candidates, capped by
the new PRESENCE_TRACK_LIMIT), applies hysteresis once, and publishes
{count, online, users} on public.presence.roster only when the uid set
changes. online is the authority for every avatar dot; users is the same
set trimmed to PRESENCE_ONLINE_LIMIT for the feed panel. The per-uid
public.presence.{uid} topics are gone, which also removes one
subscription per distinct author on a page.
is_online(user) is now stays_online(seconds_since(last_seen), False), so
the server-rendered initial state and the live set apply one formula.
PresenceManager makes one subscription and renders every
[data-presence-uid] element as membership of that set, with no clock and
no expiry timer; before the first frame the server-rendered state stands.
Relative "last seen" text is a <time data-dt data-dt-mode="ago"> handled
by the shared LocalTime. AppChat lost its presence code entirely, and the
new Avatar.badgeElement is the JS twin of _presence_dot.html, so dot
markup now lives in exactly two places.
Also fix the awards column ensure-block, which the awards tests exposed.
backfill_api_keys opened with an "if users not in db.tables" guard, but
on a brand-new database that is precisely the state at init_db time, so
the whole users ensure-block was skipped. The first signup then created
users with only the columns of that INSERT, leaving every ensured-but-
unwritten column absent from the server's reflected metadata for the rest
of the process lifetime. That is why the awards tab, the prominent award
banner and the avatar award badge were invisible on a fresh database. It
now calls get_table("users") unconditionally.
test_avatar_badge_on_feed_when_prominent asserted that any online user
carries an award badge while only awarding a user who was never active,
so it passed only by accident. It now makes the awarded user active and
asserts the badge on that user's roster entry.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,7 @@ Self-contained, presentational UI is built as custom elements with the `dp-` pre
|
||||
|
||||
**Prompt seeding on the shared Devii opener (`data-devii-prompt`).** `DeviiTerminal.bindTriggers` binds every `[data-devii-open]` element; it now reads `trigger.dataset.deviiPrompt` and passes it to `DeviiTerminal.open(prompt)`, which calls `this.element.open()` and then `devii-terminal.prefill(text)` (sets `this.input.value`, moves the caret to the end, focuses). **It never auto-sends** - the member reads the request and presses Enter, keeping the assistant's first action explicitly user-initiated. This is a platform-wide capability available to any page, not a quiz-only shim: add `data-devii-prompt="..."` beside `data-devii-open` and the terminal opens pre-filled.
|
||||
|
||||
`dp-chat` (`AppChat.js`) is the Slack-like DM chat widget that fully replaced the old page-controller trio `MessagesLayout.js`/`MessagesSocket.js`/`MessageSearch.js` (deleted) and `static/css/messages.css` (superseded by `static/css/chat.css`) on `/messages`. Unlike every other component here, it does NOT build its DOM from scratch on `connectedCallback` when server-rendered light-DOM children already exist (`mode="page"`) - it **adopts** the existing `.messages-list`/`.messages-main`/`.messages-thread`/`.message-bubble` markup `templates/messages.html` still renders (so no-JS and crawler fallback both keep working) and only builds a from-scratch skeleton when none is present (`mode="embed"`, a standalone `<dp-chat mode="embed" self-uid="..." with-uid="..." send-url="..." ws-url="...">` usable outside `/messages`). It owns its own `ChatSocket` instance, the conversation search dropdown (absorbed from the old `MessageSearch.js`, still built on the shared `ListNav` utility), consecutive-message grouping (`chat/MessageGrouping.js`), a working optimistic send with `.pending`/`.failed` (tap-to-retry) bubble states, and an opt-in (`ai-indicator` attribute) "Adjusted by AI" caption shown when a reconciled echo's `ai_processed` frame flag is true and the content changed from what was locally typed - never a diff/revert UI, the backend keeps no pre-correction copy to diff against. Presence for `mode="page"` needs no component code at all: the adopted markup keeps its `data-presence-uid` attributes, so the page-global `PresenceManager` (below) already drives it; `dp-chat` opens its own scoped `PubSubClient` subscription only in `mode="embed"`, where no page-global instance exists. The message body itself keeps rendering through `<dp-content>` (see "CLIENT-only now" below) - the redesign only removed the historical `no-copy` attribute on message bubbles so `dp-content`'s own existing `.content-copy-btn` doubles as the hover/focus-reveal action toolbar's Copy button (§6.3 of the design doc it was built from), instead of a second copy mechanism being hand-rolled.
|
||||
`dp-chat` (`AppChat.js`) is the Slack-like DM chat widget that fully replaced the old page-controller trio `MessagesLayout.js`/`MessagesSocket.js`/`MessageSearch.js` (deleted) and `static/css/messages.css` (superseded by `static/css/chat.css`) on `/messages`. Unlike every other component here, it does NOT build its DOM from scratch on `connectedCallback` when server-rendered light-DOM children already exist (`mode="page"`) - it **adopts** the existing `.messages-list`/`.messages-main`/`.messages-thread`/`.message-bubble` markup `templates/messages.html` still renders (so no-JS and crawler fallback both keep working) and only builds a from-scratch skeleton when none is present (`mode="embed"`, a standalone `<dp-chat mode="embed" self-uid="..." with-uid="..." send-url="..." ws-url="...">` usable outside `/messages`). It owns its own `ChatSocket` instance, the conversation search dropdown (absorbed from the old `MessageSearch.js`, still built on the shared `ListNav` utility), consecutive-message grouping (`chat/MessageGrouping.js`), a working optimistic send with `.pending`/`.failed` (tap-to-retry) bubble states, and an opt-in (`ai-indicator` attribute) "Adjusted by AI" caption shown when a reconciled echo's `ai_processed` frame flag is true and the content changed from what was locally typed - never a diff/revert UI, the backend keeps no pre-correction copy to diff against. Presence needs no component code at all: the adopted markup keeps its `data-presence-uid` attributes, so the page-global `PresenceManager` (below) already drives it, and the conversation list it builds live uses the shared `Avatar.badgeElement(user)` rather than hand-rolled dot markup. In `mode="embed"`, where no page-global instance may exist, it constructs a root-scoped `PresenceManager` instead of re-implementing presence - `dp-chat` owns no presence logic of its own. The message body itself keeps rendering through `<dp-content>` (see "CLIENT-only now" below) - the redesign only removed the historical `no-copy` attribute on message bubbles so `dp-content`'s own existing `.content-copy-btn` doubles as the hover/focus-reveal action toolbar's Copy button (§6.3 of the design doc it was built from), instead of a second copy mechanism being hand-rolled.
|
||||
|
||||
`static/js/chat/ChatSocket.js` and `static/js/chat/MessageGrouping.js` are chat-scoped helpers used only by `AppChat.js` - `ChatSocket` is modeled on `PubSubClient.js`'s real exponential backoff (200ms doubling to a 5000ms cap, reset on a successful connect) rather than the flat-delay retry the deleted `MessagesSocket.js` used, and preserves the same `4013` "wrong worker" fast-retry special case. `MessageGrouping.shouldGroup(previous, current)` is the one pure predicate (<=300s gap, same sender) shared by both the initial-history grouping pass and the live-append path, so the two can never disagree. Neither file is a general-purpose "do not reimplement" utility for the rest of the app (see the out-of-scope note in the design document this shipped from) - they are not added to the do-not-reimplement list below; a future non-chat WebSocket feature should keep hand-rolling its own client (as `DeviiSocket.js`/`DeepsearchProgressSocket.js`/`SeoProgressSocket.js`/`AppDeepsearchChat.js`'s inline socket already do) rather than reaching into `chat/`.
|
||||
|
||||
@@ -48,6 +48,7 @@ Detail on each utility:
|
||||
- **`OptimisticAction` (`static/js/OptimisticAction.js`).** Base with one method, `submit(url, params, errorTarget, render)`: `Http.sendForm` -> `render(result)` on success -> `console.error` + (when `errorTarget` is given) `Toast.flash(errorTarget, "Error", 1500)` on failure. `VoteManager`/`ReactionBar`/`BookmarkManager`/`PollManager` `extend` it and call `this.submit(...)` for their POST, **keeping their own event wiring** (so `ReactionBar`'s palette toggle and `PollManager`'s multi-action handlers and `VoteManager`'s per-button `stopPropagation` are untouched). Pass `errorTarget` only where the old code toasted (`VoteManager`); the others pass `null` to keep their console-only behaviour.
|
||||
- **`EmojiPickerElement` (`static/js/EmojiPickerElement.js`).** The single wrapper around the vendored `emoji-picker-element`: `EmojiPickerElement.load()` lazily imports the vendor module once (shared promise, failures swallowed) and `EmojiPickerElement.create(onSelect)` returns a configured `<emoji-picker>` (data source `static/vendor/emoji-picker-element/data.json`) that calls `onSelect(unicode)` on `emoji-click`. Both consumers use it: `EmojiPicker` (insert at cursor in a textarea) and `ReactionBar` (react with any emoji). Never build an `<emoji-picker>`, re-import the vendor module, or repeat the data-source path elsewhere.
|
||||
- **`FloatingWindow` / `WindowManager` (`static/js/components/`).** The draggable window base and shared z-order manager - documented in the Container manager section; both the container terminals and the Devii terminal extend `FloatingWindow`.
|
||||
- **`PresenceManager` (`static/js/PresenceManager.js`, `app.presence`) and `Avatar` (`static/js/Avatar.js`).** The single online-status renderer and the single avatar-markup builder. `PresenceManager` makes ONE subscription to `public.presence.roster` and drives EVERY `[data-presence-uid]` element from the pushed online uid set - there is no client-side clock and no expiry timer, so nothing can disagree with the feed's Online now panel. `Avatar.badgeElement(user)` builds the `.avatar-badge` + `.presence-dot` + award-badge trio (the JS twin of `templates/_presence_dot.html`) and is used by `OnlineUsers` and `dp-chat`. Never re-derive online status from a timestamp in feature code, and never hand-build a presence dot.
|
||||
- **`ScrollMemory` (`static/js/ScrollMemory.js`, `app.scrollMemory`).** Site-wide, per-tab scroll restoration - the fix for the "back to feed jumps to top" class of bugs. It sets `history.scrollRestoration = "manual"` once (never rely on the browser's auto-restore, which fires before late-loading content settles and never applies to normal link navigations), so ALL scroll restoration flows through this one module. State lives in `sessionStorage` (per-tab by definition, exactly the required scope): a position map keyed by exact `pathname + search` (hash ignored; saved by a throttled passive scroll listener plus a final write on `pagehide`/hidden `visibilitychange`, pruned to 50 entries / 60 min), a visited-URL trail (capped at 20), and a one-shot click-intent flag (30s validity, consumed on every load).
|
||||
**When it restores** - only when the situation is genuinely "going back": (1) navigation type `back_forward` (browser back without bfcache; with bfcache - `pageshow` `persisted` - the frozen page already has its scroll, so it only re-syncs the trail and clears the intent flag), (2) `reload`, (3) a same-origin click on `a.back-link` / `a[data-scroll-back]` / a breadcrumb link / any link whose target equals the trail's previous URL, which stamps the intent flag the next load matches. A fresh visit (topnav, address bar, redirect after POST) never restores, and a URL with a `#fragment` always wins over restoration.
|
||||
**How it restores reliably**: a `requestAnimationFrame` loop re-applies the target position (clamped to the current `scrollHeight`, `behavior: "instant"` so the global `html { scroll-behavior: smooth }` never animates it) until the document height has been stable at the target for 10 frames or a 4s deadline passes, and aborts instantly on the first `wheel`/`touchstart`/`keydown`/`pointerdown` so it never fights the user. It also **upgrades bare back-links on load**: when the previous trail URL has the same pathname as a query-less `a.back-link`/`[data-scroll-back]` href (post page's `/feed` vs the `/feed?tab=recent&before=...` the user actually came from), the href is rewritten to the exact previous URL so tab/topic/cursor AND scroll survive the round trip. Give any new "back to X" anchor the `back-link` class (or `data-scroll-back`) and it participates automatically - never hand-roll `scrollTo` persistence per page. Guarded by `tests/e2e/feed.py::test_feed_scroll_restored_via_back_link` / `_via_browser_back` / `_not_restored_on_fresh_visit`.
|
||||
|
||||
Reference in New Issue
Block a user