Skip to content

Feature/pub 1765 annotations examples #2636

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 13 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions examples/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
"pub-sub-presence/javascript",
"pub-sub-rewind/react",
"pub-sub-rewind/javascript",
"pub-sub-message-annotations/javascript",
"spaces-avatar-stack/react",
"spaces-avatar-stack/javascript",
"spaces-component-locking/react",
Expand Down Expand Up @@ -84,6 +85,7 @@
"pub-sub-presence-react": "yarn workspace pub-sub-presence-react dev",
"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",
"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",
Expand Down
36 changes: 36 additions & 0 deletions examples/pub-sub-message-annotations/javascript/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz

# testing
/coverage

# next.js
/.next/
/out/

# production
/build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env*.local

# vercel
.vercel

# typescript
*.tsbuildinfo
next-env.d.ts
90 changes: 90 additions & 0 deletions examples/pub-sub-message-annotations/javascript/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Adding annotations to messages with Pub/Sub

Enable users to annotate messages with additional data, such as reactions, flags, or other contextual information without modifying the original message content.

Message annotations provide a powerful way to extend messages with additional information. Unlike editing a message, annotations allow multiple clients to add their own metadata while preserving the original message. This is ideal for implementing features like reactions, content categorization, moderation flags, or any other metadata that enhances message context.

Message annotations are implemented using [Ably Pub/Sub](/docs/channels). The Pub/Sub SDK with annotations provides a way to add structured metadata to messages, with support for different annotation types and automatic summarization.

## Resources

Use the following methods to work with message annotations in a pub/sub application:

