mirror of
https://github.com/RoboSats/robosats.git
synced 2024-09-11 14:05:51 +03:00
Add turtle chat component
Squashed commit of the following: commit f60870fcfe574dc4ab1343e25241b6ef7cc2721b Author: Reckless_Satoshi <reckless.satoshi@protonmail.com> Date: Thu Nov 10 10:30:42 2022 -0800 Fix internal error when entering chat commit f1eeb49f2a86575eb2e85cdff20460276e71b806 Author: Reckless_Satoshi <reckless.satoshi@protonmail.com> Date: Tue Nov 8 10:08:22 2022 -0800 Fix final serializer commit d0c08ba6ad4378a9539c0be83b6f4f8b958b532e Author: Reckless_Satoshi <reckless.satoshi@protonmail.com> Date: Tue Nov 8 09:44:57 2022 -0800 Chat API changes commit a66bf64edc06d936612db6bf75476b54e6a84334 Author: Reckless_Satoshi <reckless.satoshi@protonmail.com> Date: Tue Nov 8 09:28:29 2022 -0800 Fix param on post commit60b18d13c2
Author: Reckless_Satoshi <reckless.satoshi@protonmail.com> Date: Tue Nov 8 08:56:25 2022 -0800 Fix serializer commit11212d30ee
Author: KoalaSat <yv1vtrul@duck.com> Date: Sun Nov 6 21:07:18 2022 +0100 CR 2 commitc82790cb81
Author: KoalaSat <yv1vtrul@duck.com> Date: Sun Nov 6 20:09:18 2022 +0100 Fix commit605a3b69a1
Author: KoalaSat <yv1vtrul@duck.com> Date: Sun Nov 6 14:44:42 2022 +0100 CR commit09776e9c8f
Author: KoalaSat <yv1vtrul@duck.com> Date: Wed Nov 2 18:12:29 2022 +0100 translations commit432e4d2399
Author: KoalaSat <yv1vtrul@duck.com> Date: Wed Nov 2 17:39:02 2022 +0100 Switch and better UX commitdf6e476613
Author: KoalaSat <yv1vtrul@duck.com> Date: Tue Nov 1 18:20:01 2022 +0100 Unused code commit5b8d6b4d32
Author: Reckless_Satoshi <reckless.satoshi@protonmail.com> Date: Mon Oct 31 09:20:20 2022 -0700 Add Chat Turtle Mode
This commit is contained in:
parent
160c24ca4d
commit
3446fc33d3
11
api/views.py
11
api/views.py
@ -53,7 +53,7 @@ from api.utils import (
|
||||
get_robosats_commit,
|
||||
get_robosats_version,
|
||||
)
|
||||
from chat.views import ChatView
|
||||
from chat.models import Message
|
||||
from control.models import AccountingDay, BalanceLog
|
||||
|
||||
from .nick_generator.nick_generator import NickGenerator
|
||||
@ -405,9 +405,12 @@ class OrderView(viewsets.ViewSet):
|
||||
else:
|
||||
data["asked_for_cancel"] = False
|
||||
|
||||
offset = request.GET.get("offset", None)
|
||||
if offset:
|
||||
data["chat"] = ChatView.get(None, request).data
|
||||
# Add index of last chat message. To be used by client on Chat endpoint to fetch latest messages
|
||||
messages = Message.objects.filter(order=order)
|
||||
if len(messages) == 0:
|
||||
data["chat_last_index"] = 0
|
||||
else:
|
||||
data["chat_last_index"] = messages.latest().index
|
||||
|
||||
# 9) If status is 'DIS' and all HTLCS are in LOCKED
|
||||
elif order.status == Order.Status.DIS:
|
||||
|
@ -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(
|
||||
@ -28,3 +28,10 @@ class PostMessageSerializer(serializers.ModelSerializer):
|
||||
min_value=0,
|
||||
help_text="Offset for message index to get as response",
|
||||
)
|
||||
|
||||
order_id = serializers.IntegerField(
|
||||
allow_null=False,
|
||||
required=True,
|
||||
min_value=0,
|
||||
help_text="Order ID of chatroom",
|
||||
)
|
||||
|
@ -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,11 +66,11 @@ 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:
|
||||
d = ChatSerializer(message).data
|
||||
print(d)
|
||||
# Re-serialize so the response is identical to the consumer message
|
||||
data = {
|
||||
"index": d["index"],
|
||||
@ -79,7 +80,11 @@ 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)
|
||||
|
||||
@ -94,8 +99,7 @@ class ChatView(viewsets.ViewSet):
|
||||
context = {"bad_request": "Invalid serializer"}
|
||||
return Response(context, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
print(request)
|
||||
order_id = serializer.data.get("order", None)
|
||||
order_id = serializer.data.get("order_id")
|
||||
|
||||
if order_id is None:
|
||||
return Response(
|
||||
@ -172,7 +176,6 @@ class ChatView(viewsets.ViewSet):
|
||||
messages = []
|
||||
for message in queryset:
|
||||
d = ChatSerializer(message).data
|
||||
print(d)
|
||||
# Re-serialize so the response is identical to the consumer message
|
||||
data = {
|
||||
"index": d["index"],
|
||||
|
@ -53,7 +53,7 @@ services:
|
||||
environment:
|
||||
TOR_PROXY_IP: 127.0.0.1
|
||||
TOR_PROXY_PORT: 9050
|
||||
ROBOSATS_ONION: robosats6tkf3eva7x2voqso3a5wcorsnw34jveyxfqi2fu7oyheasid.onion
|
||||
ROBOSATS_ONION: robotestagw3dcxmd66r4rgksb4nmmr43fh77bzn2ia2eucduyeafnyd.onion
|
||||
network_mode: service:tor
|
||||
volumes:
|
||||
- ./frontend/static:/usr/src/robosats/static
|
||||
|
@ -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<Props> = ({ orderId, setAudit, audit, createJsonFile }) => {
|
||||
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;
|
@ -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;
|
@ -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<Props> = ({
|
||||
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<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 [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: 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 (
|
||||
<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}
|
||||
createJsonFile={createJsonFile}
|
||||
/>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default EncryptedSocketChat;
|
@ -0,0 +1,314 @@
|
||||
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;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
const EncryptedTurtleChat: React.FC<Props> = ({
|
||||
orderId,
|
||||
userNick,
|
||||
takerNick,
|
||||
chatOffset,
|
||||
messages,
|
||||
setMessages,
|
||||
baseUrl,
|
||||
}: 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>();
|
||||
const [token] = useState<string>(systemClient.getCookie('robot_token') || '');
|
||||
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 === 0 || chatOffset > lastIndex) {
|
||||
loadMessages();
|
||||
}
|
||||
}, [chatOffset]);
|
||||
|
||||
const loadMessages: () => void = () => {
|
||||
apiClient
|
||||
.get(baseUrl, `/api/chat/?order_id=${orderId}&offset=${lastIndex}`)
|
||||
.then((results: any) => {
|
||||
if (results) {
|
||||
setPeerConnected(results.peer_connected);
|
||||
setPeerPubKey(results.peer_pubkey.split('\\').join('\n'));
|
||||
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,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response) onMessage(response as ServerMessage);
|
||||
})
|
||||
.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('\\'),
|
||||
order_id: orderId,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response) onMessage(response as ServerMessage);
|
||||
})
|
||||
.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}
|
||||
createJsonFile={createJsonFile}
|
||||
/>
|
||||
</Grid>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
export default EncryptedTurtleChat;
|
@ -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;
|
@ -1,516 +1,61 @@
|
||||
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 { WebSocketsChatMessage } from '../../../models';
|
||||
import React, { useState } from 'react';
|
||||
import EncryptedSocketChat from './EncryptedSocketChat';
|
||||
import EncryptedTurtleChat from './EncryptedTurtleChat';
|
||||
|
||||
interface Props {
|
||||
turtleMode: boolean;
|
||||
orderId: number;
|
||||
takerNick: string;
|
||||
makerNick: string;
|
||||
userNick: string;
|
||||
chatOffset: number;
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
const EncryptedChat: React.FC<Props> = ({ orderId, userNick, baseUrl }: Props): JSX.Element => {
|
||||
const { t } = useTranslation();
|
||||
const theme = useTheme();
|
||||
export interface EncryptedChatMessage {
|
||||
userNick: string;
|
||||
validSignature: boolean;
|
||||
plainTextMessage: string;
|
||||
encryptedMessage: string;
|
||||
time: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
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.getItem('pub_key') ?? '').split('\\').join('\n'),
|
||||
);
|
||||
const [ownEncPrivKey] = useState<string>(
|
||||
(systemClient.getItem('enc_priv_key') ?? '').split('\\').join('\n'),
|
||||
);
|
||||
const [peerPubKey, setPeerPubKey] = useState<string>();
|
||||
const [token] = useState<string>(systemClient.getItem('robot_token') || '');
|
||||
const [messages, setMessages] = useState<WebSocketsChatMessage[]>([]);
|
||||
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[]>([]);
|
||||
export interface ServerMessage {
|
||||
message: string;
|
||||
time: string;
|
||||
index: number;
|
||||
nick: string;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected) {
|
||||
connectWebsocket();
|
||||
}
|
||||
}, [connected]);
|
||||
const EncryptedChat: React.FC<Props> = ({
|
||||
turtleMode,
|
||||
orderId,
|
||||
takerNick,
|
||||
userNick,
|
||||
chatOffset,
|
||||
baseUrl,
|
||||
}: Props): JSX.Element => {
|
||||
const [messages, setMessages] = useState<EncryptedChatMessage[]>([]);
|
||||
|
||||
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 (
|
||||
<Card elevation={5}>
|
||||
<CardHeader
|
||||
sx={{ color: theme.palette.text.secondary }}
|
||||
avatar={
|
||||
<RobotAvatar
|
||||
statusColor={userConnected ? 'success' : 'error'}
|
||||
nickname={message.userNick}
|
||||
baseUrl={baseUrl}
|
||||
/>
|
||||
}
|
||||
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={() => {
|
||||
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>
|
||||
return turtleMode ? (
|
||||
<EncryptedTurtleChat
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
orderId={orderId}
|
||||
takerNick={takerNick}
|
||||
userNick={userNick}
|
||||
chatOffset={chatOffset}
|
||||
baseUrl={baseUrl}
|
||||
/>
|
||||
) : (
|
||||
<EncryptedSocketChat
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
orderId={orderId}
|
||||
takerNick={takerNick}
|
||||
userNick={userNick}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -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,
|
||||
turtleMode: false,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1438,6 +1442,29 @@ class TradeBox extends Component {
|
||||
</b>{' '}
|
||||
{' ' + this.stepXofY()}
|
||||
</Typography>
|
||||
<Grid item>
|
||||
<Tooltip
|
||||
enterTouchDelay={0}
|
||||
placement='top'
|
||||
title={t('Activate turtle mode (Use it when the connection is slow)')}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '4em',
|
||||
height: '1.1em',
|
||||
}}
|
||||
>
|
||||
<Switch
|
||||
size='small'
|
||||
disabled={false}
|
||||
checked={this.state.turtleMode}
|
||||
onChange={() => this.setState({ turtleMode: !this.state.turtleMode })}
|
||||
/>
|
||||
<WifiTetheringErrorIcon sx={{ color: 'text.secondary' }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<Grid item xs={12} align='center'>
|
||||
{this.props.data.is_seller ? (
|
||||
@ -1470,8 +1497,13 @@ class TradeBox extends Component {
|
||||
</Grid>
|
||||
|
||||
<EncryptedChat
|
||||
turtleMode={this.state.turtleMode}
|
||||
chatOffset={this.props.data.chat_last_index}
|
||||
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}
|
||||
baseUrl={this.props.baseUrl}
|
||||
/>
|
||||
<Grid item xs={12} align='center'>
|
||||
|
@ -6,12 +6,10 @@
|
||||
"Hide": "Hide",
|
||||
"You are self-hosting RoboSats": "You are self-hosting RoboSats",
|
||||
"RoboSats client is served from your own node granting you the strongest security and privacy.": "RoboSats client is served from your own node granting you the strongest security and privacy.",
|
||||
|
||||
"Initializing TOR daemon": "Initializing TOR daemon",
|
||||
"Connecting to TOR network": "Connecting to TOR network",
|
||||
"Connected to TOR network": "Connected to TOR network",
|
||||
"Connection error": "Connection error",
|
||||
|
||||
"USER GENERATION PAGE - UserGenPage.js": "Landing Page and User Generation",
|
||||
"Simple and Private LN P2P Exchange": "Simple and Private LN P2P Exchange",
|
||||
"This is your trading avatar": "This is your trading avatar",
|
||||
@ -28,7 +26,6 @@
|
||||
"You are about to visit Learn RoboSats. It hosts tutorials and documentation to help you learn how to use RoboSats and understand how it works.": "You are about to visit Learn RoboSats. It hosts tutorials and documentation to help you learn how to use RoboSats and understand how it works.",
|
||||
"Let's go!": "Let's go!",
|
||||
"Save token and PGP credentials to file": "Save token and PGP credentials to file",
|
||||
|
||||
"MAKER PAGE - MakerPage.js": "This is the page where users can create new orders",
|
||||
"Order": "Order",
|
||||
"Customize": "Customize",
|
||||
@ -87,7 +84,6 @@
|
||||
"Existing orders match yours!": "Existing orders match yours!",
|
||||
"Enable advanced options": "Enable advanced options",
|
||||
"Clear form": "Clear form",
|
||||
|
||||
"PAYMENT METHODS - autocompletePayments.js": "Payment method strings",
|
||||
"not specified": "Not specified",
|
||||
"Instant SEPA": "Instant SEPA",
|
||||
@ -95,7 +91,6 @@
|
||||
"Google Play Gift Code": "Google Play Gift Code",
|
||||
"Cash F2F": "Cash F2F",
|
||||
"On-Chain BTC": "On-Chain BTC",
|
||||
|
||||
"BOOK PAGE - BookPage": "The Book Order page",
|
||||
"Seller": "Seller",
|
||||
"Buyer": "Buyer",
|
||||
@ -175,7 +170,6 @@
|
||||
"swap to": "swap to",
|
||||
"DESTINATION": "DESTINATION",
|
||||
"METHOD": "METHOD",
|
||||
|
||||
"BOTTOM BAR AND MISC - BottomBar.js": "Bottom Bar user profile and miscellaneous dialogs",
|
||||
"Stats For Nerds": "Stats For Nerds",
|
||||
"LND version": "LND version",
|
||||
@ -252,7 +246,6 @@
|
||||
"Coordinator": "Coordinator",
|
||||
"RoboSats version": "RoboSats version",
|
||||
"LN Node": "LN Node",
|
||||
|
||||
"ORDER PAGE - OrderPage.js": "Order details page",
|
||||
"Order Box": "Order Box",
|
||||
"Contract": "Contract",
|
||||
@ -327,7 +320,6 @@
|
||||
"Payment not received, please check your WebLN wallet.": "Payment not received, please check your WebLN wallet.",
|
||||
"Invoice not received, please check your WebLN wallet.": "Invoice not received, please check your WebLN wallet.",
|
||||
"Payment detected, you can close now your WebLN wallet popup.": "Payment detected, you can close now your WebLN wallet popup.",
|
||||
|
||||
"CHAT BOX - Chat.js": "Chat Box",
|
||||
"You": "You",
|
||||
"Peer": "Peer",
|
||||
@ -357,7 +349,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",
|
||||
"Robots show commitment to their peers": "Robots show commitment to their peers",
|
||||
@ -501,7 +493,6 @@
|
||||
"Timestamp": "Timestamp",
|
||||
"Completed in": "Completed in",
|
||||
"Contract exchange rate": "Contract exchange rate",
|
||||
|
||||
"INFO DIALOG - InfoDiagog.js": "App information and clarifications and terms of use",
|
||||
"Close": "Close",
|
||||
"What is RoboSats?": "What is RoboSats?",
|
||||
|
@ -6,7 +6,6 @@
|
||||
"Hide": "Ocultar",
|
||||
"You are self-hosting RoboSats": "Estás hosteando RoboSats",
|
||||
"RoboSats client is served from your own node granting you the strongest security and privacy.": "El cliente RoboSats es servido por tu propio nodo, gozas de la mayor seguridad y privacidad.",
|
||||
|
||||
"UserGenPage": "User Generation Page and Landing Page",
|
||||
"Simple and Private LN P2P Exchange": "Intercambio LN P2P Fácil y Privado",
|
||||
"This is your trading avatar": "Este es tu Robot de compraventa",
|
||||
@ -23,7 +22,6 @@
|
||||
"You are about to visit Learn RoboSats. It hosts tutorials and documentation to help you learn how to use RoboSats and understand how it works.": "Vas a visitar la página Learn RoboSats. Ha sido construida por la comunidad y contiene tutoriales y documentación que te ayudará a aprender como se usa RoboSats y a entender como funciona.",
|
||||
"Let's go!": "¡Vamos!",
|
||||
"Save token and PGP credentials to file": "Guardar archivo con token y credenciales PGP",
|
||||
|
||||
"MAKER PAGE - MakerPage.js": "This is the page where users can create new orders",
|
||||
"Order": "Orden",
|
||||
"Customize": "Personalizar",
|
||||
@ -84,7 +82,6 @@
|
||||
"Existing orders match yours!": "¡Existen órdenes que coinciden!",
|
||||
"Enable advanced options": "Activar opciones avanzadas",
|
||||
"Clear form": "Borrar campos",
|
||||
|
||||
"PAYMENT METHODS - autocompletePayments.js": "Payment method strings",
|
||||
"not specified": "Sin especificar",
|
||||
"Instant SEPA": "SEPA Instantánea",
|
||||
@ -94,7 +91,6 @@
|
||||
"Google Play Gift Code": "Google Play Tarjeta Regalo",
|
||||
"Cash F2F": "Efectivo en persona",
|
||||
"On-Chain BTC": "On-Chain BTC",
|
||||
|
||||
"BOOK PAGE - BookPage.js": "The Book Order page",
|
||||
"Seller": "Vende",
|
||||
"Buyer": "Compra",
|
||||
@ -177,7 +173,6 @@
|
||||
"swap to": "swap a",
|
||||
"DESTINATION": "DESTINO",
|
||||
"METHOD": "MÉTODO",
|
||||
|
||||
"BOTTOM BAR AND MISC - BottomBar.js": "Bottom Bar user profile and miscellaneous dialogs",
|
||||
"Stats For Nerds": "Estadísticas para nerds",
|
||||
"LND version": "Versión LND",
|
||||
@ -240,7 +235,6 @@
|
||||
"Current onchain payout fee": "Coste actual de recibir onchain",
|
||||
"Use stealth invoices": "Facturas sigilosas",
|
||||
"Stealth lightning invoices do not contain details about the trade except an order reference. Enable this setting if you don't want to disclose details to a custodial lightning wallet.": "Las facturas sigilosas no contienen información sobre tu orden excepto una referencia a la misma. Activalas para no desvelar información a tu proveedor de wallet custodial.",
|
||||
|
||||
"ORDER PAGE - OrderPage.js": "Order details page",
|
||||
"Order Box": "Orden",
|
||||
"Contract": "Contrato",
|
||||
@ -315,7 +309,6 @@
|
||||
"Payment not received, please check your WebLN wallet.": "No se ha recibido el pago, echa un vistazo a tu wallet WebLN.",
|
||||
"Invoice not received, please check your WebLN wallet.": "No se ha recibido la factura, echa un vistazo a tu wallet WebLN.",
|
||||
"You can close now your WebLN wallet popup.": "Ahora puedes cerrar el popup de tu wallet WebLN.",
|
||||
|
||||
"CHAT BOX - Chat.js": "Ventana del chat",
|
||||
"You": "Tú",
|
||||
"Peer": "Él",
|
||||
@ -345,7 +338,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",
|
||||
"Robots show commitment to their peers": "Los Robots deben mostrar su compromiso",
|
||||
@ -489,7 +482,6 @@
|
||||
"Timestamp": "Marca de hora",
|
||||
"Completed in": "Completado en",
|
||||
"Contract exchange rate": "Tasa de cambio del contrato",
|
||||
|
||||
"INFO DIALOG - InfoDiagog.js": "App information and clarifications and terms of use",
|
||||
"Close": "Cerrar",
|
||||
"What is RoboSats?": "¿Qué es RoboSats?",
|
||||
|
@ -4,7 +4,6 @@
|
||||
"desktop_unsafe_alert": "Некоторые функции отключены для Вашей безопасности (чат) и без них у Вас не будет возможности завершить сделку. Чтобы защитить Вашу конфиденциальность и полностью включить RoboSats, используйте <1>Tor Browser</1> и посетите <3>Onion</3> сайт.",
|
||||
"phone_unsafe_alert": "У Вас не будет возможности завершить сделку. Используйте <1>Tor Browser</1> и посетите <3>Onion</3> сайт.",
|
||||
"Hide": "Скрыть",
|
||||
|
||||
"USER GENERATION PAGE - UserGenPage.js": "Landing Page and User Generation",
|
||||
"Simple and Private LN P2P Exchange": "Простой и Конфиденциальный LN P2P Обмен",
|
||||
"This is your trading avatar": "Это Ваш торговый аватар",
|
||||
@ -21,7 +20,6 @@
|
||||
"You are about to visit Learn RoboSats. It hosts tutorials and documentation to help you learn how to use RoboSats and understand how it works.": "Вы собираетесь посетить Learn RoboSats. На нём размещены учебные пособия и документация, которые помогут Вам научиться использовать RoboSats и понять, как он работает.",
|
||||
"Let's go!": "Поехали!",
|
||||
"Save token and PGP credentials to file": "Сохранить токен и учетные данные PGP в файл",
|
||||
|
||||
"MAKER PAGE - MakerPage.js": "This is the page where users can create new orders",
|
||||
"Order": "Ордер",
|
||||
"Customize": "Настроить",
|
||||
@ -78,7 +76,6 @@
|
||||
"Done": "Готово",
|
||||
"You do not have a robot avatar": "У Вас нет аватара робота",
|
||||
"You need to generate a robot avatar in order to become an order maker": "Вам нужно сгенерировать аватар робота, чтобы стать мейкером ордеров",
|
||||
|
||||
"PAYMENT METHODS - autocompletePayments.js": "Payment method strings",
|
||||
"not specified": "Не указано",
|
||||
"Instant SEPA": "Мгновенный SEPA",
|
||||
@ -86,7 +83,6 @@
|
||||
"Google Play Gift Code": "Подарочный код Google Play",
|
||||
"Cash F2F": "Наличные F2F",
|
||||
"On-Chain BTC": "Ончейн BTC",
|
||||
|
||||
"BOOK PAGE - BookPage.js": "The Book Order page",
|
||||
"Seller": "Продавец",
|
||||
"Buyer": "Покупатель",
|
||||
@ -160,7 +156,6 @@
|
||||
"no": "нет",
|
||||
"Depth chart": "Схемами глубин",
|
||||
"Chart": "Схемами",
|
||||
|
||||
"BOTTOM BAR AND MISC - BottomBar.js": "Bottom Bar user profile and miscellaneous dialogs",
|
||||
"Stats For Nerds": "Cтатистика для умников",
|
||||
"LND version": "LND версия",
|
||||
@ -224,7 +219,6 @@
|
||||
"Current onchain payout fee": "Текущая комиссия за выплату ончейн",
|
||||
"Use stealth invoices": "Использовать стелс инвойсы",
|
||||
"Stealth lightning invoices do not contain details about the trade except an order reference. Enable this setting if you don't want to disclose details to a custodial lightning wallet.": "Стелс Лайтнинг инвойсы не содержат подробностей о сделке, кроме ссылки на ордер. Включите этот параметр, если Вы не хотите раскрывать детали кошельку Лайтнинг.",
|
||||
|
||||
"ORDER PAGE - OrderPage.js": "Order details page",
|
||||
"Order Box": "Окно ордера",
|
||||
"Contract": "Контракт",
|
||||
@ -299,7 +293,6 @@
|
||||
"Payment not received, please check your WebLN wallet.": "Платёж не получен. Пожалуйста, проверьте Ваш WebLN Кошелёк.",
|
||||
"Invoice not received, please check your WebLN wallet.": "Платёж не получен. Пожалуйста, проверьте Ваш WebLN Кошелёк.",
|
||||
"You can close now your WebLN wallet popup.": "Вы можете закрыть всплывающее окно WebLN Кошелька",
|
||||
|
||||
"CHAT BOX - Chat.js": "Chat Box",
|
||||
"You": "Вы",
|
||||
"Peer": "Партнёр",
|
||||
@ -329,7 +322,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": "Окно контракта",
|
||||
"Robots show commitment to their peers": "Роботы демонстрируют приверженность к своим пирам",
|
||||
@ -473,7 +466,6 @@
|
||||
"Timestamp": "Временная метка",
|
||||
"Completed in": "Завершено за",
|
||||
"Contract exchange rate": "Курс обмена контракта",
|
||||
|
||||
"INFO DIALOG - InfoDiagog.js": "App information and clarifications and terms of use",
|
||||
"Close": "Закрыть",
|
||||
"What is RoboSats?": "Что такое RoboSats?",
|
||||
|
Loading…
Reference in New Issue
Block a user