Skip to content

Commit a4dfc00

Browse files
authored
Chat subprotocol (#167)
* Creates a subprotocol to broadcast messages * Creates a WebSocketAwarenessProvider * Pre commit
1 parent 0ff1f7c commit a4dfc00

File tree

9 files changed

+211
-24
lines changed

9 files changed

+211
-24
lines changed

jupyter_collaboration/handlers.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,16 @@
1616
from tornado.websocket import WebSocketHandler
1717
from ypy_websocket.websocket_server import YRoom
1818
from ypy_websocket.ystore import BaseYStore
19-
from ypy_websocket.yutils import YMessageType
19+
from ypy_websocket.yutils import YMessageType, write_var_uint
2020

2121
from .loaders import FileLoaderMapping
2222
from .rooms import DocumentRoom, TransientRoom
23-
from .utils import JUPYTER_COLLABORATION_EVENTS_URI, LogLevel, decode_file_path
23+
from .utils import (
24+
JUPYTER_COLLABORATION_EVENTS_URI,
25+
LogLevel,
26+
MessageType,
27+
decode_file_path,
28+
)
2429
from .websocketserver import JupyterWebsocketServer
2530

2631
YFILE = YDOCS["file"]
@@ -197,6 +202,8 @@ def on_message(self, message):
197202
On message receive.
198203
"""
199204
message_type = message[0]
205+
print("message type:", message_type)
206+
200207
if message_type == YMessageType.AWARENESS:
201208
# awareness
202209
skip = False
@@ -222,6 +229,18 @@ def on_message(self, message):
222229
)
223230
return skip
224231

232+
if message_type == MessageType.CHAT:
233+
msg = message[2:].decode("utf-8")
234+
user = self.get_current_user()
235+
data = json.dumps({"username": user.username, "msg": msg}).encode("utf8")
236+
for client in self.room.clients:
237+
if client != self:
238+
task = asyncio.create_task(
239+
client.send(bytes([MessageType.CHAT]) + write_var_uint(len(data)) + data)
240+
)
241+
self._websocket_server.background_tasks.add(task)
242+
task.add_done_callback(self._websocket_server.background_tasks.discard)
243+
225244
self._message_queue.put_nowait(message)
226245
self._websocket_server.ypatch_nb += 1
227246

jupyter_collaboration/utils.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,19 @@
22
# Distributed under the terms of the Modified BSD License.
33

44
import pathlib
5-
from enum import Enum
5+
from enum import Enum, IntEnum
66
from typing import Tuple
77

88
JUPYTER_COLLABORATION_EVENTS_URI = "https://schema.jupyter.org/jupyter_collaboration/session/v1"
99
EVENTS_SCHEMA_PATH = pathlib.Path(__file__).parent / "events" / "session.yaml"
1010

1111

12+
class MessageType(IntEnum):
13+
SYNC = 0
14+
AWARENESS = 1
15+
CHAT = 125
16+
17+
1218
class LogLevel(Enum):
1319
INFO = "INFO"
1420
DEBUG = "DEBUG"

packages/collaboration-extension/src/collaboration.ts

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,14 @@ import {
1616
} from '@jupyterlab/codemirror';
1717
import {
1818
CollaboratorsPanel,
19-
IAwareness,
2019
IGlobalAwareness,
2120
IUserMenu,
2221
remoteUserCursors,
2322
RendererUserMenu,
2423
UserInfoPanel,
2524
UserMenu
2625
} from '@jupyter/collaboration';
26+
import { IAwareness, WebSocketAwarenessProvider } from '@jupyter/docprovider';
2727
import { SidePanel, usersIcon } from '@jupyterlab/ui-components';
2828
import { Menu, MenuBar } from '@lumino/widgets';
2929
import { URLExt } from '@jupyterlab/coreutils';
@@ -33,7 +33,6 @@ import { ITranslator, nullTranslator } from '@jupyterlab/translation';
3333

3434
import * as Y from 'yjs';
3535
import { Awareness } from 'y-protocols/awareness';
36-
import { WebsocketProvider } from 'y-websocket';
3736

3837
/**
3938
* Jupyter plugin providing the IUserMenu.
@@ -90,26 +89,20 @@ export const rtcGlobalAwarenessPlugin: JupyterFrontEndPlugin<IAwareness> = {
9089
provides: IGlobalAwareness,
9190
activate: (app: JupyterFrontEnd, state: StateDB): IAwareness => {
9291
const { user } = app.serviceManager;
93-
const ydoc = new Y.Doc();
9492

93+
const ydoc = new Y.Doc();
9594
const awareness = new Awareness(ydoc);
9695

9796
const server = ServerConnection.makeSettings();
9897
const url = URLExt.join(server.wsUrl, 'api/collaboration/room');
9998

100-
new WebsocketProvider(url, 'JupyterLab:globalAwareness', ydoc, {
101-
awareness: awareness
99+
new WebSocketAwarenessProvider({
100+
url: url,
101+
roomID: 'JupyterLab:globalAwareness',
102+
awareness: awareness,
103+
user: user
102104
});
103105

104-
const userChanged = () => {
105-
awareness.setLocalStateField('user', user.identity);
106-
};
107-
if (user.isReady) {
108-
userChanged();
109-
}
110-
user.ready.then(userChanged).catch(e => console.error(e));
111-
user.userChanged.connect(userChanged);
112-
113106
state.changed.connect(async () => {
114107
const data: any = await state.toJSON();
115108
const current = data['layout-restorer:data']?.main?.current || '';

packages/collaboration/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"dependencies": {
4242
"@codemirror/state": "^6.2.0",
4343
"@codemirror/view": "^6.7.0",
44+
"@jupyter/docprovider": "1.0.0",
4445
"@jupyterlab/apputils": "^4.0.0",
4546
"@jupyterlab/coreutils": "^6.0.0",
4647
"@jupyterlab/services": "^7.0.0",

packages/collaboration/src/tokens.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
import type { Menu } from '@lumino/widgets';
55
import { Token } from '@lumino/coreutils';
6-
import type { Awareness } from 'y-protocols/awareness';
6+
import { IAwareness } from '@jupyter/docprovider';
77
import type { User } from '@jupyterlab/services';
88

99
/**
@@ -23,11 +23,6 @@ export const IGlobalAwareness = new Token<IAwareness>(
2323
'@jupyter/collaboration:IGlobalAwareness'
2424
);
2525

26-
/**
27-
* The awareness interface.
28-
*/
29-
export type IAwareness = Awareness;
30-
3126
/**
3227
* An interface describing the user menu.
3328
*/

packages/docprovider/src/awareness.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
/* -----------------------------------------------------------------------------
2+
| Copyright (c) Jupyter Development Team.
3+
| Distributed under the terms of the Modified BSD License.
4+
|----------------------------------------------------------------------------*/
5+
6+
import { User } from '@jupyterlab/services';
7+
8+
import { IDisposable } from '@lumino/disposable';
9+
import { ISignal, Signal } from '@lumino/signaling';
10+
11+
import * as decoding from 'lib0/decoding';
12+
import * as encoding from 'lib0/encoding';
13+
import { WebsocketProvider } from 'y-websocket';
14+
15+
import { IAwareness, IAwarenessProvider } from './tokens';
16+
17+
export enum MessageType {
18+
CHAT = 125
19+
}
20+
21+
export interface IChatMessage {
22+
username: string;
23+
msg: string;
24+
}
25+
26+
/**
27+
* A class to provide Yjs synchronization over WebSocket.
28+
*
29+
* We specify custom messages that the server can interpret. For reference please look in yjs_ws_server.
30+
*
31+
*/
32+
export class WebSocketAwarenessProvider
33+
extends WebsocketProvider
34+
implements IAwarenessProvider, IDisposable
35+
{
36+
/**
37+
* Construct a new WebSocketAwarenessProvider
38+
*
39+
* @param options The instantiation options for a WebSocketAwarenessProvider
40+
*/
41+
constructor(options: WebSocketAwarenessProvider.IOptions) {
42+
super(options.url, options.roomID, options.awareness.doc, {
43+
awareness: options.awareness
44+
});
45+
46+
this._awareness = options.awareness;
47+
48+
const user = options.user;
49+
user.ready
50+
.then(() => this._onUserChanged(user))
51+
.catch(e => console.error(e));
52+
user.userChanged.connect(this._onUserChanged, this);
53+
54+
this._chatMessage = new Signal(this);
55+
56+
this.messageHandlers[MessageType.CHAT] = (
57+
encoder,
58+
decoder,
59+
provider,
60+
emitSynced,
61+
messageType
62+
) => {
63+
const content = decoding.readVarString(decoder);
64+
const data = JSON.parse(content) as IChatMessage;
65+
console.debug('Chat:', data);
66+
this._chatMessage.emit(data);
67+
};
68+
}
69+
70+
get isDisposed(): boolean {
71+
return this._isDisposed;
72+
}
73+
74+
/**
75+
* A signal to subscribe for incoming messages.
76+
*/
77+
get chatMessage(): ISignal<this, IChatMessage> {
78+
return this._chatMessage;
79+
}
80+
81+
dispose(): void {
82+
if (this._isDisposed) {
83+
return;
84+
}
85+
86+
this.destroy();
87+
this._isDisposed = true;
88+
}
89+
90+
/**
91+
* Send a message to every collaborator.
92+
*
93+
* @param msg message
94+
*/
95+
sendMessage(msg: string): void {
96+
console.debug('Send message:', msg);
97+
const encoder = encoding.createEncoder();
98+
encoding.writeVarUint(encoder, MessageType.CHAT);
99+
encoding.writeVarString(encoder, msg);
100+
this.ws!.send(encoding.toUint8Array(encoder));
101+
}
102+
103+
private _onUserChanged(user: User.IManager): void {
104+
this._awareness.setLocalStateField('user', user.identity);
105+
}
106+
107+
private _isDisposed = false;
108+
private _awareness: IAwareness;
109+
110+
private _chatMessage: Signal<this, IChatMessage>;
111+
}
112+
113+
/**
114+
* A namespace for WebSocketAwarenessProvider statics.
115+
*/
116+
export namespace WebSocketAwarenessProvider {
117+
/**
118+
* The instantiation options for a WebSocketAwarenessProvider.
119+
*/
120+
export interface IOptions {
121+
/**
122+
* The server URL
123+
*/
124+
url: string;
125+
126+
/**
127+
* The room ID
128+
*/
129+
roomID: string;
130+
131+
/**
132+
* The awareness object
133+
*/
134+
awareness: IAwareness;
135+
136+
/**
137+
* The user data
138+
*/
139+
user: User.IManager;
140+
}
141+
}

packages/docprovider/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* @module docprovider
88
*/
99

10+
export * from './awareness';
1011
export * from './ydrive';
1112
export * from './yprovider';
1213
export * from './tokens';

packages/docprovider/src/tokens.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,13 @@
33

44
import { DocumentChange, YDocument } from '@jupyter/ydoc';
55
import { Contents } from '@jupyterlab/services';
6+
67
import { Token } from '@lumino/coreutils';
8+
import { ISignal } from '@lumino/signaling';
9+
10+
import type { Awareness } from 'y-protocols/awareness';
11+
12+
import { IChatMessage } from './awareness';
713

814
/**
915
* The collaborative drive.
@@ -45,3 +51,27 @@ export interface ISharedModelFactory extends Contents.ISharedFactory {
4551
factory: SharedDocumentFactory
4652
): void;
4753
}
54+
55+
/**
56+
* The awareness interface.
57+
*
58+
* TODO: Move to @jupyter/YDoc
59+
*/
60+
export type IAwareness = Awareness;
61+
62+
/**
63+
* A provider interface for global awareness features.
64+
*/
65+
export interface IAwarenessProvider {
66+
/**
67+
* A signal to subscribe for incoming messages.
68+
*/
69+
get chatMessage(): ISignal<this, IChatMessage>;
70+
71+
/**
72+
* Send a message to every collaborator.
73+
*
74+
* @param msg message
75+
*/
76+
sendMessage(msg: string): void;
77+
}

yarn.lock

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2054,6 +2054,7 @@ __metadata:
20542054
dependencies:
20552055
"@codemirror/state": ^6.2.0
20562056
"@codemirror/view": ^6.7.0
2057+
"@jupyter/docprovider": 1.0.0
20572058
"@jupyterlab/apputils": ^4.0.0
20582059
"@jupyterlab/coreutils": ^6.0.0
20592060
"@jupyterlab/services": ^7.0.0
@@ -2070,7 +2071,7 @@ __metadata:
20702071
languageName: unknown
20712072
linkType: soft
20722073

2073-
"@jupyter/docprovider@^1.0.0, @jupyter/docprovider@workspace:packages/docprovider":
2074+
"@jupyter/docprovider@1.0.0, @jupyter/docprovider@^1.0.0, @jupyter/docprovider@workspace:packages/docprovider":
20742075
version: 0.0.0-use.local
20752076
resolution: "@jupyter/docprovider@workspace:packages/docprovider"
20762077
dependencies:

0 commit comments

Comments
 (0)