diff --git a/src/App.tsx b/src/App.tsx index d47b976..30f3843 100644 --- a/src/App.tsx +++ b/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 - } - if (state === ConnectionStates.CONNECTED) { - return + useEffect(() => { + if (sessions.length === 0) { + addSession() + } + }, [sessions.length]); + + if (sessions.length === 0) { + return
Setting up
} + return ( -
Connected
- ); + <> + {sessions.map((session) => ( + + + + ))} + + ) }; export default App; diff --git a/src/Crypto/index.tsx b/src/Crypto/index.tsx new file mode 100644 index 0000000..09d848d --- /dev/null +++ b/src/Crypto/index.tsx @@ -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; diff --git a/src/hooks/useMessages.ts b/src/MessageList.ts similarity index 66% rename from src/hooks/useMessages.ts rename to src/MessageList.ts index afa84f5..4596cfa 100644 --- a/src/hooks/useMessages.ts +++ b/src/MessageList.ts @@ -1,5 +1,5 @@ +import EventEmitter from 'eventemitter3'; import { nanoid } from 'nanoid'; -import { useState, useCallback } from 'react'; interface BaseRequest { type: string; @@ -82,40 +82,48 @@ const updateMessage = ( }; }; -const useMessages = (postProcess: (input: any) => any) => { - const [messages, setMessage] = useState([]); +class MessageList extends EventEmitter { + #messages: Message[] = []; + #postProcess: (a: any) => any; - const addMessage = useCallback((request: Request, self: boolean) => { - setMessage((current) => { - if (request.type === 'start-message') { - const message: IncompleteMessage = { - id: request.payload.id, - type: 'incomplete', - self, - length: request.payload.length, - current: 0, - parts: [], - }; - return [ - ...current, - message, - ]; - } + constructor(postProces: (a: any) => any = (a) => a) { + super(); + this.#postProcess = postProces; + } - if (request.type === 'update-message') { - return current.map(message => { - if (message.id !== request.payload.id) { - return message; - } - return updateMessage(message, request, postProcess); - }); - } + get list() { + return this.#messages; + } - return current; - }); - }, []); + addMessage = (request: Request, self: boolean) => { + if (request.type === 'start-message') { + const message: IncompleteMessage = { + id: request.payload.id, + type: 'incomplete', + self, + length: request.payload.length, + current: 0, + parts: [], + }; + this.#messages = [ + ...this.#messages, + message, + ]; + this.emit('updated'); + } - const formatMessage = (msg: any) => { + if (request.type === 'update-message') { + this.#messages = this.#messages.map(message => { + if (message.id !== request.payload.id) { + return message; + } + return updateMessage(message, request, this.#postProcess); + }); + this.emit('updated'); + } + }; + + 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; diff --git a/src/Session/index.tsx b/src/Session/index.tsx new file mode 100644 index 0000000..41a2aa1 --- /dev/null +++ b/src/Session/index.tsx @@ -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; diff --git a/src/containers/Connected.tsx b/src/containers/Connected.tsx index e4db71a..c763e53 100644 --- a/src/containers/Connected.tsx +++ b/src/containers/Connected.tsx @@ -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({ files: [], text: '', @@ -48,7 +48,9 @@ const Connected: React.FC<{}> = () => { Loading {Math.round(message.current / message.length * 100)}% )))} - + { state === State.CONNECTED && ( + + )} ); } diff --git a/src/containers/Session.tsx b/src/containers/Session.tsx new file mode 100644 index 0000000..21fc755 --- /dev/null +++ b/src/containers/Session.tsx @@ -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 ( + + ); + } + + if (state === State.CONNECTED) { + return + } + + return ( +
{state.toString()}
+ ) +}; + +export default Session; diff --git a/src/containers/Welcome.tsx b/src/containers/Welcome.tsx index 83d4fc5..6a1c12d 100644 --- a/src/containers/Welcome.tsx +++ b/src/containers/Welcome.tsx @@ -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<{}> = () => { + {JSON.stringify(connectInfo)} {mode === 'view' && ( )} diff --git a/src/contexts/ConnectionContext.tsx b/src/contexts/ConnectionContext.tsx deleted file mode 100644 index 406af6c..0000000 --- a/src/contexts/ConnectionContext.tsx +++ /dev/null @@ -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; - connect: (connectionInfo: any) => Promise; -} - -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(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(undefined); - const [state, setState] = useState(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 ( - - {children} - - ); -}; - -export { States, ConnectionProvider }; - -export default ConnectionContext; diff --git a/src/contexts/SessionContext.tsx b/src/contexts/SessionContext.tsx new file mode 100644 index 0000000..fb3f9ce --- /dev/null +++ b/src/contexts/SessionContext.tsx @@ -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; + connectInfo: any; + state: State; + connect: (id: string, secret: string) => any; +} + +interface SessionProviderProps { + session: Session; + children: ReactNode; +} + +type Message = any; + +const SessionContext = createContext(undefined as any); + +const SessionProvider: React.FC = ({ session, children }) => { + const [messages, setMessages] = useState(session.messages); + const [state, setState] = useState(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 ( + + {children} + + ) +}; + +export { SessionProvider }; + +export default SessionContext; diff --git a/src/contexts/SessionsContext.tsx b/src/contexts/SessionsContext.tsx new file mode 100644 index 0000000..5ef497c --- /dev/null +++ b/src/contexts/SessionsContext.tsx @@ -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(undefined as any); + +const SessionsProvider: React.FC = ({ children }) => { + const [sessions, setSessions] = useState([]); + + const addSession = useCallback(() => { + const session = new Session('Unnamed session', nanoid(), nanoid()); + setSessions(current => [ + ...current, + session, + ]); + }, []); + + return ( + + {children} + + ); +}; + +export { ConnectionInfo, SessionsProvider }; + +export default SessionsContext; diff --git a/src/hooks/useConnection.ts b/src/hooks/useConnection.ts deleted file mode 100644 index 0b600d6..0000000 --- a/src/hooks/useConnection.ts +++ /dev/null @@ -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; diff --git a/src/hooks/useCrypto.ts b/src/hooks/useCrypto.ts deleted file mode 100644 index 8656da8..0000000 --- a/src/hooks/useCrypto.ts +++ /dev/null @@ -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; diff --git a/src/hooks/useSession.ts b/src/hooks/useSession.ts new file mode 100644 index 0000000..9ce8b06 --- /dev/null +++ b/src/hooks/useSession.ts @@ -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; diff --git a/src/hooks/useSessions.ts b/src/hooks/useSessions.ts new file mode 100644 index 0000000..3805eab --- /dev/null +++ b/src/hooks/useSessions.ts @@ -0,0 +1,9 @@ +import { useContext } from 'react'; +import SessionsContext from '../contexts/SessionsContext'; + +const useSessions = () => { + const context = useContext(SessionsContext); + return context; +}; + +export default useSessions; diff --git a/src/index.tsx b/src/index.tsx index 0358b67..bf6c491 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -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 = ( - + - + ); render(app, root);