Add Chat Turtle Mode

This commit is contained in:
Reckless_Satoshi 2022-10-31 09:20:20 -07:00 committed by KoalaSat
parent 2706703382
commit 5b8d6b4d32
No known key found for this signature in database
GPG Key ID: 2F7F61C6146AB157
11 changed files with 933 additions and 511 deletions

View File

@ -18,7 +18,7 @@ class ChatSerializer(serializers.ModelSerializer):
class PostMessageSerializer(serializers.ModelSerializer): class PostMessageSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Message model = Message
fields = ("PGP_message", "order", "offset") fields = ("PGP_message", "order_id", "offset")
depth = 0 depth = 0
offset = serializers.IntegerField( offset = serializers.IntegerField(

View File

@ -58,6 +58,7 @@ class ChatView(viewsets.ViewSet):
chatroom.maker_connected = True chatroom.maker_connected = True
chatroom.save() chatroom.save()
peer_connected = chatroom.taker_connected peer_connected = chatroom.taker_connected
peer_public_key = order.taker.profile.public_key
elif chatroom.taker == request.user: elif chatroom.taker == request.user:
chatroom.maker_connected = order.maker_last_seen > ( chatroom.maker_connected = order.maker_last_seen > (
timezone.now() - timedelta(minutes=1) timezone.now() - timedelta(minutes=1)
@ -65,8 +66,13 @@ class ChatView(viewsets.ViewSet):
chatroom.taker_connected = True chatroom.taker_connected = True
chatroom.save() chatroom.save()
peer_connected = chatroom.maker_connected peer_connected = chatroom.maker_connected
peer_public_key = order.maker.profile.public_key
messages = [] messages = []
peer_public_key_data = {
"message": peer_public_key
}
messages.append(peer_public_key_data)
for message in queryset: for message in queryset:
d = ChatSerializer(message).data d = ChatSerializer(message).data
print(d) print(d)

View File

@ -1,12 +1,12 @@
{ {
"name": "frontend", "name": "frontend",
"version": "0.2.2", "version": "0.2.3",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "frontend", "name": "frontend",
"version": "0.2.2", "version": "0.2.3",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@babel/plugin-proposal-class-properties": "^7.16.7", "@babel/plugin-proposal-class-properties": "^7.16.7",

View File

@ -66,6 +66,8 @@ class OrderPage extends Component {
openStoreToken: false, openStoreToken: false,
tabValue: 1, tabValue: 1,
orderId: this.props.match.params.orderId, orderId: this.props.match.params.orderId,
chat_offset: 0,
turtle_mode: false,
}; };
// Refresh delays according to Order status // Refresh delays according to Order status
@ -109,6 +111,7 @@ class OrderPage extends Component {
currencyCode: this.getCurrencyCode(newStateVars.currency), currencyCode: this.getCurrencyCode(newStateVars.currency),
penalty: newStateVars.penalty, // in case penalty time has finished, it goes back to null 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 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); const completeStateVars = Object.assign({}, newStateVars, otherStateVars);
@ -117,7 +120,13 @@ class OrderPage extends Component {
getOrderDetails = (id) => { getOrderDetails = (id) => {
this.setState({ orderId: id }); this.setState({ orderId: id });
apiClient.get('/api/order/?order_id=' + id).then(this.orderDetailsReceived);
let path = '/api/order/?order_id=' + id;
if (this.state.turtle_mode) {
path += '&offset=' + this.state.chat_offset;
}
apiClient.get(path).then(this.orderDetailsReceived);
}; };
orderDetailsReceived = (data) => { orderDetailsReceived = (data) => {

View File

@ -0,0 +1,63 @@
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;
}
const ChatBottom: React.FC<Props> = ({ orderId, setAudit, audit }) => {
const { t } = useTranslation();
const theme = useTheme();
return (
<>
<Grid item xs={6}>
<Tooltip
placement='bottom'
enterTouchDelay={0}
enterDelay={500}
enterNextDelay={2000}
title={t('Verify your privacy')}
>
<Button size='small' color='primary' variant='outlined' onClick={() => setAudit(!audit)}>
<KeyIcon />
{t('Audit PGP')}{' '}
</Button>
</Tooltip>
</Grid>
<Grid item xs={6}>
<Tooltip
placement='bottom'
enterTouchDelay={0}
enterDelay={500}
enterNextDelay={2000}
title={t('Save full log as a JSON file (messages and credentials)')}
>
<Button
size='small'
color='primary'
variant='outlined'
onClick={() => saveAsJson('complete_log_chat_' + orderId + '.json', createJsonFile())}
>
<div style={{ width: '1.4em', height: '1.4em' }}>
<ExportIcon sx={{ width: '0.8em', height: '0.8em' }} />
</div>{' '}
{t('Export')}{' '}
</Button>
</Tooltip>
</Grid>
</>
);
};
export default ChatBottom;
function createJsonFile(): any {
throw new Error('Function not implemented.');
}

View File

@ -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<Props> = ({ connected, peerConnected }) => {
const { t } = useTranslation();
const theme = useTheme();
const connectedColor = theme.palette.mode === 'light' ? '#b5e3b7' : '#153717';
const connectedTextColor = theme.palette.getContrastText(connectedColor);
return (
<Grid container spacing={0.5}>
<Grid item xs={0.3} />
<Grid item xs={5.5}>
<Paper elevation={1} sx={connected ? { backgroundColor: connectedColor } : {}}>
<Typography variant='caption' sx={{ color: connectedTextColor }}>
{t('You') + ': '}
{connected ? t('connected') : t('disconnected')}
</Typography>
</Paper>
</Grid>
<Grid item xs={0.4} />
<Grid item xs={5.5}>
<Paper elevation={1} sx={peerConnected ? { backgroundColor: connectedColor } : {}}>
<Typography variant='caption' sx={{ color: connectedTextColor }}>
{t('Peer') + ': '}
{peerConnected ? t('connected') : t('disconnected')}
</Typography>
</Paper>
</Grid>
<Grid item xs={0.3} />
</Grid>
);
};
export default ChatHeader;

View File

@ -0,0 +1,321 @@
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 ChatBottom from '../ChatBottom';
interface Props {
orderId: number;
userNick: string;
takerNick: string;
}
const EncryptedSocketChat: React.FC<Props> = ({
orderId,
userNick,
takerNick,
}: Props): JSX.Element => {
const { t } = useTranslation();
const theme = useTheme();
const audio = new Audio(`/static/assets/sounds/chat-open.mp3`);
const [connected, setConnected] = useState<boolean>(false);
const [peerConnected, setPeerConnected] = useState<boolean>(false);
const [ownPubKey] = useState<string>(
(systemClient.getCookie('pub_key') ?? '').split('\\').join('\n'),
);
const [ownEncPrivKey] = useState<string>(
(systemClient.getCookie('enc_priv_key') ?? '').split('\\').join('\n'),
);
const [peerPubKey, setPeerPubKey] = useState<string>();
const [token] = useState<string>(systemClient.getCookie('robot_token') || '');
const [messages, setMessages] = useState<EncryptedChatMessage[]>([]);
const [serverMessages, setServerMessages] = useState<ServerMessage[]>([]);
const [value, setValue] = useState<string>('');
const [connection, setConnection] = useState<WebsocketConnection>();
const [audit, setAudit] = useState<boolean>(false);
const [waitingEcho, setWaitingEcho] = useState<boolean>(false);
const [lastSent, setLastSent] = useState<string>('---BLANK---');
const [messageCount, setMessageCount] = useState<number>(0);
const [receivedIndexes, setReceivedIndexes] = useState<number[]>([]);
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();
};
return (
<Container component='main'>
<ChatHeader connected={connected} peerConnected={peerConnected} />
<div style={{ position: 'relative', left: '-0.14em', margin: '0 auto', width: '17.7em' }}>
<Paper
elevation={1}
style={{
height: '21.42em',
maxHeight: '21.42em',
width: '17.7em',
overflow: 'auto',
backgroundColor: theme.palette.background.paper,
}}
>
{messages.map((message, index) => {
const isTaker = takerNick === message.userNick;
const userConnected = message.userNick === userNick ? connected : peerConnected;
return (
<li style={{ listStyleType: 'none' }} key={index}>
<MessageCard message={message} isTaker={isTaker} userConnected={userConnected} />
</li>
);
})}
<div
style={{ float: 'left', clear: 'both' }}
ref={(el) => {
if (messages.length > messageCount) el?.scrollIntoView();
}}
/>
</Paper>
<form noValidate onSubmit={onButtonClicked}>
<Grid alignItems='stretch' style={{ display: 'flex' }}>
<Grid item alignItems='stretch' style={{ display: 'flex' }}>
<TextField
label={t('Type a message')}
variant='standard'
size='small'
helperText={
connected
? peerPubKey
? null
: t('Waiting for peer public key...')
: t('Connecting...')
}
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
sx={{ width: '13.7em' }}
/>
</Grid>
<Grid item alignItems='stretch' style={{ display: 'flex' }}>
<Button
sx={{ width: '4.68em' }}
disabled={!connected || waitingEcho || !peerPubKey}
type='submit'
variant='contained'
color='primary'
>
{waitingEcho ? (
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
minWidth: '4.68em',
width: '4.68em',
position: 'relative',
left: '1em',
}}
>
<div style={{ width: '1.2em' }}>
<KeyIcon sx={{ width: '1em' }} />
</div>
<div style={{ width: '1em', position: 'relative', left: '0.5em' }}>
<CircularProgress size={1.1 * theme.typography.fontSize} thickness={5} />
</div>
</div>
) : (
t('Send')
)}
</Button>
</Grid>
</Grid>
</form>
</div>
<div style={{ height: '0.3em' }} />
<Grid container spacing={0}>
<AuditPGPDialog
open={audit}
onClose={() => 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)}
/>
<ChatBottom orderId={orderId} audit={audit} setAudit={setAudit} />
</Grid>
</Container>
);
};
export default EncryptedSocketChat;

View File

@ -0,0 +1,308 @@
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;
}
const EncryptedTurtleChat: React.FC<Props> = ({
orderId,
userNick,
takerNick,
chatOffset,
}: Props): JSX.Element => {
const { t } = useTranslation();
const theme = useTheme();
const audio = new Audio(`/static/assets/sounds/chat-open.mp3`);
const [peerConnected, setPeerConnected] = useState<boolean>(false);
const [ownPubKey] = useState<string>(
(systemClient.getCookie('pub_key') ?? '').split('\\').join('\n'),
);
const [ownEncPrivKey] = useState<string>(
(systemClient.getCookie('enc_priv_key') ?? '').split('\\').join('\n'),
);
const [peerPubKey, setPeerPubKey] = useState<string>(
'-----BEGIN PGP PUBLIC KEY BLOCK-----mDMEY0sj/RYJKwYBBAHaRw8BAQdALiPlUEfnW9k+pVMHuUstKpdfJRNN07+Huam7jP96vj20TFJvYm9TYXRzIElEIDFlMWUzM2VkMTc0ZTgzNTYzM2JkOTY5N2UyNTBhYTllNzgzYTBjMjUyZDMwYWU3ZDI4ZWFjNzUxMzFkMDI4ZjGIjAQQFgoAPgUCY0sj/QQLCQcICRBvpYjDqTP2jQMVCAoEFgACAQIZAQIbAwIeARYhBLm0oAYSSNiZp+gd32+liMOpM/aNAADR6AD/Yrlucc5F+rQxzKZSDcGvubK4lapfyYbgN+pgvRE9bX8A/jwDgWI07mR5bK1JPKDGzGdX4rnG1RPFkGY0n/XpTigGuDgEY0sj/RIKKwYBBAGXVQEFAQEHQK/+5UIZE6WWvPpPF4BAPnPDyEpAm82bDuaB3iup8+EXAwEIB4h4BBgWCAAqBQJjSyP9CRBvpYjDqTP2jQIbDBYhBLm0oAYSSNiZp+gd32+liMOpM/aNAABzuQD9F6/YNXr4hoDHYnVQR0n0LSKyhTV8FDusOuWrMzw3BcIBAMbTMHP1ykB7xTivGVvypRKsS5oMloqv59bJx01fzLEL=H0Iy-----END PGP PUBLIC KEY BLOCK-----',
);
const [token] = useState<string>(systemClient.getCookie('robot_token') || '');
const [messages, setMessages] = useState<EncryptedChatMessage[]>([]);
const [value, setValue] = useState<string>('');
const [audit, setAudit] = useState<boolean>(false);
const [waitingEcho, setWaitingEcho] = useState<boolean>(false);
const [lastSent, setLastSent] = useState<string>('---BLANK---');
const [messageCount, setMessageCount] = useState<number>(0);
const [serverMessages, setServerMessages] = useState<ServerMessage[]>([]);
const [lastIndex, setLastIndex] = useState<number>(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);
storePeerPubKey(results.messages);
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 storePeerPubKey: (dataFromServer: ServerMessage[]) => void = (dataFromServer) => {
dataFromServer.forEach((data) => {
if (
data.message.substring(0, 36) == `-----BEGIN PGP PUBLIC KEY BLOCK-----` &&
data.message != ownPubKey
) {
setPeerPubKey(data.message);
}
});
};
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) => {
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 (
<Container component='main'>
<ChatHeader connected={true} peerConnected={peerConnected} />
<div style={{ position: 'relative', left: '-0.14em', margin: '0 auto', width: '17.7em' }}>
<Paper
elevation={1}
style={{
height: '21.42em',
maxHeight: '21.42em',
width: '17.7em',
overflow: 'auto',
backgroundColor: theme.palette.background.paper,
}}
>
{messages.map((message, index) => {
const isTaker = takerNick === message.userNick;
const userConnected = message.userNick === userNick ? true : peerConnected;
return (
<li style={{ listStyleType: 'none' }} key={index}>
<MessageCard message={message} isTaker={isTaker} userConnected={userConnected} />
</li>
);
})}
<div
style={{ float: 'left', clear: 'both' }}
ref={(el) => {
if (messages.length > messageCount) el?.scrollIntoView();
}}
/>
</Paper>
<form noValidate onSubmit={onButtonClicked}>
<Grid alignItems='stretch' style={{ display: 'flex' }}>
<Grid item alignItems='stretch' style={{ display: 'flex' }}>
<TextField
label={t('Type a message')}
variant='standard'
size='small'
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
sx={{ width: '13.7em' }}
/>
</Grid>
<Grid item alignItems='stretch' style={{ display: 'flex' }}>
<Button
sx={{ width: '4.68em' }}
disabled={waitingEcho || !peerPubKey}
type='submit'
variant='contained'
color='primary'
>
{waitingEcho ? (
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
minWidth: '4.68em',
width: '4.68em',
position: 'relative',
left: '1em',
}}
>
<div style={{ width: '1.2em' }}>
<KeyIcon sx={{ width: '1em' }} />
</div>
<div style={{ width: '1em', position: 'relative', left: '0.5em' }}>
<CircularProgress size={1.1 * theme.typography.fontSize} thickness={5} />
</div>
</div>
) : (
t('Send')
)}
</Button>
</Grid>
</Grid>
</form>
</div>
<div style={{ height: '0.3em' }} />
<Grid container spacing={0}>
<AuditPGPDialog
open={audit}
onClose={() => 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)}
/>
<ChatBottom orderId={orderId} audit={audit} setAudit={setAudit} />
</Grid>
</Container>
);
};
export default EncryptedTurtleChat;

