Presentation: floating emoji reactions broadcast to all participants #98

Open
opened 2026-04-29 06:59:20 +00:00 by AhmedHanafy725 · 3 comments
Member

Improvement

During a presentation, viewers have no way to express engagement -- no thumbs-up / heart / clap / laugh / wow. Miro, Figma, Zoom, Teams all offer floating emoji reactions during shared sessions. Each reaction floats up and fades out; everyone in the session sees it.

This pairs naturally with the "share presentation link" feature (issue spawned alongside this one). With both, a presenter can run a slide deck with attendees who see slides + their own and others' reactions in real time.

Expected behavior

  • A small reaction button cluster appears in the presentation control bar (issue #93), to the LEFT of the prev/next counter group. Buttons: thumbs-up, heart, clap, party, wow (5 fixed presets). Same var(--wb-...) styling as the existing prev / next / exit buttons.
  • Clicking a reaction button:
    • Spawns a floating emoji at a random horizontal position near the bottom-center of the viewport, animates it upward with slight horizontal drift and fade-out over ~2 s, then removes it from the DOM.
    • Broadcasts a presentation.reaction event over the existing per-board WebSocket channel: {type: 'presentation.reaction', emoji: '\u{1F44D}', sender: <localUserId>, t: <Date.now()>}.
  • Receiving a presentation.reaction event spawns the same floating emoji animation in EVERY viewer's tab (including the sender's, but the sender already saw it locally on click; the existing sync.js localUserId echo-skip handles that).
  • Reactions are throttled per sender: at most ~6 reactions/second to avoid spam.
  • Reactions are ephemeral -- not persisted, not stored on the board, not visible after refresh.
  • Reactions are visible only while the user is in presentation mode (body.wb-presenting). If a user exits, incoming events are ignored locally.

Affected files (expected)

  • crates/hero_whiteboard_ui/templates/web/board.html -- add the reaction button cluster to the existing presentation control bar; CSS for the floating-emoji animation; a small JS module that handles click-to-broadcast and event-to-render.
  • crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js -- handle msg.type === 'presentation.reaction' in handleWsMessage and dispatch to a host hook (e.g. window.onPresentationReaction(msg)).
  • crates/hero_whiteboard_ui/static/web/js/whiteboard/frames.js -- expose sendReaction(emoji) that wraps the WebSocket send via WhiteboardSync._wsSend({type:'presentation.reaction', emoji, ...}) so the reaction module doesn't have to reach into sync internals directly.

No server / SDK / openrpc / DB changes -- the WebSocket relay is a pass-through.

Acceptance criteria

  • The presentation control bar gains 5 emoji reaction buttons to the left of the existing prev/counter/next/exit cluster.
  • Clicking a button animates a floating emoji on the local viewport and broadcasts to other viewers.
  • Other viewers see the same animated emoji within ~100 ms of the click.
  • Sender's own reaction renders once locally (no double).
  • Reactions are visible only while the recipient is in presentation mode; events received outside present mode are dropped.
  • Per-sender throttle: at most ~6 reactions/second; excess clicks are ignored locally and not broadcast.
  • Reactions don't persist (no DB, no refresh-survives).
  • No regression in the existing presentation control bar, exit, or keyboard shortcuts.
  • cargo check / clippy / fmt --check / test clean.

Notes

  • Use CSS @keyframes for the float animation (translateY + opacity). Self-removing nodes via animationend keep the DOM clean.
  • The floating emojis live in a position: fixed overlay with pointer-events: none so they don't intercept clicks on the control bar or the slide.
  • The 5 emoji presets are fixed; no custom emoji picker in this iteration. Possible follow-up.
  • Throttling is per-sender on the local side -- excess clicks just don't fire. The server does not need to throttle.
  • The sender field uses WhiteboardSync.getLocalUserId() for the existing echo-skip; an event with sender === localUserId is dropped on receive (so only remote viewers' reactions render via the WS path; the local user's reaction fires from the click handler directly).
