Multi session support

This commit is contained in:
Morten Olsen
2021-06-10 12:32:47 +02:00
parent 75df75caec
commit 92d57d5b2b
15 changed files with 369 additions and 219 deletions

View File

@@ -1,20 +1,32 @@
import React from 'react';
import React, { useEffect } from 'react';
import Welcome from './containers/Welcome';
import Connected from './containers/Connected';
import useConnection, { ConnectionStates } from './hooks/useConnection';
import { SessionProvider } from './contexts/SessionContext';
import useSessions from './hooks/useSessions';
import Session from './containers/Session';
const App: React.FC<{}> = () => {
const { state } = useConnection();
const { sessions, addSession } = useSessions();
if (state === ConnectionStates.WAITING) {
return <Welcome />
useEffect(() => {
if (sessions.length === 0) {
addSession()
}
if (state === ConnectionStates.CONNECTED) {
return <Connected />
}, [sessions.length]);
if (sessions.length === 0) {
return <div>Setting up</div>
}
return (
<div>Connected</div>
);
<>
{sessions.map((session) => (
<SessionProvider session={session}>
<Session />
</SessionProvider>
))}
</>
)
};
export default App;

30
src/Crypto/index.tsx Normal file
View File

@@ -0,0 +1,30 @@
import { encrypt, decrypt } from '../utils/crypto';
class Crypto {
#secret: string;
#ticket?: Symbol;
constructor(secret: string, ticket?: Symbol) {
this.#secret = secret;
this.#ticket = ticket
}
encrypt = async (data: any) => {
const raw = JSON.stringify(data);
const result = await encrypt(raw, this.#secret);
return result;
};
decrypt = async (data: any) => {
return decrypt(data, this.#secret);
};
getSecret(ticket: Symbol) {
if (!this.#ticket || this.#ticket !== ticket) {
throw new Error('Ticket not valid');
}
return this.#secret;
}
}
export default Crypto;

View File

@@ -1,5 +1,5 @@
import EventEmitter from 'eventemitter3';
import { nanoid } from 'nanoid';
import { useState, useCallback } from 'react';
interface BaseRequest {
type: string;
@@ -82,11 +82,20 @@ const updateMessage = (
};
};
const useMessages = (postProcess: (input: any) => any) => {
const [messages, setMessage] = useState<Message[]>([]);
class MessageList extends EventEmitter {
#messages: Message[] = [];
#postProcess: (a: any) => any;
const addMessage = useCallback((request: Request, self: boolean) => {
setMessage((current) => {
constructor(postProces: (a: any) => any = (a) => a) {
super();
this.#postProcess = postProces;
}
get list() {
return this.#messages;
}
addMessage = (request: Request, self: boolean) => {
if (request.type === 'start-message') {
const message: IncompleteMessage = {
id: request.payload.id,
@@ -96,26 +105,25 @@ const useMessages = (postProcess: (input: any) => any) => {
current: 0,
parts: [],
};
return [
...current,
this.#messages = [
...this.#messages,
message,
];
this.emit('updated');
}
if (request.type === 'update-message') {
return current.map(message => {
this.#messages = this.#messages.map(message => {
if (message.id !== request.payload.id) {
return message;
}
return updateMessage(message, request, postProcess);
return updateMessage(message, request, this.#postProcess);
});
this.emit('updated');
}
};
return current;
});
}, []);
const formatMessage = (msg: any) => {
formatMessage = (msg: any) => {
const dataString = JSON.stringify(msg);
const parts = chunkSubstr(dataString, 100000);
const id = nanoid();
@@ -139,12 +147,6 @@ const useMessages = (postProcess: (input: any) => any) => {
updateMsgs,
};
};
return {
messages,
addMessage,
formatMessage,
}
};
export default useMessages;
export default MessageList;

121
src/Session/index.tsx Normal file
View File

@@ -0,0 +1,121 @@
import Peer, { DataConnection } from 'peerjs';
import Crypto from '../Crypto';
import MessageList from '../MessageList';
import EventEmitter from 'eventemitter3';
import { nanoid } from 'nanoid';
enum State {
READY,
CONNECTING,
CONNECTED,
DISCONNECTED,
};
class Session extends EventEmitter {
#cryptoTicket = Symbol('crypto-ticket');
#name: string;
#peer: Peer;
#connection?: DataConnection;
#crypto: Crypto;
#messages: MessageList = new MessageList();
#state: State = State.READY;
constructor(
name: string = 'unnamed',
id: string = nanoid(),
secret: string = nanoid(),
) {
super();
this.#name = name;
this.#peer = new Peer(id);
this.#crypto = new Crypto(secret, this.#cryptoTicket);
this.#peer.on('connection', this.#handleConnection);
this.#messages.on('updated', () => {
this.emit('updated');
});
}
#handleConnection = (connection: DataConnection) => {
if (this.#connection) {
return;
}
this.#connection = connection;
this.#connection.on('data', this.#handleData);
this.#connection.on('close', this.#handleDisconnect);
this.#connection.on('error', this.#handleDisconnect);
this.#state = State.CONNECTED;
this.emit('updated');
}
#handleData = async (encrypted: any) => {
const message = await this.#crypto.decrypt(encrypted);
this.#messages.addMessage(message, false);
console.log('foo', message);
}
#reconnect = () => {
if (!this.#connection) return;
const id = this.#connection.peer;
const secret = this.#crypto.getSecret(this.#cryptoTicket);
// TODO: Add reconnect functionality
}
#handleDisconnect = () => {
this.#state = State.DISCONNECTED;
this.emit('updated');
}
get id() {
return this.#peer.id;
}
get name() {
return this.#name;
}
get messages() {
return this.#messages.list;
}
get state() {
return this.#state;
}
get connectInfo() {
return {
id: this.#peer.id,
secret: this.#crypto.getSecret(this.#cryptoTicket),
}
}
connect = (id: string, secret: string) => {
this.#state = State.CONNECTING;
this.#crypto = new Crypto(secret, this.#cryptoTicket);
this.#connection = this.#peer.connect(id);
this.#connection.on('close', this.#handleDisconnect);
this.#connection.on('error', this.#handleDisconnect);
this.emit('updated');
this.#connection.on('open', () => {
this.#state = State.CONNECTED;
this.emit('updated');
});
};
send = async (data: any) => {
if (!this.#connection) {
throw new Error('Not connected');
}
const { startMsg, updateMsgs } = this.#messages.formatMessage(data);
this.#messages.addMessage(startMsg, true);
this.#connection.send(await this.#crypto.encrypt(startMsg));
for (let updateMsg of updateMsgs) {
this.#connection.send(await this.#crypto.encrypt(updateMsg));
this.#messages.addMessage(updateMsg, true);
}
};
};
export { State };
export default Session;

