Salle de conférence expérimentale

This commit is contained in:
wpetit 2020-10-12 21:13:28 +02:00
parent 54e8cf23f7
commit 18dc4135c4
8 changed files with 345 additions and 7 deletions

View File

@ -4035,6 +4035,11 @@
"randomfill": "^1.0.3"
}
},
"crypto-js": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.0.0.tgz",
"integrity": "sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg=="
},
"css": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz",
@ -5419,6 +5424,11 @@
"integrity": "sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg==",
"dev": true
},
"get-browser-rtc": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/get-browser-rtc/-/get-browser-rtc-1.0.2.tgz",
"integrity": "sha1-u81AyEUaftTvXDc7gWmkCd0dEdk="
},
"get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
@ -6055,8 +6065,7 @@
"inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"dev": true
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"ini": {
"version": "1.3.5",
@ -6376,6 +6385,11 @@
"integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=",
"dev": true
},
"isomorphic.js": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.1.5.tgz",
"integrity": "sha512-MkX5lLQApx/8IAIU31PKvpAZosnu2Jqcj1rM8TzxyA4CR96tv3SgMKQNTCxL58G7696Q57zd7ubHV/hTg+5fNA=="
},
"isstream": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz",
@ -6496,6 +6510,14 @@
"leven": "^3.1.0"
}
},
"lib0": {
"version": "0.2.34",
"resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.34.tgz",
"integrity": "sha512-cqsVIMPgFlDtgQcpkt7HOY6W3sbYPIe3qxMnbRSwHTgiQancgm+TRDPx28mC6GUZ6lG6Nr0bIWf4Nog6dWUNUg==",
"requires": {
"isomorphic.js": "^0.1.3"
}
},
"load-json-file": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz",
@ -8006,11 +8028,15 @@
"integrity": "sha512-w7fLxIRCRT7U8Qu53jQnJyPkYZIaR4n5151KMfcJlO/A9397Wxb1amJvROTK6TOnp7PfoAmg/qXiNHI+08jRfA==",
"dev": true
},
"queue-microtask": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.1.4.tgz",
"integrity": "sha512-eY/4Obve9cE5FK8YvC1cJsm5cr7XvAurul8UtBDJ2PR1p5NmAwHtvAt5ftcLtwYRCUKNhxCneZZlxmUDFoSeKA=="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"requires": {
"safe-buffer": "^5.1.0"
}
@ -8968,6 +8994,30 @@
"integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==",
"dev": true
},
"simple-peer": {
"version": "9.7.2",
"resolved": "https://registry.npmjs.org/simple-peer/-/simple-peer-9.7.2.tgz",
"integrity": "sha512-xeMyxa9B4V0eA6mf17fVr8nm2QhAYFu+ZZv8zkSFFTjJETGF227CshwobrIYZuspJglMD63egcevQXGOrTIsuA==",
"requires": {
"debug": "^4.0.1",
"get-browser-rtc": "^1.0.0",
"queue-microtask": "^1.1.0",
"randombytes": "^2.0.3",
"readable-stream": "^3.4.0"
},
"dependencies": {
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
}
}
}
},
"slash": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz",
@ -9397,7 +9447,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"requires": {
"safe-buffer": "~5.1.0"
}
@ -10114,8 +10163,7 @@
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
"dev": true
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
},
"util.promisify": {
"version": "1.0.0",
@ -10811,6 +10859,33 @@
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"dev": true
},
"y-protocols": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.1.tgz",
"integrity": "sha512-QP3fCM7c2gGfUi2nqf8gspyO4VW23zv3kNqPNdD3wNxMbuNQenMyoDVZYEo12jzR4RQ3aaDfPK62Sf31SVOmfg==",
"requires": {
"lib0": "^0.2.28"
}
},
"y-webrtc": {
"version": "10.1.6",
"resolved": "https://registry.npmjs.org/y-webrtc/-/y-webrtc-10.1.6.tgz",
"integrity": "sha512-b3pTIv9LcPuMb4nbDT3/kkgmcuQoTrBmaPbBqPH1LJMzI8HwYnMK8p5r0fBQJBI0YRor+i8BT15Evv1nQBP0zg==",
"requires": {
"lib0": "^0.2.32",
"simple-peer": "^9.7.2",
"ws": "^7.2.0",
"y-protocols": "^1.0.0"
},
"dependencies": {
"ws": {
"version": "7.3.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-7.3.1.tgz",
"integrity": "sha512-D3RuNkynyHmEJIpD2qrgVkc9DQ23OrN/moAwZX4L8DfvszsJxpjQuUq3LMx6HoYji9fbIOBY18XWBsAux1ZZUA==",
"optional": true
}
}
},
"y18n": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz",
@ -10936,6 +11011,14 @@
}
}
},
"yjs": {
"version": "13.4.1",
"resolved": "https://registry.npmjs.org/yjs/-/yjs-13.4.1.tgz",
"integrity": "sha512-kIh0sprCTzIm2qyr1VsovkvjKzD2GR4WcU/McJpLAEvImCJHA78Q3S6uSLnhZX0i7FQdrLPCRT8DtTPEH73jnw==",
"requires": {
"lib0": "^0.2.33"
}
},
"zen-observable": {
"version": "0.8.15",
"resolved": "https://registry.npmjs.org/zen-observable/-/zen-observable-0.8.15.tgz",

View File

@ -56,6 +56,7 @@
"bs58": "^4.0.1",
"bulma": "^0.9.0",
"bulma-timeline": "^3.0.4",
"crypto-js": "^4.0.0",
"graphql": "^15.3.0",
"react": "^16.12.0",
"react-dom": "^16.12.0",
@ -66,6 +67,8 @@
"redux-saga": "^1.1.3",
"styled-components": "^4.4.1",
"subscriptions-transport-ws": "^0.9.17",
"typescript": "^3.8.3"
"typescript": "^3.8.3",
"y-webrtc": "^10.1.6",
"yjs": "^13.4.1"
}
}