- [`channels.get()`](/docs/channels#create) - creates a new or retrieves an existing `channel`. Specify the `ANNOTATION_PUBLISH` and `ANNOTATION_SUBSCRIBE` modes to publish and subscribe to message annotations.
- [`channel.subscribe()`](/docs/pub-sub#subscribe) - subscribes to message events within a specific channel by registering a listener. Message events with a `message.create` action are received when a user publishes a message. Message events with a `message.summary` action are received when a user publishes or deletes an annotation.
<!-- TODO links -->
- `channel.annotations.publish()` - publishes an annotation for a specific message
- `channel.annotations.subscribe()` - subscribes to receive individual annotation events
- `channel.annotations.delete()` - deletes a previously published annotation

<!-- TODO link -->
Find out more about annotations.

## Annotation Types

This example demonstrates five common annotation types, each suited to different use cases:

<!-- TODO -->

## Features

This example demonstrates:

1. Publishing regular messages to a channel
2. Adding different types of annotations to messages
3. Viewing both summarized and raw annotation data
4. Deleting annotations

## Getting started

1. Clone the [Ably docs](https://github.com/ably/docs) repository where this example can be found:

```sh
git clone git@github.com:ably/docs.git
```

2. Change directory:

```sh
cd /examples/
```

3. Rename the environment file:

```sh
mv .env.example .env.local
```

4. In `.env.local` update the value of `VITE_ABLY_KEY` to be your Ably API key.

5. Install dependencies:

```sh
yarn install
```

6. Run the server:

```sh
yarn run pub-sub-message-annotations-javascript
```

7. Try it out by opening two tabs to [http://localhost:5173/](http://localhost:5173/) with your browser to see the result.

## Technical notes

- Annotations require a channel in the mutable channel namespace. This example uses `mutable:pub-sub-message-annotations`
- Annotations are logically grouped by an annotation namespace. This example uses `my-annotations`

## How to use this example

1. Enter a message in the input field and click "Publish" to send it to the channel
2. Click on a message to expand it and reveal the annotation interface
3. Select an annotation type, enter a value, and click "Publish" to add an annotation
4. Switch between the "Summary" and "Raw Annotations" tabs to see different views
5. Open the example in multiple browser tabs with different client IDs (e.g., `?clientId=user1` and `?clientId=user2`) to see how annotations from different clients are handled and summarized
6. Delete annotations by clicking the trash icon in the raw annotations view and see how the summary is updated

## Open in CodeSandbox

In CodeSandbox, rename the `.env.example` file to `.env.local` and update the value of your `VITE_ABLY_KEY` variable to use your Ably API key.
25 changes: 25 additions & 0 deletions examples/pub-sub-message-annotations/javascript/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="en">

<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href='https://fonts.googleapis.com/css?family=Inter' rel='stylesheet'>
<link rel="stylesheet" href="src/styles.css" />
<title>Pub/Sub message annotations</title>
</head>

<body class="font-inter">
<div class="flex justify-center items-start min-h-screen p-4 uk-text-primary">
<div class="w-full max-w-screen-sm mt-5 flex flex-col space-y-4">
<div class="flex space-x-2">
<input id="message-input" placeholder="Publish a message" class="uk-input uk-width-1-1 uk-border-rounded-left h-10 border rounded-md px-3 bg-white" type="text" value="">
<button id="publish-button" class="uk-btn uk-btn-sm uk-btn-primary mb-1 rounded-md hover:uk-btn-primary+1 active:uk-btn-primary+2 h-10">Publish</button>
</div>
<div id="messages"></div>
</div>
</div>
<script type="module" src="src/script.ts"></script>
</body>

</html>
11 changes: 11 additions & 0 deletions examples/pub-sub-message-annotations/javascript/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "pub-sub-message-annotations-javascript",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
}
}
34 changes: 34 additions & 0 deletions examples/pub-sub-message-annotations/javascript/src/ably.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import * as Ably from 'ably';
import { clientId, channelName } from './config';
import { MessageCreate } from './types';

// Singleton Ably client instance
let client: Ably.Realtime | null = null;

// Lazily creates and returns the Ably client instance with configured clientId
function getClient(): Ably.Realtime {
if (!client) {
client = new Ably.Realtime({
clientId,
key: import.meta.env.VITE_ABLY_KEY as string,
});
}
return client;
}

// Returns the configured channel with all annotation modes enabled
export function getChannel() {
return getClient().channels.get(channelName, {
modes: ['PUBLISH', 'SUBSCRIBE', 'ANNOTATION_PUBLISH', 'ANNOTATION_SUBSCRIBE'],
});
}

// Publishes a new annotation for a specific message
export function publishAnnotation(message: MessageCreate, annotation: Ably.OutboundAnnotation) {
return getChannel().annotations.publish(message, annotation);
}

// Deletes a specific annotation from a message
export function deleteAnnotation(messageSerial: string, annotation: Ably.OutboundAnnotation) {
return getChannel().annotations.delete(messageSerial, annotation);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Components for displaying and managing raw annotation messages

import type { Annotation } from 'ably';
import { findAnnotationType } from '../config';
import { createBadge } from './badge';
import { deleteAnnotation } from '../ably';

function formatTimestamp(timestamp: number): string {
const date = new Date(timestamp);
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}

// Extracts the type key from a namespaced annotation type string (e.g. "my-annotations:total.v1" → "total.v1")
export function getAnnotationTypeKey(fullType: string): string {
const parts = fullType.split(':');
if (parts.length > 1) {
return parts[1];
}
return fullType;
}

// Component to display a single annotation with all its details
// Includes the annotation type, value, action, client ID, timestamp, and
// a delete button for annotations with an annotation.create action
function createAnnotationItem(annotation: Annotation) {
const typeKey = getAnnotationTypeKey(annotation.type);
const { color, label } = findAnnotationType(typeKey);

const item = document.createElement('div');
item.className = `pl-3 pr-2 py-2 border-l-4 border-l-${color}-500 border-y border-r border-gray-200 bg-white shadow-sm flex flex-wrap items-center`;
item.setAttribute('data-id', annotation.id);
item.setAttribute('data-timestamp', annotation.timestamp.toString());
item.setAttribute('data-serial', annotation.messageSerial);
item.setAttribute('data-action', annotation.action || '');

// First row: type, value (left aligned) and delete button (right aligned)
const firstRow = document.createElement('div');
firstRow.className = 'flex justify-between items-center w-full';

const leftContent = document.createElement('div');
leftContent.className = 'flex items-center gap-2 min-w-0 flex-grow';

const typeLabel = document.createElement('span');
typeLabel.className = `text-sm font-medium text-${color}-800`;
typeLabel.textContent = label;
leftContent.appendChild(typeLabel);

const valueContent = document.createElement('span');
valueContent.className = 'text-sm text-gray-700 overflow-hidden text-ellipsis';
valueContent.textContent = annotation.name || 'unknown';
leftContent.appendChild(valueContent);

firstRow.appendChild(leftContent);

if (annotation.action !== 'annotation.delete') {
const deleteIcon = document.createElement('div');
deleteIcon.className = 'size-4 text-red-500 hover:text-red-800 cursor-pointer shrink-0 ml-auto';
deleteIcon.innerHTML = '<uk-icon icon="trash-2"></uk-icon>';
deleteIcon.addEventListener('click', (e) => {
e.preventDefault();
deleteAnnotation(annotation.messageSerial, annotation);
});
firstRow.appendChild(deleteIcon);
}

item.appendChild(firstRow);

// Second row: action (left aligned) and client ID with timestamp (right aligned)
const secondRow = document.createElement('div');
secondRow.className = 'flex justify-between items-center w-full mt-1';

let action = 'CREATE';
let actionColor = 'green';
if (annotation.action === 'annotation.delete') {
action = 'DELETE';
actionColor = 'red';
}
const actionBadge = createBadge(action, actionColor);
secondRow.appendChild(actionBadge);

const rightContent = document.createElement('div');
rightContent.className = 'flex items-center gap-2 ml-auto shrink-0';

const clientBadge = createBadge(annotation.clientId || 'unknown', 'gray');
clientBadge.classList.add('shrink-0');
rightContent.appendChild(clientBadge);

const timestamp = document.createElement('div');
timestamp.className = 'text-xs text-gray-500';
timestamp.textContent = formatTimestamp(annotation.timestamp);
rightContent.appendChild(timestamp);

secondRow.appendChild(rightContent);

item.appendChild(secondRow);

return item;
}

// Component for listing annotations related to a specific message
// Includes an empty state message that will be removed when annotations are added
export function createAnnotationsListElement(messageSerial: string) {
const annotationsList = document.createElement('div');
annotationsList.className = 'space-y-1 max-h-80 overflow-y-auto';
annotationsList.id = `annotations-list-${messageSerial}`;
annotationsList.setAttribute('data-message-serial', messageSerial);

const emptyState = document.createElement('div');
emptyState.className = 'text-center p-2 text-gray-500 text-sm';
emptyState.textContent = 'No annotations received yet.';
emptyState.id = `annotations-empty-${messageSerial}`;

annotationsList.appendChild(emptyState);

return annotationsList;
}

// Adds a new annotation to the appropriate message's annotation list
export function addAnnotation(annotation: Annotation) {
const messageSerial = annotation.messageSerial;
const listContainer = document.getElementById(`annotations-list-${messageSerial}`);

if (!listContainer) {
return;
}

// Check if we already have an annotation with the same ID and action
const existingAnnotation = document.querySelector(`[data-id="${annotation.id}"][data-action="${annotation.action || ''}"]`);
if (existingAnnotation) {
return;
}

const emptyState = document.getElementById(`annotations-empty-${messageSerial}`);
if (emptyState) {
emptyState.remove();
}

const annotationItem = createAnnotationItem(annotation);

// Add at the beginning (newest first)
if (listContainer.firstChild) {
listContainer.insertBefore(annotationItem, listContainer.firstChild);
return;
}
listContainer.appendChild(annotationItem);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Creates and manages SVG arrow icons for dropdown interactions

export function createDropdownArrow(color: string) {
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('class', `h-4 w-4 text-${color}-500 transform transition-transform duration-200`);
svg.setAttribute('fill', 'none');
svg.setAttribute('viewBox', '0 0 24 24');
svg.setAttribute('stroke', 'currentColor');

const path = document.createElementNS(svgNS, 'path');
path.setAttribute('stroke-linecap', 'round');
path.setAttribute('stroke-linejoin', 'round');
path.setAttribute('stroke-width', '2');
path.setAttribute('d', 'M19 9l-7 7-7-7');

svg.appendChild(path);
return svg;
}

export function rotateArrow(arrow: SVGElement, expanded: boolean) {
if (expanded) {
arrow.classList.add('rotate-180');
} else {
arrow.classList.remove('rotate-180');
}
}

export function toggleArrowRotation(arrow: SVGElement) {
arrow.classList.toggle('rotate-180');
return arrow.classList.contains('rotate-180');
}
Loading