-
Notifications
You must be signed in to change notification settings - Fork 44
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
base: main
Are you sure you want to change the base?
Changes from all commits
9ad80f8
7553d21
7fdf2d8
8b3a3fc
f3aa3c1
9a32b34
aab0fd9
789d7bc
22c822c
2f474f0
92bdac1
c7e5719
43e3aa9
223c11c
f7a321a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 |
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 [email protected]: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` | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Now I've reread the sentence, should it be There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This needs to be re-worded to make sense now, maybe something like: "Annotations need to be enabled for a channel, or namespace using a rule." This example is using the |
||||||
- Annotations are logically grouped by an annotation namespace. This example uses `my-annotations` | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @mschristensen please can you sanity check all the naming lines up? I'm not convinced this is the correct namespace because I can't see it specified anywhere in the code (other than a comment) |
||||||
|
||||||
## 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. |
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> |
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" | ||
} | ||
} |
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(`annotation:${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); | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.