View File

@ -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<Props> = ({ message, isTaker, userConnected }) => {
const [showPGP, setShowPGP] = useState<boolean>();
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 (
<Card elevation={5}>
<CardHeader
sx={{ color: theme.palette.text.secondary }}
avatar={
<RobotAvatar
statusColor={userConnected ? 'success' : 'error'}
nickname={message.userNick}
/>
}
style={{ backgroundColor: cardColor }}
title={
<Tooltip
placement='top'
enterTouchDelay={0}
enterDelay={500}
enterNextDelay={2000}
title={t(
message.validSignature
? 'Verified signature by {{nickname}}'
: 'Cannot verify signature of {{nickname}}',
{ nickname: message.userNick },
)}
>
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
position: 'relative',
left: '-0.35em',
width: '17.14em',
}}
>
<div
style={{
width: '11.78em',
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
{message.userNick}
{message.validSignature ? (
<CheckIcon sx={{ height: '0.8em' }} color='success' />
) : (
<CloseIcon sx={{ height: '0.8em' }} color='error' />
)}
</div>
<div style={{ width: '1.4em' }}>
<IconButton
sx={{ height: '1.2em', width: '1.2em', position: 'relative', right: '0.15em' }}
onClick={() => setShowPGP(!showPGP)}
>
<VisibilityIcon
color={showPGP ? 'primary' : 'inherit'}
sx={{
height: '0.6em',
width: '0.6em',
color: showPGP ? 'primary' : theme.palette.text.secondary,
}}
/>
</IconButton>
</div>
<div style={{ width: '1.4em' }}>
<Tooltip disableHoverListener enterTouchDelay={0} title={t('Copied!')}>
<IconButton
sx={{ height: '0.8em', width: '0.8em' }}
onClick={() =>
systemClient.copyToClipboard(
showPGP ? message.encryptedMessage : message.plainTextMessage,
)
}
>
<ContentCopy
sx={{
height: '0.7em',
width: '0.7em',
color: theme.palette.text.secondary,
}}
/>
</IconButton>
</Tooltip>
</div>
</div>
</Tooltip>
}
subheader={
showPGP ? (
<a>
{' '}
{message.time} <br /> {'Valid signature: ' + message.validSignature} <br />{' '}
{message.encryptedMessage}{' '}
</a>
) : (
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,
},
}}
/>
</Card>
);
};
export default MessageCard;