View File

@@ -1,6 +1,6 @@
import React, { useCallback, useMemo, useState } from 'react';
import styled from 'styled-components';
import useConnection from '../hooks/useConnection';
import useSession, { State } from '../hooks/useSession';
import Message from '../components/Message';
import ComposeMessage from '../types/ComposeMessage';
import ComposeBar from '../components/ComposeBar';
@@ -22,7 +22,7 @@ const Loading = styled.div`
const Connected: React.FC<{}> = () => {
const { send, messages } = useConnection();
const { send, messages, state } = useSession();
const [currentMessage, setCurrentMessage] = useState<ComposeMessage>({
files: [],
text: '',
@@ -48,7 +48,9 @@ const Connected: React.FC<{}> = () => {
<Loading>Loading {Math.round(message.current / message.length * 100)}%</Loading>
)))}
</MessageList>
{ state === State.CONNECTED && (
<ComposeBar onSend={onSend} message={currentMessage} setMessage={setCurrentMessage} />
)}
</Wrapper>
);
}

View File

@@ -0,0 +1,24 @@
import React from 'react';
import useSession, { State } from '../hooks/useSession';
import Welcome from './Welcome';
import Connected from './Connected';
const Session: React.FC<{}> = () => {
const { state } = useSession();
if (state === State.READY) {
return (
<Welcome />
);
}
if (state === State.CONNECTED) {
return <Connected />
}
return (
<div>{state.toString()}</div>
)
};
export default Session;

View File

@@ -1,8 +1,8 @@
import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react';
import React, { useState, useCallback } from 'react';
import styled from 'styled-components';
import QRCode from 'react-qr-code';
import QRReader from 'react-qr-reader'
import useConnection from '../hooks/useConnection';
import useSession from '../hooks/useSession';
const Wrapper = styled.div`
display: flex;
@@ -32,14 +32,15 @@ const Content = styled.div`
`;
const Welcome: React.FC<{}> = () => {
const { connect, clientInfo } = useConnection();
const { connect, connectInfo } = useSession();
const [mode, setMode] = useState<'view' | 'scan'>('view');
const onScan = useCallback(
(result) => {
if (result) {
setMode('view');
connect(JSON.parse(result));
const { id, secret } = JSON.parse(result);
connect(id, secret);
}
},
[],
@@ -52,9 +53,10 @@ const Welcome: React.FC<{}> = () => {
<Button active={mode==='scan'} onClick={() => setMode('scan')}>Scan</Button>
</Header>
<Content>
{JSON.stringify(connectInfo)}
{mode === 'view' && (
<QRCode
value={JSON.stringify(clientInfo)}
value={JSON.stringify(connectInfo)}
size={300}
/>
)}

View File

@@ -1,127 +0,0 @@
import React, { createContext, useMemo, useState, useCallback, useEffect } from 'react';
import Peer, { DataConnection } from 'peerjs';
import { nanoid } from 'nanoid';
import useCrypto from '../hooks/useCrypto';
import useMessages from '../hooks/useMessages';
enum States {
WAITING,
CONNECTING,
CONNECTED,
}
interface ConnectionContextValue {
clientInfo: any;
state: States;
messages: any[];
send: (message: any) => Promise<void>;
connect: (connectionInfo: any) => Promise<void>;
}
function dataURItoBlob(dataURI: string) {
var mime = dataURI.split(',')[0].split(':')[1].split(';')[0];
var binary = atob(dataURI.split(',')[1]);
var array = [];
for (var i = 0; i < binary.length; i++) {
array.push(binary.charCodeAt(i));
}
const blob = new Blob([new Uint8Array(array)], {type: mime});
return URL.createObjectURL(blob);
}
const ConnectionContext = createContext<ConnectionContextValue>(undefined as any);
const postProcess = (input: any) => {
if (input.mediaType === 'file') {
return {
...input,
body: dataURItoBlob(input.body),
};
}
return input;
};
const ConnectionProvider: React.FC = ({ children }) => {
const { messages, addMessage, formatMessage } = useMessages(postProcess);
const [secret, setSecret] = useState(nanoid());
const { encrypt, decrypt } = useCrypto(secret);
const id = useMemo(() => nanoid(), []);
const peer = useMemo(() => new Peer(id), [id]);
const [connection, setConnection] = useState<DataConnection | undefined>(undefined);
const [state, setState] = useState<States>(States.WAITING);
const clientInfo = useMemo(() => ({
id,
secret,
}), [id]);
const send = useCallback(async (message: any) => {
if (!connection) return;
const { startMsg, updateMsgs } = formatMessage(message);
addMessage(startMsg, true);
connection.send(await encrypt(startMsg));
for (let updateMsg of updateMsgs) {
connection.send(await encrypt(updateMsg));
addMessage(updateMsg);
}
}, [connection, encrypt]);
const connect = useCallback(async (clientInfo: any) => {
setState(States.CONNECTING);
const newConnection = peer.connect(clientInfo.id);
newConnection.on('open', () => {
setSecret(clientInfo.secret);
setState(States.CONNECTED);
setConnection(newConnection);
});
}, [peer]);
useEffect(() => {
if (connection) {
return;
}
const onConnect = (newConnection: DataConnection) => {
setState(States.CONNECTED);
setConnection(newConnection);
};
peer.on('connection', onConnect);
return () => {
peer.off('connection', onConnect);
};
}, [peer, connection]);
useEffect(() => {
if (!connection) {
return;
}
const handleData = async (encrypted: any) => {
const message = await decrypt(encrypted);
addMessage(message, false);
};
connection.on('data', handleData);
return () => {
connection.off('data', handleData);
}
}, [connection, decrypt]);
return (
<ConnectionContext.Provider
value={{
clientInfo,
state,
messages,
send,
connect,
}}
>
{children}
</ConnectionContext.Provider>
);
};
export { States, ConnectionProvider };
export default ConnectionContext;

View File

@@ -0,0 +1,54 @@
import React, { createContext, ReactNode, useState, useEffect, useCallback } from 'react';
import Session, { State } from '../Session';
interface SessionContextValue {
messages: any[];
send: (data: any) => Promise<void>;
connectInfo: any;
state: State;
connect: (id: string, secret: string) => any;
}
interface SessionProviderProps {
session: Session;
children: ReactNode;
}
type Message = any;
const SessionContext = createContext<SessionContextValue>(undefined as any);
const SessionProvider: React.FC<SessionProviderProps> = ({ session, children }) => {
const [messages, setMessages] = useState<Message[]>(session.messages);
const [state, setState] = useState<State>(session.state);
const update = useCallback(() => {
setMessages(session.messages);
setState(session.state);
}, [session]);
useEffect(() => {
session.on('updated', update);
return () => {
session.off('updated', update);
}
}, [session, update]);
return (
<SessionContext.Provider
value={{
send: session.send,
messages,
connectInfo: session.connectInfo,
state,
connect: session.connect,
}}
>
{children}
</SessionContext.Provider>
)
};
export { SessionProvider };
export default SessionContext;

View File

@@ -0,0 +1,42 @@
import { nanoid } from 'nanoid';
import React, { createContext, useState, useCallback } from 'react';
import Session from '../Session';
interface ConnectionInfo {
id: string;
secret: string;
}
interface SessionsContextValue {
sessions: Session[];
addSession: (name?: string, id?: string, secret?: string) => void;
}
const SessionsContext = createContext<SessionsContextValue>(undefined as any);
const SessionsProvider: React.FC = ({ children }) => {
const [sessions, setSessions] = useState<Session[]>([]);
const addSession = useCallback(() => {
const session = new Session('Unnamed session', nanoid(), nanoid());
setSessions(current => [
...current,
session,
]);
}, []);
return (
<SessionsContext.Provider
value={{
sessions,
addSession,
}}
>
{children}
</SessionsContext.Provider>
);
};
export { ConnectionInfo, SessionsProvider };
export default SessionsContext;

View File

@@ -1,11 +0,0 @@
import { useContext } from 'react';
import ConnectionContext, { States } from '../contexts/ConnectionContext';
const useConnection = () => {
const context = useContext(ConnectionContext);
return context;
};
export const ConnectionStates = States;
export default useConnection;

View File

@@ -1,22 +0,0 @@
import { useCallback } from 'react';
import { encrypt, decrypt } from '../utils/crypto';
const useCrypto = (secret: string) => {
const doEncrypt = useCallback(async (data: any) => {
const raw = JSON.stringify(data);
const result = await encrypt(raw, secret);
return result;
}, [secret]);
const doDecrypt = useCallback(async (data: string[]) => {
return decrypt(data, secret);
}, [secret]);
return {
encrypt: doEncrypt,
decrypt: doDecrypt,
};
}
export default useCrypto;

12
src/hooks/useSession.ts Normal file
View File

@@ -0,0 +1,12 @@
import { useContext } from 'react';
import { State } from '../Session';
import SessionContext from '../contexts/SessionContext';
const useSession = () => {
const context = useContext(SessionContext);
return context;
};
export { State };
export default useSession;

9
src/hooks/useSessions.ts Normal file
View File

@@ -0,0 +1,9 @@
import { useContext } from 'react';
import SessionsContext from '../contexts/SessionsContext';
const useSessions = () => {
const context = useContext(SessionsContext);
return context;
};
export default useSessions;

View File

@@ -1,13 +1,13 @@
import React from 'react';
import { render } from 'react-dom';
import { ConnectionProvider } from './contexts/ConnectionContext';
import { SessionsProvider } from './contexts/SessionsContext';
import App from './App';
const root = document.getElementById('root');
const app = (
<ConnectionProvider>
<SessionsProvider>
<App />
</ConnectionProvider>
</SessionsProvider>
);
render(app, root);