View File

@ -14,6 +14,7 @@ import { createClient } from '../util/apollo';
import { ApolloProvider } from '@apollo/client';
import { LogoutPage } from './LogoutPage';
import { UnauthorizedPage } from './UnauthorizedPage/UnauthorizedPage';
import { ConferencePage } from './ConferencePage/ConferencePage';
export interface AppProps {
@ -44,6 +45,7 @@ export const App: FunctionComponent<AppProps> = () => {
<Route path="/" exact component={HomePage} />
<Route path="/unauthorized" exact component={UnauthorizedPage} />
<PrivateRoute path="/profile" exact component={ProfilePage} />
<PrivateRoute path="/conference" exact component={ConferencePage} />
<PrivateRoute path="/workgroups/:id" exact component={WorkgroupPage} />
<PrivateRoute path="/decisions/:id" component={DecisionSupportFilePage} />
<PrivateRoute path="/dashboard" exact component={DashboardPage} />

View File

@ -0,0 +1,130 @@
import React, { FunctionComponent, useEffect } from 'react';
import { useUserProfile } from '../../gql/queries/profile';
import { useConference } from '../../hooks/useConference';
import { Page } from '../Page';
import { Gravatar } from './Gravatar';
export interface ConferencePageProps {
}
const StatusHandRaised = 'hand-raised';
const StatusThumbsUp = 'thumbs-up';
const StatusThumbsDown = 'thumbs-down';
const StatusNoVote = 'no-vote';
export const ConferencePage:FunctionComponent<ConferencePageProps> = () => {
const { user } = useUserProfile();
const { id, data, peers, setNickname, setEmail, ping, setStatus } = useConference();
const currentStatus = data.statuses[id];
useEffect(() => {
if (peers.length === 0) return;
if (!id || (!user.name && !user.email)) return;
setNickname(user.name || user.email.split('@')[0]);
setEmail(user.email);
}, [user.name, user.email, peers.length]);
useEffect(() => {
ping();
const intervalId = setInterval(() => ping(), 30000);
return () => clearInterval(intervalId);
}, []);
const onStatusChange = (status: string) => {
setStatus(currentStatus === status ? '' : status);
};
return (
<Page title="Conference">
<div className="container is-fluid">
<section className="mt-5">
<h3 className="is-size-3">Mes actions</h3>
<div className="buttons has-addons">
<button
className={`button is-medium ${currentStatus === StatusHandRaised ? 'is-info is-selected' : ''}`}
onClick={onStatusChange.bind(null, StatusHandRaised)}>
<span className="icon">
<i className="fa fa-hand-paper"></i>
</span>
<span>Lever la main</span>
</button>
<button
className={`button is-medium ${currentStatus === StatusThumbsUp ? 'is-success is-selected' : ''}`}
onClick={onStatusChange.bind(null, StatusThumbsUp)}>
<span className="icon">
<i className="fa fa-thumbs-up"></i>
</span>
<span>Voter pour</span>
</button>
<button
className={`button is-medium ${currentStatus === StatusNoVote ? 'is-warning is-selected' : ''}`}
onClick={onStatusChange.bind(null, StatusNoVote)}>
<span className="icon">
<i className="fa fa-mitten"></i>
</span>
<span>Ne se prononce pas</span>
</button>
<button
className={`button is-medium ${currentStatus === StatusThumbsDown ? 'is-danger is-selected' : ''}`}
onClick={onStatusChange.bind(null, StatusThumbsDown)}>
<span className="icon">
<i className="fa fa-thumbs-down"></i>
</span>
<span>Voter contre</span>
</button>
</div>
<h3 className="is-size-3">Assemblée</h3>
<div className="columns mt-1">
<UserCard className="column is-narrow"
nickname={data.nicknames[id]}
status={currentStatus}
email={user.email} />
{
peers.map(p => {
const nickname = data.nicknames[p] || '???';
const email = data.emails[p] || '';
return (
<UserCard key={`peer-${p}`} className="column is-narrow"
nickname={nickname}
status={data.statuses[p]}
email={email} />
)
})
}
</div>
</section>
</div>
</Page>
);
}
export interface UserCardProps {
nickname: string
email: string
className?: string
status: string
};
export const UserCard:FunctionComponent<UserCardProps> = ({ nickname, email, className, status }) => {
return (
<div className={className}>
<div className="box">
<div className="has-text-centered">
<div className="mb-1">
{ !status ? <span className="icon"><i className="far fa-2x fa-meh-blank"></i></span> : null }
{ status === StatusHandRaised ? <span className="icon has-text-info"><i className="fa fa-2x fa-hand-paper"></i></span> : null }
{ status === StatusThumbsUp ? <span className="icon has-text-success"><i className="fa fa-2x fa-thumbs-up"></i></span> : null }
{ status === StatusNoVote ? <span className="icon has-text-warning"><i className="fa fa-2x fa-mitten"></i></span> : null }
{ status === StatusThumbsDown ? <span className="icon has-text-danger"><i className="fa fa-2x fa-thumbs-down"></i></span> : null }
</div>
<figure className="image is-128x128 is-inline-block">
<Gravatar className="is-rounded" email={email} />
</figure>
<h4 className="is-size-4">{nickname}</h4>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,20 @@
import React, { FunctionComponent, useEffect, useState } from 'react';
import md5 from 'crypto-js/md5';
export interface GravatarProps {
className?: string
email: string
}
const defaultAvatarUrl = 'https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&s=128';
export const Gravatar:FunctionComponent<GravatarProps> = ({ className, email }) => {
const [ avatarUrl, setAvatarUrl ] = useState(defaultAvatarUrl);
useEffect(() => {
const hash = md5(email.trim().toLowerCase());
setAvatarUrl(`https://www.gravatar.com/avatar/${hash}?d=mp&s=128`);
}, [email]);
return (
<img className={className} src={avatarUrl} />
);
}

View File

@ -31,6 +31,20 @@ export function Navbar() {
</a>
</div>
<div className={`navbar-menu ${isActive ? 'is-active' : ''}`}>
<div className="navbar-start">
{
loggedIn ?
<React.Fragment>
<Link to="/dashboard" className="navbar-item">
<i className="fa fa-columns"></i>&nbsp;Tableau de bord
</Link>
<Link to="/conference" className="navbar-item">
<i className="fa fa-users"></i>&nbsp;Conférence
</Link>
</React.Fragment> :
null
}
</div>
<div className="navbar-end">
<div className="navbar-item">
<div className="buttons">

View File

@ -0,0 +1,85 @@
import * as Y from 'yjs'
import { WebrtcProvider, } from 'y-webrtc'
import { useEffect, useRef, useState } from 'react'
export function useConference() {
const docRef = useRef(new Y.Doc());
const [ state, setState ] = useState({
data: {
heartbeats: {},
emails: {},
nicknames: {},
statuses: {},
},
peers: [],
id: null,
});
const setData = (key: string, value: any) => {
setState(state => ({...state, data: { ...state.data, [key]: value }}));
}
useEffect(() => {
const doc = docRef.current;
const roomName = `${window.location.protocol}//${window.location.host}/daddy/conference`;
const provider = new WebrtcProvider(roomName, docRef.current);
const onPeers = (evt) => {
let peers = [...state.peers];
peers = peers.filter(p => evt.removed.indexOf(p) === -1);
peers.push(...evt.added);
setState(state => ({ ...state, id: provider.room.peerId, peers }));
};
provider.on('peers', onPeers);
const heartbeats = doc.getMap('heartbeats');
heartbeats.observe(evt => setData('heartbeats', evt.currentTarget.toJSON()));
const nicknames = doc.getMap('nicknames');
nicknames.observe(evt => setData('nicknames', evt.currentTarget.toJSON()));
const emails = doc.getMap('emails');
emails.observe(evt => setData('emails', evt.currentTarget.toJSON()));
const statuses = doc.getMap('statuses');
statuses.observe(evt => setData('statuses', evt.currentTarget.toJSON()));
return () => {
provider.off('peers', onPeers);
provider.destroy();
docRef.current.destroy();
};
}, []);
return {
data: state.data,
peers: state.peers,
id: state.id,
setStatus: (status: string) => {
const doc = docRef.current;
const statuses = doc.getMap('statuses');
statuses.set(state.id, status);
},
ping: () => {
const doc = docRef.current;
const heartbeats = doc.getMap('heartbeats');
heartbeats.set(state.id, (new Date()).toJSON());
},
setNickname: (nickname: string) => {
const doc = docRef.current;
const nicknames = doc.getMap('nicknames');
nicknames.set(state.id, nickname);
},
setEmail: (email: string) => {
const doc = docRef.current;
const emails = doc.getMap('emails');
emails.set(state.id, email);
},
};
}

View File

@ -96,6 +96,7 @@ func Mount(r *chi.Mux, config *config.Config) error {
"/dashboard",
"/decisions/*",
"/unauthorized",
"/conference",
}
for _, cr := range clientRoutes {