61 | {__(`(optional) Email to show to other chat participants.`)}{' '}
62 | {__(`An avatar icon will be shown if this address is registered at`)}{' '}
63 |
64 | gravatar.com
65 |
66 | .
67 |
70 | {__('Join (or create) a named Room.')}{' '}
71 | {__(
72 | 'Share this name with other users of your Hub, Binder, or others that can share a server key.'
73 | )}
74 |
75 |
76 |
77 |
78 | );
79 | };
80 |
--------------------------------------------------------------------------------
/jupyter_videochat/__init__.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | from traitlets import Dict, List, Unicode
4 | from traitlets.config import Configurable
5 |
6 | from ._version import __jspackage__, __version__
7 | from .handlers import setup_handlers
8 |
9 |
10 | class VideoChat(Configurable):
11 | room_prefix = Unicode(
12 | default_value=os.environ.get("JUPYTER_VIDEOCHAT_ROOM_PREFIX", ""),
13 | help="""
14 | Prefix to use for all meeting room names.
15 |
16 | When multiple groups are using the same Jitsi server, we need a
17 | secure, unique prefix for each group. This lets the group use any
18 | name for their meeting without worrying about conflicts with other
19 | groups.
20 |
21 | In a JupyterHub context, each JupyterHub should have its own
22 | secure prefix, to prevent clashes with other JupyterHubs using the
23 | same Jitsi server. Subgroups inside a JupyterHub might also have
24 | their prefixe to prevent clashes.
25 |
26 | When set to '' (the default), the hostname where the hub is running
27 | will be used to form a prefix.
28 | """,
29 | config=True,
30 | )
31 |
32 | rooms = List(
33 | Dict,
34 | default_value=[],
35 | help="""
36 | List of rooms shown to users in chat window.
37 |
38 | Each item should be a dict with the following keys:
39 |
40 | id - id of the meeting to be used with jitsi. Will be prefixed with `room_prefix,
41 | and escaped to contain only alphanumeric characters and '-'
42 | displayName - Name to be displayed to the users
43 | description - Description of this particular room
44 |
45 | This can be dynamically set eventually from an API call or something of that
46 | sort.
47 | """,
48 | config=True,
49 | )
50 |
51 | jitsi_server = Unicode(
52 | "meet.jit.si",
53 | help="""
54 | Domain of Jitsi server to use
55 |
56 | Must be a domain name, with HTTPS working, that serves /external_api.js
57 | """,
58 | config=True,
59 | )
60 |
61 |
62 | def _jupyter_server_extension_paths():
63 | return [{"module": "jupyter_videochat"}]
64 |
65 |
66 | def _jupyter_labextension_paths():
67 | return [{"src": "labextension", "dest": __jspackage__["name"]}]
68 |
69 |
70 | def load_jupyter_server_extension(lab_app):
71 | """Registers the API handler to receive HTTP requests from the frontend extension.
72 |
73 | Parameters
74 | ----------
75 | lab_app: jupyterlab.labapp.LabApp
76 | JupyterLab application instance
77 | """
78 | videochat = VideoChat(parent=lab_app)
79 | lab_app.web_app.settings["videochat"] = videochat
80 | setup_handlers(lab_app.web_app)
81 |
82 |
83 | # For backward compatibility
84 | _load_jupyter_server_extension = load_jupyter_server_extension
85 |
--------------------------------------------------------------------------------
/src/widget.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import { ReadonlyPartialJSONObject } from '@lumino/coreutils';
4 |
5 | import { VDomRenderer } from '@jupyterlab/apputils';
6 |
7 | import type { JitsiMeetExternalAPI } from 'jitsi-meet';
8 | import { VideoChatComponent } from './components/VideoChat';
9 | import { CSS } from './tokens';
10 | import { Room } from './types';
11 | import { VideoChatManager } from './manager';
12 |
13 | /**
14 | * The main video chat interface which can appear in the sidebar or main area
15 | */
16 | export class VideoChat extends VDomRenderer {
17 | constructor(model: VideoChatManager, options: VideoChat.IOptions) {
18 | super(model);
19 | this.addClass(CSS);
20 | }
21 |
22 | /** Handle selecting a new (or no) room */
23 | onRoomSelect = (room: Room | null): void => {
24 | this.model.currentRoom = room;
25 | };
26 |
27 | /** Create a new room */
28 | onCreateRoom = (room: Room): void => {
29 | this.model.createRoom(room).catch(console.warn);
30 | };
31 |
32 | /** Set the current meeting */
33 | onMeet = (meet: JitsiMeetExternalAPI): void => {
34 | this.model.meet = meet;
35 | };
36 |
37 | /** Set the user's email address */
38 | onEmailChanged = (email: string): void => {
39 | this.model.settings?.set('email', email).catch(console.warn);
40 | };
41 |
42 | /** Set the user's display name */
43 | onDisplayNameChanged = (displayName: string): void => {
44 | this.model.settings?.set('displayName', displayName).catch(console.warn);
45 | };
46 |
47 | /** The actual renderer */
48 | render(): JSX.Element | JSX.Element[] {
49 | const { settings } = this.model;
50 | return (
51 |
74 | );
75 | }
76 | }
77 |
78 | /** A namespace for VideoChat options */
79 | export namespace VideoChat {
80 | /** Options for constructing a new a VideoChat */
81 | export interface IOptions {}
82 | }
83 |
--------------------------------------------------------------------------------
/jupyter_videochat/handlers.py:
--------------------------------------------------------------------------------
1 | import json
2 | import string
3 | from copy import deepcopy
4 |
5 | import tornado
6 | from escapism import escape
7 | from jupyter_server.base.handlers import APIHandler
8 | from jupyter_server.utils import url_path_join
9 |
10 |
11 | def safe_id(id):
12 | """
13 | Make sure meeting-ids are safe
14 |
15 | We try to keep meeting IDs to a safe subset of characters.
16 | Not sure if Jitsi requires this, but I think it goes on some
17 | URLs so easier to be safe.
18 | """
19 | return escape(id, safe=string.ascii_letters + string.digits + "-")
20 |
21 |
22 | class BaseHandler(APIHandler):
23 | @property
24 | def videochat(self):
25 | return self.settings["videochat"]
26 |
27 | @property
28 | def room_prefix(self):
29 | prefix = self.videochat.room_prefix
30 | if not prefix:
31 | prefix = f"jp-VideoChat-{self.request.host}-"
32 | return prefix
33 |
34 |
35 | class ConfigHandler(BaseHandler):
36 | @tornado.web.authenticated
37 | def get(self):
38 | # Use camelcase for keys, since that's what typescript likes
39 | # FIXME: room_prefix from hostname is generated twice, let's try fix that
40 |
41 | self.finish(
42 | json.dumps(
43 | {
44 | "roomPrefix": self.room_prefix,
45 | "jitsiServer": self.videochat.jitsi_server,
46 | }
47 | )
48 | )
49 |
50 |
51 | class GenerateRoomHandler(BaseHandler):
52 | @tornado.web.authenticated
53 | def post(self):
54 | params = json.loads(self.request.body.decode())
55 | display_name = params["displayName"]
56 | self.finish(
57 | json.dumps(
58 | {
59 | "id": safe_id(f"{self.room_prefix}{display_name}"),
60 | "displayName": display_name,
61 | }
62 | )
63 | )
64 |
65 |
66 | class RoomsListHandler(BaseHandler):
67 | """
68 | Return list of rooms available for this user to join.
69 | """
70 |
71 | @property
72 | def videochat(self):
73 | return self.settings["videochat"]
74 |
75 | @tornado.web.authenticated
76 | def get(self):
77 | # FIXME: Do this prefixing only once
78 | rooms = deepcopy(self.videochat.rooms)
79 |
80 | for room in rooms:
81 | room["id"] = safe_id(f"{self.room_prefix}{room['id']}")
82 |
83 | self.finish(json.dumps(rooms))
84 |
85 |
86 | def setup_handlers(web_app):
87 | host_pattern = ".*$"
88 |
89 | base_url = web_app.settings["base_url"]
90 |
91 | def make_url_pattern(endpoint):
92 | return url_path_join(base_url, "videochat", endpoint)
93 |
94 | handlers = [
95 | (make_url_pattern("rooms"), RoomsListHandler),
96 | (make_url_pattern("config"), ConfigHandler),
97 | (make_url_pattern("generate-room"), GenerateRoomHandler),
98 | ]
99 | web_app.add_handlers(host_pattern, handlers)
100 |
--------------------------------------------------------------------------------
/schema/plugin.json:
--------------------------------------------------------------------------------
1 | {
2 | "jupyter.lab.setting-icon": "jupyterlab-videochat:chat-pretty",
3 | "jupyter.lab.setting-icon-label": "Video Chat",
4 | "title": "Video Chat",
5 | "description": "Video Chat settings",
6 | "type": "object",
7 | "properties": {
8 | "area": {
9 | "title": "Chat Area",
10 | "description": "Where to draw the video chat UI",
11 | "type": "string",
12 | "default": "right",
13 | "enum": ["right", "left", "main"]
14 | },
15 | "displayName": {
16 | "title": "My Display Name",
17 | "description": "The name to show to other meeting participants",
18 | "type": "string"
19 | },
20 | "email": {
21 | "title": "My Email",
22 | "description": "The email address to show to other meeting participants. If this address is registered at gravatar.com, your custom icon will be shown",
23 | "type": "string"
24 | },
25 | "disablePublicRooms": {
26 | "title": "Disable Public Rooms (Advanced)",
27 | "description": "Do not offer to create even-less-secure public rooms without a prefix",
28 | "type": "boolean",
29 | "default": true
30 | },
31 | "configOverwrite": {
32 | "title": "Jitsi Configuration (Advanced)",
33 | "description": "A customized Jitsi [configuration](https://github.com/jitsi/jitsi-meet/blob/master/config.js). The default is as conservative as possible. Set to `null` to enable all features.",
34 | "oneOf": [
35 | {
36 | "type": "object"
37 | },
38 | {
39 | "type": "null"
40 | }
41 | ],
42 | "default": {
43 | "disableThirdPartyRequests": true
44 | }
45 | },
46 | "interfaceConfigOverwrite": {
47 | "title": "Jitsi Interface Configuration (Advanced)",
48 | "description": "A customized Jitsi [interface configuration](https://github.com/jitsi/jitsi-meet/blob/master/interface_config.js) and [feature flags](https://github.com/jitsi/jitsi-meet/blob/master/react/features/base/config/interfaceConfigWhitelist.js). The default is fairly conservative. Set to `null` to enable all features. Known hidden buttons: recording, livestreaming, etherpad, invite, download. Known hidden settings: calendar.",
49 | "oneOf": [
50 | {
51 | "type": "object"
52 | },
53 | {
54 | "type": "null"
55 | }
56 | ],
57 | "default": {
58 | "TOOLBAR_BUTTONS": [
59 | "microphone",
60 | "camera",
61 | "closedcaptions",
62 | "desktop",
63 | "fullscreen",
64 | "fodeviceselection",
65 | "hangup",
66 | "profile",
67 | "chat",
68 | "sharedvideo",
69 | "settings",
70 | "raisehand",
71 | "videoquality",
72 | "filmstrip",
73 | "feedback",
74 | "stats",
75 | "shortcuts",
76 | "tileview",
77 | "videobackgroundblur",
78 | "help",
79 | "mute-everyone",
80 | "e2ee",
81 | "security"
82 | ],
83 | "SETTINGS_SECTIONS": ["devices", "language", "moderator", "profile"],
84 | "MOBILE_APP_PROMO": false,
85 | "SHOW_CHROME_EXTENSION_BANNER": false
86 | }
87 | }
88 | }
89 | }
90 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "jupyterlab-videochat",
3 | "version": "0.6.0",
4 | "description": "Video Chat with peers inside JupyterLab and RetroLab",
5 | "keywords": [
6 | "jupyter",
7 | "jupyterlab",
8 | "jupyterlab-extension"
9 | ],
10 | "homepage": "https://github.com/jupyterlab-contrib/jupyter-videochat",
11 | "bugs": {
12 | "url": "https://github.com/jupyterlab-contrib/jupyter-videochat/issues"
13 | },
14 | "license": "BSD-3-Clause",
15 | "author": "Yuvi Panda",
16 | "files": [
17 | "{lib,style,schema}/**/*.{css,ts,eot,gif,html,jpg,js,js.map,json,png,svg,woff2,ttf}",
18 | "LICENSE"
19 | ],
20 | "main": "lib/index.js",
21 | "types": "lib/index.d.ts",
22 | "style": "style/index.css",
23 | "repository": {
24 | "type": "git",
25 | "url": "https://github.com/jupyterlab-contrib/jupyter-videochat.git"
26 | },
27 | "scripts": {
28 | "bootstrap": "jlpm --ignore-optional && jlpm clean && jlpm lint && jlpm build",
29 | "build": "jlpm build:lib && jlpm build:ext",
30 | "build:lib": "tsc -b",
31 | "build:ext": "jupyter labextension build .",
32 | "clean": "jlpm clean:lib && jlpm clean:ext",
33 | "clean:lib": "rimraf lib",
34 | "clean:ext": "rimraf ./jupyter_videochat/labextension",
35 | "deduplicate": "yarn-deduplicate -s fewer --fail",
36 | "dev:ext": "jupyter labextension develop --overwrite .",
37 | "lint": "jlpm prettier && jlpm eslint",
38 | "lint:check": "jlpm prettier:check && jlpm eslint:check",
39 | "prettier": "jlpm prettier:base --list-different --write",
40 | "prettier:base": "prettier \"*.{json,md,js,yml}\" \"{.github,jupyter-config,src,style,schema,docs,binder}/**/*.{yml,json,ts,tsx,css,md,yaml}\"",
41 | "prettier:check": "jlpm prettier:base --check",
42 | "eslint": "eslint . --cache --ext .ts,.tsx --fix",
43 | "eslint:check": "eslint . --cache --ext .ts,.tsx",
44 | "watch": "run-p watch:lib watch:ext",
45 | "watch:lib": "jlpm build:lib --watch --preserveWatchOutput",
46 | "watch:ext": "jupyter labextension watch ."
47 | },
48 | "dependencies": {
49 | "@jupyterlab/application": "^3.0.0",
50 | "@jupyterlab/filebrowser": "^3.0.0",
51 | "@jupyterlab/mainmenu": "^3.0.0"
52 | },
53 | "devDependencies": {
54 | "@types/jitsi-meet": "^2.0.2",
55 | "@jupyterlab/builder": "^3.3.0",
56 | "@jupyterlab/launcher": "^3.0.0",
57 | "@typescript-eslint/eslint-plugin": "^4.8.1",
58 | "@typescript-eslint/parser": "^4.8.1",
59 | "eslint": "^7.14.0",
60 | "eslint-config-prettier": "^6.15.0",
61 | "eslint-plugin-prettier": "^3.1.4",
62 | "eslint-plugin-react": "^7.21.5",
63 | "npm-run-all": "^4.1.5",
64 | "prettier": "^2.6.1",
65 | "rimraf": "^2.6.1",
66 | "typescript": "~4.6.3",
67 | "yarn-deduplicate": "^3.1.0"
68 | },
69 | "sideEffects": [
70 | "style/*.css"
71 | ],
72 | "jupyterlab": {
73 | "discovery": {
74 | "server": {
75 | "managers": [
76 | "conda",
77 | "pip"
78 | ],
79 | "base": {
80 | "name": "jupyter-videochat"
81 | }
82 | }
83 | },
84 | "extension": "lib/plugin.js",
85 | "schemaDir": "schema",
86 | "outputDir": "jupyter_videochat/labextension"
87 | },
88 | "prettier": {
89 | "singleQuote": true,
90 | "proseWrap": "always",
91 | "printWidth": 88
92 | }
93 | }
94 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # CHANGELOG
2 |
3 | ## jupyter-videochat [0.6.0]
4 |
5 | ### UI
6 |
7 | - Rooms now show their provider, presently _Server_ or _Public_ ([#38])
8 | - Adopt _Card_ styling, like the _Launcher_ ([#38])
9 | - _New Video Chat_ is added to the _File_ menu ([#38])
10 |
11 | ### API
12 |
13 | - _Public_ rooms are still configured as part of core, and can be opted-in via _Command
14 | Palette_ or _Advanced Settings_ (and therefore `overrides.json`) ([#38])
15 | - The _Public_ implementation is in a separate, optional plugin ([#38])
16 | - _Server_ rooms similarly moved to a separate, optional plugin ([#38])
17 | - The _Toggle Sidebar_ implementation is moved to a separate, optional plugin ([#60])
18 | - The `mainWidget` is available as part of the API, and exposes a `toolbar` for adding
19 | custom features ([#60])
20 |
21 | ### Integrations
22 |
23 | - Works more harmoniously with [retrolab] ([#38])
24 | - The _Public_ plugin is compatible with [JupyterLite] ([#38])
25 | - All public strings are now [internationalizable][i18n] ([#60])
26 |
27 | ### Docs
28 |
29 | - A documentation site is now maintained on [ReadTheDocs] ([#43])
30 | - It includes a one-click, no-install demo powered by [JupyterLite] ([#40])
31 |
32 | [0.6.0]: https://pypi.org/project/jupyter-videochat/0.6.0
33 | [#38]: https://github.com/jupyterlab-contrib/jupyter-videochat/pull/38
34 | [#40]: https://github.com/jupyterlab-contrib/jupyter-videochat/pull/40
35 | [#43]: https://github.com/jupyterlab-contrib/jupyter-videochat/pull/43
36 | [#60]: https://github.com/jupyterlab-contrib/jupyter-videochat/pull/60
37 | [jupyterlite]: https://github.com/jupyterlite/jupyterlite
38 | [readthedocs]: https://jupyter-videochat.rtfd.io
39 | [retrolab]: https://github.com/jupyterlab/retrolab
40 | [i18n]: https://jupyterlab.readthedocs.io/en/stable/extension/internationalization.html
41 |
42 | ## jupyter-videochat [0.5.1]
43 |
44 | - adds missing `provides` to allow downstreams extensions to use (and not just import)
45 | `IVideoChatManager` ([#21])
46 | - moves current Lab UI area (e.g. `right`, `main`) to user settings ([#22])
47 |
48 | [0.5.1]: https://pypi.org/project/jupyter-videochat/0.5.1
49 | [#21]: https://github.com/jupyterlab-contrib/jupyter-videochat/issues/21
50 | [#22]: https://github.com/jupyterlab-contrib/jupyter-videochat/pull/22
51 |
52 | ## jupyter-videochat [0.5.0]
53 |
54 | - overhaul for JupyterLab 3 ([#12], [#14])
55 | - `pip install jupyter-videochat`, no more `jupyter labextension install`
56 | - `npm` tarballs will continue to be released for downstream extensions
57 | - user install via `jupyter labextension install` is no longer tested
58 | - exposes `IVideoChatManager` for other extensions to interact with the current Jitsi
59 | Meet instance
60 | - fully configurable via _Advanced Settings_
61 | - Jitsi configuration
62 | - persistent display name/avatar
63 | - allow joining public rooms
64 | - replaced vendored Jitsi API with use of your Jitsi server's
65 | - adds URL router
66 | - open a chat directly with `?jvc=` ([#7])
67 |
68 | [0.5.0]: https://pypi.org/project/jupyter-videochat/0.5.0
69 | [#12]: https://github.com/jupyterlab-contrib/jupyter-videochat/issues/12
70 | [#7]: https://github.com/jupyterlab-contrib/jupyter-videochat/issues/7
71 | [#14]: https://github.com/jupyterlab-contrib/jupyter-videochat/pull/14
72 |
73 | ## jupyter-videochat [0.4.0]
74 |
75 | - fixes some iframe behavior ([#2])
76 | - last release compatible with JupyterLab 2
77 |
78 | [0.4.0]: https://www.npmjs.com/package/jupyterlab-videochat
79 | [#2]: https://github.com/jupyterlab-contrib/jupyter-videochat/issues/2
80 |
--------------------------------------------------------------------------------
/style/rooms.css:
--------------------------------------------------------------------------------
1 | .jp-VideoChat-rooms {
2 | flex: 1;
3 | overflow-y: auto;
4 | overflow-x: hidden;
5 | position: relative;
6 | background-color: var(--jp-layout-color1);
7 | }
8 |
9 | .jp-VideoChat-rooms a {
10 | color: var(--jp-brand-color1);
11 | }
12 |
13 | .jp-VideoChat-rooms ul {
14 | list-style: none;
15 | margin: 0;
16 | padding: 0 1em 1em 0;
17 | display: flex;
18 | flex-direction: row;
19 | flex-wrap: wrap;
20 | }
21 |
22 | .jp-VideoChat-rooms li {
23 | align-items: center;
24 | background-color: var(--jp-layout-color1);
25 | border: solid var(--jp-border-width) var(--jp-border-color2);
26 | color: var(--jp-ui-font-color2);
27 | display: flex;
28 | flex-direction: row;
29 | flex-wrap: wrap;
30 | flex: 1;
31 | margin: 1em 0 0 1em;
32 | max-width: 500px;
33 | min-width: 200px;
34 | padding: 1em;
35 | padding-top: 0;
36 | box-shadow: var(--jp-elevation-z2);
37 | border-radius: var(--jp-border-radius);
38 | }
39 |
40 | .jp-VideoChat-rooms li label {
41 | display: block;
42 | font-size: var(--jp-ui-font-size2);
43 | font-family: var(--jp-ui-font-family);
44 | color: var(--jp-ui-font-color0);
45 | flex: 1;
46 | font-weight: bold;
47 | padding: calc(var(--jp-ui-font-size0) * 0.5);
48 | padding-left: 0;
49 | }
50 |
51 | .jp-VideoChat-rooms li button {
52 | flex: 0;
53 | }
54 |
55 | .jp-VideoChat-room-displayname-input {
56 | display: flex;
57 | flex-direction: row;
58 | align-items: center;
59 | width: 100%;
60 | }
61 |
62 | .jp-VideoChat-rooms li.jp-VideoChat-has-input {
63 | flex-direction: column;
64 | padding: 0 1em 1em 1em;
65 | border: dashed 1px var(--jp-border-color2);
66 | }
67 |
68 | .jp-VideoChat-rooms li.jp-VideoChat-has-input input {
69 | width: unset;
70 | min-width: unset;
71 | max-width: unset;
72 | padding: 0;
73 | }
74 |
75 | .jp-VideoChat-room-displayname-input input {
76 | margin-right: 1em;
77 | flex: 1;
78 | }
79 |
80 | .jp-VideoChat-rooms button {
81 | flex: 0;
82 | min-width: 5em;
83 | margin: 1em 0;
84 | }
85 |
86 | .jp-VideoChat-room-description {
87 | display: block;
88 | font-size: var(--jp-ui-font-size1);
89 | font-family: var(--jp-ui-font-family);
90 | color: var(--jp-ui-font-color2);
91 | }
92 |
93 | .jp-VideoChat-rooms > label,
94 | .jp-VideoChat-rooms-public > label,
95 | .jp-VideoChat-rooms-server > label {
96 | font-size: var(--jp-ui-font-size1);
97 | font-family: var(--jp-ui-font-family);
98 | color: var(--jp-ui-font-color1);
99 | padding: 0.5em 1em;
100 | font-weight: 600;
101 | display: flex;
102 | align-items: center;
103 | position: -webkit-sticky;
104 | position: sticky;
105 | top: 0;
106 | background-color: var(--jp-layout-color1);
107 | border: solid 1px var(--jp-border-color2);
108 | border-left: 0;
109 | border-right: 0;
110 | }
111 |
112 | .jp-VideoChat-rooms label svg {
113 | margin-right: 0.5em;
114 | }
115 |
116 | .jp-VideoChat-rooms blockquote {
117 | font-size: var(--jp-ui-font-size1);
118 | color: var(--jp-ui-font-color1);
119 | padding: 0;
120 | margin: 0;
121 | margin-top: 0.5em;
122 | width: 100%;
123 | }
124 |
125 | .jp-VideoChat-user-info {
126 | list-style: none;
127 | margin: 0;
128 | padding: 1em;
129 | }
130 |
131 | .jp-VideoChat-input-group {
132 | display: flex;
133 | flex-direction: row;
134 | padding-bottom: 1em;
135 | align-items: baseline;
136 | }
137 |
138 | .jp-VideoChat-input-group label {
139 | color: var(--jp-ui-font-color1);
140 | flex: 0;
141 | padding: 0 1em 0 0;
142 | }
143 |
144 | .jp-VideoChat-input-group input {
145 | flex: 1;
146 | }
147 |
148 | ul[aria-labelledby='id-jp-VideoChat-user-info'] li,
149 | #id-jp-VideoChat-user-info .jp-VideoChat-input-group {
150 | margin-top: 0;
151 | border-top: 0;
152 | }
153 |
154 | .jp-VideoChat-active-room-name {
155 | max-height: 1.5em;
156 | text-overflow: ellipsis;
157 | font-weight: bold;
158 | line-height: 1.8;
159 | }
160 |
161 | .jp-VideoChat-active-room-name i {
162 | margin-right: 0.5em;
163 | }
164 |
--------------------------------------------------------------------------------
/docs/conf.py:
--------------------------------------------------------------------------------
1 | """documentation for jupyter-videochat"""
2 | import datetime
3 | import json
4 | import os
5 | import subprocess
6 | import sys
7 | from configparser import ConfigParser
8 | from pathlib import Path
9 |
10 | CONF_PY = Path(__file__)
11 | HERE = CONF_PY.parent
12 | ROOT = HERE.parent
13 |
14 | PY = sys.executable
15 | PIP = [PY, "-m", "pip"]
16 | JPY = [PY, "-m", "jupyter"]
17 |
18 | DOCS_IN_CI = json.loads(os.environ.get("DOCS_IN_CI", "False").lower())
19 | RTD = json.loads(os.environ.get("READTHEDOCS", "False").lower())
20 |
21 | # extra tasks peformed on ReadTheDocs
22 | DOCS_IN_CI_TASKS = [
23 | # initialize the lite site
24 | [HERE, [*JPY, "lite", "init"]],
25 | # build the lite site
26 | [HERE, [*JPY, "lite", "build"]],
27 | # build the lite archive
28 | [HERE, [*JPY, "lite", "archive"]],
29 | # check the lite site
30 | [HERE, [*JPY, "lite", "check"]],
31 | ]
32 |
33 | RTD_TASKS = [
34 | # be very sure we've got a clean state
35 | [
36 | HERE,
37 | [
38 | "git",
39 | "clean",
40 | "-dxf",
41 | HERE / "_build",
42 | HERE / "_static/lite",
43 | HERE / ".jupyterlite.doit.db",
44 | ],
45 | ],
46 | # ensure node_modules
47 | [ROOT, ["jlpm", "--ignore-optional"]],
48 | # ensure lib an labextension
49 | [ROOT, ["jlpm", "clean"]],
50 | # ensure lib an labextension
51 | [ROOT, ["jlpm", "build"]],
52 | # install hot module
53 | [ROOT, [*PIP, "install", "-e", ".", "--no-deps", "--ignore-installed", "-vv"]],
54 | # force serverextension
55 | [ROOT, [*JPY, "server", "extension", "enable", "--py", "jupyter_videochat"]],
56 | # list serverextension
57 | [ROOT, [*JPY, "server", "extension", "list"]],
58 | # force labextension
59 | [ROOT, [*JPY, "labextension", "develop", "--overwrite", "."]],
60 | # list labextension
61 | [ROOT, [*JPY, "labextension", "list"]],
62 | ] + DOCS_IN_CI_TASKS
63 |
64 |
65 | APP_PKG = ROOT / "package.json"
66 | APP_DATA = json.loads(APP_PKG.read_text(encoding="utf-8"))
67 |
68 | SETUP_CFG = ROOT / "setup.cfg"
69 | SETUP_DATA = ConfigParser()
70 | SETUP_DATA.read_file(SETUP_CFG.open())
71 |
72 | # metadata
73 | author = APP_DATA["author"]
74 | project = SETUP_DATA["metadata"]["name"]
75 | copyright = f"{datetime.date.today().year}, {author}"
76 |
77 | # The full version, including alpha/beta/rc tags
78 | release = APP_DATA["version"]
79 |
80 | # The short X.Y version
81 | version = ".".join(release.rsplit(".", 1))
82 |
83 | # sphinx config
84 | extensions = [
85 | # first-party sphinx extensions
86 | "sphinx.ext.todo",
87 | "sphinx.ext.autosectionlabel",
88 | # for pretty schema
89 | "sphinx-jsonschema",
90 | # mostly markdown (some ipynb)
91 | "myst_nb",
92 | # autodoc-related stuff must be in order
93 | "sphinx.ext.autodoc",
94 | "sphinx.ext.napoleon",
95 | ]
96 |
97 | autosectionlabel_prefix_document = True
98 | myst_heading_anchors = 3
99 | suppress_warnings = ["autosectionlabel.*"]
100 |
101 | # files
102 | templates_path = ["_templates"]
103 |
104 | html_favicon = "_static/logo.svg"
105 |
106 | html_static_path = [
107 | "_static",
108 | ]
109 | exclude_patterns = [
110 | "_build",
111 | ".ipynb_checkpoints",
112 | "**/.ipynb_checkpoints",
113 | "**/~.*",
114 | "**/node_modules",
115 | "babel.config.*",
116 | "jest-setup.js",
117 | "jest.config.js",
118 | "jupyter_execute",
119 | ".jupyter_cache",
120 | "test/",
121 | "tsconfig.*",
122 | "webpack.config.*",
123 | ]
124 | jupyter_execute_notebooks = "auto"
125 |
126 | execution_excludepatterns = [
127 | "_static/**/*",
128 | ]
129 | # html_css_files = [
130 | # "theme.css",
131 | # ]
132 |
133 | # theme
134 | html_theme = "pydata_sphinx_theme"
135 | html_logo = "_static/logo.svg"
136 | html_theme_options = {
137 | "github_url": APP_DATA["homepage"],
138 | "use_edit_page_button": True,
139 | }
140 | html_sidebars = {
141 | "**": [
142 | "demo.html",
143 | "search-field.html",
144 | "sidebar-nav-bs.html",
145 | "sidebar-ethical-ads.html",
146 | ]
147 | }
148 |
149 | html_context = {
150 | "github_user": "jupyterlab-contrib",
151 | "github_repo": "jupyter-videochat",
152 | "github_version": "master",
153 | "doc_path": "docs",
154 | "demo_tarball": f"_static/jupyter-videochat-lite-{release}.tgz",
155 | }
156 |
157 |
158 | def before_ci_build(app, error):
159 | """performs tasks not done yet in CI/RTD"""
160 | for cwd, task in RTD_TASKS if RTD else DOCS_IN_CI_TASKS:
161 | str_args = [*map(str, task)]
162 | print(
163 | f"[jupyter-videochat-docs] {cwd.relative_to(ROOT)}: {' '.join(str_args)}",
164 | flush=True,
165 | )
166 | subprocess.check_call(str_args, cwd=str(cwd))
167 |
168 |
169 | def setup(app):
170 | if RTD or DOCS_IN_CI:
171 | app.connect("config-inited", before_ci_build)
172 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Install
4 |
5 | The `jlpm` command is JupyterLab's pinned version of [yarn](https://yarnpkg.com/) that
6 | is installed with JupyterLab.
7 |
8 | > You may use `yarn` or `npm` in lieu of `jlpm` below, but internally some subcommands
9 | > will use still use `jlpm`.
10 |
11 | ```bash
12 | # Clone the project repository
13 | git clone https://github.com/jupyterlab-contrib/jupyter-videochat
14 | # Move to jupyter-videochat directory
15 | cd jupyter-videochat
16 | # Install JS dependencies
17 | jlpm
18 | # Build TypesSript source and Lab Extension
19 | jlpm build
20 | # Install server extension
21 | pip install -e .
22 | # Register server extension
23 | jupyter server extension enable --py jupyter_videochat
24 | jupyter serverextension enable --py jupyter_videochat
25 | # Symlink your development version of the extension with JupyterLab
26 | jupyter labextension develop --overwrite .
27 | # Rebuild Typescript source after making changes
28 | jlpm build
29 | ```
30 |
31 | ## Live Development
32 |
33 | You can watch the `src` directory for changes and automatically rebuild the JS files and
34 | webpacked extension.
35 |
36 | ```bash
37 | # Watch the source directory in another terminal tab
38 | jlpm watch
39 | # ... or, as they are both pretty noisy, run two terminals with
40 | # jlpm watch:lib
41 | # jlpm watch:ext
42 | # Run jupyterlab in watch mode in one terminal tab
43 | jupyter lab
44 | # ... or, to also watch server extension source files
45 | # jupyter lab --autoreload
46 | ```
47 |
48 | ## Extending
49 |
50 | ### Jitsi Meet API
51 |
52 | Other [JupyterLab extensions] can use the `IVideoChatManager` to interact with the
53 | [Jitsi Meet API](https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-iframe)
54 | instance, which has many _commands_, _functions_ and _events_. Nobody has yet, _that we
55 | know of_: if you are successful, please consider posting an issue/screenshot on the
56 | GitHub repository!
57 |
58 | - Add `jupyterlab-videochat` as a `package.json` dependency
59 |
60 | ```bash
61 | # in the folder with your package.json
62 | jlpm add jupyterlab-videochat
63 | ```
64 |
65 | - Include `IVideoChatManager` in your plugins's `activate` function
66 |
67 | ```ts
68 | // plugin.ts
69 | import { IVideoChatManager } from 'jupyterlab-videochat';
70 |
71 | const plugin: JupyterFrontEndPlugin = {
72 | id: `my-labextension:plugin`,
73 | autoStart: true,
74 | requires: [IVideoChatManager],
75 | activate: (app: JupyterLabFrontEnd, videochat: IVideoChatManager) => {
76 | videochat.meetChanged.connect(() => {
77 | if (videochat.meet) {
78 | // do something clever with the Meet!
79 | }
80 | });
81 | },
82 | };
83 |
84 | export default plugin;
85 | ```
86 |
87 | > _The typings provided for the Jitsit API are **best-effort**, PRs welcome to improve
88 | > them._
89 |
90 | - (Probably) add `jupyter-videochat` to your extension's python dependencies, e.g.
91 |
92 | ```py
93 | # setup.py
94 | setup(
95 | install_requires=["jupyter-videochat"]
96 | )
97 | ```
98 |
99 | ### Room Provider
100 |
101 | Other [JupyterLab extensions] may add additional sources of _Rooms_ by registering a
102 | _provider_. See the core implementations of server and public rooms for examples of how
103 | to use the `IVideoChatManager.registerRoomProvider` API.
104 |
105 | _Providers_ are able to:
106 |
107 | - fetch configuration information to set up a connection to a Jitsi server
108 | - create new _Rooms_ that other users can join.
109 | - find additional _Rooms_
110 |
111 | If providing new rooms, it is important to have a scheme for generating room names that
112 | are:
113 |
114 | - unique
115 | - hard-to-guess
116 |
117 | While _passwords_, _lobbies_, and _end-to-end encryption_ are also available to
118 | moderators, the room name is the first line of defense in avoiding unexpected visitors
119 | during a Jitsi meeting.
120 |
121 | ## Releasing
122 |
123 | - Start a release issue with a checklist of tasks
124 | - see previous releases for examples
125 | - Ensure the version has been updated, roughly following [semver]
126 | - Basically, any _removal_ or _data_ constraint would trigger a `0.x+1.0`
127 | - Otherwise it's probably `0.x.y+1`
128 | - Ensure the `CHANGELOG.md` and `README.md` are up-to-date
129 | - Wait until CI passes on `master`
130 | - Validate on Binder
131 | - Download the release assets from the latest CI run
132 | - From the GitHub web UI, create a new tag/release
133 | - name the tag `v0.x.y`
134 | - upload all of the release assets (including `SHA256SUMS`!)
135 | - Upload to pypi.org
136 | ```bash
137 | twine upload jupyter-videochat*
138 | ```
139 | - Upload to `npmjs.com`
140 | ```bash
141 | npm login
142 | npm publish jupyterlab-videochat*
143 | npm logout
144 | ```
145 | - Make a new PR bumping to the next point release
146 | - just in case a quick fix is needed
147 | - Validate the as-released assets in a clean environment
148 | - e.g. on Binder with a simple `requirements.txt` gist
149 | ```bash
150 | jupyter-videochat ==0.x.y
151 | ```
152 | - Wait for the [conda-forge feedstock] to get an automated PR
153 | - validate and merge
154 | - Close the release issue!
155 |
156 | [semver]: https://semver.org/
157 | [conda-forge feedstock]: https://github.com/conda-forge/jupyter-videochat-feedstock
158 | [jupyterlab extensions]:
159 | https://jupyterlab.readthedocs.io/en/stable/extension/extension_dev.html
160 |
--------------------------------------------------------------------------------
/src/tokens.ts:
--------------------------------------------------------------------------------
1 | import { Token } from '@lumino/coreutils';
2 | import { ISignal } from '@lumino/signaling';
3 |
4 | import { JitsiMeetExternalAPI } from 'jitsi-meet';
5 |
6 | import { MainAreaWidget } from '@jupyterlab/apputils';
7 | import { ILabShell } from '@jupyterlab/application';
8 | import { ISettingRegistry } from '@jupyterlab/settingregistry';
9 |
10 | import { Room, VideoChatConfig, IJitsiFactory } from './types';
11 |
12 | /** The namespace for key tokens and IDs */
13 | export const NS = 'jupyterlab-videochat';
14 |
15 | /** The serverextension namespace, to be combined with the `base_url`
16 | */
17 | export const API_NAMESPACE = 'videochat';
18 |
19 | /** A CSS prefix */
20 | export const CSS = 'jp-VideoChat';
21 |
22 | /** The URL parameter (specified with `&` or `?`) which will trigger a re-route */
23 | export const SERVER_URL_PARAM = 'jvc';
24 |
25 | export const PUBLIC_URL_PARAM = 'JVC-PUBLIC';
26 |
27 | /** JS assets of last resort
28 | *
29 | * ### Note
30 | * If an alternate Jitsi server is provided, it is assumed `/external_api.js`
31 | * is hosted from the root.
32 | */
33 | export const DEFAULT_DOMAIN = 'meet.jit.si';
34 |
35 | /**
36 | * The URL frgament (when joined with `baseUrl`) for the retro tree
37 | */
38 | export const RETRO_TREE_URL = 'retro/tree';
39 |
40 | /**
41 | * The canary in jupyter-config-data for detecting retrolab
42 | */
43 | export const RETRO_CANARY_OPT = 'retroPage';
44 |
45 | /**
46 | * A URL param that will enable chat, even in non-full Lab
47 | */
48 | export const FORCE_URL_PARAM = 'show-videochat';
49 |
50 | /**
51 | * Names for spacer components.
52 | */
53 | export namespace ToolbarIds {
54 | /**
55 | * The main area left spacer
56 | */
57 | export const SPACER_LEFT = 'spacer-left';
58 |
59 | /**
60 | * The main area right spacer
61 | */
62 | export const SPACER_RIGHT = 'spacer-right';
63 |
64 | /**
65 | * The button for the area toggle.
66 | */
67 | export const TOGGLE_AREA = 'toggle-sidebar';
68 |
69 | /**
70 | * The button for disconnect.
71 | */
72 | export const DISCONNECT = 'disconnect';
73 | /**
74 | * The text label for the title.
75 | */
76 | export const TITLE = 'title';
77 | }
78 |
79 | /**
80 | * An interface for sources of Jitsi Rooms
81 | */
82 | export interface IRoomProvider {
83 | /**
84 | * Fetch available rooms
85 | */
86 | updateRooms: () => Promise;
87 | /**
88 | * Whether the provider can create rooms.
89 | */
90 | canCreateRooms: boolean;
91 | /**
92 | * Create a new room, filling in missing details.
93 | */
94 | createRoom?: (room: Partial) => Promise;
95 | /**
96 | * Fetch the config
97 | */
98 | updateConfig: () => Promise;
99 | /**
100 | * A signal that updates
101 | */
102 | stateChanged?: ISignal;
103 | }
104 |
105 | /**
106 | * The public interface exposed by the video chat extension
107 | */
108 | export interface IVideoChatManager extends IRoomProvider {
109 | /** The known Hub `Rooms` from the server */
110 | rooms: Room[];
111 |
112 | /** The current room */
113 | currentRoom: Room;
114 |
115 | currentRoomChanged: ISignal;
116 |
117 | /** Whether the manager is fully initialized */
118 | isInitialized: boolean;
119 |
120 | /** A `Promise` that resolves when fully initialized */
121 | initialized: Promise;
122 |
123 | /** The last-fetched config from the server */
124 | config: VideoChatConfig;
125 |
126 | /** The current Jitsi Meet instance */
127 | meet: JitsiMeetExternalAPI | null;
128 |
129 | /** A signal emitted when the current Jitsi Meet has changed */
130 | meetChanged: ISignal;
131 |
132 | /** The user settings object (usually use `composite`) */
133 | settings: ISettingRegistry.ISettings;
134 |
135 | /** The IFrame API exposed by Jitsi
136 | *
137 | * @see https://jitsi.github.io/handbook/docs/dev-guide/dev-guide-iframe
138 | */
139 | getJitsiAPI(): IJitsiFactory;
140 |
141 | /** The area in the JupyterLab UI where the chat UI will be shown
142 | *
143 | * ### Notes
144 | * probably one of: left, right, main
145 | */
146 | currentArea: ILabShell.Area;
147 |
148 | /**
149 | * Add a new room provider.
150 | */
151 | registerRoomProvider(options: IVideoChatManager.IProviderOptions): void;
152 |
153 | /**
154 | * Get the provider for a specific room.
155 | */
156 | providerForRoom(room: Room): IVideoChatManager.IProviderOptions | null;
157 |
158 | /**
159 | * A signal for when room providers change
160 | */
161 | roomProvidersChanged: ISignal;
162 |
163 | /**
164 | * A translator for strings from this package
165 | */
166 | __(msgid: string, ...args: string[]): string;
167 |
168 | /**
169 | * The main outer Video Chat widget.
170 | */
171 | mainWidget: Promise;
172 | }
173 |
174 | export interface IRoomListProps {}
175 |
176 | export type TRoomComponent = (props: RoomsListProps) => JSX.Element;
177 |
178 | export type TLazyRoomComponent = () => Promise;
179 |
180 | /** A namespace for VideoChatManager details */
181 | export namespace IVideoChatManager {
182 | /** Options for constructing a new IVideoChatManager */
183 | export interface IOptions {
184 | // TBD
185 | }
186 | export interface IProviderOptions {
187 | /** a unique identifier for the provider */
188 | id: string;
189 | /** a human-readable label for the provider */
190 | label: string;
191 | /** a rank for preference */
192 | rank: number;
193 | /** the provider implementation */
194 | provider: IRoomProvider;
195 | }
196 | }
197 |
198 | /** The lumino commands exposed by this extension */
199 | export namespace CommandIds {
200 | /** The command id for opening a specific room */
201 | export const open = `${NS}:open`;
202 |
203 | /** The command id for opening a specific room in a tabs */
204 | export const openTab = `${NS}:open-tab`;
205 |
206 | /** The command id for switching the area of the UI */
207 | export const toggleArea = `${NS}:togglearea`;
208 |
209 | /** The command id for disconnecting a video chat */
210 | export const disconnect = `${NS}:disconnect`;
211 |
212 | /** The command id for enabling public rooms */
213 | export const togglePublicRooms = `${NS}:togglepublic`;
214 |
215 | /** The special command used during server routing */
216 | export const serverRouterStart = `${NS}:routerserver`;
217 |
218 | /** The special command used during public routing */
219 | export const publicRouterStart = `${NS}:routerpublic`;
220 | }
221 |
222 | /* tslint:disable */
223 | /** The VideoManager extension point, to be used in other plugins' `activate`
224 | * functions */
225 | export const IVideoChatManager = new Token(
226 | `${NS}:IVideoChatManager`
227 | );
228 | /* tslint:enable */
229 |
230 | export type RoomsListProps = {
231 | onRoomSelect: (room: Room) => void;
232 | onCreateRoom: (room: Room) => void;
233 | onEmailChanged: (email: string) => void;
234 | onDisplayNameChanged: (displayName: string) => void;
235 | providerForRoom: (room: Room) => IVideoChatManager.IProviderOptions;
236 | currentRoom: Room;
237 | rooms: Room[];
238 | email: string;
239 | displayName: string;
240 | domain: string;
241 | disablePublicRooms: boolean;
242 | canCreateRooms: boolean;
243 | __: ITrans;
244 | };
245 |
246 | /**
247 | * A lightweight debug tool.
248 | */
249 | export const DEBUG = window.location.href.indexOf('JVC_DEBUG') > -1;
250 |
251 | /**
252 | * An gettext-style internationaliation translation signature.
253 | *
254 | * args can be referenced by 1-index, e.g. args[0] is %1
255 | */
256 | export interface ITrans {
257 | (msgid: string, ...args: string[]): string;
258 | }
259 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | - develop
8 | pull_request:
9 | branches: '*'
10 |
11 | env:
12 | CACHE_EPOCH: 4
13 |
14 | jobs:
15 | build:
16 | name: build
17 | runs-on: ${{ matrix.os }}-latest
18 | strategy:
19 | matrix:
20 | os: ['ubuntu']
21 | python-version: ['3.10']
22 | node-version: ['16.x']
23 | lab-version: ['3.3']
24 | steps:
25 | - name: Checkout
26 | uses: actions/checkout@v3
27 |
28 | - name: Select Node ${{ matrix.node-version }}
29 | uses: actions/setup-node@v2
30 | with:
31 | node-version: ${{ matrix.node-version }}
32 |
33 | - name: Select Python ${{ matrix.python-version }}
34 | uses: actions/setup-python@v3
35 | with:
36 | python-version: ${{ matrix.python-version }}
37 | architecture: 'x64'
38 |
39 | - name: Cache (Python)
40 | uses: actions/cache@v3
41 | with:
42 | path: ~/.cache/pip
43 | key: |
44 | ${{ env.CACHE_EPOCH }}-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.lab-version }}-pip-build-${{ hashFiles('setup.py', 'setup.cfg') }}
45 | restore-keys: |
46 | ${{ env.CACHE_EPOCH }}-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.lab-version }}-pip-build-
47 |
48 | - name: Install Python packaging dependencies
49 | run: pip3 install -U --user pip wheel setuptools
50 |
51 | - name: Install Python dev dependencies
52 | run: pip3 install "jupyterlab==${{ matrix.lab-version }}.*"
53 |
54 | - name: Validate Python Environment
55 | run: |
56 | set -eux
57 | pip3 freeze | tee .pip-frozen
58 | pip3 check
59 |
60 | - name: Cache (JS)
61 | uses: actions/cache@v3
62 | with:
63 | path: '**/node_modules'
64 | key: |
65 | ${{ env.CACHE_EPOCH }}-${{ runner.os }}-${{ matrix.node-version }}-${{ matrix.lab-version }}-node-build-${{ hashFiles('yarn.lock') }}
66 | restore-keys: |
67 | ${{ env.CACHE_EPOCH }}-${{ runner.os }}-${{ matrix.node-version }}-${{ matrix.lab-version }}-node-build-
68 |
69 | - name: Install JS dependencies
70 | run: jlpm --ignore-optional --frozen-lockfile
71 |
72 | - name: Build npm tarball
73 | run: |
74 | set -eux
75 | mkdir dist
76 | jlpm build
77 | mv $(npm pack) dist
78 |
79 | - name: Build Python distributions
80 | run: python3 setup.py sdist bdist_wheel
81 |
82 | - name: Generate distribution hashes
83 | run: |
84 | set -eux
85 | cd dist
86 | sha256sum * | tee SHA256SUMS
87 |
88 | - name: Upload distributions
89 | uses: actions/upload-artifact@v3
90 | with:
91 | name: jupyter-videochat ${{ github.run_number }} dist
92 | path: ./dist
93 |
94 | - name: Upload labextension
95 | uses: actions/upload-artifact@v3
96 | with:
97 | name: jupyter-videochat ${{ github.run_number }} labextension
98 | path: ./jupyter_videochat/labextension
99 |
100 | lint:
101 | needs: [build]
102 | name: lint
103 | runs-on: ${{ matrix.os }}-latest
104 | strategy:
105 | matrix:
106 | os: ['ubuntu']
107 | python-version: ['3.10']
108 | node-version: ['16.x']
109 | lab-version: ['3.3']
110 | steps:
111 | - name: Checkout
112 | uses: actions/checkout@v3
113 |
114 | - name: Select Node ${{ matrix.node-version }}
115 | uses: actions/setup-node@v2
116 | with:
117 | node-version: ${{ matrix.node-version }}
118 |
119 | - name: Select Python ${{ matrix.python-version }}
120 | uses: actions/setup-python@v3
121 | with:
122 | python-version: ${{ matrix.python-version }}
123 | architecture: 'x64'
124 |
125 | - name: Cache (Python)
126 | uses: actions/cache@v3
127 | with:
128 | path: ~/.cache/pip
129 | key: |
130 | ${{ env.CACHE_EPOCH }}-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.lab-version }}-pip-lint-${{ hashFiles('setup.cfg') }}
131 | restore-keys: |
132 | ${{ env.CACHE_EPOCH }}-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.lab-version }}-pip-lint-
133 |
134 | - name: Install Python packaging dependencies
135 | run: pip3 install -U --user pip wheel setuptools
136 |
137 | - name: Install Python dev dependencies
138 | run: pip3 install "jupyterlab==${{ matrix.lab-version }}.*"
139 |
140 | - name: Cache (JS)
141 | uses: actions/cache@v3
142 | with:
143 | path: '**/node_modules'
144 | key: |
145 | ${{ env.CACHE_EPOCH }}-${{ runner.os }}-${{ matrix.node-version }}-${{ matrix.lab-version }}-node-build-${{ hashFiles('yarn.lock') }}
146 | restore-keys: |
147 | ${{ env.CACHE_EPOCH }}-${{ runner.os }}-${{ matrix.node-version }}-${{ matrix.lab-version }}-node-build-
148 |
149 | - name: Install JS dependencies
150 | run: jlpm --ignore-optional --frozen-lockfile
151 |
152 | - name: Download built labextension
153 | uses: actions/download-artifact@v3
154 | with:
155 | name: jupyter-videochat ${{ github.run_number }} labextension
156 | path: ./jupyter_videochat/labextension
157 |
158 | - name: Python Dev Install
159 | run: |
160 | set -eux
161 | pip3 install -e .[lint]
162 |
163 | - name: Lint Lab Extension, etc.
164 | run: jlpm run lint:check
165 |
166 | - name: Lint Python
167 | run: |-
168 | isort --check setup.py docs jupyter_videochat
169 | black --check setup.py docs jupyter_videochat
170 |
171 | test:
172 | needs: [build]
173 | name: test ${{ matrix.os }} py${{ matrix.python-version }}
174 | runs-on: ${{ matrix.os }}-latest
175 | strategy:
176 | # fail-fast: false
177 | matrix:
178 | python-version: ['3.7', '3.10']
179 | os: ['ubuntu', 'windows', 'macos']
180 | include:
181 | # use python as marker for node/distribution test coverage
182 | - python-version: '3.7'
183 | artifact-glob: '*.tar.gz'
184 | lab-version: '3.0'
185 | - python-version: '3.10'
186 | artifact-glob: '*.whl'
187 | lab-version: '3.3'
188 | # os-specific settings
189 | - os: windows
190 | python-cmd: python
191 | pip-cache: ~\AppData\Local\pip\Cache
192 | - os: ubuntu
193 | python-cmd: python3
194 | pip-cache: ~/.cache/pip
195 | - os: macos
196 | python-cmd: python3
197 | pip-cache: ~/Library/Caches/pip
198 |
199 | defaults:
200 | run:
201 | shell: bash -l {0}
202 | steps:
203 | - name: Checkout
204 | uses: actions/checkout@v3
205 |
206 | - name: Select Python ${{ matrix.python-version }}
207 | uses: actions/setup-python@v3
208 | with:
209 | python-version: ${{ matrix.python-version }}
210 | architecture: 'x64'
211 |
212 | - name: Cache (Python)
213 | uses: actions/cache@v3
214 | with:
215 | path: ${{ matrix.pip-cache }}
216 | key: |
217 | ${{ env.CACHE_EPOCH }}-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.lab-version }}-pip-test-${{ hashFiles('setup.py', 'setup.cfg') }}
218 | restore-keys: |
219 | ${{ env.CACHE_EPOCH }}-${{ runner.os }}-${{ matrix.python-version }}-${{ matrix.lab-version }}-pip-test-
220 |
221 | - name: Install Python packaging dependencies
222 | run: |
223 | set -eux
224 | pip3 install -U --user pip wheel setuptools
225 |
226 | - name: Download distributions
227 | uses: actions/download-artifact@v3
228 | with:
229 | name: jupyter-videochat ${{ github.run_number }} dist
230 | path: ./dist
231 |
232 | - name: Install Python distribution
233 | run: |
234 | set -eux
235 | cd dist
236 | pip3 install -v ${{ matrix.artifact-glob }} "jupyterlab==${{ matrix.lab-version }}.*" notebook
237 |
238 | - name: Validate Python environment
239 | run: set -eux pip3 freeze | tee .pip-frozen pip3 check
240 |
241 | - name: Import smoke test
242 | run: |
243 | set -eux
244 | cd dist
245 | ${{ matrix.python-cmd }} -c "import jupyter_videochat; print(jupyter_videochat.__version__)"
246 |
247 | - name: Validate Server Extension (server)
248 | run: |
249 | set -eux
250 | jupyter server extension list --debug 1>serverextensions 2>&1
251 | cat serverextensions
252 | cat serverextensions | grep -i "jupyter_videochat.*OK"
253 |
254 | - name: Validate Server Extension (notebook)
255 | run: |
256 | set -eux
257 | jupyter serverextension list --debug 1>server_extensions 2>&1
258 | cat server_extensions
259 | cat server_extensions | grep -i "jupyter_videochat.*OK"
260 |
261 | - name: Validate Lab Extension
262 | run: |
263 | set -eux
264 | jupyter labextension list --debug 1>labextensions 2>&1
265 | cat labextensions
266 | cat labextensions | grep -i "jupyterlab-videochat.*OK"
267 |
268 | - name: Install (docs)
269 | if: matrix.python-version == '3.10' && matrix.os == 'ubuntu'
270 | run: pip install -r docs/requirements.txt
271 |
272 | - name: Build (docs)
273 | if: matrix.python-version == '3.10' && matrix.os == 'ubuntu'
274 | env:
275 | DOCS_IN_CI: 1
276 | run: sphinx-build -W -b html docs docs/_build
277 |
278 | - name: Check (links)
279 | if: matrix.python-version == '3.10' && matrix.os == 'ubuntu'
280 | run: |
281 | pytest-check-links docs/_build -p no:warnings --links-ext=html --check-anchors --check-links-ignore "^https?://"
282 |
--------------------------------------------------------------------------------
/src/manager.ts:
--------------------------------------------------------------------------------
1 | import { Signal, ISignal } from '@lumino/signaling';
2 | import { PromiseDelegate } from '@lumino/coreutils';
3 |
4 | import { ILabShell } from '@jupyterlab/application';
5 | import { TranslationBundle } from '@jupyterlab/translation';
6 |
7 | import { MainAreaWidget, VDomModel } from '@jupyterlab/apputils';
8 | import { ISettingRegistry } from '@jupyterlab/settingregistry';
9 |
10 | import { IVideoChatManager, DEFAULT_DOMAIN, CSS, DEBUG } from './tokens';
11 |
12 | import type { JitsiMeetExternalAPIConstructor, JitsiMeetExternalAPI } from 'jitsi-meet';
13 |
14 | import { Room, VideoChatConfig, IJitsiFactory } from './types';
15 | import { Widget } from '@lumino/widgets';
16 |
17 | /** A manager that can add, join, or create Video Chat rooms
18 | */
19 | export class VideoChatManager extends VDomModel implements IVideoChatManager {
20 | private _rooms: Room[] = [];
21 | private _currentRoom: Room;
22 | private _isInitialized = false;
23 | private _initialized = new PromiseDelegate();
24 | private _config: VideoChatConfig;
25 | private _meet: JitsiMeetExternalAPI;
26 | private _meetChanged: Signal;
27 | private _settings: ISettingRegistry.ISettings;
28 | private _roomProviders = new Map();
29 | private _roomProvidedBy = new WeakMap();
30 | private _roomProvidersChanged: Signal;
31 | private _currentRoomChanged: Signal;
32 | private _trans: TranslationBundle;
33 | protected _mainWidget: MainAreaWidget;
34 |
35 | constructor(options?: VideoChatManager.IOptions) {
36 | super();
37 | this._trans = options.trans;
38 | this._meetChanged = new Signal(this);
39 | this._roomProvidersChanged = new Signal(this);
40 | this._currentRoomChanged = new Signal(this);
41 | this._roomProvidersChanged.connect(this.onRoomProvidersChanged, this);
42 | }
43 |
44 | __ = (msgid: string, ...args: string[]): string => {
45 | return this._trans.__(msgid, ...args);
46 | };
47 |
48 | /** all known rooms */
49 | get rooms(): Room[] {
50 | return this._rooms;
51 | }
52 |
53 | /** whether the manager is initialized */
54 | get isInitialized(): boolean {
55 | return this._isInitialized;
56 | }
57 |
58 | /** A `Promise` that resolves when fully initialized */
59 | get initialized(): Promise {
60 | return this._initialized.promise;
61 | }
62 |
63 | /** the current room */
64 | get currentRoom(): Room {
65 | return this._currentRoom;
66 | }
67 |
68 | /**
69 | * set the current room, potentially scheduling a trip to the server for an id
70 | */
71 | set currentRoom(room: Room) {
72 | this._currentRoom = room;
73 | this.stateChanged.emit(void 0);
74 | this._currentRoomChanged.emit(void 0);
75 | if (room != null && room.id == null) {
76 | this.createRoom(room).catch(console.warn);
77 | }
78 | }
79 |
80 | /** A signal that emits when the current room changes. */
81 | get currentRoomChanged(): ISignal {
82 | return this._currentRoomChanged;
83 | }
84 |
85 | /** The configuration from the server/settings */
86 | get config(): VideoChatConfig {
87 | return this._config;
88 | }
89 |
90 | /** The current JitsiExternalAPI, as served by `/external_api.js` */
91 | get meet(): JitsiMeetExternalAPI {
92 | return this._meet;
93 | }
94 |
95 | /** Update the current meet */
96 | set meet(meet: JitsiMeetExternalAPI) {
97 | if (this._meet !== meet) {
98 | this._meet = meet;
99 | this._meetChanged.emit(void 0);
100 | }
101 | }
102 |
103 | /** A signal that emits when the current meet changes */
104 | get meetChanged(): Signal {
105 | return this._meetChanged;
106 | }
107 |
108 | /** A signal that emits when the available rooms change */
109 | get roomProvidersChanged(): Signal {
110 | return this._roomProvidersChanged;
111 | }
112 |
113 | /** The JupyterLab settings bundle */
114 | get settings(): ISettingRegistry.ISettings {
115 | return this._settings;
116 | }
117 |
118 | set settings(settings: ISettingRegistry.ISettings) {
119 | if (this._settings) {
120 | this._settings.changed.disconnect(this.onSettingsChanged, this);
121 | }
122 | this._settings = settings;
123 | if (this._settings) {
124 | this._settings.changed.connect(this.onSettingsChanged, this);
125 | if (!this.isInitialized) {
126 | this._isInitialized = true;
127 | this._initialized.resolve(void 0);
128 | }
129 | }
130 | this.stateChanged.emit(void 0);
131 | }
132 |
133 | get currentArea(): ILabShell.Area {
134 | return (this.settings?.composite['area'] || 'right') as ILabShell.Area;
135 | }
136 |
137 | set currentArea(currentArea: ILabShell.Area) {
138 | this.settings.set('area', currentArea).catch(void 0);
139 | }
140 |
141 | get mainWidget(): Promise> {
142 | return this.initialized.then(() => this._mainWidget);
143 | }
144 |
145 | setMainWidget(widget: MainAreaWidget): void {
146 | if (this._mainWidget) {
147 | console.error(this.__('Main Video Chat widget already set'));
148 | return;
149 | }
150 | this._mainWidget = widget;
151 | }
152 |
153 | /** A scoped handler for connecting to the settings Signal */
154 | protected onSettingsChanged = (): void => {
155 | this.stateChanged.emit(void 0);
156 | };
157 |
158 | /**
159 | * Add a new room provider.
160 | */
161 | registerRoomProvider(options: IVideoChatManager.IProviderOptions): void {
162 | this._roomProviders.set(options.id, options);
163 |
164 | const { stateChanged } = options.provider;
165 |
166 | if (stateChanged) {
167 | stateChanged.connect(
168 | async () => await Promise.all([this.updateConfig(), this.updateRooms()])
169 | );
170 | }
171 |
172 | this._roomProvidersChanged.emit(void 0);
173 | }
174 |
175 | providerForRoom = (room: Room): IVideoChatManager.IProviderOptions => {
176 | const key = this._roomProvidedBy.get(room) || null;
177 | if (key) {
178 | return this._roomProviders.get(key);
179 | }
180 | return null;
181 | };
182 |
183 | /**
184 | * Handle room providers changing
185 | */
186 | protected async onRoomProvidersChanged(): Promise {
187 | try {
188 | await Promise.all([this.updateConfig(), this.updateRooms()]);
189 | } catch (err) {
190 | console.warn(err);
191 | }
192 | this.stateChanged.emit(void 0);
193 | }
194 |
195 | get rankedProviders(): IVideoChatManager.IProviderOptions[] {
196 | const providers = [...this._roomProviders.values()];
197 | providers.sort((a, b) => a.rank - b.rank);
198 | return providers;
199 | }
200 |
201 | /**
202 | * Fetch all config from all providers
203 | */
204 | async updateConfig(): Promise {
205 | let config: VideoChatConfig = { jitsiServer: DEFAULT_DOMAIN };
206 | for (const { provider, id } of this.rankedProviders) {
207 | try {
208 | config = { ...config, ...(await provider.updateConfig()) };
209 | } catch (err) {
210 | console.warn(this.__(`Failed to load config from %1`, id));
211 | console.trace(err);
212 | }
213 | }
214 | this._config = config;
215 | this.stateChanged.emit(void 0);
216 | return config;
217 | }
218 |
219 | /**
220 | * Fetch all rooms from all providers
221 | */
222 | async updateRooms(): Promise {
223 | let rooms: Room[] = [];
224 | let providerRooms: Room[];
225 | for (const { provider, id } of this.rankedProviders) {
226 | try {
227 | providerRooms = await provider.updateRooms();
228 | for (const room of providerRooms) {
229 | this._roomProvidedBy.set(room, id);
230 | }
231 | rooms = [...rooms, ...providerRooms];
232 | } catch (err) {
233 | console.warn(this.__(`Failed to load rooms from %1`, id));
234 | console.trace(err);
235 | }
236 | }
237 | this._rooms = rooms;
238 | this.stateChanged.emit(void 0);
239 | return rooms;
240 | }
241 |
242 | async createRoom(room: Partial): Promise {
243 | let newRoom: Room | null = null;
244 | for (const { provider, id } of this.rankedProviders) {
245 | if (!provider.canCreateRooms) {
246 | continue;
247 | }
248 | try {
249 | newRoom = await provider.createRoom(room);
250 | break;
251 | } catch (err) {
252 | console.warn(this.__(`Failed to create room from %1`, id));
253 | }
254 | }
255 |
256 | this.currentRoom = newRoom;
257 |
258 | return newRoom;
259 | }
260 |
261 | get canCreateRooms(): boolean {
262 | for (const { provider } of this.rankedProviders) {
263 | if (provider.canCreateRooms) {
264 | return true;
265 | }
266 | }
267 | return false;
268 | }
269 |
270 | /** Lazily get the JitiExternalAPI script, as loaded from the jitsi server */
271 | getJitsiAPI(): IJitsiFactory {
272 | return () => {
273 | if (Private.api) {
274 | return Private.api;
275 | } else if (this.config != null) {
276 | const domain = this.config?.jitsiServer
277 | ? this.config.jitsiServer
278 | : DEFAULT_DOMAIN;
279 | const url = `https://${domain}/external_api.js`;
280 | Private.ensureExternalAPI(url)
281 | .then(() => this.stateChanged.emit(void 0))
282 | .catch(console.warn);
283 | }
284 | return null;
285 | };
286 | }
287 | }
288 |
289 | /** A namespace for video chat manager extras */
290 | export namespace VideoChatManager {
291 | /** placeholder options for video chat manager */
292 | export interface IOptions extends IVideoChatManager.IOptions {
293 | trans: TranslationBundle;
294 | }
295 | }
296 |
297 | /** a private namespace for the singleton jitsi script tag */
298 | namespace Private {
299 | export let api: JitsiMeetExternalAPIConstructor;
300 |
301 | let _scriptElement: HTMLScriptElement;
302 | let _loadPromise: PromiseDelegate;
303 |
304 | /** return a promise that resolves when the Jitsi external JS API is available */
305 | export async function ensureExternalAPI(
306 | url: string
307 | ): Promise {
308 | if (_loadPromise == null) {
309 | DEBUG && console.warn('loading...');
310 | _loadPromise = new PromiseDelegate();
311 | _scriptElement = document.createElement('script');
312 | _scriptElement.id = `id-${CSS}-external-api`;
313 | _scriptElement.src = url;
314 | _scriptElement.async = true;
315 | _scriptElement.type = 'text/javascript';
316 | document.body.appendChild(_scriptElement);
317 | _scriptElement.onload = () => {
318 | api = (window as any).JitsiMeetExternalAPI;
319 | DEBUG && console.warn('loaded...');
320 | _loadPromise.resolve(api);
321 | };
322 | }
323 | return _loadPromise.promise;
324 | }
325 | }
326 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # jupyter-videochat
2 |
3 | > Video Chat with JupyterHub peers inside JupyterLab and RetroLab, powered by [Jitsi].
4 |
5 | [![documentation on readthedocs][docs-badge]][docs]
6 | [](https://jupyterlab-contrib.github.io/)
7 | [![install from pypi][pypi-badge]][pypi]
8 | [![install from conda-forge][conda-forge-badge]][conda-forge]
9 | [![reuse from npm][npm-badge]][npm]
10 | [![continuous integration][workflow-badge]][workflow]
11 | [![interactive demo][binder-badge]][binder] [![changelog][changelog-badge]][changelog]
12 | [![contributing][contributing-badge]][contributing]
13 |
14 | [npm]: https://www.npmjs.com/package/jupyterlab-videochat
15 | [jupyterhub]: https://github.com/jupyterhub/jupyterhub
16 |
17 | ![jupyter-videochat screenshot][lab-screenshot]
18 |
19 | [lab-screenshot]:
20 | https://user-images.githubusercontent.com/45380/106391412-312d0400-63bb-11eb-9ed9-af3c4fe85ee4.png
21 |
22 | ## Requirements
23 |
24 | - `python >=3.7`
25 | - `jupyterlab ==3.*`
26 |
27 | ## Install
28 |
29 | Install the server extension and JupyterLab extension with `pip`:
30 |
31 | ```bash
32 | pip install -U jupyter-videochat
33 | ```
34 |
35 | ...or `conda`/`mamba`:
36 |
37 | ```bash
38 | conda install -c conda-forge jupyter-videochat
39 | ```
40 |
41 | ## Usage
42 |
43 | > See the [Jitsi Handbook] for more about using the actual chat once launched.
44 |
45 | ### View the Room List
46 |
47 | #### JupyterLab
48 |
49 | - From the _Main Menu_...
50 | - Click _File ▶ New ▶ Video Chat_
51 | - From the _Launcher_...
52 | - Open a new _JupyterLab Launcher_
53 | - Scroll down to _Other_
54 | - Click the _Video Chat_ launcher card
55 |
56 | #### RetroLab
57 |
58 | - From the _Main Menu_...
59 | - Click _File ▶ New ▶ Video Chat_
60 | - From the RetroLab File Tree...
61 | - Click the _New Video Chat_ button
62 |
63 | ### Start a Chat
64 |
65 | - Provide your name and email (optional)
66 | - these will be saved in JupyterLab user settings for future usage
67 | - your email will be used to provide [Gravatar](https://gravatar.com) icon
68 | - From one of the room _providers_, choose a room.
69 | - You may need to provide a room name
70 |
71 | ### Stop a Chat
72 |
73 | - From the the Jitsi IFrame:
74 | - Click the red "hang up" button, or
75 | - From the _Video Chat toolbar_
76 | - Click the _Disconnect Video Chat_ button
77 |
78 | ## Troubleshoot
79 |
80 | > If the Jitsi frame actually loads, the [Jitsi Handbook] is the best source for more
81 | > help.
82 |
83 | ### I see the Lab UI, but the video chat IFrame doesn't load
84 |
85 | Sometimes the Jitsi IFrame runs into issues, and just shows a white frame.
86 |
87 | _Try reloading the browser._
88 |
89 | ### I see the UI but I'm missing rooms
90 |
91 | If you are seeing the frontend extension but it is not working, check that the server
92 | extension is enabled:
93 |
94 | ```bash
95 | jupyter server extension list
96 | jupyter server extension enable --sys-prefix --py jupyter_videochat
97 | ```
98 |
99 | ... and restart the server.
100 |
101 | > If you launch your Jupyter server with `jupyter notebook`, as Binder does, the
102 | > equivalent commands are:
103 | >
104 | > ```bash
105 | > jupyter serverextension list
106 | > jupyter serverextension enable --sys-prefix --py jupyter_videochat
107 | > ```
108 |
109 | If the server extension is installed and enabled but you are not seeing the frontend,
110 | check the frontend is installed:
111 |
112 | ```bash
113 | jupyter labextension list
114 | ```
115 |
116 | If you do not see `jupyterlab-videochat`, the best course of action is to
117 | [uninstall](#uninstall) and [reinstall](#install), and carefully watch the log output.
118 |
119 | ## Architecture
120 |
121 | This extension is composed of:
122 |
123 | - a Python package named `jupyter_videochat`, which offers:
124 | - a `jupyter_server` extension which provides convenient, configurable defaults for
125 | rooms on a [JupyterHub]
126 | - a JupyterLab _pre-built_ or _federated extension_ named `jupyter-videochat`
127 | - also distributed on [npm]
128 | - for more about the TypeScript/JS API, see [CONTRIBUTING]
129 | - at JupyterLab runtime, some _Plugins_ which can be independently disabled
130 | - `jupyterlab-videochat:plugin` which is required by:
131 | - `jupyterlab-videochat:rooms-server`
132 | - `jupyterlab-videochat:rooms-public`
133 | - `jupyterlab-videochat:toggle-area`
134 |
135 | ## Configuration
136 |
137 | ### Server Configuration
138 |
139 | In your `jupyter_server_config.json` (or equivalent `.py` or `conf.d/*.json`), you can
140 | configure the `VideoChat`:
141 |
142 | - `room_prefix`, a prefix used for your group, by default a URL-frieldy version of your
143 | JupyterHub's hostname
144 | - can be overriden with the `JUPYTER_VIDEOCHAT_ROOM_PREFIX` environment variable
145 | - `jitsi_server`, an HTTPS host that serves the Jitsi web application, by default
146 | `meet.jit.si`
147 | - `rooms`, a list of Room descriptions that everyone on your Hub will be able to join
148 |
149 | #### Example
150 |
151 | ```json
152 | {
153 | "VideoChat": {
154 | "room_prefix": "our-spiffy-room-prefix",
155 | "rooms": [
156 | {
157 | "id": "stand-up",
158 | "displayName": "Stand-Up",
159 | "description": "Daily room for meeting with the team"
160 | },
161 | {
162 | "id": "all-hands",
163 | "displayName": "All-Hands",
164 | "description": "A weekly room for the whole team"
165 | }
166 | ],
167 | "jitsi_server": "jitsi.example.com"
168 | }
169 | }
170 | ```
171 |
172 | ### Client Configuration
173 |
174 | In the JupyterLab _Advanced Settings_ panel, the _Video Chat_ settings can be further
175 | configured, as can a user's default `displayName` and `email`. The defaults provided are
176 | generally pretty conservative, and disable as many third-party services as possible.
177 |
178 | Additionally, access to **globally-accessible** public rooms may be enabled.
179 |
180 | #### Binder Client Example
181 |
182 | For example, to enable all third-party features, public rooms, and open in the `main`
183 | area by default:
184 |
185 | - create an `overrides.json`
186 |
187 | ```json
188 | {
189 | "jupyter-videochat:plugin": {
190 | "interfaceConfigOverwrite": null,
191 | "configOverwrite": null,
192 | "disablePublicRooms": false,
193 | "area": "main"
194 | }
195 | }
196 | ```
197 |
198 | - Copy it to the JupyterLab settings directory
199 |
200 | ```bash
201 | # postBuild
202 | mkdir -p ${NB_PYTHON_PREFIX}/share/jupyter/lab/settings
203 | cp overrides.json ${NB_PYTHON_PREFIX}/share/jupyter/lab/settings
204 | ```
205 |
206 | #### JupyterLite Client Example
207 |
208 | > Note: _JupyterLite_ is still alpha software, and the API is likely to change.
209 |
210 | `jupyter lite build`
211 |
212 | `jupyter_lite_config_.json`
213 |
214 | ```json
215 | {
216 | "LabBuildConfig": {
217 | "federated_extensions": ["https://pypi.io/.../jupyterlab-videochat-0.6.0.whl"]
218 | }
219 | }
220 | ```
221 |
222 | Add a runtime `jupyter-lite.json` (or a build time `overrides.json`) to disable server
223 | rooms.
224 |
225 | ```json
226 | {
227 | "jupyter-lite-schema-version": 0,
228 | "jupyter-config-data": {
229 | "disabledExtensions": ["jupyterlab-videochat:rooms-server"],
230 | "settingsOverrides": {
231 | "jupyterlab-videochat:plugin": {
232 | "disablePublicRooms": false
233 | }
234 | }
235 | }
236 | }
237 | ```
238 |
239 | This can then be tested with:
240 |
241 | ```bash
242 | jupyter lite serve
243 | ```
244 |
245 | ### Start a Meet by URL
246 |
247 | Appending `?jvc=room-name` to a JupyterLab URL will automatically open the Meet (but not
248 | _fully_ start it, as browsers require a user gesture to start audio/video).
249 |
250 | #### Binder URL Example
251 |
252 | On [Binder](https://mybinder.org), use the `urlpath` to append the argument, ensuring
253 | the arguments get properly URL-encoded.
254 |
255 | ```
256 | https://mybinder.org/v2/gh/jupyterlab-contrib/jupyter-videochat/demo?urlpath=tree%3Fjvc%3DStand-Up
257 | # URL-encoded [? ] [= ]
258 | ```
259 |
260 | ##### nbgitpuller
261 |
262 | If you have two repos (or branches) that contain:
263 |
264 | - content that changes frequently
265 | - a stable environment
266 |
267 | ...you can use [nbgitpuller](https://jupyterhub.github.io/nbgitpuller/link) to have
268 | fast-building, (almost) single-click URLs that launch right into JupyterLab showing your
269 | meeting and content. For example, to use...
270 |
271 | - the [Python Data Science Handbook] as `master`
272 | - this project's repo, at `demo` (_not recommended, as it's pretty
273 | [minimal][binder-reqs]_)
274 |
275 | ...and launch directly into JupyterLab showing
276 |
277 | - the _Preface_ notebook
278 | - the _Office Hours_ room
279 |
280 | ...the doubly-escaped URL would be something like:
281 |
282 | ```
283 | https://mybinder.org/v2/gh/jupyterlab-contrib/jupyter-videochat/demo?
284 | urlpath=git-pull
285 | %3Frepo%3Dhttps%253A%252F%252Fgithub.com%252Fjakevdp%252FPythonDataScienceHandbook
286 | %26branch%3Dmaster
287 | %26urlpath%3Dlab%252Ftree%252FPythonDataScienceHandbook%252Fnotebooks%252F00.00-Preface.ipynb
288 | %253Fjvc%253DOffice%2BHours
289 | ```
290 |
291 | #### JupyterLite Example
292 |
293 | Additionally, `?JVC-PUBLIC=a-very-long-and-well-thought-key` can be enabled, providing a
294 | similar experience, but for unobfuscated, publicly-visible rooms. **Use with care**, and
295 | as a moderator take additional whatever steps you can from within the Jitsi security UI,
296 | including:
297 |
298 | - _lobbies_
299 | - _passwords_
300 | - _end-to-end encryption_
301 |
302 | Once properly configured above, a JupyterLite site can be `git push`ed to GitHub Pages,
303 | where a URL is far less obfuscated.
304 |
305 | ```
306 | https://example.github.io/my-repo/lab?JVC-PUBLIC=a-very-long-and-well-thought-key
307 | ```
308 |
309 | - probably _don't_ click on links shorter than about ten characters
310 |
311 | ## Uninstall
312 |
313 | ```bash
314 | pip uninstall jupyter-videochat
315 | ```
316 |
317 | or
318 |
319 | ```bash
320 | conda uninstall jupyter-videochat
321 | ```
322 |
323 | [workflow]:
324 | https://github.com/jupyterlab-contrib/jupyter-videochat/actions?query=workflow%3ACI+branch%3Amaster
325 | [workflow-badge]:
326 | https://github.com/jupyterlab-contrib/jupyter-videochat/workflows/CI/badge.svg
327 | [binder]:
328 | https://mybinder.org/v2/gh/jupyterlab-contrib/jupyter-videochat/demo?urlpath=lab
329 | [binder-reqs]:
330 | https://github.com/jupyterlab-contrib/jupyter-videochat/blob/master/binder/requirements.txt
331 | [binder-badge]: https://mybinder.org/badge_logo.svg
332 | [pypi-badge]: https://img.shields.io/pypi/v/jupyter-videochat
333 | [pypi]: https://pypi.org/project/jupyter-videochat/
334 | [conda-forge-badge]: https://img.shields.io/conda/vn/conda-forge/jupyter-videochat
335 | [conda-forge]: https://anaconda.org/conda-forge/jupyter-videochat
336 | [npm-badge]: https://img.shields.io/npm/v/jupyterlab-videochat
337 | [changelog]:
338 | https://github.com/jupyterlab-contrib/jupyter-videochat/blob/master/CHANGELOG.md
339 | [changelog-badge]: https://img.shields.io/badge/CHANGELOG-md-000
340 | [contributing-badge]: https://img.shields.io/badge/CONTRIBUTING-md-000
341 | [contributing]:
342 | https://github.com/jupyterlab-contrib/jupyter-videochat/blob/master/CONTRIBUTING.md
343 | [jitsi]: https://jitsi.org
344 | [docs-badge]: https://readthedocs.org/projects/jupyter-videochat/badge/?version=stable
345 | [docs]: https://jupyter-videochat.readthedocs.io/en/stable/
346 | [jitsi-handbook]: https://jitsi.github.io/handbook
347 |
--------------------------------------------------------------------------------
/src/plugin.ts:
--------------------------------------------------------------------------------
1 | import { PageConfig, URLExt } from '@jupyterlab/coreutils';
2 |
3 | import {
4 | ILabShell,
5 | ILayoutRestorer,
6 | IRouter,
7 | JupyterFrontEnd,
8 | JupyterFrontEndPlugin,
9 | LabShell,
10 | } from '@jupyterlab/application';
11 |
12 | import { launcherIcon, stopIcon } from '@jupyterlab/ui-components';
13 |
14 | import { ITranslator, nullTranslator } from '@jupyterlab/translation';
15 |
16 | import {
17 | CommandToolbarButton,
18 | ICommandPalette,
19 | Toolbar,
20 | WidgetTracker,
21 | MainAreaWidget,
22 | } from '@jupyterlab/apputils';
23 |
24 | import { IFileBrowserFactory } from '@jupyterlab/filebrowser';
25 | import { ISettingRegistry } from '@jupyterlab/settingregistry';
26 | import { ILauncher } from '@jupyterlab/launcher';
27 | import { IMainMenu } from '@jupyterlab/mainmenu';
28 |
29 | import {
30 | CommandIds,
31 | CSS,
32 | DEBUG,
33 | FORCE_URL_PARAM,
34 | IVideoChatManager,
35 | NS,
36 | PUBLIC_URL_PARAM,
37 | RETRO_CANARY_OPT,
38 | RETRO_TREE_URL,
39 | SERVER_URL_PARAM,
40 | ToolbarIds,
41 | } from './tokens';
42 | import { IChatArgs } from './types';
43 | import { VideoChatManager } from './manager';
44 | import { VideoChat } from './widget';
45 | import { chatIcon, prettyChatIcon } from './icons';
46 | import { ServerRoomProvider } from './rooms-server';
47 | import { RoomTitle } from './widgets/title';
48 |
49 | const DEFAULT_LABEL = 'Video Chat';
50 |
51 | const category = DEFAULT_LABEL;
52 |
53 | function isFullLab(app: JupyterFrontEnd) {
54 | return !!(app.shell as ILabShell).layoutModified;
55 | }
56 |
57 | /**
58 | * Handle application-level concerns
59 | */
60 | async function activateCore(
61 | app: JupyterFrontEnd,
62 | settingRegistry: ISettingRegistry,
63 | translator?: ITranslator,
64 | palette?: ICommandPalette,
65 | launcher?: ILauncher,
66 | restorer?: ILayoutRestorer,
67 | mainmenu?: IMainMenu
68 | ): Promise {
69 | const { commands, shell } = app;
70 |
71 | const labShell = isFullLab(app) ? (shell as LabShell) : null;
72 |
73 | const manager = new VideoChatManager({
74 | trans: (translator || nullTranslator).load(NS),
75 | });
76 |
77 | const { __ } = manager;
78 |
79 | let widget: MainAreaWidget;
80 | let chat: VideoChat;
81 | let subject: string | null = null;
82 |
83 | const tracker = new WidgetTracker({ namespace: NS });
84 |
85 | if (!widget || widget.isDisposed) {
86 | // Create widget
87 | chat = new VideoChat(manager, {});
88 | widget = new MainAreaWidget({ content: chat });
89 | widget.addClass(`${CSS}-wrapper`);
90 | manager.setMainWidget(widget);
91 |
92 | widget.toolbar.addItem(ToolbarIds.SPACER_LEFT, Toolbar.createSpacerItem());
93 |
94 | widget.toolbar.addItem(ToolbarIds.TITLE, new RoomTitle(manager));
95 |
96 | widget.toolbar.addItem(ToolbarIds.SPACER_RIGHT, Toolbar.createSpacerItem());
97 |
98 | const disconnectBtn = new CommandToolbarButton({
99 | id: CommandIds.disconnect,
100 | commands,
101 | icon: stopIcon,
102 | });
103 |
104 | const onCurrentRoomChanged = () => {
105 | if (manager.currentRoom) {
106 | disconnectBtn.show();
107 | } else {
108 | disconnectBtn.hide();
109 | }
110 | };
111 |
112 | manager.currentRoomChanged.connect(onCurrentRoomChanged);
113 |
114 | widget.toolbar.addItem(ToolbarIds.DISCONNECT, disconnectBtn);
115 |
116 | onCurrentRoomChanged();
117 |
118 | chat.id = `id-${NS}`;
119 | chat.title.caption = __(DEFAULT_LABEL);
120 | chat.title.closable = false;
121 | chat.title.icon = chatIcon;
122 | }
123 |
124 | // hide the label when in sidebar, as it shows the rotated text
125 | function updateTitle() {
126 | if (subject != null) {
127 | widget.title.caption = subject;
128 | } else {
129 | widget.title.caption = __(DEFAULT_LABEL);
130 | }
131 | widget.title.label = manager.currentArea === 'main' ? widget.title.caption : '';
132 | }
133 |
134 | // add to shell, update tracker, title, etc.
135 | function addToShell(area?: ILabShell.Area, activate = true) {
136 | DEBUG && console.warn(`add to shell in are ${area}, ${!activate || 'not '} active`);
137 | area = area || manager.currentArea;
138 | if (labShell) {
139 | labShell.add(widget, area);
140 | updateTitle();
141 | widget.update();
142 | if (!tracker.has(widget)) {
143 | tracker.add(widget).catch(void 0);
144 | }
145 | if (activate) {
146 | shell.activateById(widget.id);
147 | }
148 | } else if (window.location.search.indexOf(FORCE_URL_PARAM) !== -1) {
149 | document.title = [document.title.split(' - ')[0], __(DEFAULT_LABEL)].join(' - ');
150 | app.shell.currentWidget.parent = null;
151 | app.shell.add(widget, 'main', { rank: 0 });
152 | const { parent } = widget;
153 | parent.addClass(`${CSS}-main-parent`);
154 | setTimeout(() => {
155 | parent.update();
156 | parent.fit();
157 | app.shell.fit();
158 | app.shell.update();
159 | }, 100);
160 | }
161 | }
162 |
163 | // listen for the subject to update the widget title dynamically
164 | manager.meetChanged.connect(() => {
165 | if (manager.meet) {
166 | manager.meet.on('subjectChange', (args: any) => {
167 | subject = args.subject;
168 | updateTitle();
169 | });
170 | } else {
171 | subject = null;
172 | }
173 | updateTitle();
174 | });
175 |
176 | // connect settings
177 | settingRegistry
178 | .load(corePlugin.id)
179 | .then((settings) => {
180 | manager.settings = settings;
181 | let lastArea = manager.settings.composite.area;
182 | settings.changed.connect(() => {
183 | if (lastArea !== manager.settings.composite.area) {
184 | addToShell();
185 | }
186 | lastArea = manager.settings.composite.area;
187 | });
188 | addToShell(null, false);
189 | })
190 | .catch(() => addToShell(null, false));
191 |
192 | // add commands
193 | commands.addCommand(CommandIds.open, {
194 | label: __(DEFAULT_LABEL),
195 | icon: prettyChatIcon,
196 | execute: async (args: IChatArgs) => {
197 | await manager.initialized;
198 | addToShell(null, true);
199 | // Potentially navigate to new room
200 | if (manager.currentRoom?.displayName !== args.displayName) {
201 | manager.currentRoom = { displayName: args.displayName };
202 | }
203 | },
204 | });
205 |
206 | commands.addCommand(CommandIds.disconnect, {
207 | label: __('Disconnect Video Chat'),
208 | execute: () => (manager.currentRoom = null),
209 | icon: stopIcon,
210 | });
211 |
212 | commands.addCommand(CommandIds.toggleArea, {
213 | label: __('Toggle Video Chat Sidebar'),
214 | icon: launcherIcon,
215 | execute: async () => {
216 | manager.currentArea = ['right', 'left'].includes(manager.currentArea)
217 | ? 'main'
218 | : 'right';
219 | },
220 | });
221 |
222 | // If available, add the commands to the palette
223 | if (palette) {
224 | palette.addItem({ command: CommandIds.open, category: __(category) });
225 | }
226 |
227 | // If available, add a card to the launcher
228 | if (launcher) {
229 | launcher.add({ command: CommandIds.open, args: { area: 'main' } });
230 | }
231 |
232 | // If available, restore the position
233 | if (restorer) {
234 | restorer
235 | .restore(tracker, { command: CommandIds.open, name: () => `id-${NS}` })
236 | .catch(console.warn);
237 | }
238 |
239 | // If available, add to the file->new menu.... new tab handled in retroPlugin
240 | if (mainmenu && labShell) {
241 | mainmenu.fileMenu.newMenu.addGroup([{ command: CommandIds.open }]);
242 | }
243 |
244 | // Return the manager that others extensions can use
245 | return manager;
246 | }
247 |
248 | /**
249 | * Initialization data for the `jupyterlab-videochat:plugin` Plugin.
250 | *
251 | * This only rooms provided are opt-in, global rooms without any room name
252 | * obfuscation.
253 | */
254 | const corePlugin: JupyterFrontEndPlugin = {
255 | id: `${NS}:plugin`,
256 | autoStart: true,
257 | requires: [ISettingRegistry],
258 | optional: [ITranslator, ICommandPalette, ILauncher, ILayoutRestorer, IMainMenu],
259 | provides: IVideoChatManager,
260 | activate: activateCore,
261 | };
262 |
263 | /**
264 | * Create the server room plugin
265 | *
266 | * In the future, this might `provide` itself with some reasonable API,
267 | * but is already accessible from the manager, which is likely preferable.
268 | */
269 | function activateServerRooms(
270 | app: JupyterFrontEnd,
271 | chat: IVideoChatManager,
272 | router?: IRouter
273 | ): void {
274 | const { __ } = chat;
275 |
276 | const { commands } = app;
277 | const provider = new ServerRoomProvider({
278 | serverSettings: app.serviceManager.serverSettings,
279 | });
280 |
281 | chat.registerRoomProvider({
282 | id: 'server',
283 | label: __('Server'),
284 | rank: 0,
285 | provider,
286 | });
287 |
288 | // If available, Add to the router
289 | if (router) {
290 | commands.addCommand(CommandIds.serverRouterStart, {
291 | label: 'Open Server Video Chat from URL',
292 | execute: async (args) => {
293 | const { request } = args as IRouter.ILocation;
294 | const url = new URL(`http://example.com${request}`);
295 | const params = url.searchParams;
296 | const displayName = params.get(SERVER_URL_PARAM);
297 |
298 | const chatAfterRoute = async () => {
299 | router.routed.disconnect(chatAfterRoute);
300 | if (chat.currentRoom?.displayName != displayName) {
301 | await commands.execute(CommandIds.open, { displayName });
302 | }
303 | };
304 |
305 | router.routed.connect(chatAfterRoute);
306 | },
307 | });
308 |
309 | router.register({
310 | command: CommandIds.serverRouterStart,
311 | pattern: /.*/,
312 | rank: 29,
313 | });
314 | }
315 | }
316 |
317 | /**
318 | * Initialization data for the `jupyterlab-videochat:rooms-server` plugin, provided
319 | * by the serverextension REST API
320 | */
321 | const serverRoomsPlugin: JupyterFrontEndPlugin = {
322 | id: `${NS}:rooms-server`,
323 | autoStart: true,
324 | requires: [IVideoChatManager],
325 | optional: [IRouter],
326 | activate: activateServerRooms,
327 | };
328 |
329 | /**
330 | * Initialization data for the `jupyterlab-videochat:rooms-public` plugin, which
331 | * offers no persistence or even best-effort guarantee of privacy
332 | */
333 | const publicRoomsPlugin: JupyterFrontEndPlugin = {
334 | id: `${NS}:rooms-public`,
335 | autoStart: true,
336 | requires: [IVideoChatManager],
337 | optional: [IRouter, ICommandPalette],
338 | activate: activatePublicRooms,
339 | };
340 |
341 | /**
342 | * Create the public room plugin
343 | *
344 | * In the future, this might `provide` itself with some reasonable API,
345 | * but is already accessible from the manager, which is likely preferable.
346 | */
347 | async function activatePublicRooms(
348 | app: JupyterFrontEnd,
349 | chat: IVideoChatManager,
350 | router?: IRouter,
351 | palette?: ICommandPalette
352 | ): Promise {
353 | const { commands } = app;
354 |
355 | const { __ } = chat;
356 |
357 | chat.registerRoomProvider({
358 | id: 'public',
359 | label: __('Public'),
360 | rank: 999,
361 | provider: {
362 | updateRooms: async () => [],
363 | canCreateRooms: false,
364 | updateConfig: async () => {
365 | return {} as any;
366 | },
367 | },
368 | });
369 |
370 | commands.addCommand(CommandIds.togglePublicRooms, {
371 | label: __('Toggle Video Chat Public Rooms'),
372 | isVisible: () => !!chat.settings,
373 | isToggleable: true,
374 | isToggled: () => !chat.settings?.composite.disablePublicRooms,
375 | execute: async () => {
376 | if (!chat.settings) {
377 | console.warn(__('Video chat settings not loaded'));
378 | return;
379 | }
380 | await chat.settings.set(
381 | 'disablePublicRooms',
382 | !chat.settings?.composite.disablePublicRooms
383 | );
384 | },
385 | });
386 |
387 | // If available, Add to the router
388 | if (router) {
389 | commands.addCommand(CommandIds.publicRouterStart, {
390 | label: __('Open Public Video Chat from URL'),
391 | execute: async (args) => {
392 | const { request } = args as IRouter.ILocation;
393 | const url = new URL(`http://example.com${request}`);
394 | const params = url.searchParams;
395 | const roomId = params.get(PUBLIC_URL_PARAM);
396 |
397 | const chatAfterRoute = async () => {
398 | router.routed.disconnect(chatAfterRoute);
399 | if (chat.currentRoom?.displayName != roomId) {
400 | chat.currentRoom = {
401 | id: roomId,
402 | displayName: roomId,
403 | description: __('A Public Room'),
404 | };
405 | }
406 | };
407 |
408 | router.routed.connect(chatAfterRoute);
409 | },
410 | });
411 |
412 | router.register({
413 | command: CommandIds.publicRouterStart,
414 | pattern: /.*/,
415 | rank: 99,
416 | });
417 | }
418 |
419 | // If available, add to command palette
420 | if (palette) {
421 | palette.addItem({ command: CommandIds.togglePublicRooms, category });
422 | }
423 | }
424 |
425 | /**
426 | * Initialization for the `jupyterlab-videochat:retro` retrolab (no-op in full)
427 | */
428 | const retroPlugin: JupyterFrontEndPlugin = {
429 | id: `${NS}:retro`,
430 | autoStart: true,
431 | requires: [IVideoChatManager],
432 | optional: [IFileBrowserFactory, IMainMenu],
433 | activate: activateRetro,
434 | };
435 |
436 | function activateRetro(
437 | app: JupyterFrontEnd,
438 | chat: IVideoChatManager,
439 | filebrowser?: IFileBrowserFactory,
440 | mainmenu?: IMainMenu
441 | ): void {
442 | if (!PageConfig.getOption(RETRO_CANARY_OPT)) {
443 | return;
444 | }
445 |
446 | const { __ } = chat;
447 |
448 | const baseUrl = PageConfig.getBaseUrl();
449 |
450 | // this is basically hard-coded upstream
451 | const treeUrl = URLExt.join(baseUrl, RETRO_TREE_URL);
452 |
453 | const { commands } = app;
454 |
455 | commands.addCommand(CommandIds.openTab, {
456 | label: __('New Video Chat'),
457 | icon: prettyChatIcon,
458 | execute: (args: any) => {
459 | window.open(`${treeUrl}?${FORCE_URL_PARAM}`, '_blank');
460 | },
461 | });
462 |
463 | // If available, add menu item
464 | if (mainmenu) {
465 | mainmenu.fileMenu.newMenu.addGroup([{ command: CommandIds.openTab }]);
466 | }
467 |
468 | // If available, add button to file browser
469 | if (filebrowser) {
470 | const spacer = Toolbar.createSpacerItem();
471 | spacer.node.style.flex = '1';
472 | filebrowser.defaultBrowser.toolbar.insertItem(999, 'videochat-spacer', spacer);
473 | filebrowser.defaultBrowser.toolbar.insertItem(
474 | 1000,
475 | 'new-videochat',
476 | new CommandToolbarButton({
477 | commands,
478 | id: CommandIds.openTab,
479 | })
480 | );
481 | }
482 | }
483 |
484 | /**
485 | * Initialization for the `jupyterlab-videochat:toggle-area`, which allows the user
486 | * to switch where video chat occurs.
487 | */
488 | const areaTogglePlugin: JupyterFrontEndPlugin = {
489 | id: `${NS}:toggle-area`,
490 | autoStart: true,
491 | requires: [IVideoChatManager],
492 | optional: [ICommandPalette],
493 | activate: activateToggleArea,
494 | };
495 |
496 | function activateToggleArea(
497 | app: JupyterFrontEnd,
498 | chat: IVideoChatManager,
499 | palette?: ICommandPalette
500 | ): void {
501 | const { shell, commands } = app;
502 | const { __ } = chat;
503 |
504 | const labShell = isFullLab(app) ? (shell as LabShell) : null;
505 |
506 | if (!labShell) {
507 | return;
508 | }
509 |
510 | const toggleBtn = new CommandToolbarButton({
511 | id: CommandIds.toggleArea,
512 | commands,
513 | icon: launcherIcon,
514 | });
515 |
516 | chat.mainWidget
517 | .then((widget) => {
518 | widget.toolbar.insertBefore(
519 | ToolbarIds.SPACER_LEFT,
520 | ToolbarIds.TOGGLE_AREA,
521 | toggleBtn
522 | );
523 | })
524 | .catch((err) => console.warn(__(`Couldn't add Video Chat area toggle`), err));
525 |
526 | if (palette) {
527 | palette.addItem({ command: CommandIds.toggleArea, category: __(category) });
528 | }
529 | }
530 |
531 | // In the future, there may be more extensions
532 | export default [
533 | corePlugin,
534 | serverRoomsPlugin,
535 | publicRoomsPlugin,
536 | retroPlugin,
537 | areaTogglePlugin,
538 | ];
539 |
--------------------------------------------------------------------------------