Skip to content

Commit 537ac59

Browse files
authored
Merge branch 'main' into dx-1128/docs-components-interactive
2 parents 72f8545 + 8f75f53 commit 537ac59

44 files changed

Lines changed: 4862 additions & 4 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

examples/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
"pub-sub-rewind/react",
4949
"pub-sub-rewind/javascript",
5050
"pub-sub-message-annotations/javascript",
51+
"pub-sub-live-voting/javascript",
5152
"spaces-avatar-stack/react",
5253
"spaces-avatar-stack/javascript",
5354
"spaces-component-locking/react",
@@ -100,6 +101,7 @@
100101
"pub-sub-rewind-javascript": "yarn workspace pub-sub-rewind-javascript dev",
101102
"pub-sub-rewind-react": "yarn workspace pub-sub-rewind-react dev",
102103
"pub-sub-message-annotations-javascript": "yarn workspace pub-sub-message-annotations-javascript dev",
104+
"pub-sub-live-voting-javascript": "yarn workspace pub-sub-live-voting-javascript dev",
103105
"spaces-avatar-stack-javascript": "yarn workspace spaces-avatar-stack-javascript dev",
104106
"spaces-avatar-stack-react": "yarn workspace spaces-avatar-stack-react dev",
105107
"spaces-component-locking-javascript": "yarn workspace spaces-component-locking-javascript dev",
@@ -119,6 +121,7 @@
119121
"lodash": "^4.18.1",
120122
"minifaker": "^1.34.1",
121123
"nanoid": "^5.0.7",
124+
"qrcode": "^1.5.4",
122125
"react": "^18",
123126
"react-dom": "^18",
124127
"react-icons": "^5.4.0",
@@ -130,6 +133,7 @@
130133
"@tailwindcss/postcss": "^4.0.14",
131134
"@types/express": "^5.0.0",
132135
"@types/node": "^20",
136+
"@types/qrcode": "^1.5.5",
133137
"@types/react": "^18",
134138
"@types/react-dom": "^18",
135139
"@types/uikit": "^3.7.0",
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
node_modules
2+
dist
3+
*.local
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Build live voting with message annotations
2+
3+
This is a live-voting app: an admin runs a show of polls, an audience votes
4+
from their phones, and a big screen shows results updating in realtime. The
5+
voter pane and presenter pane on the left are both live: cast a vote and watch
6+
the presenter's heatmap and vote bubbles react instantly.
7+
8+
There's a lot of different ways you can implement this using Ably. This example
9+
shows one possible pattern, using
10+
[**annotations**](/docs/messages/annotations) for votes. A poll
11+
is a regular Ably message, and each vote is an annotation attached to that
12+
message. Ably aggregates the annotations into a summary, attached to the
13+
original poll message, and delivers it to subscribers.
14+
15+
Votes are never stored in a database. They live entirely as annotations on the channel, and Ably does the aggregation. Your own server's job is narrow: it authenticates clients and it hands out the poll definitions. Anything expensive and latency-sensitive is done by Ably.
16+
17+
## Why annotations?
18+
19+
The simplest way you could imagine implementing a live voting system with Ably would be to have each vote be a message. The presenter would subscribe to the channel and keep a running tally in memory, and re-render when needed. This works for a demo, but has drawbacks. Everyone gets the same set of messages, so every voter sees everyone's vote unnecessarily; and nothing constrains one client to only have one vote without custom deduplication logic in any client that wants to count votes, which could get increasingly expensive as the set of voters grows. There's no server-side aggregation, so a phone that joins late, or a presenter view that refreshes, has missed the votes that already happened and has to reconstruct the tally from history. (And imagine if you do have a hundred thousand voters, you'd have to paginate through tens of thousands of pages of history to get the vote count).
20+
21+
[LiveObjects](/docs/liveobjects) is another possibility, which solves some of these problems. You could model the tally as a [`LiveCounter`](https://ably.com/docs/liveobjects/counter) per option; a synchronised, conflict-free counter on the channel. It's a lovely primitive. But there's no built-in one-vote-per-client constraint: any client can call `increment` as often as it likes. To stop ballot-stuffing you'd need a separate [`LiveMap`](https://ably.com/docs/liveobjects/map) of `clientId → vote` and check it before incrementing, but there's no atomic check-and-increment across the two, so a double-counting is still possible. And there's no fine-grained capabilities; any client with the ability to increment one livecounter can arbitrarily mutate the whole state.
22+
23+
So, [annotations](/docs/messages/annotations), which have three big advantages:
24+
25+
- Summaries and raw events are two separate streams, and different clients want different ones. A voter's phone only needs the *summary*: a single compact, rolled-up object, with aggregated information on all the annotations that have been contributed. The exact information in the summary depends on the annotation's [aggregation type](/docs/messages/annotations#aggregation), and there are several choices. With very popular polls, each voter only gets a periodically-rolled-up summary update, not one event per vote. But the presenter screen does want to see individual annotation events, so it can animate a bubble for each one (and label it with the voter who cast it), and it can opt into that higher-volume stream separately, with the [`ANNOTATION_SUBSCRIBE` channel mode](https://ably.com/docs/messages/annotations#individual-annotations) and an `annotations.subscribe()` listener. Same underlying data is exposed in two ways, each appropriate to the view that needs it.
26+
27+
- Capabilities for least-privilege. Because a vote is an annotation, a voter's [token](/docs/auth/capabilities) can grant *only* the `annotation-publish` capability on the channel (and `message-subscribe`). A voter can cast votes and do literally nothing else: it can't publish poll messages (which are 'full' messages, not annotations), and it can't subscribe to other voters' raw annotations. It can't tamper with the poll or snoop on who voted for what. The presenter gets `annotation-subscribe`; the admin gets full message publish rights. 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).
28+
29+
- One person, one vote, enforced by Ably. Votes use the `unique` [aggregation type](/docs/messages/annotations#aggregation). In `unique` mode Ably keeps at most one annotation per `clientId`, and switching to another option *moves* that client's vote rather than adding a second one. As long as your auth server vets users and assigns each one a unique `clientId`, a careful choice of aggregation type gives you the vote semantics you need.
30+
31+
## Roles
32+
33+
The app has three roles.
34+
35+
- The *voter* is the phone in your hand: pick an option (a list, or a d-pad for something more playful), and watch the live percentages. It only ever publishes annotations and reads the summary.
36+
- The *presenter* is the big screen. It reads the summary to draw the bar chart or d-pad heatmap (with a star badge on the current leader), and subscribes to individual annotations to pop a bubble for each vote and suggestion as it lands.
37+
- The *admin* is the operator console, the only role that publishes poll messages. It walks through a show of polls, opens and closes voting, and (because it can both publish and subscribe) shows the organiser a live view of what's happening.
38+
39+
## How it works
40+
41+
1. The admin starts a poll by publishing a message:
42+
43+
```javascript
44+
await channel.publish('poll', { pollId, question, type, options });
45+
```
46+
47+
2. A voter attaches a `vote:unique.v1` annotation to that message's `serial`,
48+
naming the chosen option:
49+
50+
```javascript
51+
await channel.annotations.publish(pollSerial, {
52+
type: 'vote:unique.v1',
53+
name: optionId,
54+
});
55+
```
56+
57+
3. Ably aggregates the votes and delivers a summary on the poll message. Voters
58+
read it to render live percentages:
59+
60+
```javascript
61+
channel.subscribe((message) => {
62+
const summary = message.annotations?.summary?.['vote:unique.v1'];
63+
// summary[optionId].total === votes for that option
64+
});
65+
```
66+
67+
4. The presenter additionally subscribes to the individual events for its vote
68+
and suggestion bubbles:
69+
70+
```javascript
71+
channel.annotations.subscribe('vote:unique.v1', (annotation) => {
72+
// one event per vote — annotation.name is the option, annotation.clientId the voter
73+
});
74+
```
75+
76+
That annotation type string, `vote:unique.v1`, follows the `namespace:summarization.version` convention; `unique` is one of [five aggregation types](/docs/messages/annotations#aggregation) (`unique`, `distinct`, `multiple`, `total`, `flag`), each rolling up the same raw annotations a different way.
77+
78+
The channel [is attached with `rewind: 1`](/docs/channels/options/rewind), so a phone that joins (or refreshes) mid-poll immediately receives the current poll message and its latest summary. Late joiners are caught up without the admin republishing anything.
79+
80+
## Unaggregated annotations
81+
82+
Open-ended "suggest" polls (where voters type free text instead of picking an
83+
option) use a second annotation type, `suggestion`, with no aggregation suffix.
84+
Votes need a running tally, so they use `unique` and are read from the summary.
85+
A suggestion is a one-off piece of text that floats across the presenter once
86+
and is gone, with nothing to count. Since they are unaggregated, they are
87+
received by anyone subscribing to annotations, and they are retrievable through
88+
anyone using
89+
[annotations.get()](/docs/messages/annotations#retrieve) to
90+
retrieve a list of annotations for a given message, but they don't result in a
91+
new message summary being generated.
92+
93+
## Server-side batching
94+
95+
For high volume usecases, where individual votes might (say) overwhelm the
96+
presenter view's message rate limits, you can turn on [server-side
97+
batching](/docs/messages/batch) for the `voting` channel namespace so Ably
98+
groups messages over a short interval (for example 100ms) before fanning them
99+
out. This keeps cost and rate-limits in check during surges with only a small,
100+
configurable delay. Create the rule in your app settings, or via the Control
101+
API / CLI:
102+
103+
```shell
104+
ably apps rules create --name "voting" --batching-enabled --batching-interval 100
105+
```
106+
107+
## Required channel rule
108+
109+
Annotations require the *Message annotations, updates, deletes, and appends*
110+
rule to be enabled on the channel or namespace. Enable it for the `voting`
111+
namespace before running this against your own app.
112+
113+
## Getting started
114+
115+
The live demo above is a cut-down stub of the full demo, intended to be run entirely in the browser, to demonstrate the voting page. If you want to try it out yourself, the real example, with a proper auth server and the admin/voter/presenter split and token authentication, lives at [ably-demos/live-voting-with-annotations](https://github.com/ably-demos/live-voting-with-annotations).
116+
117+
Its README has the full setup, but in short: give the server an Ably API key and
118+
an admin password, pick the static (`SHOWS_FILE`) or Postgres poll store, then
119+
run the server and the client. The default view is the voter; `?role=admin`
120+
drives the show and `?role=presenter` is the big screen, and the admin shows a QR
121+
code that points voters at the right session.
122+
123+
The client never sees an API key — it calls the server's `/auth` endpoint for
124+
short-lived, role-scoped tokens. The hosted demo above has no backend, so it uses
125+
a raw key embedded in the page; that's just for the demo and should never be done
126+
in a real app.
127+
128+
## Related
129+
130+
- [Message annotations](/docs/messages/annotations) — the full feature reference.
131+
- [Message annotations example](/examples/pub-sub-message-annotations) — a
132+
lower-level tour of all five aggregation types.
133+
- [Message batching](/docs/messages/batch) — scaling high-throughput channels.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
{
2+
"shows": [
3+
{
4+
"id": 1,
5+
"name": "Ably Live Voting demo",
6+
"polls": [
7+
{
8+
"id": 1,
9+
"question": "Which real-time feature do you reach for most?",
10+
"type": "list",
11+
"options": [
12+
{ "id": 101, "label": "Pub/Sub channels" },
13+
{ "id": 102, "label": "Presence" },
14+
{ "id": 103, "label": "Message history" },
15+
{ "id": 104, "label": "Push notifications" }
16+
]
17+
},
18+
{
19+
"id": 2,
20+
"question": "Rate this demo with the d-pad",
21+
"type": "dpad",
22+
"options": [
23+
{ "id": 201, "label": "Love it", "direction": "up" },
24+
{ "id": 202, "label": "It's neat", "direction": "right" },
25+
{ "id": 203, "label": "Meh", "direction": "down" },
26+
{ "id": 204, "label": "Confused", "direction": "left" }
27+
]
28+
},
29+
{
30+
"id": 3,
31+
"question": "What should we build with Ably next?",
32+
"type": "suggest",
33+
"options": []
34+
}
35+
]
36+
}
37+
]
38+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
ALTER TABLE polls
2+
ADD COLUMN IF NOT EXISTS type TEXT NOT NULL DEFAULT 'list'
3+
CHECK (type IN ('list', 'dpad'));
4+
5+
ALTER TABLE poll_options
6+
ADD COLUMN IF NOT EXISTS direction TEXT
7+
CHECK (direction IS NULL OR direction IN ('up', 'right', 'down', 'left'));
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TABLE polls DROP CONSTRAINT IF EXISTS polls_type_check;
2+
ALTER TABLE polls ADD CONSTRAINT polls_type_check
3+
CHECK (type IN ('list', 'dpad', 'suggest'));
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- Optional sample data for the Postgres store. The d-pad and suggest poll
2+
-- types make the presenter view more expressive than list polls alone.
3+
INSERT INTO shows (name) VALUES ('Ably Live Voting demo');
4+
5+
INSERT INTO polls (show_id, question, type, sort_order) VALUES
6+
(1, 'Which real-time feature do you reach for most?', 'list', 0),
7+
(1, 'Rate this demo with the d-pad', 'dpad', 1),
8+
(1, 'What should we build with Ably next?', 'suggest', 2);
9+
10+
INSERT INTO poll_options (poll_id, label, direction, sort_order) VALUES
11+
(1, 'Pub/Sub channels', NULL, 0),
12+
(1, 'Presence', NULL, 1),
13+
(1, 'Message history', NULL, 2),
14+
(1, 'Push notifications', NULL, 3),
15+
(2, 'Love it', 'up', 0),
16+
(2, 'It''s neat', 'right', 1),
17+
(2, 'Meh', 'down', 2),
18+
(2, 'Confused', 'left', 3);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
CREATE TABLE shows (
2+
id SERIAL PRIMARY KEY,
3+
name TEXT NOT NULL
4+
);
5+
6+
CREATE TABLE polls (
7+
id SERIAL PRIMARY KEY,
8+
show_id INTEGER NOT NULL REFERENCES shows(id) ON DELETE CASCADE,
9+
question TEXT NOT NULL,
10+
sort_order INTEGER NOT NULL DEFAULT 0,
11+
type TEXT NOT NULL DEFAULT 'list'
12+
CHECK (type IN ('list', 'dpad', 'suggest'))
13+
);
14+
15+
CREATE TABLE poll_options (
16+
id SERIAL PRIMARY KEY,
17+
poll_id INTEGER NOT NULL REFERENCES polls(id) ON DELETE CASCADE,
18+
label TEXT NOT NULL,
19+
sort_order INTEGER NOT NULL DEFAULT 0,
20+
direction TEXT
21+
CHECK (direction IS NULL OR direction IN (
22+
'up', 'right', 'down', 'left'
23+
))
24+
);
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
4+
<head>
5+
<meta charset="UTF-8">
6+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
7+
<link rel="preconnect" href="https://fonts.googleapis.com">
8+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
10+
<link rel="stylesheet" href="src/styles.css" />
11+
<title>Ably Live Voting</title>
12+
</head>
13+
14+
<body>
15+
<!-- script.ts reads ?role= and renders the voter, presenter, or admin view. -->
16+
<script type="module" src="src/script.ts"></script>
17+
</body>
18+
19+
</html>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "pub-sub-live-voting-javascript",
3+
"version": "1.0.0",
4+
"main": "index.js",
5+
"license": "MIT",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "tsc && vite build",
9+
"preview": "vite preview"
10+
}
11+
}

0 commit comments

Comments
 (0)