## Improvement During a presentation, viewers have no way to express engagement -- no thumbs-up / heart / clap / laugh / wow. Miro, Figma, Zoom, Teams all offer floating emoji reactions during shared sessions. Each reaction floats up and fades out; everyone in the session sees it. This pairs naturally with the "share presentation link" feature (issue spawned alongside this one). With both, a presenter can run a slide deck with attendees who see slides + their own and others' reactions in real time. ## Expected behavior - A small reaction button cluster appears in the presentation control bar (issue #93), to the LEFT of the prev/next counter group. Buttons: thumbs-up, heart, clap, party, wow (5 fixed presets). Same `var(--wb-...)` styling as the existing prev / next / exit buttons. - Clicking a reaction button: - Spawns a floating emoji at a random horizontal position near the bottom-center of the viewport, animates it upward with slight horizontal drift and fade-out over ~2 s, then removes it from the DOM. - Broadcasts a `presentation.reaction` event over the existing per-board WebSocket channel: `{type: 'presentation.reaction', emoji: '\u{1F44D}', sender: <localUserId>, t: <Date.now()>}`. - Receiving a `presentation.reaction` event spawns the same floating emoji animation in EVERY viewer's tab (including the sender's, but the sender already saw it locally on click; the existing sync.js localUserId echo-skip handles that). - Reactions are throttled per sender: at most ~6 reactions/second to avoid spam. - Reactions are ephemeral -- not persisted, not stored on the board, not visible after refresh. - Reactions are visible only while the user is in presentation mode (`body.wb-presenting`). If a user exits, incoming events are ignored locally. ## Affected files (expected) - `crates/hero_whiteboard_ui/templates/web/board.html` -- add the reaction button cluster to the existing presentation control bar; CSS for the floating-emoji animation; a small JS module that handles click-to-broadcast and event-to-render. - `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` -- handle `msg.type === 'presentation.reaction'` in `handleWsMessage` and dispatch to a host hook (e.g. `window.onPresentationReaction(msg)`). - `crates/hero_whiteboard_ui/static/web/js/whiteboard/frames.js` -- expose `sendReaction(emoji)` that wraps the WebSocket send via `WhiteboardSync._wsSend({type:'presentation.reaction', emoji, ...})` so the reaction module doesn't have to reach into sync internals directly. No server / SDK / openrpc / DB changes -- the WebSocket relay is a pass-through. ## Acceptance criteria - [ ] The presentation control bar gains 5 emoji reaction buttons to the left of the existing prev/counter/next/exit cluster. - [ ] Clicking a button animates a floating emoji on the local viewport and broadcasts to other viewers. - [ ] Other viewers see the same animated emoji within ~100 ms of the click. - [ ] Sender's own reaction renders once locally (no double). - [ ] Reactions are visible only while the recipient is in presentation mode; events received outside present mode are dropped. - [ ] Per-sender throttle: at most ~6 reactions/second; excess clicks are ignored locally and not broadcast. - [ ] Reactions don't persist (no DB, no refresh-survives). - [ ] No regression in the existing presentation control bar, exit, or keyboard shortcuts. - [ ] `cargo check / clippy / fmt --check / test` clean. ## Notes - Use CSS `@keyframes` for the float animation (translateY + opacity). Self-removing nodes via `animationend` keep the DOM clean. - The floating emojis live in a `position: fixed` overlay with `pointer-events: none` so they don't intercept clicks on the control bar or the slide. - The 5 emoji presets are fixed; no custom emoji picker in this iteration. Possible follow-up. - Throttling is per-sender on the local side -- excess clicks just don't fire. The server does not need to throttle. - The `sender` field uses `WhiteboardSync.getLocalUserId()` for the existing echo-skip; an event with `sender === localUserId` is dropped on receive (so only remote viewers' reactions render via the WS path; the local user's reaction fires from the click handler directly).
Author
Member

Spec — Issue #98: Floating emoji reactions during presentation

Objective

Let any participant in a live presentation (presenter or audience) send a floating emoji reaction that animates upward locally and broadcasts to everyone else in the same board over the existing per-board WebSocket relay. Five fixed presets: thumbs-up, heart, clap, party, wow. Reactions are ephemeral, throttled, and only visible while in presentation mode.

Requirements

  • Five reaction buttons in the presentation control bar to the LEFT of the existing controls. Same var(--wb-...) styling as the other .pres-btn buttons.
  • Click → spawns a floating emoji locally + broadcasts {type: 'presentation.reaction', emoji, t} over the existing WebSocket fan-out (sender stamping is the existing wsSend behaviour: it sets sender = localUserId).
  • Receivers in presentation mode render the same animation; receivers not in presentation mode drop the event.
  • Per-sender local throttle: at most ~6 reactions/second (≈167 ms minimum between sends). Excess clicks are silently ignored. Apply the same throttle to incoming events keyed by msg.sender so a misbehaving client can't flood.
  • No persistence: not stored in objects.data, not in the DB, gone on reload.
  • Floating emoji overlay must not intercept clicks (use pointer-events: none).
  • Animation removes its DOM nodes on animationend so the overlay stays clean.