View File

@ -1,40 +1,16 @@
import React, { useEffect, useState } from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import EncryptedSocketChat from './EncryptedSocketChat';
import { import EncryptedTrutleChat from './EncryptedTrutleChat';
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';
interface Props { interface Props {
turtleMode: boolean;
orderId: number; orderId: number;
takerNick: string;
makerNick: string;
userNick: string; userNick: string;
chatOffset: number;
} }
export interface EncryptedChatMessage {
interface EncryptedChatMessage {
userNick: string; userNick: string;
validSignature: boolean; validSignature: boolean;
plainTextMessage: string; plainTextMessage: string;
@ -42,481 +18,29 @@ interface EncryptedChatMessage {
time: string; time: string;
index: number; index: number;
} }
export interface ServerMessage {
const EncryptedChat: React.FC<Props> = ({ orderId, userNick }: Props): JSX.Element => { message: string;
const { t } = useTranslation(); time: string;
const theme = useTheme(); index: number;
nick: string;
const audio = new Audio(`/static/assets/sounds/chat-open.mp3`);
const [connected, setConnected] = useState<boolean>(false);
const [peerConnected, setPeerConnected] = useState<boolean>(false);
const [ownPubKey] = useState<string>(
(systemClient.getCookie('pub_key') ?? '').split('\\').join('\n'),
);
const [ownEncPrivKey] = useState<string>(
(systemClient.getCookie('enc_priv_key') ?? '').split('\\').join('\n'),
);
const [peerPubKey, setPeerPubKey] = useState<string>();
const [token] = useState<string>(systemClient.getCookie('robot_token') || '');
const [messages, setMessages] = useState<EncryptedChatMessage[]>([]);
const [serverMessages, setServerMessages] = useState<any[]>([]);
const [value, setValue] = useState<string>('');
const [connection, setConnection] = useState<WebsocketConnection>();
const [audit, setAudit] = useState<boolean>(false);
const [showPGP, setShowPGP] = useState<boolean[]>([]);
const [waitingEcho, setWaitingEcho] = useState<boolean>(false);
const [lastSent, setLastSent] = useState<string>('---BLANK---');
const [messageCount, setMessageCount] = useState<number>(0);
const [receivedIndexes, setReceivedIndexes] = useState<number[]>([]);
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 const EncryptedChat: React.FC<Props> = ({
else if (value != '') { turtleMode,
setValue(''); orderId,
setWaitingEcho(true); takerNick,
setLastSent(value); userNick,
encryptMessage(value, ownPubKey, peerPubKey, ownEncPrivKey, token).then( chatOffset,
(encryptedMessage) => { }: Props): JSX.Element => {
if (connection) { return turtleMode ? (
connection.send({ <EncryptedTrutleChat
message: encryptedMessage.toString().split('\n').join('\\'), orderId={orderId}
nick: userNick, takerNick={takerNick}
}); userNick={userNick}
} chatOffset={chatOffset}
},
);
}
e.preventDefault();
};
const messageCard: (
message: EncryptedChatMessage,
index: number,
cardColor: string,
userConnected: boolean,
) => JSX.Element = (message, index, cardColor, userConnected) => {
return (
<Card elevation={5}>
<CardHeader
sx={{ color: theme.palette.text.secondary }}
avatar={
<RobotAvatar
statusColor={userConnected ? 'success' : 'error'}
nickname={message.userNick}
/> />
}
style={{ backgroundColor: cardColor }}
title={
<Tooltip
placement='top'
enterTouchDelay={0}
enterDelay={500}
enterNextDelay={2000}
title={t(
message.validSignature
? 'Verified signature by {{nickname}}'
: 'Cannot verify signature of {{nickname}}',
{ nickname: message.userNick },
)}
>
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
position: 'relative',
left: '-0.35em',
width: '17.14em',
}}
>
<div
style={{
width: '11.78em',
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
}}
>
{message.userNick}
{message.validSignature ? (
<CheckIcon sx={{ height: '0.8em' }} color='success' />
) : ( ) : (
<CloseIcon sx={{ height: '0.8em' }} color='error' /> <EncryptedSocketChat orderId={orderId} takerNick={takerNick} userNick={userNick} />
)}
</div>
<div style={{ width: '1.4em' }}>
<IconButton
sx={{ height: '1.2em', width: '1.2em', position: 'relative', right: '0.15em' }}
onClick={() => {
const newShowPGP = [...showPGP];
newShowPGP[index] = !newShowPGP[index];
setShowPGP(newShowPGP);
}}
>
<VisibilityIcon
color={showPGP[index] ? 'primary' : 'inherit'}
sx={{
height: '0.6em',
width: '0.6em',
color: showPGP[index] ? 'primary' : theme.palette.text.secondary,
}}
/>
</IconButton>
</div>
<div style={{ width: '1.4em' }}>
<Tooltip disableHoverListener enterTouchDelay={0} title={t('Copied!')}>
<IconButton
sx={{ height: '0.8em', width: '0.8em' }}
onClick={() =>
systemClient.copyToClipboard(
showPGP[index] ? message.encryptedMessage : message.plainTextMessage,
)
}
>
<ContentCopy
sx={{
height: '0.7em',
width: '0.7em',
color: theme.palette.text.secondary,
}}
/>
</IconButton>
</Tooltip>
</div>
</div>
</Tooltip>
}
subheader={
showPGP[index] ? (
<a>
{' '}
{message.time} <br /> {'Valid signature: ' + message.validSignature} <br />{' '}
{message.encryptedMessage}{' '}
</a>
) : (
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,
},
}}
/>
</Card>
);
};
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 (
<Container component='main'>
<Grid container spacing={0.5}>
<Grid item xs={0.3} />
<Grid item xs={5.5}>
<Paper elevation={1} sx={connected ? { backgroundColor: connectedColor } : {}}>
<Typography variant='caption' sx={{ color: connectedTextColor }}>
{t('You') + ': '}
{connected ? t('connected') : t('disconnected')}
</Typography>
</Paper>
</Grid>
<Grid item xs={0.4} />
<Grid item xs={5.5}>
<Paper elevation={1} sx={peerConnected ? { backgroundColor: connectedColor } : {}}>
<Typography variant='caption' sx={{ color: connectedTextColor }}>
{t('Peer') + ': '}
{peerConnected ? t('connected') : t('disconnected')}
</Typography>
</Paper>
</Grid>
<Grid item xs={0.3} />
</Grid>
<div style={{ position: 'relative', left: '-0.14em', margin: '0 auto', width: '17.7em' }}>
<Paper
elevation={1}
style={{
height: '21.42em',
maxHeight: '21.42em',
width: '17.7em',
overflow: 'auto',
backgroundColor: theme.palette.background.paper,
}}
>
{messages.map((message, index) => (
<li style={{ listStyleType: 'none' }} key={index}>
{message.userNick == userNick
? messageCard(message, index, ownCardColor, connected)
: messageCard(message, index, peerCardColor, peerConnected)}
</li>
))}
<div
style={{ float: 'left', clear: 'both' }}
ref={(el) => {
if (messages.length > messageCount) el?.scrollIntoView();
}}
/>
</Paper>
<form noValidate onSubmit={onButtonClicked}>
<Grid alignItems='stretch' style={{ display: 'flex' }}>
<Grid item alignItems='stretch' style={{ display: 'flex' }}>
<TextField
label={t('Type a message')}
variant='standard'
size='small'
helperText={
connected
? peerPubKey
? null
: t('Waiting for peer public key...')
: t('Connecting...')
}
value={value}
onChange={(e) => {
setValue(e.target.value);
}}
sx={{ width: '13.7em' }}
/>
</Grid>
<Grid item alignItems='stretch' style={{ display: 'flex' }}>
<Button
sx={{ width: '4.68em' }}
disabled={!connected || waitingEcho || !peerPubKey}
type='submit'
variant='contained'
color='primary'
>
{waitingEcho ? (
<div
style={{
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
minWidth: '4.68em',
width: '4.68em',
position: 'relative',
left: '1em',
}}
>
<div style={{ width: '1.2em' }}>
<KeyIcon sx={{ width: '1em' }} />
</div>
<div style={{ width: '1em', position: 'relative', left: '0.5em' }}>
<CircularProgress size={1.1 * theme.typography.fontSize} thickness={5} />
</div>
</div>
) : (
t('Send')
)}
</Button>
</Grid>
</Grid>
</form>
</div>
<div style={{ height: '0.3em' }} />
<Grid container spacing={0}>
<AuditPGPDialog
open={audit}
onClose={() => 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)}
/>
<Grid item xs={6}>
<Tooltip
placement='bottom'
enterTouchDelay={0}
enterDelay={500}
enterNextDelay={2000}
title={t('Verify your privacy')}
>
<Button
size='small'
color='primary'
variant='outlined'
onClick={() => setAudit(!audit)}
>
<KeyIcon />
{t('Audit PGP')}{' '}
</Button>
</Tooltip>
</Grid>
<Grid item xs={6}>
<Tooltip
placement='bottom'
enterTouchDelay={0}
enterDelay={500}
enterNextDelay={2000}
title={t('Save full log as a JSON file (messages and credentials)')}
>
<Button
size='small'
color='primary'
variant='outlined'
onClick={() => saveAsJson('complete_log_chat_' + orderId + '.json', createJsonFile())}
>
<div style={{ width: '1.4em', height: '1.4em' }}>
<ExportIcon sx={{ width: '0.8em', height: '0.8em' }} />
</div>{' '}
{t('Export')}{' '}
</Button>
</Tooltip>
</Grid>
</Grid>
</Container>
); );
}; };

View File

@ -1469,7 +1469,15 @@ class TradeBox extends Component {
)} )}
</Grid> </Grid>
<EncryptedChat orderId={this.props.data.id} userNick={this.props.data.ur_nick} /> <EncryptedChat
turtleMode={this.props.data.turtle_mode}
chatOffset={this.props.data.chat_offset}
orderId={this.props.data.id}
takerNick={this.props.data.taker_nick}
makerNick={this.props.data.maker_nick}
userNick={this.props.data.ur_nick}
chat={this.props.data.chat}
/>
<Grid item xs={12} align='center'> <Grid item xs={12} align='center'>
{showDisputeButton ? this.showOpenDisputeButton() : ''} {showDisputeButton ? this.showOpenDisputeButton() : ''}
{showSendButton ? this.showFiatSentButton() : ''} {showSendButton ? this.showFiatSentButton() : ''}