Chatter List
Overview
The chatter list shows who is in the chat right now, grouped as Broadcaster → Moderators → Bots → VIPs → Viewers, each with avatar, display name and how long they have been in the chat (first_seen_at). People who left stay for 30 minutes in a separate recently left group with the time they were last seen (last_seen_at), lurkers included. It is platform-neutral: every platform is a provider behind one registry, and the API only ever reports the platforms that have a registered provider (supported_platforms). Twitch is the only provider today.
- Drawn from two sources. A platform snapshot (Twitch: Helix
GET /chat/chatters, every 30 s) is the baseline. Everyone who writes in the chat is added immediately from the normalisedchat:messagestream every platform already produces. - Logged-out viewers are invisible to everyone, Twitch included. The list therefore counts chatters (logged-in chat participants), not viewers, and its number is less than or equal to the viewer count.
- Platform latency, stated honestly. Twitch updates the Helix list with a delay: someone who only watches shows up after 30 s to a few minutes, and leaving is just as slow. Only people who write show up instantly. The
chatters_snapshot_lag_secondsmetric measures this delay. If it turns out to be a problem, an IRCJOIN/PARTsource is the prepared next step; it would be a second source inside the Twitch provider and needs no change to the API or the UI. It is not built.
In / out rule
The key of an entry is (platform, platform_user_id) — never the name.
- In: in the last platform snapshot, or wrote in the last 10 minutes.
- Out: missing from two consecutive snapshots and no message since the first miss, and no message in the last 10 minutes. The two-snapshot rule keeps the slow platform list from making entries flicker.
- Recently left. An entry that is out is not removed. It becomes
present = falsewith itslast_seen_atfrozen and stays forrecently_left_retention_secs(30 min) counted fromlast_seen_at; then it is dropped. Someone who comes back within that time ispresent = trueagain with a newfirst_seen_at. - Never counted. Recently-left entries are never part of a count:
platforms[].totalis the platform's own in-the-chat count, and without it a client countspresent = trueentries only. - Times.
first_seen_atis when the key entered the list in the current stream (snapshot or message) and never moves while the person stays.last_seen_atis the later of the last snapshot that listed them and their last message. Both are cleared with the whole state when the stream goes offline, so times never span two streams. - No duplicates. A snapshot and a chat message write into the same entry. A
joinis sent only for a key that is new; anything else is anupdate, and nothing is sent when nothing visible changed. A rename changes only the display, never the row. The same login on two platforms is two rows (two people may own it).
Roles
Roles are neutral: broadcaster | moderator | bot | vip | viewer. Each provider maps its platform onto them. Twitch:
| Group | Source |
|---|---|
| Broadcaster | the connected channel's own user id |
| Moderators | Helix Get Moderators (cached 10 min, dropped on EventSub channel.moderate mod/unmod) — falls back to the moderator/lead_moderator badge when the list can't be read |
| Bots | a curated list of known bots (Nightbot, StreamElements, Moobot, Fossabot, …), Lumio's own lumiobot, the account's own custom bot account, and the bot badge |
| VIPs | Helix Get VIPs (cached like moderators, dropped on vip/unvip) — badge vip as fallback |
| Viewers | everyone else |
Bot wins over moderator/VIP: almost every bot is a moderator; without this rule the bot group would nearly always be empty. Unknown bots appear as viewers until they are added to the curated list or carry the bot badge.
Avatars come from Helix GET /users (100 ids per call) through the Redis cache lumio:avatar:\{platform\}:\{uid\} (24 h). Broadcaster, moderators, bots and VIPs are resolved first; missing avatars arrive later as update deltas.
Polling: open panel or live stream
An (account, platform) is polled while a panel is open, or while live_polling is on, the account uses the list and that platform's stream is live. Nothing is polled for an account that never opened the list.
- Subscribing to the WebSocket channel
chatters:\{account_id\}sets the interest keylumio:chatters:interest:\{account_id\}(TTLinterest_ttl_secs, 90 s). Every API replica renews it every 5 s for eachchatters:*channel it holds open. - Every subscription also renews the wanted marker
lumio:chatters:wanted:\{account_id\}(TTLwanted_marker_ttl_secs, 7 days) and adds the account to the index setlumio:chatters:wanted. While the marker exists and the platform is live, the account is polled without an open panel, sofirst_seen_atand the recently-left group stay true. An account that does not open the list for 7 days drops out (its index member is pruned). - Live signal: the per-account online set
lumio:channel_online:\{account_id\}, maintained by the channel-status service from Twitch EventSubstream.online/stream.offline(plus the Helix check at worker start), the Kick live-status path and the YouTube poller, and rebuilt from Postgres at API start. - Per (account, platform), one replica takes the lock
lumio:chatters:lock:\{account_id\}:\{platform\}(SET NX) and runs the poller. Replicas never poll twice. - The poller reads the platform snapshot every
poll_interval_secs(30 s), at mostmax_chatters(5000) entries. A larger chat reportstruncated = truewith the platform's fulltotal. - Stream offline: when the poller sees its platform go from live to offline, it drops the whole state (a
leavefor every entry) and its meta. If no panel is open it then stops. - With neither interest nor (marker + live), the poller stops. The state hash then expires 5 minutes later.
Cost. Live polling costs 1 Helix call per 30 s per live account that used the list in the last 7 days (plus avatar lookups for new chatters, cached 24 h). chatters_snapshot_requests_total shows it. live_polling = false is the kill switch and returns to panel-only polling.
State lives only in Redis — lumio:chatters:\{account_id\}:\{platform\} (hash, field = platform user id, TTL 5 min, renewed on every poll). There is no database table and no history. A present chatter's moving last_seen_at is written at 5-minute granularity (exact in memory, exact once frozen at departure), so a snapshot can show it up to 5 minutes old; clients render "in the chat since" from first_seen_at.
Availability
platforms[].available = false comes with a reason:
reason | Meaning | What still works |
|---|---|---|
missing_scope | The Twitch connection was made before the chatter scopes existed | People who write still appear; reconnect Twitch once for the full list |
not_connected | No usable channel connection | Nothing for this platform |
disabled | Switched off by the operator ([chatters] / [chatters.<platform>]) | Nothing for this platform |
The Twitch list needs moderator:read:chatters; the moderator and VIP groups need moderation:read and channel:read:vips (see Channel Connections OAuth Scopes). Without the latter two, roles fall back to chat badges.
API
All three protocols carry the same fields, the same guard — chat:read + feature feature:chat-viewer-list — and the same errors. Popout tokens work on all three (the chat popout authenticates with a popout token / ws-token).
| Protocol | Surface |
|---|---|
| GraphQL | chatters(platforms: [String!]): ChattersSnapshot! |
| REST | GET /v1/chat/chatters?platform=twitch (comma-separated, optional) |
| WebSocket | channel chatters:\{account_id\} — snapshot as bootstrap, then chatters:delta events |
An unknown platform is Bad request: Unsupported chatter platform: <name> (BAD_REQUEST) on GraphQL and REST. See GraphQL, REST and WebSocket for the exact shapes.
Plan gating
feature:chat-viewer-list (category feature) is enabled on the same plans as feature:multichat. A plan without a plan_features row resolves to off. The multichat entitlement alone does not grant the list.
The panel (web)
The list is a side panel of ChatShell, so the chat page and the chat popout share one implementation; EventsShell has none. User-facing walkthrough: Viewer List.
- Opt-in, setting = visible. A "Viewer list" section on the General tab of the chat settings (
chatvariant only). On means the panel is open on load, in the popout and after a preset. The header button next to the settings cog (and the panel's X) is a quick hide, persisted per browser under its own(user, account)-scoped localStorage keylumio:chat-viewer-list-hidden:<userId>:<accountId>— not in thepopout-settings:chatblob, so presets, export and import never carry it. The chat page and the chat popout in the same browser share it; an OBS dock keeps its own. Applying a preset or import of the chat display section, or switching the setting off → on, clears it, so the panel opens. An unknown stored value reads as shown. - Gating. Button and panel need
chat:read,feature:chat-viewer-listand a channel on at least one platform fromsupported_platforms. Without feature or permission the settings section is absent; without a connected supported platform it is shown locked, naming the supported platforms. - Data. A one-shot
GET /api/chat/chatters(GraphQLchattersthrough the chat proxy, popout-aware) tells the shell which platforms are supported. Thechatters:\{account_id\}WebSocket channel is subscribed only while the list is enabled and not quick-hidden — list off or hidden means no subscription and therefore no platform polling; the one-shot snapshot still runs so the header button stays available. Popouts mint their WS token from the popout session. - Loading state. The list body shows the chat's loading indicator — the regular spinner, or the active season's icons when the season look is on (
SeasonalSpinner; pulsing instead of bobbing with "Animations: Off" orprefers-reduced-motion) — instead of text.chatterListPhase()inlib/chatter-list.tspicks the body: rows whenever the client holds any (a re-subscribe after a quick hide refreshes the existing list in place, never a spinner); otherwise loading before the first answer (REST snapshot or WS bootstrap) and while every available platform still hasupdated_at = null— an empty snapshot taken before the backend ever polled is not "nobody in chat". Unavailable platforms (missing_scope,not_connected,disabled) never count as waiting, so their hint shows at once. The wait is capped at 15 s from the first answer since the list went live (CHATTER_FIRST_POLL_GRACE_MS); after that an empty list shows the empty state. The indicator sits centred in the list area; the ring follows the panel's font scale (20·s px); the head shows no count while loading. - Client dedupe. The client holds a
Mapkeyed by (platform,user_id). A bootstrap replaces it, a duplicatejoinis applied as anupdate, aleaveof an unknown key is ignored. Anupdatewithpresent = falsemoves the row into the Last seen group; theleaveafter the retention removes it. - Groups and counts. Present people sit in the five role groups; every
present = falseentry, whatever its role, sits in Last seen at the bottom (newest departure first), collapsible like the others (group idrecent). The panel head and the group headers count present people only; the header button carries no number (platforms[].totalwhere known, else present rows). - Two-line rows (38 px at 14 px font size). A 26 px avatar beside the 13 px display name (with the ZAF-1452 trailing login) and a muted 11 px time line with tabular figures, formatted by
chatterTimeLabel()inlib/chatter-list.tsand translated (chat.viewerList.time.*). Present: clock icon + "here 48 min" fromfirst_seen_at("just arrived" under a minute, "here 1 h 52 min" from an hour). Last seen: eye icon + "last seen 4 min ago" fromlast_seen_at, avatar at 45 % opacity, name muted; rows stay clickable. The line is never blank: withoutfirst_seen_atit falls back tolast_seen_at, then to "in chat". One shared clock re-renders the rows every 30 s (no per-row timers); "updated 12 s ago" in the head has its own 5 s clock. - Follows the chat font size. The whole panel scales with the chat's
fontSizebys = fontSize / 14(chatterRowMetrics(viewerListScale(fontSize))inlib/chatter-list.ts): row height 38·s, group header 28·s with its 10·s name and count, avatar 26·s, name 13·s, time line and avatar initial 11·s, row icons 12·s; in the head the title and count 12·s, "updated…" 10·s, the filter trigger, search box and close button 28·s high with 12·s text and 14·s icons, the status notes 11·s and the filter menu 14·s — at 14 px exactly the unscaled values. A font-size change re-measures the virtual list and the Auto width live. - Platform-neutral. Nothing in the UI names a platform on its own; the platform filter and per-row platform icons (at the row end) appear only from two connected supported platforms on. The filter is a single-select
Selectfrom@lumio/uileft of the search: an icon-only trigger (layers icon for All platforms, else the platform icon; tooltip andaria-labelname the choice) whose items are All platforms plus every platform insupported_platformsthe account is connected to — no hard-coded platform list. The choice filters groups, group counts, the head count and the search results. - Rows open the existing user info card through the same selected-user state as a chat-row name (works for lurkers without messages); without
chat:userinforows are plain text. Long lists are virtualised (@tanstack/react-virtual) with sticky group headers. - Layout. The chat keeps at least 420 px. The panel docks while the chat area (its own width, not the viewport) is at least 420 px + panel width, re-docking 16 px above that threshold; a docked panel is never squeezed below its width. Otherwise it overlays the feed and leaves a 48 px chat strip. The resize separator (
role="separator", arrows ±8 px,Home= Auto, double-click = Auto) works docked and — from 288 px chat-area width — in the overlay; a drag never switches mode. The chat page opens the popout at 420 px + panel width (Auto counts as its upper limit: 740 px at 14 px font size) while the list is on. Auto fits the widest row — the wider of name and time line (measured in the scaled fonts) plus the avatar, paddings and, from two platforms on, the platform icon — clamped to200 … min(480, 320·s)px; the time line is measured as of the latest snapshot. While dragging the separator shows no tooltip or pixel readout; the hover hint appears before a drag only, andaria-valuenowcarries the width. - Settings fields in the
popout-settings:chatblob, all optional (absent = default, no schema-version bump, validated at read time):viewerListEnabled,viewerListSide(left/right),viewerListWidth("auto"or 200–480),viewerListCollapsed(role ids plusrecent),viewerListPlatform("all"or one platform id; a platform that is no longer connected applies as"all"). The retired multi-selectviewerListHiddenPlatformsmay still sit in old blobs and is ignored. They travel with presets, export and import; the export summary reads "Viewer list on · right · Auto". - Motion uses
motion: panel slide + fade (200 ms in, 150 ms out) only after a user action, row fade-in (150 ms, small joins only) and fade-out (120 ms); someone who leaves cross-fades from their group into Last seen (old place fades out in 180 ms, new row fades in over 220 ms, then the rows below glide up 200 ms instead of jumping) — none of it for batches of more than 20 changes; only at thefullmotion level (OS, "Animations in Lumio" and the chat surface override, strictest wins); instant underreduced/off.
Audit
No audit event, on purpose. The chatter list is a read-only, ephemeral surface: it changes no rights, no roles and no credentials, and stores nothing beyond an ephemeral Redis state that is cleared at stream end (departed people at most 30 minutes). Live polling does not change that: it is a background read of the same public list. The audit log is a security record, not an activity stream (see Audit Events). The one-time Twitch reconnect for the new scopes runs through the existing connection flow, which already emits account:connection_added.
Privacy
The list processes public chat usernames, display names and avatars that the platform itself shows to everyone in the chat, plus when each person was first and last seen in the current stream.
Data lifetime:
- A present chatter's entry lives as long as they are in the chat during the stream.
- A person who left is kept for at most 30 minutes (
recently_left_retention_secs) after they were last seen, then dropped. - Everything is cleared when the stream goes offline. If polling stops for another reason, the Redis state expires 5 minutes after the last poll.
- The avatar cache keeps avatar URLs for 24 hours. The wanted marker stores only the account id (7 days).
There is no database table and no history.
Configuration
See Installation → Chatter list for [chatters] and [chatters.twitch].
Metrics
| Series | Labels | Meaning |
|---|---|---|
chatters_snapshot_lag_seconds | platform, outcome (seen | missed) | Seconds until a snapshot first shows someone who already wrote (seen), or until such a writer left without any snapshot showing them (missed) |
chatters_snapshot_requests_total | platform, outcome (ok | missing_scope | not_connected | error) | Every platform list read. Flat while no panel is open and no live-polled stream is live |