Skip to content
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

Add Presence functionality #322

Merged
merged 9 commits into from
Mar 4, 2020
Merged
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
120 changes: 120 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,8 @@ __Options__
through this pub/sub adapter. Defaults to `ShareDB.MemoryPubSub()`.
* `options.milestoneDb` _(instance of ShareDB.MilestoneDB`)_
Store snapshots of documents at a specified interval of versions
* `options.presence` _boolean_
Enable presence functionality. Off by default. Note that this feature is not optimized for large numbers of clients and could cause fan-out issues

#### Database Adapters
* `ShareDB.MemoryDB`, backed by a non-persistent database with no queries
Expand Down Expand Up @@ -158,6 +160,7 @@ Register a new middleware.
the database.
* `'receive'`: Received a message from a client
* `'reply'`: About to send a non-error reply to a client message
* `'sendPresence'`: About to send presence information to a client
* `fn` _(Function(context, callback))_
Call this function at the time specified by `action`.
* `context` will always have the following properties:
Expand Down Expand Up @@ -307,6 +310,20 @@ Get a read-only snapshot of a document at the requested version.
}
```

`connection.getPresence(channel): Presence;`
Get a [`Presence`](#class-sharedbpresence) instance that can be used to subscribe to presence information to other clients, and create instances of local presence.

* `channel` _(String)_
Presence channel to subscribe to

`connection.getDocPresence(collection, id): DocPresence;`
Get a special [`DocPresence`](#class-sharedbdocpresence) instance that can be used to subscribe to presence information to other clients, and create instances of local presence. This is tied to a `Doc`, and all presence will be automatically transformed against ops to keep presence current. Note that the `Doc` must be of a type that supports presence.

* `collection` _(String)_
Document collection
* `id` _(String)_
Document ID

### Class: `ShareDB.Doc`

`doc.type` _(String_)
Expand Down Expand Up @@ -640,6 +657,109 @@ const connectionInfo = getUserPermissions();
const connection = backend.connect(null, connectionInfo);
```

### Class: `ShareDB.Presence`

Representation of the presence data associated with a given channel.

#### `subscribe`

```javascript
presence.subscribe(callback): void;
```

Subscribe to presence updates from other clients. Note that presence can be submitted without subscribing, but remote clients will not be able to re-request presence from you if you are not subscribed.

* `callback` _Function_: a callback with the signature `function (error: Error): void;`

#### `unsubscribe`

```javascript
presence.unsubscribe(callback): void;
```

Unsubscribe from presence updates from remote clients.

* `callback` _Function_: a callback with the signature `function (error: Error): void;`

#### `on`

```javascript
presence.on('receive', callback): void;
```

An update from a remote presence client has been received.

* `callback` _Function_: callback for handling the received presence: `function (presenceId, presenceValue): void;`

```javascript
presence.on('error', callback): void;
```

A presence-related error has occurred.

* `callback` _Function_: a callback with the signature `function (error: Error): void;`

#### `create`

```javascript
presence.create(presenceId): LocalPresence;
```

Create an instance of [`LocalPresence`](#class-sharedblocalpresence), which can be used to represent local presence. Many or none such local presences may exist on a `Presence` instance.

* `presenceId` _string (optional)_: a unique ID representing the local presence. Remember - depending on use-case - the same client might have multiple presences, so this might not necessarily be a user or client ID. If one is not provided, a random ID will be assigned for you.

#### `destroy`

```javascript
presence.destroy(callback);
```

Updates all remote clients with a `null` presence, and removes it from the `Connection` cache, so that it can be garbage-collected. This should be called when you are done with a presence, and no longer need to use it to fire updates.

* `callback` _Function_: a callback with the signature `function (error: Error): void;`

### Class: `ShareDB.DocPresence`

Specialised case of [`Presence`](#class-sharedbpresence), which is tied to a specific [`Doc`](#class-sharedbdoc). When using presence with an associated `Doc`, any ops applied to the `Doc` will automatically be used to transform associated presence. On destroy, the `DocPresence` will unregister its listeners from the `Doc`.

See [`Presence`](#class-sharedbpresence) for available methods.

### Class: `ShareDB.LocalPresence`

`LocalPresence` represents the presence of the local client in a given `Doc`. For example, this might be the position of a caret in a text document; which field has been highlighted in a complex JSON object; etc. Multiple presences may exist per `Doc` even on the same client.

#### `submit`

```javascript
localPresence.submit(presence, callback): void;
```

Update the local representation of presence, and broadcast that presence to any other document presence subscribers.

* `presence` _Object_: the presence object to broadcast. The structure of this will depend on the OT type
* `callback` _Function_: a callback with the signature `function (error: Error): void;`

#### `send`

```javascript
localPresence.send(callback): void;
```

Send presence like `submit`, but without updating the value. Can be useful if local presences expire periodically.

* `callback` _Function_: a callback with the signature `function (error: Error): void;`

#### `destroy`

```javascript
localPresence.destroy(callback): void;
```

Informs all remote clients that this presence is now `null`, and deletes itself for garbage collection.

* `callback` _Function_: a callback with the signature `function (error: Error): void;`

### Logging

By default, ShareDB logs to `console`. This can be overridden if you wish to silence logs, or to log to your own logging driver or alert service.
Expand Down
1 change: 1 addition & 0 deletions examples/rich-text-presence/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
static/dist/
20 changes: 20 additions & 0 deletions examples/rich-text-presence/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Collaborative Rich Text Editor with ShareDB

This is a collaborative rich text editor using [Quill](https://github.com/quilljs/quill) and the [rich-text OT type](https://github.com/ottypes/rich-text).

In this demo, data is not persisted. To persist data, run a Mongo
server and initialize ShareDB with the
[ShareDBMongo](https://github.com/share/sharedb-mongo) database adapter.

## Install dependencies
```
npm install
```

## Build JavaScript bundle and run server
```
npm run build && npm start
```

## Run app in browser
Load [http://localhost:8080](http://localhost:8080)
101 changes: 101 additions & 0 deletions examples/rich-text-presence/client.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
var ReconnectingWebSocket = require('reconnecting-websocket');
var sharedb = require('sharedb/lib/client');
var richText = require('./rich-text');
var Quill = require('quill');
var QuillCursors = require('quill-cursors');
var tinycolor = require('tinycolor2');
var ObjectID = require('bson-objectid');

sharedb.types.register(richText.type);
Quill.register('modules/cursors', QuillCursors);

var connectionButton = document.getElementById('client-connection');
connectionButton.addEventListener('click', function() {
toggleConnection(connectionButton);
});

var nameInput = document.getElementById('name');

var colors = {};

var collection = 'examples';
var id = 'richtext';
var presenceId = new ObjectID().toString();

var socket = new ReconnectingWebSocket('ws://' + window.location.host);
var connection = new sharedb.Connection(socket);
var doc = connection.get(collection, id);

doc.subscribe(function(err) {
if (err) throw err;
initialiseQuill(doc);
});

function initialiseQuill(doc) {
var quill = new Quill('#editor', {
theme: 'bubble',
modules: {cursors: true}
});
var cursors = quill.getModule('cursors');

quill.setContents(doc.data);

quill.on('text-change', function(delta, oldDelta, source) {
if (source !== 'user') return;
doc.submitOp(delta);
});

doc.on('op', function(op, source) {
if (source) return;
quill.updateContents(op);
});

var presence = doc.connection.getDocPresence(collection, id);
presence.subscribe(function(error) {
if (error) throw error;
});
var localPresence = presence.create(presenceId);

quill.on('selection-change', function(range) {
// Ignore blurring, so that we can see lots of users in the
// same window. In real use, you may want to clear the cursor.
if (!range) return;
// In this particular instance, we can send extra information
// on the presence object. This ability will vary depending on
// type.
range.name = nameInput.value;
localPresence.submit(range, function(error) {
if (error) throw error;
});
});

presence.on('receive', function(id, range) {
colors[id] = colors[id] || tinycolor.random().toHexString();
var name = (range && range.name) || 'Anonymous';
cursors.createCursor(id, name, colors[id]);
cursors.moveCursor(id, range);
});

return quill;
}

function toggleConnection(button) {
if (button.classList.contains('connected')) {
button.classList.remove('connected');
button.textContent = 'Connect';
disconnect();
} else {
button.classList.add('connected');
button.textContent = 'Disconnect';
connect();
}
}

function disconnect() {
doc.connection.close();
}

function connect() {
var socket = new ReconnectingWebSocket('ws://' + window.location.host);
doc.connection.bindToSocket(socket);
}
32 changes: 32 additions & 0 deletions examples/rich-text-presence/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "sharedb-example-rich-text-presence",
"version": "1.0.0",
"description": "An example of presence using ShareDB and Quill",
"main": "server.js",
"scripts": {
"build": "mkdir -p static/dist/ && ./node_modules/.bin/browserify client.js -o static/dist/bundle.js",
"test": "echo \"Error: no test specified\" && exit 1",
"start": "node server.js"
},
"author": "Nate Smith",
"contributors": [
"Avital Oliver <[email protected]> (https://aoliver.org/)",
"Alec Gibson <[email protected]>"
],
"license": "MIT",
"dependencies": {
"@teamwork/websocket-json-stream": "^2.0.0",
"bson-objectid": "^1.3.0",
"express": "^4.17.1",
"quill": "^1.3.7",
"quill-cursors": "^2.2.1",
"reconnecting-websocket": "^4.2.0",
"rich-text": "^4.0.0",
"sharedb": "file:../../",
"tinycolor2": "^1.4.1",
"ws": "^7.2.0"
},
"devDependencies": {
"browserify": "^16.5.0"
}
}
20 changes: 20 additions & 0 deletions examples/rich-text-presence/rich-text.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
var richText = require('rich-text');

richText.type.transformPresence = function(presence, op, isOwnOp) {
if (!presence) {
return null;
}

var start = presence.index;
var end = presence.index + presence.length;
var delta = new richText.Delta(op);
start = delta.transformPosition(start, !isOwnOp);
end = delta.transformPosition(end, !isOwnOp);

return Object.assign({}, presence, {
index: start,
length: end - start
});
};

module.exports = richText;
42 changes: 42 additions & 0 deletions examples/rich-text-presence/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
var http = require('http');
var express = require('express');
var ShareDB = require('sharedb');
var richText = require('./rich-text');
var WebSocket = require('ws');
var WebSocketJSONStream = require('@teamwork/websocket-json-stream');

ShareDB.types.register(richText.type);
var backend = new ShareDB({presence: true});
createDoc(startServer);

// Create initial document then fire callback
function createDoc(callback) {
var connection = backend.connect();
var doc = connection.get('examples', 'richtext');
doc.fetch(function(err) {
if (err) throw err;
if (doc.type === null) {
doc.create([{insert: 'Hi!'}], 'rich-text', callback);
return;
}
callback();
});
}

function startServer() {
// Create a web server to serve files and listen to WebSocket connections
var app = express();
app.use(express.static('static'));
app.use(express.static('node_modules/quill/dist'));
var server = http.createServer(app);

// Connect any incoming WebSocket connection to ShareDB
var wss = new WebSocket.Server({server: server});
wss.on('connection', function(ws) {
var stream = new WebSocketJSONStream(ws);
backend.listen(stream);
});

server.listen(8080);
console.log('Listening on http://localhost:8080');
}
19 changes: 19 additions & 0 deletions examples/rich-text-presence/static/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<meta charset="utf-8">
<title>ShareDB Rich Text</title>
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0"/>
<link href="quill.bubble.css" rel="stylesheet">
<link href="style.css" rel="stylesheet">

<div class="controls">
<input type="text" placeholder="Enter your name..." id="name" />
<button id="client-connection" class="connected">Disconnect</button>
</div>

<center>
Open a new window to see another client!
</center>

<div id="editor"></div>

<script src="dist/bundle.js"></script>
Loading