# Unread indicators The embed reports unread and mention counts to your page, where nothing is drawn by default: inside the chat the "@" badge is always on for a signed-in viewer, but on your own page the indicator is yours to render. > **Note:** The host side of this feature, the `unreadState` event, `getUnreadState()`, `getUnreadCount()`, `refreshUnreadState()` and visibility reporting from `show()` / `hide()`, needs `@cherrydotfun/chat-embed-sdk` **0.1.7 or newer**. On 0.1.6 the event never fires and the getters do not exist, so counters cannot be observed from the host page; the iframe still keeps them. ## What you get The case this was built for is a chat icon in your own header. Subscribe to `unreadState`, and the icon carries a dot while the widget is closed, plus a louder one when someone addressed the viewer directly: ```ts chat.on('unreadState', ({ total }) => { chatIcon.classList.toggle('has-unread', total.unread > 0); chatIcon.classList.toggle('has-mention', total.mentions > 0); }); // Nothing is emitted for a signed-out viewer, so clear the dot yourself. chat.on('authStateChange', (signedIn) => { if (!signedIn) chatIcon.classList.remove('has-unread', 'has-mention'); }); ``` That is the whole integration. Everything below is the detail behind those two numbers. ## The `unreadState` event | Field | Type | Holds | |---|---|---| | `rooms` | `UnreadRoomState[]` | Counters for the room this embed renders. 0 or 1 entries today. | | `rooms[].roomId` | `string` | The room the counters belong to. | | `rooms[].unread` | `number` | Unread messages in that room. | | `rooms[].mentions` | `number` | Unread signals that addressed the viewer. | | `total` | `{ unread: number; mentions: number }` | Sums across `rooms`. With one room it mirrors that entry. | ```ts chat.on('unreadState', (state) => { // state: // { // rooms: [{ roomId: 'room_abc123', unread: 7, mentions: 2 }], // total: { unread: 7, mentions: 2 }, // } }); ``` `rooms` describes the room this embed renders and nothing else: the iframe never reports the visitor's other chats. So it holds 0 or 1 entries today, with `total` mirroring the single entry, and the array shape is future-proof for list mode. Emission is held until the room join resolves, so you never catch a half-built snapshot mid-join; `rooms` comes back empty only when the embed has no `roomId` to join or the join failed. Read `total`, or index defensively (`rooms[0]?.unread`), rather than assuming `rooms[0]` exists. > **Note:** `mentions` counts everything that addressed the viewer: @-mentions, replies to their messages, **and reactions on those messages**. It is the same signal that drives the in-chat "@" badge, so a bare thumbs-up on the viewer's message lights up a mention dot. That is the intended behavior, not a bug to report. ### When it fires | Moment | What arrives | |---|---| | After the viewer's session loads | One full snapshot: the initial state. | | On every counter change | The new snapshot, debounced, so a burst of arriving messages settles into a single emit. | | On `refreshUnreadState()` | A re-emit of the current snapshot, throttled to one per 250ms with the trailing call coalesced, and held until the iframe's readiness gate lifts. | Two silences are deliberate: - **Preview mode.** An anonymous visitor has nothing to catch up on, so nothing is emitted before sign-in, nor after `chat.signOut()`, which drops the cached snapshot back to `null`. Reset your own indicator on `authStateChange(false)`. - **Before your first command.** The iframe starts emitting events, `unreadState` and the legacy `unreadCount` alike, only once the host has sent it at least one command. `mount()` always sends one, so this bites only if you drive the iframe with your own `postMessage` instead of the SDK. ## Pull instead of subscribe The SDK caches the latest snapshot, so a host that repaints on its own schedule can read it directly. | Method | Signature | Notes | |---|---|---| | `getUnreadState` | `() => UnreadState \| null` | The cached snapshot. `null` until the first event lands. | | `getUnreadCount` | `(roomId?: string) => number` | Unread messages across every reported room, or in one room when you pass `roomId`. | | `refreshUnreadState` | `() => void` | Asks the iframe to re-emit `unreadState`. | ```ts chat.getUnreadState(); // UnreadState | null chat.getUnreadCount(); // unread messages across every reported room chat.getUnreadCount(roomId); // unread messages in one room chat.refreshUnreadState(); // ask the iframe to re-emit unreadState ``` Both getters are synchronous cache reads, so poll them as often as your UI repaints. `refreshUnreadState()` is different: it crosses the iframe bridge and answers asynchronously through the `unreadState` event. The iframe throttles those solicited flushes to one per 250ms (coalescing the trailing call) and holds them until its readiness gate lifts, so a tight loop of calls buys you nothing. Keep it to a coarse interval, seconds rather than frames, and remember the iframe already pushes on every change: subscribing beats polling. ## When the counters grow Counters accrue only while the chat is not being read: hidden through `hide()`, mounted `collapsed`, or scrolled up into history. The SDK reports widget visibility to the iframe on `show()`, `hide()`, `toggle()`, and right after mount (0.1.7 or newer), so this is automatic as long as you use those methods. > **Warning:** A widget you hide with your own CSS instead of `hide()` still counts as open, so it keeps marking incoming messages read and nothing ever accrues. Toggle floating widgets through the SDK. The two numbers clear on different boundaries, which decides what your dot can mean: - **`unread` clears once the viewer reaches the newest message.** On reopen the chat first freezes an "Unread messages" divider above everything that arrived while you were away, so the viewer can see where they left off, and then marks those messages read, dropping `total.unread` back to 0. The reset is conditional: it runs only when the viewport actually settles at the tail, so a jump that parks the view on the divider instead of the bottom defers it until the viewer returns to the newest message. - **`mentions` keeps its own boundary.** It survives the reopen and comes down only as the viewer steps through the in-chat "@" badge. A mention dot bound to `total.mentions` therefore outlives the click that opened the chat, which is deliberate: a mention is worth seeing even after a glance at the room. Because reactions count as mentions, that badge can also outlive the last unread message. ## Cherry-recommended UI The indicator is yours to draw, so the sizes and the pink below are what Cherry recommends for it, scaled to whatever icon you hang the badge on: | Piece | Size | Notes | |---|---|---| | Dot | about 30% of the host icon: 12px on a 40px icon | A 2px ring in the page background color keeps it legible where it overlaps a busy icon. | | Counter pill | about 40% of the host icon: 16px on a 40px icon. Cherry's own 56px launcher uses 18px. | That is the pill height; let the width grow with the number. | | Color | `#ff1493` | Cherry pink with white text, the pink the chat itself uses. | | Mention | one pill reading `@ N` | A single pill, the "@" and the number separated by a 2 to 3px gap. Never two badges side by side. The number stays the unread count; "@" is a flag, not a counter. | One copy-paste wiring, dot and counter in the same element: ```html ``` One counting model, not two: the number in that pill is always `total.unread`, capped at `99+`. `total.mentions` never becomes a number of its own, it only decides whether the "@" is drawn, so the pill reads `@ 7` while seven messages are unread and drops to a lone "@" once `unread` clears with the mention still outstanding. That lone "@" is the boundary described above, made visible. With `SHOW_COUNTS` off the same rule paints a bare dot for unread and upgrades it to a lone "@" when someone addresses the viewer, which is the quietest version of the same badge. Cherry's own launcher centres the badge on a fixed point instead of pinning its corner, so the dot to pill swap does not shift it. ## Floating widgets: the badge is built in If the embed is floating, you do not have to draw any of this. `chatBubble: true` renders Cherry's own launcher button and puts the badge on it for you. Out of the box that badge is the quiet version, `chatBubbleBadge: 'dot'`: a bare 12px dot while messages are unread, upgraded to a lone "@" pill once someone addressed the viewer, and never a number. Switch to `chatBubbleBadge: 'count'` for the full model above: one pill, the number always the unread count capped at `99+`, "@" in front of it while a mention is outstanding, and "@" alone once unread clears. Either mode reads `unreadState` itself, hides only when both counters are zero, and clears on sign-out and on every viewer switch, so there is nothing to subscribe to. `'off'` draws no badge at all. Badge text follows `theme.fontFamily` and falls back to Inter, then the system UI face, so the launcher reads as part of the chat it opens. Color follows too: Cherry pink `#ff1493` out of the box, then the mention colors the embed's own theme engine resolved once the iframe reports them, which is what makes the launcher badge and the in-chat "@" badge match. The theme can override those two with [`mentionBadgeColor` / `mentionBadgeTextColor`](https://portal.cherry.fun/docs/embed/theming.md), and both badges move together. On an embed deploy older than the SDK nothing is reported and the badge stays pink. No config key recolors it directly. That is the recommended path for `floating-right` / `floating-left` embeds. The recipe above stays the answer for an indicator you hang on your own UI, such as a chat icon in your header. Setup, the SDK version it needs, and the theming details: [Display modes](https://portal.cherry.fun/docs/embed/display-modes.md). ## Next steps - [SDK API reference](https://portal.cherry.fun/docs/embed/api-reference.md) · [Configuration](https://portal.cherry.fun/docs/embed/configuration.md) · [Display modes](https://portal.cherry.fun/docs/embed/display-modes.md)