From 3446fc33d3c2040cc1d09881e50ab44116fe7f7f Mon Sep 17 00:00:00 2001 From: Reckless_Satoshi Date: Fri, 11 Nov 2022 01:28:09 -0800 Subject: [PATCH] Add turtle chat component Squashed commit of the following: commit f60870fcfe574dc4ab1343e25241b6ef7cc2721b Author: Reckless_Satoshi Date: Thu Nov 10 10:30:42 2022 -0800 Fix internal error when entering chat commit f1eeb49f2a86575eb2e85cdff20460276e71b806 Author: Reckless_Satoshi Date: Tue Nov 8 10:08:22 2022 -0800 Fix final serializer commit d0c08ba6ad4378a9539c0be83b6f4f8b958b532e Author: Reckless_Satoshi Date: Tue Nov 8 09:44:57 2022 -0800 Chat API changes commit a66bf64edc06d936612db6bf75476b54e6a84334 Author: Reckless_Satoshi Date: Tue Nov 8 09:28:29 2022 -0800 Fix param on post commit 60b18d13c2ec625497323371a2a6f64c9c911e47 Author: Reckless_Satoshi Date: Tue Nov 8 08:56:25 2022 -0800 Fix serializer commit 11212d30eeffde37e07d2e6e5c1fb36df46916ad Author: KoalaSat Date: Sun Nov 6 21:07:18 2022 +0100 CR 2 commit c82790cb81dd9919de97c39f2553974784ffe92d Author: KoalaSat Date: Sun Nov 6 20:09:18 2022 +0100 Fix commit 605a3b69a1fcf795e45b2acba1e12436f8545f8a Author: KoalaSat Date: Sun Nov 6 14:44:42 2022 +0100 CR commit 09776e9c8fa85c253f28c75361829dab5df4d978 Author: KoalaSat Date: Wed Nov 2 18:12:29 2022 +0100 translations commit 432e4d23991164b164d2ab3e4f31790a992dc601 Author: KoalaSat Date: Wed Nov 2 17:39:02 2022 +0100 Switch and better UX commit df6e476613006f6a861bab68f8a4261bc8f641e0 Author: KoalaSat Date: Tue Nov 1 18:20:01 2022 +0100 Unused code commit 5b8d6b4d32980e31bb1d682444b53df1a8e16c47 Author: Reckless_Satoshi Date: Mon Oct 31 09:20:20 2022 -0700 Add Chat Turtle Mode --- api/views.py | 11 +- chat/serializers.py | 9 +- chat/views.py | 13 +- docker-compose.yml | 2 +- .../EncryptedChat/ChatBottom/index.tsx | 61 ++ .../EncryptedChat/ChatHeader/index.tsx | 41 ++ .../EncryptedSocketChat/index.tsx | 328 +++++++++++ .../EncryptedTurtleChat/index.tsx | 314 ++++++++++ .../EncryptedChat/MessageCard/index.tsx | 142 +++++ .../TradeBox/EncryptedChat/index.tsx | 553 ++---------------- frontend/src/components/TradeBox/index.js | 32 + frontend/static/locales/en.json | 11 +- frontend/static/locales/es.json | 10 +- frontend/static/locales/ru.json | 10 +- 14 files changed, 994 insertions(+), 543 deletions(-) create mode 100644 frontend/src/components/TradeBox/EncryptedChat/ChatBottom/index.tsx create mode 100644 frontend/src/components/TradeBox/EncryptedChat/ChatHeader/index.tsx create mode 100644 frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx create mode 100644 frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx create mode 100644 frontend/src/components/TradeBox/EncryptedChat/MessageCard/index.tsx diff --git a/api/views.py b/api/views.py index 5f214d82b..0d61507a1 100644 --- a/api/views.py +++ b/api/views.py @@ -53,7 +53,7 @@ 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 @@ def get(self, request, format=None): 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: diff --git a/chat/serializers.py b/chat/serializers.py index 76d95454d..4d69659c1 100644 --- a/chat/serializers.py +++ b/chat/serializers.py @@ -18,7 +18,7 @@ class Meta: 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 Meta: 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", + ) diff --git a/chat/views.py b/chat/views.py index 1b11617dd..92672214a 100644 --- a/chat/views.py +++ b/chat/views.py @@ -58,6 +58,7 @@ def get(self, request, format=None): 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 @@ def get(self, request, format=None): 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 @@ def get(self, request, format=None): } 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 @@ def post(self, request, format=None): 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 @@ def post(self, request, format=None): 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"], diff --git a/docker-compose.yml b/docker-compose.yml index 49e8f4233..bf02580a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/src/components/TradeBox/EncryptedChat/ChatBottom/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/ChatBottom/index.tsx new file mode 100644 index 000000000..a1de80b27 --- /dev/null +++ b/frontend/src/components/TradeBox/EncryptedChat/ChatBottom/index.tsx @@ -0,0 +1,61 @@ +import React, { useState } from 'react'; +import { Grid, useTheme, Tooltip, Button } from '@mui/material'; +import { ExportIcon } from '../../../Icons'; +import KeyIcon from '@mui/icons-material/Key'; +import { useTranslation } from 'react-i18next'; +import { saveAsJson } from '../../../../utils'; + +interface Props { + orderId: number; + setAudit: (audit: boolean) => void; + audit: boolean; + createJsonFile: () => object; +} + +const ChatBottom: React.FC = ({ orderId, setAudit, audit, createJsonFile }) => { + const { t } = useTranslation(); + const theme = useTheme(); + + return ( + <> + + + + + + + + + + + + + ); +}; + +export default ChatBottom; diff --git a/frontend/src/components/TradeBox/EncryptedChat/ChatHeader/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/ChatHeader/index.tsx new file mode 100644 index 000000000..5a0a07fe2 --- /dev/null +++ b/frontend/src/components/TradeBox/EncryptedChat/ChatHeader/index.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import { Grid, Paper, Typography, useTheme } from '@mui/material'; +import { useTranslation } from 'react-i18next'; + +interface Props { + connected: boolean; + peerConnected: boolean; +} + +const ChatHeader: React.FC = ({ connected, peerConnected }) => { + const { t } = useTranslation(); + const theme = useTheme(); + const connectedColor = theme.palette.mode === 'light' ? '#b5e3b7' : '#153717'; + const connectedTextColor = theme.palette.getContrastText(connectedColor); + + return ( + + + + + + {t('You') + ': '} + {connected ? t('connected') : t('disconnected')} + + + + + + + + {t('Peer') + ': '} + {peerConnected ? t('connected') : t('disconnected')} + + + + + + ); +}; + +export default ChatHeader; diff --git a/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx new file mode 100644 index 000000000..d21929629 --- /dev/null +++ b/frontend/src/components/TradeBox/EncryptedChat/EncryptedSocketChat/index.tsx @@ -0,0 +1,328 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, Tooltip, TextField, Grid, Container, Paper, Typography } from '@mui/material'; +import { encryptMessage, decryptMessage } from '../../../../pgp'; +import { saveAsJson } from '../../../../utils'; +import { AuditPGPDialog } from '../../../Dialogs'; +import { systemClient } from '../../../../services/System'; +import { websocketClient, WebsocketConnection } from '../../../../services/Websocket'; + +// Icons +import CircularProgress from '@mui/material/CircularProgress'; +import KeyIcon from '@mui/icons-material/Key'; +import { useTheme } from '@mui/system'; +import MessageCard from '../MessageCard'; +import ChatHeader from '../ChatHeader'; +import { EncryptedChatMessage, ServerMessage } from '..'; +import ChatBottom from '../ChatBottom'; + +interface Props { + orderId: number; + userNick: string; + takerNick: string; + messages: EncryptedChatMessage[]; + setMessages: (messages: EncryptedChatMessage[]) => void; +} + +const EncryptedSocketChat: React.FC = ({ + orderId, + userNick, + takerNick, + messages, + setMessages, +}: Props): JSX.Element => { + const { t } = useTranslation(); + const theme = useTheme(); + + const audio = new Audio(`/static/assets/sounds/chat-open.mp3`); + const [connected, setConnected] = useState(false); + const [peerConnected, setPeerConnected] = useState(false); + const [ownPubKey] = useState( + (systemClient.getCookie('pub_key') ?? '').split('\\').join('\n'), + ); + const [ownEncPrivKey] = useState( + (systemClient.getCookie('enc_priv_key') ?? '').split('\\').join('\n'), + ); + const [peerPubKey, setPeerPubKey] = useState(); + const [token] = useState(systemClient.getCookie('robot_token') || ''); + const [serverMessages, setServerMessages] = useState([]); + const [value, setValue] = useState(''); + const [connection, setConnection] = useState(); + const [audit, setAudit] = useState(false); + const [waitingEcho, setWaitingEcho] = useState(false); + const [lastSent, setLastSent] = useState('---BLANK---'); + const [messageCount, setMessageCount] = useState(0); + const [receivedIndexes, setReceivedIndexes] = useState([]); + + useEffect(() => { + if (!connected) { + connectWebsocket(); + } + }, [connected]); + + useEffect(() => { + if (messages.length > messageCount) { + audio.play(); + setMessageCount(messages.length); + } + }, [messages, messageCount]); + + useEffect(() => { + if (serverMessages) { + serverMessages.forEach(onMessage); + } + }, [serverMessages]); + + const connectWebsocket = () => { + websocketClient.open(`ws://${window.location.host}/ws/chat/${orderId}/`).then((connection) => { + setConnection(connection); + setConnected(true); + + connection.send({ + message: ownPubKey, + nick: userNick, + }); + + connection.onMessage((message) => setServerMessages((prev) => [...prev, message])); + connection.onClose(() => setConnected(false)); + connection.onError(() => setConnected(false)); + }); + }; + + const createJsonFile: () => object = () => { + return { + credentials: { + own_public_key: ownPubKey, + peer_public_key: peerPubKey, + encrypted_private_key: ownEncPrivKey, + passphrase: token, + }, + messages: messages, + }; + }; + + const onMessage: (message: any) => void = (message) => { + const dataFromServer = JSON.parse(message.data); + + if (dataFromServer && !receivedIndexes.includes(dataFromServer.index)) { + setReceivedIndexes((prev) => [...prev, dataFromServer.index]); + setPeerConnected(dataFromServer.peer_connected); + // If we receive a public key other than ours (our peer key!) + if ( + connection && + dataFromServer.message.substring(0, 36) == `-----BEGIN PGP PUBLIC KEY BLOCK-----` && + dataFromServer.message != ownPubKey + ) { + setPeerPubKey(dataFromServer.message); + connection.send({ + message: `-----SERVE HISTORY-----`, + nick: userNick, + }); + } + // If we receive an encrypted message + else if (dataFromServer.message.substring(0, 27) == `-----BEGIN PGP MESSAGE-----`) { + decryptMessage( + dataFromServer.message.split('\\').join('\n'), + dataFromServer.user_nick == userNick ? ownPubKey : peerPubKey, + ownEncPrivKey, + token, + ).then((decryptedData) => { + setWaitingEcho(waitingEcho ? decryptedData.decryptedMessage !== lastSent : false); + setLastSent(decryptedData.decryptedMessage === lastSent ? '----BLANK----' : lastSent); + setMessages((prev) => { + const existingMessage = prev.find((item) => item.index === dataFromServer.index); + if (existingMessage) { + return prev; + } else { + return [ + ...prev, + { + index: dataFromServer.index, + encryptedMessage: dataFromServer.message.split('\\').join('\n'), + plainTextMessage: decryptedData.decryptedMessage, + validSignature: decryptedData.validSignature, + userNick: dataFromServer.user_nick, + time: dataFromServer.time, + } as EncryptedChatMessage, + ].sort((a, b) => a.index - b.index); + } + }); + }); + } + // We allow plaintext communication. The user must write # to start + // If we receive an plaintext message + else if (dataFromServer.message.substring(0, 1) == '#') { + setMessages((prev: EncryptedChatMessage[]) => { + const existingMessage = prev.find( + (item) => item.plainTextMessage === dataFromServer.message, + ); + if (existingMessage) { + return prev; + } else { + return [ + ...prev, + { + index: prev.length + 0.001, + encryptedMessage: dataFromServer.message, + plainTextMessage: dataFromServer.message, + validSignature: false, + userNick: dataFromServer.user_nick, + time: new Date().toString(), + } as EncryptedChatMessage, + ].sort((a, b) => a.index - b.index); + } + }); + } + } + }; + + const onButtonClicked = (e: any) => { + if (token && value.indexOf(token) !== -1) { + alert( + `Aye! You just sent your own robot token to your peer in chat, that's a catastrophic idea! So bad your message was blocked.`, + ); + setValue(''); + } + // If input string contains '#' send unencrypted and unlogged message + else if (connection && value.substring(0, 1) == '#') { + connection.send({ + message: value, + nick: userNick, + }); + setValue(''); + } + + // Else if message is not empty send message + else if (value != '') { + setValue(''); + setWaitingEcho(true); + setLastSent(value); + encryptMessage(value, ownPubKey, peerPubKey, ownEncPrivKey, token).then( + (encryptedMessage) => { + if (connection) { + connection.send({ + message: encryptedMessage.toString().split('\n').join('\\'), + nick: userNick, + }); + } + }, + ); + } + e.preventDefault(); + }; + + return ( + + +
+ + {messages.map((message, index) => { + const isTaker = takerNick === message.userNick; + const userConnected = message.userNick === userNick ? connected : peerConnected; + + return ( +
  • + +
  • + ); + })} +
    { + if (messages.length > messageCount) el?.scrollIntoView(); + }} + /> + +
    + + + { + setValue(e.target.value); + }} + sx={{ width: '13.7em' }} + /> + + + + + +
    +
    + +
    + + + setAudit(false)} + orderId={Number(orderId)} + messages={messages} + own_pub_key={ownPubKey || ''} + own_enc_priv_key={ownEncPrivKey || ''} + peer_pub_key={peerPubKey || 'Not received yet'} + passphrase={token || ''} + onClickBack={() => setAudit(false)} + /> + + + + + ); +}; + +export default EncryptedSocketChat; diff --git a/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx new file mode 100644 index 000000000..1f319d600 --- /dev/null +++ b/frontend/src/components/TradeBox/EncryptedChat/EncryptedTurtleChat/index.tsx @@ -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 = ({ + 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(false); + const [ownPubKey] = useState( + (systemClient.getCookie('pub_key') ?? '').split('\\').join('\n'), + ); + const [ownEncPrivKey] = useState( + (systemClient.getCookie('enc_priv_key') ?? '').split('\\').join('\n'), + ); + const [peerPubKey, setPeerPubKey] = useState(); + const [token] = useState(systemClient.getCookie('robot_token') || ''); + const [value, setValue] = useState(''); + const [audit, setAudit] = useState(false); + const [waitingEcho, setWaitingEcho] = useState(false); + const [lastSent, setLastSent] = useState('---BLANK---'); + const [messageCount, setMessageCount] = useState(0); + const [serverMessages, setServerMessages] = useState([]); + const [lastIndex, setLastIndex] = useState(0); + + useEffect(() => { + if (messages.length > messageCount) { + audio.play(); + setMessageCount(messages.length); + } + }, [messages, messageCount]); + + useEffect(() => { + if (serverMessages.length > 0 && peerPubKey) { + serverMessages.forEach(onMessage); + } + }, [serverMessages, peerPubKey]); + + useEffect(() => { + if (chatOffset === 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 ( + + +
    + + {messages.map((message, index) => { + const isTaker = takerNick === message.userNick; + const userConnected = message.userNick === userNick ? true : peerConnected; + + return ( +
  • + +
  • + ); + })} +
    { + if (messages.length > messageCount) el?.scrollIntoView(); + }} + /> + +
    + + + { + setValue(e.target.value); + }} + sx={{ width: '13.7em' }} + /> + + + + + +
    +
    + +
    + + + setAudit(false)} + orderId={Number(orderId)} + messages={messages} + own_pub_key={ownPubKey || ''} + own_enc_priv_key={ownEncPrivKey || ''} + peer_pub_key={peerPubKey || 'Not received yet'} + passphrase={token || ''} + onClickBack={() => setAudit(false)} + /> + + + + ); +}; + +export default EncryptedTurtleChat; diff --git a/frontend/src/components/TradeBox/EncryptedChat/MessageCard/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/MessageCard/index.tsx new file mode 100644 index 000000000..b89ae7490 --- /dev/null +++ b/frontend/src/components/TradeBox/EncryptedChat/MessageCard/index.tsx @@ -0,0 +1,142 @@ +import React, { useState } from 'react'; +import { IconButton, Tooltip, Card, CardHeader, useTheme } from '@mui/material'; +import RobotAvatar from '../../../RobotAvatar'; +import { systemClient } from '../../../../services/System'; +import { useTranslation } from 'react-i18next'; + +// Icons +import CheckIcon from '@mui/icons-material/Check'; +import CloseIcon from '@mui/icons-material/Close'; +import ContentCopy from '@mui/icons-material/ContentCopy'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import { EncryptedChatMessage } from '..'; + +interface Props { + message: EncryptedChatMessage; + isTaker: boolean; + userConnected: boolean; +} + +const MessageCard: React.FC = ({ message, isTaker, userConnected }) => { + const [showPGP, setShowPGP] = useState(); + const { t } = useTranslation(); + const theme = useTheme(); + + const takerCardColor = theme.palette.mode === 'light' ? '#d1e6fa' : '#082745'; + const makerCardColor = theme.palette.mode === 'light' ? '#f2d5f6' : '#380d3f'; + const cardColor = isTaker ? takerCardColor : makerCardColor; + + return ( + + + } + style={{ backgroundColor: cardColor }} + title={ + +
    +
    + {message.userNick} + {message.validSignature ? ( + + ) : ( + + )} +
    +
    + setShowPGP(!showPGP)} + > + + +
    +
    + + + systemClient.copyToClipboard( + showPGP ? message.encryptedMessage : message.plainTextMessage, + ) + } + > + + + +
    +
    +
    + } + subheader={ + showPGP ? ( + + {' '} + {message.time}
    {'Valid signature: ' + message.validSignature}
    {' '} + {message.encryptedMessage}{' '} +
    + ) : ( + message.plainTextMessage + ) + } + subheaderTypographyProps={{ + sx: { + wordWrap: 'break-word', + width: '14.3em', + position: 'relative', + right: '1.5em', + textAlign: 'left', + fontSize: showPGP ? theme.typography.fontSize * 0.78 : null, + }, + }} + /> +
    + ); +}; + +export default MessageCard; diff --git a/frontend/src/components/TradeBox/EncryptedChat/index.tsx b/frontend/src/components/TradeBox/EncryptedChat/index.tsx index 82af72567..fbc473b91 100644 --- a/frontend/src/components/TradeBox/EncryptedChat/index.tsx +++ b/frontend/src/components/TradeBox/EncryptedChat/index.tsx @@ -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 = ({ orderId, userNick, baseUrl }: Props): JSX.Element => { - const { t } = useTranslation(); - const theme = useTheme(); - - const audio = new Audio(`/static/assets/sounds/chat-open.mp3`); - const [connected, setConnected] = useState(false); - const [peerConnected, setPeerConnected] = useState(false); - const [ownPubKey] = useState( - (systemClient.getItem('pub_key') ?? '').split('\\').join('\n'), - ); - const [ownEncPrivKey] = useState( - (systemClient.getItem('enc_priv_key') ?? '').split('\\').join('\n'), - ); - const [peerPubKey, setPeerPubKey] = useState(); - const [token] = useState(systemClient.getItem('robot_token') || ''); - const [messages, setMessages] = useState([]); - const [serverMessages, setServerMessages] = useState([]); - const [value, setValue] = useState(''); - const [connection, setConnection] = useState(); - const [audit, setAudit] = useState(false); - const [showPGP, setShowPGP] = useState([]); - const [waitingEcho, setWaitingEcho] = useState(false); - const [lastSent, setLastSent] = useState('---BLANK---'); - const [messageCount, setMessageCount] = useState(0); - const [receivedIndexes, setReceivedIndexes] = useState([]); - - useEffect(() => { - if (!connected) { - connectWebsocket(); - } - }, [connected]); - - useEffect(() => { - if (messages.length > messageCount) { - audio.play(); - setMessageCount(messages.length); - } - }, [messages, messageCount]); - - useEffect(() => { - if (serverMessages) { - serverMessages.forEach(onMessage); - } - }, [serverMessages]); - - const connectWebsocket = () => { - websocketClient.open(`ws://${window.location.host}/ws/chat/${orderId}/`).then((connection) => { - setConnection(connection); - setConnected(true); - - connection.send({ - message: ownPubKey, - nick: userNick, - }); - - connection.onMessage((message) => setServerMessages((prev) => [...prev, message])); - connection.onClose(() => setConnected(false)); - connection.onError(() => setConnected(false)); - }); - }; - - const createJsonFile: () => object = () => { - return { - credentials: { - own_public_key: ownPubKey, - peer_public_key: peerPubKey, - encrypted_private_key: ownEncPrivKey, - passphrase: token, - }, - messages: messages, - }; - }; - - const onMessage: (message: any) => void = (message) => { - const dataFromServer = JSON.parse(message.data); - - if (dataFromServer && !receivedIndexes.includes(dataFromServer.index)) { - setReceivedIndexes((prev) => [...prev, dataFromServer.index]); - setPeerConnected(dataFromServer.peer_connected); - // If we receive a public key other than ours (our peer key!) - if ( - connection && - dataFromServer.message.substring(0, 36) == `-----BEGIN PGP PUBLIC KEY BLOCK-----` && - dataFromServer.message != ownPubKey - ) { - setPeerPubKey(dataFromServer.message); - connection.send({ - message: `-----SERVE HISTORY-----`, - nick: userNick, - }); - } - // If we receive an encrypted message - else if (dataFromServer.message.substring(0, 27) == `-----BEGIN PGP MESSAGE-----`) { - decryptMessage( - dataFromServer.message.split('\\').join('\n'), - dataFromServer.user_nick == userNick ? ownPubKey : peerPubKey, - ownEncPrivKey, - token, - ).then((decryptedData) => { - setWaitingEcho(waitingEcho ? decryptedData.decryptedMessage !== lastSent : false); - setLastSent(decryptedData.decryptedMessage === lastSent ? '----BLANK----' : lastSent); - setMessages((prev) => { - const existingMessage = prev.find((item) => item.index === dataFromServer.index); - if (existingMessage) { - return prev; - } else { - return [ - ...prev, - { - index: dataFromServer.index, - encryptedMessage: dataFromServer.message.split('\\').join('\n'), - plainTextMessage: decryptedData.decryptedMessage, - validSignature: decryptedData.validSignature, - userNick: dataFromServer.user_nick, - time: dataFromServer.time, - } as EncryptedChatMessage, - ].sort((a, b) => a.index - b.index); - } - }); - }); - } - // We allow plaintext communication. The user must write # to start - // If we receive an plaintext message - else if (dataFromServer.message.substring(0, 1) == '#') { - setMessages((prev) => { - const existingMessage = prev.find( - (item) => item.plainTextMessage === dataFromServer.message, - ); - if (existingMessage) { - return prev; - } else { - return [ - ...prev, - { - index: prev.length + 0.001, - encryptedMessage: dataFromServer.message, - plainTextMessage: dataFromServer.message, - validSignature: false, - userNick: dataFromServer.user_nick, - time: new Date().toString(), - } as EncryptedChatMessage, - ].sort((a, b) => a.index - b.index); - } - }); - } - } - }; - - const onButtonClicked = (e: any) => { - if (token && value.indexOf(token) !== -1) { - alert( - `Aye! You just sent your own robot token to your peer in chat, that's a catastrophic idea! So bad your message was blocked.`, - ); - setValue(''); - } - // If input string contains '#' send unencrypted and unlogged message - else if (connection && value.substring(0, 1) == '#') { - connection.send({ - message: value, - nick: userNick, - }); - setValue(''); - } - - // Else if message is not empty send message - else if (value != '') { - setValue(''); - setWaitingEcho(true); - setLastSent(value); - encryptMessage(value, ownPubKey, peerPubKey, ownEncPrivKey, token).then( - (encryptedMessage) => { - if (connection) { - connection.send({ - message: encryptedMessage.toString().split('\n').join('\\'), - nick: userNick, - }); - } - }, - ); - } - e.preventDefault(); - }; - - const messageCard: ( - message: EncryptedChatMessage, - index: number, - cardColor: string, - userConnected: boolean, - ) => JSX.Element = (message, index, cardColor, userConnected) => { - return ( - - - } - style={{ backgroundColor: cardColor }} - title={ - -
    -
    - {message.userNick} - {message.validSignature ? ( - - ) : ( - - )} -
    -
    - { - const newShowPGP = [...showPGP]; - newShowPGP[index] = !newShowPGP[index]; - setShowPGP(newShowPGP); - }} - > - - -
    -
    - - - systemClient.copyToClipboard( - showPGP[index] ? message.encryptedMessage : message.plainTextMessage, - ) - } - > - - - -
    -
    -
    - } - subheader={ - showPGP[index] ? ( - - {' '} - {message.time}
    {'Valid signature: ' + message.validSignature}
    {' '} - {message.encryptedMessage}{' '} -
    - ) : ( - message.plainTextMessage - ) - } - subheaderTypographyProps={{ - sx: { - wordWrap: 'break-word', - width: '14.3em', - position: 'relative', - right: '1.5em', - textAlign: 'left', - fontSize: showPGP[index] ? theme.typography.fontSize * 0.78 : null, - }, - }} - /> -
    - ); - }; - - const connectedColor = theme.palette.mode === 'light' ? '#b5e3b7' : '#153717'; - const connectedTextColor = theme.palette.getContrastText(connectedColor); - const ownCardColor = theme.palette.mode === 'light' ? '#d1e6fa' : '#082745'; - const peerCardColor = theme.palette.mode === 'light' ? '#f2d5f6' : '#380d3f'; - - return ( - - - - - - - {t('You') + ': '} - {connected ? t('connected') : t('disconnected')} - - - - - - - - {t('Peer') + ': '} - {peerConnected ? t('connected') : t('disconnected')} - - - - - -
    - - {messages.map((message, index) => ( -
  • - {message.userNick == userNick - ? messageCard(message, index, ownCardColor, connected) - : messageCard(message, index, peerCardColor, peerConnected)} -
  • - ))} -
    { - if (messages.length > messageCount) el?.scrollIntoView(); - }} - /> - -
    - - - { - setValue(e.target.value); - }} - sx={{ width: '13.7em' }} - /> - - - - - -
    -
    - -
    - - - setAudit(false)} - orderId={Number(orderId)} - messages={messages} - own_pub_key={ownPubKey || ''} - own_enc_priv_key={ownEncPrivKey || ''} - peer_pub_key={peerPubKey || 'Not received yet'} - passphrase={token || ''} - onClickBack={() => setAudit(false)} - /> +export interface EncryptedChatMessage { + userNick: string; + validSignature: boolean; + plainTextMessage: string; + encryptedMessage: string; + time: string; + index: number; +} - - - - - +export interface ServerMessage { + message: string; + time: string; + index: number; + nick: string; +} - - - - - - - +const EncryptedChat: React.FC = ({ + turtleMode, + orderId, + takerNick, + userNick, + chatOffset, + baseUrl, +}: Props): JSX.Element => { + const [messages, setMessages] = useState([]); + + return turtleMode ? ( + + ) : ( + ); }; diff --git a/frontend/src/components/TradeBox/index.js b/frontend/src/components/TradeBox/index.js index 9e10d4f71..79340e7a8 100644 --- a/frontend/src/components/TradeBox/index.js +++ b/frontend/src/components/TradeBox/index.js @@ -19,6 +19,7 @@ import { ListItem, ListItemText, Divider, + Switch, ListItemIcon, Dialog, DialogActions, @@ -37,6 +38,7 @@ import { apiClient } from '../../services/api'; // Icons import PercentIcon from '@mui/icons-material/Percent'; +import SelfImprovement from '@mui/icons-material/SelfImprovement'; import BookIcon from '@mui/icons-material/Book'; import LockIcon from '@mui/icons-material/Lock'; import LockOpenIcon from '@mui/icons-material/LockOpen'; @@ -47,6 +49,7 @@ import PlayCircleIcon from '@mui/icons-material/PlayCircle'; import BoltIcon from '@mui/icons-material/Bolt'; import LinkIcon from '@mui/icons-material/Link'; import AccountBalanceWalletIcon from '@mui/icons-material/AccountBalanceWallet'; +import WifiTetheringErrorIcon from '@mui/icons-material/WifiTetheringError'; import FavoriteIcon from '@mui/icons-material/Favorite'; import RocketLaunchIcon from '@mui/icons-material/RocketLaunch'; import RefreshIcon from '@mui/icons-material/Refresh'; @@ -72,6 +75,7 @@ class TradeBox extends Component { badInvoice: false, badAddress: false, badStatement: false, + turtleMode: false, }; } @@ -1438,6 +1442,29 @@ class TradeBox extends Component { {' '} {' ' + this.stepXofY()} + + +
    + this.setState({ turtleMode: !this.state.turtleMode })} + /> + +
    +
    +
    {this.props.data.is_seller ? ( @@ -1470,8 +1497,13 @@ class TradeBox extends Component { diff --git a/frontend/static/locales/en.json b/frontend/static/locales/en.json index edd0ee795..517ca5e45 100644 --- a/frontend/static/locales/en.json +++ b/frontend/static/locales/en.json @@ -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?", diff --git a/frontend/static/locales/es.json b/frontend/static/locales/es.json index a557695bb..38aeda1b2 100644 --- a/frontend/static/locales/es.json +++ b/frontend/static/locales/es.json @@ -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?", diff --git a/frontend/static/locales/ru.json b/frontend/static/locales/ru.json index 6737ef45b..abebe959b 100644 --- a/frontend/static/locales/ru.json +++ b/frontend/static/locales/ru.json @@ -4,7 +4,6 @@ "desktop_unsafe_alert": "Некоторые функции отключены для Вашей безопасности (чат) и без них у Вас не будет возможности завершить сделку. Чтобы защитить Вашу конфиденциальность и полностью включить RoboSats, используйте <1>Tor Browser и посетите <3>Onion сайт.", "phone_unsafe_alert": "У Вас не будет возможности завершить сделку. Используйте <1>Tor Browser и посетите <3>Onion сайт.", "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?",