Files to Modify

  • New: crates/hero_whiteboard_ui/static/web/js/whiteboard/reactions.jsWhiteboardReactions IIFE with init, fire(emoji), onIncoming(msg), spawn(emoji). Internal per-local + per-sender throttle.
  • crates/hero_whiteboard_ui/templates/web/board.html:
    • CSS: keyframe + overlay container styling.
    • Markup: prepend the 5 reaction buttons + a divider in #presentation-controls.
    • Script tag for reactions.js.
    • In the existing bindPresentation IIFE, call WhiteboardReactions.init() (or do it on DOMContentLoaded — whichever is simpler).
  • crates/hero_whiteboard_ui/templates/web/board_view.html: same three additions (CSS, buttons, script tag + init).
  • crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js:
    • In handleWsMessage, after the existing presentation.slide branch, add a presentation.reaction branch that dispatches to WhiteboardReactions.onIncoming(msg). The existing if (msg.sender === localUserId) return; echo-skip at the top of handleWsMessage already prevents the sender from rendering twice.

Files to Leave Alone

  • frames.js — the issue suggests an optional sendReaction(emoji) wrapper, but reactions.js can call WhiteboardSync._wsSend directly (same pattern themes/connectors use). Avoiding the indirection keeps the diff smaller.
  • Server crates / openrpc / SDK / DB — pass-through over the existing WebSocket relay.

Step-by-Step Plan

Step 1 — Create reactions.js

File: crates/hero_whiteboard_ui/static/web/js/whiteboard/reactions.js

Skeleton:

var WhiteboardReactions = (function() {
    var THROTTLE_MS = 167; // ~6/sec
    var _lastLocal = 0;
    var _lastBySender = {};
    var _overlay = null;

    function init() {
        if (!_overlay) {
            _overlay = document.createElement('div');
            _overlay.id = 'wb-reactions-overlay';
            document.body.appendChild(_overlay);
        }
        var bar = document.getElementById('presentation-controls');
        if (bar) {
            bar.querySelectorAll('.pres-reaction[data-emoji]').forEach(function(btn) {
                btn.addEventListener('click', function() { fire(btn.getAttribute('data-emoji')); });
            });
        }
    }

    function fire(emoji) {
        if (!emoji) return;
        var now = Date.now();
        if (now - _lastLocal < THROTTLE_MS) return;
        _lastLocal = now;
        spawn(emoji);
        if (typeof WhiteboardSync !== 'undefined' && WhiteboardSync._wsSend) {
            WhiteboardSync._wsSend({ type: 'presentation.reaction', emoji: emoji, t: now });
        }
    }

    function onIncoming(msg) {
        if (!msg || !msg.emoji) return;
        if (typeof WhiteboardFrames === 'undefined' || !WhiteboardFrames.isPresentationMode()) return;
        var key = msg.sender || 'anon';
        var now = Date.now();
        if (now - (_lastBySender[key] || 0) < THROTTLE_MS) return;
        _lastBySender[key] = now;
        spawn(msg.emoji);
    }

    function spawn(emoji) {
        if (!_overlay) init();
        var node = document.createElement('span');
        node.className = 'wb-reaction';
        node.textContent = emoji;
        // Random horizontal position within ±120px of viewport center
        var x = (window.innerWidth / 2) + (Math.random() * 240 - 120);
        node.style.left = Math.round(x) + 'px';
        node.style.setProperty('--wb-react-drift', (Math.random() * 80 - 40).toFixed(0) + 'px');
        node.addEventListener('animationend', function() {
            if (node.parentNode) node.parentNode.removeChild(node);
        });
        _overlay.appendChild(node);
    }

    return { init: init, fire: fire, onIncoming: onIncoming, spawn: spawn };
})();

Step 2 — Wire reactions.js and add CSS in board.html

File: crates/hero_whiteboard_ui/templates/web/board.html

Add CSS to the existing <style> block (the one that already styles #pres-spotlight / #presentation-controls):

#wb-reactions-overlay {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 10000; /* above the spotlight (9999), below the control bar (10001) */
    overflow: hidden;
}
.wb-reaction {
    position: absolute;
    bottom: 80px;
    font-size: 32px;
    pointer-events: none;
    will-change: transform, opacity;
    animation: wb-react-float 2s ease-out forwards;
    --wb-react-drift: 0px;
}
@keyframes wb-react-float {
    0%   { transform: translate(0, 0) scale(0.85); opacity: 0; }
    10%  { opacity: 1; transform: translate(0, -10px) scale(1); }
    100% { transform: translate(var(--wb-react-drift), -320px) scale(1.1); opacity: 0; }
}
.pres-reaction { font-size: 18px; line-height: 1; }

Markup change in #presentation-controls — prepend reaction buttons + a divider before the existing pres-prev:

