diff --git a/examples/package.json b/examples/package.json index 2bc7219d0f..b8a58f6e4e 100644 --- a/examples/package.json +++ b/examples/package.json @@ -48,6 +48,7 @@ "pub-sub-rewind/react", "pub-sub-rewind/javascript", "pub-sub-message-annotations/javascript", + "pub-sub-live-voting/javascript", "spaces-avatar-stack/react", "spaces-avatar-stack/javascript", "spaces-component-locking/react", @@ -100,6 +101,7 @@ "pub-sub-rewind-javascript": "yarn workspace pub-sub-rewind-javascript dev", "pub-sub-rewind-react": "yarn workspace pub-sub-rewind-react dev", "pub-sub-message-annotations-javascript": "yarn workspace pub-sub-message-annotations-javascript dev", + "pub-sub-live-voting-javascript": "yarn workspace pub-sub-live-voting-javascript dev", "spaces-avatar-stack-javascript": "yarn workspace spaces-avatar-stack-javascript dev", "spaces-avatar-stack-react": "yarn workspace spaces-avatar-stack-react dev", "spaces-component-locking-javascript": "yarn workspace spaces-component-locking-javascript dev", @@ -119,6 +121,7 @@ "lodash": "^4.18.1", "minifaker": "^1.34.1", "nanoid": "^5.0.7", + "qrcode": "^1.5.4", "react": "^18", "react-dom": "^18", "react-icons": "^5.4.0", @@ -130,6 +133,7 @@ "@tailwindcss/postcss": "^4.0.14", "@types/express": "^5.0.0", "@types/node": "^20", + "@types/qrcode": "^1.5.5", "@types/react": "^18", "@types/react-dom": "^18", "@types/uikit": "^3.7.0", diff --git a/examples/pub-sub-live-voting/javascript/.gitignore b/examples/pub-sub-live-voting/javascript/.gitignore new file mode 100644 index 0000000000..804bd4a4ae --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +*.local diff --git a/examples/pub-sub-live-voting/javascript/README.md b/examples/pub-sub-live-voting/javascript/README.md new file mode 100644 index 0000000000..941c4ebbed --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/README.md @@ -0,0 +1,161 @@ +# Build live voting with message annotations + +This is a live-voting app: an admin runs a show of polls, an audience votes +from their phones, and a big screen shows results updating in realtime. The +voter pane and presenter pane on the left are both live: cast a vote and watch +the presenter's heatmap and vote bubbles react instantly. + +The design pattern this example highlights is how a vote is represented. A poll +is a regular Ably **message**. Each vote is an **annotation** attached to that +message. Ably aggregates the annotations into a **summary**, attached to the +original poll message, and delivers it to subscribers. Votes do not need to be +stored in a database, and your own server is only needed to autenticate +clients, all the realtime low-latency vote distribution and counting is done by +Ably. + +## Why annotations? + +There are a few different patterns you could use to build a voting app. +Firstly, of course, you could just use regular messages. Secondly, you could +use [LiveObjects](https://ably.com/docs/liveobjects), an ably feature which +attaches a synchonized, conflict-free, concurrently-modifyable state object to +a channel. Either would be an excellent choice, but +[annotations](https://ably.com/docs/messages/annotations) have some advantages +that make it well-suited to this use-case: + +**Summaries and raw events are separate streams, so each view subscribes to +what it needs.** Voters subscribe to the annotation *summary* only — one +compact, regularly-rolled-up object (`{ optionId: { total } }`) with enough information to +show live percentages on every phone. A busy poll is one cheap summary update +per phone, not one event per vote. The presenter view, however, does want to +subscribe to individual annotation events, one per vote, so it can to animate a +bubble for each (together with the user id of the person who made that vote). +It opts into that higher-volume stream separately by specifying the +[`ANNOTATION_SUBSCRIBE` channel +mode](https://ably.com/docs/messages/annotations#individual-annotations) and +add a listener with with `annotations.subscribe()`. Same data, two consumption +modes, each scoped to the view that needs it. + +**Capabilities keep voters locked down.** A voter's token grants only the +`annotation-publish` capability on the channel. A voter can cast votes but +cannot publish poll messages, and cannot subscribe to other voters' raw +annotations, so it can't tamper with the poll or snoop on individual votes. See +the per-role capabilities in +[`server/src/server.ts`](https://github.com/ably-demos/live-voting-with-annotations/blob/main/server/src/server.ts). + +**One vote per person, enforced by Ably.** Votes use the `unique` aggregation +(`vote:unique.v1`). In this aggregation mode, Ably keeps at most one vote per +`clientId`, and moving to another option moves that client's vote rather than +adding a second one. You get "one person, one vote" semantics for free. + +If building on [LiveObjects](/docs/liveobjects), you'd use a `LiveCounter` per +option, but there's no built-in one-vote-per-client constraint — any client can +call `increment` as many times as it likes. You could add a separate `LiveMap` +tracking `clientId → vote` and check it before allowing a vote, but there's no +atomic check-and-increment. Of course, LiveObjects have other advantages -- and +if some part of your app needs the features of liveobjects, you can of course +use a LiveObject on the same channel as well. + +## How it works + +1. The admin starts a poll by publishing a message: + + ```javascript + await channel.publish('poll', { pollId, question, type, options }); + ``` + +2. A voter attaches a `vote:unique.v1` annotation to that message's `serial`, + naming the chosen option: + + ```javascript + await channel.annotations.publish(pollSerial, { + type: 'vote:unique.v1', + name: optionId, + }); + ``` + +3. Ably aggregates the votes and delivers a summary on the poll message. Voters + read it to render live percentages: + + ```javascript + channel.subscribe((message) => { + const summary = message.annotations?.summary?.['vote:unique.v1']; + // summary[optionId].total === votes for that option + }); + ``` + +4. The presenter additionally subscribes to the individual events for its vote + and suggestion bubbles: + + ```javascript + channel.annotations.subscribe('vote:unique.v1', (annotation) => { + // one event per vote — annotation.name is the option, annotation.clientId the voter + }); + ``` + +The annotation type is written `namespace:summarization.version` — here +`vote`, `unique`, `v1`. Read more about the aggregation types (`unique`, +`distinct`, `multiple`, `total`, `flag`) in +[Message annotations](/docs/messages/annotations). + +The channel is attached with `rewind: 1`, so a phone that joins (or refreshes) +mid-poll immediately receives the current poll and its latest summary without +the admin re-publishing. + +## Unaggregated annotations + +Open-ended "suggest" polls (where voters type free text instead of picking an +option) use a second annotation type, `suggestion`, with no aggregation suffix. +Votes need a running tally, so they use `unique` and are read from the summary. +A suggestion is a one-off piece of text that floats across the presenter once +and is gone, with nothing to count. Since they are unaggregated, they are +received by anyone subscribing to annotations, and they are retrievable through +anyone using +[annotations.get()](https://ably.com/docs/messages/annotations#retrieve) to +retrieve a list of annotations for a given message, but they don't result in a +new message summary being generated. + +## Server-side batching + +For very high usecases, where individual votes might (say) overwhelm the +presenter view's message rate limits, you can turn on [server-side +batching](/docs/messages/batch) for the `voting` channel namespace so Ably +groups messages over a short interval (for example 100ms) before fanning them +out. Each batch counts as a single message, which keeps cost and rate-limits in +check during surges with only a small, configurable delay. Create the rule in +your app settings, or via the Control API / CLI: + +```shell +ably apps rules create --name "voting" --batching-enabled --batching-interval 100 +``` + +## Required channel rule + +Annotations require the *Message annotations, updates, deletes, and appends* +rule to be enabled on the channel or namespace. Enable it for the `voting` +namespace before running this against your own app. + +## Getting started + +The live demo above runs entirely in the browser. For a complete app you can +clone and run yourself — admin, voter and presenter, with proper token +authentication and none of the in-page scaffolding — get +[ably-demos/live-voting-with-annotations](https://github.com/ably-demos/live-voting-with-annotations). + +Its README has the full setup, but in short: give the server an Ably API key and +an admin password, pick the static (`SHOWS_FILE`) or Postgres poll store, then +run the server and the client. The default view is the voter; `?role=admin` +drives the show and `?role=presenter` is the big screen, and the admin shows a QR +code that points voters at the right session. + +The client never sees an API key — it calls the server's `/auth` endpoint for +short-lived, role-scoped tokens. The hosted demo above has no backend, so it uses +a raw key embedded in the page; that's just for the demo and should never be done +in a real app. + +## Related + +- [Message annotations](/docs/messages/annotations) — the full feature reference. +- [Message annotations example](/examples/pub-sub-message-annotations) — a + lower-level tour of all five aggregation types. +- [Message batching](/docs/messages/batch) — scaling high-throughput channels. diff --git a/examples/pub-sub-live-voting/javascript/data/demo-shows.json b/examples/pub-sub-live-voting/javascript/data/demo-shows.json new file mode 100644 index 0000000000..ce877c4447 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/data/demo-shows.json @@ -0,0 +1,38 @@ +{ + "shows": [ + { + "id": 1, + "name": "Ably Live Voting demo", + "polls": [ + { + "id": 1, + "question": "Which real-time feature do you reach for most?", + "type": "list", + "options": [ + { "id": 101, "label": "Pub/Sub channels" }, + { "id": 102, "label": "Presence" }, + { "id": 103, "label": "Message history" }, + { "id": 104, "label": "Push notifications" } + ] + }, + { + "id": 2, + "question": "Rate this demo with the d-pad", + "type": "dpad", + "options": [ + { "id": 201, "label": "Love it", "direction": "up" }, + { "id": 202, "label": "It's neat", "direction": "right" }, + { "id": 203, "label": "Meh", "direction": "down" }, + { "id": 204, "label": "Confused", "direction": "left" } + ] + }, + { + "id": 3, + "question": "What should we build with Ably next?", + "type": "suggest", + "options": [] + } + ] + } + ] +} diff --git a/examples/pub-sub-live-voting/javascript/database/migrations/001_poll_types.sql b/examples/pub-sub-live-voting/javascript/database/migrations/001_poll_types.sql new file mode 100644 index 0000000000..0eaa99d8b7 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/database/migrations/001_poll_types.sql @@ -0,0 +1,7 @@ +ALTER TABLE polls + ADD COLUMN IF NOT EXISTS type TEXT NOT NULL DEFAULT 'list' + CHECK (type IN ('list', 'dpad')); + +ALTER TABLE poll_options + ADD COLUMN IF NOT EXISTS direction TEXT + CHECK (direction IS NULL OR direction IN ('up', 'right', 'down', 'left')); diff --git a/examples/pub-sub-live-voting/javascript/database/migrations/002_suggest_poll_type.sql b/examples/pub-sub-live-voting/javascript/database/migrations/002_suggest_poll_type.sql new file mode 100644 index 0000000000..d2d180a737 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/database/migrations/002_suggest_poll_type.sql @@ -0,0 +1,3 @@ +ALTER TABLE polls DROP CONSTRAINT IF EXISTS polls_type_check; +ALTER TABLE polls ADD CONSTRAINT polls_type_check + CHECK (type IN ('list', 'dpad', 'suggest')); diff --git a/examples/pub-sub-live-voting/javascript/database/seed.sql b/examples/pub-sub-live-voting/javascript/database/seed.sql new file mode 100644 index 0000000000..633bd85c27 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/database/seed.sql @@ -0,0 +1,18 @@ +-- Optional sample data for the Postgres store. The d-pad and suggest poll +-- types make the presenter view more expressive than list polls alone. +INSERT INTO shows (name) VALUES ('Ably Live Voting demo'); + +INSERT INTO polls (show_id, question, type, sort_order) VALUES + (1, 'Which real-time feature do you reach for most?', 'list', 0), + (1, 'Rate this demo with the d-pad', 'dpad', 1), + (1, 'What should we build with Ably next?', 'suggest', 2); + +INSERT INTO poll_options (poll_id, label, direction, sort_order) VALUES + (1, 'Pub/Sub channels', NULL, 0), + (1, 'Presence', NULL, 1), + (1, 'Message history', NULL, 2), + (1, 'Push notifications', NULL, 3), + (2, 'Love it', 'up', 0), + (2, 'It''s neat', 'right', 1), + (2, 'Meh', 'down', 2), + (2, 'Confused', 'left', 3); diff --git a/examples/pub-sub-live-voting/javascript/database/setup.sql b/examples/pub-sub-live-voting/javascript/database/setup.sql new file mode 100644 index 0000000000..d71ade40bc --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/database/setup.sql @@ -0,0 +1,24 @@ +CREATE TABLE shows ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL +); + +CREATE TABLE polls ( + id SERIAL PRIMARY KEY, + show_id INTEGER NOT NULL REFERENCES shows(id) ON DELETE CASCADE, + question TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + type TEXT NOT NULL DEFAULT 'list' + CHECK (type IN ('list', 'dpad', 'suggest')) +); + +CREATE TABLE poll_options ( + id SERIAL PRIMARY KEY, + poll_id INTEGER NOT NULL REFERENCES polls(id) ON DELETE CASCADE, + label TEXT NOT NULL, + sort_order INTEGER NOT NULL DEFAULT 0, + direction TEXT + CHECK (direction IS NULL OR direction IN ( + 'up', 'right', 'down', 'left' + )) +); diff --git a/examples/pub-sub-live-voting/javascript/index.html b/examples/pub-sub-live-voting/javascript/index.html new file mode 100644 index 0000000000..cf21dc306f --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/index.html @@ -0,0 +1,19 @@ + + + + + + + + + + + Ably Live Voting + + + + + + + + diff --git a/examples/pub-sub-live-voting/javascript/package.json b/examples/pub-sub-live-voting/javascript/package.json new file mode 100644 index 0000000000..3425e78671 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/package.json @@ -0,0 +1,11 @@ +{ + "name": "pub-sub-live-voting-javascript", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "preview": "vite preview" + } +} diff --git a/examples/pub-sub-live-voting/javascript/server/.env.example b/examples/pub-sub-live-voting/javascript/server/.env.example new file mode 100644 index 0000000000..573433b69c --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/server/.env.example @@ -0,0 +1,14 @@ +# Your Ably API key, in "keyName:keySecret" form. Used to sign role-scoped JWTs. +ABLY_API_KEY= + +# Password the admin console must supply to drive a show. +ADMIN_PASSWORD=changeme + +# Read polls from a static JSON file (no database). Omit to use Postgres. +# Path is resolved relative to the server's working directory. +SHOWS_FILE=../data/demo-shows.json + +# Postgres connection string (only used when SHOWS_FILE is unset). +# DATABASE_URL=postgresql://localhost/ably_voting + +# PORT=3000 diff --git a/examples/pub-sub-live-voting/javascript/server/package.json b/examples/pub-sub-live-voting/javascript/server/package.json new file mode 100644 index 0000000000..eb1e196fd4 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/server/package.json @@ -0,0 +1,26 @@ +{ + "name": "pub-sub-live-voting-server", + "version": "1.0.0", + "type": "module", + "main": "src/server.ts", + "license": "MIT", + "scripts": { + "dev": "tsx watch src/server.ts", + "start": "tsx src/server.ts" + }, + "dependencies": { + "cors": "^2.8.5", + "express": "^4.21.0", + "jsonwebtoken": "^9.0.0", + "pg": "^8.20.0" + }, + "devDependencies": { + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.0", + "@types/node": "^20", + "@types/pg": "^8.20.0", + "tsx": "^4.19.0", + "typescript": "^5.7.0" + } +} diff --git a/examples/pub-sub-live-voting/javascript/server/src/db.ts b/examples/pub-sub-live-voting/javascript/server/src/db.ts new file mode 100644 index 0000000000..47ac7be578 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/server/src/db.ts @@ -0,0 +1,177 @@ +import pg from 'pg'; +import type { OptionInput, ShowStore } from './showStore.js'; + +// Lazily constructed so that static (SHOWS_FILE) deployments never open — or +// even configure — a database connection. +let _pool: pg.Pool | null = null; +function pool(): pg.Pool { + if (!_pool) { + _pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL || 'postgresql://localhost/ably_voting', + }); + } + return _pool; +} + +async function getShows() { + const { rows } = await pool().query(` + SELECT s.id, s.name, COUNT(p.id)::int AS poll_count + FROM shows s + LEFT JOIN polls p ON p.show_id = s.id + GROUP BY s.id + ORDER BY s.id + `); + return rows; +} + +async function createShow(name: string) { + const { rows } = await pool().query( + 'INSERT INTO shows (name) VALUES ($1) RETURNING id, name', + [name], + ); + return rows[0]; +} + +async function updateShow(id: number, name: string) { + const { rowCount } = await pool().query( + 'UPDATE shows SET name = $1 WHERE id = $2', + [name, id], + ); + return rowCount! > 0; +} + +async function deleteShow(id: number) { + const { rowCount } = await pool().query('DELETE FROM shows WHERE id = $1', [id]); + return rowCount! > 0; +} + +async function getShowPolls(showId: number) { + const showResult = await pool().query('SELECT id, name FROM shows WHERE id = $1', [showId]); + if (showResult.rows.length === 0) return null; + + const pollRows = await pool().query( + 'SELECT id, question, type, sort_order FROM polls WHERE show_id = $1 ORDER BY sort_order, id', + [showId], + ); + + const optionRows = await pool().query( + `SELECT po.id, po.poll_id, po.label, po.direction, po.sort_order + FROM poll_options po + JOIN polls p ON p.id = po.poll_id + WHERE p.show_id = $1 + ORDER BY po.sort_order, po.id`, + [showId], + ); + + const optionsByPoll = new Map>(); + for (const opt of optionRows.rows) { + if (!optionsByPoll.has(opt.poll_id)) optionsByPoll.set(opt.poll_id, []); + optionsByPoll.get(opt.poll_id)!.push({ + id: opt.id, label: opt.label, direction: opt.direction, sort_order: opt.sort_order, + }); + } + + const polls = pollRows.rows.map((p) => ({ + id: p.id, + question: p.question, + type: p.type, + sort_order: p.sort_order, + options: optionsByPoll.get(p.id) ?? [], + })); + + return { show: showResult.rows[0], polls }; +} + +async function createPoll(showId: number, type: string, question: string, options: OptionInput[]) { + const client = await pool().connect(); + try { + await client.query('BEGIN'); + + const maxOrder = await client.query( + 'SELECT COALESCE(MAX(sort_order), -1) + 1 AS next FROM polls WHERE show_id = $1', + [showId], + ); + + const pollResult = await client.query( + 'INSERT INTO polls (show_id, question, type, sort_order) VALUES ($1, $2, $3, $4) RETURNING id', + [showId, question, type, maxOrder.rows[0].next], + ); + const pollId = pollResult.rows[0].id; + + for (let i = 0; i < options.length; i++) { + await client.query( + 'INSERT INTO poll_options (poll_id, label, direction, sort_order) VALUES ($1, $2, $3, $4)', + [pollId, options[i].label, options[i].direction ?? null, i], + ); + } + + await client.query('COMMIT'); + return pollId; + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} + +async function updatePoll(pollId: number, type: string, question: string, options: OptionInput[]) { + const client = await pool().connect(); + try { + await client.query('BEGIN'); + + await client.query('UPDATE polls SET question = $1, type = $2 WHERE id = $3', [question, type, pollId]); + await client.query('DELETE FROM poll_options WHERE poll_id = $1', [pollId]); + + for (let i = 0; i < options.length; i++) { + await client.query( + 'INSERT INTO poll_options (poll_id, label, direction, sort_order) VALUES ($1, $2, $3, $4)', + [pollId, options[i].label, options[i].direction ?? null, i], + ); + } + + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} + +async function deletePoll(pollId: number) { + const { rowCount } = await pool().query('DELETE FROM polls WHERE id = $1', [pollId]); + return rowCount! > 0; +} + +async function reorderPolls(showId: number, pollIds: number[]) { + const client = await pool().connect(); + try { + await client.query('BEGIN'); + for (let i = 0; i < pollIds.length; i++) { + await client.query( + 'UPDATE polls SET sort_order = $1 WHERE id = $2 AND show_id = $3', + [i, pollIds[i], showId], + ); + } + await client.query('COMMIT'); + } catch (err) { + await client.query('ROLLBACK'); + throw err; + } finally { + client.release(); + } +} + +export const pgStore: ShowStore = { + readOnly: false, + getShows, + getShowPolls, + createShow, + updateShow, + deleteShow, + createPoll, + updatePoll, + deletePoll, + reorderPolls, +}; diff --git a/examples/pub-sub-live-voting/javascript/server/src/server.ts b/examples/pub-sub-live-voting/javascript/server/src/server.ts new file mode 100644 index 0000000000..a0b586fb57 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/server/src/server.ts @@ -0,0 +1,326 @@ +import express from 'express'; +import cors from 'cors'; +import jwt from 'jsonwebtoken'; +import path from 'path'; +import { existsSync } from 'fs'; +import { fileURLToPath } from 'url'; +import type { OptionInput, ShowStore } from './showStore.js'; +import { pgStore } from './db.js'; +import { createStaticStore } from './staticStore.js'; + +// The backend has two jobs: +// 1. Issue short-lived, role-scoped Ably tokens (GET /auth). This is the +// important bit for the tutorial — a voter token can publish annotations +// and nothing else. +// 2. Serve the poll definitions to the admin (GET /api/shows...), from either +// a static JSON file (SHOWS_FILE) or Postgres. +// It never touches votes — those live entirely as Ably annotations. + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +const ABLY_API_KEY = process.env.ABLY_API_KEY; +if (!ABLY_API_KEY) { + console.error('ABLY_API_KEY environment variable is required'); + process.exit(1); +} + +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD; +if (!ADMIN_PASSWORD) { + console.error('ADMIN_PASSWORD environment variable is required'); + process.exit(1); +} + +const [keyName, keySecret] = ABLY_API_KEY.split(':'); + +// Data source: a JSON file (read-only, no database) when SHOWS_FILE is set, +// otherwise Postgres. Resolved relative to the working directory. +const SHOWS_FILE = process.env.SHOWS_FILE; +let store: ShowStore; +try { + store = SHOWS_FILE ? createStaticStore(path.resolve(SHOWS_FILE)) : pgStore; +} catch (err: any) { + console.error(err.message); + process.exit(1); +} +console.log(store.readOnly + ? `Using read-only static show data from ${SHOWS_FILE}` + : 'Using Postgres show store'); + +const app = express(); + +// The client is a separate Vite app. In production it can be served from the +// same origin (see the static block below); in development it runs on Vite's +// dev server and reaches the API through Vite's proxy. CORS keeps direct +// cross-origin calls working too. +app.use(cors({ origin: true, methods: ['GET', 'POST', 'PUT', 'DELETE'] })); +app.use(express.json()); + +function fail(res: express.Response, label: string, err: any) { + console.error(`${label} failed:`, err); + const message = err?.message || err?.code || String(err); + res.status(500).json({ error: message }); +} + +// Basic auth middleware for admin routes. Only the password half is checked — +// the client sends `Basic base64("admin:")`. +function requireAdmin(req: express.Request, res: express.Response, next: express.NextFunction) { + const auth = req.headers.authorization; + if (!auth?.startsWith('Basic ')) { + res.set('WWW-Authenticate', 'Basic realm="Admin"'); + res.status(401).send('Authentication required'); + return; + } + const decoded = Buffer.from(auth.slice(6), 'base64').toString(); + const password = decoded.split(':').slice(1).join(':'); + if (password !== ADMIN_PASSWORD) { + res.set('WWW-Authenticate', 'Basic realm="Admin"'); + res.status(401).send('Invalid password'); + return; + } + next(); +} + +// Reject writes when the active store is read-only (static demo data). +function requireWritable(_req: express.Request, res: express.Response, next: express.NextFunction) { + if (store.readOnly) { + res.status(403).json({ error: 'This is a read-only demo — editing is disabled.' }); + return; + } + next(); +} + +// Lets the admin client discover whether editing is available (static vs db). +app.get('/api/config', (_req, res) => { + res.json({ readOnly: store.readOnly }); +}); + +// Admin API — all behind basic auth +app.get('/api/shows', requireAdmin, async (_req, res) => { + try { + res.json(await store.getShows()); + } catch (err: any) { + fail(res, 'GET /api/shows', err); + } +}); + +app.post('/api/shows', requireAdmin, requireWritable, async (req, res) => { + const { name } = req.body; + if (!name) { res.status(400).json({ error: 'Missing name' }); return; } + try { + res.json(await store.createShow(name)); + } catch (err: any) { + fail(res, 'POST /api/shows', err); + } +}); + +app.put('/api/shows/:id', requireAdmin, requireWritable, async (req, res) => { + const { name } = req.body; + if (!name) { res.status(400).json({ error: 'Missing name' }); return; } + try { + const ok = await store.updateShow(Number(req.params.id), name); + if (!ok) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ ok: true }); + } catch (err: any) { + fail(res, `PUT /api/shows/${req.params.id}`, err); + } +}); + +app.delete('/api/shows/:id', requireAdmin, requireWritable, async (req, res) => { + try { + const ok = await store.deleteShow(Number(req.params.id)); + if (!ok) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ ok: true }); + } catch (err: any) { + fail(res, `DELETE /api/shows/${req.params.id}`, err); + } +}); + +// Polls within a show — read endpoint is public (presenter needs it) +app.get('/api/shows/:showId/polls', async (req, res) => { + try { + const result = await store.getShowPolls(Number(req.params.showId)); + if (!result) { res.status(404).json({ error: 'Show not found' }); return; } + // Convert numeric option IDs to strings for Ably annotation compatibility + const polls = result.polls.map((p) => ({ + ...p, + options: p.options.map((o) => ({ + id: String(o.id), + label: o.label, + ...(o.direction ? { direction: o.direction } : {}), + })), + })); + res.json({ show: result.show, polls }); + } catch (err: any) { + fail(res, `GET /api/shows/${req.params.showId}/polls`, err); + } +}); + +const DPAD_SLOTS = new Set(['up', 'right', 'down', 'left']); + +const VALID_TYPES = new Set(['list', 'dpad', 'suggest']); +const ALL_SLOTS = new Set(DPAD_SLOTS); + +function validatePollInput(body: any): { type: string; question: string; options: OptionInput[] } | string { + const { question } = body; + const type = body.type ?? 'list'; + if (!VALID_TYPES.has(type)) return 'Invalid poll type'; + if (!question || typeof question !== 'string') return 'Missing question'; + + if (type === 'suggest') { + return { type, question, options: [] }; + } + + const options = body.options; + if (!Array.isArray(options) || options.length === 0) return 'Missing options'; + + const normalized: OptionInput[] = []; + const seenDirections = new Set(); + for (const o of options) { + if (typeof o === 'string') { + normalized.push({ label: o }); + } else if (o && typeof o.label === 'string') { + const direction = o.direction ?? null; + if (direction !== null) { + if (!ALL_SLOTS.has(direction)) return `Invalid direction: ${direction}`; + if (seenDirections.has(direction)) return `Duplicate direction: ${direction}`; + seenDirections.add(direction); + } + normalized.push({ label: o.label, direction }); + } else { + return 'Invalid option entry'; + } + } + + if (type === 'dpad') { + if (normalized.some((o) => !o.direction)) return 'Every d-pad option needs a slot'; + if (normalized.some((o) => o.direction && !DPAD_SLOTS.has(o.direction))) { + return 'Invalid slot for d-pad poll'; + } + if (normalized.length > DPAD_SLOTS.size) return `A d-pad poll has at most ${DPAD_SLOTS.size} options`; + } + + return { type, question, options: normalized }; +} + +app.post('/api/shows/:showId/polls', requireAdmin, requireWritable, async (req, res) => { + const parsed = validatePollInput(req.body); + if (typeof parsed === 'string') { res.status(400).json({ error: parsed }); return; } + try { + const pollId = await store.createPoll(Number(req.params.showId), parsed.type, parsed.question, parsed.options); + res.json({ id: pollId }); + } catch (err: any) { + fail(res, `POST /api/shows/${req.params.showId}/polls`, err); + } +}); + +app.put('/api/shows/:showId/polls/reorder', requireAdmin, requireWritable, async (req, res) => { + const { order } = req.body; + if (!Array.isArray(order)) { + res.status(400).json({ error: 'Missing order array' }); + return; + } + try { + await store.reorderPolls(Number(req.params.showId), order); + res.json({ ok: true }); + } catch (err: any) { + fail(res, `PUT /api/shows/${req.params.showId}/polls/reorder`, err); + } +}); + +app.put('/api/shows/:showId/polls/:pollId', requireAdmin, requireWritable, async (req, res) => { + const parsed = validatePollInput(req.body); + if (typeof parsed === 'string') { res.status(400).json({ error: parsed }); return; } + try { + await store.updatePoll(Number(req.params.pollId), parsed.type, parsed.question, parsed.options); + res.json({ ok: true }); + } catch (err: any) { + fail(res, `PUT /api/shows/${req.params.showId}/polls/${req.params.pollId}`, err); + } +}); + +app.delete('/api/shows/:showId/polls/:pollId', requireAdmin, requireWritable, async (req, res) => { + try { + const ok = await store.deletePoll(Number(req.params.pollId)); + if (!ok) { res.status(404).json({ error: 'Not found' }); return; } + res.json({ ok: true }); + } catch (err: any) { + fail(res, `DELETE /api/shows/${req.params.showId}/polls/${req.params.pollId}`, err); + } +}); + +// ── Ably token auth ── +// +// The heart of the security model. Each role gets a JWT whose capabilities are +// scoped to exactly what that role needs on this one channel: +// - voter: annotation-publish only (cast votes; can't read others' votes +// or publish poll messages) +// - presenter: annotation-subscribe + subscribe (read-only) +// - admin: publish + subscribe + annotation-subscribe (drives the show) +// The clientId baked into the token is what makes `vote:unique.v1` enforce one +// vote per person. +app.get('/auth', (req, res) => { + const clientId = req.query.clientId as string; + const sessionId = req.query.sessionId as string; + const role = req.query.role as string; + + if (!clientId || !sessionId || !role) { + res.status(400).json({ error: 'Missing clientId, sessionId, or role' }); + return; + } + + if (role === 'admin') { + const password = req.query.password as string; + if (password !== ADMIN_PASSWORD) { + res.status(403).json('Invalid admin password'); + return; + } + } + + const channelName = `voting:${sessionId}`; + const capsByRole: Record = { + admin: ['publish', 'subscribe', 'annotation-subscribe'], + voter: ['subscribe', 'annotation-publish'], + presenter: ['subscribe', 'annotation-subscribe'], + }; + const caps = capsByRole[role]; + if (!caps) { + res.status(400).json({ error: 'Invalid role' }); + return; + } + const capabilities: Record = { [channelName]: caps }; + + const now = Math.floor(Date.now() / 1000); + const token = jwt.sign( + { + 'x-ably-capability': JSON.stringify(capabilities), + 'x-ably-clientId': clientId, + iat: now, + exp: now + 3600, + }, + keySecret, + { + header: { + typ: 'JWT', + alg: 'HS256', + kid: keyName, + }, + }, + ); + + res.set('Content-Type', 'application/jwt'); + res.send(token); +}); + +// Optionally serve the built client (../../dist, i.e. the javascript/ project +// root) from the same origin, so a production deploy needs only this one +// server. In development you'd run the Vite dev server instead and let it proxy +// the API here. +const clientDist = path.resolve(__dirname, '../../dist'); +if (existsSync(clientDist)) { + app.use(express.static(clientDist)); +} + +const PORT = process.env.PORT || 3000; +app.listen(PORT, () => { + console.log(`Server running on http://localhost:${PORT}`); +}); diff --git a/examples/pub-sub-live-voting/javascript/server/src/showStore.ts b/examples/pub-sub-live-voting/javascript/server/src/showStore.ts new file mode 100644 index 0000000000..67c00ff7dc --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/server/src/showStore.ts @@ -0,0 +1,54 @@ +// Data layer behind the admin API. Two implementations: a Postgres store +// (db.ts) for full read/write, and a read-only static store (staticStore.ts) +// backed by a JSON file for DB-less demo deployments. The server picks one at +// startup based on the SHOWS_FILE env var. + +export type Slot = 'up' | 'right' | 'down' | 'left'; + +export interface OptionInput { + label: string; + direction?: Slot | null; +} + +export interface ShowSummary { + id: number; + name: string; + poll_count: number; +} + +export interface StoredOption { + id: number; + label: string; + direction: string | null; + sort_order: number; +} + +export interface StoredPoll { + id: number; + question: string; + type: string; + sort_order: number; + options: StoredOption[]; +} + +export interface ShowPolls { + show: { id: number; name: string }; + polls: StoredPoll[]; +} + +export interface ShowStore { + /** When true, the write methods are unavailable and the admin UI is read-only. */ + readonly readOnly: boolean; + + getShows(): Promise; + getShowPolls(showId: number): Promise; + + createShow(name: string): Promise<{ id: number; name: string }>; + updateShow(id: number, name: string): Promise; + deleteShow(id: number): Promise; + + createPoll(showId: number, type: string, question: string, options: OptionInput[]): Promise; + updatePoll(pollId: number, type: string, question: string, options: OptionInput[]): Promise; + deletePoll(pollId: number): Promise; + reorderPolls(showId: number, pollIds: number[]): Promise; +} diff --git a/examples/pub-sub-live-voting/javascript/server/src/staticStore.ts b/examples/pub-sub-live-voting/javascript/server/src/staticStore.ts new file mode 100644 index 0000000000..c22196795e --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/server/src/staticStore.ts @@ -0,0 +1,92 @@ +import { readFileSync } from 'fs'; +import type { + ShowStore, ShowSummary, ShowPolls, StoredPoll, StoredOption, +} from './showStore.js'; + +// JSON authoring format (see ../../data/demo-shows.json): +// { "shows": [ { "id", "name", "polls": [ +// { "id", "question", "type", "options": [ { "id", "label", "direction"? } ] } +// ] } ] } +interface RawOption { id: number; label: string; direction?: string | null; } +interface RawPoll { id: number; question: string; type: string; options?: RawOption[]; } +interface RawShow { id: number; name: string; polls?: RawPoll[]; } + +const VALID_TYPES = new Set(['list', 'dpad', 'suggest']); + +function readonlyWrite(): never { + throw new Error('Static show store is read-only; editing is disabled.'); +} + +function parseShows(filePath: string): RawShow[] { + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(filePath, 'utf-8')); + } catch (err: any) { + throw new Error(`Failed to read SHOWS_FILE "${filePath}": ${err.message}`); + } + + const shows = (parsed as { shows?: unknown })?.shows; + if (!Array.isArray(shows)) { + throw new Error(`SHOWS_FILE "${filePath}" must contain a top-level "shows" array.`); + } + + for (const show of shows as RawShow[]) { + if (typeof show.id !== 'number' || typeof show.name !== 'string') { + throw new Error('Each show needs a numeric "id" and a string "name".'); + } + for (const poll of show.polls ?? []) { + if (typeof poll.id !== 'number' || typeof poll.question !== 'string') { + throw new Error(`Show ${show.id}: each poll needs a numeric "id" and a string "question".`); + } + if (!VALID_TYPES.has(poll.type)) { + throw new Error(`Show ${show.id}, poll ${poll.id}: invalid type "${poll.type}".`); + } + for (const opt of poll.options ?? []) { + if (typeof opt.id !== 'number' || typeof opt.label !== 'string') { + throw new Error(`Show ${show.id}, poll ${poll.id}: each option needs a numeric "id" and a string "label".`); + } + } + } + } + + return shows as RawShow[]; +} + +export function createStaticStore(filePath: string): ShowStore { + const shows = parseShows(filePath); + const byId = new Map(shows.map((s) => [s.id, s])); + + return { + readOnly: true, + + async getShows(): Promise { + return shows.map((s) => ({ id: s.id, name: s.name, poll_count: (s.polls ?? []).length })); + }, + + async getShowPolls(showId: number): Promise { + const show = byId.get(showId); + if (!show) return null; + const polls: StoredPoll[] = (show.polls ?? []).map((p, pi) => ({ + id: p.id, + question: p.question, + type: p.type, + sort_order: pi, + options: (p.options ?? []).map((o, oi): StoredOption => ({ + id: o.id, + label: o.label, + direction: o.direction ?? null, + sort_order: oi, + })), + })); + return { show: { id: show.id, name: show.name }, polls }; + }, + + createShow: readonlyWrite, + updateShow: readonlyWrite, + deleteShow: readonlyWrite, + createPoll: readonlyWrite, + updatePoll: readonlyWrite, + deletePoll: readonlyWrite, + reorderPolls: readonlyWrite, + }; +} diff --git a/examples/pub-sub-live-voting/javascript/server/tsconfig.json b/examples/pub-sub-live-voting/javascript/server/tsconfig.json new file mode 100644 index 0000000000..2cb7b6f25c --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/server/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "lib": ["ES2022"], + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules", "dist"] +} diff --git a/examples/pub-sub-live-voting/javascript/src/admin.ts b/examples/pub-sub-live-voting/javascript/src/admin.ts new file mode 100644 index 0000000000..59965423cc --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/admin.ts @@ -0,0 +1,986 @@ +import * as Ably from 'ably'; +import { createAblyClient, getChannel } from './shared/ably'; +import { renderChart } from './shared/chart'; +import { displayName } from './shared/clientId'; +import { showError } from './shared/errors'; +import { generateSessionId } from './shared/sessionId'; +import { IS_SANDBOX } from './config'; +import { + VOTE_ANNOTATION_TYPE, + SUGGESTION_ANNOTATION_TYPE, + SESSION_KEY, + AdminState, + PollMessage, + PollType, + ControlMessage, + PollOption, + PollDefinition, + ControllerKind, + SLOTS_BY_KIND, + isControllerKind, +} from './shared/types'; +import { slotGlyph } from './shared/controller'; + +// The operator console. It drives the show — it's the only role that *publishes +// poll messages*. Voters and the presenter only react to them. This view talks +// to the backend (the shows API + token auth), so it's not part of the +// in-browser sandbox demo; clone the repo and run the server to use it. + +const VIEW_HTML = ` +
+ + + + +
+
+
+

