Chat
Overview
The Chat module provides a unified, multi-platform chat system that aggregates messages from Twitch, YouTube, and Kick into a single real-time stream. It supports message history with pagination, moderation actions (bans, timeouts, message deletion), user profiles with enrichment data from a ProfileService, moderator notes, emote rendering (Twitch, Kick, 7TV, BTTV, FFZ), polls, predictions, and raid management. Messages are buffered in Redis, flushed to TimescaleDB in batches, and broadcast to all connected WebSocket clients in real time.
Twitch AutoMod held-message queue
Twitch EventSub automod.message.hold and automod.message.update supply held messages and outcomes; Helix performs allow and deny with the acting moderator's Twitch login. The channel connection needs moderator:manage:automod, so older grants need a reconnect. The live queue is separate from ordinary chat: a held message is published only to automod:\{account_id\} (permission chat:automod-read, feature feature:multichat) and never enters the chat-message pipeline, overlays or widgets. Allow publishes a separate chat message with a new ID. The queue is available in the dashboard and authorized chat popouts; allow and deny require chat:automod-allow and chat:automod-deny respectively. The action is written to the chat moderation log; no separate audit event is emitted. When the acting user's stored Twitch login scopes are known and lack moderator:manage:automod, allow and deny fail with error_code: login_scope_missing before any Helix call; an empty stored scope set counts as unknown and Helix decides, and a Helix 401 maps to the same code. ConnectionStatus.loginMissingScopes / login_missing_scopes lists the login scopes the acting user's grant lacks, so the UI can ask for a new Twitch login before a click fails.
Each held message carries emotes (Twitch emotes and cheermotes, the same shape as a chat message's emotes, derived from the EventSub fragments when the message leaves the server and never stored in Redis; absent or null when there are none or the fragments were dropped for size) and the sender's avatar_url, color and badges. The look is a snapshot of platform_users at hold time, read with a 250 ms timeout, fail-open and never through Helix; it is absent for a chatter Lumio has not seen. Above the 4 KiB Redis entry cap, Lumio drops fragments first, then badges, then avatar_url; color and the text stay. 7TV, BTTV and FFZ emotes are matched by word from the channel emote map in the client.
The reason distinguishes automod, blocked_term and blocked_link. Marked offsets are Unicode code-point positions with an inclusive end. The held row renders Twitch emotes and Cheers with their amount, and 7TV/BTTV/FFZ from the channel's emote map. It shows the sender's snapshot avatar, color and badges before the name, respecting the chat's avatar, badge and density settings; if no snapshot exists it uses the sender's latest feed row, then the ordinary no-avatar placeholder without badges. The name opens the regular user card with chat:userinfo and stays plain text without it, including after a decision or conflict. The UI renders held links as plain text and shows waiting time when Twitch provides no expiry deadline. In the dashboard, a known missing moderator:manage:automod scope on the acting user's login replaces Allow/Deny with Sign in with Twitch again; popouts retain the decision buttons and show the error toast after a refused click. Kick has no held-message queue: its AutoMod deletes immediately, and Lumio labels those deletions Kick AutoMod. Twitch IRC and the retired PubSub path do not carry this queue. The private Stream History log retains held-message details separately from the live queue. The same log is the third source of the server-merged history read, so held messages, including resolved ones with their outcome, survive a reload and appear when scrolling back at their original time.
Architecture
Backend
- GraphQL (
apps/api/src/graphql/chat.rs) -- Queries for chat history, message count, user profiles, emotes, moderation log, and moderator notes. Mutations for sending messages, moderation actions, and CRUD on user notes. - Crate (
crates/lo-chat/src/) -- Core chat logic includingChatBuffer(Redis-backed message buffering),ChatFilter/ChatMessageInputtypes,ProfileService(caching + circuit breaking for platform API enrichment), emote set types, moderation log, and platform user management. - Database -- Chat messages are stored in TimescaleDB (
platform_chat_messageshypertable). Platform users are tracked in PostgreSQL (platform_users). Moderator notes inplatform_user_notes. Moderation log entries inmoderation_log. - YouTube Member Badges -- Custom-tier-badge image URLs are sourced from YouTube's internal InnerTube endpoint (the public Data API does not expose them) and cached in Redis. Inline-enriched into
platform_chat_messages.badgesat INSERT time so chat history scrollback renders correctly. See Member Badges for the full architecture, configuration, and GDPR considerations. - Real-time -- Messages are broadcast via Redis pub/sub to all WebSocket subscribers for the account. The
PendingChatAccountsstate tracks which accounts have buffered messages needing flush.
Frontend
- Next.js API proxy routes call GraphQL internally; browser hooks use the
/api/chat/*proxy and keep popout-token support on every fetch. - Emotes and badges in Multichat, event rows and user-card chat rows open a larger media card after desktop hover or a tap on touch devices. A phone uses a bottom sheet. The card resolves cross-channel origin and Kick/YouTube animation through the popout-capable
emoteInfoproxy; unknown animation shows no chip. Emotes can copy their paste text and ID, while badge cards have no copy controls. The same card works in chat popouts, including OBS; OBS browser sources offer Copy link beside external links because they can block popups. - The Multichat UI renders messages with emotes, badges, colors, reply threading, platform/broadcast filters, keyword tabs, rule colors, per-user highlight colors (each highlighted user carries its own colour - the shared highlight token by default, a custom colour, or switched off to park the entry - edited in a colour dialog opened from the chip's colour button, with a live preview and an explicit Apply/Cancel), notification sounds (per keyword rule, plus one fixed sound for the whole highlighted-user list - a keyword rule's sound wins when a message matches both), and hidden/deleted-message display controls.
- Localized chatter names surface the login (Twitch). A chat row, the reply-preview header, and the moderation-confirm dialogs render a chatter's display name on its own. On Twitch a viewer can set a display name in a non-Latin script (
こさむん) that is unrelated to their immutable login (kosamun); when that happens the surface renders both asこさむん (kosamun), so a moderator still sees the handle to act or search on. An ordinary name is never bracketed: a display name that is only a case-variant of the login (Cruex/cruex) or an accented Latin name (Ömer) renders alone, so the bracket is not noise on every chatter. The trigger is three conditions ANDed - the display name differs from the login case-insensitively, it carries at least one letter outsideScript=Latin, and the platform is Twitch - so Kick/YouTube (where the display name is not an independently editable field) never bracket. The decision is made at render time from theusername+display_namecolumns every message already stores, so it also applies to older history rows after deploy; the composed string is display-only and never persisted. The@-mention completion lists and the composed reply body always key on the raw login, never the bracketed string. The User Info Card keeps Twitch's own two-line layout (the localized name as the title,@loginbeneath it) rather than the inline bracket. - First-time chatter card (Twitch). When a viewer sends their first-ever message in a connected Twitch channel, the row renders as the first-time-chatter spotlight card (see Spotlight cards). It is driven by the
twitch:first_messageevent (from the IRC-tags listener) correlated to the chat row by message id, so the card appears even when the event arrives just after the message. It shows in the dashboard chat and in a logged-in chat pop-out/overlay. A delegated/shared chat pop-out (a popout session opened from a sharedlm_pop_*link) shows it only when its token was grantedevents:read- the card rides theevents:\{account_id\}stream, which a shared popout subscribes to only under that condition; see Inline stream events. - The tab bar (platform + keyword tabs) is a single horizontally-scrolling row: it never wraps to a second line, so a crowded bar or a narrow pop-out costs no chat height. Desktop shows edge arrows and fades only while the row overflows; touch swipes instead. The + (new rule) and gear (settings) are anchored outside the scroll area. The active tab scrolls into view on selection and on reload-restore. Keyword tabs are drag-sortable on a mouse (
@dnd-kit, 5px activation so a click still selects); touch keeps the Filters-list up/down arrows - both persist into the samekeywordTabsorder. - User info card (
apps/web/src/components/info-user-modal.tsx, sub-components inapps/web/src/components/user-card/) is a full-page view on the shared@lumio/uiFullscreenPanel(platform accent line, platform gradient,backdrop="blur"): header with avatar/name/badges/@username/ID, then two columns atlgand up (a sticky "who" column with profile facts, treatment status and actions; a full-height "what" column with the Messages · Mod notes · Moderation history · Events tabs) and a single column belowlgand in popout mode. Its moderation confirmation and external-link warning are passed through the panel'slayersslot so they render inside the panel's portal and stack above it (see Frontend Overlays); gating is unchanged - thepermissionsprop,platformSupportsModeration, thegetChatPermissionsactor-target matrix viaownBadges, and the popout session permission subset. The card fetches a unified profile combining DB data with live platform API enrichment. Its message history still shows rows that the feed hides as deleted, so moderators keep audit context. From the chat (not the event feed), it also carries one-click Highlight / Hide buttons that write the same name-based, cross-platform rule as the Filters tab via the shared@lumio/chat-filterstoggle helpers (toggleHighlightUseris three-valued - add / switch a parked entry back on / remove - so a second click never stacks a duplicate); the buttons are prop-gated on a filters setter, so the event-feed variant of the card never offers a folgenlos toggle. - Chat filters and keyword tabs live in
localStoragewith an account-scoped key plus an unscoped mirror for token popouts; the shared@lumio/chat-filtersmatcher keeps dashboard and popout behavior identical. Astorage-event listener re-reads the filter/tab keys when another document writes them, so a Highlight/Hide toggle made in the OBS popout reaches the dashboard tab (and vice-versa) without a reload. - Reconnect notices before a send fails. When a grant the Multichat send path depends on can no longer be refreshed - the
reconnect_requiredstate the dashboard shows as Neuverbindung erforderlich - the Multichat shows it in its connection notices under the topbar before a send fails at the provider. Two sources, two entries: the platform's channel connection (connectionStatuses.reconnectRequired) and the acting user's personal login grant (connectionStatuses.loginReconnectRequired, ZAF-1045) - the send actually uses the login grant (the founder'saead::Errorcase). Both appear as separate entries because they are repaired in different places: the login entry routes to Account → Profile, the channel entry starts the OAuth flow in place for viewers holdingconnections:create, and for everyone else links to the connections page. Login entries appear only for platforms the user can actually send on (the active account has the platform's channel connection and a login of the user for it is assigned to the account, the rule that decides which send toggles are shown) and only withchat:write; a viewer withoutconnections:create(a Moderator) keeps the channel entry exactly for those sendable platforms. The affected platform's send toggle is disabled (with a reconnect tooltip) and dropped from the active send set, so no message is dispatched to a grant that would 401. A delegated pop-out guest (token session, not a first-party user) sees the entries without a button, and never the login-assignment notice. With the pop-out header hidden but the input visible, only these send-relevant entries appear, compactly, above the input; header and input both hidden ⇒ none. The notices sit in the normal layout flow, and the feed keeps a bottom-stuck reader at the newest message (and a scrolled-up reader on their line) while they change height. - Failed-send messages are translated, never raw. A rejected send now carries a machine-readable
errorCode(login_reconnect_required|internal_error|send_failed) on bothsendChatToPlatform(GraphQL) andPOST /v1/chat/send(REST). The failed-send banner renders a translated line off that code - thelogin_reconnect_requiredcode is always a dead personal login grant, so it shows the login-specific reconnect line and a Jetzt neu verbinden button that opens Account → Profile;send_failed(the platform refused the message) shows "{platform} rejected the message.";internal_errorshows "Lumio could not send the message - please try again." Both arrive as HTTP 200 withsent: false, so they carry no status suffix. An unrecognised or absent code shows only "Send failed (status)". The raw backend error chain (crypto/DB internals such asaead::Error) is never rendered in the UI (ZAF-1047, ZAF-1048, ZAF-1627). - The alert box (hype train, ad break, poll, prediction, outgoing raid) is event-sourced through one pure reducer (
applyAlertEvent) and persisted across refreshes by two layers. Layer A writes the five card slots plus adismissedregistry tolocalStorage(lumio:chat-alerts, same account-scoped-key + unscoped-mirror Pattern-B storage as the filters). On mount it reconciles every slot against the wall clock - countdowns are absolute epoch-ms deadlines, so an ad break/raid resumes with the correct remaining time, an alert whose end already passed returns ended (with its wind-down window) or is dropped, and anything past its age limit (prediction locked 30 min; hype train / YouTube poll 15 min) is discarded, never shown as running. Layer B is a server replay of the last 30 minutes of Twitch event history viaGET /api/events, gated client-side onevents:read(so a token dock without that scope never fires the request) and fail-soft (a502/empty result is additive-only and can never blank Layer A). One merge reconciles both sources: Layer A is the source of truth for live values, Layer B for existence and end - so an alert whose end the browser missed is restored ended with the last live numbers. A dismissed card is filtered out of both sources and the dismiss propagates cross-tab through the samestoragelistener as the filters. A restored card renders identically to a live one - the alert box carries no restored/stale badge.
API
GraphQL Queries
| Query | Permission | Description |
|---|---|---|
chatHistory(filter: ChatFilterInput) | chat:read | Paginated chat message history with filters for platform, user, date range, keyword search, and YouTube liveChatId (broadcast-level). Without from it reads the 30 days before to (or now); total is capped at 10 000 (totalCapped: true = more match) — see Bounded list reads |
mergedChatHistory(filter: MergedChatHistoryFilterInput): ChatHistoryPage! | chat:read and/or events:read, plus chat:automod-read for AutoMod rows (per-source, see below) | Server-merged messages + events + AutoMod holds timeline: one created_at-ordered, keyset-paged page for the multichat history view (see Inline stream events) |
platformUserProfile(platform, platformUserId) | chat:userinfo | Unified user profile with platform API enrichment via ProfileService |
searchPlatformUsers(query, platforms, limit) | chat:userinfo | Prefix-search the account's known chatters by username/display name for the filter name-completion (min 2 chars, else empty; capped at 25) |
emotes(platform, channelId) | chat:read | Emote sets for a platform/channel (7TV, BTTV, FFZ, YouTube native, Kick native) |
userEmotes | chat:read | Twitch emotes available to the authenticated user (subs, globals, follower) |
emoteInfo(platform, items) | Authenticated (sessions, API/service/developer keys, popout tokens, System keys; overlay/widget/extension tokens refused); no RBAC permission | Owner details and Kick/YouTube animation for up to 50 emotes (see GraphQL reference) |
platformUserNotes(platform, platformUserId) | chat:notes | List moderator notes for a platform user |
moderationLog(platform, platformUserId, page, limit) | chat:userinfo | Moderation action history for a user |
GraphQL Mutations
| Mutation | Permission | Description |
|---|---|---|
sendChatMessage(input: ChatMessageInputGql!) | System key with bots:internal + feature:multichat for input.accountId | Bot-fleet ingest: buffer a chat message in Redis and broadcast via pub/sub. accountId is required (account_id is required); tenant credentials cannot call it (ZAF-1922) |
sendChatToPlatform(input: SendToPlatformInput!) | chat:write | Send a chat message to a platform (twitch, youtube, kick). The message is authored by the logged-in user's platform identity (their Twitch/Google login), so they need a login connection for the target provider. The target chat is the broadcaster's stream - for YouTube the active liveChatId is read from the polling worker's Redis cache (same source as youtubeActiveStreams), so invited members can post even though they themselves have no active stream. Resolution prefers a currently-live broadcast; when nothing is live yet it falls back to the open waiting-room chat of a scheduled (upcoming) broadcast, so bots and commands can respond in the pre-stream chat that the worker already reads. With multiple concurrent broadcasts (of the same status) the caller must pass liveChatId to choose which one to post to. |
createPlatformUserNote(platform, platformUserId, note) | chat:notes | Create a moderator note on a user |
updatePlatformUserNote(noteId, note) | chat:notes | Update an existing moderator note |
deletePlatformUserNote(noteId) | chat:notes | Delete a moderator note |
updateUserTreatment(platform, platformUserId, treatment) | chat:ban | Update chat user treatment (none, active_monitoring, restricted); Twitch syncs to Helix best-effort |
moderateChat(input: ModerationInput!) | chat:ban / chat:timeout / chat:delete | Perform a moderation action (ban, timeout, delete) on Twitch, YouTube, or Kick. Permission checked per action type. YouTube auto-resolves the active liveChatId from the polling worker's Redis cache when not supplied. After a successful ban/timeout the server soft-deletes the affected user's messages of the last 24 hours (see Ban / Timeout Sweep) and broadcasts a chat:clear_user WebSocket event so all connected clients grey those messages out - same UX as the platform's native chat. All three platforms support delete + timeout + ban; Kick goes through its official public API. The action runs on the moderator's login token, which is refreshed on demand. When an action is refused the mutation returns success: false with a human-readable details reason and a stable errorCode (login_scope_missing, login_reconnect_required, moderation_forbidden, moderation_failed, internal_error) as HTTP 200, not an error. The client surfaces the translated reason and leaves the user's messages untouched. See GraphQL → moderateChat. |
cancelRaid | chat:raid | Cancel a pending Twitch raid |
endPoll(pollId, status?) | chat:poll | End an active Twitch poll (status defaults to TERMINATED). Acts as the channel: it uses the broadcaster token of the account's Twitch channel connection (refreshed on demand), never the caller's login, so moderators and service keys can end polls. Shares one implementation with DELETE /v1/chat/poll (services::twitch_channel_actions) - same guard, same errors |
endPrediction(predictionId, status, winningOutcomeId?) | chat:prediction | End/cancel a Twitch prediction (RESOLVED requires winningOutcomeId). Acts as the channel like endPoll, sharing one implementation with DELETE /v1/chat/prediction |
refreshPlatformUserProfile(platform: String!, platformUserId: String!) | chat:refresh_user | Force a ProfileService re-enrichment for one platform user, bypassing the Redis cache. Rate-limited to once per 10 minutes per user via a Redis SETNX. |
Every chat query and mutation additionally requires the feature:multichat
feature flag; the GraphQL guards combine FeatureGuard("feature:multichat")
with the permission above, and the REST handlers call the same
require_feature. moderateChat is the one exception to the guard shape: its
declared guard is AuthGuard + FeatureGuard, because the required permission
(chat:ban / chat:timeout / chat:delete) depends on the action in the
input and is therefore resolved inside the resolver.
Sending: result shape and reconnect signalling
sendChatToPlatform and POST /v1/chat/send author the message with the
logged-in user's personal login grant for the platform (twitch→twitch,
youtube→google, kick→kick) - not the account's channel
connection. That grant is acquired through the central refresh chokepoint, so an
expired-but-refreshable token is refreshed transparently before sending; a grant
that can no longer be used is flagged reconnect_required and reported as a
typed signal.
Both protocols call one service, services::chat_send::send_to_platform
(apps/api/src/services/chat_send.rs): it checks the channel connection,
acquires the login grant, dispatches to the platform and returns either a hard
refusal (no channel connection, unsupported platform, no login connection,
missing Twitch client id, database error - each protocol keeps its own error
text) or a send outcome with the errorCode below.
The send result carries a machine-readable errorCode (error_code on REST) so
the client renders a translated message instead of parsing backend strings.
Both protocols return the identical shape, codes and platform, as HTTP 200 in
every case below (only a hard validation error - unsupported platform, no channel
connection - is a protocol error):
errorCode | Meaning |
|---|---|
absent (sent: true) | Sent; messageId is set when the platform returns one. |
login_reconnect_required | The user's personal login grant is dead - undecryptable ciphertext, already flagged, or expired with no refresh token. The connection is flagged reconnect_required and the user must reconnect that login. No crypto/DB internals are ever returned in error. |
internal_error | An internal failure reading the credential; the human-readable error is scrubbed and the cause is logged, never shipped. |
send_failed | The platform rejected the send (slow-mode, ban, invalid content, …); error carries the platform's message. |
A dead login grant also surfaces on the connection-status read model as
loginReconnectRequired (see Connections),
so the Multichat can warn before the first send attempt.
REST Endpoints
All paths live under /v1. Bodies are snake_case and mirror the GraphQL inputs.
| Method | Path | Permission | Description |
|---|---|---|---|
GET | /v1/chat/history | chat:read | Paginated chat history (filter by platform, user, date, keyword search, YouTube live_chat_id) |
GET | /v1/chat/history/merged | chat:read and/or events:read, plus chat:automod-read for AutoMod rows (per-source) | Server-merged messages + events + AutoMod holds, one keyset-paged timeline page (see Inline stream events) |
POST | /v1/chat/message | System key with bots:internal + feature:multichat for body account_id | Bot-fleet ingest/buffer of a message; account_id required (ZAF-1922) |
POST | /v1/chat/send | chat:write | Send a chat message to a connected platform |
POST | /v1/chat/moderate | chat:ban / chat:timeout / chat:delete | Ban, timeout, or delete on twitch / youtube / kick (permission resolved per action). Mirrors the GraphQL moderateChat mutation 1:1 - same fields, same auto-resolve of live_chat_id for YouTube, same chat:clear_user broadcast on ban/timeout, same error_code. Both twins call one shared implementation (services::chat_moderation::moderate). |
DELETE | /v1/chat/raid | chat:raid | Cancel the current Twitch raid |
DELETE | /v1/chat/poll | chat:poll | End the current Twitch poll. Runs on the channel's broadcaster token; mirrors GraphQL endPoll 1:1 |
DELETE | /v1/chat/prediction | chat:prediction | Lock/resolve the current Twitch prediction. Runs on the channel's broadcaster token; mirrors GraphQL endPrediction 1:1 |
GET | /v1/chat/users/search | chat:userinfo | Prefix-search known chatters by username/display name for the filter name-completion. ?q= (min 2 chars, else empty), optional ?platform= (comma-separated), ?limit= (capped at 25). Mirrors GraphQL searchPlatformUsers 1:1. |
GET | /v1/chat/users/{platform}/{platform_user_id} | chat:userinfo | Unified user profile (DB + enrichment) |
GET | /v1/chat/users/{platform}/{platform_user_id}/moderation-log | chat:userinfo | Moderation action history |
GET | /v1/chat/users/{platform}/{platform_user_id}/notes | chat:notes | List moderator notes |
POST | /v1/chat/users/{platform}/{platform_user_id}/notes | chat:notes | Create a moderator note |
PATCH | /v1/chat/users/{platform}/{platform_user_id}/notes/{note_id} | chat:notes | Update a moderator note |
DELETE | /v1/chat/users/{platform}/{platform_user_id}/notes/{note_id} | chat:notes | Delete a moderator note |
PUT | /v1/chat/users/{platform}/{platform_user_id}/treatment | chat:ban | Set user treatment (none, active_monitoring, restricted) |
POST | /v1/chat/users/{platform}/{platform_user_id}/refresh | chat:refresh_user | Force-refresh the enriched profile, bypassing the cache. Mirrors GraphQL refreshPlatformUserProfile 1:1, including the 10-minute per-user rate limit. |
WebSocket
| Channel | Gate | Feature flag |
|---|---|---|
chat:{account_id} | chat:read on the account in the channel key | feature:multichat |
The channel carries the live message stream plus the moderation side-events:
chat:message, chat:delete, chat:clear_user, chat:moderation_log, and
chat:user_treatment_update. The feature flag is enforced at subscribe time —
without feature:multichat a crafted chat:{account_id} subscription is
rejected, matching the REST/GraphQL gate.
Bounded list reads
chatHistory and GET /v1/chat/history always read a bounded time range: the supplied from, else 12 hours for a keyword search, else the 30 days before to (or now). Chat is kept for up to 1 095 days, and an unbounded read would scan the account's whole history on every page. Pass an explicit from to page further back. mergedChatHistory keeps its own keyset walk window.
total is a count capped at 10 000. When more messages match, total is 10000 and totalCapped (REST: _meta.total_capped) is true, shown as "10 000+". pages covers the capped total, and a page past it is empty. GraphQL counts only when total, totalCapped or pages is selected. The same rules apply to events / GET /v1/events (Events). There is no whole-account message-count endpoint.
Keyword search
chatHistory accepts a keyword search filter so a keyword tab can pull older matches from TimescaleDB instead of only what is left in the in-browser buffer. It is exposed as ChatFilterInput.search (GraphQL, [String!]) and as the comma-separated search query parameter on GET /v1/chat/history (REST, e.g. ?search=hello,world). A message is a candidate when its body contains any of the keywords (case-insensitive substring). The server predicate is a coarse message ILIKE-any-keyword - a deliberate superset of the shared @lumio/chat-filters matcher, which the client re-checks so live and back-loaded messages judge identically.
- Always time-bounded. A keyword search never scans the whole hypertable: it always carries a lower time bound - the caller's
fromwhen supplied, otherwise the last 12 hours. Plain (non-search) history queries default to the 30 days beforeto(or now) instead (Bounded list reads). Because a keyword search is capped to a window and the daily retention sweep bounds the data, "no older matches in the window" is an expected, ordinary end state. - Bounded input. At most 20 keywords, each at most 100 characters (after trimming and dropping blanks). A violation returns
400 BAD_REQUESTwith the identical message on GraphQL and REST - the sharedlo_chat::validate_search_termsbacks both. Wildcard metacharacters (%,_) inside a keyword match literally. REST splits the singlesearchquery parameter on commas, so a keyword that itself contains a comma cannot be expressed through REST; use GraphQLChatFilterInput.search: [String!]for comma-containing keywords. - No new permission or flag.
searchonly narrows the existing history surface, which is already gated onchat:read+feature:multichat.
The client side (useMultichat / Multichat) drives this window:
- Per-tab paging.
loadMoreruns in two modes: for a keyword tab it pages the keywordsearchwindow (fromseeded once at 12 h on first open); otherwise it pages the general history. Paging state (page/hasMore) is kept per view so one tab never inherits another's exhausted end. - Capped follow-on. A candidate page can yield zero visible rows (the server pre-filter is a superset of the matcher). The infinite-scroll keeps loading - hard-capped at 5 pages per trigger - until a row matches or the window is exhausted, so a keyword with no recent hits never loops. Back-loaded rows are tagged
__source: "history"(client-only) and merged bycreated_atwith dedupe onplatform_message_id ?? id, keeping the list chronological and duplicate-free. Both merge modes trim only from the oldest end — never the newest (see below). - A scroll-up never deletes the newest rows; rendering is scroll-independent (ZAF-1529 / ZAF-1530 / ZAF-1533). Founder invariant: "Neuste nachrichten sollten nie gelöscht werden." Paging older history grows the buffer to keep both ends.
mergeOlderMessagestrims only from the oldest end, keeping the newestMAX_HISTORY_MESSAGES = 5000— an order of magnitude above theMAX_MESSAGES = 500live-tail cap — so a normal scroll-up simply grows the buffer and the trim bites only at that documented memory ceiling (~50 scroll-up pages), never mid-scrollback. The earlierslice(0, max)did the opposite (kept the oldest, dropped the newest overflow); from the 5th scroll-up page on it deleted exactly the history the reader had just scrolled past, which also shrankscrollHeightand grew the scrollbar thumb — the ZAF-248 (04abe689, 2026-08-04) regression this reverses. What renders is a pure function of the loaded data, never of the scroll position: scrolling can neither add nor remove a line. The live-append path (addMessage, viatrimLiveTail) trims the oldest back only once the reader is at the live end — toMAX_MESSAGESfor a fresh buffer, but toMAX_HISTORY_MESSAGESonce a scroll-up has grown it (bufferGrownRef), so returning to the present and then receiving a live message never snaps the paged history back out (ZAF-1538). Jump-to-bottom does not reload the window: the buffer already holds the live tail and the older history the reader paged in, so re-loading a 500-row window there would only discard that history (the founder's ZAF-1538 report). It instead callsfillGap(throttled, deduped, never discards a row — see Gap-fill) to pull any message caught in the ~5 s write-buffer flush lag without dropping anything. Only mount and a server-side filter change reload the newest window (loadLatestWindow→mergeLatestWindow, which keeps any live message that landed during the fetch and resets the buffer to the 500-row window), because there discarding the previous view's rows is correct. - Following the live end is tied to reader intent, not to a pixel zone (ZAF-1408 / ZAF-1647). Multichat keeps the feed glued to the newest line through a bottom-stick window: armed by every deliberate end-landing (mount, filter or tab switch to the end, the jump-to-bottom button, Page Down) and by the reader scrolling down until they reach the true end (
AT_END_PX = 2px,interpretReaderScrollinlib/chat-display.ts). While armed, content growth (live tail, event back-fill, late avatars/embeds via aResizeObserver) re-pins to the bottom. Any reader gesture away from the end (wheel,touchmove, Arrow Up / Page Up / Home) disarms it, and any upward movement - however small - marks the reader as scrolled up, so the 100-px near-bottom zone (NEAR_BOTTOM_PX) no longer re-pins on live growth (shouldStickToBottom). That zone drives only the UI: the jump bar, live-end buffer trimming and the load-older gate. A single wheel notch up (~100 px in Chromium, ~3 lines in Firefox) therefore stays put; scrolling back down to the end glues again without aloadMore. - The inline event ring falls under the same invariant.
mergeEventBatch(use-chat-events.ts) never evacuates the newest events: it keeps every reachable event (created_at >= floorAt, the oldest buffered message — the interleaved + live-tail region) up toMAX_HISTORY_EVENTS = 5000, and drops below-floor events (older than the oldest loaded message) unconditionally once over the cap — the server-merged cursor re-fetches them together with their messages when the reader pages that window in (ZAF-1535). Dropping them, rather than the old "keep the freshest below-floor to fill headroom", is what couples the ring to the message span: a live-tail trim that raisesfloorAtsheds the now-orphaned event lines instead of the ring hoarding thousands of them (the 500-message-vs-5000-event divergence). The window is a pure function of the loaded data, never of scroll position. At the start of history the floor lifts (ZAF-2329): once the view has no older page left (hasMore === false) and the buffer still holds its oldest message, the events before the first message are the real start of the chat, so the ring keeps them as reachable instead of dropping them. - Reliable end state. The BFF (
/api/chat/history) passes the GraphQLtotal/page/pagesthrough, sohasMoreis read frompage < pagesrather than guessed from the page length; a failed first fetch leaves paging retryable instead of permanently off.
Name completion (searchPlatformUsers)
The Hidden users and Highlighted users filter lists offer an @-completion bound to the account's real chatter base - the platform_users table, which the chat flush updates for every sender (see Chatter tally). searchPlatformUsers (GraphQL) and GET /v1/chat/users/search (REST) back it, returning the slim { platform, platformUserId, username, displayName, avatarUrl, lastSeenAt, messageCount } shape.
- Prefix match, both name columns. Case-insensitive prefix (
prefix%) onusernameanddisplay_name.%,_, and\in the query match literally (the same escaping as member search). Orderedlast_seen_at DESC, message_count DESC- whoever wrote most recently ranks first. - Minimum two characters. A query shorter than 2 characters (after trimming) returns an empty list, not an error - the UI queries on every keystroke.
platforms(GraphQL[String!], REST comma-separated?platform=) restricts the platforms; omit for all.limitis hard-capped at 25 regardless of the requested value. - Same guard as the profile query.
feature:multichat+chat:userinfo, identical toplatformUserProfile, so the completion never surfaces a user the caller could not already inspect - and withoutchat:userinfothe search returns nothing (no data leak). The account is always derived server-side from the (cookie or popout) token, never from a client parameter, so a popout with?token=lm_pop_*gets results without sending an account id. - Indexed prefix. Backed by
(account_id, lower(username) text_pattern_ops)and the matchingdisplay_nameindex (20260805000001_platform_users_search_prefix_index) so a per-keystroke prefix lookup range-scans instead of full-scanning the monotonically growing, never-pruned table. The predicate useslower(col) LIKE …(notILIKE) to match the index expressions exactly. - All chat platforms populate it. Twitch, YouTube, and Kick all record their senders in the chatter tally, so a chatter on any platform appears in the completion within one flush tick (≈ 5 s) of their first message.
Chat badges — where they come from
Every chat message and every chatter in the user card carries a badges array in one shared shape, whatever the platform:
[{ "set_id": "moderator", "id": "1", "image_url": null, "info": "Moderator" }]
set_id names the badge (broadcaster, moderator, subscriber, vip, …), id is its version or tier, image_url is the platform's artwork when it is known, and info is free text (Twitch sub months, Kick badge label). Twitch badges add title, Twitch's readable English name for the badge version (Gold Pixel Heart, 25 Gift Subs). Kick badges add count (subscription months, gifted subs). YouTube member badges add tier_name, duration_value and duration_unit. Next to the array, each message and user carries the role flags is_mod, is_sub and is_vip, derived from the same badges.
Twitch
| Step | Current behaviour |
|---|---|
| Source | EventSub channel.chat.message badges[] (set_id, id, info). |
| Image URLs and titles | Resolved from the worker's badge cache (global and channel badges, loaded from Helix when the worker starts; a channel badge wins over a global one with the same set_id and id). The cache holds each version's image and its Helix title. |
| Normalisation | Passed through unchanged plus image_url and title. title is stored with the message as it was at that moment and is left out when Twitch sends none; messages stored before titles were recorded have no title. The twitch:watch_streak event's badges use the same cache and shape. is_mod = moderator, lead_moderator or broadcaster; is_sub = subscriber; is_vip = vip. |
YouTube
| Step | Current behaviour |
|---|---|
| Source | InnerTube author flags (owner, moderator, member) and author badges. |
| Image URLs | Only for the member badge: its artwork and tier come from the InnerTube badge or the membership badge cache (see Channel Memberships). Broadcaster and moderator have no image URL. |
| Normalisation | One entry per flag: broadcaster, moderator, and subscriber (members, enriched with image_url, tier_name, duration and info = months when the cache knows the tier). |
Kick
| Step | Current behaviour |
|---|---|
| Source | chat.message.sent webhook, sender.identity.badges[] (type, text, count) and sender.is_verified. |
| Image URLs | Kick sends no badge artwork. Two caches fill it in, both from undocumented kick.com endpoints (see below): the channel's sub-badge images and the chatter's selected global badges (level, founder …). Role badges (broadcaster, moderator, VIP, OG, verified, sub gifter …) have no image anywhere and render from built-in icons. |
| Normalisation | Each badge becomes {set_id, id, info, count?, image_url?}: set_id = Kick type, info = Kick text, count = Kick's number (subscription months, gifted subs), id = the tier (below). sender.is_verified adds a verified badge. |
Kick type | set_id | id | Flag |
|---|---|---|---|
broadcaster | broadcaster | "1" | is_mod |
moderator | moderator | "1" | is_mod |
subscriber | subscriber | the chosen sub-badge tier's months (without a tier cache: count) | is_sub |
sub_gifter | sub_gifter | count (gifted subs) | — (gifting is not subscribing) |
vip | vip | "1" | is_vip |
| global badge (selected by the user) | level, or the badge name (e.g. kick_founder22) | the level (level only), else "1" | — |
anything else (e.g. og, verified, staff) | passed through unchanged | count or "1" | — |
Sub-badge images, per channel. https://kick.com/api/v2/channels/{slug} →
subscriber_badges[{months, badge_image.src}], cached in Redis for 6 h. For a
subscriber with count months the highest tier with months ≤ count is chosen:
its months become id and its image is frozen into image_url on the stored
message.
Global badges, per user. https://kick.com/api/v2/channels/{channel_slug}/users/{user_slug}
(user_slug = the webhook's sender.channel_slug) → badges_v2, keeping only
the entries the user chose to show (selected: true): name, image_url on
ext.cdn.kick.com and metadata.level. The level is Kick's global watch time,
so the cache is keyed by user and kept 4 h. A level badge adds
{set_id: "level", id: "<level>", info: "Level <level>", image_url}; if Kick
itself sent a badge of the same type, that badge only gains the image.
Ingest never waits for either cache. It only reads Redis. On a miss it
schedules a background fetch and the message goes out without the image or the
global badge, so a chatter's first message in a cache period can lack their
level badge. Fetches are de-duplicated across API replicas with a Redis lock
(which also spaces out retries after a failure), limited to 4 in flight per
process (a burst beyond that is skipped, not queued), and stopped for 5 minutes
by a circuit breaker after 5 failures in a minute. Both endpoints have no
contract and sit behind Cloudflare: when they fail or change shape, only the
images and global badges disappear — chat, role badges and history are
unaffected. Metrics: chat_sub_badge_fetch_total, chat_identity_badge_fetch_total
and chat_identity_badge_cache_total, labelled only by platform and outcome.
Kick message text, replies and decode failures. Kick emotes arrive as
[emote:ID:NAME] markers inside content; the stored message is plain text
plus one emotes entry per occurrence (see Emotes → Kick chat emotes).
A reply stores the parent's id in reply_parent_msg_id when it is a UUID — Kick's
Chat API types message ids as format: uuid — and leaves it NULL otherwise; the
parent's text in reply_parent_message is plain text too. A
chat.message.sent payload that does not decode is still answered with 200
(Kick disables a subscription only for an app that stops answering), but it is
logged at warn and counted in chat_ingest_failures_total{platform="kick", outcome="decode_error"} instead of disappearing silently.
Storage
| Where | Database | Holds | Read by |
|---|---|---|---|
platform_chat_messages.badges | TimescaleDB | The badges as they were on that message. | Live chat (WebSocket chat:message), chat history. |
platform_users.badges | PostgreSQL | The latest known badges of the chatter, overwritten by every chat message. | User card: GraphQL platformUserProfile and REST GET /v1/chat/users/{platform}/{platform_user_id}. |
platform_users.badges is merged with COALESCE, so a write without badge data keeps the stored value. Moderation events (bans, timeouts) carry no badge data and never touch it. A chat message whose sender has no badges writes [], so a demoted moderator loses the badge on their next message. On Kick, only a webhook without any identity counts as "no badge data". A message row stores null instead of [] when there are no badges, which renders the same. The user card goes through ProfileService, which caches an enriched profile for up to 4 hours, so a badge change can take that long to show there.
Rendering
apps/web/src/components/chat-badges.tsx renders the array in chat rows and the user card:
image_urlwins (Twitch badges, YouTube member badges, Kick sub badges and Kick global badges such as the level badge).- A
levelbadge without an image renders a green plaque showing the level number. - Otherwise a per-platform built-in icon, if one exists for that
set_id. Kick has one for every role badge:broadcaster,moderator,subscriber(used when the channel's sub-badge image is unknown),sub_gifter,vip,og,verified,staff,sidekickandbot. The broadcaster, moderator and VIP icons are Kick's ownHostBadge,ModeratorBadgeandVIPBadgeartwork, rendered verbatim at the same size as every other badge. The others are Lumio's own artwork, drawn as a coloured plaque with one glyph. - Otherwise the generic built-in SVG for the
set_id(broadcaster,moderator,lead_moderator,subscriber,vip). - Otherwise a
•placeholder.
Tooltip and accessible name. Every badge carries a tooltip, which is also its alt text or aria-label. It never shows a raw badge ID; the first rule that applies wins:
-
Standard badge — a translated label from
chat.badge.*(en/de). Twitch and Kick spell some IDs differently; both map to the same label:Label (en / de) Twitch set_idKick set_idBroadcaster / Streamer broadcasterbroadcasterModerator / Moderator moderatormoderatorLead Moderator / Lead-Moderator lead_moderator– VIP / VIP vipvipSubscriber / Abonnent subscribersubscriberFounder / Gründer founderfounderSub Gifter / Sub-Gifter sub-giftersub_gifterTop Sub Gifter / Top-Sub-Gifter sub-gift-leader– Bits / Bits bits– Top Cheerer / Top-Cheerer bits-leader– Verified / Verifiziert partnerverifiedPrime Gaming / Prime Gaming premium– Turbo / Turbo turbo– Staff / Staff staffstaffAdmin / Admin admin– Global Moderator / Globaler Moderator global_mod– Artist / Künstler artist-badge– Bot / Bot bot-badgebotWatching without audio / Ohne Ton no_audio– Listening only / Ohne Video no_video– OG / OG – ogSidekick / Sidekick – sidekickYouTube role badges use the same labels. Standard badges add their tier: Twitch
subscribershows the months frominfoand Kicksubscriberthe months fromcount("Subscriber · 12 months" / "Abonnent · 12 Monate"); Twitchsub-gifterandbitsshow the versionidand Kicksub_gifteritscount("Sub Gifter · 50"). Kicklevelshows "Level 7"; YouTube member badges keep their parsed tier and duration. -
Twitch
title— every other Twitch badge (event, channel and campaign badges) shows Twitch's English name, untranslated ("Gold Pixel Heart"). -
info— the platform's own text, for example Kick's "Kick Founder". -
Readable ID — otherwise the
set_idwith-/_turned into spaces and each word capitalised (gold-pixel-heart→ "Gold Pixel Heart"); this covers Twitch messages stored beforetitlewas recorded.
When a role flag is set but the array has no matching badge (for example is_mod without a moderator or broadcaster entry), ChatBadges synthesises the badge from the flag. A synthesised badge has no count, so its tooltip carries no tier. Badges sort as broadcaster, lead moderator, moderator, VIP, OG, subscriber, sub gifter, then the rest in their original order.
Twitch Shared Chat
Twitch Shared Chat lets several channels share one chat session - a message sent in channel B also appears in channel A's chat. Lumio recognises these cross-channel messages and attributes each to its origin channel so the dashboard/overlay can show which channel a message actually came from.
-
Source fields. On the Twitch EventSub
channel.chat.messagepath (crates/lo-twitch-apiChatMessageEvent), Lumio capturessource_broadcaster_user_id/login/name,source_message_id,is_source_only, andsource_badges. When the source broadcaster differs from the host broadcaster, ingest (process_chat_message) resolves the source channel's avatar throughProfileService::get_profile(a channel is a user on Twitch, keyed byplatform_user_id) and populates two fields on the chat message. -
Contract fields (identical on all three protocols).
is_shared_chat(isSharedChatin GraphQL) -truewhen the message originated in a different channel's room during a shared-chat session.source_channel(sourceChannelin GraphQL) -{ platform_channel_id, login, display_name, avatar_url }for the origin channel;null/absent for normal messages and for host-origin messages in a shared session.
These ship on the
chat:messageWebSocket payload, the GraphQLchatHistoryChatMessagetype, and the RESTGET /v1/chat/historyresponse. Non-shared messages are byte-for-byte unchanged (fields absent/null). -
Persistence. The
platform_chat_messageshypertable storessource_channel_id,source_channel_login,source_channel_display_name, andsource_channel_avatar_url(nullable) via the buffer→flush path, so chat-history scrollback keeps the origin-channel attribution. -
Dashboard and popout display. A Twitch Shared Chat message places its square, rounded-corner origin-channel avatar after the platform icon and before the badges. Its ring uses a stable channel colour shared with history charts; it falls back to an initial when no image is available. Kick and YouTube rows have no origin marker. The 40 px session bar below the tabs shows the Twitch icon, host-first avatar stack (at most four, then
+N), title, live state and Summe aller Kanäle. It appears only with the header visible and the All or Twitch tab selected. YouTube, Kick and keyword tabs have no bar or session event lines, and tab buttons have no channel avatars. An OBS popout with Show Header off retains origin markers and event lines without the bar. There is no separate Shared Chat bar setting. -
Session participants (backend). Twitch
channel.shared_chat.begin,.updateand.enddrive a server-owned session state, independently of whether a partner has written a message. GraphQLsharedChatSession, RESTGET /v1/chat/shared-chatandshared_chat_sessionframes on the existing chat WebSocket channel expose the same state. The read returnssession,noneorunknown; onunknownthe client keeps its last known session. Revisions prevent a slow read from undoing a newer push. A previously unseen partner message can show a provisional participant and trigger a debounced Helix reconciliation, but the message buffer never removes participants. Reloads, reconnects and tab switches show the current state without an entrance animation. -
Session bar controls. The bar opens one popover with a scrollable channel list and an in-place channel card. The list shows channel, host/own-channel markers, category, viewers, Only this channel and a Twitch link; its footer gives the sum and start time. Selecting a channel opens its card with banner, follower count when available, category, viewers and messages observed in this browser since session start. Escape returns to the list before closing it. Hovering an avatar with a mouse or activating it opens the same card directly; narrow popouts keep it inside the chat window. Session views use round avatars, while message and event rows use square avatars. The host has a crown; joining/leaving channels keep their origin avatar on older rows.
-
Motion. Only a fresh WebSocket transition animates. Full motion moves the bar and avatars, briefly highlights a join, fades a departure, and pulses the live dot. Reduced motion uses opacity only; Off switches immediately. The Chat/Events/Music motion override can further reduce the global preference. End state shows the duration and every partner seen in the session, then the bar closes after eight seconds.
-
Partner-channel events. Subs, resubs, gifts, upgrades, pay-it-forward, raids into the partner and Bits that happen in a partner channel of the session are stored as the own event types, marked
raw.is_shared_chat: truewith the sameraw.source_channelshape. They show in the events feed and as inline chat event lines with the channel-coloured origin avatar, but never count toward own-channel stream history or fire automations. The multichat alert box ignores them (applyAlertEventskipsraw.is_shared_chat, including persisted-alert replay). See Events → Twitch Shared Chat partner events. -
Session transition events. Lumio keeps the own channel's Shared Chat session state and stores each change as an event:
twitch:shared_chat_begin,twitch:shared_chat_join,twitch:shared_chat_leaveandtwitch:shared_chat_end. Every payload carriessession_id,host_broadcaster_id, the subject channel (broadcaster_id,login,display_name,avatar_url),inferredandrev.beginaddsparticipantsandtotal_viewers.joinadds the channel'sviewer_count,category_id,category,titleandfollowerswhen known.leaveaddsduration_secs,message_countandlast_viewer_count.endaddsduration_secs,partner_count,peak_total_viewers,partner_messages,end_reasonandparticipants. Aparticipantsentry hasbroadcaster_id,login,display_name,avatar_urland, when known,viewer_count,categoryandtitle. Onbeginit lists the channels in the session at that moment. Onendit lists every channel that was in the session, with its last known values: the channels still in it at the end in join order, then the partners that left earlier, in the order they left. -
No new gate. Shared-chat display is intrinsic to Twitch chat - it adds no RBAC permission, feature flag, or
plan_featuresrow and emits no audit event (chat is high-volume telemetry). Attribution is by channel (public broadcaster identity), never by viewer.
Channel-points messages
A Twitch chat message sent through a channel-points reward with text input carries
only the reward's id (channel_points_custom_reward_id) - its Twitch
message_type stays text. Lumio stores and delivers such a message with
message_type: "channel_points_custom_reward" on all three protocols (the
chat:message WebSocket payload, GraphQL ChatMessage.messageType, REST
GET /v1/chat/history). Twitch's own channel_points_highlighted
("Highlight my message") passes through unchanged. Twitch sends no reward name or
cost on the message, so none is shown. This applies to own and Shared Chat
partner messages alike; a partner message also carries source_channel. Rewards
without text input in a partner channel are invisible to Lumio, because Twitch
delivers the redemption event (twitch:reward) only for the own channel.
In Multichat such a message carries a "Redeemed with channel points" chip
(chat.redeemedWithChannelPoints, tinted with the twitch:reward accent) after the
badges - live and in loaded history, since the history proxy requests messageType.
Permissions
| Permission | Description |
|---|---|
chat:read | Read chat messages, view emotes |
chat:write | Send/ingest chat messages |
chat:userinfo | View user profiles, follow status, moderation log |
chat:delete | Delete chat messages |
chat:ban | Ban/unban chat users |
chat:timeout | Timeout chat users |
chat:notes | Manage moderator notes on platform users |
chat:raid | Cancel raids |
chat:poll | End polls |
chat:prediction | End predictions |
chat:refresh_user | Force a cache-bypassing profile re-enrichment |
Database
| Table | Database | Description |
|---|---|---|
platform_chat_messages | TimescaleDB | Chat messages hypertable with compression. Fields: id, account_id, platform, channel_name, announcement_color, user_id, username, display_name, message, emotes (JSONB), gifs (JSONB), badges (JSONB), color, is_mod, is_sub, is_vip, sub_tier, platform_message_id, reply fields (reply_parent_msg_id, reply_parent_username — the parent author's display name, which is also what is stripped as @{name} from the echoed reply body; reply_parent_login — the parent author's login/handle twin, ZAF-1452 F1, NULL for platforms without a distinct login and for pre-existing rows; reply_parent_message), deleted_at, deleted_by, action, action_duration_secs. created_at is populated from the platform timestamp (YouTube publishedAt, Twitch metadata.message_timestamp, Kick webhook created_at) so re-ingested backlog stays chronologically aligned across platforms. A partial UNIQUE index on (account_id, platform, platform_message_id, created_at) makes ingestion idempotent - workers re-fetching the live-chat backlog after a restart no longer create duplicates. The action column records why a row was hidden ("ban", "timeout", "delete"); action_duration_secs carries the timeout length. Both survive a page refresh so the UI keeps rendering "Hidden by X" / "Blocked 5min by X" instead of the generic "Deleted by X". The live_chat_id column stores the YouTube live chat ID for broadcast-level filtering, enabling the Multichat to show messages from a specific broadcast when multiple YouTube streams are active simultaneously. The gifs column stores the per-message Twitch GIF-keyboard fragments (an ordered JSONB array of {id, url, provider, alt}) in its own field - never folded into emotes, whose per-emote counts feed already-frozen stream-history reports. Twitch cheermotes do live in emotes as kind: "cheermote" entries (Emotes → Twitch cheermotes); the report counts skip them. |
platform_users | PostgreSQL | Platform user profiles with ban status, treatment, follower info, enrichment timestamps, and the latest known badges (see Chat badges) |
platform_user_notes | PostgreSQL | Moderator notes on platform users (created_by, created_by_name) |
moderation_log | PostgreSQL | Log of moderation actions (ban, timeout, delete) with target user/message, moderator, reason, duration. Both the target and the moderator carry a login/display-name pair (target_username/target_display_name, moderator_name/moderator_login) so a localized name renders as display (login); a half is null when the platform exposes only one name or a system placeholder acted (ZAF-1452 F7). |
Data Flow
- Platform adapter (Twitch IRC, YouTube live chat, etc.) receives a message.
- Message is sent to the API via the
sendChatMessageGraphQL mutation (bot-fleet System key withbots:internal, explicitaccountId). - Message is buffered in Redis via
lo_chat::buffer_or_persist()(see Buffer-push failures). - Message is broadcast via Redis pub/sub to all connected WebSocket clients.
PendingChatAccountstracks the account for the background flush worker.- Flush worker periodically (every 5s) writes buffered messages from Redis to TimescaleDB, splitting each account's batch into bind-parameter-safe chunks (see Flush durability).
- Frontend receives the message via WebSocket and renders it with emotes, GIFs, and badges.
Flush durability
ChatBuffer::flush() reads and deletes an account's Redis buffer atomically - LRANGE + DEL run inside one MULTI/EXEC transaction (RedisClient::pipeline_lrange_del), so a message pushed concurrently lands either in this batch or in the next one, never between the read and the delete. The drained batch is then briefly the only surviving copy. Several safeguards keep that copy from being lost and keep a bad batch from wedging the account:
- Chunked INSERT.
insert_messages_batchsplits the batch into fixed-size chunks (1000 rows) before writing. Each chat row binds 28 parameters and Postgres caps a single statement at 65535 bind parameters, so an un-chunked flush of ≥ 2341 messages - trivially reached by a raid or emote wave in one 5s window - would fail as a protocol error (the batch then re-queued forever). Chunking keeps every statement well under the ceiling regardless of batch size. - Re-queue on failed INSERT. If a chunk INSERT fails, the flush worker re-queues the drained messages onto the Redis buffer (
ChatBuffer::requeue) and re-arms the account so the next tick retries, instead of dropping them. Because ingestion is idempotent (the partial UNIQUE index →ON CONFLICT DO NOTHING), a retry after a partial insert never duplicates rows - including across chunk boundaries. Only if Redis is also unreachable are the messages genuinely lost, and that path logs loudly. - Dead-letter on repeated failure. A batch that keeps failing to persist must not re-queue onto the live buffer forever - that "poison pill" would silently stop the account's chat from ever being stored. After
CHAT_FLUSH_MAX_RETRIES(5) consecutive failed flush cycles for an account, the worker parks the batch in a dead-letter list (lumio:chat:deadletter:{account_id}) instead of re-queuing it, resets the failure counter, and logs aterror. No data is lost - the batch is preserved - and live chat keeps buffering normally. The dead-letter list is deliberately not matched by the live-buffer recovery scan, so a genuinely un-insertable batch can never re-wedge the live buffer. - Dead-letter drain / replay (ZAF-1565). Parking is not a dead end. A leader-gated drain pass runs on a slower cadence (~60s) -
ChatBuffer::dead_letter_account_idsscanslumio:chat:deadletter:*, and for each accountChatBuffer::drain_dead_letterread-and-deletes the parked batch and replays it into TimescaleDB (idempotent, so a partial prior insert never duplicates). If the insert still fails the batch is re-parked - which re-sets its TTL - so it is retried next cycle rather than dropped. This is what makes a parked chat window become visible again automatically once the persistence path heals (e.g. after a brief TimescaleDB outage), instead of requiring manual intervention. Because the dead-letter lists live in shared Redis, the drain leader can replay a batch that a different, now-stalled replica parked. Every park pairs theRPUSHwith aCHAT_DEAD_LETTER_TTL_SECS(72h) TTL in one atomic pipeline (RedisClient::rpush_with_expiry), so a parked batch can never linger in Redis without an expiry - the TTL is the backstop for the case where draining has stopped entirely. - Supervised worker. The flush worker runs under a supervisor (
workers::supervisor::supervise). If it panics - or returns without being asked to shut down - the supervisor logs aterror, incrementschat_flush_worker_restarts_total, and starts a fresh worker after a backoff (1s doubling to 30s). Without this a dead worker stopped message persistence silently until the next process restart, while ingest kept buffering and events (written synchronously, not through this buffer) stayed current. - Bounded concurrency. Each tick flushes up to 4 accounts at once, so one large or slow account no longer holds back every other account's persistence. Missed ticks are delayed rather than burst, so a slow pass does not turn into a back-to-back drain loop.
- Backlog visibility.
ChatBuffer::push()andChatBuffer::requeue()emit awarn(withaccount_idand length) whenever an account's buffer crosses a multiple ofCHAT_BUFFER_WARN_THRESHOLD(10 000) messages - an edge check, so a bulk re-queue that jumps over a multiple still warns. The flush worker also exports Prometheus series so backlog is visible before anyone reports it:chat_buffer_depth/chat_pending_accounts(live-buffer lag),chat_dead_letter_depth(parked backlog awaiting replay - a value that does not fall back to0means a batch is stuck un-replayable),chat_dead_letter_recovered_total(messages the drain replayed), andchat_flush_worker_restarts_total. The depth gauges are measured from Redis (the index setslumio:chat:buffer:pending/lumio:chat:deadletter:pending+ pipelinedLLEN, every ~30s, on every replica) - not from the worker's in-process state, which would read empty exactly when the backlog belongs to a dead worker or another process. See Metrics › Chat-domain series. - Restart recovery + periodic rescan (ZAF-1565). The pending-account set is in-process, so an API restart - or a replica whose flush loop has stalled - would otherwise orphan a buffer whose account does not chat again. Every buffer write keeps a Redis index set current in the same
MULTI/EXEC: a push or re-queue adds the account tolumio:chat:buffer:pending, the flush's read-and-delete removes it (the dead-letter list has the twinlumio:chat:deadletter:pending). Every ~30s the flush worker reads that set (ChatBuffer::buffer_backlog, the same pass that feeds the depth gauges) and re-seeds the pending set, so a buffer written on a now-stalled replica is picked up and drained by any live replica; members whose list is gone (an expired dead-letter TTL) are pruned in the same pass. A full keyspaceSCANruns only at worker start and once more ~15 minutes later (ChatBuffer::buffer_backlog_full_scan); it adds every buffer it finds to the index, which covers buffers written before a restart, or during a rolling deploy, by a replica on older code. Cross-replica double-flush is harmless (ingestion is idempotent).
Chatter tally (platform_users write cadence)
Every chat ingest path (Twitch and YouTube workers, Kick webhook) records the message's sender in an in-process tally (lo_chat::ChatterTally) instead of writing platform_users per message. Each flush tick, workers::chat_flush::flush_chatter_tally drains the tally and writes one multi-row upsert per account (lo_chat::upsert_chatter_batch), keyed by distinct chatter:
message_countadds the messages seen since the last tick,last_seen_atkeeps the latest sighting (GREATEST).- Profile fields are last-write-wins: an absent value (no display name, avatar, colour or badge data, or an empty username) keeps the stored one, the role flags take the newest message. Kick writes
[]badges for a sender without badges, so a demoted moderator still loses the badge. - The tally holds at most 250 000 distinct chatters per process (~75 MB). Chatters already in it keep counting; a message from a new chatter beyond the cap (a stalled flush tick, a long Postgres outage) is not tallied, is counted in
chat_tally_dropped_total{reason="cap"}and is logged atwarnat most once a minute with the number of drops since the last warning. - A failed write puts the rows back into the tally for the next tick (counts add up, newer profile values win), within the same cap; rows that no longer fit are counted in
chat_tally_dropped_total{reason="restore"}. A crash or an unhandledSIGTERMloses at most one tick of counts; the next message rewrites the profile. platform_userskeeps 20 % free space per page (fillfactor = 80). The counters are unindexed, so the per-tick update of an unchanged profile stays HOT instead of writing a new tuple plus every index entry.- Moderation and treatment paths that touch a user who may never have chatted (
lo_chat::upsert_platform_user) write the row directly and count no message.
The Kick webhook resolves the connection of every chat delivery through a 60 s in-process cache (db::connections::find_cached_ingest_route), which holds only the connection id, account id and channel login, never tokens. Connection writes, disconnects and a scheduled dissolution clear it on the replica that made the change; other replicas pick the change up when their entry expires.
Buffer-push failures
Every chat ingest path - POST /v1/chat/message, the GraphQL sendChatMessage mutation, the Kick webhook, and the Twitch and YouTube workers - goes through one helper, lo_chat::buffer_or_persist(). Before it existed, a failed Redis LPUSH was logged and swallowed while the message was still broadcast, so it appeared live and never reached the history read path. Now:
- The message is pushed onto the Redis buffer (the normal, batched path) and the account is armed for the flush worker.
- If the push fails, the single message is written directly to TimescaleDB. The insert is idempotent (
ON CONFLICT DO NOTHING), so a message that reached Redis anyway is never duplicated by the later flush. Counted aschat_buffer_push_failures_total{outcome="direct_insert"}. - If the direct insert fails too, the loss is logged at
errorand counted aschat_buffer_push_failures_total{outcome="lost"}andchat_messages_dropped_total{reason="push_failed"}. It is never silent.
What step 3 means depends on the caller:
- REST
POST /v1/chat/messageand GraphQLsendChatMessagefail the request with the same message on both protocols (Failed to persist chat message, HTTP500on REST) and do not broadcast it. The caller can retry safely, because the insert is idempotent. - Platform ingest (Kick webhook, Twitch, YouTube) still broadcasts the message. It exists on the platform whether or not Lumio could store it, and hiding it from the live feed would not bring it back. On Twitch, persistence runs after the broadcast anyway (ZAF-904).
Inspecting and replaying a backlog
Replay of dead-lettered batches is automatic - there is no manual replay command. The drain leader retries every parked batch each ~60s, so once the cause of the failed insert is fixed (TimescaleDB reachable again, or the offending data/schema corrected) the next drain pass writes the batch and chat_dead_letter_depth falls to 0. A batch that still cannot persist after CHAT_DEAD_LETTER_TTL_SECS (72h) without a successful drain expires; that window is the time to fix the cause. To look at the backlog directly on an instance:
# Live buffers and dead-letter lists, largest first (read the index sets, no keyspace walk)
redis-cli smembers lumio:chat:buffer:pending | while read a; do echo "$(redis-cli llen "lumio:chat:buffer:$a") $a"; done | sort -rn | head
redis-cli smembers lumio:chat:deadletter:pending | while read a; do echo "$(redis-cli llen "lumio:chat:deadletter:$a") $a"; done | sort -rn | head
# One parked batch (JSON-encoded ChatMessageInput rows) and its remaining TTL
redis-cli lrange lumio:chat:deadletter:<account_id> 0 -1
redis-cli ttl lumio:chat:deadletter:<account_id>
# Worker gauges/counters on the internal metrics listener
curl -s 127.0.0.1:9100/metrics | grep -E '^chat_'
The lumio:chat:deadletter:drain:leader key in that namespace is the drain leader lock, not a parked batch.
ChatMessageInput.user_treatment is a live-broadcast-only snapshot and is intentionally not persisted per message - the durable source is the platform_users.user_treatment column, read back by the profile/GraphQL layer on load.
Retention
Each plan advertises a chat retention window via plans.chat_retention_days (shown in Admin and on billing): Free 365 days, Pro 1095 days (3 years). The value is DB-canonical and admin-editable; the windows are published maxima. Enforcement is handled by a background sweep (apps/api/src/workers/chat_retention.rs) that hard-deletes rows in the platform_chat_messages and platform_event_logs hypertables older than each account's plan window.
- Per-account, not a table-wide TimescaleDB policy. Retention is plan-dependent while
add_retention_policyis table-wide, so the sweep resolves each account's window (api::db::plans::list_account_chat_retention) and issues per-account deletes (lo_chat::delete_old_messages,lo_events::delete_old_events). - Bounded slices. Each account's delete walks from the table's oldest chunk up to the cutoff in slices of at most 30 days (
SLICE_DAYS), so one statement plans over and locks at most ~31 one-day chat chunks however long the window is. Because the walk starts at the oldest chunk, a row stored with an old timestamp (a backfill) is still deleted. - Chunk tail. Row deletes never drop a chunk. After the per-account pass the sweep calls
drop_chunkson both hypertables for every chunk older than the longest effective window + 1 day; those rows are past every account's maximum. Every effective window is finite, so an account with a stored value of0does not prevent the chunk tail from running. - Per-platform caps.
apps/api/src/retention.rsholds a per-platform maximum table (PLATFORM_RETENTION_CAP_DAYS). It is empty: every platform keeps the plan window. - Per-account override respected. The effective window is the account's
account_limits.chat_retention_daysoverride when set, otherwise the plan'splans.chat_retention_days- the sameCOALESCE(override, plan)precedence the admin limits API applies. An account granted extended retention is never purged on the shorter plan default. chat_retention_days = 0uses the 1095-day maximum. The sweep clamps each account's effective window to the published maximum. A stored value of0resolves to 1095 days for chat and events, just as it does for chatter profiles and moderation data. No channel data is kept indefinitely by this setting.- Config-gated. The worker only runs when
chat.retention_enforcement_enabledis true. It is disabled by default (dev/test/staging keep their history so test data is not purged too quickly) and enabled in production (config/production.toml). Cadence ischat.retention_sweep_interval_secs(default 86400 = daily); a sweep also runs once on startup.
| Setting | ENV | Default |
|---|---|---|
chat.retention_enforcement_enabled | LUMIO__CHAT__RETENTION_ENFORCEMENT_ENABLED | false (dev) · true (production) |
chat.retention_sweep_interval_secs | LUMIO__CHAT__RETENTION_SWEEP_INTERVAL_SECS | 86400 |
Deletion is permanent - retention runs in addition to moderation soft-deletes (which only set deleted_at/deleted_by and keep the row).
Erasure (GDPR Art. 17)
Retention is time-driven (plan-scoped for messages and events, fixed for the chatter tables); erasure is event-driven and removes a data subject's chat PII immediately, independent of any retention window. Chatter PII lives in five tables (api::services::chat_privacy):
| Table | Store | Personal data |
|---|---|---|
platform_chat_messages | TimescaleDB | message text, username/display_name, avatar_url, platform user_id, badges/emotes (JSONB) |
platform_event_logs | TimescaleDB | raw payload: usernames, donation/cheer messages |
platform_users | Postgres | one chatter profile per account: name, avatar, badges, ban reason |
platform_user_notes | Postgres | moderator notes about a chatter |
platform_moderation_log | Postgres | bans, timeouts, deletes with target names and reasons |
None of them is reached by the main-database DELETE FROM accounts … CASCADE: the first two live in TimescaleDB, the other three have no foreign key to accounts (deliberately — the platform_users upsert runs on every chat flush and a foreign key would add a lookup and a lock on the account row to each one). They are deleted explicitly:
- On account deletion. Channel dissolution (immediate, or by the job at the end of the grace period — see Accounts › Account Dissolution Flow) and the operator delete (
DELETE /v1/admin/accounts/{id}/ GraphQLadminDeleteAccount) erase all five tables for the account before deleting the account itself, so a failure leaves the account in place and the erasure retryable. The account's stream history is deleted in the same run, also before the account row (Accounts › Account Dissolution Flow, step 4; founder ruling ZAF-2626). After the delete both paths drop the channel's live AutoMod held queue instead of waiting out its 30-minute TTL (best-effort). - Not on platform disconnect. Disconnecting a platform (
DELETE /v1/connections/channel/{platform}/ GraphQLdisconnectChannel, the operator twinsDELETE /v1/admin/accounts/{id}/connections/{platform}/adminDeleteAccountChannelConnection, and the credential-delete cascade) deletes only the connection and its tokens. The account'splatform_users,platform_user_notes,platform_moderation_logand AutoMod log rows for that platform stay, like its chat messages and platform events: they belong to the channel and follow the plan retention window (founder ruling ZAF-2626). The retention sweeps work per account, not per connection, so they keep running after a disconnect. - On the channel's instruction (data-subject request). Channel data belongs to the channel: Lumio processes its chat participants' data on the channel's behalf, so a person's data in a channel is deleted only on that channel's instruction, and only in that channel (founder ruling ZAF-2626). There is no cross-account erasure of channel data; a request for several channels is one request per channel.
POST /v1/admin/privacy/chat/erase(GraphQLeraseChatSubjectData), gated byadmin:privacy-erase, takes the instructing channel (account_id, required), theinstruction_reference(ticket or e-mail id, required, at most 200 characters), thereason(why, required, at most 500 characters, kept in the audit event only),initiated_by(required:channelwhen the channel itself initiated the erasure,subjectwhen the person asked for it) and the subject, and hard-deletes in that account only every chat row authored by the subject, theirplatform_usersprofile, the moderator notes about them, their AutoMod history log and everyplatform_moderation_logentry that targets them (bans, timeouts, unbans, deletes, treatment changes and AutoMod decisions). Entries in which the subject is the acting moderator stay: they record a moderator's action, not data about the subject as a chatter. The subject's platform events (platform_event_logs- follows, subscriptions, Bits, raids and the messages sent with them) are not deleted: they belong to the channel's history and stay until the plan retention window expires or the channel is dissolved (founder ruling ZAF-2626; the privacy policy says so under "Chat messages and events"). It also deletes the subject's entry in the account's bot list (account_bots) and removes their name from the account's frozen stream history: wherever a finalized session's top chatters, links, commands or keywords list them, or its announcements and pins name them as author, deleter, sender or pinner, the entry keeps its counts but loses its identity (user_idnull,loginand name empty, no avatar, command arguments empty,erased: true; an announcement or pin they wrote also loses its message id), and their key leaves the session'saccount_bot_keys. Reports, exports and the public share show such an entry as "Deleted user". This reaches sessions whose chat was already removed by retention, because the stream history is kept longer than the chat. List entries without a user id are matched by login on Twitch and Kick, using the logins found on the subject's own entries and bot-list rows. Their still-open messages in the channel's live AutoMod held queue are closed with a text-free tombstone, the same close as the single-record erasure of an AutoMod entry (best-effort: Redis is a cache with a 30-minute TTL; the response reportsremoved_held_queue_entry_count). Other channels keep the same person's data untouched, and crawled sessions are not touched; they hold no chat lists. The subject is identified by exactly one oflumio_user_id, orplatform+user_id; alumio_user_idis resolved to its chatter identities through the user's login connections and the(platform, user_id)of the chat rows erased. An unknownaccount_idreturns 404 "Account not found". The YouTube member-erasure endpoint (DELETE /v1/admin/privacy/youtube/member/{id}?account_id=…&instruction_reference=…&initiated_by=…) runs the same purge in the instructing account for the member's(platform = 'youtube', user_id)(Memberships). Both erasures also record the subject's identities (with their Twitch/Kick logins) in that account's suppression list (account_chatter_suppressions), so the channel's sessions frozen later do not name them either (Stream History → Suppressed chatters). The entry keepsinitiated_by; once any erasure of the person in that channel wassubject, it stayssubject. The entry is written before the history scrub, and a failure fails the erasure. If the channel withdraws an erasure it initiated itself, an operator deletes that entry withPOST /v1/admin/privacy/chat/erase/withdraw(GraphQLwithdrawChannelChatterSuppression, gated byadmin:privacy-erase-withdraw) and{account_id, platform, user_id, instruction_reference}: sessions frozen from then on name the person again, while everything the erasure deleted or blanked stays gone. It acts on that channel's list only (404 when it has no entry). An entry the person asked for (initiated_by: subject, which includes every entry recorded before the initiator was stored) cannot be lifted on the channel's instruction: the withdrawal answers 409 and the entry stays. Only a successful withdrawal writes one system-scopedprivacy:channel_suppression_withdrawnevent, without the user id or login. After the deletes the channel's owner gets a confirmation e-mail with the channel, the date, what was deleted (kinds and counts) and a case number, never the person or the operator's instruction reference (ZAF-2640 F9; no mail when nothing was deleted, and a failed mail never fails the erasure). The case number is the first 8 characters of thecase_idthat the response and the audit event carry. - Orphan sweep. Rows of accounts that no longer exist — written before account deletion erased them, or by an ingest worker between the erasure and the delete — are removed by a daily sweep (
apps/api/src/workers/chatter_orphan_sweep.rs) across all five tables. It is gated bychat.orphan_sweep_enabled(off by default, on in production), because it treats every TimescaleDB account id without an account row in the main database as orphaned — only correct when the TimescaleDB belongs to that database alone.
| Setting | ENV | Default |
|---|---|---|
chat.orphan_sweep_enabled | LUMIO__CHAT__ORPHAN_SWEEP_ENABLED | false (dev) · true (production) |
chat.orphan_sweep_interval_secs | LUMIO__CHAT__ORPHAN_SWEEP_INTERVAL_SECS | 86400 |
Both subject lookups are indexed — (platform, user_id, created_at DESC) and (lumio_user_id, created_at DESC) WHERE lumio_user_id IS NOT NULL — so on compressed chunks TimescaleDB skips every batch whose bloom filter rules the subject out instead of decompressing it. Chunks compressed before the indexes existed gain the filter when they are recompressed. The indexes are built chunk by chunk (timescaledb.transaction_per_chunk), which can leave an invalid chunk index behind if the build is interrupted. After deploying them, SELECT indexrelid::regclass FROM pg_index WHERE NOT indisvalid; on the TimescaleDB must return no rows; drop and rebuild any index it lists.
Objection (GDPR Art. 21)
After an objection or erasure removes content from an indexed Lumio Stats
channel or directory page, the operator requests a search-engine refresh for
the affected URL (Google's Remove Outdated Content, "Veraltete Inhalte
entfernen") and records the request with the case. Search results can retain
an older snippet until the refresh is processed. Stream and channel-history
pages are noindex, follow.
A chatter can object to being shown on Lumio's own surfaces without asking for their data to be erased. A Lumio operator records the objection with POST /v1/admin/privacy/chat/objections (GraphQL addChatterObjection, gated by admin:privacy-objection-create) and the body {"platform": "twitch" | "youtube" | "kick", "user_id": "…", "login": "…"}; login is optional, Twitch and Kick only, and also covers the chatter's entries that have no user id. The objection is stored in the global chatter_suppressions list, which acts on the public Lumio Stats leaderboard only: it masks the chatter on read, including on streams published before the objection. Nothing is deleted, and no channel's data is touched: the chat log, profiles, AutoMod history log, stream reports, exports and history share links of every channel stay as they are — that data belongs to the channel, and an objection to it is assessed per channel and carried out through the channel's instructed erasure (founder ruling ZAF-2626). Recording an objection twice changes nothing; for a chatter whose entry is an erasure entry the response reports reason: "erasure".
A person who asks Lumio itself to erase them is handled the same way on Lumio's own surfaces: POST /v1/admin/privacy/lumio/erase (GraphQL eraseChatSubjectAtLumio, gated by admin:privacy-erase) with {platform, user_id, login?, instruction_reference, reason} (reason required, at most 500 characters, audit only) writes an erasure entry in the global list (Lumio Stats leaderboard) and, on YouTube, clears Lumio's member cache across every account. It touches no channel's data; erasing their data in a channel needs that channel's instruction (above). It is idempotent and writes one system-scoped chat_pii_erasure event with target: "lumio".
When a channel asks to erase a single record instead of everything about a person, POST /v1/admin/privacy/records/erase (GraphQL eraseChatRecord, gated by admin:privacy-erase) erases just that record in that channel: one chat message (by its id and created_at; the text copy a moderator delete left in the moderation log goes too), one moderator note, one moderation-log entry, one AutoMod-log entry (with its AutoMod moderation-log rows and a still-open held-queue entry), one bot-list entry, or one person's entries in one finalized stream report, including their slots in its announcements and pins (place and counts stay, the name goes). account_id, instruction_reference and a reason are required. A record of another account answers like a missing one (404 Record not found). No suppression entry is written, so later stream reports of the channel and Lumio Stats are not affected; recounts from the chat log skip a report that holds an erased entry, so the name does not come back there. A deleted chat message can still appear in its stream report as a command argument or a posted link: erase the person in that report as well. Each erased record writes one system-scoped chat_pii_erasure event with target: "record", without names, texts or platform user ids.
DELETE /v1/admin/privacy/chat/objections/{platform}/{user_id} (GraphQL removeChatterObjection, gated by admin:privacy-objection-delete) lifts the objection: the public Stats leaderboard shows the name again. It returns 404 when no objection is recorded, and 400 for an erasure entry, which cannot be lifted this way. Both protocols share the validation and the error texts, and both return {platform, suppressed, reason}. Each change writes a system-scoped audit event (privacy:chatter_objection_added / privacy:chatter_objection_removed) whose metadata names only the platform, never the user id or login.
Every account and subject erasure writes a chat_pii_erasure audit event with a count per table (account-scoped for an account deletion; exactly one system-scoped event for an instructed subject erasure, naming the target account and the instruction reference but never the subject — see Audit Log). A platform disconnect writes no chat_pii_erasure event; it is recorded by the disconnect's own event (account:connection_removed, or system:account_connection_deleted for the operator delete). Erasure is a hard delete, distinct from moderation soft-deletes that keep the plaintext.
Chatter retention
The three main-database chatter tables are also bounded in time, by the same plan window as chat and events: the account's effective chat_retention_days (the account_limits override if set, else the plan's value — Free 365 days, Pro 1095 days). The window is clamped to 1095 days, the longest published maximum: a stored value of 0 resolves to 1095 days for all channel data. Every row uses the same window W:
| Rows | Deleted |
|---|---|
platform_users without a moderation state | W after last_seen_at |
platform_users in a moderation state: a permanent ban or an unexpired timeout, user_treatment other than none, or at least one moderator note | W after the last moderation activity: the latest of last_seen_at, banned_at, treatment_updated_at and the newest note edit — together with the chatter's notes |
platform_user_notes whose chatter row is already gone | W after updated_at |
platform_moderation_log | W after created_at |
Reading or refreshing a chatter (opening the profile card, profile enrichment, the timeout expiry) never restarts the moderation clock, because a read must not extend retention. An expired timeout is not a moderation state: once it has run out and nothing else is set, the chatter's clock is last_seen_at again. A platform with a published per-platform maximum (PLATFORM_RETENTION_CAP_DAYS in apps/api/src/retention.rs, currently empty) uses the shorter of that maximum and W. Downgrading from Pro to Free shortens the window on the next daily cycle. The sweep (apps/api/src/workers/chatter_retention.rs) runs daily and once on startup, on one replica at a time (advisory lock). It does not replace the event-driven deletes above: dissolution, the operator delete and Art. 17 requests still delete immediately. A platform disconnect deletes none of these rows.
There is no index on last_seen_at: the chat flush rewrites it on every tick, and an index would turn those updates into non-HOT updates. The sweep instead walks each account's chatters in key windows of 5,000 over the (account_id, platform, platform_user_id) index, and re-checks the retention predicate on the DELETE itself, so a chatter seen again during the sweep survives. Each cycle logs the deleted rows per table and warns when a cycle takes longer than its interval or keeps failing, because then rows outlive the published window. Metrics on the internal /metrics: chatter_retention_rows_deleted_total{table}, chatter_retention_cycles_total{outcome} (ok, partial, failed, skipped), chatter_retention_last_success_timestamp_seconds and chatter_retention_last_cycle_seconds (the walk reads every retained row each cycle, so its duration grows with the window; alert well before it reaches the interval).
| Setting | ENV | Default |
|---|---|---|
chat.chatter_retention_enabled | LUMIO__CHAT__CHATTER_RETENTION_ENABLED | false (dev) · true (production) |
chat.chatter_retention_interval_secs | LUMIO__CHAT__CHATTER_RETENTION_INTERVAL_SECS | 86400 — also the maximum, because a row outlives its window by up to one interval: the API refuses to start with a larger value or 0 |
The window itself has no setting: it comes from the plan and the account override. The admin APIs refuse a chat_retention_days above 1095 (or below 0) on plans and account limits, on REST and GraphQL alike, and CHECK constraints on both tables hold the stored value to 0–1095.
Moderation Permission Matrix
Moderation availability is gated by two independent layers:
- Actor → target hierarchy -
getChatPermissions(apps/web/src/app/(main)/(app)/dashboard/chat/chat-permissions.ts) encodes each platform's role model (who may act on whom). The matrix below mirrors it. - Platform capability -
platformSupportsModeration(apps/web/src/lib/moderation.ts) greys out actions the platform's API simply cannot perform, regardless of role. Twitch, YouTube and Kick all support delete + timeout + ban.platformMaxTimeoutSecsin the same file caps the custom timeout in the dropdown and the user info card at the platform maximum (Kick 7 days / 10 080 minutes, Twitch 14 days); a longer value is refused with a localized hint, never clamped silently.
If an action still reaches the backend and is rejected, the mutation returns success: false with a human-readable details reason and a machine errorCode. The client shows an error toast and does not hide the user's messages. moderationErrorKey (apps/web/src/lib/moderation.ts) maps the known codes to localized hints - login_scope_missing → "log in with Kick again (Account → Profile → Reconnect)", login_reconnect_required → "reconnect your login", moderation_forbidden → "no moderation rights in this channel", internal_error and any 5xx → the generic "Moderation failed" line - and otherwise shows details verbatim. The multichat dropdown, the chat user info card and the Events user info card all use this mapping. The matrix below mirrors the actor → target hierarchy:
Twitch - broadcaster, lead-mod, mod hierarchy (Twitch's three-tier model):
| Actor → Target | Self | Broadcaster | Lead Mod | Mod | User |
|---|---|---|---|---|---|
| Broadcaster | only delete (own) | - | delete + ban + timeout | delete + ban + timeout | delete + ban + timeout |
| Lead Mod | only delete (own) | - | delete only | delete only | delete + ban + timeout |
| Mod | - | - | - | - | delete + ban + timeout |
Lead Mods and Mods cannot ban or timeout other moderators (only the broadcaster can). Lead Mods can delete other mods' messages but not ban them.
YouTube - strict three-tier (broadcaster + mod = "staff"). YouTube's Data API rejects liveChatBans.insert / liveChatMessages.delete against staff with HTTP 403 even when the broadcaster is the actor. Lumio therefore hides the moderation dropdown entirely when the target is a moderator or the broadcaster:
| Actor → Target | Self | Broadcaster | Mod | User |
|---|---|---|---|---|
| Any role | only broadcaster can delete own | - | - | delete + ban + timeout |
UI labels also differ on YouTube - "Timeout" reads as "Vorübergehend blockieren" / "Block temporarily" and "Ban" reads as "Auf diesem Kanal ausblenden" / "Hide on this channel" to mirror YouTube Studio's wording.
Kick - the actor → target hierarchy follows the same lenient pattern as Twitch's broadcaster ↔ mod tier (broadcaster can act against mods, mods cannot act against staff). On top of that hierarchy the platform-capability layer applies: Kick supports delete, timeout (whole minutes, at most 7 days) and ban, all through the acting user's Kick login token, which must carry moderation:ban and moderation:chat_message:manage.
Ban / Timeout Sweep (chat:clear_user)
A successful ban or timeout on any platform triggers a server-side sweep that mirrors the platform's native chat behaviour:
lo_chat::soft_delete_user_messagessetsdeleted_at,deleted_by,action("ban"|"timeout"), andaction_duration_secson every undeleted row of the affected user from the last 24 hours — the lines the platform's own chat clears. Older history stays as stored. The window equals the chat compression delay, so the sweep only touches uncompressed chunks; an unbounded sweep had to decompress the account's entire history and failed outright on large channels once that passed TimescaleDB's per-statement decompression limit (100 000 tuples), leaving even the recent lines un-flagged (ZAF-2195). Per-chatter lookups are indexed by(platform, user_id, created_at DESC), which also gives compressed batches auser_idbloom filter for GDPR erasure.- The server publishes a
chat:clear_userevent tolumio:chat:{account_id}with payload{platform, platform_user_id, deleted_by, action, duration_secs}. - All connected clients (dashboard + popouts + member sessions) immediately grey out the matching messages and append the localised suffix:
- Twitch ban → "Banned by X" / "Gebannt von X"
- Twitch timeout → "Timed out 5min by X" / "Timeout 5min von X"
- YouTube ban → "Hidden by X" / "Ausgeblendet von X"
- YouTube timeout → "Blocked 5min by X" / "Vorübergehend blockiert 5min von X"
After a page refresh the suffix survives because action and action_duration_secs are persisted in platform_chat_messages.
Kick moderation. Delete, timeout and ban are issued from Lumio through Kick's official public API, on the moderator's Kick login grant (not the channel connection). The grant must carry moderation:chat_message:manage (delete) or moderation:ban (ban/timeout). Lumio checks the stored login scopes before calling Kick. A grant without the scope, or a Kick 401, returns errorCode = login_scope_missing, and the moderator logs in with Kick again to grant it. A Kick 403 returns moderation_forbidden (not a moderator in this channel). Kick timeouts are whole minutes: seconds are rounded up (minimum 1 min), and anything above 7 days is rejected. Timeouts and bans done on kick.com itself arrive via the moderation.banned webhook. When that webhook echoes an action already issued from Lumio within 60 s, it is deduplicated (KICK_BAN_WEBHOOK_DEDUP_SECS), so the user gets one log entry and one chat:clear_user.
Live moderation log. Every stored moderation-log entry is also published live as chat:moderation_log, built from the same fields as the row: Lumio-issued actions (all platforms, both protocols), Twitch EventSub ban/unban, the Kick webhook, and the Kick chat-moderation listener (delete, unban). The payload carries duration_secs / ends_at, so an open user card shows the real timeout length without a reload.
External ban/timeout events (e.g. issued from a platform's own UI) flow through the same code path: apps/api/src/workers/twitch_eventsub.rs reacts to Twitch channel.ban notifications, and apps/api/src/routes/webhooks.rs reacts to Kick moderation.banned webhooks, each performing the identical sweep + broadcast. Unbans made on kick.com arrive from the Kick chat-moderation listener and are deduplicated against an unban issued from Lumio within the same 60 s window. (Chat messages live in TimescaleDB, so the sweep targets tsdb, not the main Postgres pool.)
Single-message delete ingest
Beyond bans, individual message removals made outside Lumio are ingested so the Multichat stays in sync. Every ingested deletion follows the same invariant as Lumio's own moderation: the message is never hard-deleted - it is soft-deleted (deleted_at marker) via lo_chat::soft_delete_message and additionally recorded in the affected user's moderation log (platform, target_user_id, action, target_message_id). The soft-delete's deleted_at IS NULL guard makes ingest idempotent: a duplicate delete (e.g. an EventSub echo of a Lumio-initiated delete) flips no row and is skipped, so no duplicate log entry is written.
| Platform | Single-message delete ingest | Source |
|---|---|---|
| Twitch | ✅ moderator delete | EventSub channel.chat.message_delete (twitch_eventsub.rs). The event carries the target user + message id but not the acting moderator, so the removal is attributed generically ("moderator"). |
| YouTube | ✅ moderator delete + author retract | gRPC live-chat MessageDeletedEvent / MessageRetractedEvent and the InnerTube MessageDeleted path (youtube.rs). |
| Lumio | ✅ | moderateChat / POST /v1/chat/moderate (graphql/chat.rs, routes/chat.rs). |
| Kick | ✅ moderator delete + Kick AutoMod (unofficial source) | Kick's webhook catalog has no message-delete event (declined upstream, KickEngineering/KickDevDocs#310). Deletes arrive as App\Events\MessageDeletedEvent on Kick's unofficial public Pusher chat socket, picked up by the Kick chat-moderation listener. The event names no moderator: a delete is attributed to "moderator", or to "Kick AutoMod" when Kick flags it aiModerated (its violatedRules go into the log entry's details). An id Lumio does not know changes nothing. |
Kick: which kick.com action arrives how
Kick's official webhooks do not cover every moderation action taken on kick.com itself. Lumio combines two sources:
| Action on kick.com | Source | Result in Lumio |
|---|---|---|
| Ban, timeout | Official moderation.banned webhook | Moderation log ban / timeout, ban state, chat:clear_user sweep |
| Delete one message (moderator or Kick AutoMod) | Unofficial Pusher chat socket, MessageDeletedEvent | Soft-delete, moderation log delete, chat:delete |
| Unban, lifted timeout | Unofficial Pusher chat socket, UserUnbannedEvent | Moderation log unban with the acting moderator, ban state reset, chat:moderation_log ("Unbanned" in the user card) |
| Clear chat | none | Not picked up |
The unofficial source is best effort: Kick documents no contract for it, is moving its own web client to another provider, and can break it without notice. When it fails, deletes and unbans made on kick.com stop reaching Lumio; chat, events, bans and moderation issued from Lumio are unaffected. Operations: Kick Chat-Moderation Listener.
YouTube Channel Emotes (display only)
Channel-custom emotes (:_yourchannel-purr: style) live inside InnerTube messageRuns and are not exposed by YouTube's Data API or our gRPC streamList. Lumio harvests them out-of-band:
| Component | Role |
|---|---|
apps/api/src/workers/youtube.rs (poll cycle) | The YouTube chat worker polls get_live_chat for chat + member badges and, from the same response, extracts channel-custom emotes (emojiId of form <channelId>/<emoteId>) via parse_emote_observations |
crates/lo-chat/src/emote_catalog.rs::EmoteCatalog::upsert | Upserts the observations into the durable chat_emotes table (the emote catalog) and invalidates the Redis map lumio:emotes:map:youtube:{channel_id}. Postgres is the truth; Redis (6h) and an in-process LRU (~60s) are caches in front of it. |
apps/api/src/workers/youtube.rs::process_text_chat | At chat ingest, scans displayMessage for :shortcut: patterns, resolves them through the catalog's three-tier read-through, attaches matching entries to ChatMessageInput.emotes in the same JSON shape as Twitch ingest |
apps/web/src/app/(main)/(app)/dashboard/chat/message-content.tsx | Emote renderer; per-message channel emotes take priority over the static standard-emote set (:smile:, :cry:, etc.) so a streamer's :smile: upload wins over the platform fallback |
Lumio is read-only on this - we never upload emotes/badges to YouTube and we don't host the images (URLs point at yt3.ggpht.com). Cold-start caveat: the very first occurrence of an emote on a channel may render as plain :shortcut: text until the chat worker's next InnerTube poll has recorded it. A cache expiry no longer has that effect - the mapping is durable, so a cold or flushed Redis falls through to Postgres instead of dropping enrichment.
Twitch GIFs (display only)
Twitch's GIF keyboard (Tier 2/3 subscriber feature) delivers each posted GIF as a gif fragment on the EventSub channel.chat.message payload - a gif object carrying the GIPHY url and a gif_id, with the bracketed GIF caption (e.g. [Y A Y Yes GIF by Djemilah Birnie]) in the fragment's text, not the URL. Lumio ingests this into its own per-message field:
| Component | Role |
|---|---|
apps/api/src/workers/twitch_eventsub.rs | At chat ingest, collects every gif fragment into an ordered list of {id, url, provider, alt} (id = gif_id, alt = caption). A fragment missing its gif object or url is skipped, never a panic. |
platform_chat_messages.gifs (JSONB) | The persisted per-message GIF list - its own column, deliberately never folded into emotes (which feeds frozen stream-history emote_total/top_emotes counts). NULL for every non-GIF message. |
ChatMessage.gifs (GraphQL) / ChatMessageResponse.gifs (REST) | The read field, exposed identically on both protocols; the live WebSocket chat:message payload carries it automatically via the ChatMessageInput broadcast. |
The GIF url is stored and rendered verbatim - Twitch's terms forbid rewriting, proxying, or resizing it. GIF support is Twitch-only (no YouTube/Kick equivalent) and read-only (Lumio never sends GIFs). GIFs are intentionally not surfaced in the automation template context (like emotes and badges).
Rendering is gated by the Show GIFs display setting (General settings tab, default on, per-browser localStorage["popout-settings:chat"]). With it off the image is not rendered and nothing is fetched from GIPHY - enforced at every render surface: the Multichat feed and reply preview, and the user-info card's message history (which has no PopoutSettings host and reads the same store via readChatShowGifs). A GIF is likewise never rendered on a moderation placeholder or a deleted row.
Inline stream events
Announcements and pins
Twitch channel.chat.notification announcements enter the ordinary chat-message
path with message_type = announcement and announcement_color set to
primary, blue, green, orange or purple. A missing or unrecognised
colour resolves to primary, rendered in Lumio's fixed teal. Shared Chat
announcements retain their source channel. The stored colour reaches GraphQL,
REST and chat:message, so history and reload use the same presentation as live
chat. Twitch does not permit deleting or pinning an announcement. A timeout or
ban of its author can still mark it deleted through the usual chat path.
Announcements participate in the ordinary keyword, command, automation and
chatter counts; there is no announcement-specific exclusion. Bare domains are
linked by the shared message renderer in ordinary, announcement, first-chatter
and watch-streak rows. Twitch watch-streak raw.emotes carries the attached
message's emotes.
chatPins / GET /v1/chat/pins reads the current Twitch and Kick state with
chat:read and feature:multichat. A chat:pins update on the account's
existing chat WebSocket channel replaces both platform states. Pin actions need
chat:pin and feature:multichat; they use the acting member's Twitch login,
which must moderate the channel and grant moderator:manage:chat_messages.
Twitch supports pin, duration change and unpin; Kick is read-only in Lumio.
Pin durations are 30–1,800 seconds or until stream end. Twitch is polled every
10 seconds while live and every 30 seconds while offline, including connected
channels without a stream; Twitch has no IRC or EventSub pin trigger. At stream
end Lumio polls Twitch rather than clearing its state. Kick socket pins are
treated as permanent, including a socket value of duration: "1200"; a pin set
while offline stays until Kick removes it, while a live-to-offline transition
clears it. A rejected already pinned Twitch action resyncs the current card.
The current state lives in Redis under lumio:chat:pins:{account_id}:twitch
(120-second TTL) and lumio:chat:pins:{account_id}:kick (no TTL). A single
lumio:chat:pins:poller:leader lease coordinates polling. Five pin events in
platform_event_logs record transitions; there is no separate pin table.
An unpin and an expiry both arrive from Helix as an absent pin and are
distinguished by the stored ends_at. Changing a duration keeps starts_at;
re-pinning the same message starts a new pin_removed / pin_added pair.
The card's sent_at is the message's original time, never the pin time. It is
nullable when no source timestamp is known. A browser can hide or collapse the
card locally without changing the platform pin. Pin event rows use the
account's Events filters and history; their chat presentation has avatar and
name but no badges, plus a separate To message link. The Events feed shows
badges only beside the pinned message's sender.
Twitch channel.moderate v2 role events use a centred chat capsule with the
platform icon first when that event-row setting is on. Grants use role colour;
removals use grey and a dashed treatment. The four Mod/VIP types are each
switchable and default on. The VIP fallback glyph is a diamond. Twitch does
not report a Lead Moderator distinction, so the text always says Moderator;
Kick and YouTube have no corresponding role event rows.
Live stream events (subscriptions, resubs, gift subs, cheers/tips/superchats, raids, follows, the stream go-live/ad-break lifecycle, the hype-train lifecycle, watch streaks, …) are surfaced inline in the multichat feed as compact event lines, interleaved with chat messages by timestamp - the Twitch-native "notice line then message" shape. This keeps the streamer's attention in one place instead of a separate events panel.
The Discord member events (discord:member_join, discord:member_leave, discord:boost) are deliberately not shown as inline chat event lines (CHAT_EVENT_EXCLUDED_TYPES in chat-display.ts); they remain on the events feed and in the events settings, but carry no chat toggle.
| Component | Role |
|---|---|
apps/web/src/hooks/use-chat-events.ts | Bounded rolling buffer (500 deep) fed by three sources: live events off the events:{account_id} WebSocket channel (dispatched as the lumio:ws-event window event); the server-merged history cursor — event rows split out of each mergedChatHistory page by use-multichat.ts and folded in as they page down with the messages (see Server-merged history read below); and a reconnect gap-fill over GET /api/events for events that streamed in while the socket was down. Buffers every line-eligible type; per-type visibility is applied downstream, so toggling a type on reveals events already received while it was hidden. All sources dedupe on the shared event id; past the cap the ring keeps only the events bound to the loaded message span — every event reachable (created_at >= floorAt, the oldest buffered message: the interleaved region plus the live tail above the newest message) survives, and everything below the floor is dropped (ZAF-1535: it has no message in the buffer, so keeping it would build the render-time event wall). This floor is a pure function of the loaded data, never of the scroll position (keying it on scroll dropped the newest event lines when the reader scrolled up); a just-loaded scroll-up batch survives a full ring because its merged page lowers floorAt in the same render that publishes the events. At the start of the view's history (no older page left, oldest message still buffered) the floor lifts and the events before the first message are kept (ZAF-2329). |
apps/web/src/hooks/use-event-reconnect-gapfill.ts | Shared reconnect gap-fill used by both use-chat-events.ts (event lines) and use-first-time-chatters.ts (the first-message highlight): on each socket reconnect it fetches GET /api/events for [newest known event, now] so events missed while the socket was down still light up. History scroll-up no longer fetches here — that rides the merged cursor. It deliberately does nothing on mount: the mount's events arrive complete for the loaded span over the merged cursor (see First load). Fail-silent (see below). |
apps/web/src/lib/chat-display.ts | CHAT_EVENT_LINE_TYPES - the line-eligible catalog, derived from the canonical EVENT_CATALOG_TYPES (so it never restates the list and grows automatically when a type is added), minus the row-highlight exceptions and minus CHAT_EVENT_EXCLUDED_TYPES (the Discord family - kept in the feed, dropped from the chat). interleaveDisplayItems merges events into the message list by created_at, applying the visibility filter, and binds events to the loaded message span: events older than the oldest displayed message are dropped (the ZAF-1535 floor — otherwise they stack as a message-less wall above the first row) while events newer than the newest message stay at the tail (open ceiling, so a chat-quiet channel still shows its live events). The floor is derived only from messages, never the scroll position (ZAF-1529). It lifts at the start of history (atHistoryStart from useMultichat: no older page exists and the buffer still holds its oldest message), so events before the very first message — a follow or raid before the first chat line, a fresh account — render above it in time order (ZAF-2329). |
apps/web/src/app/(main)/(app)/dashboard/chat/chat-event-row.tsx | The event line, styled at chat density after the events-feed EventRow and reusing its exact colour and label tables - so the chat can never drift against the events feed on accent or wording. Its accent glyph comes from a chat-only override table, LINE_ICONS, which falls back to the shared EVENT_TYPE_ICONS table and then to a generic Zap bolt, so the Events page keeps its own icon slot. LINE_ICONS is keyed per action across platforms: follows show a Heart (twitch:follower, kick:follower, youtube:subscribe), new subs and memberships a Star (twitch:subscribe, kick:subscribe, youtube:member), gifted subs and memberships a Gift (twitch:gift, kick:gift, youtube:gift_membership, youtube:gift_membership_received), resubs a Repeat, reward redemptions a Ticket, stream go-live/offline a Radio and title/category changes a Pencil. Platforms are paired by meaning, not by name - a YouTube subscribe is a follow and a YouTube member is a sub - and a parity test fails when an action's glyph differs between the platforms that have it. Only the glyph is per-action; the accent colour, label, tier, ×N plaque and recipient list are unchanged. Takes a variant: "compact" | "full" that is always the global chat density - the same for every event type and for the message rows (there is no per-type density). Renders an avatar (shared resolveEventAvatar, same order as EventRow) and a Spotify now-playing line (track/artist from raw, cover as the avatar). For the message-bearing types (gated on MESSAGE_EVENT_TYPES), it renders the viewer's own message in a grey box (bg-muted, rounded) with a muted MessageCircle icon inside - the same treatment the events-feed EventRow uses, so a written message is visibly set apart. The box sits on its own line below the event summary (founder ruling 2026-09-17): the platform recap - the label, name, per-type context, and plaques - stays inline on the event line, and only the viewer's typed text drops into the box beneath it, so an automatic recap never masquerades as something the viewer wrote. That recap is built and translated by Lumio from the structured raw fields (the subscription tier and cumulative months, e.g. "Stufe 1 · 30 Monate"), not Twitch's own system_message sentence - Twitch writes that recap in English only, so echoing it verbatim printed English lines like "subscribed at Tier 1. They've subscribed for 30 months!" into a German UI. Twitch's system_message is therefore never rendered on the line at all. The box is a block-level (flex) element rendered inside the line's text span, so it inherits the line's text size (no fontSize on the box) while breaking onto the next line; its spacing is em-relative (it never re-introduces the feed's root-relative padding), it carries no leading separator (the box is the separator), and it hugs its text (w-fit max-w-full). Text + per-message emotes come from the shared extractEventMessage (lib/event-message.ts, the same extractor the events feed uses, so the two surfaces never drift on which raw field an event stores its text in), drawn with MessageContent (not EmoteText) so emotes inherit the chat's --lm-emote-scale and the line's text size. The message wraps within the box and is shown in full, never clipped with an ellipsis or a "show more" (no expand chevron - that stays on the events feed). The displayed name is itself role-aware and shared: resolveEventDisplayName (lib/event-user.ts) resolves an outgoing raid / unraid (twitch:raid_out / twitch:raid_done / twitch:unraid) to the raid recipient (to_broadcaster_user_name) and an incoming raid (twitch:raid) to the raider (from_broadcaster_user_name), so a completed raid names the channel that was raided rather than the streamer's own channel; every other type keeps the former display chain (including the anonymous-gifter fallback). The feed row and the chat line read this one resolver, so they never disagree on which side of a raid they show. Every per-type context - the Spotify artist, the channel-point reward's title + price, the Hype Train's Level N + total, the incoming Raid's title/category + viewer count, the outgoing raid's viewer count + initiator (by <name>), the stream go-live's title + category, the ad-break's length (e.g. 180s, accepting Twitch's string form) + manual requester, the stream-update's changed title/category, the Twitch goal's description + goal type (all seven Twitch types, follow and follower both read as Follower, an unknown type as a neutral "Other goal", never the raw value), with current/target as a locale-formatted plaque (13.284/14.000 in German) - the row label names the goal's phase via the shared resolveEventLabelKey: twitch:goal is always a goal start ("Ziel gestartet"; Twitch's channel.goal.progress arrives as a broadcast-only twitch:goal that neither surface renders) and twitch:goal_end reads "Ziel geschafft" / "Ziel nicht geschafft" from is_achieved (Twitch has no separate cancel event; without the flag it stays "Ziel beendet"), the stream-offline's duration on Twitch, YouTube and Kick as a clock plaque (3h 12m, from duration_seconds, else ended_at − started_at; a missing, zero, negative or unparseable duration shows no plaque), the watch-streak count, and the subscription family's tier + cumulative months (translated, month counts pluralised via next-intl ICU) - is derived by one shared resolver (lib/event-details.ts, mirroring event-message.ts / event-user.ts), never a per-type if-ladder in the row: it returns muted · <text> suffix segments and accent plaques ({text, icon?}, icon ∈ flame/users/clock), and a missing field drops only its own part - never an empty separator. The events feed (event-row.tsx) reads the same resolver for these raid/stream/ad-break/goal/stream-offline types - it stacks the segments under the name (a goal's description stays the row title and is not repeated) and renders the plaques (outgoing-raid viewers, ad-break length, goal current/target, stream duration) as chips in its badge column beside the type badge, like the incoming raid's viewer chip - so the detail can never drift between the two surfaces. Reading order is label → name → segments → plaques → recap on the event line, then the viewer's message in the box below. The event line's text renders at the same size as a chat message - the row never grades its own text - and the trailing copy/report/recommend actions follow that same size. The variant only changes the line's spacing; the text size of the whole list is set once by the global density (below). Event lines and message rows share one leading-strip build (ChatAxis, dashboard/chat/chat-axis.tsx): the timestamp, platform icon, avatar, badges, chips, event icon and plaques are centred geometrically on the first text line's line box, so they stay level with the text at every font-size step and next to a tall emote; the message text still wraps under the strip across the full width. A gift line reads its count and recipient list through a second shared resolver, lib/gift-summary.ts (the same one the events feed EventRow and the User Info Card read, so all three surfaces share one field path across twitch:gift / kick:gift / youtube:gift_membership and both YouTube raw forms): the count renders as an accent ×N plaque and the recipients as a muted wrapping suffix (to A, B, C, D, E) that names every recipient - never capped and never CSS truncated, with the recipient names as plain text (the clickable name is the gifter). A bundle with no recipients yet renders the count and omits the "to …" segment. |
First messages are a row treatment, not a line. twitch:first_message turns the correlated message row into the first-time-chatter spotlight card instead of adding a separate line - the event is the message, so a standalone "X sent their first message" line above X's message would double every first message. This line vs row-highlight decision lives in CHAT_EVENT_TREATMENTS (the single source; a row-highlight type is excluded from the line catalog).
Spotlight cards
Announcements, first-time chatters and watch streaks share the B-family highlight row: a 4 px gradient edge and a soft tint over the normal chat line. Full density has an icon, label and hint above the message; compact density keeps the icon and tooltip in the message line with no label. A moderation treatment replaces the highlight frame with its red or amber edge.
| Piece | Where | Role |
|---|---|---|
ChatHighlightRow | dashboard/chat/chat-spotlight-card.tsx | Shared presentational frame for announcement, first-chatter and watch-streak rows. Full uses a heading above a normal ChatAxis line; compact puts only the type icon and tooltip before the time, with no visible label. Both keep the normal text renderer, badges and avatar settings. Narrow full rows omit the hint. |
| First-time chatter | chat-message-row.tsx | Rendered when isFirstMessage and the treatment is none. Wins over role tints, keyword-tab and highlight-users colours and the upcoming plaque (badges still show mod/VIP); a self-mention stays marked in the text. A restricted / active_monitoring treatment wins over the card: the row stays a plain moderation row with a small first-chat pill, no animation, no Greet. The hover bar is the message bar, always mounted on the card and revealed by group-hover / group-focus-within (keyboard-reachable); plain rows keep rendering it only on hover. onGreet is in the memo comparator. |
| Watch streak | watch-streak-card.tsx, branched in multichat.tsx | twitch:watch_streak renders WatchStreakCard instead of ChatEventRow (so the event-row comparator gains no props). Badges, colour and avatar come from the optional raw.badges / raw.color / raw.avatar_url (payload); the count from resolveEventDetails (its plaque now carries the raw value), the message from extractEventMessage. Icon and time follow the event-line display options, badges the chat showBadge; highlightStyle and the event avatar/label options do not apply. |
| Moderation on the streak | watch-streak.ts | Reuses ModerationDropdown + onModerate against a ChatMessage-shaped target built from the event (user_id, login, display name, the USERNOTICE message_id). Fail-closed: watchStreakPermissions returns no rights when the event has no badges key (pre-ZAF-1726 events), because getChatPermissions treats an empty badge list as a moderatable viewer; ZAF-1726 always sends badges ([] included). No delete on the streak card (Helix documents message deletion for PRIVMSG ids only); no reply either. |
| Arrival rule | lib/spotlight-arrivals.ts | The entry animation runs only when the key arrived over the live WS ≤ 3 s ago and has not played in this document. Only the live handlers stamp it (use-chat-events.ts for the streak by chatEventKey, use-first-time-chatters.ts by platform_message_id); history and the reconnect gap-fill - which is deliberately not tagged as history - never do. The card decides in a useLayoutEffect and writes data-enter straight onto its node (no state); the played set covers every remount path (empty-state unmount, filter in/out, loadLatestWindow history copies, card ↔ row on a treatment change, a second multichat). |
| Motion switch | shared/ui/src/globals.css, apps/web/src/app/globals.css | Every spotlight animation (entry, glow, sparks, count-up, flame) sits under the motion-ok custom variant: prefers-reduced-motion: no-preference and no data-motion="reduced" or "off" on the element or above it (the global "Animations in Lumio" setting writes it on <html>). It is not coupled to data-season-motion (that attribute only exists while a season runs). useMotionAllowed() (from useMotionPreference()) is the JS twin, used only to skip the flame observer. Only transform / opacity / box-shadow animate; the card has its final height from the first frame. |
| Count-up | card CSS | A registered @property --lm-spot-n counted via counter(); armed (data-countup) only when CSS.registerProperty exists and n < 1000. The real number stays in the DOM (transparent while the aria-hidden overlay counts). |
| Living flame | spotlight-assets.tsx, spotlight-observer.tsx | flame-live.svg with CSS motion on its part hooks and a per-card phase (negative delay hashed from the event key). One shared IntersectionObserver per feed (SpotlightObserverContext, root = the scroll container, rootMargin: 200px 0px) sets data-offscreen, which pauses the flame. A moderated streak card holds it still. |
| Tokens | shared/ui/src/globals.css | --first-chatter / -2 / -foreground and --watch-streak / -2 / -foreground (light + dark) with --color-* Tailwind bindings. In light the pill and tone-coloured text use the darker second stop (white on the first stop is below 4.5:1); in dark the full gradient holds ≥ 5.4:1. |
| Assets | assets/chat/spotlight/ | flame-live.svg, flame-glyph.svg (streak pill), spark.svg (first-chat pill + arrival sparks); inline components copy the geometry, spotlight-assets-parity.test.tsx fails on drift. |
Greet (first-time chatter only) calls handleReply(msg) - the same reply target as the reply button - then prefillGreeting fills the input only when it is empty, via ChatInputHandle.setText (execCommand("insertText"), never innerHTML; resets the history cursor, mention query and saved emote range). It never sends. The text comes from chatGreetingText in the popout-settings blob (null/absent = chat.greetChatterDefault in the current UI language, "" = reply only, otherwise the own text with {name} replaced literally by display_name; max 300). Gated like the reply button: canWrite ∧ platform access.
Event lines in history
Event lines and the first-message highlight are loaded back with the message history, not only streamed live — without this the feed showed up to 100 messages of scrollback and zero event lines until the next live event arrived. History events now ride the server-merged cursor (see Server-merged history read below): each mergedChatHistory page carries messages and events for the same time span, so use-multichat.ts splits the event rows out of every page (mount and every scroll-up loadMore) and hands them to the event ring as they page down with the messages. There is no second event cursor to drift against; the only remaining GET /api/events history query is the reconnect gap-fill (use-event-reconnect-gapfill.ts), which fetches [newest known event, now] on a socket reconnect, parallel to the message fillGap.
These properties keep the ring correct across all sources:
- Dedupe on
id. The WS frame, the merged-page event, and the gap-fill row carry the same uuidid, so live and history copies of one event collapse to one line. - Replay-safe merge. The buffer update is a pure
(prev, incoming, window) → nextfold (mergeEventBatch): its dedupe set is rebuilt fromprevon every call, with no external "seen" bookkeeping. This matters because React invokes auseStateupdater more than once for the sameprev(StrictMode in development, the non-eager path in production). An earlier version mutated a sharedseenset inside the updater and, on the replay, found every key already present and returnedprev— silently dropping the entire batch, which is why a refresh restored the chat messages but showed no event lines until the next live event. The same discipline applies to the first-message highlight ring, which derives eviction order from its own set rather than a replayed side-list. A known key is replaced in place when the incoming copy is a richer enrichment of the same event: a sub-gift bundle is broadcast on arrival (header only) and re-broadcast under the same event id once its recipient names are collected, so the merge swaps the header line for the enriched copy at the same position — same buffer index, a fresh object identity only for that one row so its memo repaints — instead of skipping it. The replacement is monotonic on recipient count, so an out-of-order re-delivery of the bare header can never clobber the enriched line back to no-names. - Span-bound eviction (scroll-independent). Past the 500-deep cap the ring keeps every event reachable from the loaded message span (
created_at >= floorAt, the oldest buffered message — the interleaved region plus the live tail above the newest message) up toMAX_HISTORY_EVENTS = 5000, trimming the oldest reachable first so the newest are never evacuated (ZAF-1530 / ZAF-1533). Events below the floor are dropped unconditionally (ZAF-1535) — they have no message in the buffer, and the merged cursor re-fetches them with their messages on scroll-up. The floor is a pure function of the loaded data — the oldest buffered message — never of the scroll position (ZAF-1529). A just-loaded scroll-up batch survives a full ring because its merged page lowersfloorAtin the same render that publishes its events (otherwise the batch would be evacuated as below-floor before it reached the screen). At the start of the view's history the floor is lifted (ZAF-2329, see below), so the events before the first message stay in the ring. - No fetch budget, no density cap; rendering is scroll-independent. Because messages and events page down together on one bounded, timestamp-ordered cursor, a scroll-up can no longer pull a message-less run of events out of a multi-day gap — so the client neither budgets how many events it fetches per step nor caps how many render per gap (hiding events to rebalance the ratio is falsification, founder ruling ZAF-1442 / ZAF-622 §73). What renders is a pure function of the loaded data, never of the scroll position (ZAF-1529).
interleaveDisplayItemscarries exactly one bound, derived only frommessages: a floor at the oldest displayed message (events older than it are dropped, so no message-less event block can stack above the first row — the ZAF-1535 event wall) and an open ceiling (events newer than the newest message append at the tail, so a chat-quiet, event-loud channel still shows its freshest live events). Every surviving event renders at its truecreated_at, identically wherever the reader has scrolled. The floor lifts at the start of history (ZAF-2329): it only guards a range whose messages can still page in. Once the view has no older page left (hasMore === false, from the mount window or a scroll-up that loaded the last, possibly empty page) and the buffer still holds its oldest message, no older message will ever arrive — so every visible event older than the first message (a follow or raid before the first chat line, a fresh account, the beginning of the retention window) renders above it in time order, without any further action. WhilehasMoreis true the floor stays. A buffer at its cap (500, or 5000 after a scroll-up) counts as "not at the start", because a trim may have shed the oldest rows. Keyword tabs stay event-free (§62). - History, not new arrivals. History events never move the unread counter or the "new messages" divider and never play the notification sound — those read the message buffer, which folding events does not touch.
- Fail-silent, correctly gated. Events are gated on
feature:events+events:read— a different guard than the chat messages (feature:multichat+chat:read). The merged endpoint applies both gates independently server-side (see below), and the reconnect gap-fill is fail-silent on the client (a502is treated as "stop, silently"; a development build logs aconsole.warnso "no permission", "server broke" and "there was nothing" can be told apart, production stays silent). A member who holds multichat but not events sees the chat exactly as before: no error, no empty state, no retry loop.
Server-merged history read (mergedChatHistory)
Reading the message history and the event lines over two independent cursors (chatHistory plus a parallel event window over GET /api/events, merged in the browser) drifts on a chat-quiet, event-loud channel: a 24h window can hold 4 messages but hundreds of events, the two streams page apart, and the reader scrolls into a wall of message-less event lines. Capping or hiding events to rebalance the ratio is falsification (founder ruling, ZAF-1442 / ZAF-622 §73). The fix is a single server-merged history read that returns messages and events already interleaved:
- GraphQL
mergedChatHistory(filter: MergedChatHistoryFilterInput): ChatHistoryPage!· RESTGET /v1/chat/history/merged— identical fields, filters, gates and errors (three-protocol parity; WebSocket live-append is unchanged, this is the history read path only). - One page, three sources.
ChatHistoryPage { items: [ChatHistoryItem!]!, nextCursor, hasMore }; eachChatHistoryItemis taggedkind: MESSAGE | EVENT | AUTOMODand carries the full row inmessage(theChatMessagetype),event(theEventtype) orautomod(theAutomodLogEntrytype of the Stream History log: outcomeopen/approved/denied/expired,resolvedVialumio/twitch, moderator, reason, text,emotesand the sender look) — no field drifts from the standalone endpoints. An AutoMod item'sidis the log row id and itscreated_atisheld_at; REST tags itkind: "automod". A merged message row is the exactchatHistoryrow: both reads select one shared column list (SELECT_COLUMNSincrates/lo-chat/src/queries.rs), includingreply_parent_login, andapps/api/tests/chat_history_merged.rsasserts field-for-field equality with nonullon a fully populated row. - Keyset cursor, not page/offset. Ordered
created_atDESC with anidtiebreak; pass the opaquebeforecursor (a page'snextCursor, or any item'scursor) to fetch the strictly-older page. One cursor spans both sources, so the two streams can never page apart — loading is synchronous across the same time span, with nothing dropped.limitis total items across both streams, default 50, max 100. It is one budget shared by both sources: the merge guarantees that every message and every event newer than a page's oldest item is in that page (no drift), but not a ratio — on an event-loud, chat-quiet channel the newest page can be all events and hold no message at all. - Filters.
platformnarrows every source, and a non-Twitchplatformskips the AutoMod source;liveChatIdnarrows messages only (YouTube broadcast) and skips the AutoMod source;types(REST comma-separatedtype) is the enabled event-type set and narrows events only — the same master-switch / platform / group / per-type gating the client decides today, passed through unchanged. - Bounded walk window. One request reads at most 30 days of history below its cursor (
HISTORY_WALK_WINDOW_DAYSinapps/api/src/services/chat_history.rs), so it opens at most ~31 one-day chunks per hypertable however old the account's history is (ZAF-2251). When the account's retention window is enforced (chat.retention_enforcement_enabled) and shorter, the read stops at the retention cutoff instead and ends the history there — older rows are being deleted by the retention sweep. With the sweep off (development, staging) the full stored history stays reachable. The history also ends 7 days before the account was created (ACCOUNT_BACKLOG_GRACE_DAYS; the slack keeps a YouTube live-chat backlog reachable, whose messages are stored with their original timestamps when a channel is connected mid-broadcast). When a window is exhausted while older data still exists, the page ends withhasMore: trueand anextCursorat the window edge, so a page can be short or even empty and still havehasMore: true. Keep paging untilhasMoreisfalse. An account created within the last 23 days getshasMore: falseon its first page once that page is exhausted; an older account without history gets it once the walk reaches its creation date or no chunk of the hypertable reaches below the window, whichever comes first. - Independent, server-side gates. Messages are included only with
feature:multichatandchat:read; events only withfeature:eventsandevents:read— the same split the back-fill enforces client-side, now enforced in one place on both protocols. AutoMod holds are included only withfeature:multichatandchat:readandchat:automod-read(one shared helper on both protocols): withhistory:readacross the account's full chat window, without it for the last 24 hours. The AutoMod source always applies the plan-window floor, even withchat.retention_enforcement_enabledoff, and its continuation probe is a per-accountEXISTS, so a 24-hour caller's history ends instead of paging empty windows. A member holding one gate but not another gets only the streams they may read (no error, no empty state); none yields an empty page. Popout tokens (lm_pop_*) are accepted and scoped exactly like a member, so the pop-out runs the same path (founder ruling, ZAF-2531). Overlay, shared-overlay, widget, extension, developer and system keys and anonymous callers get no AutoMod items. The app-side k-way merge of the per-source keyset cursors is bounded tolimitrows per source and is lossless (apps/api/src/services/chat_history.rs).
The multichat history view — dashboard and both pop-out kinds — consumes this endpoint for its general history (mount and every scroll-up loadMore), splitting each page into the message buffer (use-multichat.ts), the event ring (use-chat-events.ts / use-first-time-chatters.ts) and AutoMod holds (use-held-messages.ts).
First load always shows the newest messages (ZAF-1566). Because the page budget is shared, the mount does not rely on the merged page alone for messages. loadLatestWindow issues two reads in parallel, neither of them throttled: the newest merged page, and the newest 100 messages from the message-only chatHistory read (same platform / liveChatId filters). Both are unioned (deduplicated on platform_message_id ?? id) into the buffer, so the newest messages are on screen after one round trip — independent of how event-loud the channel is and of the order in which the WebSocket connects. Where those messages reach further back than the merged page, the client then pages the merged cursor down over exactly that span (pageMergedSpan, at most 10 pages per pass), so every event inside the span the reader sees is loaded too — nothing hidden, nothing capped, and messages and events cover the same span. A scroll-up that lands inside an already-loaded span keeps paging in the same trigger, so the top sentinel always makes progress. The reconnect fillGap (throttled, ZAF-907) is no longer what delivers the newest messages on mount; it only catches messages flushed just after the reads.
Short and empty pages are walked, not treated as the end (ZAF-2252). Because of the server's 30-day walk window, a page can be short or empty and still carry has_more. The client does not stop there: the mount and every scroll-up loadMore keep requesting the next page from the window-edge cursor until a page's worth of rows (100 messages or events) has landed, has_more is false, or 5 pages have been read in this trigger (walkMergedPages in apps/web/src/lib/merged-history.ts). The cap keeps a long empty stretch from looping without end; the next scroll-up resumes from the cursor where the walk stopped. While it walks, the feed shows its normal loading skeleton — on mount the history skeleton, on scroll-up the loading older messages line at the top of the feed. Only has_more: false ends the history; an empty page with has_more: true never shows the end-of-history state. Without the walk, an empty page would leave the buffer unchanged, the top sentinel would stay in view without firing again, and older history would be unreachable — on mount, an account whose last chat is older than 30 days would show nothing at all.
Gap-fill: closing the span between history and the live tail
fillGap (ZAF-1572) runs after every WebSocket (re)connect, once after the mount load, and on jump-to-bottom. It fetches every message persisted after the buffer's history high-water mark and merges it in, so after mount plus gap-fill no message is missing between the loaded history window and the newest message received live.
- Anchored on history, not on the newest live row. The gap-fill starts from the newest
created_atup to which the buffer is known to hold every persisted message — set by the mount window (a database read) and advanced by each completed gap-fill. A live message does not move that anchor when it follows a discontinuity: on mount, on a socket drop, and the moment a gap-fill is requested (before its throttled read runs), the anchor is frozen, so a live row that lands first can never make the catch-up skip the span below it. Once a gap-fill completes, live rows extend the anchor again while the socket stays connected. - Pages forward over the whole span.
chatHistory/GET /v1/chat/historywithfromreturns the newestlimit(at most 100) rows at or afterfrom, so a single page would leave a hole whenever more than 100 messages arrived. The gap-fill walkspage = 1, 2, …(newest first — a row inserted between two reads can only cause an overlap, never a skip) until a short page or the last page. It stops early only at the buffer's own trim cap (MAX_MESSAGES = 500for a fresh buffer at the live end,MAX_HISTORY_MESSAGES = 5000otherwise): anything beyond that would be trimmed from the oldest end anyway, and the newest rows it did fetch are contiguous. A failed read keeps the anchor frozen, so the next gap-fill retries the same span. - Ordered and deduplicated. Gap rows are merged into the buffer by
created_at(mergeGapFill), not appended, because they are older than any live row that arrived after the reconnect. Duplicates — the inclusivefrombound, the overlap between pages, a row already received live — collapse onplatform_message_id ?? id. Gap rows are not tagged as older history: they streamed in while the reader was away. Nothing is hidden and the trim only ever sheds the oldest rows. - Throttled. At most one gap-fill starts per 5 s (ZAF-907); further calls inside the cooldown coalesce into one trailing catch-up that starts from the frozen anchor.
Both history proxies (/api/chat/history, /api/chat/history/merged) answer Cache-Control: no-store, and the client requests them with cache: "no-store" — the mount URL is byte-identical on every load, so no HTTP cache may ever replay a stale window. A merged row whose kind is unknown or whose payload is missing is never dropped silently: splitMergedHistory returns it in dropped and the client logs a console.warn with per-reason counts, so a schema rename is visible instead of eating rows. Keyword tabs stay on the message-only chatHistory search (they render no inline event lines and the merged read has no keyword filter). The client passes the inline-line catalog plus twitch:first_message as type=, so events that never render never consume a page's slots.
Where inline events appear (and where they don't)
Inline event lines - and the first-time-chatter / watch-streak spotlight cards - are driven by the events:\{account_id\} WebSocket channel (live) and the server-merged history read (restored on scroll-up). Both carry the same authorization: subscribing to the channel requires events:read (and the account to hold feature:events) at the WS gate, and the merged read enforces events:read + feature:events server-side for its event source - so history events light up on exactly the surfaces that receive live events, never a half-state where one shows and the other doesn't. Which surfaces show them therefore follows directly from which WebSocket channels each subscribes to:
| Surface | WebSocket channels | Inline events? |
|---|---|---|
| Dashboard chat | chat:\{account_id\} + events:\{account_id\} (shared useChatWebSocket) | ✅ |
Logged-in chat pop-out (own account, cookie/issueWsToken) | chat:\{account_id\} + events:\{account_id\} | ✅ |
Delegated / shared chat pop-out - default subset (chat:read) | chat:\{account_id\} only | ❌ |
Delegated / shared chat pop-out - token granted events:read (account holds feature:events) | chat:\{account_id\} + events:\{account_id\} | ✅ |
A delegated/shared pop-out is event-free by default, and this is a fail-closed authorization decision, not an oversight. A shared popout token authenticates as a popout session carrying only the permission subset its creator delegated - a chat pop-out is normally shared with just chat:read, not events:read. The event stream carries the account's full live event feed (subscriptions, cheers/tips/superchats, revenue-adjacent events, follows), so it is not widened onto a third-party share token merely to decorate chat rows; the account owner must deliberately grant that popout token events:read first. The pop-out resolves events:read + feature:events server-side (SSR) and subscribes to events: only when both hold - a single shared socket that subscribed to a channel its token cannot read would draw an UNAUTHORIZED frame that the resilient client escalates to a reconnect, so a surface only subscribes to the channels its credential can actually read (see WebSocket → Client reconnection). No backend gate is widened and no share token is broadened: a delegate sees inline events exactly when, and only when, the owner chose to delegate events:read.
The All chat tab shows the mixed feed: every message plus every visible inline event line. A concrete platform tab keeps message rows for that platform and usually keeps event lines whose type has the same platform prefix (twitch:* in Twitch, youtube:* in YouTube, kick:* in Kick). A 7tv:* emote-set event is the exception: it appears in each chat platform listed in raw.platforms, both live and in server-merged history. Without that list it appears only in All. The always-on rules still apply inside that platform scope: first-message highlighting stays attached to its message row, and watch-streak lines remain exempt from the Events-tab visibility switches. A keyword tab is different: it is a message-only view. It shows only the messages matching its keywords and renders no inline event lines at all - on any platform and for every event type, the always-on types included. Nothing the viewer typed is lost, because the watch-streak USERNOTICE text also arrives as an ordinary chat row, which still appears in the keyword tab whenever it matches the keyword; the always-on exception affects the standalone event line only, and standalone event lines never appear in a keyword tab. First-message row highlighting is a message-row treatment, so a first-time-chatter card on a keyword-matched message keeps rendering there too.
7TV emote-set lines. Additions, removals, renames and active-set switches
appear in the Multichat, chat popout/OBS and Events feed when feature:events
is enabled. The editor's 7TV name is plain text, with no user card. The line
shows the emote image and name, a dimmed struck-through removal, an old → new
rename, or the set name and an emote count when known on a switch. Adjacent
additions or
removals of the same type by the same known editor in the same set group into
one chat line when each is within 30 seconds of the previous change. Any
intervening message or event breaks the group. A group shows at most 12 emote
images and then +N more. Renames and set switches never group. Each change
remains a separate stored event and Events-feed row. Live arrivals animate
according to Animations in Lumio and the Chat
override; history, gap-fill and tab switches show the final state immediately.
The Events settings tab
The 7TV group contains four per-type switches, one for each emote-set change. They are on by default, including in existing saved settings. The Events feed also has a 7TV platform chip and type chips. The test-event panel offers all four 7TV types.
The chat settings (dashboard chat and popout chat alike) have three tabs: General · Events · Filters. The inline event lines are configured under Events (the one exception is the global Density control, which lives on General - see below), which carries a count badge of how many event types are currently enabled - so a fully-silenced event feed is visible without opening the tab. All of it is per-browser, persisted in the same localStorage["popout-settings:chat"] blob as the other chat display settings - no separate mechanism.
Master switch - at the top of the Events tab, a Show Event Lines toggle turns the inline event lines off at once, so a busy streamer can silence the whole event feed without unticking each type. It is a display gate only: turning it off leaves the per-type selection below untouched (the rest of the tab dims so the selection is visibly resting, not lost), and turning it back on restores exactly that selection. Persisted as an optional chatEventsEnabled key in the same blob (absent → on), so an existing browser and an imported preset without it keep showing events.
Always-on exception (ZAF-1340 / ZAF-622 §46, founder ruling 2026-09-17) - the master switch is not absolute. ALWAYS_ON_CHAT_EVENT_TYPES in chat-display.ts (currently twitch:watch_streak) lists the inline types that carry the viewer's own typed chat text and must always be shown. resolveVisibleEventTypes(stored, enabled) unions this set in on both paths: with the switch off it returns exactly that set (not the empty set), and with it on it unions the set onto the resolved selection - so a Watch Streak survives the master switch, an unticked per-type toggle, and a stored or imported {"twitch:watch_streak": false} blob. These types therefore have no per-type toggle in the tab (one fixed "always visible" note replaces the dead switches), and the tab's count badge excludes them, so with the master switch off it still reads 0. twitch:first_message is deliberately not in the set: it is a "row-highlight" treatment (it turns the correlated message row into the spotlight card and never becomes a standalone line), so it never flows through this resolver and is always visible structurally - listing it would be a misleading no-op.
Per-platform switch (ZAF-622 §56) - each platform heading in the per-type list below is itself an on/off switch for that whole platform's event lines. Switching Twitch off drops every Twitch event line at once, without touching the per-type selection under it (the group dims and inactivates, so it is visibly resting), and switching the platform back on restores exactly that selection. Precedence is a pure AND: master switch → platform → per-type. Persisted as an optional chatEventPlatforms (prefix → boolean) map in the same blob; a platform absent → on, so an existing browser, an imported preset, and any platform added later all default to showing events. The always-on exception still wins: a Watch Streak line survives even with its platform switched off (the resolver unions ALWAYS_ON_CHAT_EVENT_TYPES in after the platform filter). There is deliberately no "all platforms off" button - that is exactly what the master switch is for. resolveVisibleEventTypes(stored, enabled, platforms?) applies all three gates at the single render-time site, so the dashboard chat, the popout and the count badge stay in lock-step.
Feature & permission gate (ZAF-622 §72) - an event category only appears at all - as a per-type toggle in this tab, as a chip on the events feed, and as an inline line - when the account holds the feature that category depends on and any RBAC permission its backend guard requires. The rule lives once in apps/web/src/lib/event-access.ts (EVENT_GROUP_ACCESS + the pure filterEventTypesByAccess), keyed by the event-type prefix, and both surfaces read it, so they can never drift on which categories are visible. The feature side maps Twitch/YouTube/Kick/Discord to their platform features, 7TV to feature:events, Spotify to platform:spotify + feature:music, Shopify to integration:shopify, StreamElements to feature:streamelements, and Copyright to feature:copyright_detection; the permission side is added only where a real backend guard demands it. Today Copyright is the only category with a permission: its lines and toggles show only when the account has feature:copyright_detection and the viewer holds copyright:read - exactly the pair the GraphQL/REST copyright guard enforces (FeatureGuard("feature:copyright_detection").and(PermissionGuard("copyright:read"))), so the UI never advertises a category the API would refuse. A category with no entry (system) is always shown. The gate sits above every user switch, including the always-on exception and a saved per-type selection: a {"copyright:warning": true} blob saved while the feature was on does not resurface a Copyright line after the feature is switched off - the render path (resolveVisibleEventTypes) drops the gated-off type regardless of the stored map, and the events feed filters the same categories out of its rendered rows, not just its chips. The chat popout resolves the feature list server-side and threads it in (it has no FeatureProvider); the dashboard reads the live feature/permission contexts. Before the feature/permission state has loaded the gate fails open, so a category never flickers away on first paint, and an unknown feature key never hides a category on its own.
Connected sources in the controls - the per-type groups in this tab and the platform/type chips in the events feed list Twitch, YouTube, Kick, and Spotify only when the account has a channel connection for that platform. A connection row still counts while it needs reconnecting. StreamElements needs a stored StreamElements token instead, and 7TV needs at least one connected Twitch, YouTube or Kick channel (the 7TV listener only watches those channels' emote sets). Discord, Shopify, Copyright, and System do not use this connection check. If the connection or token status cannot be read, the affected groups stay listed; a member without se-tokens:read therefore still sees StreamElements. This check only limits the settings and filter controls: it does not remove past events from chat or the events feed, or rewrite saved switches. The shared rule is filterEventTypesBySource in apps/web/src/lib/event-sources.ts.
Display options - how the event lines look. Each defaults to today's behaviour so the tab is invisible until touched:
- Avatar - show the viewer's avatar, or the track cover for a now-playing line (default off).
- Platform Icon / Timestamp - inherit the chat's own icon/timestamp display settings until overridden here, then detach.
- Event Label - the coloured label ("Sub", "Raid", …); default on. When off it still renders as a fallback for events with no username (channel events like hype train), so a row is never blank.
Density is global and lives on the General tab, not here. The Density control (Compact · Full, default Full) applies one density to every row of the chat feed - message rows and event lines of every type alike: Compact tightens the line spacing, Full keeps the standard spacing. There is no per-event-type density; a stored or imported auto value from before this change resolves to Full (settings migration v4, normalizeChatDensity). Compact also shrinks the whole chat list together - message rows, event lines, date/unread separators, chips, avatars and emotes - to 0.875× of the font-size setting (chatDensityTextScale in lib/chat-display.ts, applied once on the list wrapper in multichat.tsx), so the font-size slider still scales it and the separate emote scale still multiplies on top. Full keeps the list at the font-size setting. GIFs and stickers keep their fixed pixel cap. The value persists in the same popout-settings:chat blob.
Which event types show inline is a per-type toggle list grouped by platform, labelled identically to the events-feed filter chips. Each platform's group heading doubles as the per-platform switch described above.
- Defaults. All line-eligible types, including the four 7TV changes, are on until the viewer turns them off. Saved per-type choices remain in effect.
- Spotify now-playing (
spotify:trackand the rest of thespotify:*family) is line-eligible; enablingspotify:tracksurfaces a now-playing line per track change with the album art as the avatar. - New-type defaulting. A type absent from a saved selection defaults to on, so it does not silently disappear.
- Independent of the events feed. The inline chat visibility is a separate setting from the events feed's own filter (
lumio:event-filters): the chat and the events feed are different consumption contexts, so a filter change in one never surprises the other.
Motion preferences
The user-profile motion_preference (system, reduced, off) applies to the dashboard and popouts. Chat's settings panel stores a stricter motion override (inherit, reduced, off) per browser and user/account pair; ?motion=reduced|off tightens a single popout URL. The effective level is the strictest of the operating system, profile and override. The seasonal animation row only controls seasonal decoration. Chat spotlight, scrolling and the watch-streak flame follow the effective level; animated emotes and GIFs continue playing as viewer content. See Animations in Lumio and the Motion developer guide.
Settings export/import (browser-local)
All Multichat, Events and Music settings live in the browser's localStorage —
nothing is stored server-side. Export/import (ZAF-622) lets a user save a selection
of those settings and re-import it on another device, an OBS browser source, a fresh
browser, or a different account. It is purely client-side: no API endpoint, no
RBAC, no audit event (the data never leaves the browser except as something the user
themselves copies or downloads).
The UI is one section inside the shared PopoutSettings panel, so it appears in the
dashboard and every popout, in both modes and all three variants (chat/events/music).
Four transports, one data model (§4a). The same envelope is offered four ways,
all converging on the same parseImport validator:
- Code string (
LS1Z.<base64url(deflate-raw(utf8(JSON)))>, orLS1P.<base64url(utf8(JSON))>whenCompressionStreamis unavailable). Compression uses the nativeCompressionStream/DecompressionStream- no dependency.LS1versions the transport; the JSON'sformatstill versions the content; the decoder reads both forms and tolerates injected whitespace/zero-width spaces (messengers wrap long lines). It is reversible cleartext, not a hash or encryption. - Share link carrying the same code in its URL fragment
(
{origin}/dashboard/{chat|events|music}#s=LS1…- the path is the variant's own dashboard route, origin fromwindow.location.origin, never hard-coded; a link built inside a popout still points at the dashboard, since it is clicked on a device with no popout token). The fragment never reaches the server (RFC 3986 §3.5), so this stays backend-free. A link has no hard length ceiling - the dialog only shows a soft hint for very long links (some messengers wrap them). Both the code and the link react live to the section selection. - Copy JSON and Download a
lumio-settings-YYYY-MM-DD.jsonfile - the file is the transport you want for a backup.
Import is one input field with auto-detection (§4c): content starting with {
is JSON, with LS1 is a code, a value starting with http that contains #s= is a
share link (everything up to and including #s= is stripped and the rest decoded as a
code), anything else is a defined error - no toggle to set wrong. When a share link is
opened, the matching feature shell (chat / events / music, dashboard and popout) reads
location.hash on mount, decodes it and opens that feature's settings-import panel with
the preview directly (it never writes on its own).
File shape (envelope is snake_case; each section payload is the store value verbatim,
so chat.display is camelCase and chat.filters is snake_case - no field-name mapping to drift):
{
"kind": "lumio.settings",
"format": 1,
"app_version": "2026.9.93",
"exported_at": "2026-09-14T19:40:00.000Z",
"sections": {
"chat.display": { "fontSize": 16, "settingsVersion": 1 },
"chat.filters": { "hide_users": [], "highlight_users": [], "match_mode": "substring" },
"chat.keyword_tabs": [ { "id": "…", "label": "Raid", "keywords": ["raid"] } ],
"events.filters": { "platforms": ["twitch"], "types": ["twitch:follower"], "allPlatforms": [], "allTypes": [] }
}
}
Sections: chat.display, chat.filters, chat.keyword_tabs, events.display,
events.filters, music.display. Deliberately excluded (device/account
properties or transient UI state, not feature settings): theme, language, streamer
mode, active account, the active platform/keyword-tab selection, the YouTube
broadcast id, send-platform selection, emote/recent-emote caches, and alert-box
runtime state.
Design invariant: the registry's parse/write reuse the existing store
parsers and writers (parseChatFilters/saveChatFilters,
parseKeywordTabs/saveKeywordTabs, parsePopoutSettings/savePopoutSettings,
parseStoredEventFilters/saveStoredEventFilters) - there is no second validator,
so an imported value can never reach a state the normal load path could not also
produce. Chat filters and keyword tabs are written through the Pattern-B
scoped-key + unscoped-mirror writer, so an import is visible to token popouts too.
Import semantics (v1): a selected section replaces its store wholesale (it does
not merge - the protection is the pre-write preview). The page reloads after import
because components read the stores once on mount and a storage event does not fire
in the writing tab. Envelope validation is fail-safe: bad JSON, a wrong kind, or a
format newer than the app aborts with a message and writes nothing; an older
format is accepted (the versioned per-section parsers handle it); an unknown
section id is listed as skipped; a corrupt section is marked unusable while the rest
stay importable.
An export that includes chat.filters or chat.keyword_tabs contains the viewer
names the user hid/highlighted and their keyword lists. Because the code/link/JSON/file
are all reversible cleartext, sharing them shares those names - the export dialog
warns about this next to the Copy code/Copy link buttons. It is the user's own
local data, exported by the user, with no server involved.
Key files: apps/web/src/lib/settings-transfer.ts (registry + build/parse/apply,
plus encodeTransferCode/decodeTransferCode, parseImportInput auto-detection and
buildTransferUrl), apps/web/src/lib/popout-settings-defaults.ts (display-blob
type/defaults/parse extracted from the client component),
apps/web/src/components/settings-transfer.tsx (the export/import dialogs +
ImportReview), apps/web/src/components/settings-import-fragment.tsx
(SettingsImportFromFragment: each feature shell mounts it once; it reads a
#s=<code> URL fragment on mount and opens the import preview), apps/web/src/lib/download.ts (downloadBlob).
Key Files
| Path | Description |
|---|---|
apps/api/src/graphql/chat.rs | GraphQL queries and mutations |
crates/lo-chat/src/ | Core chat crate (buffer, filter, profile service, emotes, moderation) |
apps/api/src/services/emotes.rs | Emote fetching from Twitch, YouTube, Kick, 7TV, BTTV, and FFZ |
apps/api/src/services/moderation.rs | Per-platform Twitch/YouTube/Kick moderation HTTP calls invoked from moderateChat and POST /v1/chat/moderate |
apps/api/src/services/chat_moderation.rs | Moderation orchestration shared by moderateChat and POST /v1/chat/moderate (validation, login-token refresh, Kick scope pre-check, dispatch, error codes, moderation log + live broadcasts) and the Kick moderation.banned webhook handling (60 s dedup) |
apps/api/src/workers/chat_retention.rs | Config-gated background sweep that enforces per-plan chat_retention_days by deleting expired platform_chat_messages and platform_event_logs in 30-day slices, then dropping the chunk tail |
apps/api/src/workers/chatter_retention.rs | Config-gated daily sweep that deletes platform_users, notes and platform_moderation_log after the account's effective plan window (Free 365 days, Pro 1095 days; ceiling 1095 days) — Chatter retention |
crates/lo-chat/src/emote_catalog.rs | Durable chat_emotes catalog + three-tier (LRU → Redis → Postgres) read-through - written and read by the YouTube chat worker |
crates/lo-chat/src/youtube_emote_cache.rs | YouTube :shortcut: tokenizer and message enrichment (storage-independent) |
apps/api/src/workers/youtube.rs | The single YouTube chat worker: harvests member-tier badges and channel-custom emotes inline from its InnerTube poll cycle and enriches chat messages |
apps/api/src/state.rs | PendingChatAccounts shared state |
shared/chat-filters/src/ | Shared hide/highlight and keyword-tab matcher used by dashboard, popout, and tests |
apps/web/src/hooks/use-multichat.ts | Browser hook for live chat, history paging, keyword-search back-fill, token fetches, and per-tab paging state |
apps/web/src/lib/chat-display.ts | Pure display filter for platform/broadcast scoping, deleted-message hiding, keyword-tab views, and hidden-message removal (default) or placeholders |
apps/web/src/lib/chat-persistence.ts | Defensive localStorage persistence for chat filters, keyword tabs, and active platform selection (exports the Pattern-B readScoped/writeScoped helpers reused by the alert box) |
apps/web/src/lib/chat-alerts-reducer.ts | Pure applyAlertEvent reducer + live-state shapes for the five alert cards; folds live WS events and replayed history identically |
apps/web/src/lib/chat-alert-persistence.ts | Alert-box persistence: types, defensive parsing, reconciliation, the GET /api/events server replay (build/fetch/fold), the Layer-A/Layer-B merge, and the cross-tab dismiss registry |
apps/web/src/app/(main)/(app)/dashboard/chat/chat-alerts.tsx | The alert-box component: renders the five cards and wires the load/save, replay, and cross-tab dismiss effects |
apps/web/src/components/info-user-modal.tsx | The user info card (UserInfoModal): full-page FullscreenPanel, all permission/actor-target gates, the moderation confirmation and link warning as panel layers; sub-components (header, profile facts, action bar, the four tabs and their data hooks) live in apps/web/src/components/user-card/ |
apps/web/src/components/keyword-tab-dialog.tsx | Keyword-rule editor for tab visibility, platform scope, match mode, color, and notification sound |
apps/web/src/components/popout-settings.tsx | General/Filter settings tabs for display toggles, deleted-message visibility, hidden-message style, notification volume, and keyword-rule management |
apps/web/src/lib/settings-transfer.ts | Settings export/import registry - declarative section list whose parse/write reuse the existing store parsers/writers, plus buildExport/parseImport/applyImport, the LS1 code encode/decode, parseImportInput auto-detection and buildTransferUrl (share link) |
apps/web/src/lib/popout-settings-defaults.ts | The display-blob type, defaults, constants and parse/save path extracted from the client component so pure modules can reuse them |
apps/web/src/components/settings-transfer.tsx | The Transfer-settings panel section + export dialog (section selection, copy code/link/JSON, download) and import dialog (file/drag, one auto-detecting field, preview with replace notes); exports ImportReview |
apps/web/src/components/settings-import-fragment.tsx | SettingsImportFromFragment: mounted once per feature shell (chat/events/music, dashboard + popout); reads a #s=<code> URL fragment on mount and opens that feature's settings-import panel with the preview; never writes on its own |