<div id="presentation-controls" role="group" aria-label="Presentation controls">
    <button type="button" class="pres-btn pres-reaction" data-emoji="\u{1F44D}" title="Thumbs up" aria-label="Thumbs up">\u{1F44D}</button>
    <button type="button" class="pres-btn pres-reaction" data-emoji="\u{2764}\u{FE0F}" title="Heart" aria-label="Heart">\u{2764}\u{FE0F}</button>
    <button type="button" class="pres-btn pres-reaction" data-emoji="\u{1F44F}" title="Clap" aria-label="Clap">\u{1F44F}</button>
    <button type="button" class="pres-btn pres-reaction" data-emoji="\u{1F389}" title="Party" aria-label="Party">\u{1F389}</button>
    <button type="button" class="pres-btn pres-reaction" data-emoji="\u{1F60D}" title="Wow" aria-label="Wow">\u{1F60D}</button>
    <span class="pres-divider"></span>
    <button type="button" class="pres-btn" id="pres-prev" ... </button>
    ...existing buttons unchanged...
</div>

Add the script tag near the other whiteboard/*.js includes in the {% block scripts %} section (alphabetical-ish order, near frames.js):

<script src="{{ base_path }}/js/whiteboard/reactions.js"></script>

In bindPresentation() call WhiteboardReactions.init() once after the existing setOnChange block (or unconditionally on DOMContentLoaded — pick the spot that keeps the diff minimal).

Step 3 — Mirror Step 2 changes in board_view.html

File: crates/hero_whiteboard_ui/templates/web/board_view.html

Same CSS additions, same 5 buttons + divider prepended to the control bar, same <script src=".../reactions.js"> include, same WhiteboardReactions.init() call inside the readonly bindPresentation. The audience gets identical reaction UX as the presenter.

Step 4 — Receiver in sync.js

File: crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js

In handleWsMessage, after the existing presentation.slide branch:

} else if (msg.type === 'presentation.reaction') {
    if (typeof WhiteboardReactions !== 'undefined' && WhiteboardReactions.onIncoming) {
        WhiteboardReactions.onIncoming(msg);
    }
}

The localUserId echo-skip at the top of handleWsMessage already drops our own broadcast, so we only ever render remote reactions through this branch (the local user already saw their own emoji from the click handler).

Acceptance Criteria

  • Presentation control bar gains 5 emoji buttons to the left of the existing prev/counter/next/share/exit cluster (presenter) and counter/exit (audience).
  • Clicking a button animates the floating emoji on the local viewport AND broadcasts to others.
  • Other viewers see the same animated emoji within ~100 ms of the click.
  • Sender's own reaction renders once locally (no double).
  • Reactions are visible only while the recipient is in presentation mode; events received outside present mode are dropped silently.
  • Per-sender throttle (~6 reactions/second) for both local sends and incoming events.
  • Reactions don't persist (not stored, not visible after reload).
  • No regression to the existing presentation control bar, exit, keyboard shortcuts, or presentation.slide sync.
  • cargo fmt --all -- --check / cargo check --workspace / cargo clippy --workspace -- -D warnings clean.

Notes

  • Local throttle uses _lastLocal; remote throttle uses _lastBySender[sender]. Together they cap incoming spam without coordination.
  • The overlay container is a single #wb-reactions-overlay div that exists for the whole page lifetime; individual emoji nodes self-remove on animationend.
  • pointer-events: none on the overlay AND each reaction prevents them from intercepting clicks on the control bar or the canvas backdrop.
  • z-index: 9999 (spotlight) < 10000 (reactions overlay) < 10001 (control bar) < 10002 (toast in present mode). Reactions float behind the control bar, which is what we want.
  • Five fixed emoji are baked into the markup. Custom picker is out of scope.
  • No frames.js changes — reactions.js calls WhiteboardSync._wsSend directly, mirroring the pattern themes/connectors already use.
  • The new reactions.js is a separate file (not inline) so the same module is available to both board.html and board_view.html without duplication.
# Spec — Issue #98: Floating emoji reactions during presentation ## Objective Let any participant in a live presentation (presenter or audience) send a floating emoji reaction that animates upward locally and broadcasts to everyone else in the same board over the existing per-board WebSocket relay. Five fixed presets: thumbs-up, heart, clap, party, wow. Reactions are ephemeral, throttled, and only visible while in presentation mode. ## Requirements - Five reaction buttons in the presentation control bar to the LEFT of the existing controls. Same `var(--wb-...)` styling as the other `.pres-btn` buttons. - Click → spawns a floating emoji locally + broadcasts `{type: 'presentation.reaction', emoji, t}` over the existing WebSocket fan-out (sender stamping is the existing `wsSend` behaviour: it sets `sender = localUserId`). - Receivers in presentation mode render the same animation; receivers not in presentation mode drop the event. - Per-sender local throttle: at most ~6 reactions/second (≈167 ms minimum between sends). Excess clicks are silently ignored. Apply the same throttle to incoming events keyed by `msg.sender` so a misbehaving client can't flood. - No persistence: not stored in `objects.data`, not in the DB, gone on reload. - Floating emoji overlay must not intercept clicks (use `pointer-events: none`). - Animation removes its DOM nodes on `animationend` so the overlay stays clean. ## Files to Modify - **New:** `crates/hero_whiteboard_ui/static/web/js/whiteboard/reactions.js` — `WhiteboardReactions` IIFE with `init`, `fire(emoji)`, `onIncoming(msg)`, `spawn(emoji)`. Internal per-local + per-sender throttle. - `crates/hero_whiteboard_ui/templates/web/board.html`: - CSS: keyframe + overlay container styling. - Markup: prepend the 5 reaction buttons + a divider in `#presentation-controls`. - Script tag for `reactions.js`. - In the existing `bindPresentation` IIFE, call `WhiteboardReactions.init()` (or do it on DOMContentLoaded — whichever is simpler). - `crates/hero_whiteboard_ui/templates/web/board_view.html`: same three additions (CSS, buttons, script tag + init). - `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js`: - In `handleWsMessage`, after the existing `presentation.slide` branch, add a `presentation.reaction` branch that dispatches to `WhiteboardReactions.onIncoming(msg)`. The existing `if (msg.sender === localUserId) return;` echo-skip at the top of `handleWsMessage` already prevents the sender from rendering twice. ## Files to Leave Alone - `frames.js` — the issue suggests an optional `sendReaction(emoji)` wrapper, but `reactions.js` can call `WhiteboardSync._wsSend` directly (same pattern themes/connectors use). Avoiding the indirection keeps the diff smaller. - Server crates / openrpc / SDK / DB — pass-through over the existing WebSocket relay. ## Step-by-Step Plan ### Step 1 — Create `reactions.js` File: `crates/hero_whiteboard_ui/static/web/js/whiteboard/reactions.js` Skeleton: ```js var WhiteboardReactions = (function() { var THROTTLE_MS = 167; // ~6/sec var _lastLocal = 0; var _lastBySender = {}; var _overlay = null; function init() { if (!_overlay) { _overlay = document.createElement('div'); _overlay.id = 'wb-reactions-overlay'; document.body.appendChild(_overlay); } var bar = document.getElementById('presentation-controls'); if (bar) { bar.querySelectorAll('.pres-reaction[data-emoji]').forEach(function(btn) { btn.addEventListener('click', function() { fire(btn.getAttribute('data-emoji')); }); }); } } function fire(emoji) { if (!emoji) return; var now = Date.now(); if (now - _lastLocal < THROTTLE_MS) return; _lastLocal = now; spawn(emoji); if (typeof WhiteboardSync !== 'undefined' && WhiteboardSync._wsSend) { WhiteboardSync._wsSend({ type: 'presentation.reaction', emoji: emoji, t: now }); } } function onIncoming(msg) { if (!msg || !msg.emoji) return; if (typeof WhiteboardFrames === 'undefined' || !WhiteboardFrames.isPresentationMode()) return; var key = msg.sender || 'anon'; var now = Date.now(); if (now - (_lastBySender[key] || 0) < THROTTLE_MS) return; _lastBySender[key] = now; spawn(msg.emoji); } function spawn(emoji) { if (!_overlay) init(); var node = document.createElement('span'); node.className = 'wb-reaction'; node.textContent = emoji; // Random horizontal position within ±120px of viewport center var x = (window.innerWidth / 2) + (Math.random() * 240 - 120); node.style.left = Math.round(x) + 'px'; node.style.setProperty('--wb-react-drift', (Math.random() * 80 - 40).toFixed(0) + 'px'); node.addEventListener('animationend', function() { if (node.parentNode) node.parentNode.removeChild(node); }); _overlay.appendChild(node); } return { init: init, fire: fire, onIncoming: onIncoming, spawn: spawn }; })(); ``` ### Step 2 — Wire `reactions.js` and add CSS in `board.html` File: `crates/hero_whiteboard_ui/templates/web/board.html` Add CSS to the existing `<style>` block (the one that already styles `#pres-spotlight` / `#presentation-controls`): ```css #wb-reactions-overlay { position: fixed; inset: 0; pointer-events: none; z-index: 10000; /* above the spotlight (9999), below the control bar (10001) */ overflow: hidden; } .wb-reaction { position: absolute; bottom: 80px; font-size: 32px; pointer-events: none; will-change: transform, opacity; animation: wb-react-float 2s ease-out forwards; --wb-react-drift: 0px; } @keyframes wb-react-float { 0% { transform: translate(0, 0) scale(0.85); opacity: 0; } 10% { opacity: 1; transform: translate(0, -10px) scale(1); } 100% { transform: translate(var(--wb-react-drift), -320px) scale(1.1); opacity: 0; } } .pres-reaction { font-size: 18px; line-height: 1; } ``` Markup change in `#presentation-controls` — prepend reaction buttons + a divider before the existing `pres-prev`: ```html <div id="presentation-controls" role="group" aria-label="Presentation controls"> <button type="button" class="pres-btn pres-reaction" data-emoji="\u{1F44D}" title="Thumbs up" aria-label="Thumbs up">\u{1F44D}</button> <button type="button" class="pres-btn pres-reaction" data-emoji="\u{2764}\u{FE0F}" title="Heart" aria-label="Heart">\u{2764}\u{FE0F}</button> <button type="button" class="pres-btn pres-reaction" data-emoji="\u{1F44F}" title="Clap" aria-label="Clap">\u{1F44F}</button> <button type="button" class="pres-btn pres-reaction" data-emoji="\u{1F389}" title="Party" aria-label="Party">\u{1F389}</button> <button type="button" class="pres-btn pres-reaction" data-emoji="\u{1F60D}" title="Wow" aria-label="Wow">\u{1F60D}</button> <span class="pres-divider"></span> <button type="button" class="pres-btn" id="pres-prev" ... </button> ...existing buttons unchanged... </div> ``` Add the script tag near the other `whiteboard/*.js` includes in the `{% block scripts %}` section (alphabetical-ish order, near `frames.js`): ```html <script src="{{ base_path }}/js/whiteboard/reactions.js"></script> ``` In `bindPresentation()` call `WhiteboardReactions.init()` once after the existing `setOnChange` block (or unconditionally on DOMContentLoaded — pick the spot that keeps the diff minimal). ### Step 3 — Mirror Step 2 changes in `board_view.html` File: `crates/hero_whiteboard_ui/templates/web/board_view.html` Same CSS additions, same 5 buttons + divider prepended to the control bar, same `<script src=".../reactions.js">` include, same `WhiteboardReactions.init()` call inside the readonly `bindPresentation`. The audience gets identical reaction UX as the presenter. ### Step 4 — Receiver in `sync.js` File: `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` In `handleWsMessage`, after the existing `presentation.slide` branch: ```js } else if (msg.type === 'presentation.reaction') { if (typeof WhiteboardReactions !== 'undefined' && WhiteboardReactions.onIncoming) { WhiteboardReactions.onIncoming(msg); } } ``` The localUserId echo-skip at the top of `handleWsMessage` already drops our own broadcast, so we only ever render remote reactions through this branch (the local user already saw their own emoji from the click handler). ## Acceptance Criteria - [ ] Presentation control bar gains 5 emoji buttons to the left of the existing prev/counter/next/share/exit cluster (presenter) and counter/exit (audience). - [ ] Clicking a button animates the floating emoji on the local viewport AND broadcasts to others. - [ ] Other viewers see the same animated emoji within ~100 ms of the click. - [ ] Sender's own reaction renders once locally (no double). - [ ] Reactions are visible only while the recipient is in presentation mode; events received outside present mode are dropped silently. - [ ] Per-sender throttle (~6 reactions/second) for both local sends and incoming events. - [ ] Reactions don't persist (not stored, not visible after reload). - [ ] No regression to the existing presentation control bar, exit, keyboard shortcuts, or `presentation.slide` sync. - [ ] `cargo fmt --all -- --check` / `cargo check --workspace` / `cargo clippy --workspace -- -D warnings` clean. ## Notes - Local throttle uses `_lastLocal`; remote throttle uses `_lastBySender[sender]`. Together they cap incoming spam without coordination. - The overlay container is a single `#wb-reactions-overlay` div that exists for the whole page lifetime; individual emoji nodes self-remove on `animationend`. - `pointer-events: none` on the overlay AND each reaction prevents them from intercepting clicks on the control bar or the canvas backdrop. - z-index: `9999` (spotlight) < `10000` (reactions overlay) < `10001` (control bar) < `10002` (toast in present mode). Reactions float behind the control bar, which is what we want. - Five fixed emoji are baked into the markup. Custom picker is out of scope. - No `frames.js` changes — `reactions.js` calls `WhiteboardSync._wsSend` directly, mirroring the pattern themes/connectors already use. - The new `reactions.js` is a separate file (not inline) so the same module is available to both `board.html` and `board_view.html` without duplication.
Author
Member