Shows

+
    +
    + + +
    +
    + +
    +

    Select a show to edit its polls.

    +
    +
    +
    + + +
    +
    +

    Run a Show

    +
    + + + +
    +
    +
    + + +
    +
    +
    +
    +

    Session:

    +

    Show:

    +

    +
    + +
    + + + + + +
    + +
    + + +
    +
    + Open Presenter +
    + +
    +
    + +
    +

    Ready to start

    +
    +

    +
    +
    +
    +
    +`; + +// ── State ── + +let state: AdminState = 'manage'; +let ablyClient: Ably.Realtime | null = null; +let channel: Ably.RealtimeChannel | null = null; +let sessionId: string; + +// Show runner state +let runningShowId: number | null = null; +let showPolls: PollDefinition[] = []; +let showName = ''; +let currentPollIndex = 0; +let currentPollSerial: string | null = null; +let currentPollId: number | null = null; +let currentPollType: PollType = 'list'; +let currentPollOptions: PollOption[] = []; +let currentSummary: Ably.SummaryUniqueValues | null = null; +let suggestListener: ((a: Ably.Annotation) => void) | null = null; + +// Poll editor state +let selectedShowId: number | null = null; + +// Whether the active show store is read-only (static demo data). Fetched from +// the server's /api/config at startup. When true, editing controls are hidden +// and a notice is shown; running a show still works fully. +let readOnly = false; + +const $ = (sel: string) => document.querySelector(sel) as HTMLElement; + +// Build a URL on the current origin that carries the session id. +// Path "/" is the voter URL (encoded into the QR); "/?role=presenter" is the +// presenter URL. Both flow from admin → voter/screen, so they share the shape. +function buildSessionUrl(path: string): string { + return `${path}?${SESSION_KEY}=${sessionId}`; +} + +// ── Admin password ── +// +// The shows API and the admin Ably token are both gated on a password. The +// server serves the client statically, so (unlike a server-rendered admin +// page) the password isn't injected — we prompt for it once and cache it for +// the tab. It's sent as HTTP Basic auth on API calls and as the `password` +// auth param when minting the admin Ably token. +const PASSWORD_KEY = 'admin-password'; + +function getAdminPassword(): string { + let pw = sessionStorage.getItem(PASSWORD_KEY); + if (!pw) { + pw = window.prompt('Admin password') || ''; + if (pw) sessionStorage.setItem(PASSWORD_KEY, pw); + } + return pw; +} + +function authHeader(): Record { + // requireAdmin only checks the password half of the Basic credentials. + return { Authorization: `Basic ${btoa(`admin:${getAdminPassword()}`)}` }; +} + +// ── API helpers ── + +async function api(method: string, path: string, body?: any) { + let res: Response; + try { + res = await fetch(path, { + method, + headers: { + ...authHeader(), + ...(body ? { 'Content-Type': 'application/json' } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + }); + } catch (err: any) { + const message = err?.message || String(err); + showError(`${method} ${path}: ${message}`); + throw err; + } + if (res.status === 401) { + // Wrong/blank password — drop the cached value so the next attempt re-prompts. + sessionStorage.removeItem(PASSWORD_KEY); + } + if (!res.ok) { + const err = await res.json().catch(() => ({})); + const message = err.error || res.statusText || `Request failed (${res.status})`; + showError(`${method} ${path} → ${res.status}: ${message}`); + throw new Error(message); + } + return res.json(); +} + +// ── Tab switching ── + +function setState(newState: AdminState) { + state = newState; + document.body.dataset.state = newState; + if (newState !== 'manage') { + updateShowControls(); + } + if (newState === 'manage' || newState === 'setup') { + clearSavedShowState(); + } else { + saveShowState(); + } +} + +const ADMIN_SHOW_STATE_KEY = 'admin-show-state'; + +interface SavedShowState { + showId: number; + sessionId: string; + state: AdminState; + currentPollIndex: number; +} + +function saveShowState() { + if (runningShowId === null || !sessionId) return; + try { + const data: SavedShowState = { + showId: runningShowId, + sessionId, + state, + currentPollIndex, + }; + sessionStorage.setItem(ADMIN_SHOW_STATE_KEY, JSON.stringify(data)); + } catch {} +} + +function clearSavedShowState() { + try { + sessionStorage.removeItem(ADMIN_SHOW_STATE_KEY); + } catch {} +} + +function loadSavedShowState(): SavedShowState | null { + try { + const raw = sessionStorage.getItem(ADMIN_SHOW_STATE_KEY); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function initTabs() { + const tabManage = $('#tab-manage'); + const tabShow = $('#tab-show'); + + tabManage.addEventListener('click', () => { + tabManage.classList.add('active'); + tabManage.setAttribute('aria-selected', 'true'); + tabShow.classList.remove('active'); + tabShow.setAttribute('aria-selected', 'false'); + setState('manage'); + loadShowList(); + }); + + tabShow.addEventListener('click', async () => { + tabShow.classList.add('active'); + tabShow.setAttribute('aria-selected', 'true'); + tabManage.classList.remove('active'); + tabManage.setAttribute('aria-selected', 'false'); + if (channel) { + // Already running a show, restore the active state + setState(state === 'manage' ? 'ready' : state); + } else { + await populateShowSelect(); + setState('setup'); + } + }); + + tabManage.addEventListener('keydown', (e) => { + if (e.key === 'ArrowRight') { tabShow.focus(); tabShow.click(); } + }); + tabShow.addEventListener('keydown', (e) => { + if (e.key === 'ArrowLeft') { tabManage.focus(); tabManage.click(); } + }); +} + +// ── Poll Management ── + +async function loadShowList() { + const shows = await api('GET', '/api/shows'); + const list = $('#show-list'); + list.innerHTML = ''; + for (const show of shows) { + const li = document.createElement('li'); + li.role = 'option'; + li.tabIndex = 0; + li.dataset.showId = String(show.id); + li.classList.toggle('selected', show.id === selectedShowId); + li.innerHTML = ` + ${esc(show.name)} + ${show.poll_count} polls + `; + li.addEventListener('click', () => selectShow(show.id)); + li.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); selectShow(show.id); } + if (e.key === 'ArrowDown') { (li.nextElementSibling as HTMLElement)?.focus(); } + if (e.key === 'ArrowUp') { (li.previousElementSibling as HTMLElement)?.focus(); } + }); + list.appendChild(li); + } +} + +async function selectShow(showId: number) { + selectedShowId = showId; + // Highlight in list + document.querySelectorAll('#show-list li').forEach((li) => { + (li as HTMLElement).classList.toggle('selected', (li as HTMLElement).dataset.showId === String(showId)); + }); + await renderPollEditor(showId); +} + +async function renderPollEditor(showId: number) { + const data = await api('GET', `/api/shows/${showId}/polls`); + const main = $('#manage-main'); + + let html = `${readonlyBanner()}
    +
    +

    ${esc(data.show.name)}

    + +
    `; + + for (const poll of data.polls) { + html += renderPollCard(poll); + } + + html += `
    + +
    + + ${renderPollTypeSelect('new-poll-type', 'list')} +
    +
    + +
    + ${renderSlotInputs('dpad', 'new-poll', {}, true)} + + +
    `; + + main.innerHTML = html; + bindPollEditorEvents(showId, data.polls); +} + +const POLL_TYPE_LABELS: Record = { + list: 'List', + dpad: 'D-pad', + suggest: 'Suggest', +}; + +function renderPollTypeSelect(id: string, selected: PollType): string { + const opts = (Object.keys(POLL_TYPE_LABELS) as PollType[]).map((t) => + ``, + ).join(''); + return ``; +} + +function renderSlotInputs(kind: ControllerKind, idPrefix: string, bySlot: Record, startHidden: boolean): string { + const inputs = SLOTS_BY_KIND[kind].map((s) => + ``, + ).join(''); + const hiddenCls = startHidden ? ' hidden' : ''; + return `
    +
    ${inputs}
    +
    `; +} + +function renderPollCard(poll: any): string { + const type: PollType = poll.type ?? 'list'; + const listValue = poll.options.map((o: any) => esc(o.label)).join(', '); + const bySlot: Record = {}; + for (const o of poll.options) { + if (o.direction) bySlot[o.direction] = o.label; + } + const slotEditors = (['dpad'] as ControllerKind[]) + .map((k) => renderSlotInputs(k, `poll-${poll.id}`, k === type ? bySlot : {}, k !== type)) + .join(''); + return `
    +
    + + ${renderPollTypeSelect(`poll-${poll.id}-type`, type)} +
    + + + +
    +
    +
    + +
    + ${slotEditors} +
    +

    Voters will type their own suggestions. No options to set.

    +
    +
    + +
    +
    `; +} + +function readPollEditor(card: Element): { type: PollType; options: { label: string; direction?: string }[] } | null { + const type = (card.querySelector('.poll-type-select') as HTMLSelectElement).value as PollType; + if (type === 'suggest') { + return { type, options: [] }; + } + if (isControllerKind(type)) { + const options: { label: string; direction: string }[] = []; + const editor = card.querySelector(`.poll-options-${type}`); + editor?.querySelectorAll('.slot-input').forEach((inp) => { + const label = inp.value.trim(); + if (label) options.push({ label, direction: inp.dataset.direction! }); + }); + if (options.length === 0) return null; + return { type, options }; + } else { + const optionsStr = (card.querySelector('.poll-options-input') as HTMLInputElement).value; + const options = optionsStr.split(',').map((s) => ({ label: s.trim() })).filter((o) => o.label); + if (options.length === 0) return null; + return { type, options }; + } +} + +function bindPollTypeSwitch(card: Element) { + const select = card.querySelector('.poll-type-select') as HTMLSelectElement; + const sections: Array<[string, string]> = [ + ['.poll-options-list', 'list'], + ['.poll-options-dpad', 'dpad'], + ['.poll-options-suggest', 'suggest'], + ]; + select?.addEventListener('change', () => { + (card as HTMLElement).dataset.pollType = select.value; + for (const [sel, kind] of sections) { + card.querySelector(sel)?.classList.toggle('hidden', select.value !== kind); + } + }); +} + +function bindPollEditorEvents(showId: number, polls: any[]) { + // Show name editing + const nameEl = $('#show-name-edit'); + nameEl?.addEventListener('blur', async () => { + const newName = nameEl.textContent?.trim(); + if (newName) { + await api('PUT', `/api/shows/${showId}`, { name: newName }); + loadShowList(); + } + }); + nameEl?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { e.preventDefault(); nameEl.blur(); } + }); + + // Delete show + $('#btn-delete-show')?.addEventListener('click', async () => { + if (!confirm('Delete this show and all its polls?')) return; + await api('DELETE', `/api/shows/${showId}`); + selectedShowId = null; + $('#manage-main').innerHTML = '

    Select a show to edit its polls.

    '; + loadShowList(); + }); + + // Per-poll save, delete, reorder + document.querySelectorAll('.poll-card').forEach((card, idx) => { + const pollId = Number((card as HTMLElement).dataset.pollId); + bindPollTypeSwitch(card); + + card.querySelector('.btn-save-poll')?.addEventListener('click', async () => { + const question = (card.querySelector('.poll-question-input') as HTMLInputElement).value.trim(); + const parsed = readPollEditor(card); + if (!question || !parsed) return; + await api('PUT', `/api/shows/${showId}/polls/${pollId}`, { + question, type: parsed.type, options: parsed.options, + }); + await renderPollEditor(showId); + }); + + card.querySelector('.btn-delete-poll')?.addEventListener('click', async () => { + await api('DELETE', `/api/shows/${showId}/polls/${pollId}`); + await renderPollEditor(showId); + }); + + card.querySelector('.btn-move-up')?.addEventListener('click', async () => { + if (idx === 0) return; + const order = polls.map((p: any) => p.id); + [order[idx - 1], order[idx]] = [order[idx], order[idx - 1]]; + await api('PUT', `/api/shows/${showId}/polls/reorder`, { order }); + await renderPollEditor(showId); + }); + + card.querySelector('.btn-move-down')?.addEventListener('click', async () => { + if (idx === polls.length - 1) return; + const order = polls.map((p: any) => p.id); + [order[idx], order[idx + 1]] = [order[idx + 1], order[idx]]; + await api('PUT', `/api/shows/${showId}/polls/reorder`, { order }); + await renderPollEditor(showId); + }); + }); + + // Add poll + const addCard = document.querySelector('.add-poll-card'); + if (addCard) bindPollTypeSwitch(addCard); + $('#btn-add-poll')?.addEventListener('click', async () => { + const question = ($('#new-poll-question') as HTMLInputElement).value.trim(); + if (!addCard) return; + const parsed = readPollEditor(addCard); + if (!question || !parsed) return; + await api('POST', `/api/shows/${showId}/polls`, { + question, type: parsed.type, options: parsed.options, + }); + await renderPollEditor(showId); + loadShowList(); + }); +} + +function esc(s: string): string { + const d = document.createElement('div'); + d.textContent = s; + return d.innerHTML; +} + +async function initManage() { + await loadShowList(); + + $('#btn-add-show').addEventListener('click', async () => { + const input = $('#new-show-name') as HTMLInputElement; + const name = input.value.trim(); + if (!name) return; + await api('POST', '/api/shows', { name }); + input.value = ''; + await loadShowList(); + }); + + // Allow Enter to submit + $('#new-show-name').addEventListener('keydown', (e) => { + if ((e as KeyboardEvent).key === 'Enter') { + e.preventDefault(); + ($('#btn-add-show') as HTMLButtonElement).click(); + } + }); +} + +// ── Show Runner ── + +async function populateShowSelect() { + const shows = await api('GET', '/api/shows'); + const select = $('#show-select') as HTMLSelectElement; + select.innerHTML = ''; + for (const show of shows) { + const opt = document.createElement('option'); + opt.value = String(show.id); + opt.textContent = `${show.name} (${show.poll_count} polls)`; + select.appendChild(opt); + } +} + +function updateShowControls() { + const totalPolls = showPolls.length; + + $('#poll-progress').textContent = (state === 'setup' || state === 'manage') + ? '' + : `Poll ${currentPollIndex + 1} of ${totalPolls}`; + + $('#btn-start').classList.toggle('hidden', state !== 'ready'); + $('#btn-close').classList.toggle('hidden', state !== 'poll-open'); + $('#btn-clear-poll').classList.toggle('hidden', state !== 'poll-closed'); + $('#btn-next').classList.toggle('hidden', state !== 'show-qr'); + $('#btn-clear-suggestions').classList.toggle( + 'hidden', + !(currentPollType === 'suggest' && (state === 'poll-open' || state === 'poll-closed')), + ); + + if (state === 'show-qr' && currentPollIndex >= totalPolls - 1) { + $('#btn-next').textContent = 'Finish'; + } else { + $('#btn-next').textContent = 'Next Poll'; + } +} + +function populateJumpSelect() { + const select = $('#jump-select') as HTMLSelectElement; + select.innerHTML = ''; + + const startOpt = document.createElement('option'); + startOpt.value = 'start'; + startOpt.textContent = 'Start (QR / waiting)'; + select.appendChild(startOpt); + + for (let i = 0; i < showPolls.length; i++) { + const opt = document.createElement('option'); + opt.value = String(i); + opt.textContent = `${i + 1}. ${showPolls[i].question}`; + select.appendChild(opt); + } +} + +function handleMessage(msg: Ably.Message) { + if (msg.name === 'poll') { + const data = msg.data as PollMessage; + const summary = msg.annotations?.summary?.[VOTE_ANNOTATION_TYPE] as Ably.SummaryUniqueValues | undefined; + + if (msg.serial !== currentPollSerial) { + currentPollSerial = msg.serial!; + currentPollId = data.pollId; + currentPollOptions = data.options; + currentPollType = data.type ?? 'list'; + + const idx = showPolls.findIndex((p) => p.id === data.pollId); + if (idx !== -1) currentPollIndex = idx; + + currentSummary = summary ?? null; + + $('#admin-question').textContent = data.question; + + unsubscribeAdminSuggestions(); + if (currentPollType === 'suggest') { + renderAdminSuggestList([]); + subscribeAdminSuggestions(); + } else { + renderChart($('#chart'), currentPollOptions, currentSummary, 'both'); + } + updateTotalVotes(); + updateShowControls(); + } else if (summary && currentPollType !== 'suggest') { + currentSummary = summary; + renderChart($('#chart'), currentPollOptions, currentSummary, 'both'); + updateTotalVotes(); + } + } else if (msg.name === 'control') { + const data = msg.data as ControlMessage; + if (data.action === 'clear-suggestions') { + renderAdminSuggestList([]); + return; + } + if (data.pollId === currentPollId) { + if (data.action === 'close') setState('poll-closed'); + else if (data.action === 'show-qr') setState('show-qr'); + } + } +} + +let adminSuggestList: { text: string; from: string }[] = []; + +function renderAdminSuggestList(items: { text: string; from: string }[]) { + adminSuggestList = items; + const chart = $('#chart'); + chart.innerHTML = ''; + const ul = document.createElement('ul'); + ul.className = 'admin-suggest-list'; + for (const item of items) { + const li = document.createElement('li'); + const t = document.createElement('span'); + t.className = 'admin-suggest-text'; + t.textContent = item.text; + const f = document.createElement('span'); + f.className = 'admin-suggest-from'; + f.textContent = item.from; + li.appendChild(t); + li.appendChild(f); + ul.appendChild(li); + } + chart.appendChild(ul); +} + +function subscribeAdminSuggestions() { + if (!channel || suggestListener) return; + const handler = (annotation: Ably.Annotation) => { + if (annotation.action === 'annotation.delete') return; + const text = annotation.name; + const cid = annotation.clientId; + if (!text || !cid) return; + renderAdminSuggestList([{ text, from: displayName(cid) }, ...adminSuggestList]); + }; + suggestListener = handler; + channel.annotations.subscribe(SUGGESTION_ANNOTATION_TYPE, handler); +} + +function unsubscribeAdminSuggestions() { + if (channel && suggestListener) { + channel.annotations.unsubscribe(SUGGESTION_ANNOTATION_TYPE, suggestListener); + } + suggestListener = null; + adminSuggestList = []; +} + +async function startPoll() { + if (!channel) return; + const poll = showPolls[currentPollIndex]; + currentPollOptions = poll.options; + currentPollId = poll.id; + currentSummary = null; + + try { + // Starting a poll is just publishing a message. Voters and the presenter + // attach their annotations to *this* message's serial. + await channel.publish('poll', { + pollId: poll.id, + question: poll.question, + type: poll.type, + options: poll.options, + } satisfies PollMessage); + } catch (err: any) { + showError(`Failed to start poll: ${err.message || err}`); + return; + } + + $('#admin-question').textContent = poll.question; + if (poll.type === 'suggest') { + renderAdminSuggestList([]); + } else { + renderChart($('#chart'), currentPollOptions, null, 'both'); + } + updateTotalVotes(); + setState('poll-open'); +} + +async function closeVoting() { + const pollId = currentPollId; + if (!channel || pollId == null) return; + try { + await channel.publish('control', { + action: 'close', + pollId, + } satisfies ControlMessage); + setState('poll-closed'); + } catch (err: any) { + showError(`Failed to close voting: ${err.message || err}`); + } +} + +async function clearPoll() { + const pollId = currentPollId; + if (!channel || pollId == null) return; + const voterUrl = buildSessionUrl(`${window.location.origin}/`); + try { + await channel.publish('control', { + action: 'show-qr', + pollId, + voterUrl, + } satisfies ControlMessage); + setState('show-qr'); + } catch (err: any) { + showError(`Failed to clear poll: ${err.message || err}`); + } +} + +function nextPoll() { + currentPollIndex++; + if (currentPollIndex < showPolls.length) { + startPoll(); + } else { + currentPollIndex = 0; + setState('ready'); + $('#admin-question').textContent = 'All polls complete!'; + $('#chart').innerHTML = ''; + $('#vote-count').textContent = ''; + } +} + +async function jumpToSection() { + const value = ($('#jump-select') as HTMLSelectElement).value; + if (value === 'start') { + if (!channel) return; + const voterUrl = buildSessionUrl(`${window.location.origin}/`); + try { + await channel.publish('control', { + action: 'reset', + voterUrl, + } satisfies ControlMessage); + } catch (err: any) { + showError(`Failed to jump to start: ${err.message || err}`); + return; + } + currentPollIndex = 0; + currentPollId = null; + currentPollSerial = null; + currentPollOptions = []; + currentSummary = null; + $('#admin-question').textContent = 'Ready to start'; + $('#chart').innerHTML = ''; + $('#vote-count').textContent = ''; + setState('ready'); + return; + } + currentPollIndex = parseInt(value, 10); + startPoll(); +} + +function updateTotalVotes() { + const total = currentPollOptions.reduce( + (sum, opt) => sum + (currentSummary?.[opt.id]?.total ?? 0), + 0, + ); + $('#vote-count').textContent = `${total} vote${total !== 1 ? 's' : ''}`; +} + +async function connectToShow( + showId: number, + opts?: { sessionId?: string; initialState?: AdminState; pollIndex?: number }, +) { + const data = await api('GET', `/api/shows/${showId}/polls`); + showPolls = data.polls; + showName = data.show.name; + runningShowId = showId; + + if (showPolls.length === 0) { + showError('This show has no polls. Add some in the Manage Polls tab.'); + return; + } + + sessionId = opts?.sessionId ?? generateSessionId(); + + if (opts?.pollIndex !== undefined) { + currentPollIndex = opts.pollIndex; + } + + $('#session-display').textContent = sessionId; + $('#show-display').textContent = showName; + const presenterUrl = `${window.location.origin}/?role=presenter&${SESSION_KEY}=${sessionId}`; + const presenterLink = $('#presenter-link') as HTMLAnchorElement; + presenterLink.href = presenterUrl; + // Open in a dedicated popup window (not just a tab) so the presenter view + // is visually separable for screen-sharing. Modifier-clicks fall through to + // the browser's normal new-tab behavior. The named target reuses the same + // window across repeat clicks rather than spawning more. + presenterLink.onclick = (e) => { + if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return; + e.preventDefault(); + window.open(presenterUrl, 'ably-voting-presenter', 'popup,width=1280,height=800'); + }; + + populateJumpSelect(); + + const password = getAdminPassword(); + + const clientId = `admin-${sessionId}`; + ablyClient = createAblyClient(clientId, sessionId, 'admin', { password }); + channel = getChannel(ablyClient, sessionId, 'admin'); + channel.subscribe(handleMessage); + + setState(opts?.initialState ?? 'ready'); +} + +function initShowRunner() { + const form = $('#setup-form') as HTMLFormElement; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const showId = parseInt(($('#show-select') as HTMLSelectElement).value, 10); + await connectToShow(showId); + }); + + $('#btn-start').addEventListener('click', startPoll); + $('#btn-close').addEventListener('click', closeVoting); + $('#btn-clear-poll').addEventListener('click', clearPoll); + $('#btn-next').addEventListener('click', nextPoll); + $('#btn-jump').addEventListener('click', jumpToSection); + $('#btn-end-show').addEventListener('click', endShow); + $('#btn-clear-suggestions').addEventListener('click', clearSuggestions); +} + +async function clearSuggestions() { + if (!channel) return; + try { + await channel.publish('control', { + action: 'clear-suggestions', + pollId: currentPollId!, + } satisfies ControlMessage); + renderAdminSuggestList([]); + } catch (err: any) { + showError(`Failed to clear: ${err.message || err}`); + } +} + +async function endShow() { + if (!confirm('End the show? Voters and the presenter will see a closing screen.')) return; + + if (channel) { + try { + await channel.publish('control', { action: 'end' } satisfies ControlMessage); + } catch (err: any) { + showError(`Failed to end show: ${err.message || err}`); + return; + } + } + + unsubscribeAdminSuggestions(); + if (ablyClient) { + try { ablyClient.close(); } catch {} + } + ablyClient = null; + channel = null; + + runningShowId = null; + showPolls = []; + showName = ''; + currentPollIndex = 0; + currentPollSerial = null; + currentPollId = null; + currentPollType = 'list'; + currentPollOptions = []; + currentSummary = null; + + await populateShowSelect(); + setState('setup'); +} + +// ── Init ── + +function readonlyBanner(): string { + return readOnly + ? '

    Read-only demo — these polls come from a static data file and can’t be edited here. Switch to “Run Show” to run them.

    ' + : ''; +} + +async function init() { + try { + const cfg = await api('GET', '/api/config'); + readOnly = cfg.readOnly === true; + } catch { + // Non-fatal: assume writable and let individual writes surface errors. + } + + if (readOnly) { + document.body.dataset.readonly = 'true'; + $('#manage-main').innerHTML = `${readonlyBanner()}

    Select a show to view its polls.

    `; + } + initTabs(); + initManage(); + initShowRunner(); + + const saved = loadSavedShowState(); + if (saved) { + activateShowTab(); + try { + await connectToShow(saved.showId, { + sessionId: saved.sessionId, + initialState: saved.state, + pollIndex: saved.currentPollIndex, + }); + } catch (err: any) { + showError(`Failed to resume show: ${err.message || err}`); + clearSavedShowState(); + } + } +} + +function activateShowTab() { + const tabManage = $('#tab-manage'); + const tabShow = $('#tab-show'); + tabShow.classList.add('active'); + tabShow.setAttribute('aria-selected', 'true'); + tabManage.classList.remove('active'); + tabManage.setAttribute('aria-selected', 'false'); +} + +export function mount() { + document.body.dataset.view = 'admin'; + document.body.dataset.state = 'manage'; + + // The admin console needs the backend (shows API + token auth), which the + // in-browser sandbox doesn't have. Clone the repo and run the server to use + // it; the live docs demo only exercises the voter and presenter. + if (IS_SANDBOX) { + document.body.innerHTML = ` +
    +
    +

    Admin runs against the backend

    +

    The operator console drives the show and talks to the shows API and token-auth + endpoint, so it isn't part of the in-browser demo. Clone the repo and run the server + (see the README) to use it.

    +
    +
    `; + return; + } + + document.body.innerHTML = VIEW_HTML; + init(); +} diff --git a/examples/pub-sub-live-voting/javascript/src/config.ts b/examples/pub-sub-live-voting/javascript/src/config.ts new file mode 100644 index 0000000000..a8d05a14be --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/config.ts @@ -0,0 +1,22 @@ +// Configuration and sandbox detection. +// +// In the hosted docs sandbox the build replaces `import.meta.env.VITE_ABLY_KEY` +// and `import.meta.env.VITE_NAME` with real string literals, so the demo runs +// entirely in the browser with no backend. In a normal clone these are +// undefined, and the app talks to the bundled server instead (see ../../server). +// +// Only reference env vars that the docs build injects (VITE_ABLY_KEY, +// VITE_NAME). Other `import.meta.env` lookups would survive into the sandbox +// bundle and break it — the server-backed config (auth URL, API base) is wired +// through same-origin paths and a Vite dev proxy instead (see vite.config.ts). + +export const SANDBOX_ABLY_KEY = import.meta.env.VITE_ABLY_KEY as string | undefined; + +// A shared channel seed. In the sandbox both preview panes get the same +// injected value, so the voter and presenter land on the same channel without +// exchanging a session id. In a clone this is undefined and the session id +// comes from the URL (`?s=...`) instead. +export const SANDBOX_CHANNEL_SEED = import.meta.env.VITE_NAME as string | undefined; + +/** True only inside the hosted docs sandbox (a raw key was injected). */ +export const IS_SANDBOX = Boolean(SANDBOX_ABLY_KEY); diff --git a/examples/pub-sub-live-voting/javascript/src/demo-bootstrap.ts b/examples/pub-sub-live-voting/javascript/src/demo-bootstrap.ts new file mode 100644 index 0000000000..7ee4bab9df --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/demo-bootstrap.ts @@ -0,0 +1,102 @@ +import * as Ably from 'ably'; +import { SANDBOX_ABLY_KEY, SANDBOX_CHANNEL_SEED } from './config'; +import { votingChannelName } from './shared/ably'; +import demoShows from './demo-shows.json'; +import { PollMessage, PollOption, VOTE_ANNOTATION_TYPE } from './shared/types'; + +// ─── SANDBOX ONLY ─────────────────────────────────────────────────────────── +// None of this is part of the real app. In a real show the admin console +// starts the show and a live audience supplies the votes. The hosted docs +// demo has neither, so this module fakes a "host": it publishes one poll and +// seeds a handful of votes from throwaway clients, purely so the presenter +// pane is alive and expressive the moment the page loads. The real voter pane +// then adds its own live votes on top. +// +// Clone the repo and run the server (see the README) for the genuine flow — +// you'll never use this file there. + +// Throwaway clients that each cast one vote. Distinct clientIds matter: votes +// are `unique` annotations, deduped per clientId, so N distinct voters are what +// give the heatmap N counts to spread across the options. +const SEED_VOTERS = 6; + +const channelName = () => votingChannelName(SANDBOX_CHANNEL_SEED!); + +// Auto-start the d-pad poll — the most expressive presenter screen (a heatmap +// with a leader badge), as opposed to a plain bar chart or the suggest stage. +function pickDpadPoll(): PollMessage | null { + const show = (demoShows as { shows?: any[] }).shows?.[0]; + const polls: any[] = show?.polls ?? []; + const dpad = polls.find((p) => p.type === 'dpad') ?? polls[0]; + if (!dpad) return null; + return { + pollId: dpad.id, + question: dpad.question, + type: dpad.type, + // Option ids are strings on the wire (the server stringifies them); match that. + options: (dpad.options ?? []).map((o: any): PollOption => ({ + id: String(o.id), + label: o.label, + direction: o.direction, + })), + }; +} + +export async function runDemoHost() { + const poll = pickDpadPoll(); + if (!poll) return; + + const host = new Ably.Realtime({ key: SANDBOX_ABLY_KEY, clientId: 'demo-host' }); + const channel = host.channels.get(channelName(), { modes: ['publish', 'subscribe'] }); + + // Publishing returns void, so read the serial back from the echoed message — + // the seed votes are annotations and need the poll's serial to attach to. + channel.subscribe('poll', (msg: Ably.Message) => { + const data = msg.data as PollMessage; + if (msg.serial && data.pollId === poll.pollId) { + channel.unsubscribe('poll'); + seedVotes(poll, msg.serial); + } + }); + + try { + await channel.publish('poll', poll); + } catch { + /* best-effort demo seeding; ignore failures */ + } +} + +function seedVotes(poll: PollMessage, serial: string) { + // Stagger them so the presenter shows a trickle of vote bubbles rather than + // one synchronised burst. + for (let i = 0; i < SEED_VOTERS; i++) { + setTimeout(() => castSeedVote(poll, serial), 600 + i * 350); + } +} + +function weightedPick(options: PollOption[]): PollOption { + // Bias toward earlier options so a clear leader emerges on the heatmap. + const weights = options.map((_, i) => options.length - i); + const total = weights.reduce((a, b) => a + b, 0); + let r = Math.random() * total; + for (let i = 0; i < options.length; i++) { + r -= weights[i]; + if (r <= 0) return options[i]; + } + return options[0]; +} + +function castSeedVote(poll: PollMessage, serial: string) { + if (poll.options.length === 0) return; + const opt = weightedPick(poll.options); + const suffix = Math.random().toString(36).slice(2, 6); + // `Guest-xxxx` so the presenter's displayName() shows "Guest" on the bubble. + const bot = new Ably.Realtime({ key: SANDBOX_ABLY_KEY, clientId: `Guest-${suffix}` }); + const channel = bot.channels.get(channelName(), { modes: ['annotation_publish'] }); + channel.annotations + .publish(serial, { type: VOTE_ANNOTATION_TYPE, name: opt.id }) + .catch(() => {}) + // The vote persists in the summary after the bot leaves (annotations aren't + // presence), so we can disconnect to free the connection. + .finally(() => setTimeout(() => bot.close(), 2000)); +} diff --git a/examples/pub-sub-live-voting/javascript/src/demo-shows.json b/examples/pub-sub-live-voting/javascript/src/demo-shows.json new file mode 100644 index 0000000000..ce877c4447 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/demo-shows.json @@ -0,0 +1,38 @@ +{ + "shows": [ + { + "id": 1, + "name": "Ably Live Voting demo", + "polls": [ + { + "id": 1, + "question": "Which real-time feature do you reach for most?", + "type": "list", + "options": [ + { "id": 101, "label": "Pub/Sub channels" }, + { "id": 102, "label": "Presence" }, + { "id": 103, "label": "Message history" }, + { "id": 104, "label": "Push notifications" } + ] + }, + { + "id": 2, + "question": "Rate this demo with the d-pad", + "type": "dpad", + "options": [ + { "id": 201, "label": "Love it", "direction": "up" }, + { "id": 202, "label": "It's neat", "direction": "right" }, + { "id": 203, "label": "Meh", "direction": "down" }, + { "id": 204, "label": "Confused", "direction": "left" } + ] + }, + { + "id": 3, + "question": "What should we build with Ably next?", + "type": "suggest", + "options": [] + } + ] + } + ] +} diff --git a/examples/pub-sub-live-voting/javascript/src/presenter.ts b/examples/pub-sub-live-voting/javascript/src/presenter.ts new file mode 100644 index 0000000000..37b80ca728 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/presenter.ts @@ -0,0 +1,316 @@ +import * as Ably from 'ably'; +import QRCode from 'qrcode'; +import { createAblyClient, getChannel } from './shared/ably'; +import { renderChart } from './shared/chart'; +import { renderController } from './shared/controller'; +import { displayName } from './shared/clientId'; +import { isValidSessionId } from './shared/sessionId'; +import { IS_SANDBOX, SANDBOX_CHANNEL_SEED } from './config'; +import { + VOTE_ANNOTATION_TYPE, + SUGGESTION_ANNOTATION_TYPE, + SESSION_KEY, + PresenterState, + PollMessage, + PollType, + ControlMessage, + PollOption, + isControllerKind, +} from './shared/types'; + +// The big-screen view. The presenter is the consumer that wants *individual* +// annotation events, not just the summary: every vote and every suggestion +// becomes a floating bubble. That's a much higher-volume stream than the +// summary the voters get — which is exactly why annotations let each audience +// subscribe to the slice it needs. + +const VIEW_HTML = ` +
    Ably Live Voting
    +
    + +
    +
    +

    That session ID doesn't look right

    +

    Please double-check the URL.

    +
    +
    + + +
    +
    +

    +
    +
    +
    +
    + + +
    +
    +

    +
    +
    +
    + + +
    +
    +

    Scan to join the show →

    +
    +
    + + +
    +
    +

    That's all, folks!

    +

    Thanks for taking part.

    +
    +
    +
    + + + +`; + +let channel: Ably.RealtimeChannel | null = null; +let currentPollOptions: PollOption[] = []; +let currentPollType: PollType = 'list'; +let currentPollSerial: string | null = null; +let currentPollId: number | null = null; +let currentSummary: Ably.SummaryUniqueValues | null = null; +let suggestListener: ((a: Ably.Annotation) => void) | null = null; +let voteListener: ((a: Ably.Annotation) => void) | null = null; +let acceptingSuggestions = false; + +const $ = (sel: string) => document.querySelector(sel) as HTMLElement; + +function setState(newState: PresenterState) { + document.body.dataset.state = newState; +} + +function handleMessage(msg: Ably.Message) { + if (msg.name === 'poll') { + const data = msg.data as PollMessage; + const summary = msg.annotations?.summary?.[VOTE_ANNOTATION_TYPE] as Ably.SummaryUniqueValues | undefined; + + if (msg.serial !== currentPollSerial) { + currentPollOptions = data.options; + currentPollType = data.type ?? 'list'; + currentPollSerial = msg.serial!; + currentPollId = data.pollId; + currentSummary = summary ?? null; + + if (currentPollType === 'suggest') { + unsubscribeSuggestions(); + unsubscribeVotes(); + $('#suggest-question').textContent = data.question; + $('#bubble-layer').innerHTML = ''; + subscribeSuggestions(); + setState('suggesting'); + } else { + unsubscribeSuggestions(); + $('#question').textContent = data.question; + renderResults(); + setState('live'); + subscribeVotes(); + } + } else if (summary && currentPollType !== 'suggest') { + currentSummary = summary; + renderResults(); + } + } else if (msg.name === 'control') { + const data = msg.data as ControlMessage; + if (data.action === 'end') { + unsubscribeSuggestions(); + unsubscribeVotes(); + setState('ended'); + return; + } + if (data.action === 'reset') { + unsubscribeSuggestions(); + unsubscribeVotes(); + currentPollSerial = null; + currentPollId = null; + currentPollOptions = []; + currentSummary = null; + if (data.voterUrl) showQR(data.voterUrl); + return; + } + if (data.action === 'clear-suggestions') { + $('#bubble-layer').innerHTML = ''; + return; + } + if (data.pollId === currentPollId) { + if (data.action === 'close') { + if (currentPollType === 'suggest') { + acceptingSuggestions = false; + } else { + setState('results'); + } + } else if (data.action === 'show-qr') { + showQR(data.voterUrl!); + } + } + } +} + +async function showQR(voterUrl: string) { + const canvas = $('#qr-canvas') as HTMLCanvasElement; + await QRCode.toCanvas(canvas, voterUrl, { width: 1000, margin: 2 }); + // qrcode pins an inline 1000px width/height on the canvas, which would + // override our stylesheet sizing. Clear it so the CSS clamp() wins while + // the 1000px bitmap stays for crisp rendering. + canvas.style.width = ''; + canvas.style.height = ''; + setState('show-qr'); +} + +function renderResults() { + const chart = $('#chart'); + if (isControllerKind(currentPollType)) { + // The d-pad heatmap: each slot fills proportionally to its share of the + // leader, with a star badge on the winner — all driven by the summary. + chart.classList.add('presenter-controller'); + renderController(currentPollType, chart, currentPollOptions, currentSummary, { + showHeatmap: true, + showLeaderBadge: true, + display: 'percent', + }); + } else { + chart.classList.remove('presenter-controller', 'controller', 'controller-dpad'); + renderChart(chart, currentPollOptions, currentSummary, 'percent'); + } +} + +function subscribeSuggestions() { + if (!channel || suggestListener) return; + acceptingSuggestions = true; + const handler = (annotation: Ably.Annotation) => { + if (!acceptingSuggestions) return; + if (annotation.action === 'annotation.delete') return; + const text = annotation.name; + const cid = annotation.clientId; + if (!text || !cid) return; + spawnBubble($('#bubble-layer'), text, displayName(cid), { baseMs: 16000, decayPerAlive: 300, minMs: 3000 }); + }; + suggestListener = handler; + channel.annotations.subscribe(SUGGESTION_ANNOTATION_TYPE, handler); +} + +function unsubscribeSuggestions() { + acceptingSuggestions = false; + if (channel && suggestListener) { + channel.annotations.unsubscribe(SUGGESTION_ANNOTATION_TYPE, suggestListener); + } + suggestListener = null; +} + +function subscribeVotes() { + if (!channel || voteListener) return; + // One bubble per individual vote annotation. This is the high-volume stream + // the summary spares the voters from; the presenter opts in via + // annotation_subscribe. + const handler = (annotation: Ably.Annotation) => { + if (annotation.action === 'annotation.delete') return; + const optionId = annotation.name; + const cid = annotation.clientId; + if (!optionId || !cid) return; + const opt = currentPollOptions.find((o) => o.id === optionId); + if (!opt) return; + spawnBubble($('#vote-bubble-layer'), opt.label, displayName(cid), { baseMs: 1500, decayPerAlive: 0, minMs: 1500 }); + }; + voteListener = handler; + channel.annotations.subscribe(VOTE_ANNOTATION_TYPE, handler); +} + +function unsubscribeVotes() { + if (channel && voteListener) { + channel.annotations.unsubscribe(VOTE_ANNOTATION_TYPE, voteListener); + } + voteListener = null; +} + +interface BubbleLifetime { + baseMs: number; + decayPerAlive: number; + minMs: number; +} + +function spawnBubble(layer: HTMLElement, text: string, attribution: string, lifetime: BubbleLifetime) { + const el = document.createElement('div'); + el.className = 'bubble'; + el.innerHTML = ``; + el.querySelector('.bubble-text')!.textContent = text; + el.querySelector('.bubble-attribution')!.textContent = attribution; + + const { x, y } = pickBubblePosition(); + el.style.left = `${x}%`; + el.style.top = `${y}%`; + + const alive = layer.children.length; + const lifetimeMs = Math.max(lifetime.minMs, lifetime.baseMs - alive * lifetime.decayPerAlive); + el.style.setProperty('--lifetime', `${lifetimeMs}ms`); + el.addEventListener('animationend', () => el.remove(), { once: true }); + + layer.appendChild(el); +} + +function pickBubblePosition(): { x: number; y: number } { + // Polar around the centre, avoiding the question text in the middle. + const angle = Math.random() * Math.PI * 2; + const r = 25 + Math.random() * 20; // 25–45% of half-viewport + const x = 50 + Math.cos(angle) * r; + const y = 50 + Math.sin(angle) * r; + return { x, y }; +} + +function connect(sessionId: string) { + const clientId = `presenter-${crypto.randomUUID().slice(0, 8)}`; + const ably = createAblyClient(clientId, sessionId, 'presenter'); + channel = getChannel(ably, sessionId, 'presenter'); + channel.subscribe(handleMessage); +} + +function init() { + // Sandbox: join the shared demo channel and wait for the auto-started poll + // (published by demo-bootstrap). No QR — the voter pane is already connected. + if (IS_SANDBOX) { + document.body.dataset.sandbox = 'true'; + $('#show-qr-heading').textContent = 'Connecting…'; + setState('show-qr'); + connect(SANDBOX_CHANNEL_SEED!); + return; + } + + const sessionId = new URLSearchParams(window.location.search).get(SESSION_KEY); + if (!sessionId || !isValidSessionId(sessionId)) { + setState('invalid-session'); + return; + } + + connect(sessionId); + + // Carry the presenter's querystring (the session) straight into the voter + // URL — admin builds the presenter link with the same querystring, so they + // stay in sync. + const voterUrl = `${window.location.origin}/${window.location.search}`; + showQR(voterUrl); + + // Manual-entry fallback: the short URL displayed under the QR for people + // without a working camera. It already carries just the session, so it + // matches the QR target. + const qrLink = $('#presenter-qr-url') as HTMLAnchorElement; + qrLink.textContent = `${window.location.host}/?${SESSION_KEY}=${sessionId}`; + qrLink.href = voterUrl; +} + +export function mount() { + document.body.dataset.view = 'presenter'; + document.body.dataset.state = 'show-qr'; + document.body.innerHTML = VIEW_HTML; + init(); +} diff --git a/examples/pub-sub-live-voting/javascript/src/script.ts b/examples/pub-sub-live-voting/javascript/src/script.ts new file mode 100644 index 0000000000..ff71259394 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/script.ts @@ -0,0 +1,25 @@ +// Single entry point. The app has three roles — voter, presenter, admin — +// selected by the `?role=` query param. Splitting one app this way (rather +// than three HTML pages) lets the docs sandbox show two of the roles side by +// side in separate preview panes, each loaded with a different `?role=`. +import { mount as mountVoter } from './voter'; +import { mount as mountPresenter } from './presenter'; +import { mount as mountAdmin } from './admin'; +import { runDemoHost } from './demo-bootstrap'; +import { IS_SANDBOX } from './config'; + +const params = new URLSearchParams(window.location.search); +const role = params.get('role') ?? 'voter'; + +if (role === 'presenter') { + mountPresenter(); + // Sandbox only: `?demo=1` also makes this pane the host that auto-starts the + // show, since there's no admin running. See demo-bootstrap.ts. + if (IS_SANDBOX && params.get('demo') === '1') { + runDemoHost(); + } +} else if (role === 'admin') { + mountAdmin(); +} else { + mountVoter(); +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/ably.ts b/examples/pub-sub-live-voting/javascript/src/shared/ably.ts new file mode 100644 index 0000000000..9025e550f4 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/ably.ts @@ -0,0 +1,86 @@ +import * as Ably from 'ably'; +import { Role } from './types'; +import { showError, clearError } from './errors'; +import { IS_SANDBOX, SANDBOX_ABLY_KEY } from '../config'; + +export function createAblyClient( + clientId: string, + sessionId: string, + role: Role, + extraParams?: Record, +): Ably.Realtime { + // ── Authentication ── + // + // Real app: the client never sees an API key. It calls the server's `/auth` + // endpoint, which returns a short-lived JWT scoped to exactly this role's + // capabilities — a voter token can publish annotations but cannot publish + // messages or read other voters' raw annotations (see server/src/server.ts). + // This is the pattern you should use in production. + // + // Hosted docs sandbox ONLY: there is no server to call, so we fall back to a + // raw API key embedded in the page. Do not do this with a real app, it's used here + // purely so the live demo can run without a backend. + const client = new Ably.Realtime( + IS_SANDBOX + ? { key: SANDBOX_ABLY_KEY, clientId } + : { + authUrl: '/auth', + authParams: { clientId, sessionId, role, ...extraParams }, + clientId, + }, + ); + + client.connection.on('failed', (change) => { + const reason = change.reason; + const detail = reason?.cause?.message || reason?.message || 'unknown error'; + showError(`Connection failed: ${detail}`); + }); + + client.connection.on('connected', () => { + clearError(); + }); + + return client; +} + +// Channel modes are the client's declared intent, enforced by Ably. They're +// the second layer of least-privilege after the token capabilities: a voter +// attaches with annotation_publish only, so it can cast votes but never +// receive the (much higher-volume) individual annotation stream the presenter +// consumes, nor publish poll messages. +const MODES_BY_ROLE: Record = { + // Admin publishes poll/control messages and subscribes for echo + summaries. + // Also subscribes to per-annotation events to render the live suggest list. + admin: ['publish', 'subscribe', 'annotation_subscribe'], + // Voter only publishes annotations (votes, suggestions) and subscribes for + // poll/control messages. + voter: ['subscribe', 'annotation_publish'], + // Presenter is read-only on both messages and per-annotation events. + presenter: ['subscribe', 'annotation_subscribe'], +}; + +// The channel a session runs on. Always namespaced under `voting:` so the +// channel rules this app needs, such as Message annotations and serverside batching, can +// be configured against the `voting` namespace. In the sandbox `sessionId` is the +// injected channel name; in the real app it's the admin-generated session id. +export function votingChannelName(sessionId: string): string { + return `voting:${sessionId}`; +} + +export function getChannel(client: Ably.Realtime, sessionId: string, role: Role): Ably.RealtimeChannel { + // `rewind: 1` means a client that attaches mid-poll immediately receives the + // current poll message (and its latest annotation summary), so late joiners + // and page refreshes catch up without the admin re-publishing. + const channel = client.channels.get(votingChannelName(sessionId), { + params: { rewind: '1' }, + modes: MODES_BY_ROLE[role], + }); + + channel.on('failed', (change) => { + const reason = change.reason; + const detail = reason?.cause?.message || reason?.message || 'unknown error'; + showError(`Channel error: ${detail}`); + }); + + return channel; +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/chart.ts b/examples/pub-sub-live-voting/javascript/src/shared/chart.ts new file mode 100644 index 0000000000..e26e31235d --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/chart.ts @@ -0,0 +1,60 @@ +import * as Ably from 'ably'; +import { PollOption } from './types'; +import { CountDisplay, formatCount } from './format'; + +const COLORS = ['#ff5416', '#1a1a1a', '#6b6b6b', '#ffa07a', '#c9c5bd', '#ff7b47']; + +// Renders a horizontal bar per option from an annotation summary. The summary +// is Ably's server-side rollup of every `vote:unique.v1` annotation on the +// poll message — `summary[optionId].total` is the live vote count for that +// option, computed for us, not by tallying events on the client. +export function renderChart( + container: HTMLElement, + options: PollOption[], + summary: Ably.SummaryUniqueValues | null, + display: CountDisplay = 'count', +): void { + const totalVotes = options.reduce( + (sum, opt) => sum + (summary?.[opt.id]?.total ?? 0), + 0, + ); + + // Reuse existing rows if structure matches, otherwise rebuild + const existingRows = container.querySelectorAll('.chart-row'); + if (existingRows.length !== options.length) { + container.innerHTML = ''; + for (let i = 0; i < options.length; i++) { + const opt = options[i]; + const count = summary?.[opt.id]?.total ?? 0; + const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0; + + const row = document.createElement('div'); + row.className = 'chart-row'; + row.dataset.optionId = opt.id; + row.innerHTML = ` + ${opt.label} +
    +
    +
    + ${formatCount(count, totalVotes, display)} + `; + container.appendChild(row); + } + } else { + // Update in place for smooth transitions + for (let i = 0; i < options.length; i++) { + const opt = options[i]; + const row = existingRows[i] as HTMLElement; + const count = summary?.[opt.id]?.total ?? 0; + const pct = totalVotes > 0 ? (count / totalVotes) * 100 : 0; + + row.dataset.optionId = opt.id; + const labelEl = row.querySelector('.chart-label') as HTMLElement; + labelEl.textContent = opt.label; + const fill = row.querySelector('.chart-bar-fill') as HTMLElement; + fill.style.width = `${pct}%`; + const countEl = row.querySelector('.chart-count') as HTMLElement; + countEl.textContent = formatCount(count, totalVotes, display); + } + } +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/clientId.ts b/examples/pub-sub-live-voting/javascript/src/shared/clientId.ts new file mode 100644 index 0000000000..ff484cdae6 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/clientId.ts @@ -0,0 +1,16 @@ +/** ClientId format: `${name}-${random4}`. The random suffix keeps clientIds + * (and therefore votes) distinct even when two people pick the same username. + * + * The clientId matters: votes are `unique` annotations, which Ably dedupes + * per clientId. One clientId means one counted vote, no matter how many times + * that client taps. */ +export function voterClientId(name: string): string { + const suffix = Math.random().toString(36).slice(2, 6); + return `${name}-${suffix}`; +} + +/** Strip the trailing `-suffix` to get a presentable display name. */ +export function displayName(clientId: string): string { + const lastDash = clientId.lastIndexOf('-'); + return lastDash > 0 ? clientId.slice(0, lastDash) : clientId; +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/controller.ts b/examples/pub-sub-live-voting/javascript/src/shared/controller.ts new file mode 100644 index 0000000000..19e4e9b200 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/controller.ts @@ -0,0 +1,140 @@ +import * as Ably from 'ably'; +import { PollOption, Direction, ControllerKind, SLOTS_BY_KIND } from './types'; +import { CountDisplay, formatCount } from './format'; + +interface SlotStyle { + glyph: string; + color: string; +} + +const SLOT_STYLES: Record = { + up: { glyph: '▲', color: '#ff5416' }, + right: { glyph: '▶', color: '#ff5416' }, + down: { glyph: '▼', color: '#ff5416' }, + left: { glyph: '◀', color: '#ff5416' }, +}; + +export function slotGlyph(slot: Direction): string { + return SLOT_STYLES[slot].glyph; +} + +export interface ControllerRenderOpts { + clickable?: boolean; + onClick?: (optionId: string) => void; + /** Mark this option as "your vote". With invertSelected this swaps the + * slot to black-on-white; without it, fills the slot with its colour. */ + selectedId?: string | null; + /** Fill each button proportionally to its share of the leader. */ + showHeatmap?: boolean; + /** Place a star badge on the option(s) with the most votes. */ + showLeaderBadge?: boolean; + /** Render every populated slot in full colour by default. */ + defaultFilled?: boolean; + /** Selected slot inverts to black-bg / white-text instead of taking its + * slot colour. Pairs naturally with defaultFilled. */ + invertSelected?: boolean; + /** How to render the per-slot tally. Defaults to the raw count. */ + display?: CountDisplay; +} + +// Renders the d-pad: four positional buttons fed from the same annotation +// summary as the bar chart. On the presenter this drives the heatmap (fill +// proportional to each slot's share) and the leader star badge. +export function renderController( + kind: ControllerKind, + container: HTMLElement, + options: PollOption[], + summary: Ably.SummaryUniqueValues | null, + opts: ControllerRenderOpts = {}, +): void { + const slots = SLOTS_BY_KIND[kind]; + + const optionBySlot = new Map(); + for (const o of options) { + if (o.direction) optionBySlot.set(o.direction, o); + } + + const counts = new Map(); + let max = 0; + let total = 0; + for (const slot of slots) { + const opt = optionBySlot.get(slot); + const c = opt && summary ? (summary[opt.id]?.total ?? 0) : 0; + counts.set(slot, c); + if (c > max) max = c; + total += c; + } + + container.classList.add('controller', `controller-${kind}`); + + let buttons = container.querySelectorAll('.controller-button'); + const existingSlots = Array.from(buttons).map((b) => b.dataset.slot); + const slotsMatch = existingSlots.length === slots.length && existingSlots.every((s, i) => s === slots[i]); + if (!slotsMatch) { + container.innerHTML = ''; + for (const slot of slots) { + const el = document.createElement(opts.clickable ? 'button' : 'div'); + el.className = `controller-button slot-${slot}`; + el.dataset.slot = slot; + el.innerHTML = ` + + ${SLOT_STYLES[slot].glyph} + + + `; + container.appendChild(el); + } + buttons = container.querySelectorAll('.controller-button'); + } + + for (const button of buttons) { + const slot = button.dataset.slot as Direction; + const opt = optionBySlot.get(slot); + const count = counts.get(slot) ?? 0; + const labelEl = button.querySelector('.controller-label')!; + const countEl = button.querySelector('.controller-count')!; + + if (opt) { + labelEl.textContent = opt.label; + countEl.textContent = formatCount(count, total, opts.display ?? 'count'); + button.dataset.optionId = opt.id; + button.classList.remove('slot-empty'); + button.style.setProperty('--slot-fill', SLOT_STYLES[slot].color); + + const isSelected = opts.selectedId === opt.id; + const isInverted = !!opts.invertSelected && isSelected; + button.classList.toggle('slot-inverted', isInverted); + + let intensity = 0; + if (opts.showHeatmap) { + // Quadratic so the leader pops harder against trailing options. + const ratio = max > 0 ? count / max : 0; + intensity = ratio * ratio; + } else if (isInverted) { + intensity = 0; // .slot-inverted CSS handles the visual itself. + } else if (isSelected || opts.defaultFilled) { + intensity = 1; + } + button.style.setProperty('--slot-fill-intensity', String(intensity)); + + const isLeader = opts.showLeaderBadge && max > 0 && count === max; + button.classList.toggle('marked-leader', isLeader); + if (opts.clickable && opts.onClick) { + const handler = () => opts.onClick!(opt.id); + const existing = (button as any)._controllerHandler; + if (existing) button.removeEventListener('click', existing); + (button as any)._controllerHandler = handler; + button.addEventListener('click', handler); + (button as HTMLButtonElement).disabled = false; + } + } else { + labelEl.textContent = ''; + countEl.textContent = ''; + delete button.dataset.optionId; + button.classList.add('slot-empty'); + button.classList.remove('marked-leader', 'slot-inverted'); + button.style.setProperty('--slot-fill-intensity', '0'); + if (opts.clickable) (button as HTMLButtonElement).disabled = true; + } + } +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/errors.ts b/examples/pub-sub-live-voting/javascript/src/shared/errors.ts new file mode 100644 index 0000000000..e2b4c6691a --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/errors.ts @@ -0,0 +1,22 @@ +let bannerEl: HTMLElement | null = null; + +function getBanner(): HTMLElement { + if (bannerEl) return bannerEl; + bannerEl = document.createElement('div'); + bannerEl.className = 'error-banner hidden'; + document.body.prepend(bannerEl); + return bannerEl; +} + +export function showError(message: string) { + const banner = getBanner(); + banner.textContent = message; + banner.classList.remove('hidden'); + console.error(message); +} + +export function clearError() { + if (bannerEl) { + bannerEl.classList.add('hidden'); + } +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/format.ts b/examples/pub-sub-live-voting/javascript/src/shared/format.ts new file mode 100644 index 0000000000..6259ae2859 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/format.ts @@ -0,0 +1,13 @@ +export type CountDisplay = 'count' | 'percent' | 'both'; + +export function formatCount(count: number, total: number, mode: CountDisplay): string { + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + switch (mode) { + case 'count': + return String(count); + case 'percent': + return `${pct}%`; + case 'both': + return `${count} (${pct}%)`; + } +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/sessionId.ts b/examples/pub-sub-live-voting/javascript/src/shared/sessionId.ts new file mode 100644 index 0000000000..7416a29b33 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/sessionId.ts @@ -0,0 +1,31 @@ +// Session IDs are 6 base36 chars: 5 random + 1 check digit. The check digit +// catches typos when someone is entering the ID by hand (the QR-scan path +// already gets it right). The weighted sum catches single-char substitutions +// and most adjacent transpositions. + +const ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; +const SESSION_LENGTH = 6; + +function checkChar(body: string): string { + let sum = 0; + for (let i = 0; i < body.length; i++) { + const idx = ALPHABET.indexOf(body[i]); + if (idx < 0) return ''; + sum += (i + 1) * idx; + } + return ALPHABET[sum % ALPHABET.length]; +} + +export function generateSessionId(): string { + let body = ''; + while (body.length < SESSION_LENGTH - 1) { + body += Math.floor(Math.random() * ALPHABET.length).toString(36); + } + return body + checkChar(body); +} + +export function isValidSessionId(s: string): boolean { + if (s.length !== SESSION_LENGTH) return false; + if (!/^[0-9a-z]+$/.test(s)) return false; + return checkChar(s.slice(0, -1)) === s.slice(-1); +} diff --git a/examples/pub-sub-live-voting/javascript/src/shared/types.ts b/examples/pub-sub-live-voting/javascript/src/shared/types.ts new file mode 100644 index 0000000000..3137de6e9b --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/shared/types.ts @@ -0,0 +1,63 @@ +export type PollType = 'list' | 'dpad' | 'suggest'; + +/** Subset of PollType that renders as a directional controller (positional buttons). */ +export type ControllerKind = 'dpad'; + +/** Slot a poll option occupies on the d-pad layout. The DB column and the + * PollOption field are both named `direction` since these are arrow directions. */ +export type Direction = 'up' | 'right' | 'down' | 'left'; + +export const SLOTS_BY_KIND: Record = { + dpad: ['up', 'right', 'down', 'left'], +}; + +export function isControllerKind(t: PollType): t is ControllerKind { + return t === 'dpad'; +} + +export interface PollOption { + id: string; + label: string; + direction?: Direction; +} + +export interface PollDefinition { + id: number; + question: string; + type: PollType; + options: PollOption[]; +} + +export interface Show { + id: number; + name: string; + polls: PollDefinition[]; +} + +export interface PollMessage { + pollId: number; + question: string; + type: PollType; + options: PollOption[]; +} + +export interface ControlMessage { + action: 'close' | 'show-qr' | 'end' | 'reset' | 'clear-suggestions'; + pollId?: number; + voterUrl?: string; +} + +// The annotation type that carries a vote. The `unique` summarisation means +// Ably keeps at most one vote per clientId — a built-in "one person, one vote" +// that you'd otherwise have to enforce yourself. See the README. +export const VOTE_ANNOTATION_TYPE = 'vote:unique.v1'; +export const SUGGESTION_ANNOTATION_TYPE = 'suggestion'; + +/** URL search-param key carrying the session id (e.g. `/?s=ab12cd`). */ +export const SESSION_KEY = 's'; + +export type Role = 'admin' | 'voter' | 'presenter'; + +export type VoterState = 'no-session' | 'invalid-session' | 'enter-name' | 'waiting' | 'voting' | 'voted' | 'results' | 'suggesting' | 'ended'; +export type AdminState = 'setup' | 'manage' | 'ready' | 'poll-open' | 'poll-closed' | 'show-qr'; +export type PresenterState = 'invalid-session' | 'live' | 'results' | 'show-qr' | 'ended' | 'suggesting'; diff --git a/examples/pub-sub-live-voting/javascript/src/styles.css b/examples/pub-sub-live-voting/javascript/src/styles.css new file mode 100644 index 0000000000..85f4bb6812 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/styles.css @@ -0,0 +1,1309 @@ +/* Reset & base */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #f7f6f3; + --surface: #ffffff; + --ink: #1a1a1a; + --ink-soft: #6b6b6b; + --border: #e6e3dd; + --border-strong: #d6d2ca; + --primary: #ff5416; + --primary-hover: #e64a12; + --primary-soft: #fff1ea; + --warning: #f5a623; + --warning-hover: #e0961a; + --success: #2ea043; + --danger: #e5484d; + --danger-hover: #cf3b40; + --info: #2563eb; + + --radius: 12px; + --radius-sm: 8px; + --shadow-sm: 0 1px 2px rgba(20, 20, 20, 0.06); + --shadow: 0 2px 10px rgba(20, 20, 20, 0.07); + --shadow-lg: 0 12px 32px rgba(20, 20, 20, 0.10); + + --font-pixel: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; + --font-body: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; +} + +html, body { + height: 100%; + font-family: var(--font-body); + background: var(--bg); + color: var(--ink); + line-height: 1.5; + font-size: 16px; + -webkit-font-smoothing: antialiased; +} + +h1, h2, h3 { + font-family: var(--font-pixel); + font-weight: 700; + letter-spacing: -0.01em; +} + +h1 { font-size: 1.7rem; line-height: 1.3; } +h2 { font-size: 1.3rem; line-height: 1.35; } +h3 { font-size: 1.05rem; line-height: 1.4; } + +p { font-size: 1rem; } + +/* Brand mark (top-left on voter & presenter views) */ +.brand { + position: fixed; + top: 1rem; + left: 1.25rem; + display: flex; + align-items: center; + gap: 0.6rem; + font-family: var(--font-pixel); + font-size: 0.95rem; + font-weight: 700; + letter-spacing: -0.01em; + color: var(--ink); + z-index: 10; + pointer-events: none; +} + +body[data-view="presenter"] .brand { + font-size: 1.25rem; + top: 1.25rem; + left: 1.5rem; +} + +@media (max-width: 768px) { + .brand { font-size: 0.85rem; } + body[data-view="presenter"] .brand { font-size: 1rem; } +} + +/* Reserve clear space under the fixed brand mark on branded views */ +body[data-view="voter"] .view { padding-top: 3.5rem; } +body[data-view="presenter"] .view { padding-top: 4.25rem; } +@media (max-width: 768px) { + body[data-view="voter"] .view { padding-top: 3rem; } + body[data-view="presenter"] .view { padding-top: 3rem; } +} + +/* View visibility based on body[data-state] */ +.view { display: none; } + +body[data-state="no-session"] .view-no-session, +body[data-state="invalid-session"] .view-invalid-session, +body[data-state="enter-name"] .view-enter-name, +body[data-state="waiting"] .view-waiting, +body[data-state="voting"] .view-voting, +body[data-state="voted"] .view-voted, +body[data-state="results"] .view-results, +body[data-state="manage"] .view-manage, +body[data-state="setup"] .view-setup, +body[data-state="ready"] .view-ready, +body[data-state="poll-open"] .view-poll-open, +body[data-state="poll-closed"] .view-poll-closed, +body[data-state="show-qr"] .view-show-qr, +body[data-state="live"] .view-live, +body[data-state="suggesting"] .view-suggesting, +body[data-state="ended"] .view-ended { + display: flex; +} + +/* Layout */ +#app { + min-height: 100%; + display: flex; + flex-direction: column; +} + +.view { + flex: 1; + align-items: center; + justify-content: center; + padding: 2rem; +} + +/* Cards */ +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 2rem; + max-width: 460px; + width: 100%; + box-shadow: var(--shadow); +} + +.card.wide { + max-width: 680px; +} + +.card h1, .card h2 { + margin-bottom: 1rem; +} + +/* Forms */ +label { + display: block; + font-family: var(--font-pixel); + font-size: 0.72rem; + font-weight: 600; + color: var(--ink-soft); + margin-bottom: 0.4rem; + margin-top: 1rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +input[type="text"], input[type="password"], select { + width: 100%; + padding: 0.65rem 0.8rem; + background: var(--surface); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + color: var(--ink); + font-family: var(--font-body); + font-size: 1rem; +} + +input[type="text"]:focus, input[type="password"]:focus, select:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px rgba(255, 84, 22, 0.15); +} + +/* Buttons */ +button { + cursor: pointer; + padding: 0.65rem 1.1rem; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + background: var(--surface); + color: var(--ink); + font-family: var(--font-pixel); + font-size: 0.95rem; + font-weight: 600; + letter-spacing: 0; + transition: background 0.15s, border-color 0.15s, box-shadow 0.15s, transform 0.05s; +} + +button:hover { + border-color: var(--ink-soft); +} + +button:active { + transform: translateY(1px); +} + +button:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +form button[type="submit"], .btn-primary { + background: var(--primary); + border-color: var(--primary); + color: #fff; + width: 100%; + margin-top: 1.25rem; +} + +form button[type="submit"]:hover, .btn-primary:hover { + background: var(--primary-hover); + border-color: var(--primary-hover); +} + +.btn-warning { + background: var(--warning); + border-color: var(--warning); + color: #fff; + width: 100%; + margin-top: 0.5rem; +} + +.btn-warning:hover { + background: var(--warning-hover); + border-color: var(--warning-hover); +} + +.btn-secondary { + background: var(--surface); + color: var(--ink); + width: 100%; + margin-top: 0.5rem; +} + +.btn-secondary:hover { + background: var(--bg); +} + +.btn-danger { + background: var(--danger); + border-color: var(--danger); + color: #fff; + width: 100%; + margin-top: 0.5rem; +} + +.btn-danger:hover { + background: var(--danger-hover); + border-color: var(--danger-hover); +} + +a.btn-primary, a.btn-secondary, a.btn-warning, a.btn-danger { + display: block; + text-align: center; + text-decoration: none; + padding: 0.65rem 1.1rem; + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + font-family: var(--font-pixel); + font-size: 0.95rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +a.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; } +a.btn-primary:hover { background: var(--primary-hover); border-color: var(--primary-hover); } +a.btn-secondary:hover { background: var(--bg); border-color: var(--ink-soft); } + +/* Vote buttons */ +.options-grid { + display: grid; + gap: 0.6rem; + margin-bottom: 1.5rem; +} + +.vote-button { + background: var(--surface); + border: 1px solid var(--border-strong); + color: var(--ink); + padding: 1rem; + border-radius: var(--radius-sm); + font-family: var(--font-pixel); + font-size: 1rem; + font-weight: 500; + text-align: left; + transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; +} + +.vote-button:hover { + border-color: var(--primary); + box-shadow: var(--shadow-sm); +} + +.vote-button.selected { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +body[data-state="results"] .vote-button { + pointer-events: none; + opacity: 0.55; +} + +body[data-state="results"] .vote-button.selected { + opacity: 1; +} + +/* Controller (d-pad — positional button layout) */ +.controller { + margin: 1rem auto; + width: 100%; +} + +.controller-dpad { + display: grid; + /* minmax(0, 1fr) — without the 0 floor, each column's `auto` min-content + would be the full nowrap label width, forcing the controller wider than + its max-width and shoving the card off small screens (e.g. iPhone SE). */ + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: repeat(3, minmax(0, 1fr)); + gap: 8px; + max-width: 360px; + aspect-ratio: 1 / 1; +} + +.controller-button { + position: relative; + border: 1px solid var(--border-strong); + border-radius: var(--radius); + background: var(--surface); + color: var(--ink); + font-family: var(--font-pixel); + font-weight: 600; + cursor: pointer; + padding: 0.25rem; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.2rem; + box-shadow: var(--shadow-sm); + overflow: hidden; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.05s; +} + +.controller-button::before { + content: ''; + position: absolute; + inset: 0; + background: var(--slot-fill, var(--primary)); + opacity: var(--slot-fill-intensity, 0); + transition: opacity 0.3s ease-out; + z-index: 0; + pointer-events: none; +} + +.controller-button > * { + position: relative; + z-index: 1; +} + +/* Slot positions: dpad (3x3) */ +.controller-dpad .slot-up { grid-column: 2; grid-row: 1; } +.controller-dpad .slot-right { grid-column: 3; grid-row: 2; } +.controller-dpad .slot-down { grid-column: 2; grid-row: 3; } +.controller-dpad .slot-left { grid-column: 1; grid-row: 2; } + +/* Controller text sizes are in px, not rem — the buttons themselves are + fixed-size, so the text inside should keep its proportions instead of + stepping at the 768px page font-size breakpoint. */ +.controller-glyph { + font-size: 26px; + line-height: 1; + color: var(--ink-soft); +} + +.controller-button:not(.slot-empty) .controller-glyph { + color: var(--primary); +} + +.controller-label { + font-size: 12px; + font-weight: 600; + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.controller-count { + font-size: 14px; +} + +.controller-button.slot-empty { + background: var(--bg); + color: #b8b4ac; + cursor: not-allowed; + box-shadow: none; +} + +button.controller-button:not(:disabled):hover { + border-color: var(--primary); + box-shadow: var(--shadow); +} + +button.controller-button:not(:disabled):active { + transform: translateY(1px); +} + +.controller-badge { + display: none; + position: absolute; + top: 6px; + right: 6px; + width: 30px; + height: 30px; + border-radius: 50%; + background: var(--primary); + color: #fff; + font-family: var(--font-pixel); + font-size: 0.95rem; + line-height: 30px; + text-align: center; + z-index: 2; + box-shadow: var(--shadow-sm); + animation: controller-medal 1.2s ease-in-out infinite; +} + +.controller-button.marked-leader .controller-badge { + display: block; +} + +@keyframes controller-medal { + 0%, 100% { transform: scale(1) rotate(-4deg); } + 50% { transform: scale(1.12) rotate(4deg); } +} + +/* Presenter sizing */ +.presenter-controller.controller-dpad { max-width: 540px; } +.presenter-controller .controller-glyph { font-size: 48px; } +.presenter-controller .controller-label { font-size: 18px; } +.presenter-controller .controller-count { font-size: 28px; } + +/* Chart */ +.chart { + margin: 1rem auto; +} + +.chart-row { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; +} + +.chart-label { + width: 160px; + text-align: right; + font-family: var(--font-pixel); + font-size: 0.9rem; + font-weight: 500; + flex-shrink: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.chart-bar-track { + flex: 1; + height: 32px; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.chart-bar-fill { + height: 100%; + border-radius: var(--radius-sm) 0 0 var(--radius-sm); + transition: width 0.3s ease-out; + min-width: 0; +} + +.chart-count { + width: 56px; + text-align: right; + font-family: var(--font-pixel); + font-size: 0.95rem; + font-weight: 600; + flex-shrink: 0; +} + +.vote-count { + font-family: var(--font-pixel); + color: var(--ink-soft); + font-size: 0.9rem; + font-weight: 500; + margin-top: 0.75rem; +} + +/* Presenter — body is a two-column grid: main content on the left, + persistent QR sidebar on the right. Show-takeover states (ended, + invalid-session) collapse to a single column. */ +body[data-view="presenter"] { + background: var(--bg); + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 100vh; + min-height: 100vh; +} + +body[data-view="presenter"] #app { + position: relative; + min-width: 0; +} + +.presenter-qr-aside { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 2rem; + border-left: 1px solid var(--border); +} + +.presenter-qr-aside canvas { + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-lg); + background: #fff; + width: clamp(260px, 34vw, 640px); + height: auto; +} + +/* End and bad-session states take the whole screen. */ +body[data-view="presenter"][data-state="ended"], +body[data-view="presenter"][data-state="invalid-session"] { + grid-template-columns: 1fr; +} +body[data-view="presenter"][data-state="ended"] .presenter-qr-aside, +body[data-view="presenter"][data-state="invalid-session"] .presenter-qr-aside { + display: none; +} + +/* Phone-width fallback: too narrow for a useful QR alongside the main + pane, so drop the sidebar entirely and let the content take the screen. */ +@media (max-width: 768px) { + body[data-view="presenter"] { + grid-template-columns: 1fr; + } + .presenter-qr-aside { + display: none; + } +} + +/* Hosted docs sandbox: there's no QR onboarding (the voter pane is already + connected), so drop the sidebar and let the results take the pane. */ +body[data-view="presenter"][data-sandbox] { + grid-template-columns: 1fr; +} +body[data-view="presenter"][data-sandbox] .presenter-qr-aside { + display: none; +} + +/* The docs preview panes are short (~320px). Trim the chrome and cap the + d-pad / chart so a poll fits without being clipped. Sandbox-only — the real + app's full-screen views are untouched (they never set data-sandbox). */ +body[data-sandbox] .brand { display: none; } +body[data-sandbox] .view { padding: 0.75rem; } +body[data-sandbox] .card { padding: 1rem; } + +/* Voter d-pad */ +body[data-sandbox] .controller-dpad { max-width: 210px; } + +/* Presenter d-pad + heading */ +body[data-sandbox] .presenter-center h1 { font-size: 1.1rem; margin-bottom: 0.6rem; } +body[data-sandbox] .presenter-controller.controller-dpad { max-width: 230px; } +body[data-sandbox] .presenter-controller .controller-glyph { font-size: 26px; } +body[data-sandbox] .presenter-controller .controller-label { font-size: 11px; } +body[data-sandbox] .presenter-controller .controller-count { font-size: 18px; } + +/* Presenter bar chart (list polls) */ +body[data-sandbox] .presenter-chart .chart-bar-track { height: 30px; } +body[data-sandbox] .presenter-chart .chart-label { font-size: 0.85rem; width: 120px; } +body[data-sandbox] .presenter-chart .chart-count { font-size: 0.95rem; width: 48px; } + +.presenter-center { + text-align: center; + width: 100%; + max-width: 960px; +} + +.presenter-center h1 { + font-size: 2.5rem; + line-height: 1.3; + margin-bottom: 2rem; +} + +.presenter-chart .chart-label { + font-size: 1.1rem; + width: 240px; +} + +.presenter-chart .chart-bar-track { + height: 56px; +} + +.presenter-chart .chart-count { + font-size: 1.5rem; + width: 90px; +} + +.large { + font-size: 1.5rem; +} + +/* Suggest stage (presenter) */ +.suggest-stage { + position: relative; + width: 100%; + height: 100%; + min-height: 80vh; + display: flex; + align-items: center; + justify-content: center; +} + +.suggest-question { + font-size: 2rem; + line-height: 1.35; + text-align: center; + max-width: 60vw; + z-index: 1; +} + +.bubble-layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.bubble { + position: absolute; + transform: translate(-50%, -50%); + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); + padding: 0.5rem 0.85rem; + display: flex; + flex-direction: column; + gap: 0.15rem; + max-width: 220px; + animation: bubble-fade var(--lifetime, 8000ms) ease-out forwards; +} + +.bubble-text { + font-family: var(--font-pixel); + font-size: 0.95rem; + font-weight: 600; + line-height: 1.35; + word-break: break-word; +} + +.bubble-attribution { + font-family: var(--font-body); + font-size: 0.8rem; + color: var(--ink-soft); +} + +@keyframes bubble-fade { + 0% { opacity: 0; transform: translate(-50%, -50%) scale(0.85); } + 10% { opacity: 1; transform: translate(-50%, -50%) scale(1); } + 100% { opacity: 0; transform: translate(-50%, calc(-50% - 30px)) scale(1); } +} + +/* Admin tabs */ +.admin-tabs { + display: flex; + gap: 0.25rem; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + background: var(--bg); + padding: 0 0.5rem; +} + +.admin-tab { + padding: 0.75rem 1.25rem; + background: transparent; + color: var(--ink-soft); + border: none; + border-bottom: 2px solid transparent; + border-radius: 0; + font-family: var(--font-pixel); + font-size: 0.9rem; + font-weight: 600; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.admin-tab:hover { + color: var(--ink); +} + +.admin-tab.active { + color: var(--ink); + border-bottom-color: var(--primary); +} + +/* Admin layout */ +.admin-layout { + display: flex; + gap: 2rem; + width: 100%; + max-width: 1080px; + height: 100%; +} + +.admin-sidebar { + width: 280px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.admin-info { + background: var(--surface); + padding: 1rem 1.25rem; + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow-sm); +} + +.admin-info p { + margin-bottom: 0.4rem; + font-family: var(--font-pixel); + font-size: 0.72rem; + font-weight: 600; + color: var(--ink-soft); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-info strong { + font-family: var(--font-body); + font-size: 1.05rem; + font-weight: 600; + color: var(--ink); + text-transform: none; + letter-spacing: 0; +} + +.admin-controls { + display: flex; + flex-direction: column; +} + +.admin-controls hr { + border: none; + border-top: 1px solid var(--border); + margin: 1rem 0; +} + +.admin-main { + flex: 1; + background: var(--surface); + padding: 2rem; + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} + +.admin-question { + margin-bottom: 1.5rem; +} + +.jump-row { + display: flex; + gap: 0.5rem; + margin-top: 0.25rem; +} + +.jump-row select { + flex: 1; +} + +.jump-row button { + width: auto; + margin-top: 0; +} + +/* Poll management */ +.manage-layout { + display: flex; + gap: 2rem; + width: 100%; + max-width: 1080px; + height: 100%; +} + +.manage-sidebar { + width: 280px; + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: 1rem; +} + +.manage-sidebar h2 { + margin-bottom: 0.25rem; +} + +.manage-sidebar-actions { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.manage-sidebar-actions .btn-primary { + margin-top: 0; +} + +.show-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 8px; + flex: 1; + min-height: 0; + overflow-y: auto; +} + +.show-list li { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 0.9rem; + border-radius: var(--radius-sm); + background: var(--surface); + border: 1px solid var(--border); + cursor: pointer; + box-shadow: var(--shadow-sm); + transition: border-color 0.15s, box-shadow 0.15s, background 0.15s; +} + +.show-list .show-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 1rem; +} + +.show-list li::after { + content: '›'; + color: var(--ink-soft); + font-size: 1.2rem; + line-height: 1; + margin-left: 0.25rem; + transition: transform 0.15s; +} + +.show-list li:hover { + border-color: var(--primary); + box-shadow: var(--shadow); +} + +.show-list li:hover::after { + transform: translateX(2px); +} + +.show-list li.selected { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +.show-list li.selected::after { + color: #fff; +} + +.show-list li:focus-visible { + outline: 2px solid var(--primary); + outline-offset: 2px; +} + +.show-name { + font-weight: 500; +} + +.show-count { + font-family: var(--font-pixel); + font-size: 0.72rem; + font-weight: 600; + color: var(--ink-soft); +} + +.show-list li.selected .show-count { + color: #fff; +} + +.manage-main { + flex: 1; + overflow-y: auto; +} + +.poll-editor { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.poll-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; +} + +.poll-editor-header h2 { + margin-bottom: 0; + padding: 0.25rem 0.5rem; + outline: none; + min-width: 0; + flex: 1; + border-radius: var(--radius-sm); +} + +.poll-editor-header h2:focus { + background: var(--surface); + box-shadow: 0 0 0 2px var(--primary); +} + +.btn-danger-sm { + background: var(--danger); + border-color: var(--danger); + color: #fff; + padding: 0.45rem 0.85rem; + font-family: var(--font-pixel); + font-size: 0.8rem; + font-weight: 600; + width: auto; + margin: 0; + flex-shrink: 0; +} + +.btn-danger-sm:hover { + background: var(--danger-hover); + border-color: var(--danger-hover); +} + +.poll-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1rem 1.25rem; + box-shadow: var(--shadow-sm); +} + +.poll-card-header { + display: flex; + gap: 0.5rem; + align-items: center; +} + +.poll-card .poll-type-select { + width: auto; + flex-shrink: 0; + font-family: var(--font-pixel); + font-size: 0.85rem; + padding: 0.4rem 0.5rem; +} + +.poll-type-row { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.5rem; +} + +.poll-type-row label { + margin: 0; + font-size: 0.72rem; +} + +.poll-type-row .poll-type-select { + width: auto; + font-family: var(--font-pixel); + font-size: 0.85rem; + padding: 0.4rem 0.5rem; + margin: 0; +} + +.slot-edit-grid { + display: grid; + gap: 6px; + width: 100%; + margin: 0.5rem 0; +} + +.slot-edit-grid.kind-dpad { + grid-template-columns: 1fr 1fr 1fr; + grid-template-rows: 1fr 1fr 1fr; + max-width: 360px; +} + +.slot-edit-slot { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + margin: 0; + padding: 0; +} + +.slot-edit-slot .slot-edit-glyph { + font-family: var(--font-pixel); + font-size: 1rem; + line-height: 1; + color: var(--primary); +} + +.slot-edit-slot .slot-input { + width: 100%; + padding: 0.4rem 0.5rem; + font-size: 0.95rem; + text-align: center; +} + +/* dpad slot placement */ +.kind-dpad .slot-edit-up { grid-column: 2; grid-row: 1; } +.kind-dpad .slot-edit-right { grid-column: 3; grid-row: 2; } +.kind-dpad .slot-edit-down { grid-column: 2; grid-row: 3; } +.kind-dpad .slot-edit-left { grid-column: 1; grid-row: 2; } + +.poll-save-row { + display: flex; + justify-content: flex-end; + margin-top: 0.5rem; +} + +.poll-save-row .btn-save-poll, +.poll-options-edit .btn-save-poll { + width: auto; + margin: 0; + padding: 0.45rem 1rem; + font-size: 0.85rem; +} + +.poll-question-input { + flex: 1; + font-weight: 500; +} + +.poll-card-actions { + display: flex; + gap: 0.35rem; + flex-shrink: 0; +} + +.btn-icon { + width: 36px; + height: 36px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + font-family: var(--font-pixel); + font-size: 1rem; + background: var(--surface); + color: var(--ink); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); +} + +.btn-icon:hover { + background: var(--primary); + border-color: var(--primary); + color: #fff; +} + +.poll-options-edit { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + align-items: center; +} + +.poll-options-input { + flex: 1; + font-size: 1rem; +} + +.poll-options-edit .btn-secondary { + width: auto; + margin: 0; + padding: 0.45rem 1rem; + font-size: 0.85rem; +} + +.add-poll-card { + background: var(--surface); + border: 1px dashed var(--border-strong); + border-radius: var(--radius); + padding: 1rem 1.25rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.add-poll-card .btn-primary { + margin-top: 0.25rem; +} + +/* QR fallback URL — shown beneath the QR for the rare voter without a + working phone camera. Smaller and more subdued than the QR itself. */ +.qr-url-hint { + margin-top: 0.5rem; + font-family: var(--font-body); + font-size: 0.95rem; + color: var(--ink-soft); + word-break: break-all; +} +.qr-url-hint.qr-url { + margin-top: 0.15rem; + color: var(--primary); + font-weight: 600; +} + +/* Suggest poll */ +.suggest-form { + display: flex; + gap: 0.5rem; + margin-top: 0.75rem; + align-items: stretch; +} + +.suggest-form input[type="text"] { + flex: 1; + margin: 0; +} + +.suggest-form button[type="submit"] { + width: auto; + margin: 0; + flex-shrink: 0; +} + +.my-suggestions-heading { + margin-top: 1.5rem; + margin-bottom: 0.5rem; + font-size: 0.9rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--ink-soft); +} + +.my-suggestions { + list-style: none; + display: flex; + flex-direction: column; + gap: 6px; +} + +.my-suggestions li { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.5rem 0.75rem; + font-size: 1rem; +} + +.admin-suggest-list { + list-style: none; + display: flex; + flex-direction: column; + gap: 4px; + max-height: 60vh; + overflow-y: auto; +} + +.admin-suggest-list li { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 0.75rem; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 0.4rem 0.7rem; + font-size: 1rem; +} + +.admin-suggest-text { + flex: 1; + word-break: break-word; +} + +.admin-suggest-from { + font-family: var(--font-pixel); + font-size: 0.78rem; + color: var(--ink-soft); + flex-shrink: 0; +} + +/* Read-only (static demo data) admin mode */ +.readonly-note { + background: var(--primary-soft); + border: 1px solid var(--primary); + color: var(--ink); + border-radius: var(--radius-sm); + padding: 0.6rem 0.9rem; + margin-bottom: 1rem; + font-size: 0.95rem; +} + +body[data-readonly] .manage-sidebar-actions, +body[data-readonly] .add-poll-card, +body[data-readonly] .poll-card-actions, +body[data-readonly] .poll-save-row, +body[data-readonly] #btn-delete-show { display: none; } + +body[data-readonly] #panel-manage input, +body[data-readonly] #panel-manage select, +body[data-readonly] #panel-manage [contenteditable] { + pointer-events: none; + background: var(--bg); + color: var(--ink-soft); +} + +/* Utilities */ +.hidden { display: none !important; } + +/* Error banner */ +.error-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + background: var(--danger); + color: #fff; + padding: 0.75rem 1rem; + text-align: center; + font-family: var(--font-pixel); + font-size: 0.95rem; + font-weight: 600; + z-index: 200; +} + +.subtle { + color: var(--ink-soft); + font-size: 0.95rem; +} + +.error { + color: var(--danger); + text-align: center; + padding: 2rem; + font-family: var(--font-pixel); + font-weight: 600; +} + +.pulse { + animation: pulse 1.4s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } +} + +/* Responsive */ +@media (max-width: 768px) { + .admin-layout, .manage-layout { + flex-direction: column; + } + .admin-sidebar, .manage-sidebar { + width: 100%; + } + .presenter-center h1 { + font-size: 1.6rem; + } + .chart-label { + width: 110px; + font-size: 0.85rem; + } +} + +/* Phones around iPhone SE width (375px) don't have room for the default + 2rem view/card padding plus a 3×3 controller — tighten the surrounding + chrome so the controller stays usable. */ +@media (max-width: 480px) { + .view { padding-left: 1rem; padding-right: 1rem; padding-bottom: 1rem; } + .card { padding: 1.25rem; } + .controller-dpad { gap: 6px; } +} + +/* Below ~360px buttons get small enough that the option label can't share + space with the glyph and count. Drop the dynamic option text and rely on + the static glyph — the chart below still shows label→count. */ +@media (max-width: 360px) { + .controller-label { display: none; } +} diff --git a/examples/pub-sub-live-voting/javascript/src/voter.ts b/examples/pub-sub-live-voting/javascript/src/voter.ts new file mode 100644 index 0000000000..b739272054 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/src/voter.ts @@ -0,0 +1,442 @@ +import * as Ably from 'ably'; +import { createAblyClient, getChannel } from './shared/ably'; +import { renderChart } from './shared/chart'; +import { renderController } from './shared/controller'; +import { voterClientId } from './shared/clientId'; +import { showError } from './shared/errors'; +import { isValidSessionId } from './shared/sessionId'; +import { IS_SANDBOX, SANDBOX_CHANNEL_SEED } from './config'; +import { + VOTE_ANNOTATION_TYPE, + SUGGESTION_ANNOTATION_TYPE, + SESSION_KEY, + VoterState, + PollMessage, + PollType, + ControlMessage, + PollOption, + isControllerKind, +} from './shared/types'; + +// The phone-side view. A voter only ever *publishes* annotations (a vote, or a +// suggestion) onto the current poll message, and *subscribes* to the poll's +// annotation summary to show live percentages. It never sees other voters' +// individual votes — that high-volume stream goes to the presenter only. + +const VIEW_HTML = ` +
    Ably Live Voting
    +
    + +
    +
    +

    Welcome!

    +

    Scan the QR code, or enter the full URL, to join in.

    +
    +
    + + +
    +
    +

    Hmm, that doesn't look right

    +

    That session ID doesn't look right — please double-check the URL or rescan the QR code.

    +
    +
    + + +
    +
    +

    Join the vote

    +

    Session:

    +
    + + + +
    +
    +
    + + +
    +
    +

    Hi, !

    +

    Waiting for the next poll...

    +
    +
    + + +
    +
    +

    +
    +
    +

    +
    +
    + + +
    +
    +

    +
    + + +
    + +
      +
      +
      + + +
      +
      +

      That's all, folks!

      +

      Thanks for taking part.

      +
      +
      +
      +`; + +let state: VoterState = 'enter-name'; +let channel: Ably.RealtimeChannel; +let clientId: string; +let currentPollOptions: PollOption[] = []; +let currentPollType: PollType = 'list'; +let currentPollSerial: string | null = null; +let currentPollId: number | null = null; +let currentSummary: Ably.SummaryUniqueValues | null = null; +let myVote: string | null = null; + +let sessionId: string; + +const $ = (sel: string) => document.querySelector(sel) as HTMLElement; + +function setState(newState: VoterState) { + state = newState; + document.body.dataset.state = newState; + const statusEl = document.getElementById('status-text'); + if (statusEl) { + statusEl.textContent = + newState === 'voted' ? 'Vote recorded! Tap another option to change your vote.' : + newState === 'results' ? 'Voting closed' : ''; + } +} + +interface SavedSession { + clientId: string; + name: string; +} + +const sessionKey = (sid: string) => `voter-session-${sid}`; +const voteKey = (sid: string, pollId: number) => `voter-vote-${sid}-${pollId}`; +const suggestionsKey = (sid: string, pollId: number) => `voter-suggestions-${sid}-${pollId}`; + +function loadSavedSession(sid: string): SavedSession | null { + try { + const raw = sessionStorage.getItem(sessionKey(sid)); + return raw ? JSON.parse(raw) : null; + } catch { + return null; + } +} + +function loadSavedVote(sid: string, pollId: number): string | null { + try { + return sessionStorage.getItem(voteKey(sid, pollId)); + } catch { + return null; + } +} + +function saveVote(sid: string, pollId: number, optionId: string) { + try { + sessionStorage.setItem(voteKey(sid, pollId), optionId); + } catch {} +} + +function loadSavedSuggestions(sid: string, pollId: number): string[] { + try { + const raw = sessionStorage.getItem(suggestionsKey(sid, pollId)); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +} + +function appendSavedSuggestion(sid: string, pollId: number, text: string) { + try { + const existing = loadSavedSuggestions(sid, pollId); + existing.push(text); + sessionStorage.setItem(suggestionsKey(sid, pollId), JSON.stringify(existing)); + } catch {} +} + +function connectAndWait(name: string, existingClientId?: string) { + clientId = existingClientId ?? voterClientId(name); + try { + sessionStorage.setItem(sessionKey(sessionId), JSON.stringify({ clientId, name })); + } catch {} + + $('#display-name').textContent = name; + + const ably = createAblyClient(clientId, sessionId, 'voter'); + channel = getChannel(ably, sessionId, 'voter'); + channel.subscribe(handleMessage); + setState('waiting'); +} + +function handleMessage(msg: Ably.Message) { + if (msg.name === 'poll') { + const data = msg.data as PollMessage; + const summary = msg.annotations?.summary?.[VOTE_ANNOTATION_TYPE] as Ably.SummaryUniqueValues | undefined; + + if (msg.serial !== currentPollSerial) { + // New poll (or rewound message after refresh). + currentPollOptions = data.options; + currentPollType = data.type ?? 'list'; + currentPollSerial = msg.serial!; + currentPollId = data.pollId; + currentSummary = summary ?? null; + + if (currentPollType === 'suggest') { + myVote = null; + renderSuggestPoll(data); + setState('suggesting'); + setSuggestFormDisabled(false); + } else { + myVote = loadSavedVote(sessionId, data.pollId); + renderPoll(data); + if (myVote) { + setState('voted'); + highlightVote(myVote); + } else { + setState('voting'); + } + } + } else if (summary && currentPollType !== 'suggest') { + // Annotation summary update for the current poll. + currentSummary = summary; + renderResults(); + } + } else if (msg.name === 'control') { + const data = msg.data as ControlMessage; + if (data.action === 'end') { + clearVoterStorage(sessionId); + setState('ended'); + return; + } + if (data.action === 'reset') { + currentPollSerial = null; + currentPollId = null; + currentPollOptions = []; + currentSummary = null; + myVote = null; + setState('waiting'); + return; + } + if (data.pollId === currentPollId) { + if (data.action === 'close') { + setState('waiting'); + } + } + } +} + +function clearVoterStorage(sid: string) { + try { + sessionStorage.removeItem(sessionKey(sid)); + const prefixes = [`voter-vote-${sid}-`, `voter-suggestions-${sid}-`]; + for (let i = sessionStorage.length - 1; i >= 0; i--) { + const key = sessionStorage.key(i); + if (key && prefixes.some((p) => key.startsWith(p))) sessionStorage.removeItem(key); + } + } catch {} +} + +function renderSuggestPoll(data: PollMessage) { + $('#suggest-question').textContent = data.question; + const input = $('#suggest-input') as HTMLInputElement; + input.value = ''; + renderMySuggestions(loadSavedSuggestions(sessionId, data.pollId)); +} + +function renderMySuggestions(items: string[]) { + const list = $('#my-suggestions'); + const heading = $('#my-suggestions-heading'); + list.innerHTML = ''; + for (const text of items) { + const li = document.createElement('li'); + li.textContent = text; + list.appendChild(li); + } + heading.classList.toggle('hidden', items.length === 0); +} + +function setSuggestFormDisabled(disabled: boolean) { + const input = $('#suggest-input') as HTMLInputElement; + const submit = ($('#suggest-form') as HTMLFormElement).querySelector('button') as HTMLButtonElement; + input.disabled = disabled; + submit.disabled = disabled; + if (disabled) input.placeholder = 'Suggestions closed'; + else input.placeholder = 'Type a suggestion…'; +} + +async function castSuggestion(text: string) { + if (!currentPollSerial || currentPollId === null) return; + try { + // A suggestion is just an annotation with a free-text `name` on the poll + // message. Same mechanism as a vote, different annotation type. + await channel.annotations.publish(currentPollSerial, { + type: SUGGESTION_ANNOTATION_TYPE, + name: text, + }); + appendSavedSuggestion(sessionId, currentPollId, text); + renderMySuggestions(loadSavedSuggestions(sessionId, currentPollId)); + } catch (err: any) { + showError(`Failed to submit: ${err.message || err}`); + } +} + +function renderControllerForVoter(selectedId: string | null) { + if (!isControllerKind(currentPollType)) return; + renderController(currentPollType, $('#options'), currentPollOptions, currentSummary, { + clickable: state !== 'results', + onClick: castVote, + selectedId, + showHeatmap: false, + showLeaderBadge: false, + display: 'percent', + }); +} + +function renderPoll(data: PollMessage) { + $('#question').textContent = data.question; + + const optionsEl = $('#options'); + const chartEl = $('#chart'); + + if (isControllerKind(currentPollType)) { + chartEl.innerHTML = ''; + chartEl.classList.add('hidden'); + optionsEl.innerHTML = ''; + optionsEl.classList.remove('options-grid'); + renderControllerForVoter(myVote); + } else { + chartEl.classList.remove('hidden'); + optionsEl.classList.remove('controller', 'controller-dpad'); + optionsEl.classList.add('options-grid'); + optionsEl.innerHTML = ''; + for (const opt of data.options) { + const btn = document.createElement('button'); + btn.className = 'vote-button'; + btn.dataset.optionId = opt.id; + btn.textContent = opt.label; + btn.addEventListener('click', () => castVote(opt.id)); + optionsEl.appendChild(btn); + } + renderChart(chartEl, currentPollOptions, currentSummary, 'percent'); + } +} + +function renderResults() { + if (isControllerKind(currentPollType)) { + renderControllerForVoter(myVote); + } else { + renderChart($('#chart'), currentPollOptions, currentSummary, 'percent'); + } +} + +function highlightVote(optionId: string) { + if (isControllerKind(currentPollType)) { + renderControllerForVoter(optionId); + } else { + document.querySelectorAll('.vote-button').forEach((btn) => { + (btn as HTMLElement).classList.toggle('selected', (btn as HTMLElement).dataset.optionId === optionId); + }); + } +} + +async function castVote(optionId: string) { + if (!currentPollSerial || state === 'results') return; + + try { + // The whole vote: publish a `vote:unique.v1` annotation naming the chosen + // option onto the poll message. Because it's a `unique` annotation, Ably + // replaces any previous vote from this clientId — tapping another option + // moves your vote rather than adding a second one. + await channel.annotations.publish(currentPollSerial, { + type: VOTE_ANNOTATION_TYPE, + name: optionId, + }); + myVote = optionId; + if (currentPollId !== null) saveVote(sessionId, currentPollId, optionId); + highlightVote(optionId); + setState('voted'); + } catch (err: any) { + showError(`Failed to vote: ${err.message || err}`); + } +} + +function wireSuggestForm() { + const suggestForm = $('#suggest-form') as HTMLFormElement; + suggestForm.addEventListener('submit', (e) => { + e.preventDefault(); + const input = $('#suggest-input') as HTMLInputElement; + const text = input.value.trim(); + if (!text) return; + castSuggestion(text); + input.value = ''; + input.focus(); + }); +} + +function init() { + // Sandbox: skip the QR/session/name flow entirely and join the shared demo + // channel as a single voter, so the preview pane is interactive immediately. + if (IS_SANDBOX) { + document.body.dataset.sandbox = 'true'; + sessionId = SANDBOX_CHANNEL_SEED!; + wireSuggestForm(); + connectAndWait('You'); + return; + } + + const params = new URLSearchParams(window.location.search); + sessionId = params.get(SESSION_KEY) ?? ''; + if (!sessionId) { + setState('no-session'); + return; + } + if (!isValidSessionId(sessionId)) { + setState('invalid-session'); + return; + } + + $('#session-name').textContent = sessionId; + + wireSuggestForm(); + + const saved = loadSavedSession(sessionId); + if (saved) { + connectAndWait(saved.name, saved.clientId); + return; + } + + setState('enter-name'); + const nameInput = $('#name-input') as HTMLInputElement; + nameInput.focus(); + + const form = $('#name-form') as HTMLFormElement; + form.addEventListener('submit', (e) => { + e.preventDefault(); + const name = nameInput.value.trim(); + if (!name) return; + connectAndWait(name); + }); +} + +export function mount() { + document.body.dataset.view = 'voter'; + document.body.dataset.state = 'enter-name'; + document.body.innerHTML = VIEW_HTML; + init(); +} diff --git a/examples/pub-sub-live-voting/javascript/tailwind.config.ts b/examples/pub-sub-live-voting/javascript/tailwind.config.ts new file mode 100644 index 0000000000..1c86e1c371 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/tailwind.config.ts @@ -0,0 +1,9 @@ +import baseConfig from '../../tailwind.config'; +import type { Config } from 'tailwindcss'; + +const config: Config = { + ...baseConfig, + content: ['./src/**/*.{js,ts,tsx}', './index.html'], +}; + +export default config; diff --git a/examples/pub-sub-live-voting/javascript/tsconfig.json b/examples/pub-sub-live-voting/javascript/tsconfig.json new file mode 100644 index 0000000000..7b5d055766 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "include": ["./src/**/*.ts", "./src/**/*.tsx"], + "exclude": ["../../node_modules", "../../dist", "../../lib"] +} diff --git a/examples/pub-sub-live-voting/javascript/vite-env.d.ts b/examples/pub-sub-live-voting/javascript/vite-env.d.ts new file mode 100644 index 0000000000..5c9d57f19d --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/vite-env.d.ts @@ -0,0 +1,9 @@ +interface ImportMetaEnv { + // Hosted docs sandbox only — injected by the docs build. Unset in a clone. + readonly VITE_ABLY_KEY: string; + readonly VITE_NAME: string; +} + +interface ImportMeta { + readonly env: ImportMetaEnv; +} diff --git a/examples/pub-sub-live-voting/javascript/vite.config.ts b/examples/pub-sub-live-voting/javascript/vite.config.ts new file mode 100644 index 0000000000..082d066270 --- /dev/null +++ b/examples/pub-sub-live-voting/javascript/vite.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite'; +import baseConfig from '../../vite.config'; + +export default defineConfig({ + ...baseConfig, + envDir: '../../', + // Clone-only convenience: when running the client on Vite's dev server, + // proxy the auth + shows API to the local Express server (see ../server). + // The hosted docs sandbox ignores this — it runs without a backend. + server: { + proxy: { + '/auth': 'http://localhost:3000', + '/api': 'http://localhost:3000', + }, + }, +}); diff --git a/examples/yarn.lock b/examples/yarn.lock index 7962b898be..98121e6414 100644 --- a/examples/yarn.lock +++ b/examples/yarn.lock @@ -835,6 +835,13 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.15.tgz#e6e5a86d602beaca71ce5163fadf5f95d70931c7" integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== +"@types/qrcode@^1.5.5": + version "1.5.6" + resolved "https://registry.yarnpkg.com/@types/qrcode/-/qrcode-1.5.6.tgz#07c33cb9ec0ad88be4636e636e28e54d99b65f42" + integrity sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw== + dependencies: + "@types/node" "*" + "@types/qs@*": version "6.14.0" resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.14.0.tgz#d8b60cecf62f2db0fb68e5e006077b9178b85de5" @@ -1152,6 +1159,11 @@ camelcase-css@^2.0.1: resolved "https://registry.yarnpkg.com/camelcase-css/-/camelcase-css-2.0.1.tgz#ee978f6947914cc30c6b44741b6ed1df7f043fd5" integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== +camelcase@^5.0.0: + version "5.3.1" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746: version "1.0.30001748" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001748.tgz#628a5a9293014e58f8ba1216bb4966b04c58bee0" @@ -1185,6 +1197,15 @@ chownr@^3.0.0: resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4" integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g== +cliui@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-6.0.0.tgz#511d702c0c4e41ca156d7d0e96021f23e13225b1" + integrity sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^6.2.0" + clone-response@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/clone-response/-/clone-response-1.0.3.tgz#af2032aa47816399cf5f0a1d0db902f517abb8c3" @@ -1287,6 +1308,11 @@ debug@^4.1.0, debug@^4.3.1, debug@^4.3.2: dependencies: ms "^2.1.3" +decamelize@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" + integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== + decompress-response@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/decompress-response/-/decompress-response-6.0.0.tgz#ca387612ddb7e104bd16d85aab00d5ecf09c66fc" @@ -1329,6 +1355,11 @@ didyoumean@^1.2.2: resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== +dijkstrajs@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23" + integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA== + dlv@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/dlv/-/dlv-1.1.3.tgz#5c198a8a11453596e751494d49874bc7732f2e79" @@ -1663,6 +1694,14 @@ finalhandler@1.3.1: statuses "2.0.1" unpipe "~1.0.0" +find-up@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + find-up@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" @@ -1733,6 +1772,11 @@ gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== +get-caller-file@^2.0.1: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + get-intrinsic@^1.2.5, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" @@ -2120,6 +2164,13 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + locate-path@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" @@ -2385,6 +2436,13 @@ p-cancelable@^2.0.0: resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-2.1.1.tgz#aab7fbd416582fa32a3db49859c122487c5ed2cf" integrity sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg== +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + p-limit@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -2392,6 +2450,13 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + p-locate@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" @@ -2399,6 +2464,11 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + package-json-from-dist@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" @@ -2469,6 +2539,11 @@ pirates@^4.0.1: resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== +pngjs@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" + integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== + postcss-combine-duplicated-selectors@^10.0.3: version "10.0.3" resolved "https://registry.yarnpkg.com/postcss-combine-duplicated-selectors/-/postcss-combine-duplicated-selectors-10.0.3.tgz#71e8b6783e99cd560cf08ba7b896ad0db318c11c" @@ -2561,6 +2636,15 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== +qrcode@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/qrcode/-/qrcode-1.5.4.tgz#5cb81d86eb57c675febb08cf007fff963405da88" + integrity sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg== + dependencies: + dijkstrajs "^1.0.1" + pngjs "^5.0.0" + yargs "^15.3.1" + qs@6.13.0: version "6.13.0" resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" @@ -2647,6 +2731,16 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +require-main-filename@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" + integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== + resolve-alpn@^1.0.0: version "1.2.1" resolved "https://registry.yarnpkg.com/resolve-alpn/-/resolve-alpn-1.2.1.tgz#b7adbdac3546aaaec20b45e7d8265927072726f9" @@ -2777,6 +2871,11 @@ serve-static@1.16.2: parseurl "~1.3.3" send "0.19.0" +set-blocking@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" + integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== + setprototypeof@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" @@ -2863,7 +2962,7 @@ statuses@2.0.1: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string-width@^4.1.0: +string-width@^4.1.0, string-width@^4.2.0: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -3130,6 +3229,11 @@ vite@5: optionalDependencies: fsevents "~2.3.3" +which-module@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" + integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" @@ -3151,6 +3255,15 @@ word-wrap@^1.2.5: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz#e9393ba07102e6c91a3b221478f0257cd2856e53" + integrity sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -3170,6 +3283,11 @@ ws@^8.17.1: resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.3.tgz#b56b88abffde62791c639170400c93dcb0c95472" integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg== +y18n@^4.0.0: + version "4.0.3" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf" + integrity sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ== + yallist@^3.0.2: version "3.1.1" resolved "https://registry.yarnpkg.com/yallist/-/yallist-3.1.1.tgz#dbb7daf9bfd8bac9ab45ebf602b8cbad0d5d08fd" @@ -3180,6 +3298,31 @@ yallist@^5.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533" integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== +yargs-parser@^18.1.2: + version "18.1.3" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-18.1.3.tgz#be68c4975c6b2abf469236b0c870362fab09a7b0" + integrity sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ== + dependencies: + camelcase "^5.0.0" + decamelize "^1.2.0" + +yargs@^15.3.1: + version "15.4.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-15.4.1.tgz#0d87a16de01aee9d8bec2bfbf74f67851730f4f8" + integrity sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A== + dependencies: + cliui "^6.0.0" + decamelize "^1.2.0" + find-up "^4.1.0" + get-caller-file "^2.0.1" + require-directory "^2.1.1" + require-main-filename "^2.0.0" + set-blocking "^2.0.0" + string-width "^4.2.0" + which-module "^2.0.0" + y18n "^4.0.0" + yargs-parser "^18.1.2" + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" diff --git a/gatsby-config.ts b/gatsby-config.ts index a6a5597171..0d1665eac7 100644 --- a/gatsby-config.ts +++ b/gatsby-config.ts @@ -150,6 +150,11 @@ export const plugins = [ options: { name: `examples`, path: `${__dirname}/examples`, + // Examples can contain nested projects (e.g. a server) with their own + // installed deps and build output. Never source those — a node_modules + // under an `//` dir would otherwise be picked up as example + // files for that language. + ignore: ['**/node_modules/**', '**/dist/**'], }, }, { diff --git a/src/components/Examples/ExamplesRenderer.tsx b/src/components/Examples/ExamplesRenderer.tsx index 8ccadbc0e3..e2498a33cf 100644 --- a/src/components/Examples/ExamplesRenderer.tsx +++ b/src/components/Examples/ExamplesRenderer.tsx @@ -52,6 +52,7 @@ const getDependencies = (id: string, products: string[], activeLanguage: Languag : {}), ...(products.includes('spaces') ? { '@ably/spaces': '~0.4.0' } : {}), ...(id === 'spaces-component-locking' ? { 'usehooks-ts': '^3.1.0' } : {}), + ...(id === 'pub-sub-live-voting' ? { qrcode: '^1.5.4' } : {}), ...(activeLanguage === 'react' || products.includes('chat') || products.includes('spaces') ? { react: '^18', @@ -200,8 +201,17 @@ const ExamplesRenderer = ({ showRefreshButton {...(id === 'pub-sub-message-encryption' && { startRoute: '?encrypted=true' })} {...(id === 'pub-sub-message-annotations' && { startRoute: '?clientId=user1' })} + {...(id === 'pub-sub-live-voting' && { startRoute: '?role=voter' })} + /> + - {isDoubleLayout && (
      @@ -210,8 +220,9 @@ const ExamplesRenderer = ({ showOpenInCodeSandbox={false} startRoute="/?publisher=false" {...(id === 'pub-sub-message-annotations' && { startRoute: '?publisher=false&clientId=user2' })} + {...(id === 'pub-sub-live-voting' && { startRoute: '?role=presenter&demo=1' })} /> - +
      )} diff --git a/src/data/examples/index.ts b/src/data/examples/index.ts index a09a3d2fa9..21e57f3987 100644 --- a/src/data/examples/index.ts +++ b/src/data/examples/index.ts @@ -249,6 +249,25 @@ export const examples: Example[] = [ metaTitle: `Implement message annotations with the Ably Pub/Sub SDK`, metaDescription: `Use the Ably Pub/Sub JavaScript SDK to implement message annotations. Annotate messages with additional data and view summaries of the annotations.`, }, + { + id: 'pub-sub-live-voting', + name: 'Live voting with annotations', + description: 'Run live polls and tally votes in realtime using message annotations.', + products: ['pubsub'], + languages: ['javascript'], + layout: 'double-horizontal', + githubUrl: 'https://github.com/ably-demos/live-voting-with-annotations', + visibleFiles: [ + 'src/script.ts', + 'src/shared/ably.ts', + 'src/voter.ts', + 'src/presenter.ts', + 'src/shared/types.ts', + 'src/demo-shows.json', + ], + metaTitle: `Build live voting using annotations with the Ably Pub/Sub SDK`, + metaDescription: `Build a realtime live-voting app with Ably message annotations. Tally votes server-side with unique-vote summaries and stream live results to a presenter view.`, + }, { id: 'spaces-avatar-stack', name: 'Avatar stack', diff --git a/src/data/examples/types.ts b/src/data/examples/types.ts index abdfa6f87d..879713bba4 100644 --- a/src/data/examples/types.ts +++ b/src/data/examples/types.ts @@ -10,6 +10,10 @@ export type Example = { visibleFiles?: string[]; metaTitle?: string; metaDescription?: string; + // Override for the "View on GitHub" button. Defaults to this example's folder + // in the ably/docs repo; set it when the canonical source lives elsewhere + // (e.g. a standalone ably-demos repo). + githubUrl?: string; }; export type ExampleWithContent = Example & { diff --git a/src/images/examples/pub-sub-live-voting.png b/src/images/examples/pub-sub-live-voting.png new file mode 100644 index 0000000000..ee588d9368 Binary files /dev/null and b/src/images/examples/pub-sub-live-voting.png differ diff --git a/src/templates/examples.tsx b/src/templates/examples.tsx index 67cd3222a2..023e3c5774 100644 --- a/src/templates/examples.tsx +++ b/src/templates/examples.tsx @@ -171,7 +171,9 @@ const Examples = ({ pageContext }: { pageContext: { example: ExampleWithContent ) : null}