mirror of
https://github.com/morten-olsen/catpic.delivery.git
synced 2026-02-08 01:46:26 +01:00
Multi session support
This commit is contained in:
30
src/App.tsx
30
src/App.tsx
@@ -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
30
src/Crypto/index.tsx
Normal 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;
|
||||
@@ -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
121
src/Session/index.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
24
src/containers/Session.tsx
Normal file
24
src/containers/Session.tsx
Normal 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;
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
54
src/contexts/SessionContext.tsx
Normal file
54
src/contexts/SessionContext.tsx
Normal 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;
|
||||
42
src/contexts/SessionsContext.tsx
Normal file
42
src/contexts/SessionsContext.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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
12
src/hooks/useSession.ts
Normal 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
9
src/hooks/useSessions.ts
Normal 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;
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user