Test Results

  • cargo fmt --all -- --check: pass
  • cargo check --workspace: pass
  • node --check reactions.js: pass
  • node --check sync.js: pass

The Askama board.html and board_view.html templates re-compiled cleanly via cargo check.

## Test Results - cargo fmt --all -- --check: pass - cargo check --workspace: pass - node --check reactions.js: pass - node --check sync.js: pass The Askama board.html and board_view.html templates re-compiled cleanly via cargo check.
Author
Member

Implementation Summary

Four files touched: one new module + three wires. Total +70/-0 in the tracked diff plus one new ~70-line file (reactions.js). No server / SDK / openrpc / DB changes — the WebSocket relay is a pass-through.

crates/hero_whiteboard_ui/static/web/js/whiteboard/reactions.js (new)

WhiteboardReactions IIFE with the public surface:

  • init() — appends #wb-reactions-overlay to document.body (idempotent) and binds click handlers to every .pres-reaction[data-emoji] button inside #presentation-controls (idempotent via _bound).
  • fire(emoji) — local-throttle (~6/sec via THROTTLE_MS = 167), spawns local emoji, and broadcasts {type: 'presentation.reaction', emoji, t} via the existing WhiteboardSync._wsSend. The send path stamps sender automatically.
  • onIncoming(msg) — gates on WhiteboardFrames.isPresentationMode(); per-sender throttle keyed by msg.sender || 'anon'; spawns the same animation. The localUserId echo-skip in sync.js::handleWsMessage ensures the originator doesn't render their own reaction twice.
  • spawn(emoji) — creates a <span class="wb-reaction"> at a random left within ±120 px of the viewport center, sets a --wb-react-drift random horizontal offset, and self-removes on animationend.

