├── docs ├── changelog.md ├── user-guide.md ├── developer-guide.md ├── jupyter_lite_config.json ├── requirements.txt ├── jupyter-lite.json ├── _static │ ├── logo-light.svg │ └── logo.svg ├── environment.yml ├── index.md ├── _templates │ └── demo.html └── conf.py ├── src ├── index.ts ├── typings.d.ts ├── widgets │ └── title.tsx ├── types.ts ├── icons.ts ├── components │ ├── PublicRooms.tsx │ ├── RoomsList.tsx │ ├── VideoChat.tsx │ ├── JitsiMeet.tsx │ └── ServerRooms.tsx ├── rooms-server.ts ├── widget.tsx ├── tokens.ts ├── manager.ts └── plugin.ts ├── .eslintignore ├── .prettierignore ├── pyproject.toml ├── jupyter-config ├── jupyter_server_config.d │ └── jupyter_videochat.json └── jupyter_notebook_config.d │ └── jupyter_videochat.json ├── binder ├── overrides.json ├── requirements.txt ├── jupyter_config.json └── postBuild ├── install.json ├── jupyter_videochat ├── _version.py ├── __init__.py └── handlers.py ├── .readthedocs.yml ├── style ├── icons │ ├── user.svg │ ├── videochat.svg │ ├── public.svg │ ├── group-add.svg │ └── group.svg ├── retro.css ├── index.css └── rooms.css ├── MANIFEST.in ├── tsconfig.json ├── setup.cfg ├── LICENSE ├── .eslintrc.js ├── .gitignore ├── setup.py ├── schema └── plugin.json ├── package.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── .github └── workflows │ └── ci.yml └── README.md /docs/changelog.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CHANGELOG.md 2 | 3 | ``` 4 | -------------------------------------------------------------------------------- /docs/user-guide.md: -------------------------------------------------------------------------------- 1 | ```{include} ../README.md 2 | 3 | ``` 4 | -------------------------------------------------------------------------------- /docs/developer-guide.md: -------------------------------------------------------------------------------- 1 | ```{include} ../CONTRIBUTING.md 2 | 3 | ``` 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './tokens'; 2 | export * from './types'; 3 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | **/*.d.ts 5 | tests 6 | docs 7 | -------------------------------------------------------------------------------- /src/typings.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const script: string; 3 | export default script; 4 | } 5 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/build 2 | **/dist 3 | **/lib 4 | **/node_modules 5 | **/package.json 6 | docs/_build/ 7 | docs/_static/lite/ 8 | node_modules/ 9 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools"] 3 | build-backend = "setuptools.build_meta" 4 | 5 | [tool.isort] 6 | profile = "black" 7 | -------------------------------------------------------------------------------- /docs/jupyter_lite_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "LiteBuildConfig": { 3 | "lite_dir": ".", 4 | "output_dir": "./_static/lite", 5 | "contents": ["../README.md"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyter-config/jupyter_server_config.d/jupyter_videochat.json: -------------------------------------------------------------------------------- 1 | { 2 | "ServerApp": { 3 | "jpserver_extensions": { 4 | "jupyter_videochat": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /binder/overrides.json: -------------------------------------------------------------------------------- 1 | { 2 | "jupyter-videochat:plugin": { 3 | "interfaceConfigOverwrite": null, 4 | "configOverwrite": null, 5 | "disablePublicRooms": false 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /jupyter-config/jupyter_notebook_config.d/jupyter_videochat.json: -------------------------------------------------------------------------------- 1 | { 2 | "NotebookApp": { 3 | "nbserver_extensions": { 4 | "jupyter_videochat": true 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /binder/requirements.txt: -------------------------------------------------------------------------------- 1 | # run-time dependencies 2 | escapism 3 | jupyterlab ==3.* 4 | 5 | # DEMO stuff 6 | jupyterlab >=3.3.0,<4 7 | retrolab >=0.3.20 8 | nbgitpuller 9 | jupyterlab-link-share 10 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | ipywidgets 2 | jupyterlab >=3.3.0,<4 3 | jupyterlite ==0.1.0b4 4 | myst-nb 5 | pydata-sphinx-theme 6 | pytest-check-links 7 | retrolab >=0.3.20 8 | sphinx 9 | sphinx-jsonschema 10 | -------------------------------------------------------------------------------- /install.json: -------------------------------------------------------------------------------- 1 | { 2 | "packageManager": "python", 3 | "packageName": "jupyter-videochat", 4 | "uninstallInstructions": "Use your Python package manager (pip, conda, etc.) to uninstall the package jupyter-videochat" 5 | } 6 | -------------------------------------------------------------------------------- /jupyter_videochat/_version.py: -------------------------------------------------------------------------------- 1 | import json 2 | from pathlib import Path 3 | 4 | EXT = Path(__file__).parent / "labextension" 5 | 6 | __jspackage__ = json.loads((EXT / "package.json").read_text(encoding="utf-8")) 7 | 8 | __version__ = __jspackage__["version"] 9 | -------------------------------------------------------------------------------- /.readthedocs.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: ubuntu-20.04 5 | tools: 6 | python: mambaforge-4.10 7 | 8 | conda: 9 | environment: docs/environment.yml 10 | 11 | sphinx: 12 | builder: html 13 | configuration: docs/conf.py 14 | fail_on_warning: true 15 | -------------------------------------------------------------------------------- /docs/jupyter-lite.json: -------------------------------------------------------------------------------- 1 | { 2 | "jupyter-lite-schema-version": 0, 3 | "jupyter-config-data": { 4 | "collaborative": true, 5 | "disabledExtensions": [ 6 | "jupyterlab-videochat:rooms-server", 7 | "nbdime-jupyterlab:plugin" 8 | ], 9 | "settingsOverrides": { 10 | "jupyterlab-videochat:plugin": { 11 | "disablePublicRooms": false 12 | } 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /style/icons/user.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE README.md install.json 2 | 3 | recursive-include jupyter-config *.json 4 | recursive-include jupyter_videochat/labextension *.* 5 | 6 | # Javascript files 7 | graft src 8 | graft style 9 | prune **/node_modules 10 | prune lib 11 | 12 | # Patterns to exclude from any directory 13 | global-exclude *~ 14 | global-exclude *.pyc 15 | global-exclude *.pyo 16 | global-exclude .git 17 | global-exclude .ipynb_checkpoints 18 | -------------------------------------------------------------------------------- /docs/_static/logo-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /docs/_static/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /style/icons/videochat.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /style/icons/public.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /style/icons/group-add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /docs/environment.yml: -------------------------------------------------------------------------------- 1 | name: jupyter-videochat-docs 2 | 3 | channels: 4 | - conda-forge 5 | - nodefaults 6 | 7 | dependencies: 8 | # run 9 | - escapism 10 | - python >=3.7,<3.10 11 | # build 12 | - nodejs >=14,!=15,<17 13 | - pip 14 | - twine 15 | # lint 16 | - black ==22.3.0 17 | - isort 18 | # docs 19 | - ipywidgets 20 | - myst-nb 21 | - pydata-sphinx-theme 22 | - pytest-check-links 23 | - sphinx 24 | - sphinx-autobuild 25 | - sphinx-jsonschema 26 | # lite 27 | - jupyterlab >=3.3.0,<4 28 | - retrolab >=0.3.20 29 | - pip: 30 | - jupyterlite ==0.1.0b4 31 | -------------------------------------------------------------------------------- /binder/jupyter_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "VideoChat": { 3 | "rooms": [ 4 | { 5 | "id": "demo", 6 | "displayName": "📹 Demo", 7 | "description": "A demo of jupyter-videochat" 8 | }, 9 | { 10 | "id": "breakout-1-apple", 11 | "displayName": "🍏 Breakout Room 1", 12 | "description": "Another room" 13 | }, 14 | { 15 | "id": "breakout-2-penguin", 16 | "displayName": "🐧 Breakout Room 2", 17 | "description": "Yet another room" 18 | } 19 | ] 20 | }, 21 | "LabApp": { 22 | "collaborative": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /style/retro.css: -------------------------------------------------------------------------------- 1 | /* some overrides for retrolab applications */ 2 | .jp-VideoChat-main-parent { 3 | display: flex !important; 4 | flex-direction: column !important; 5 | margin: 0 !important; 6 | max-width: unset !important; 7 | padding: 0 !important; 8 | width: 100% !important; 9 | } 10 | 11 | .jp-VideoChat-main-parent .jp-VideoChat-wrapper { 12 | height: 100% !important; 13 | } 14 | 15 | .jp-VideoChat-main-parent .jp-VideoChat-sidebar-toggle { 16 | display: none !important; 17 | } 18 | 19 | .jp-VideoChat-main-parent .jp-VideoChat-rooms > * { 20 | max-width: 1140px; 21 | margin-left: auto; 22 | margin-right: auto; 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "checkJs": false, 5 | "allowSyntheticDefaultImports": true, 6 | "composite": true, 7 | "declaration": true, 8 | "esModuleInterop": true, 9 | "incremental": true, 10 | "jsx": "react", 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "noEmitOnError": true, 14 | "noImplicitAny": true, 15 | "noUnusedLocals": true, 16 | "preserveWatchOutput": true, 17 | "resolveJsonModule": true, 18 | "outDir": "lib", 19 | "rootDir": "src", 20 | "strict": true, 21 | "strictNullChecks": false, 22 | "skipLibCheck": true, 23 | "target": "es2017", 24 | "types": [], 25 | "tsBuildInfoFile": "lib/.tsbuildinfo" 26 | }, 27 | "include": ["src/**/*"] 28 | } 29 | -------------------------------------------------------------------------------- /src/widgets/title.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { VDomRenderer } from '@jupyterlab/apputils'; 4 | 5 | import { VideoChatManager } from '../manager'; 6 | 7 | import { CSS } from '../tokens'; 8 | 9 | export class RoomTitle extends VDomRenderer { 10 | protected render(): JSX.Element { 11 | const { currentRoom } = this.model; 12 | 13 | if (!currentRoom) { 14 | return <>; 15 | } 16 | const provider = this.model.providerForRoom(currentRoom); 17 | const providerLabel = provider ? provider.label : this.model.__('Public'); 18 | 19 | return ( 20 |
21 | {providerLabel} 22 | {`${currentRoom.displayName || currentRoom.id}`} 23 |
24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /style/icons/group.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | -------------------------------------------------------------------------------- /binder/postBuild: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eux 3 | 4 | jlpm bootstrap 5 | 6 | python -m pip install -e . --ignore-installed --no-deps -vv 7 | 8 | # squash build warning 9 | jupyter labextension disable jupyter-offlinenotebook || echo "not enabled" 10 | python -m pip uninstall -y jupyter-offlinenotebook || echo "not installed" 11 | 12 | # install our extensions 13 | jupyter labextension develop --overwrite . 14 | jupyter serverextension enable --sys-prefix --py jupyter_videochat 15 | jupyter server extension enable --sys-prefix --py jupyter_videochat 16 | 17 | # list all extensions 18 | jupyter server extension list 19 | jupyter serverextension list 20 | jupyter labextension list 21 | 22 | # server configuration 23 | cp binder/jupyter_config.json . 24 | 25 | # lab settings 26 | mkdir -p ${NB_PYTHON_PREFIX}/share/jupyter/lab/settings 27 | cp binder/overrides.json ${NB_PYTHON_PREFIX}/share/jupyter/lab/settings 28 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { JitsiMeetExternalAPIConstructor } from 'jitsi-meet'; 2 | 3 | /** 4 | * Command args for toggling position in shell 5 | */ 6 | export interface IChatArgs { 7 | area?: 'left' | 'right' | 'main'; 8 | displayName?: string; 9 | } 10 | 11 | /** 12 | * A model of a room 13 | */ 14 | export type Room = { 15 | /** The human-readable name of the room */ 16 | displayName: string; 17 | 18 | /** A machine-friendly mangled version of the name */ 19 | id?: string; 20 | 21 | /** Human-readable description of the room */ 22 | description?: string; 23 | }; 24 | 25 | /** 26 | * Configuration for a video chat 27 | */ 28 | export type VideoChatConfig = { 29 | jitsiServer: string; 30 | }; 31 | 32 | export interface IJitsiFactory { 33 | (): JitsiMeetExternalAPIConstructor; 34 | } 35 | 36 | /** Expected response types from the serverextension routes */ 37 | export interface IServerResponses { 38 | config: VideoChatConfig; 39 | rooms: Room[]; 40 | 'generate-room': Room; 41 | } 42 | -------------------------------------------------------------------------------- /style/index.css: -------------------------------------------------------------------------------- 1 | /** throughout the code, the constant `CSS` provides the common prefix */ 2 | 3 | .jp-VideoChat-jitsi-container { 4 | height: 100%; 5 | } 6 | 7 | #jp-right-stack .jp-VideoChat-wrapper, 8 | #jp-left-stack .jp-VideoChat-wrapper { 9 | min-width: 390px !important; 10 | width: 390px; 11 | } 12 | 13 | .jp-VideoChat { 14 | min-width: 390px; 15 | height: 100%; 16 | background-color: var(--jp-layout-color1); 17 | display: flex; 18 | flex-direction: column; 19 | } 20 | @import './rooms.css'; 21 | @import './retro.css'; 22 | 23 | /* 24 | from 25 | https://github.com/jupyterlab/jupyterlab/blob/v2.1.5/packages/apputils/style/iframe.css 26 | */ 27 | /* 28 | When drag events occur, `lm-mod-override-cursor` is added to the body. 29 | Because iframes steal all cursor events, the following two rules are necessary 30 | to suppress pointer events while resize drags are occurring. There may be a 31 | better solution to this problem. 32 | */ 33 | body.lm-mod-override-cursor .jp-VideoChat-jitsi-container { 34 | position: relative; 35 | } 36 | 37 | body.lm-mod-override-cursor .jp-VideoChat-jitsi-container:before { 38 | content: ''; 39 | position: absolute; 40 | top: 0; 41 | left: 0; 42 | right: 0; 43 | bottom: 0; 44 | background: transparent; 45 | } 46 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | name = jupyter-videochat 3 | project_urls = 4 | Source Code = https://github.com/jupyterlab-contrib/jupyter-videochat 5 | long_description = file: ./README.md 6 | long_description_content_type = text/markdown 7 | license_file = LICENSE 8 | keywords = 9 | Jupyter 10 | JupyterLab 11 | classifiers = 12 | Intended Audience :: Developers 13 | License :: OSI Approved :: BSD License 14 | Programming Language :: Python 15 | Programming Language :: Python :: 3 16 | Framework :: Jupyter 17 | Framework :: Jupyter :: JupyterLab 18 | Framework :: Jupyter :: JupyterLab :: 3 19 | Framework :: Jupyter :: JupyterLab :: Extensions 20 | Framework :: Jupyter :: JupyterLab :: Extensions :: Prebuilt 21 | 22 | [options] 23 | python_requires = >=3.7 24 | packages = find: 25 | include_package_data = True 26 | zip_safe = False 27 | 28 | install_requires = 29 | jupyter_server 30 | escapism 31 | 32 | [options.extras_require] 33 | lab = 34 | jupyterlab==3.* 35 | 36 | lint = 37 | %(lab)s 38 | isort 39 | black==22.3.0 40 | 41 | docs = 42 | %(lab)s 43 | ipywidgets 44 | jupyterlite ==0.1.0b4 45 | myst-nb 46 | pydata-sphinx-theme 47 | pytest-check-links 48 | retrolab >=0.3.20 49 | sphinx 50 | sphinx-jsonschema 51 | -------------------------------------------------------------------------------- /src/icons.ts: -------------------------------------------------------------------------------- 1 | import { LabIcon } from '@jupyterlab/ui-components'; 2 | import { NS } from './tokens'; 3 | import CHAT_ICON from '../style/icons/videochat.svg'; 4 | import GROUP_ICON from '../style/icons/group.svg'; 5 | import GROUP_NEW_ICON from '../style/icons/group-add.svg'; 6 | import PUBLIC_ICON from '../style/icons/public.svg'; 7 | import USER_ICON from '../style/icons/user.svg'; 8 | 9 | /** Color class that appears in the SVG files */ 10 | const BASE_COLOR = 'jp-icon3'; 11 | 12 | /** A highlight color, (maybe) close-ish to Jitsi Blue. 13 | * 14 | * ### Note 15 | * Don't use to disinguish between states */ 16 | const PRETTY_COLOR = 'jp-icon-brand0'; 17 | 18 | /** Main chat icon */ 19 | export const chatIcon = new LabIcon({ name: `${NS}:chat`, svgstr: CHAT_ICON }); 20 | 21 | /** Pretty chat icon */ 22 | export const prettyChatIcon = new LabIcon({ 23 | name: `${NS}:chat-pretty`, 24 | svgstr: CHAT_ICON.replace(BASE_COLOR, PRETTY_COLOR), 25 | }); 26 | 27 | /** Group icon */ 28 | export const groupIcon = new LabIcon({ 29 | name: `${NS}:group`, 30 | svgstr: GROUP_ICON, 31 | }); 32 | 33 | /** New Group icon */ 34 | export const newGroupIcon = new LabIcon({ 35 | name: `${NS}:group-new`, 36 | svgstr: GROUP_NEW_ICON, 37 | }); 38 | 39 | /** Global icon */ 40 | export const publicIcon = new LabIcon({ 41 | name: `${NS}:public`, 42 | svgstr: PUBLIC_ICON, 43 | }); 44 | 45 | /** User icon */ 46 | export const userIcon = new LabIcon({ 47 | name: `${NS}:user`, 48 | svgstr: USER_ICON, 49 | }); 50 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # jupyter-videochat 2 | 3 | > Video Chat with JupyterHub peers (or the world) inside JupyterLab or RetroLab, powered 4 | > by [Jitsi]. 5 | 6 | ![jupyter-videochat screenshot][lab-screenshot] 7 | 8 | ## Quick Start 9 | 10 | | from PyPI | from conda-forge | 11 | | ------------------------------- | ------------------------------------------------ | 12 | | `pip install jupyter-videochat` | `conda install -c conda-forge jupyter-videochat` | 13 | 14 | This will install: 15 | 16 | - a Python package named `jupyter-videochat` on PyPI, which offers: 17 | - a `jupyter_server` extension which provides convenient, 18 | [configurable](./user-guide.md#configuration) defaults for rooms on a JupyterHub 19 | - will start as soon as you **re-launch** your `jupyter_server` 20 | - a JupyterLab 3+ _federated extension_ named `jupyterlab-videochat` 21 | - will be available immediately 22 | - can launch a meet via [URL](./user-guide.md#start-a-meet-by-url) 23 | - also distributed on [npm] 24 | - for more about the TypeScript/JS API, see the 25 | [developer guide](./developer-guide.md) 26 | 27 | ## Learn More 28 | 29 | ```{toctree} 30 | :maxdepth: 2 31 | 32 | user-guide 33 | developer-guide 34 | changelog 35 | ``` 36 | 37 | [npm]: https://www.npmjs.com/package/jupyterlab-videochat 38 | [jupyterhub]: https://github.com/jupyterhub/jupyterhub 39 | [jitsi]: https://jitsi.org 40 | [lab-screenshot]: 41 | https://user-images.githubusercontent.com/45380/106391412-312d0400-63bb-11eb-9ed9-af3c4fe85ee4.png 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2020, Yuvi Panda All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name of the copyright holder nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /src/components/PublicRooms.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { CSS, RoomsListProps } from '../tokens'; 4 | import * as icons from '../icons'; 5 | 6 | import { littleIcon, openBlank } from './RoomsList'; 7 | 8 | /** 9 | * A component for rendering public rooms 10 | */ 11 | export const PublicRoomsComponent = (props: RoomsListProps): JSX.Element => { 12 | const [publicRoomId, setPublicRoomId] = useState(''); 13 | const { __ } = props; 14 | 15 | return ( 16 |
17 | 21 |
    22 |
  • 23 |
    24 | setPublicRoomId(evt.currentTarget.value)} 28 | /> 29 | 43 |
    44 |
    45 | {__('Join or create a public room.')}{' '} 46 | {__('Share this name with anyone who can access')}{' '} 47 | 48 | {props.domain} 49 | 50 | . 51 |
    52 |
  • 53 |
54 |
55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | commonjs: true, 6 | node: true, 7 | }, 8 | root: true, 9 | extends: [ 10 | 'eslint:recommended', 11 | 'plugin:@typescript-eslint/eslint-recommended', 12 | 'plugin:@typescript-eslint/recommended', 13 | 'prettier/@typescript-eslint', 14 | 'plugin:react/recommended', 15 | ], 16 | parser: '@typescript-eslint/parser', 17 | parserOptions: { 18 | project: 'tsconfig.json', 19 | }, 20 | globals: { 21 | JSX: 'readonly', 22 | }, 23 | plugins: ['@typescript-eslint'], 24 | rules: { 25 | '@typescript-eslint/no-floating-promises': ['error', { ignoreVoid: true }], 26 | '@typescript-eslint/naming-convention': [ 27 | 'error', 28 | { 29 | selector: 'interface', 30 | format: ['PascalCase'], 31 | custom: { 32 | regex: '^I[A-Z]', 33 | match: true, 34 | }, 35 | }, 36 | ], 37 | '@typescript-eslint/no-unused-vars': ['warn', { args: 'none' }], 38 | '@typescript-eslint/no-use-before-define': 'off', 39 | '@typescript-eslint/camelcase': 'off', 40 | '@typescript-eslint/no-explicit-any': 'off', 41 | '@typescript-eslint/no-non-null-assertion': 'off', 42 | '@typescript-eslint/no-namespace': 'off', 43 | '@typescript-eslint/interface-name-prefix': 'off', 44 | '@typescript-eslint/explicit-function-return-type': 'off', 45 | '@typescript-eslint/ban-ts-comment': ['warn', { 'ts-ignore': true }], 46 | '@typescript-eslint/ban-types': 'warn', 47 | '@typescript-eslint/no-non-null-asserted-optional-chain': 'warn', 48 | '@typescript-eslint/no-var-requires': 'off', 49 | '@typescript-eslint/no-empty-interface': 'off', 50 | '@typescript-eslint/triple-slash-reference': 'warn', 51 | '@typescript-eslint/no-inferrable-types': 'off', 52 | 'no-inner-declarations': 'off', 53 | 'no-prototype-builtins': 'off', 54 | 'no-control-regex': 'warn', 55 | 'no-undef': 'warn', 56 | 'no-case-declarations': 'warn', 57 | 'no-useless-escape': 'off', 58 | 'prefer-const': 'off', 59 | 'react/prop-types': 'warn', 60 | }, 61 | settings: { 62 | react: { 63 | version: 'detect', 64 | }, 65 | }, 66 | }; 67 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.bundle.* 2 | lib/ 3 | node_modules/ 4 | *.egg-info/ 5 | .ipynb_checkpoints 6 | *.tsbuildinfo 7 | 8 | */labextension/*.tgz 9 | # Created by https://www.gitignore.io/api/python 10 | # Edit at https://www.gitignore.io/?templates=python 11 | 12 | ### Python ### 13 | # Byte-compiled / optimized / DLL files 14 | __pycache__/ 15 | *.py[cod] 16 | *$py.class 17 | 18 | # C extensions 19 | *.so 20 | 21 | # Distribution / packaging 22 | .Python 23 | build/ 24 | develop-eggs/ 25 | dist/ 26 | downloads/ 27 | eggs/ 28 | .eggs/ 29 | lib/ 30 | lib64/ 31 | parts/ 32 | sdist/ 33 | var/ 34 | wheels/ 35 | pip-wheel-metadata/ 36 | share/python-wheels/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | .hypothesis/ 62 | .pytest_cache/ 63 | 64 | # Translations 65 | *.mo 66 | *.pot 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # pyenv 78 | .python-version 79 | 80 | # celery beat schedule file 81 | celerybeat-schedule 82 | 83 | # SageMath parsed files 84 | *.sage.py 85 | 86 | # Spyder project settings 87 | .spyderproject 88 | .spyproject 89 | 90 | # Rope project settings 91 | .ropeproject 92 | 93 | # Mr Developer 94 | .mr.developer.cfg 95 | .project 96 | .pydevproject 97 | 98 | # mkdocs documentation 99 | /site 100 | 101 | # mypy 102 | .mypy_cache/ 103 | .dmypy.json 104 | dmypy.json 105 | 106 | # Pyre type checker 107 | .pyre/ 108 | 109 | # End of https://www.gitignore.io/api/python 110 | 111 | # built npm tarballs 112 | *.tgz 113 | 114 | # built labextension 115 | jupyter_videochat/labextension 116 | 117 | # docs 118 | docs/_build/ 119 | .jupyterlite* 120 | docs/_static/lite 121 | 122 | # eslint 123 | .eslintcache 124 | -------------------------------------------------------------------------------- /docs/_templates/demo.html: -------------------------------------------------------------------------------- 1 |
2 | 16 |
Try jupyter-videochat Now
17 | 18 |
19 |
20 | 30 | 31 | 32 |
33 | 34 |
38 | 48 | 49 | 50 |
51 |
52 | 53 |
54 | Launch in Public Room... 55 |
56 | Room 57 | 58 |
59 | 60 | This public room name will be used for Jitsi chat and collaborative 61 | editing. 62 | 63 |
64 | 65 | 73 |
74 | -------------------------------------------------------------------------------- /src/components/RoomsList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { LabIcon } from '@jupyterlab/ui-components'; 3 | 4 | import { CSS, RoomsListProps } from '../tokens'; 5 | import * as icons from '../icons'; 6 | import { ServerRoomsComponent } from './ServerRooms'; 7 | import { PublicRoomsComponent } from './PublicRooms'; 8 | 9 | export const littleIcon: Partial = { 10 | tag: 'span', 11 | width: '20px', 12 | height: '20px', 13 | }; 14 | 15 | export const noRoom = ( 16 |
  • 17 |
    18 |

    19 | No named Hub rooms are configured. 20 |

    21 |

    22 | Create or join a Hub room by name below. 23 |

    24 |
    25 |
  • 26 | ); 27 | 28 | export const openBlank = { 29 | target: '_blank', 30 | rel: 'noopener noreferrer', 31 | }; 32 | 33 | export const RoomsListComponent = (props: RoomsListProps): JSX.Element => { 34 | const { __ } = props; 35 | 36 | return ( 37 |
    38 | 42 |
      43 |
    • 44 | 45 | props.onDisplayNameChanged(evt.currentTarget.value)} 48 | defaultValue={props.displayName} 49 | /> 50 |
      51 | {__('(optional) Default name to show to other chat participants')} 52 |
      53 |
      54 | 55 | props.onEmailChanged(evt.currentTarget.value)} 58 | defaultValue={props.email} 59 | /> 60 |
      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 |
      68 |
    • 69 |
    70 | {props.canCreateRooms ? ServerRoomsComponent(props) : <>} 71 | {props.disablePublicRooms ? <> : PublicRoomsComponent(props)} 72 |
    73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import json 2 | import sys 3 | from pathlib import Path 4 | 5 | from setuptools import setup 6 | 7 | ETC = "etc/jupyter" 8 | 9 | HERE = Path(__file__).parent 10 | EXT_SRC = HERE / "jupyter_videochat" / "labextension" 11 | PACKAGE_JSON = EXT_SRC / "package.json" 12 | 13 | JLPM_MSG = f""" 14 | Please ensure a clean build of the labextension in {EXT_SRC} 15 | 16 | jlpm clean 17 | jlpm build""" 18 | 19 | assert PACKAGE_JSON.exists(), f""" 20 | Did not find `labextension/package.json` 21 | 22 | {JLPM_MSG} 23 | """ 24 | 25 | __jspackage__ = json.loads(PACKAGE_JSON.read_text(encoding="utf-8")) 26 | 27 | EXT_DEST = f"""share/jupyter/labextensions/{__jspackage__["name"]}""" 28 | 29 | 30 | DATA_FILES = [ 31 | ( 32 | f"{EXT_DEST}/{p.parent.relative_to(EXT_SRC).as_posix()}", 33 | [str(p.relative_to(HERE).as_posix())], 34 | ) 35 | for p in EXT_SRC.rglob("*") 36 | if not p.is_dir() 37 | ] 38 | 39 | 40 | ALL_FILES = sum([df[1] for df in DATA_FILES], []) 41 | REMOTE_ENTRY = [p for p in ALL_FILES if "remoteEntry" in p] 42 | 43 | 44 | if "sdist" in sys.argv or "bdist_wheel" in sys.argv: 45 | assert ( 46 | len(REMOTE_ENTRY) == 1 47 | ), f""" 48 | Expected _exactly one_ `labextension/remoteEntry*.js`, found: 49 | 50 | {[p for p in REMOTE_ENTRY]} 51 | 52 | {JLPM_MSG} 53 | """ 54 | 55 | assert not [ 56 | p for p in ALL_FILES if "build_log.json" in p 57 | ], f""" 58 | Found `build_log.json`, which contains paths on your computer, etc. 59 | {JLPM_MSG} 60 | """ 61 | 62 | DATA_FILES += [ 63 | # percolates up to the UI about the installed labextension 64 | (EXT_DEST, ["install.json"]), 65 | # enables the serverextension under various apps 66 | *[ 67 | ( 68 | f"{ETC}/jupyter_{app}_config.d", 69 | [f"jupyter-config/jupyter_{app}_config.d/jupyter_videochat.json"], 70 | ) 71 | for app in ["notebook", "server"] 72 | ], 73 | ] 74 | 75 | if __name__ == "__main__": 76 | setup( 77 | version=__jspackage__["version"], 78 | url=__jspackage__["homepage"], 79 | description=__jspackage__["description"], 80 | data_files=DATA_FILES, 81 | author=__jspackage__["author"], 82 | license=__jspackage__["license"], 83 | project_urls={ 84 | "Bug Tracker": __jspackage__["bugs"]["url"], 85 | "Source Code": __jspackage__["repository"]["url"], 86 | }, 87 | ) 88 | -------------------------------------------------------------------------------- /src/rooms-server.ts: -------------------------------------------------------------------------------- 1 | import { URLExt } from '@jupyterlab/coreutils'; 2 | import { ServerConnection } from '@jupyterlab/services'; 3 | 4 | import { API_NAMESPACE, IRoomProvider } from './tokens'; 5 | import { IServerResponses, Room, VideoChatConfig } from './types'; 6 | 7 | export class ServerRoomProvider implements IRoomProvider { 8 | private _serverSettings: ServerConnection.ISettings; 9 | 10 | constructor(options: ServerRoomProvider.IOptions) { 11 | this._serverSettings = options.serverSettings || ServerConnection.makeSettings(); 12 | } 13 | 14 | /** Request the configuration from the server */ 15 | async updateConfig(): Promise { 16 | return await this.requestAPI('config'); 17 | } 18 | 19 | /** Request the room list from the server */ 20 | async updateRooms(): Promise { 21 | return await this.requestAPI('rooms'); 22 | } 23 | 24 | get canCreateRooms(): boolean { 25 | return true; 26 | } 27 | 28 | /** Create a new named room */ 29 | async createRoom(room: Partial): Promise { 30 | const newRoom = await this.requestAPI('generate-room', { 31 | method: 'POST', 32 | body: JSON.stringify(room), 33 | }); 34 | return newRoom; 35 | } 36 | /** 37 | * Call the API extension 38 | * 39 | * @param endPoint API REST end point for the extension 40 | * @param init Initial values for the request 41 | * @returns The response body interpreted as JSON 42 | */ 43 | async requestAPI( 44 | endPoint: U, 45 | init: RequestInit = {} 46 | ): Promise { 47 | // Make request to Jupyter API 48 | const settings = this._serverSettings; 49 | const requestUrl = URLExt.join(settings.baseUrl, API_NAMESPACE, endPoint); 50 | 51 | let response: Response; 52 | try { 53 | response = await ServerConnection.makeRequest(requestUrl, init, settings); 54 | } catch (error) { 55 | throw new ServerConnection.NetworkError(error as any); 56 | } 57 | 58 | const data = await response.json(); 59 | 60 | if (!response.ok) { 61 | throw new ServerConnection.ResponseError(response, data.message); 62 | } 63 | 64 | return data as T; 65 | } 66 | } 67 | 68 | /** 69 | * A namespace for server room provider settings 70 | */ 71 | export namespace ServerRoomProvider { 72 | /** 73 | * Initialization options for a server room provider 74 | */ 75 | export interface IOptions { 76 | serverSettings?: ServerConnection.ISettings; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/components/VideoChat.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { ReadonlyPartialJSONObject } from '@lumino/coreutils'; 4 | 5 | import { IVideoChatManager, ITrans } from '../tokens'; 6 | import { Room, VideoChatConfig, IJitsiFactory } from '../types'; 7 | import { JitsiMeetComponent } from './JitsiMeet'; 8 | import { RoomsListComponent } from './RoomsList'; 9 | import { JitsiMeetExternalAPI } from 'jitsi-meet'; 10 | 11 | export type VideoChatProps = { 12 | jitsiAPI: IJitsiFactory; 13 | currentRoom: Room; 14 | onCreateRoom: (room: Room) => void; 15 | onRoomSelect: (room: Room) => void; 16 | onEmailChanged: (email: string) => void; 17 | onDisplayNameChanged: (displayName: string) => void; 18 | onMeet: (meet: JitsiMeetExternalAPI) => void; 19 | providerForRoom: (room: Room) => IVideoChatManager.IProviderOptions; 20 | rooms: Room[]; 21 | config: VideoChatConfig; 22 | email: string; 23 | displayName: string; 24 | configOverwrite: ReadonlyPartialJSONObject | null; 25 | interfaceConfigOverwrite: ReadonlyPartialJSONObject | null; 26 | disablePublicRooms: boolean; 27 | canCreateRooms: boolean; 28 | __: ITrans; 29 | }; 30 | 31 | export const VideoChatComponent = (props: VideoChatProps): JSX.Element => { 32 | const domain = props.config?.jitsiServer; 33 | return ( 34 | <> 35 | {domain != null && props.currentRoom?.id != null ? ( 36 | 49 | ) : ( 50 | 65 | )} 66 | 67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /src/components/JitsiMeet.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | import { ReadonlyPartialJSONObject } from '@lumino/coreutils'; 4 | 5 | import { PageConfig } from '@jupyterlab/coreutils'; 6 | 7 | import { CSS, IVideoChatManager, ITrans } from '../tokens'; 8 | import { Room, IJitsiFactory } from '../types'; 9 | 10 | import type { ExternalAPIOptions, JitsiMeetExternalAPI } from 'jitsi-meet'; 11 | 12 | export type JitsiMeetProps = { 13 | jitsiAPI: IJitsiFactory; 14 | onRoomSelect: (room: Room) => void; 15 | onMeet: (meet: JitsiMeetExternalAPI) => void; 16 | providerForRoom: (room: Room) => IVideoChatManager.IProviderOptions; 17 | room: Room; 18 | domain: string; 19 | email: string; 20 | displayName: string; 21 | configOverwrite: ReadonlyPartialJSONObject | null; 22 | interfaceConfigOverwrite: ReadonlyPartialJSONObject | null; 23 | __: ITrans; 24 | }; 25 | 26 | export const JitsiMeetComponent = (props: JitsiMeetProps): JSX.Element => { 27 | const container = React.createRef(); 28 | const { __ } = props; 29 | 30 | useEffect(() => { 31 | const options: ExternalAPIOptions = { 32 | roomName: props.room.id, 33 | parentNode: container.current, 34 | userInfo: { 35 | displayName: PageConfig.getOption('hubUser'), 36 | }, 37 | }; 38 | 39 | const { displayName, email, configOverwrite, interfaceConfigOverwrite } = props; 40 | 41 | if (displayName != null) { 42 | options.userInfo.displayName = displayName; 43 | } 44 | 45 | if (email != null) { 46 | options.userInfo.email = email; 47 | } 48 | 49 | if (configOverwrite != null) { 50 | options.configOverwrite = configOverwrite; 51 | } else { 52 | console.warn(__('No Jitsi third-party requests will be blocked')); 53 | } 54 | 55 | if (interfaceConfigOverwrite != null) { 56 | options.interfaceConfigOverwrite = interfaceConfigOverwrite; 57 | } else { 58 | console.warn(__('All Jitsi features will be enabled')); 59 | } 60 | 61 | let meet: JitsiMeetExternalAPI; 62 | let Jitsi = props.jitsiAPI(); 63 | 64 | if (Jitsi == null) { 65 | console.info(__('Jitsi API not yet loaded, will try again in a moment')); 66 | } else { 67 | meet = new Jitsi(props.domain, options); 68 | } 69 | 70 | if (meet) { 71 | props.onMeet(meet); 72 | 73 | meet.executeCommand('subject', props.room.displayName); 74 | 75 | meet.on('readyToClose', () => props.onRoomSelect(null)); 76 | } 77 | 78 | return () => { 79 | props.onMeet(null); 80 | if (meet) { 81 | meet.dispose(); 82 | } 83 | }; 84 | }); 85 | 86 | return
    ; 87 | }; 88 | -------------------------------------------------------------------------------- /src/components/ServerRooms.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { CSS, RoomsListProps } from '../tokens'; 4 | import * as icons from '../icons'; 5 | 6 | import { noRoom, littleIcon } from './RoomsList'; 7 | 8 | export const openBlank = { 9 | target: '_blank', 10 | rel: 'noopener noreferrer', 11 | }; 12 | 13 | /** 14 | * a component for rendering server rooms 15 | */ 16 | export const ServerRoomsComponent = (props: RoomsListProps): JSX.Element => { 17 | const [roomName, setRoomName] = useState(''); 18 | 19 | const { __ } = props; 20 | 21 | return ( 22 |
    23 | 27 |
      28 | {!props.rooms.length 29 | ? noRoom 30 | : props.rooms.map((value, i) => { 31 | return ( 32 |
    • 33 | 34 | 40 |
      {value.description}
      41 | {props.providerForRoom(value)?.label} 42 |
    • 43 | ); 44 | })} 45 |
    46 | 47 | 51 |
      52 |
    • 53 |
      54 | setRoomName(evt.currentTarget.value)} 58 | /> 59 | 68 |
      69 |
      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 | [![Extension status](https://img.shields.io/badge/status-ready-success 'ready to be used')](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 | --------------------------------------------------------------------------------