diff --git a/client/package-lock.json b/client/package-lock.json index 72a4a13..b43e35f 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -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", diff --git a/client/package.json b/client/package.json index b83fa25..8882b7e 100644 --- a/client/package.json +++ b/client/package.json @@ -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" } } diff --git a/client/src/components/App.tsx b/client/src/components/App.tsx index 5c06542..147d692 100644 --- a/client/src/components/App.tsx +++ b/client/src/components/App.tsx @@ -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 = () => { + diff --git a/client/src/components/ConferencePage/ConferencePage.tsx b/client/src/components/ConferencePage/ConferencePage.tsx new file mode 100644 index 0000000..8bc2a02 --- /dev/null +++ b/client/src/components/ConferencePage/ConferencePage.tsx @@ -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 = () => { + 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 ( + +
+
+

Mes actions

+
+ + + + +
+

Assemblée

+
+ + { + peers.map(p => { + const nickname = data.nicknames[p] || '???'; + const email = data.emails[p] || ''; + return ( + + ) + }) + } +
+
+
+
+ ); +} + +export interface UserCardProps { + nickname: string + email: string + className?: string + status: string +}; + +export const UserCard:FunctionComponent = ({ nickname, email, className, status }) => { + return ( +
+
+
+
+ { !status ? : null } + { status === StatusHandRaised ? : null } + { status === StatusThumbsUp ? : null } + { status === StatusNoVote ? : null } + { status === StatusThumbsDown ? : null } +
+
+ +
+

{nickname}

+
+
+
+ ); +}; \ No newline at end of file diff --git a/client/src/components/ConferencePage/Gravatar.tsx b/client/src/components/ConferencePage/Gravatar.tsx new file mode 100644 index 0000000..60943fc --- /dev/null +++ b/client/src/components/ConferencePage/Gravatar.tsx @@ -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 = ({ 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 ( + + ); +} \ No newline at end of file diff --git a/client/src/components/Navbar.tsx b/client/src/components/Navbar.tsx index b240eda..9665914 100644 --- a/client/src/components/Navbar.tsx +++ b/client/src/components/Navbar.tsx @@ -31,6 +31,20 @@ export function Navbar() {
+
+ { + loggedIn ? + + +  Tableau de bord + + +  ConfĂ©rence + + : + null + } +
diff --git a/client/src/hooks/useConference.tsx b/client/src/hooks/useConference.tsx new file mode 100644 index 0000000..0c94e5b --- /dev/null +++ b/client/src/hooks/useConference.tsx @@ -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); + }, + }; +} \ No newline at end of file diff --git a/internal/route/mount.go b/internal/route/mount.go index f7970d3..f999bed 100644 --- a/internal/route/mount.go +++ b/internal/route/mount.go @@ -96,6 +96,7 @@ func Mount(r *chi.Mux, config *config.Config) error { "/dashboard", "/decisions/*", "/unauthorized", + "/conference", } for _, cr := range clientRoutes {