crates/hero_whiteboard_ui/templates/web/board.html

  • New CSS rules: #wb-reactions-overlay (z-index 10000, pointer-events: none, inset: 0, overflow: hidden), .wb-reaction (positioned, font-size 32 px, animation), @keyframes wb-react-float (translate up + drift + opacity), .pres-reaction { font-size: 18px; line-height: 1; }.
  • Five reaction buttons (👍 ❤️ 👏 🎉 😍) prepended to #presentation-controls, then a .pres-divider, then the existing prev/counter/next/share/exit cluster.
  • <script src="{{ base_path }}/js/whiteboard/reactions.js"> added next to frames.js.
  • bindPresentation() now calls WhiteboardReactions.init() after wiring the existing buttons.

crates/hero_whiteboard_ui/templates/web/board_view.html

Same three wires (CSS, buttons + divider, script tag, init call). Audience gets identical reaction UX.

crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js

After the existing presentation.slide branch in handleWsMessage:

} else if (msg.type === 'presentation.reaction') {
    if (typeof WhiteboardReactions !== 'undefined' && WhiteboardReactions.onIncoming) {
        WhiteboardReactions.onIncoming(msg);
    }
}

The if (msg.sender === localUserId) return; echo-skip earlier in the function already drops the originator's own broadcast.

