diff --git a/chat/serializers.py b/chat/serializers.py index 76d95454..b1f5dbfc 100644 --- a/chat/serializers.py +++ b/chat/serializers.py @@ -18,7 +18,7 @@ class ChatSerializer(serializers.ModelSerializer): class PostMessageSerializer(serializers.ModelSerializer): class Meta: model = Message - fields = ("PGP_message", "order", "offset") + fields = ("PGP_message", "order_id", "offset") depth = 0 offset = serializers.IntegerField( diff --git a/chat/views.py b/chat/views.py index 1b11617d..5d6b7b34 100644 --- a/chat/views.py +++ b/chat/views.py @@ -58,6 +58,7 @@ class ChatView(viewsets.ViewSet): chatroom.maker_connected = True chatroom.save() peer_connected = chatroom.taker_connected + peer_public_key = order.taker.profile.public_key elif chatroom.taker == request.user: chatroom.maker_connected = order.maker_last_seen > ( timezone.now() - timedelta(minutes=1) @@ -65,6 +66,7 @@ class ChatView(viewsets.ViewSet): chatroom.taker_connected = True chatroom.save() peer_connected = chatroom.maker_connected + peer_public_key = order.maker.profile.public_key messages = [] for message in queryset: @@ -79,7 +81,7 @@ class ChatView(viewsets.ViewSet): } messages.append(data) - response = {"peer_connected": peer_connected, "messages": messages} + response = {"peer_connected": peer_connected, "messages": messages, "peer_pubkey": peer_public_key} return Response(response, status.HTTP_200_OK) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4c5ddc67..7e209c8a 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "frontend", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "frontend", - "version": "0.2.2", + "version": "0.2.3", "license": "ISC", "dependencies": { "@babel/plugin-proposal-class-properties": "^7.16.7", diff --git a/frontend/src/basic/OrderPage/index.js b/frontend/src/basic/OrderPage/index.js index 26cc59db..dad6b6b4 100644 --- a/frontend/src/basic/OrderPage/index.js +++ b/frontend/src/basic/OrderPage/index.js @@ -66,6 +66,7 @@ class OrderPage extends Component { openStoreToken: false, tabValue: 1, orderId: this.props.match.params.orderId, + chat_offset: 0, }; // Refresh delays according to Order status @@ -109,6 +110,7 @@ class OrderPage extends Component { currencyCode: this.getCurrencyCode(newStateVars.currency), penalty: newStateVars.penalty, // in case penalty time has finished, it goes back to null invoice_expired: newStateVars.invoice_expired, // in case invoice had expired, it goes back to null when it is valid again + chat_offset: this.state.chat_offset + newStateVars?.chat?.messages.length, }; const completeStateVars = Object.assign({}, newStateVars, otherStateVars); @@ -117,7 +119,10 @@ class OrderPage extends Component { getOrderDetails = (id) => { this.setState({ orderId: id }); - apiClient.get('/api/order/?order_id=' + id).then(this.orderDetailsReceived); + + apiClient + .get('/api/order/?order_id=' + id + '&offset=' + this.state.chat_offset) + .then(this.orderDetailsReceived); }; orderDetailsReceived = (data) => { diff --git a/frontend/src/components/TradeBox/EncryptedChat/ChatBottom/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/ChatBottom/index.tsx new file mode 100644 index 00000000..a1de80b2 --- /dev/null +++ b/frontend/src/components/TradeBox/EncryptedChat/ChatBottom/index.tsx @@ -0,0 +1,61 @@ +import React, { useState } from 'react'; +import { Grid, useTheme, Tooltip, Button } from '@mui/material'; +import { ExportIcon } from '../../../Icons'; +import KeyIcon from '@mui/icons-material/Key'; +import { useTranslation } from 'react-i18next'; +import { saveAsJson } from '../../../../utils'; + +interface Props { + orderId: number; + setAudit: (audit: boolean) => void; + audit: boolean; + createJsonFile: () => object; +} + +const ChatBottom: React.FC = ({ orderId, setAudit, audit, createJsonFile }) => { + const { t } = useTranslation(); + const theme = useTheme(); + + return ( + <> + + + + + + + + + + + + + ); +}; + +export default ChatBottom; diff --git a/frontend/src/components/TradeBox/EncryptedChat/ChatHeader/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/ChatHeader/index.tsx new file mode 100644 index 00000000..5a0a07fe --- /dev/null +++ b/frontend/src/components/TradeBox/EncryptedChat/ChatHeader/index.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Grid, Paper, Typography, useTheme } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +interface Props { + connected: boolean; + peerConnected: boolean; +} + +const ChatHeader: React.FC = ({ connected, peerConnected }) => { + const { t } = useTranslation(); + const theme = useTheme(); + const connectedColor = theme.palette.mode === 'light' ? '#b5e3b7' : '#153717'; + const connectedTextColor = theme.palette.getContrastText(connectedColor); + + return ( + + + + + + {t('You') + ': '} + {connected ? t('connected') : t('disconnected')} + + + + + + + + {t('Peer') + ': '} + {peerConnected ? t('connected') : t('disconnected')} + + + + + + ); +}; + +export default ChatHeader; diff --git a/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx new file mode 100644 index 00000000..d2192962 --- /dev/null +++ b/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx @@ -0,0 +1,328 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Tooltip, TextField, Grid, Container, Paper, Typography } from '@mui/material'; +import { encryptMessage, decryptMessage } from '../../../../pgp'; +import { saveAsJson } from '../../../../utils'; +import { AuditPGPDialog } from '../../../Dialogs'; +import { systemClient } from '../../../../services/System'; +import { websocketClient, WebsocketConnection } from '../../../../services/Websocket'; + +// Icons +import CircularProgress from '@mui/material/CircularProgress'; +import KeyIcon from '@mui/icons-material/Key'; +import { useTheme } from '@mui/system'; +import MessageCard from '../MessageCard'; +import ChatHeader from '../ChatHeader'; +import { EncryptedChatMessage, ServerMessage } from '..'; +import ChatBottom from '../ChatBottom'; + +interface Props { + orderId: number; + userNick: string; + takerNick: string; + messages: EncryptedChatMessage[]; + setMessages: (messages: EncryptedChatMessage[]) => void; +} + +const EncryptedSocketChat: React.FC = ({ + orderId, + userNick, + takerNick, + messages, + setMessages, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const theme = useTheme(); + + const audio = new Audio(`/static/assets/sounds/chat-open.mp3`); + const [connected, setConnected] = useState(false); + const [peerConnected, setPeerConnected] = useState(false); + const [ownPubKey] = useState( + (systemClient.getCookie('pub_key') ?? '').split('\\').join('\n'), + ); + const [ownEncPrivKey] = useState( + (systemClient.getCookie('enc_priv_key') ?? '').split('\\').join('\n'), + ); + const [peerPubKey, setPeerPubKey] = useState(); + const [token] = useState(systemClient.getCookie('robot_token') || ''); + const [serverMessages, setServerMessages] = useState([]); + const [value, setValue] = useState(''); + const [connection, setConnection] = useState(); + const [audit, setAudit] = useState(false); + const [waitingEcho, setWaitingEcho] = useState(false); + const [lastSent, setLastSent] = useState('---BLANK---'); + const [messageCount, setMessageCount] = useState(0); + const [receivedIndexes, setReceivedIndexes] = useState([]); + + useEffect(() => { + if (!connected) { + connectWebsocket(); + } + }, [connected]); + + useEffect(() => { + if (messages.length > messageCount) { + audio.play(); + setMessageCount(messages.length); + } + }, [messages, messageCount]); + + useEffect(() => { + if (serverMessages) { + serverMessages.forEach(onMessage); + } + }, [serverMessages]); + + const connectWebsocket = () => { + websocketClient.open(`ws://${window.location.host}/ws/chat/${orderId}/`).then((connection) => { + setConnection(connection); + setConnected(true); + + connection.send({ + message: ownPubKey, + nick: userNick, + }); + + connection.onMessage((message) => setServerMessages((prev) => [...prev, message])); + connection.onClose(() => setConnected(false)); + connection.onError(() => setConnected(false)); + }); + }; + + const createJsonFile: () => object = () => { + return { + credentials: { + own_public_key: ownPubKey, + peer_public_key: peerPubKey, + encrypted_private_key: ownEncPrivKey, + passphrase: token, + }, + messages: messages, + }; + }; + + const onMessage: (message: any) => void = (message) => { + const dataFromServer = JSON.parse(message.data); + + if (dataFromServer && !receivedIndexes.includes(dataFromServer.index)) { + setReceivedIndexes((prev) => [...prev, dataFromServer.index]); + setPeerConnected(dataFromServer.peer_connected); + // If we receive a public key other than ours (our peer key!) + if ( + connection && + dataFromServer.message.substring(0, 36) == `-----BEGIN PGP PUBLIC KEY BLOCK-----` && + dataFromServer.message != ownPubKey + ) { + setPeerPubKey(dataFromServer.message); + connection.send({ + message: `-----SERVE HISTORY-----`, + nick: userNick, + }); + } + // If we receive an encrypted message + else if (dataFromServer.message.substring(0, 27) == `-----BEGIN PGP MESSAGE-----`) { + decryptMessage( + dataFromServer.message.split('\\').join('\n'), + dataFromServer.user_nick == userNick ? ownPubKey : peerPubKey, + ownEncPrivKey, + token, + ).then((decryptedData) => { + setWaitingEcho(waitingEcho ? decryptedData.decryptedMessage !== lastSent : false); + setLastSent(decryptedData.decryptedMessage === lastSent ? '----BLANK----' : lastSent); + setMessages((prev) => { + const existingMessage = prev.find((item) => item.index === dataFromServer.index); + if (existingMessage) { + return prev; + } else { + return [ + ...prev, + { + index: dataFromServer.index, + encryptedMessage: dataFromServer.message.split('\\').join('\n'), + plainTextMessage: decryptedData.decryptedMessage, + validSignature: decryptedData.validSignature, + userNick: dataFromServer.user_nick, + time: dataFromServer.time, + } as EncryptedChatMessage, + ].sort((a, b) => a.index - b.index); + } + }); + }); + } + // We allow plaintext communication. The user must write # to start + // If we receive an plaintext message + else if (dataFromServer.message.substring(0, 1) == '#') { + setMessages((prev: EncryptedChatMessage[]) => { + const existingMessage = prev.find( + (item) => item.plainTextMessage === dataFromServer.message, + ); + if (existingMessage) { + return prev; + } else { + return [ + ...prev, + { + index: prev.length + 0.001, + encryptedMessage: dataFromServer.message, + plainTextMessage: dataFromServer.message, + validSignature: false, + userNick: dataFromServer.user_nick, + time: new Date().toString(), + } as EncryptedChatMessage, + ].sort((a, b) => a.index - b.index); + } + }); + } + } + }; + + const onButtonClicked = (e: any) => { + if (token && value.indexOf(token) !== -1) { + alert( + `Aye! You just sent your own robot token to your peer in chat, that's a catastrophic idea! So bad your message was blocked.`, + ); + setValue(''); + } + // If input string contains '#' send unencrypted and unlogged message + else if (connection && value.substring(0, 1) == '#') { + connection.send({ + message: value, + nick: userNick, + }); + setValue(''); + } + + // Else if message is not empty send message + else if (value != '') { + setValue(''); + setWaitingEcho(true); + setLastSent(value); + encryptMessage(value, ownPubKey, peerPubKey, ownEncPrivKey, token).then( + (encryptedMessage) => { + if (connection) { + connection.send({ + message: encryptedMessage.toString().split('\n').join('\\'), + nick: userNick, + }); + } + }, + ); + } + e.preventDefault(); + }; + + return ( + + +
+ + {messages.map((message, index) => { + const isTaker = takerNick === message.userNick; + const userConnected = message.userNick === userNick ? connected : peerConnected; + + return ( +
  • + +
  • + ); + })} +
    { + if (messages.length > messageCount) el?.scrollIntoView(); + }} + /> + +
    + + + { + setValue(e.target.value); + }} + sx={{ width: '13.7em' }} + /> + + + + + +
    +
    + +
    + + + setAudit(false)} + orderId={Number(orderId)} + messages={messages} + own_pub_key={ownPubKey || ''} + own_enc_priv_key={ownEncPrivKey || ''} + peer_pub_key={peerPubKey || 'Not received yet'} + passphrase={token || ''} + onClickBack={() => setAudit(false)} + /> + + + + + ); +}; + +export default EncryptedSocketChat; diff --git a/frontend/src/components/TradeBox/EncryptedChat/EncryptedTrutleChat/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/EncryptedTrutleChat/index.tsx new file mode 100644 index 00000000..bc1c79c4 --- /dev/null +++ b/frontend/src/components/TradeBox/EncryptedChat/EncryptedTrutleChat/index.tsx @@ -0,0 +1,303 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Tooltip, TextField, Grid, Container, Paper, Typography } from '@mui/material'; +import { encryptMessage, decryptMessage } from '../../../../pgp'; +import { saveAsJson } from '../../../../utils'; +import { AuditPGPDialog } from '../../../Dialogs'; +import { systemClient } from '../../../../services/System'; +import { websocketClient, WebsocketConnection } from '../../../../services/Websocket'; + +// Icons +import CircularProgress from '@mui/material/CircularProgress'; +import KeyIcon from '@mui/icons-material/Key'; +import { ExportIcon } from '../../../Icons'; +import { useTheme } from '@mui/system'; +import MessageCard from '../MessageCard'; +import ChatHeader from '../ChatHeader'; +import { EncryptedChatMessage, ServerMessage } from '..'; +import { apiClient } from '../../../../services/api'; +import ChatBottom from '../ChatBottom'; + +interface Props { + orderId: number; + userNick: string; + takerNick: string; + chatOffset: number; + messages: EncryptedChatMessage[]; + setMessages: (messages: EncryptedChatMessage[]) => void; +} + +const EncryptedTurtleChat: React.FC = ({ + orderId, + userNick, + takerNick, + chatOffset, + messages, + setMessages, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const theme = useTheme(); + + const audio = new Audio(`/static/assets/sounds/chat-open.mp3`); + const [peerConnected, setPeerConnected] = useState(false); + const [ownPubKey] = useState( + (systemClient.getCookie('pub_key') ?? '').split('\\').join('\n'), + ); + const [ownEncPrivKey] = useState( + (systemClient.getCookie('enc_priv_key') ?? '').split('\\').join('\n'), + ); + const [peerPubKey, setPeerPubKey] = useState(); + const [token] = useState(systemClient.getCookie('robot_token') || ''); + const [value, setValue] = useState(''); + const [audit, setAudit] = useState(false); + const [waitingEcho, setWaitingEcho] = useState(false); + const [lastSent, setLastSent] = useState('---BLANK---'); + const [messageCount, setMessageCount] = useState(0); + const [serverMessages, setServerMessages] = useState([]); + const [lastIndex, setLastIndex] = useState(0); + + useEffect(() => { + if (messages.length > messageCount) { + audio.play(); + setMessageCount(messages.length); + } + }, [messages, messageCount]); + + useEffect(() => { + if (serverMessages.length > 0 && peerPubKey) { + serverMessages.forEach(onMessage); + } + }, [serverMessages, peerPubKey]); + + useEffect(() => { + if (chatOffset > lastIndex) { + loadMessages(); + } + }, [chatOffset]); + + const loadMessages: () => void = () => { + apiClient.get(`/api/chat?order_id=${orderId}&offset=${lastIndex}`).then((results: any) => { + if (results) { + setPeerConnected(results.peer_connected); + setPeerPubKey(results.peer_public_key); + setServerMessages(results.messages); + } + }); + }; + + const createJsonFile: () => object = () => { + return { + credentials: { + own_public_key: ownPubKey, + peer_public_key: peerPubKey, + encrypted_private_key: ownEncPrivKey, + passphrase: token, + }, + messages: messages, + }; + }; + + const onMessage: (dataFromServer: ServerMessage) => void = (dataFromServer) => { + if (dataFromServer) { + // If we receive an encrypted message + if (dataFromServer.message.substring(0, 27) == `-----BEGIN PGP MESSAGE-----`) { + decryptMessage( + dataFromServer.message.split('\\').join('\n'), + dataFromServer.nick == userNick ? ownPubKey : peerPubKey, + ownEncPrivKey, + token, + ).then((decryptedData) => { + setLastSent(decryptedData.decryptedMessage === lastSent ? '----BLANK----' : lastSent); + setLastIndex(lastIndex < dataFromServer.index ? dataFromServer.index : lastIndex); + setMessages((prev: EncryptedChatMessage[]) => { + const existingMessage = prev.find((item) => item.index === dataFromServer.index); + if (existingMessage) { + return prev; + } else { + return [ + ...prev, + { + index: dataFromServer.index, + encryptedMessage: dataFromServer.message.split('\\').join('\n'), + plainTextMessage: decryptedData.decryptedMessage, + validSignature: decryptedData.validSignature, + userNick: dataFromServer.nick, + time: dataFromServer.time, + } as EncryptedChatMessage, + ].sort((a, b) => a.index - b.index); + } + }); + }); + } + // We allow plaintext communication. The user must write # to start + // If we receive an plaintext message + else if (dataFromServer.message.substring(0, 1) == '#') { + setMessages((prev) => { + const existingMessage = prev.find( + (item) => item.plainTextMessage === dataFromServer.message, + ); + if (existingMessage) { + return prev; + } else { + return [ + ...prev, + { + index: prev.length + 0.001, + encryptedMessage: dataFromServer.message, + plainTextMessage: dataFromServer.message, + validSignature: false, + userNick: dataFromServer.nick, + time: new Date().toString(), + } as EncryptedChatMessage, + ].sort((a, b) => a.index - b.index); + } + }); + } + } + }; + + const onButtonClicked = (e: any) => { + if (token && value.indexOf(token) !== -1) { + alert( + `Aye! You just sent your own robot token to your peer in chat, that's a catastrophic idea! So bad your message was blocked.`, + ); + setValue(''); + } + // If input string contains '#' send unencrypted and unlogged message + else if (value.substring(0, 1) == '#') { + apiClient + .post(`/api/chat`, { + PGP_message: value, + }) + .finally(() => { + setWaitingEcho(false); + setValue(''); + }); + } + // Else if message is not empty send message + else if (value != '') { + setWaitingEcho(true); + setLastSent(value); + encryptMessage(value, ownPubKey, peerPubKey, ownEncPrivKey, token).then( + (encryptedMessage) => { + apiClient + .post(`/api/chat`, { + PGP_message: encryptedMessage.toString().split('\n').join('\\'), + }) + .finally(() => { + setWaitingEcho(false); + setValue(''); + }); + }, + ); + } + e.preventDefault(); + }; + + return ( + + +
    + + {messages.map((message, index) => { + const isTaker = takerNick === message.userNick; + const userConnected = message.userNick === userNick ? true : peerConnected; + + return ( +
  • + +
  • + ); + })} +
    { + if (messages.length > messageCount) el?.scrollIntoView(); + }} + /> + +
    + + + { + setValue(e.target.value); + }} + sx={{ width: '13.7em' }} + /> + + + + + +
    +
    + +
    + + + setAudit(false)} + orderId={Number(orderId)} + messages={messages} + own_pub_key={ownPubKey || ''} + own_enc_priv_key={ownEncPrivKey || ''} + peer_pub_key={peerPubKey || 'Not received yet'} + passphrase={token || ''} + onClickBack={() => setAudit(false)} + /> + + + + ); +}; + +export default EncryptedTurtleChat; diff --git a/frontend/src/components/TradeBox/EncryptedChat/MessageCard/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/MessageCard/index.tsx new file mode 100644 index 00000000..b89ae749 --- /dev/null +++ b/frontend/src/components/TradeBox/EncryptedChat/MessageCard/index.tsx @@ -0,0 +1,142 @@ +import React, { useState } from 'react'; +import { IconButton, Tooltip, Card, CardHeader, useTheme } from '@mui/material'; +import RobotAvatar from '../../../RobotAvatar'; +import { systemClient } from '../../../../services/System'; +import { useTranslation } from 'react-i18next'; + +// Icons +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; +import ContentCopy from '@mui/icons-material/ContentCopy'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import { EncryptedChatMessage } from '..'; + +interface Props { + message: EncryptedChatMessage; + isTaker: boolean; + userConnected: boolean; +} + +const MessageCard: React.FC = ({ message, isTaker, userConnected }) => { + const [showPGP, setShowPGP] = useState(); + const { t } = useTranslation(); + const theme = useTheme(); + + const takerCardColor = theme.palette.mode === 'light' ? '#d1e6fa' : '#082745'; + const makerCardColor = theme.palette.mode === 'light' ? '#f2d5f6' : '#380d3f'; + const cardColor = isTaker ? takerCardColor : makerCardColor; + + return ( + + + } + style={{ backgroundColor: cardColor }} + title={ + +
    +
    + {message.userNick} + {message.validSignature ? ( + + ) : ( + + )} +
    +
    + setShowPGP(!showPGP)} + > + + +
    +
    + + + systemClient.copyToClipboard( + showPGP ? message.encryptedMessage : message.plainTextMessage, + ) + } + > + + + +
    +
    +
    + } + subheader={ + showPGP ? ( + + {' '} + {message.time}
    {'Valid signature: ' + message.validSignature}
    {' '} + {message.encryptedMessage}{' '} +
    + ) : ( + message.plainTextMessage + ) + } + subheaderTypographyProps={{ + sx: { + wordWrap: 'break-word', + width: '14.3em', + position: 'relative', + right: '1.5em', + textAlign: 'left', + fontSize: showPGP ? theme.typography.fontSize * 0.78 : null, + }, + }} + /> +
    + ); +}; + +export default MessageCard; diff --git a/frontend/src/components/TradeBox/EncryptedChat/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/index.tsx index 53041e7f..2739d86f 100644 --- a/frontend/src/components/TradeBox/EncryptedChat/index.tsx +++ b/frontend/src/components/TradeBox/EncryptedChat/index.tsx @@ -1,40 +1,16 @@ -import React, { useEffect, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { - Button, - IconButton, - Tooltip, - TextField, - Grid, - Container, - Card, - CardHeader, - Paper, - Typography, -} from '@mui/material'; -import { encryptMessage, decryptMessage } from '../../../pgp'; -import { saveAsJson } from '../../../utils'; -import { AuditPGPDialog } from '../../Dialogs'; -import RobotAvatar from '../../RobotAvatar'; -import { systemClient } from '../../../services/System'; -import { websocketClient, WebsocketConnection } from '../../../services/Websocket'; - -// Icons -import CheckIcon from '@mui/icons-material/Check'; -import CloseIcon from '@mui/icons-material/Close'; -import ContentCopy from '@mui/icons-material/ContentCopy'; -import VisibilityIcon from '@mui/icons-material/Visibility'; -import CircularProgress from '@mui/material/CircularProgress'; -import KeyIcon from '@mui/icons-material/Key'; -import { ExportIcon } from '../../Icons'; -import { useTheme } from '@mui/system'; +import React, { useState } from 'react'; +import EncryptedSocketChat from './EncryptedSocketChat'; +import EncryptedTrutleChat from './EncryptedTrutleChat'; interface Props { + turtleMode: boolean; orderId: number; + takerNick: string; + makerNick: string; userNick: string; + chatOffset: number; } - -interface EncryptedChatMessage { +export interface EncryptedChatMessage { userNick: string; validSignature: boolean; plainTextMessage: string; @@ -42,481 +18,39 @@ interface EncryptedChatMessage { time: string; index: number; } +export interface ServerMessage { + message: string; + time: string; + index: number; + nick: string; +} -const EncryptedChat: React.FC = ({ orderId, userNick }: Props): JSX.Element => { - const { t } = useTranslation(); - const theme = useTheme(); - - const audio = new Audio(`/static/assets/sounds/chat-open.mp3`); - const [connected, setConnected] = useState(false); - const [peerConnected, setPeerConnected] = useState(false); - const [ownPubKey] = useState( - (systemClient.getCookie('pub_key') ?? '').split('\\').join('\n'), - ); - const [ownEncPrivKey] = useState( - (systemClient.getCookie('enc_priv_key') ?? '').split('\\').join('\n'), - ); - const [peerPubKey, setPeerPubKey] = useState(); - const [token] = useState(systemClient.getCookie('robot_token') || ''); +const EncryptedChat: React.FC = ({ + turtleMode, + orderId, + takerNick, + userNick, + chatOffset, +}: Props): JSX.Element => { const [messages, setMessages] = useState([]); - const [serverMessages, setServerMessages] = useState([]); - const [value, setValue] = useState(''); - const [connection, setConnection] = useState(); - const [audit, setAudit] = useState(false); - const [showPGP, setShowPGP] = useState([]); - const [waitingEcho, setWaitingEcho] = useState(false); - const [lastSent, setLastSent] = useState('---BLANK---'); - const [messageCount, setMessageCount] = useState(0); - const [receivedIndexes, setReceivedIndexes] = useState([]); - useEffect(() => { - if (!connected) { - connectWebsocket(); - } - }, [connected]); - - useEffect(() => { - if (messages.length > messageCount) { - audio.play(); - setMessageCount(messages.length); - } - }, [messages, messageCount]); - - useEffect(() => { - if (serverMessages) { - serverMessages.forEach(onMessage); - } - }, [serverMessages]); - - const connectWebsocket = () => { - websocketClient.open(`ws://${window.location.host}/ws/chat/${orderId}/`).then((connection) => { - setConnection(connection); - setConnected(true); - - connection.send({ - message: ownPubKey, - nick: userNick, - }); - - connection.onMessage((message) => setServerMessages((prev) => [...prev, message])); - connection.onClose(() => setConnected(false)); - connection.onError(() => setConnected(false)); - }); - }; - - const createJsonFile: () => object = () => { - return { - credentials: { - own_public_key: ownPubKey, - peer_public_key: peerPubKey, - encrypted_private_key: ownEncPrivKey, - passphrase: token, - }, - messages: messages, - }; - }; - - const onMessage: (message: any) => void = (message) => { - const dataFromServer = JSON.parse(message.data); - - if (dataFromServer && !receivedIndexes.includes(dataFromServer.index)) { - setReceivedIndexes((prev) => [...prev, dataFromServer.index]); - setPeerConnected(dataFromServer.peer_connected); - // If we receive a public key other than ours (our peer key!) - if ( - connection && - dataFromServer.message.substring(0, 36) == `-----BEGIN PGP PUBLIC KEY BLOCK-----` && - dataFromServer.message != ownPubKey - ) { - setPeerPubKey(dataFromServer.message); - connection.send({ - message: `-----SERVE HISTORY-----`, - nick: userNick, - }); - } - // If we receive an encrypted message - else if (dataFromServer.message.substring(0, 27) == `-----BEGIN PGP MESSAGE-----`) { - decryptMessage( - dataFromServer.message.split('\\').join('\n'), - dataFromServer.user_nick == userNick ? ownPubKey : peerPubKey, - ownEncPrivKey, - token, - ).then((decryptedData) => { - setWaitingEcho(waitingEcho ? decryptedData.decryptedMessage !== lastSent : false); - setLastSent(decryptedData.decryptedMessage === lastSent ? '----BLANK----' : lastSent); - setMessages((prev) => { - const existingMessage = prev.find((item) => item.index === dataFromServer.index); - if (existingMessage) { - return prev; - } else { - return [ - ...prev, - { - index: dataFromServer.index, - encryptedMessage: dataFromServer.message.split('\\').join('\n'), - plainTextMessage: decryptedData.decryptedMessage, - validSignature: decryptedData.validSignature, - userNick: dataFromServer.user_nick, - time: dataFromServer.time, - } as EncryptedChatMessage, - ].sort((a, b) => a.index - b.index); - } - }); - }); - } - // We allow plaintext communication. The user must write # to start - // If we receive an plaintext message - else if (dataFromServer.message.substring(0, 1) == '#') { - setMessages((prev) => { - const existingMessage = prev.find( - (item) => item.plainTextMessage === dataFromServer.message, - ); - if (existingMessage) { - return prev; - } else { - return [ - ...prev, - { - index: prev.length + 0.001, - encryptedMessage: dataFromServer.message, - plainTextMessage: dataFromServer.message, - validSignature: false, - userNick: dataFromServer.user_nick, - time: new Date().toString(), - } as EncryptedChatMessage, - ].sort((a, b) => a.index - b.index); - } - }); - } - } - }; - - const onButtonClicked = (e: any) => { - if (token && value.indexOf(token) !== -1) { - alert( - `Aye! You just sent your own robot token to your peer in chat, that's a catastrophic idea! So bad your message was blocked.`, - ); - setValue(''); - } - // If input string contains '#' send unencrypted and unlogged message - else if (connection && value.substring(0, 1) == '#') { - connection.send({ - message: value, - nick: userNick, - }); - setValue(''); - } - - // Else if message is not empty send message - else if (value != '') { - setValue(''); - setWaitingEcho(true); - setLastSent(value); - encryptMessage(value, ownPubKey, peerPubKey, ownEncPrivKey, token).then( - (encryptedMessage) => { - if (connection) { - connection.send({ - message: encryptedMessage.toString().split('\n').join('\\'), - nick: userNick, - }); - } - }, - ); - } - e.preventDefault(); - }; - - const messageCard: ( - message: EncryptedChatMessage, - index: number, - cardColor: string, - userConnected: boolean, - ) => JSX.Element = (message, index, cardColor, userConnected) => { - return ( - - - } - style={{ backgroundColor: cardColor }} - title={ - -
    -
    - {message.userNick} - {message.validSignature ? ( - - ) : ( - - )} -
    -
    - { - const newShowPGP = [...showPGP]; - newShowPGP[index] = !newShowPGP[index]; - setShowPGP(newShowPGP); - }} - > - - -
    -
    - - - systemClient.copyToClipboard( - showPGP[index] ? message.encryptedMessage : message.plainTextMessage, - ) - } - > - - - -
    -
    -
    - } - subheader={ - showPGP[index] ? ( - - {' '} - {message.time}
    {'Valid signature: ' + message.validSignature}
    {' '} - {message.encryptedMessage}{' '} -
    - ) : ( - message.plainTextMessage - ) - } - subheaderTypographyProps={{ - sx: { - wordWrap: 'break-word', - width: '14.3em', - position: 'relative', - right: '1.5em', - textAlign: 'left', - fontSize: showPGP[index] ? theme.typography.fontSize * 0.78 : null, - }, - }} - /> -
    - ); - }; - - const connectedColor = theme.palette.mode === 'light' ? '#b5e3b7' : '#153717'; - const connectedTextColor = theme.palette.getContrastText(connectedColor); - const ownCardColor = theme.palette.mode === 'light' ? '#d1e6fa' : '#082745'; - const peerCardColor = theme.palette.mode === 'light' ? '#f2d5f6' : '#380d3f'; - - return ( - - - - - - - {t('You') + ': '} - {connected ? t('connected') : t('disconnected')} - - - - - - - - {t('Peer') + ': '} - {peerConnected ? t('connected') : t('disconnected')} - - - - - -
    - - {messages.map((message, index) => ( -
  • - {message.userNick == userNick - ? messageCard(message, index, ownCardColor, connected) - : messageCard(message, index, peerCardColor, peerConnected)} -
  • - ))} -
    { - if (messages.length > messageCount) el?.scrollIntoView(); - }} - /> - -
    - - - { - setValue(e.target.value); - }} - sx={{ width: '13.7em' }} - /> - - - - - -
    -
    - -
    - - - setAudit(false)} - orderId={Number(orderId)} - messages={messages} - own_pub_key={ownPubKey || ''} - own_enc_priv_key={ownEncPrivKey || ''} - peer_pub_key={peerPubKey || 'Not received yet'} - passphrase={token || ''} - onClickBack={() => setAudit(false)} - /> - - - - - - - - - - - - - - + return turtleMode ? ( + + ) : ( + ); }; diff --git a/frontend/src/components/TradeBox/index.js b/frontend/src/components/TradeBox/index.js index c962bd65..5e5e8c0e 100644 --- a/frontend/src/components/TradeBox/index.js +++ b/frontend/src/components/TradeBox/index.js @@ -19,6 +19,7 @@ import { ListItem, ListItemText, Divider, + Switch, ListItemIcon, Dialog, DialogActions, @@ -37,6 +38,7 @@ import { apiClient } from '../../services/api'; // Icons import PercentIcon from '@mui/icons-material/Percent'; +import SelfImprovement from '@mui/icons-material/SelfImprovement'; import BookIcon from '@mui/icons-material/Book'; import LockIcon from '@mui/icons-material/Lock'; import LockOpenIcon from '@mui/icons-material/LockOpen'; @@ -47,6 +49,7 @@ import PlayCircleIcon from '@mui/icons-material/PlayCircle'; import BoltIcon from '@mui/icons-material/Bolt'; import LinkIcon from '@mui/icons-material/Link'; import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; +import WifiTetheringErrorIcon from '@mui/icons-material/WifiTetheringError'; import FavoriteIcon from '@mui/icons-material/Favorite'; import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; import RefreshIcon from '@mui/icons-material/Refresh'; @@ -72,6 +75,7 @@ class TradeBox extends Component { badInvoice: false, badAddress: false, badStatement: false, + turtle_mode: false, }; } @@ -1438,6 +1442,29 @@ class TradeBox extends Component { {' '} {' ' + this.stepXofY()} + + +
    + this.setState({ turtle_mode: !this.state.turtle_mode })} + /> + +
    +
    +
    {this.props.data.is_seller ? ( @@ -1469,7 +1496,15 @@ class TradeBox extends Component { )} - + {showDisputeButton ? this.showOpenDisputeButton() : ''} {showSendButton ? this.showFiatSentButton() : ''} diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index edd0ee79..befdd3c0 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -357,6 +357,7 @@ "Messages": "Messages", "Verified signature by {{nickname}}": "Verified signature by {{nickname}}", "Cannot verify signature of {{nickname}}": "Cannot verify signature of {{nickname}}", + "Activate turtle mode (Use it when the connection is slow)": "Activate turtle mode (Use it when the connection is slow)", "CONTRACT BOX - TradeBox.js": "The Contract Box that guides users trough the whole trade pipeline", "Contract Box": "Contract Box", diff --git a/frontend/static/locales/es.json b/frontend/static/locales/es.json index a557695b..6e48d805 100644 --- a/frontend/static/locales/es.json +++ b/frontend/static/locales/es.json @@ -345,6 +345,7 @@ "Messages": "Mensajes", "Verified signature by {{nickname}}": "Firma de {{nickname}} verificada", "Cannot verify signature of {{nickname}}": "No se pudo verificar la firma de {{nickname}}", + "Activate turtle mode (Use it when the connection is slow)": "Activar modo tortuga (Úsalo cuando tu conexión es lenta)", "CONTRACT BOX - TradeBox.js": "The Contract Box that guides users trough the whole trade pipeline", "Contract Box": "Contrato", diff --git a/frontend/static/locales/ru.json b/frontend/static/locales/ru.json index 6737ef45..280e57e5 100644 --- a/frontend/static/locales/ru.json +++ b/frontend/static/locales/ru.json @@ -329,6 +329,7 @@ "Messages": "Сообщения", "Verified signature by {{nickname}}": "Проверенная подпись пользователя {{nickname}}", "Cannot verify signature of {{nickname}}": "Не удается проверить подпись {{nickname}}", + "Activate turtle mode (Use it when the connection is slow)": "Включить режим \"черепахи\" (Используйте при низкой скорости интернета)", "CONTRACT BOX - TradeBox.js": "The Contract Box that guides users trough the whole trade pipeline", "Contract Box": "Окно контракта",