Verification

  • cargo fmt --all -- --check: clean
  • cargo check --workspace: clean (Askama re-compiled both templates)
  • node --check reactions.js and node --check sync.js: clean

Manual smoke test

  1. Open a board with several frames in two tabs (presenter + audience via /s/<token>?present=1).
  2. Click any of the five reaction buttons in either tab. The clicked emoji floats up from the lower-center of the local viewport with a slight horizontal drift and fades out within ~2 s.
  3. The same animation appears in the other tab within ~100 ms.
  4. Mash the same button rapidly — at most ~6 spawns/second locally, the rest are dropped.
  5. Exit presentation in one tab; incoming reactions from the other tab no longer animate locally (gated by isPresentationMode()).
  6. Reload either tab — no reactions persist.
  7. Existing controls (Prev / Next / Share / Exit / Esc / arrows) still work; toast still appears at top during presentation; remote slide changes still propagate.

Notes / scope

  • z-index stack: spotlight 9999 < reactions overlay 10000 < control bar 10001 < toast 10002. Reactions float behind the control bar, which is intentional — they shouldn't obscure the prev/next/share buttons.
  • pointer-events: none on both the overlay and each .wb-reaction ensures reactions never intercept clicks on the control bar or the canvas backdrop.
  • Five fixed emoji presets are hard-coded in the template markup. A custom picker is out of scope.
  • THROTTLE_MS = 167 (~6/sec) applies to both local sends and incoming events keyed by msg.sender. A misbehaving client can't flood other tabs.
  • Emoji nodes self-remove on animationend so the overlay never accumulates DOM cruft.
  • No frames.js change — reactions.js calls WhiteboardSync._wsSend directly, mirroring the same pattern used by themes.js and connectors.js.
## Implementation Summary Four files touched: one new module + three wires. Total +70/-0 in the tracked diff plus one new ~70-line file (`reactions.js`). No server / SDK / openrpc / DB changes — the WebSocket relay is a pass-through. ### `crates/hero_whiteboard_ui/static/web/js/whiteboard/reactions.js` (new) `WhiteboardReactions` IIFE with the public surface: - `init()` — appends `#wb-reactions-overlay` to `document.body` (idempotent) and binds click handlers to every `.pres-reaction[data-emoji]` button inside `#presentation-controls` (idempotent via `_bound`). - `fire(emoji)` — local-throttle (~6/sec via `THROTTLE_MS = 167`), spawns local emoji, and broadcasts `{type: 'presentation.reaction', emoji, t}` via the existing `WhiteboardSync._wsSend`. The send path stamps `sender` automatically. - `onIncoming(msg)` — gates on `WhiteboardFrames.isPresentationMode()`; per-sender throttle keyed by `msg.sender || 'anon'`; spawns the same animation. The localUserId echo-skip in `sync.js::handleWsMessage` ensures the originator doesn't render their own reaction twice. - `spawn(emoji)` — creates a `<span class="wb-reaction">` at a random `left` within ±120 px of the viewport center, sets a `--wb-react-drift` random horizontal offset, and self-removes on `animationend`. ### `crates/hero_whiteboard_ui/templates/web/board.html` - New CSS rules: `#wb-reactions-overlay` (z-index 10000, `pointer-events: none`, `inset: 0`, `overflow: hidden`), `.wb-reaction` (positioned, font-size 32 px, animation), `@keyframes wb-react-float` (translate up + drift + opacity), `.pres-reaction { font-size: 18px; line-height: 1; }`. - Five reaction buttons (👍 ❤️ 👏 🎉 😍) prepended to `#presentation-controls`, then a `.pres-divider`, then the existing prev/counter/next/share/exit cluster. - `<script src="{{ base_path }}/js/whiteboard/reactions.js">` added next to `frames.js`. - `bindPresentation()` now calls `WhiteboardReactions.init()` after wiring the existing buttons. ### `crates/hero_whiteboard_ui/templates/web/board_view.html` Same three wires (CSS, buttons + divider, script tag, init call). Audience gets identical reaction UX. ### `crates/hero_whiteboard_ui/static/web/js/whiteboard/sync.js` After the existing `presentation.slide` branch in `handleWsMessage`: ```js } else if (msg.type === 'presentation.reaction') { if (typeof WhiteboardReactions !== 'undefined' && WhiteboardReactions.onIncoming) { WhiteboardReactions.onIncoming(msg); } } ``` The `if (msg.sender === localUserId) return;` echo-skip earlier in the function already drops the originator's own broadcast. ### Verification - `cargo fmt --all -- --check`: clean - `cargo check --workspace`: clean (Askama re-compiled both templates) - `node --check reactions.js` and `node --check sync.js`: clean ### Manual smoke test 1. Open a board with several frames in two tabs (presenter + audience via `/s/<token>?present=1`). 2. Click any of the five reaction buttons in either tab. The clicked emoji floats up from the lower-center of the local viewport with a slight horizontal drift and fades out within ~2 s. 3. The same animation appears in the other tab within ~100 ms. 4. Mash the same button rapidly — at most ~6 spawns/second locally, the rest are dropped. 5. Exit presentation in one tab; incoming reactions from the other tab no longer animate locally (gated by `isPresentationMode()`). 6. Reload either tab — no reactions persist. 7. Existing controls (Prev / Next / Share / Exit / Esc / arrows) still work; toast still appears at top during presentation; remote slide changes still propagate. ### Notes / scope - z-index stack: spotlight 9999 < reactions overlay 10000 < control bar 10001 < toast 10002. Reactions float behind the control bar, which is intentional — they shouldn't obscure the prev/next/share buttons. - `pointer-events: none` on both the overlay and each `.wb-reaction` ensures reactions never intercept clicks on the control bar or the canvas backdrop. - Five fixed emoji presets are hard-coded in the template markup. A custom picker is out of scope. - `THROTTLE_MS = 167` (~6/sec) applies to both local sends and incoming events keyed by `msg.sender`. A misbehaving client can't flood other tabs. - Emoji nodes self-remove on `animationend` so the overlay never accumulates DOM cruft. - No `frames.js` change — `reactions.js` calls `WhiteboardSync._wsSend` directly, mirroring the same pattern used by `themes.js` and `connectors.js`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
lhumina_code/hero_whiteboard#98
No description provided.