mirror of
https://github.com/darkwire/darkwire.io.git
synced 2025-07-18 18:54:52 +00:00
plop
This commit is contained in:
parent
de53ea24be
commit
f99bc83a3a
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
*
|
||||||
|
!package.json
|
||||||
|
!yarn.lock
|
||||||
|
!build.sh
|
||||||
|
!default.conf
|
||||||
|
!start.sh
|
||||||
|
!server/*
|
||||||
|
!client/*
|
||||||
|
**/node_modules/*
|
5
.gitignore
vendored
5
.gitignore
vendored
@ -1,5 +0,0 @@
|
|||||||
.DS_Store
|
|
||||||
node_modules
|
|
||||||
*.log
|
|
||||||
*sublime*
|
|
||||||
*.rdb
|
|
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"trailingComma": "all",
|
||||||
|
"tabWidth": 2,
|
||||||
|
"singleQuote": true,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"printWidth": 120
|
||||||
|
}
|
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
#FROM nginx:alpine3.18
|
||||||
|
FROM node:20.9.0-alpine3.18
|
||||||
|
|
||||||
|
WORKDIR /home/node
|
||||||
|
COPY --chown=node:node . .
|
||||||
|
|
||||||
|
RUN apk update && apk add --no-cache bash nginx openssl && \
|
||||||
|
rm /etc/nginx/http.d/default.conf && \
|
||||||
|
mv /home/node/default.conf /etc/nginx/http.d/ && \
|
||||||
|
chmod +x /home/node/start.sh && \
|
||||||
|
npm install -g yarn@latest --force && \
|
||||||
|
yarn upgrade --no-cache && \
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
|
||||||
|
STOPSIGNAL SIGINT
|
||||||
|
# Start the startup script
|
||||||
|
CMD ["/home/node/start.sh"]
|
23
LICENSE
Normal file
23
LICENSE
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
|
||||||
|
(The MIT License)
|
||||||
|
|
||||||
|
Copyright (c) 2016-present darkwire.io
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of this software and associated documentation files (the
|
||||||
|
'Software'), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish,
|
||||||
|
distribute, sublicense, and/or sell copies of the Software, and to
|
||||||
|
permit persons to whom the Software is furnished to do so, subject to
|
||||||
|
the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be
|
||||||
|
included in all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
|
||||||
|
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||||
|
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
|
||||||
|
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
|
||||||
|
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
54
app.json
Normal file
54
app.json
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "Darkwire",
|
||||||
|
"description": "End-to-end encrypted web chat",
|
||||||
|
"keywords": [
|
||||||
|
"cryptography",
|
||||||
|
"chat",
|
||||||
|
"privacy"
|
||||||
|
],
|
||||||
|
"website": "https://darkwire.io",
|
||||||
|
"repository": "https://github.com/darkwire/darkwire.io",
|
||||||
|
"env": {
|
||||||
|
"HEROKU_APP_NAME": {
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"HEROKU_PARENT_APP_NAME": {
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"MAILGUN_API_KEY": {
|
||||||
|
"description": "Mailgun API Key (only required for abuse reporting)",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"ABUSE_TO_EMAIL_ADDRESS": {
|
||||||
|
"description": "Where to send abuse reports (only required for abuse reporting)",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"MAILGUN_DOMAIN": {
|
||||||
|
"description": "Mailgun domain (only required for abuse reporting)",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"ABUSE_FROM_EMAIL_ADDRESS": {
|
||||||
|
"description": "From address on abuse emails (only required for abuse reporting)",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"API_HOST": {
|
||||||
|
"description": "Example: 'api.your-darkwire-api.com'",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"API_PROTOCOL": {
|
||||||
|
"description": "Example: 'https'",
|
||||||
|
"required": false,
|
||||||
|
"value": "https"
|
||||||
|
},
|
||||||
|
"API_PORT": {
|
||||||
|
"description": "Example: 443",
|
||||||
|
"required": false,
|
||||||
|
"value": "443"
|
||||||
|
},
|
||||||
|
"SITE_URL": {
|
||||||
|
"description": "Full URL of site. Example: https://darkwire.io",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"image": "heroku/nodejs"
|
||||||
|
}
|
14
build.sh
Normal file
14
build.sh
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
|
||||||
|
api_host=$API_HOST
|
||||||
|
|
||||||
|
echo "building client..."
|
||||||
|
cd client
|
||||||
|
yarn upgrade
|
||||||
|
yarn build
|
||||||
|
cd ../
|
||||||
|
|
||||||
|
echo "building server..."
|
||||||
|
cd server
|
||||||
|
yarn upgrade
|
||||||
|
yarn build
|
14
client/.env.dist
Normal file
14
client/.env.dist
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Api settings
|
||||||
|
TZ=UTC
|
||||||
|
VITE_API_HOST=localhost
|
||||||
|
VITE_API_PROTOCOL=http
|
||||||
|
VITE_API_PORT=3001
|
||||||
|
VITE_COMMIT_SHA=some_sha
|
||||||
|
|
||||||
|
# To display darkwire version
|
||||||
|
VITE_COMMIT_SHA=some_sha
|
||||||
|
|
||||||
|
# Set max transferable file size in MB
|
||||||
|
VITE_MAX_FILE_SIZE=4
|
||||||
|
|
||||||
|
|
10
client/.eslintrc.cjs
Normal file
10
client/.eslintrc.cjs
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
module.exports = {
|
||||||
|
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
plugins: ['@typescript-eslint'],
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
};
|
25
client/.gitignore
vendored
Normal file
25
client/.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist/
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
21
client/LICENSE
Normal file
21
client/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2016-present darkwire.io
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
11
client/README.md
Normal file
11
client/README.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Darkwire Client
|
||||||
|
|
||||||
|
This is the client for [Darkwire](https://github.com/darkwire/darkwire.io). It requires [darkwire-server](../server) in order to run.
|
||||||
|
|
||||||
|
## Translations
|
||||||
|
|
||||||
|
Translation strings are pulled from JSON files in [this directory](https://github.com/darkwire/darkwire.io/tree/master/client/src/i18n). We welcome pull requests to add support for more lanuages. For native speakers, it should take only a few minutes to translate the site.
|
||||||
|
|
||||||
|
Please see [this PR](https://github.com/darkwire/darkwire.io/pull/95) for an example of what your translation PR should look like.
|
||||||
|
|
||||||
|
Thank you for your contributions!
|
17
client/index.html
Normal file
17
client/index.html
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class="h-100">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="shortcut icon" href="/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<meta name="robots" content="index,nofollow" />
|
||||||
|
<meta name="googlebot" content="index,nofollow" />
|
||||||
|
<meta name="description" content="darkwire.io is the simplest way to chat with encryption online." />
|
||||||
|
<title>Darkwire.io - instant encrypted web chat</title>
|
||||||
|
</head>
|
||||||
|
<body class="h-100">
|
||||||
|
<div id="root" class="h-100"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
9
client/jsconfig.json
Normal file
9
client/jsconfig.json
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "src",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
77
client/package.json
Normal file
77
client/package.json
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
{
|
||||||
|
"name": "darkwire-client",
|
||||||
|
"version": "2.0.0-beta.12",
|
||||||
|
"main": "index.js",
|
||||||
|
"type": "module",
|
||||||
|
"contributors": [
|
||||||
|
{
|
||||||
|
"name": "Daniel Seripap"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alan Friedman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"bootstrap": "^5.3.2",
|
||||||
|
"classnames": "^2.3.2",
|
||||||
|
"clipboard": "^2.0.11",
|
||||||
|
"jquery": "3",
|
||||||
|
"moment": "^2.29.4",
|
||||||
|
"nanoid": "^5.0.4",
|
||||||
|
"randomcolor": "^0.6.2",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-feather": "^2.0.10",
|
||||||
|
"react-linkify": "^1.0.0-alpha",
|
||||||
|
"react-modal": "^3.16.1",
|
||||||
|
"react-redux": "^8.0.5",
|
||||||
|
"react-router": "^6.4.4",
|
||||||
|
"react-router-dom": "^6.4.4",
|
||||||
|
"react-simple-dropdown": "^3.2.3",
|
||||||
|
"redux": "^4.2.0",
|
||||||
|
"redux-thunk": "^2.4.2",
|
||||||
|
"sanitize-html": "^2.7.3",
|
||||||
|
"socket.io-client": "^4.5.4",
|
||||||
|
"tinycon": "^0.6.8"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "TZ=UTC vitest",
|
||||||
|
"lint": "eslint src",
|
||||||
|
"coverage": "TZ=UTC vitest --coverage --watch=false"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/jest-dom": "^6.1.6",
|
||||||
|
"@testing-library/react": "^14.1.2",
|
||||||
|
"@testing-library/user-event": "^14.4.3",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.16.0",
|
||||||
|
"@typescript-eslint/parser": "^6.16.0",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"@vitest/coverage-istanbul": "^1.1.0",
|
||||||
|
"eslint": "^8.29.0",
|
||||||
|
"eslint-config-prettier": "^9.1.0",
|
||||||
|
"eslint-plugin-prettier": "^5.1.2",
|
||||||
|
"jest-environment-jsdom-sixteen": "^2.0.0",
|
||||||
|
"jest-fetch-mock": "^3.0.3",
|
||||||
|
"prettier": "^3.1.1",
|
||||||
|
"sass": "^1.69.6",
|
||||||
|
"typescript": "^5.3.3",
|
||||||
|
"vitest": "^1.1.0",
|
||||||
|
"vitest-fetch-mock": "^0.2.1"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
BIN
client/public/favicon.ico
Normal file
BIN
client/public/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.3 KiB |
15
client/public/manifest.json
Normal file
15
client/public/manifest.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"short_name": "Darkwire",
|
||||||
|
"name": "Darkwire.io - encrypted web chat",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "favicon.ico",
|
||||||
|
"sizes": "64x64 32x32 24x24 16x16",
|
||||||
|
"type": "image/x-icon"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"background_color": "#ffffff"
|
||||||
|
}
|
2
client/public/robots.txt
Normal file
2
client/public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
44
client/src/actions/app.js
Normal file
44
client/src/actions/app.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
export const openModal = payload => ({ type: 'OPEN_MODAL', payload });
|
||||||
|
export const closeModal = () => ({ type: 'CLOSE_MODAL' });
|
||||||
|
|
||||||
|
export const setScrolledToBottom = payload => ({ type: 'SET_SCROLLED_TO_BOTTOM', payload });
|
||||||
|
|
||||||
|
export const showNotice = payload => async dispatch => {
|
||||||
|
dispatch({ type: 'SHOW_NOTICE', payload });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleWindowFocus = payload => async dispatch => {
|
||||||
|
dispatch({ type: 'TOGGLE_WINDOW_FOCUS', payload });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleSoundEnabled = payload => async dispatch => {
|
||||||
|
dispatch({ type: 'TOGGLE_SOUND_ENABLED', payload });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const togglePersistenceEnabled = payload => async dispatch => {
|
||||||
|
dispatch({ type: 'TOGGLE_PERSISTENCE_ENABLED', payload });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleNotificationEnabled = payload => async dispatch => {
|
||||||
|
dispatch({ type: 'TOGGLE_NOTIFICATION_ENABLED', payload });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleNotificationAllowed = payload => async dispatch => {
|
||||||
|
dispatch({ type: 'TOGGLE_NOTIFICATION_ALLOWED', payload });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toggleSocketConnected = payload => async dispatch => {
|
||||||
|
dispatch({ type: 'TOGGLE_SOCKET_CONNECTED', payload });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createUser = payload => async dispatch => {
|
||||||
|
dispatch({ type: 'CREATE_USER', payload });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const clearActivities = () => async dispatch => {
|
||||||
|
dispatch({ type: 'CLEAR_ACTIVITIES' });
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setLanguage = payload => async dispatch => {
|
||||||
|
dispatch({ type: 'CHANGE_LANGUAGE', payload });
|
||||||
|
};
|
55
client/src/actions/app.test.js
Normal file
55
client/src/actions/app.test.js
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import * as actions from './app';
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
describe('App actions', () => {
|
||||||
|
it('should create an action to scroll to bottom', () => {
|
||||||
|
expect(actions.setScrolledToBottom('test')).toEqual({
|
||||||
|
type: 'SET_SCROLLED_TO_BOTTOM',
|
||||||
|
payload: 'test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an action to close modal', () => {
|
||||||
|
expect(actions.closeModal()).toEqual({
|
||||||
|
type: 'CLOSE_MODAL',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an action to open modal', () => {
|
||||||
|
expect(actions.openModal('test')).toEqual({
|
||||||
|
type: 'OPEN_MODAL',
|
||||||
|
payload: 'test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an action to clear activities', () => {
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
|
||||||
|
actions.clearActivities()(mockDispatch);
|
||||||
|
|
||||||
|
expect(mockDispatch).toHaveBeenLastCalledWith({
|
||||||
|
type: 'CLEAR_ACTIVITIES',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('should create all actions', () => {
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
|
||||||
|
const actionsResults = [
|
||||||
|
[actions.toggleWindowFocus('test'), 'TOGGLE_WINDOW_FOCUS'],
|
||||||
|
[actions.showNotice('test'), 'SHOW_NOTICE'],
|
||||||
|
[actions.toggleSoundEnabled('test'), 'TOGGLE_SOUND_ENABLED'],
|
||||||
|
[actions.toggleSocketConnected('test'), 'TOGGLE_SOCKET_CONNECTED'],
|
||||||
|
[actions.createUser('test'), 'CREATE_USER'],
|
||||||
|
[actions.setLanguage('test'), 'CHANGE_LANGUAGE'],
|
||||||
|
];
|
||||||
|
|
||||||
|
actionsResults.forEach(([action, type]) => {
|
||||||
|
action(mockDispatch);
|
||||||
|
expect(mockDispatch).toHaveBeenLastCalledWith({
|
||||||
|
type,
|
||||||
|
payload: 'test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
16
client/src/actions/encrypted_messages.js
Normal file
16
client/src/actions/encrypted_messages.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { getSocket } from '@/utils/socket';
|
||||||
|
import { prepare as prepareMessage, process as processMessage } from '@/utils/message';
|
||||||
|
|
||||||
|
export const sendEncryptedMessage = payload => async (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const msg = await prepareMessage(payload, state);
|
||||||
|
dispatch({ type: `SEND_ENCRYPTED_MESSAGE_${msg.original.type}`, payload: msg.original.payload });
|
||||||
|
getSocket().emit('ENCRYPTED_MESSAGE', msg.toSend);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const receiveEncryptedMessage = payload => async (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const message = await processMessage(payload, state);
|
||||||
|
// Pass current state to all RECEIVE_ENCRYPTED_MESSAGE reducers for convenience, since each may have different needs
|
||||||
|
dispatch({ type: `RECEIVE_ENCRYPTED_MESSAGE_${message.type}`, payload: { payload: message.payload, state } });
|
||||||
|
};
|
51
client/src/actions/encrypted_messages.test.js
Normal file
51
client/src/actions/encrypted_messages.test.js
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
import * as actions from './encrypted_messages';
|
||||||
|
import { getSocket } from '@/utils/socket';
|
||||||
|
import { prepare as prepareMessage, process as processMessage } from '@/utils/message';
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('@/utils/message', () => {
|
||||||
|
return {
|
||||||
|
prepare: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ original: { type: 'messageType', payload: 'test' }, toSend: 'encryptedpayload' }),
|
||||||
|
process: vi.fn().mockResolvedValue({ type: 'messageType', payload: 'test' }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockEmit = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('@/utils/socket', () => {
|
||||||
|
return {
|
||||||
|
getSocket: vi.fn().mockImplementation(() => ({
|
||||||
|
emit: mockEmit,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Encrypted messages actions', () => {
|
||||||
|
it('should create an action to send message', async () => {
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
|
||||||
|
await actions.sendEncryptedMessage({ payload: 'payload' })(mockDispatch, vi.fn().mockReturnValue({ state: {} }));
|
||||||
|
|
||||||
|
expect(prepareMessage).toHaveBeenLastCalledWith({ payload: 'payload' }, { state: {} });
|
||||||
|
expect(mockDispatch).toHaveBeenLastCalledWith({ payload: 'test', type: 'SEND_ENCRYPTED_MESSAGE_messageType' });
|
||||||
|
expect(getSocket().emit).toHaveBeenLastCalledWith('ENCRYPTED_MESSAGE', 'encryptedpayload');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create an action to receive message', async () => {
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
|
||||||
|
await actions.receiveEncryptedMessage({ payload: 'encrypted' })(
|
||||||
|
mockDispatch,
|
||||||
|
vi.fn().mockReturnValue({ state: {} }),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(processMessage).toHaveBeenLastCalledWith({ payload: 'encrypted' }, { state: {} });
|
||||||
|
expect(mockDispatch).toHaveBeenLastCalledWith({
|
||||||
|
payload: { payload: 'test', state: { state: {} } },
|
||||||
|
type: 'RECEIVE_ENCRYPTED_MESSAGE_messageType',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
5
client/src/actions/index.js
Normal file
5
client/src/actions/index.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/* istanbul ignore file */
|
||||||
|
|
||||||
|
export * from './app';
|
||||||
|
export * from './unencrypted_messages';
|
||||||
|
export * from './encrypted_messages';
|
80
client/src/actions/unencrypted_messages.js
Normal file
80
client/src/actions/unencrypted_messages.js
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { getSocket } from '@/utils/socket';
|
||||||
|
|
||||||
|
const receiveUserEnter = (payload, dispatch) => {
|
||||||
|
dispatch({ type: 'USER_ENTER', payload });
|
||||||
|
};
|
||||||
|
|
||||||
|
const receiveToggleLockRoom = (payload, dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
|
||||||
|
const lockedByUser = state.room.members.find(m => m.publicKey.n === payload.publicKey.n);
|
||||||
|
const lockedByUsername = lockedByUser.username;
|
||||||
|
const lockedByUserId = lockedByUser.id;
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'RECEIVE_TOGGLE_LOCK_ROOM',
|
||||||
|
payload: {
|
||||||
|
username: lockedByUsername,
|
||||||
|
locked: payload.locked,
|
||||||
|
id: lockedByUserId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const receiveUserExit = (payload, dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
const payloadPublicKeys = payload.map(member => member.publicKey.n);
|
||||||
|
const exitingUser = state.room.members.find(m => !payloadPublicKeys.includes(m.publicKey.n));
|
||||||
|
|
||||||
|
if (!exitingUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const exitingUserId = exitingUser.id;
|
||||||
|
const exitingUsername = exitingUser.username;
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'USER_EXIT',
|
||||||
|
payload: {
|
||||||
|
members: payload,
|
||||||
|
id: exitingUserId,
|
||||||
|
username: exitingUsername,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const receiveUnencryptedMessage = (type, payload) => async (dispatch, getState) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'USER_ENTER':
|
||||||
|
return receiveUserEnter(payload, dispatch);
|
||||||
|
case 'USER_EXIT':
|
||||||
|
return receiveUserExit(payload, dispatch, getState);
|
||||||
|
case 'TOGGLE_LOCK_ROOM':
|
||||||
|
return receiveToggleLockRoom(payload, dispatch, getState);
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendToggleLockRoom = (dispatch, getState) => {
|
||||||
|
const state = getState();
|
||||||
|
getSocket().emit('TOGGLE_LOCK_ROOM', null, res => {
|
||||||
|
dispatch({
|
||||||
|
type: 'TOGGLE_LOCK_ROOM',
|
||||||
|
payload: {
|
||||||
|
locked: res.isLocked,
|
||||||
|
username: state.user.username,
|
||||||
|
sender: state.user.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const sendUnencryptedMessage = type => async (dispatch, getState) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'TOGGLE_LOCK_ROOM':
|
||||||
|
return sendToggleLockRoom(dispatch, getState);
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
126
client/src/actions/unencrypted_messages.test.js
Normal file
126
client/src/actions/unencrypted_messages.test.js
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
import * as actions from './unencrypted_messages';
|
||||||
|
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mockEmit = vi.fn((_type, _null, callback) => {
|
||||||
|
callback({ isLocked: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/utils/socket', () => {
|
||||||
|
return {
|
||||||
|
getSocket: vi.fn().mockImplementation(() => ({
|
||||||
|
emit: mockEmit,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Receive unencrypted message actions', () => {
|
||||||
|
it('should create no action', () => {
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
actions.receiveUnencryptedMessage('FAKE')(mockDispatch, vi.fn().mockReturnValue({}));
|
||||||
|
expect(mockDispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create user enter action', () => {
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
actions.receiveUnencryptedMessage('USER_ENTER', 'test')(mockDispatch, vi.fn().mockReturnValue({ state: {} }));
|
||||||
|
expect(mockDispatch).toHaveBeenLastCalledWith({ type: 'USER_ENTER', payload: 'test' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create user exit action', () => {
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
const state = {
|
||||||
|
room: {
|
||||||
|
members: [
|
||||||
|
{ publicKey: { n: 'alankey' }, id: 'alankey', username: 'alan' },
|
||||||
|
{ publicKey: { n: 'dankey' }, id: 'dankey', username: 'dan' },
|
||||||
|
{ publicKey: { n: 'alicekey' }, id: 'alicekey', username: 'dan' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const mockGetState = vi.fn().mockReturnValue(state);
|
||||||
|
const payload1 = [
|
||||||
|
{ publicKey: { n: 'alankey' } },
|
||||||
|
{ publicKey: { n: 'dankey' } },
|
||||||
|
{ publicKey: { n: 'alicekey' } },
|
||||||
|
];
|
||||||
|
const payload2 = [{ publicKey: { n: 'dankey' } }, { publicKey: { n: 'alicekey' } }];
|
||||||
|
|
||||||
|
// Nobody left
|
||||||
|
actions.receiveUnencryptedMessage('USER_EXIT', payload1)(mockDispatch, mockGetState);
|
||||||
|
|
||||||
|
expect(mockDispatch).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
actions.receiveUnencryptedMessage('USER_EXIT', payload2)(mockDispatch, mockGetState);
|
||||||
|
expect(mockDispatch).toHaveBeenLastCalledWith({
|
||||||
|
payload: {
|
||||||
|
id: 'alankey',
|
||||||
|
members: [{ publicKey: { n: 'dankey' } }, { publicKey: { n: 'alicekey' } }],
|
||||||
|
username: 'alan',
|
||||||
|
},
|
||||||
|
type: 'USER_EXIT',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create receive toggle lock room action', () => {
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
const state = {
|
||||||
|
room: {
|
||||||
|
members: [
|
||||||
|
{ publicKey: { n: 'alankey' }, id: 'idalan', username: 'alan' },
|
||||||
|
{ publicKey: { n: 'dankey' }, id: 'iddan', username: 'dan' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const mockGetState = vi.fn().mockReturnValue(state);
|
||||||
|
const payload = { publicKey: { n: 'alankey' } };
|
||||||
|
|
||||||
|
actions.receiveUnencryptedMessage('TOGGLE_LOCK_ROOM', payload)(mockDispatch, mockGetState);
|
||||||
|
expect(mockDispatch).toHaveBeenLastCalledWith({
|
||||||
|
payload: { id: 'idalan', locked: undefined, username: 'alan' },
|
||||||
|
type: 'RECEIVE_TOGGLE_LOCK_ROOM',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create receive toggle lock room action', () => {
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
const state = {
|
||||||
|
user: {
|
||||||
|
username: 'alan',
|
||||||
|
id: 'idalan',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const mockGetState = vi.fn().mockReturnValue(state);
|
||||||
|
|
||||||
|
actions.sendUnencryptedMessage('TOGGLE_LOCK_ROOM')(mockDispatch, mockGetState);
|
||||||
|
expect(mockDispatch).toHaveBeenLastCalledWith({
|
||||||
|
payload: { locked: true, sender: 'idalan', username: 'alan' },
|
||||||
|
type: 'TOGGLE_LOCK_ROOM',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Send unencrypted message actions', () => {
|
||||||
|
it('should create no action', () => {
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
actions.sendUnencryptedMessage('FAKE')(mockDispatch, vi.fn().mockReturnValue({}));
|
||||||
|
expect(mockDispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create toggle lock room action', () => {
|
||||||
|
const mockDispatch = vi.fn();
|
||||||
|
const state = {
|
||||||
|
user: {
|
||||||
|
username: 'alan',
|
||||||
|
id: 'idalan',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const mockGetState = vi.fn().mockReturnValue(state);
|
||||||
|
|
||||||
|
actions.sendUnencryptedMessage('TOGGLE_LOCK_ROOM')(mockDispatch, mockGetState);
|
||||||
|
expect(mockDispatch).toHaveBeenLastCalledWith({
|
||||||
|
payload: { locked: true, sender: 'idalan', username: 'alan' },
|
||||||
|
type: 'TOGGLE_LOCK_ROOM',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
27
client/src/api/config.js
Normal file
27
client/src/api/config.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
/* istanbul ignore file */
|
||||||
|
let host;
|
||||||
|
let protocol;
|
||||||
|
let port;
|
||||||
|
|
||||||
|
switch (import.meta.env.MODE) {
|
||||||
|
case 'staging':
|
||||||
|
host = import.meta.env.VITE_API_HOST;
|
||||||
|
protocol = import.meta.env.VITE_API_PROTOCOL || 'https';
|
||||||
|
port = import.meta.env.VITE_API_PORT || 443;
|
||||||
|
break;
|
||||||
|
case 'production':
|
||||||
|
host = import.meta.env.VITE_API_HOST;
|
||||||
|
protocol = import.meta.env.VITE_API_PROTOCOL || 'https';
|
||||||
|
port = import.meta.env.VITE_API_PORT || 443;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
host = import.meta.env.VITE_API_HOST || 'localhost';
|
||||||
|
protocol = import.meta.env.VITE_API_PROTOCOL || 'http';
|
||||||
|
port = import.meta.env.VITE_API_PORT || 3001;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
protocol,
|
||||||
|
};
|
14
client/src/api/generator.js
Normal file
14
client/src/api/generator.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/* istanbul ignore file */
|
||||||
|
import config from './config';
|
||||||
|
|
||||||
|
export default (resourceName = '') => {
|
||||||
|
const { port, protocol, host } = config;
|
||||||
|
|
||||||
|
const resourcePath = resourceName;
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
return `/${resourcePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${protocol}://${host}:${port}/${resourcePath}`;
|
||||||
|
};
|
BIN
client/src/audio/beep.mp3
Normal file
BIN
client/src/audio/beep.mp3
Normal file
Binary file not shown.
54
client/src/components/About/About.test.jsx
Normal file
54
client/src/components/About/About.test.jsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
import About from '.';
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
// Mock Api generator
|
||||||
|
|
||||||
|
vi.mock('@/api/generator', () => {
|
||||||
|
return {
|
||||||
|
default: path => {
|
||||||
|
return `http://fakedomain/${path}`;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('About component', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
fetchMock.resetMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display', async () => {
|
||||||
|
const { asFragment } = render(<About roomId={'test'} />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should report abuse', async () => {
|
||||||
|
const { getByText, queryByText } = render(<About roomId={'test'} />);
|
||||||
|
|
||||||
|
expect(queryByText('Thank you!')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(getByText('Submit'));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith('http://fakedomain/abuse/test', { method: 'POST' });
|
||||||
|
|
||||||
|
expect(getByText('Thank you!')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should change room id', async () => {
|
||||||
|
const { getByPlaceholderText, getByText, queryByText } = render(<About roomId={'test'} />);
|
||||||
|
|
||||||
|
expect(queryByText('Thank you!')).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(getByPlaceholderText('Room ID'), { target: { value: 'newRoomName' } });
|
||||||
|
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
fireEvent.click(getByText('Submit'));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenLastCalledWith('http://fakedomain/abuse/newRoomName', { method: 'POST' });
|
||||||
|
});
|
||||||
|
});
|
513
client/src/components/About/__snapshots__/About.test.jsx.snap
Normal file
513
client/src/components/About/__snapshots__/About.test.jsx.snap
Normal file
@ -0,0 +1,513 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`About component > should display 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
class="_base_4f26aa"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_links_4f26aa"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="#version"
|
||||||
|
>
|
||||||
|
Version
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="#software"
|
||||||
|
>
|
||||||
|
Software
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="#report-abuse"
|
||||||
|
>
|
||||||
|
Report Abuse
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="#acceptable-use"
|
||||||
|
>
|
||||||
|
Acceptable Use Policy
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="#disclaimer"
|
||||||
|
>
|
||||||
|
Disclaimer
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="#terms"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="#contact"
|
||||||
|
>
|
||||||
|
Contact
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a
|
||||||
|
href="#donate"
|
||||||
|
>
|
||||||
|
Donate
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<section
|
||||||
|
id="version"
|
||||||
|
>
|
||||||
|
<h4>
|
||||||
|
Version
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
Commit SHA:
|
||||||
|
<a
|
||||||
|
href="https://github.com/darkwire/darkwire.io/commit/some_sha"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
some_sha
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
id="software"
|
||||||
|
>
|
||||||
|
<h4>
|
||||||
|
Software
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
This software uses the
|
||||||
|
<a
|
||||||
|
href="https://developer.mozilla.org/en-US/docs/Web/API/Crypto"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Web Cryptography API
|
||||||
|
</a>
|
||||||
|
to encrypt data which is transferred using
|
||||||
|
<a
|
||||||
|
href="https://en.wikipedia.org/wiki/WebSocket"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
secure WebSockets
|
||||||
|
</a>
|
||||||
|
. Messages are never stored on a server or sent over the wire in plain-text.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We believe in privacy and transparency.
|
||||||
|
<a
|
||||||
|
href="https://github.com/darkwire/darkwire.io"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
View the source code and documentation on GitHub.
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
id="report-abuse"
|
||||||
|
>
|
||||||
|
<h4>
|
||||||
|
Report Abuse
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
We encourage you to report problematic content to us. Please keep in mind that to help ensure the safety, confidentiality and security of your messages, we do not have the contents of messages available to us, which limits our ability to verify the report and take action.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When needed, you can take a screenshot of the content and share it, along with any available contact info, with appropriate law enforcement authorities.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
To report any content, email us at abuse[at]darkwire.io or submit the room ID below to report anonymously.
|
||||||
|
</p>
|
||||||
|
<form>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="input-group"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Room ID"
|
||||||
|
type="text"
|
||||||
|
value="test"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="input-group-append"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-secondary"
|
||||||
|
type="submit"
|
||||||
|
>
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<br />
|
||||||
|
<p>
|
||||||
|
If you feel you or anyone else is in immediate danger, please contact your local emergency services.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you receive content from someone who wishes to hurt themselves, and you're concerned for their safety, please contact your local emergency services or a
|
||||||
|
<a
|
||||||
|
href="https://faq.whatsapp.com/en/general/28030010"
|
||||||
|
>
|
||||||
|
suicide prevention hotline
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you receive or encounter content indicating abuse or exploitation of a child, please contact the
|
||||||
|
<a
|
||||||
|
href="http://www.missingkids.com"
|
||||||
|
>
|
||||||
|
National Center for Missing and Exploited Children (NCMEC)
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
id="acceptable-use"
|
||||||
|
>
|
||||||
|
<h4>
|
||||||
|
Acceptable Use Policy
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
This Acceptable Use Policy (this “Policy”) describes prohibited uses of the web services offered by Darkwire and its affiliates (the “Services”) and the website located at https://darkwire.io (the “Darkwire Site”). The examples described in this Policy are not exhaustive. We may modify this Policy at any time by posting a revised version on the Darkwire Site. By using the Services or accessing the Darkwire Site, you agree to the latest version of this Policy. If you violate the Policy or authorize or help others to do so, we may suspend or terminate your use of the Services.
|
||||||
|
</p>
|
||||||
|
<strong>
|
||||||
|
No Illegal, Harmful, or Offensive Use or Content
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
You may not use, or encourage, promote, facilitate or instruct others to use, the Services or Darkwire Site for any illegal, harmful, fraudulent, infringing or offensive use, or to transmit, store, display, distribute or otherwise make available content that is illegal, harmful, fraudulent, infringing or offensive. Prohibited activities or content include:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Illegal, Harmful or Fraudulent Activities.
|
||||||
|
</strong>
|
||||||
|
Any activities that are illegal, that violate the rights of others, or that may be harmful to others, our operations or reputation, including disseminating, promoting or facilitating child pornography, offering or disseminating fraudulent goods, services, schemes, or promotions, make-money-fast schemes, ponzi and pyramid schemes, phishing, or pharming.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Infringing Content.
|
||||||
|
</strong>
|
||||||
|
Content that infringes or misappropriates the intellectual property or proprietary rights of others.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Offensive Content.
|
||||||
|
</strong>
|
||||||
|
Content that is defamatory, obscene, abusive, invasive of privacy, or otherwise objectionable, including content that constitutes child pornography, relates to bestiality, or depicts non-consensual sex acts.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Harmful Content.
|
||||||
|
</strong>
|
||||||
|
Content or other computer technology that may damage, interfere with, surreptitiously intercept, or expropriate any system, program, or data, including viruses, Trojan horses, worms, time bombs, or cancelbots.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<strong>
|
||||||
|
No Security Violations
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
You may not use the Services to violate the security or integrity of any network, computer or communications system, software application, or network or computing device (each, a “System”). Prohibited activities include:
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Unauthorized Access.
|
||||||
|
</strong>
|
||||||
|
Accessing or using any System without permission, including attempting to probe, scan, or test the vulnerability of a System or to breach any security or authentication measures used by a System.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Interception.
|
||||||
|
</strong>
|
||||||
|
Monitoring of data or traffic on a System without permission.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Falsification of Origin.
|
||||||
|
</strong>
|
||||||
|
Forging TCP-IP packet headers, e-mail headers, or any part of a message describing its origin or route. The legitimate use of aliases and anonymous remailers is not prohibited by this provision.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<strong>
|
||||||
|
No Network Abuse
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
You may not make network connections to any users, hosts, or networks unless you have permission to communicate with them. Prohibited activities include:
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Monitoring or Crawling.
|
||||||
|
</strong>
|
||||||
|
Monitoring or crawling of a System that impairs or disrupts the System being monitored or crawled.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Denial of Service (DoS).
|
||||||
|
</strong>
|
||||||
|
Inundating a target with communications requests so the target either cannot respond to legitimate traffic or responds so slowly that it becomes ineffective.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Intentional Interference.
|
||||||
|
</strong>
|
||||||
|
Interfering with the proper functioning of any System, including any deliberate attempt to overload a system by mail bombing, news bombing, broadcast attacks, or flooding techniques.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Operation of Certain Network Services.
|
||||||
|
</strong>
|
||||||
|
Operating network services like open proxies, open mail relays, or open recursive domain name servers.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>
|
||||||
|
Avoiding System Restrictions.
|
||||||
|
</strong>
|
||||||
|
Using manual or electronic means to avoid any use limitations placed on a System, such as access and storage restrictions.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<strong>
|
||||||
|
No E-Mail or Other Message Abuse
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
You will not distribute, publish, send, or facilitate the sending of unsolicited mass e-mail or other messages, promotions, advertising, or solicitations (like “spam”), including commercial advertising and informational announcements. You will not alter or obscure mail headers or assume a sender’s identity without the sender’s explicit permission. You will not collect replies to messages sent from another internet service provider if those messages violate this Policy or the acceptable use policy of that provider.
|
||||||
|
<strong>
|
||||||
|
Our Monitoring and Enforcement
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
We reserve the right, but do not assume the obligation, to investigate any violation of this Policy or misuse of the Services or Darkwire Site. We may:
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
investigate violations of this Policy or misuse of the Services or Darkwire Site; or
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
remove, disable access to, or modify any content or resource that violates this Policy or any other agreement we have with you for use of the Services or the Darkwire Site.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
We may report any activity that we suspect violates any law or regulation to appropriate law enforcement officials, regulators, or other appropriate third parties. Our reporting may include disclosing appropriate customer information. We also may cooperate with appropriate law enforcement agencies, regulators, or other appropriate third parties to help with the investigation and prosecution of illegal conduct by providing network and systems information related to alleged violations of this Policy.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
Reporting of Violations of this Policy
|
||||||
|
<br />
|
||||||
|
If you become aware of any violation of this Policy, you will immediately notify us and provide us with assistance, as requested, to stop or remedy the violation. To report any violation of this Policy, please follow our abuse reporting process.
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
id="terms"
|
||||||
|
>
|
||||||
|
<h4>
|
||||||
|
Terms of Service ("Terms")
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
Last updated: December 11, 2017
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Please read these Terms of Service ("Terms", "Terms of Service") carefully before using the https://darkwire.io website (the "Service") operated by Darkwire ("us", "we", or "our").
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Your access to and use of the Service is conditioned on your acceptance of and compliance with these Terms. These Terms apply to all visitors, users and others who access or use the Service.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
By accessing or using the Service you agree to be bound by these Terms. If you disagree with any part of the terms then you may not access the Service.
|
||||||
|
</p>
|
||||||
|
<strong>
|
||||||
|
Links To Other Web Sites
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
Our Service may contain links to third-party web sites or services that are not owned or controlled by Darkwire.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Darkwire has no control over, and assumes no responsibility for, the content, privacy policies, or practices of any third party web sites or services. You further acknowledge and agree that Darkwire shall not be responsible or liable, directly or indirectly, for any damage or loss caused or alleged to be caused by or in connection with use of or reliance on any such content, goods or services available on or through any such web sites or services.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We strongly advise you to read the terms and conditions and privacy policies of any third-party web sites or services that you visit.
|
||||||
|
</p>
|
||||||
|
<strong>
|
||||||
|
Termination
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
We may terminate or suspend access to our Service immediately, without prior notice or liability, for any reason whatsoever, including without limitation if you breach the Terms.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
All provisions of the Terms which by their nature should survive termination shall survive termination, including, without limitation, ownership provisions, warranty disclaimers, indemnity and limitations of liability.
|
||||||
|
</p>
|
||||||
|
<strong>
|
||||||
|
Governing Law
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
These Terms shall be governed and construed in accordance with the laws of New York, United States, without regard to its conflict of law provisions.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Our failure to enforce any right or provision of these Terms will not be considered a waiver of those rights. If any provision of these Terms is held to be invalid or unenforceable by a court, the remaining provisions of these Terms will remain in effect. These Terms constitute the entire agreement between us regarding our Service, and supersede and replace any prior agreements we might have between us regarding the Service.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
id="disclaimer"
|
||||||
|
>
|
||||||
|
<h4>
|
||||||
|
Disclaimer
|
||||||
|
</h4>
|
||||||
|
<p
|
||||||
|
class="bold"
|
||||||
|
>
|
||||||
|
WARNING: Darkwire does not mask IP addresses nor can verify the integrity of parties recieving messages. Proceed with caution and always confirm recipients beforre starting a chat session.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Please also note that
|
||||||
|
<strong>
|
||||||
|
ALL CHATROOMS
|
||||||
|
</strong>
|
||||||
|
are public. Anyone can guess your room URL. If you need a more-private room, use the lock feature or set the URL manually by entering a room ID after "darkwire.io/".
|
||||||
|
</p>
|
||||||
|
<br />
|
||||||
|
<strong>
|
||||||
|
No Warranties; Exclusion of Liability; Indemnification
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
<strong>
|
||||||
|
OUR WEBSITE IS OPERATED BY Darkwire ON AN "AS IS," "AS AVAILABLE" BASIS, WITHOUT REPRESENTATIONS OR WARRANTIES OF ANY KIND. TO THE FULLEST EXTENT PERMITTED BY LAW, Darkwire SPECIFICALLY DISCLAIMS ALL WARRANTIES AND CONDITIONS OF ANY KIND, INCLUDING ALL IMPLIED WARRANTIES AND CONDITIONS OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NONINFRINGEMENT FOR OUR WEBSITE AND ANY CONTRACTS AND SERVICES YOU PURCHASE THROUGH IT. Darkwire SHALL NOT HAVE ANY LIABILITY OR RESPONSIBILITY FOR ANY ERRORS OR OMISSIONS IN THE CONTENT OF OUR WEBSITE, FOR CONTRACTS OR SERVICES SOLD THROUGH OUR WEBSITE, FOR YOUR ACTION OR INACTION IN CONNECTION WITH OUR WEBSITE OR FOR ANY DAMAGE TO YOUR COMPUTER OR DATA OR ANY OTHER DAMAGE YOU MAY INCUR IN CONNECTION WITH OUR WEBSITE. YOUR USE OF OUR WEBSITE AND ANY CONTRACTS OR SERVICES ARE AT YOUR OWN RISK. IN NO EVENT SHALL EITHER Darkwire OR THEIR AGENTS BE LIABLE FOR ANY DIRECT, INDIRECT, PUNITIVE, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF OR IN ANY WAY CONNECTED WITH THE USE OF OUR WEBSITE, CONTRACTS AND SERVICES PURCHASED THROUGH OUR WEBSITE, THE DELAY OR INABILITY TO USE OUR WEBSITE OR OTHERWISE ARISING IN CONNECTION WITH OUR WEBSITE, CONTRACTS OR RELATED SERVICES, WHETHER BASED ON CONTRACT, TORT, STRICT LIABILITY OR OTHERWISE, EVEN IF ADVISED OF THE POSSIBILITY OF ANY SUCH DAMAGES. IN NO EVENT SHALL Darkwire’s LIABILITY FOR ANY DAMAGE CLAIM EXCEED THE AMOUNT PAID BY YOU TO Darkwire FOR THE TRANSACTION GIVING RISE TO SUCH DAMAGE CLAIM.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>
|
||||||
|
SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO THE ABOVE EXCLUSION MAY NOT APPLY TO YOU.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>
|
||||||
|
WITHOUT LIMITING THE FOREGOING, Darkwire DO NOT REPRESENT OR WARRANT THAT THE INFORMATION ON THE WEBITE IS ACCURATE, COMPLETE, RELIABLE, USEFUL, TIMELY OR CURRENT OR THAT OUR WEBSITE WILL OPERATE WITHOUT INTERRUPTION OR ERROR.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>
|
||||||
|
YOU AGREE THAT ALL TIMES, YOU WILL LOOK TO ATTORNEYS FROM WHOM YOU PURCHASE SERVICES FOR ANY CLAIMS OF ANY NATURE, INCLUDING LOSS, DAMAGE, OR WARRANTY. Darkwire AND THEIR RESPECTIVE AFFILIATES MAKE NO REPRESENTATION OR GUARANTEES ABOUT ANY CONTRACTS AND SERVICES OFFERED THROUGH OUR WEBSITE.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>
|
||||||
|
Darkwire MAKES NO REPRESENTATION THAT CONTENT PROVIDED ON OUR WEBSITE, CONTRACTS, OR RELATED SERVICES ARE APPLICABLE OR APPROPRIATE FOR USE IN ALL JURISDICTIONS.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<strong>
|
||||||
|
Indemnification
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
You agree to defend, indemnify and hold Darkwire harmless from and against any and all claims, damages, costs and expenses, including attorneys' fees, arising from or related to your use of our Website or any Contracts or Services you purchase through it.
|
||||||
|
</p>
|
||||||
|
<strong>
|
||||||
|
Changes
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is material we will try to provide at least 30 days notice prior to any new terms taking effect. What constitutes a material change will be determined at our sole discretion.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
By continuing to access or use our Service after those revisions become effective, you agree to be bound by the revised terms. If you do not agree to the new terms, please stop using the Service.
|
||||||
|
</p>
|
||||||
|
<strong>
|
||||||
|
Contact Us
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
If you have any questions about these Terms, please contact us at hello[at]darkwire.io.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
id="contact"
|
||||||
|
>
|
||||||
|
<h4>
|
||||||
|
Contact
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
Questions/comments? Email us at hello[at]darkwire.io
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Found a bug or want a new feature?
|
||||||
|
<a
|
||||||
|
href="https://github.com/darkwire/darkwire.io/issues"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Open a ticket on Github
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section
|
||||||
|
id="donate"
|
||||||
|
>
|
||||||
|
<h4>
|
||||||
|
Donate
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
Darkwire is maintained and hosted by two developers with full-time jobs. If you get some value from this service we would appreciate any donation you can afford. We use these funds for server and DNS costs. Thank you!
|
||||||
|
</p>
|
||||||
|
<strong>
|
||||||
|
Bitcoin
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
189sPnHGcjP5uteg2UuNgcJ5eoaRAP4Bw4
|
||||||
|
</p>
|
||||||
|
<strong>
|
||||||
|
Ethereum
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
0x36dc407bB28aA1EE6AafBee0379Fe6Cff881758E
|
||||||
|
</p>
|
||||||
|
<strong>
|
||||||
|
Litecoin
|
||||||
|
</strong>
|
||||||
|
<p>
|
||||||
|
LUViQeSggBBtYoN2qNtXSuxYoRMzRY8CSX
|
||||||
|
</p>
|
||||||
|
<strong>
|
||||||
|
PayPal:
|
||||||
|
</strong>
|
||||||
|
<br />
|
||||||
|
<form
|
||||||
|
action="https://www.paypal.com/cgi-bin/webscr"
|
||||||
|
method="post"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
name="cmd"
|
||||||
|
type="hidden"
|
||||||
|
value="_s-xclick"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
name="hosted_button_id"
|
||||||
|
type="hidden"
|
||||||
|
value="UAH5BCLA9Y8VW"
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
alt="PayPal - The safer, easier way to pay online!"
|
||||||
|
border="0"
|
||||||
|
name="submit"
|
||||||
|
src="https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif"
|
||||||
|
type="image"
|
||||||
|
/>
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
border="0"
|
||||||
|
height="1"
|
||||||
|
src="https://www.paypalobjects.com/en_US/i/scr/pixel.gif"
|
||||||
|
width="1"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
451
client/src/components/About/index.jsx
Normal file
451
client/src/components/About/index.jsx
Normal file
@ -0,0 +1,451 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import { COMMIT_SHA } from '@/config/env';
|
||||||
|
import apiUrlGenerator from '@/api/generator';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
|
class About extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
roomId: props.roomId,
|
||||||
|
abuseReported: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
handleUpdateRoomId(evt) {
|
||||||
|
this.setState({
|
||||||
|
roomId: evt.target.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleReportAbuse(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
fetch(`${apiUrlGenerator('abuse')}/${this.state.roomId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
this.setState({
|
||||||
|
abuseReported: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className={styles.base}>
|
||||||
|
<div className={styles.links}>
|
||||||
|
<div>
|
||||||
|
<a href="#version">Version</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="#software">Software</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="#report-abuse">Report Abuse</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="#acceptable-use">Acceptable Use Policy</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="#disclaimer">Disclaimer</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="#terms">Terms of Service</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="#contact">Contact</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<a href="#donate">Donate</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section id="version">
|
||||||
|
<h4>Version</h4>
|
||||||
|
<p>
|
||||||
|
Commit SHA:{' '}
|
||||||
|
<a target="_blank" href={`https://github.com/darkwire/darkwire.io/commit/${COMMIT_SHA}`}>
|
||||||
|
{COMMIT_SHA}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="software">
|
||||||
|
<h4>Software</h4>
|
||||||
|
<p>
|
||||||
|
This software uses the{' '}
|
||||||
|
<a href="https://developer.mozilla.org/en-US/docs/Web/API/Crypto" target="_blank" rel="noopener noreferrer">
|
||||||
|
Web Cryptography API
|
||||||
|
</a>{' '}
|
||||||
|
to encrypt data which is transferred using{' '}
|
||||||
|
<a href="https://en.wikipedia.org/wiki/WebSocket" target="_blank" rel="noopener noreferrer">
|
||||||
|
secure WebSockets
|
||||||
|
</a>
|
||||||
|
. Messages are never stored on a server or sent over the wire in plain-text.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We believe in privacy and transparency.
|
||||||
|
<a href="https://github.com/darkwire/darkwire.io" target="_blank" rel="noopener noreferrer">
|
||||||
|
View the source code and documentation on GitHub.
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="report-abuse">
|
||||||
|
<h4>Report Abuse</h4>
|
||||||
|
<p>
|
||||||
|
We encourage you to report problematic content to us. Please keep in mind that to help ensure the safety,
|
||||||
|
confidentiality and security of your messages, we do not have the contents of messages available to us,
|
||||||
|
which limits our ability to verify the report and take action.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
When needed, you can take a screenshot of the content and share it, along with any available contact info,
|
||||||
|
with appropriate law enforcement authorities.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
To report any content, email us at abuse[at]darkwire.io or submit the room ID below to report anonymously.
|
||||||
|
</p>
|
||||||
|
<form onSubmit={this.handleReportAbuse.bind(this)}>
|
||||||
|
{this.state.abuseReported && <div>Thank you!</div>}
|
||||||
|
<div>
|
||||||
|
<div className="input-group">
|
||||||
|
<input
|
||||||
|
className="form-control"
|
||||||
|
placeholder="Room ID"
|
||||||
|
onChange={this.handleUpdateRoomId.bind(this)}
|
||||||
|
value={this.state.roomId}
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
<div className="input-group-append">
|
||||||
|
<button className="btn btn-secondary" type="submit">
|
||||||
|
Submit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<br />
|
||||||
|
<p>If you feel you or anyone else is in immediate danger, please contact your local emergency services.</p>
|
||||||
|
<p>
|
||||||
|
If you receive content from someone who wishes to hurt themselves, and you're concerned for their safety,
|
||||||
|
please contact your local emergency services or a{' '}
|
||||||
|
<a href="https://faq.whatsapp.com/en/general/28030010">suicide prevention hotline</a>.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
If you receive or encounter content indicating abuse or exploitation of a child, please contact the{' '}
|
||||||
|
<a href="http://www.missingkids.com">National Center for Missing and Exploited Children (NCMEC)</a>.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="acceptable-use">
|
||||||
|
<h4>Acceptable Use Policy</h4>
|
||||||
|
<p>
|
||||||
|
This Acceptable Use Policy (this “Policy”) describes prohibited uses of the web services offered by Darkwire
|
||||||
|
and its affiliates (the “Services”) and the website located at https://darkwire.io (the “Darkwire Site”).
|
||||||
|
The examples described in this Policy are not exhaustive. We may modify this Policy at any time by posting a
|
||||||
|
revised version on the Darkwire Site. By using the Services or accessing the Darkwire Site, you agree to the
|
||||||
|
latest version of this Policy. If you violate the Policy or authorize or help others to do so, we may
|
||||||
|
suspend or terminate your use of the Services.
|
||||||
|
</p>
|
||||||
|
<strong>No Illegal, Harmful, or Offensive Use or Content</strong>
|
||||||
|
<p>
|
||||||
|
You may not use, or encourage, promote, facilitate or instruct others to use, the Services or Darkwire Site
|
||||||
|
for any illegal, harmful, fraudulent, infringing or offensive use, or to transmit, store, display,
|
||||||
|
distribute or otherwise make available content that is illegal, harmful, fraudulent, infringing or
|
||||||
|
offensive. Prohibited activities or content include:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Illegal, Harmful or Fraudulent Activities.</strong> Any activities that are illegal, that violate
|
||||||
|
the rights of others, or that may be harmful to others, our operations or reputation, including
|
||||||
|
disseminating, promoting or facilitating child pornography, offering or disseminating fraudulent goods,
|
||||||
|
services, schemes, or promotions, make-money-fast schemes, ponzi and pyramid schemes, phishing, or
|
||||||
|
pharming.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong>Infringing Content.</strong> Content that infringes or misappropriates the intellectual property
|
||||||
|
or proprietary rights of others.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong>Offensive Content.</strong> Content that is defamatory, obscene, abusive, invasive of privacy, or
|
||||||
|
otherwise objectionable, including content that constitutes child pornography, relates to bestiality, or
|
||||||
|
depicts non-consensual sex acts.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong>Harmful Content.</strong> Content or other computer technology that may damage, interfere with,
|
||||||
|
surreptitiously intercept, or expropriate any system, program, or data, including viruses, Trojan horses,
|
||||||
|
worms, time bombs, or cancelbots.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<strong>No Security Violations</strong>
|
||||||
|
<br />
|
||||||
|
You may not use the Services to violate the security or integrity of any network, computer or communications
|
||||||
|
system, software application, or network or computing device (each, a “System”). Prohibited activities
|
||||||
|
include:
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Unauthorized Access.</strong> Accessing or using any System without permission, including
|
||||||
|
attempting to probe, scan, or test the vulnerability of a System or to breach any security or
|
||||||
|
authentication measures used by a System.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong>Interception.</strong> Monitoring of data or traffic on a System without permission.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong>Falsification of Origin.</strong> Forging TCP-IP packet headers, e-mail headers, or any part of a
|
||||||
|
message describing its origin or route. The legitimate use of aliases and anonymous remailers is not
|
||||||
|
prohibited by this provision.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<strong>No Network Abuse</strong>
|
||||||
|
<br />
|
||||||
|
You may not make network connections to any users, hosts, or networks unless you have permission to
|
||||||
|
communicate with them. Prohibited activities include:
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
<strong>Monitoring or Crawling.</strong> Monitoring or crawling of a System that impairs or disrupts the
|
||||||
|
System being monitored or crawled.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong>Denial of Service (DoS).</strong> Inundating a target with communications requests so the target
|
||||||
|
either cannot respond to legitimate traffic or responds so slowly that it becomes ineffective.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong>Intentional Interference.</strong> Interfering with the proper functioning of any System,
|
||||||
|
including any deliberate attempt to overload a system by mail bombing, news bombing, broadcast attacks, or
|
||||||
|
flooding techniques.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong>Operation of Certain Network Services.</strong> Operating network services like open proxies, open
|
||||||
|
mail relays, or open recursive domain name servers.
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<strong>Avoiding System Restrictions.</strong> Using manual or electronic means to avoid any use
|
||||||
|
limitations placed on a System, such as access and storage restrictions.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<strong>No E-Mail or Other Message Abuse</strong>
|
||||||
|
<br />
|
||||||
|
You will not distribute, publish, send, or facilitate the sending of unsolicited mass e-mail or other
|
||||||
|
messages, promotions, advertising, or solicitations (like “spam”), including commercial advertising and
|
||||||
|
informational announcements. You will not alter or obscure mail headers or assume a sender’s identity without
|
||||||
|
the sender’s explicit permission. You will not collect replies to messages sent from another internet service
|
||||||
|
provider if those messages violate this Policy or the acceptable use policy of that provider.
|
||||||
|
<strong>Our Monitoring and Enforcement</strong>
|
||||||
|
<br />
|
||||||
|
We reserve the right, but do not assume the obligation, to investigate any violation of this Policy or misuse
|
||||||
|
of the Services or Darkwire Site. We may:
|
||||||
|
<ul>
|
||||||
|
<li>investigate violations of this Policy or misuse of the Services or Darkwire Site; or</li>
|
||||||
|
<li>
|
||||||
|
remove, disable access to, or modify any content or resource that violates this Policy or any other
|
||||||
|
agreement we have with you for use of the Services or the Darkwire Site.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
We may report any activity that we suspect violates any law or regulation to appropriate law enforcement
|
||||||
|
officials, regulators, or other appropriate third parties. Our reporting may include disclosing
|
||||||
|
appropriate customer information. We also may cooperate with appropriate law enforcement agencies,
|
||||||
|
regulators, or other appropriate third parties to help with the investigation and prosecution of illegal
|
||||||
|
conduct by providing network and systems information related to alleged violations of this Policy.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
Reporting of Violations of this Policy
|
||||||
|
<br />
|
||||||
|
If you become aware of any violation of this Policy, you will immediately notify us and provide us with
|
||||||
|
assistance, as requested, to stop or remedy the violation. To report any violation of this Policy, please
|
||||||
|
follow our abuse reporting process.
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="terms">
|
||||||
|
<h4>Terms of Service ("Terms")</h4>
|
||||||
|
<p>Last updated: December 11, 2017</p>
|
||||||
|
<p>
|
||||||
|
Please read these Terms of Service ("Terms", "Terms of Service") carefully before using the
|
||||||
|
https://darkwire.io website (the "Service") operated by Darkwire ("us", "we", or "our").
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Your access to and use of the Service is conditioned on your acceptance of and compliance with these Terms.
|
||||||
|
These Terms apply to all visitors, users and others who access or use the Service.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
By accessing or using the Service you agree to be bound by these Terms. If you disagree with any part of the
|
||||||
|
terms then you may not access the Service.
|
||||||
|
</p>
|
||||||
|
<strong>Links To Other Web Sites</strong>
|
||||||
|
<p>
|
||||||
|
Our Service may contain links to third-party web sites or services that are not owned or controlled by
|
||||||
|
Darkwire.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Darkwire has no control over, and assumes no responsibility for, the content, privacy policies, or practices
|
||||||
|
of any third party web sites or services. You further acknowledge and agree that Darkwire shall not be
|
||||||
|
responsible or liable, directly or indirectly, for any damage or loss caused or alleged to be caused by or
|
||||||
|
in connection with use of or reliance on any such content, goods or services available on or through any
|
||||||
|
such web sites or services.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
We strongly advise you to read the terms and conditions and privacy policies of any third-party web sites or
|
||||||
|
services that you visit.
|
||||||
|
</p>
|
||||||
|
<strong>Termination</strong>
|
||||||
|
<p>
|
||||||
|
We may terminate or suspend access to our Service immediately, without prior notice or liability, for any
|
||||||
|
reason whatsoever, including without limitation if you breach the Terms.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
All provisions of the Terms which by their nature should survive termination shall survive termination,
|
||||||
|
including, without limitation, ownership provisions, warranty disclaimers, indemnity and limitations of
|
||||||
|
liability.
|
||||||
|
</p>
|
||||||
|
<strong>Governing Law</strong>
|
||||||
|
<p>
|
||||||
|
These Terms shall be governed and construed in accordance with the laws of New York, United States, without
|
||||||
|
regard to its conflict of law provisions.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Our failure to enforce any right or provision of these Terms will not be considered a waiver of those
|
||||||
|
rights. If any provision of these Terms is held to be invalid or unenforceable by a court, the remaining
|
||||||
|
provisions of these Terms will remain in effect. These Terms constitute the entire agreement between us
|
||||||
|
regarding our Service, and supersede and replace any prior agreements we might have between us regarding the
|
||||||
|
Service.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="disclaimer">
|
||||||
|
<h4>Disclaimer</h4>
|
||||||
|
<p className="bold">
|
||||||
|
WARNING: Darkwire does not mask IP addresses nor can verify the integrity of parties recieving messages.
|
||||||
|
Proceed with caution and always confirm recipients beforre starting a chat session.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Please also note that <strong>ALL CHATROOMS</strong> are public. Anyone can guess your room URL. If
|
||||||
|
you need a more-private room, use the lock feature or set the URL manually by entering a room ID after
|
||||||
|
"darkwire.io/".
|
||||||
|
</p>
|
||||||
|
<br />
|
||||||
|
<strong>No Warranties; Exclusion of Liability; Indemnification</strong>
|
||||||
|
<p>
|
||||||
|
<strong>
|
||||||
|
OUR WEBSITE IS OPERATED BY Darkwire ON AN "AS IS," "AS AVAILABLE" BASIS, WITHOUT REPRESENTATIONS OR
|
||||||
|
WARRANTIES OF ANY KIND. TO THE FULLEST EXTENT PERMITTED BY LAW, Darkwire SPECIFICALLY DISCLAIMS ALL
|
||||||
|
WARRANTIES AND CONDITIONS OF ANY KIND, INCLUDING ALL IMPLIED WARRANTIES AND CONDITIONS OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NONINFRINGEMENT FOR OUR WEBSITE AND ANY CONTRACTS AND SERVICES
|
||||||
|
YOU PURCHASE THROUGH IT. Darkwire SHALL NOT HAVE ANY LIABILITY OR RESPONSIBILITY FOR ANY ERRORS OR
|
||||||
|
OMISSIONS IN THE CONTENT OF OUR WEBSITE, FOR CONTRACTS OR SERVICES SOLD THROUGH OUR WEBSITE, FOR YOUR
|
||||||
|
ACTION OR INACTION IN CONNECTION WITH OUR WEBSITE OR FOR ANY DAMAGE TO YOUR COMPUTER OR DATA OR ANY OTHER
|
||||||
|
DAMAGE YOU MAY INCUR IN CONNECTION WITH OUR WEBSITE. YOUR USE OF OUR WEBSITE AND ANY CONTRACTS OR SERVICES
|
||||||
|
ARE AT YOUR OWN RISK. IN NO EVENT SHALL EITHER Darkwire OR THEIR AGENTS BE LIABLE FOR ANY DIRECT,
|
||||||
|
INDIRECT, PUNITIVE, INCIDENTAL, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF OR IN ANY WAY CONNECTED
|
||||||
|
WITH THE USE OF OUR WEBSITE, CONTRACTS AND SERVICES PURCHASED THROUGH OUR WEBSITE, THE DELAY OR INABILITY
|
||||||
|
TO USE OUR WEBSITE OR OTHERWISE ARISING IN CONNECTION WITH OUR WEBSITE, CONTRACTS OR RELATED SERVICES,
|
||||||
|
WHETHER BASED ON CONTRACT, TORT, STRICT LIABILITY OR OTHERWISE, EVEN IF ADVISED OF THE POSSIBILITY OF ANY
|
||||||
|
SUCH DAMAGES. IN NO EVENT SHALL Darkwire’s LIABILITY FOR ANY DAMAGE CLAIM EXCEED THE AMOUNT PAID BY YOU TO
|
||||||
|
Darkwire FOR THE TRANSACTION GIVING RISE TO SUCH DAMAGE CLAIM.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>
|
||||||
|
SOME JURISDICTIONS DO NOT ALLOW THE EXCLUSION OR LIMITATION OF INCIDENTAL OR CONSEQUENTIAL DAMAGES, SO THE
|
||||||
|
ABOVE EXCLUSION MAY NOT APPLY TO YOU.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>
|
||||||
|
WITHOUT LIMITING THE FOREGOING, Darkwire DO NOT REPRESENT OR WARRANT THAT THE INFORMATION ON THE WEBITE IS
|
||||||
|
ACCURATE, COMPLETE, RELIABLE, USEFUL, TIMELY OR CURRENT OR THAT OUR WEBSITE WILL OPERATE WITHOUT
|
||||||
|
INTERRUPTION OR ERROR.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>
|
||||||
|
YOU AGREE THAT ALL TIMES, YOU WILL LOOK TO ATTORNEYS FROM WHOM YOU PURCHASE SERVICES FOR ANY CLAIMS OF ANY
|
||||||
|
NATURE, INCLUDING LOSS, DAMAGE, OR WARRANTY. Darkwire AND THEIR RESPECTIVE AFFILIATES MAKE NO
|
||||||
|
REPRESENTATION OR GUARANTEES ABOUT ANY CONTRACTS AND SERVICES OFFERED THROUGH OUR WEBSITE.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>
|
||||||
|
Darkwire MAKES NO REPRESENTATION THAT CONTENT PROVIDED ON OUR WEBSITE, CONTRACTS, OR RELATED SERVICES ARE
|
||||||
|
APPLICABLE OR APPROPRIATE FOR USE IN ALL JURISDICTIONS.
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
<strong>Indemnification</strong>
|
||||||
|
<p>
|
||||||
|
You agree to defend, indemnify and hold Darkwire harmless from and against any and all claims, damages,
|
||||||
|
costs and expenses, including attorneys' fees, arising from or related to your use of our Website or any
|
||||||
|
Contracts or Services you purchase through it.
|
||||||
|
</p>
|
||||||
|
<strong>Changes</strong>
|
||||||
|
<p>
|
||||||
|
We reserve the right, at our sole discretion, to modify or replace these Terms at any time. If a revision is
|
||||||
|
material we will try to provide at least 30 days notice prior to any new terms taking effect. What
|
||||||
|
constitutes a material change will be determined at our sole discretion.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
By continuing to access or use our Service after those revisions become effective, you agree to be bound by
|
||||||
|
the revised terms. If you do not agree to the new terms, please stop using the Service.
|
||||||
|
</p>
|
||||||
|
<strong>Contact Us</strong>
|
||||||
|
<p>If you have any questions about these Terms, please contact us at hello[at]darkwire.io.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="contact">
|
||||||
|
<h4>Contact</h4>
|
||||||
|
<p>Questions/comments? Email us at hello[at]darkwire.io</p>
|
||||||
|
<p>
|
||||||
|
Found a bug or want a new feature?{' '}
|
||||||
|
<a href="https://github.com/darkwire/darkwire.io/issues" target="_blank" rel="noopener noreferrer">
|
||||||
|
Open a ticket on Github
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="donate">
|
||||||
|
<h4>Donate</h4>
|
||||||
|
<p>
|
||||||
|
Darkwire is maintained and hosted by two developers with full-time jobs. If you get some value from this
|
||||||
|
service we would appreciate any donation you can afford. We use these funds for server and DNS costs. Thank
|
||||||
|
you!
|
||||||
|
</p>
|
||||||
|
<strong>Bitcoin</strong>
|
||||||
|
<p>189sPnHGcjP5uteg2UuNgcJ5eoaRAP4Bw4</p>
|
||||||
|
<strong>Ethereum</strong>
|
||||||
|
<p>0x36dc407bB28aA1EE6AafBee0379Fe6Cff881758E</p>
|
||||||
|
<strong>Litecoin</strong>
|
||||||
|
<p>LUViQeSggBBtYoN2qNtXSuxYoRMzRY8CSX</p>
|
||||||
|
<strong>PayPal:</strong>
|
||||||
|
<br />
|
||||||
|
<form action="https://www.paypal.com/cgi-bin/webscr" method="post" target="_blank">
|
||||||
|
<input type="hidden" name="cmd" value="_s-xclick" />
|
||||||
|
<input type="hidden" name="hosted_button_id" value="UAH5BCLA9Y8VW" />
|
||||||
|
<input
|
||||||
|
type="image"
|
||||||
|
src="https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif"
|
||||||
|
border="0"
|
||||||
|
name="submit"
|
||||||
|
alt="PayPal - The safer, easier way to pay online!"
|
||||||
|
/>
|
||||||
|
<img alt="" border="0" src="https://www.paypalobjects.com/en_US/i/scr/pixel.gif" width="1" height="1" />
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
About.propTypes = {
|
||||||
|
roomId: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default About;
|
9
client/src/components/About/styles.module.scss
Normal file
9
client/src/components/About/styles.module.scss
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
.links {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.base {
|
||||||
|
section {
|
||||||
|
margin-bottom: 50px;
|
||||||
|
}
|
||||||
|
}
|
296
client/src/components/Chat/Chat.jsx
Normal file
296
client/src/components/Chat/Chat.jsx
Normal file
@ -0,0 +1,296 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import sanitizeHtml from 'sanitize-html';
|
||||||
|
import { CornerDownRight } from 'react-feather';
|
||||||
|
|
||||||
|
import { getSelectedText, hasTouchSupport } from '@/utils/dom';
|
||||||
|
|
||||||
|
import FileTransfer from '@/components/FileTransfer';
|
||||||
|
|
||||||
|
export class Chat extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
message: '',
|
||||||
|
touchSupport: hasTouchSupport,
|
||||||
|
shiftKeyDown: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.commands = [
|
||||||
|
{
|
||||||
|
command: 'nick',
|
||||||
|
description: 'Changes nickname.',
|
||||||
|
parameters: ['{username}'],
|
||||||
|
usage: '/nick {username}',
|
||||||
|
scope: 'global',
|
||||||
|
action: params => {
|
||||||
|
// eslint-disable-line
|
||||||
|
let newUsername = params.join(' ') || ''; // eslint-disable-line
|
||||||
|
|
||||||
|
// Remove things that aren't digits or chars
|
||||||
|
newUsername = newUsername.replace(/[^A-Za-z0-9]/g, '-');
|
||||||
|
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
if (!newUsername.trim().length) {
|
||||||
|
errors.push('Username cannot be blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newUsername.toString().length > 16) {
|
||||||
|
errors.push('Username cannot be greater than 16 characters');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!newUsername.match(/^[A-Z]/i)) {
|
||||||
|
errors.push('Username must start with a letter');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errors.length) {
|
||||||
|
return this.props.showNotice({
|
||||||
|
message: `${errors.join(', ')}`,
|
||||||
|
level: 'error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.sendEncryptedMessage({
|
||||||
|
type: 'CHANGE_USERNAME',
|
||||||
|
payload: {
|
||||||
|
id: this.props.userId,
|
||||||
|
newUsername,
|
||||||
|
currentUsername: this.props.username,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'help',
|
||||||
|
description: 'Shows a list of commands.',
|
||||||
|
paramaters: [],
|
||||||
|
usage: '/help',
|
||||||
|
scope: 'local',
|
||||||
|
action: params => {
|
||||||
|
// eslint-disable-line
|
||||||
|
const validCommands = this.commands.map(command => `/${command.command}`);
|
||||||
|
this.props.showNotice({
|
||||||
|
message: `Valid commands: ${validCommands.sort().join(', ')}`,
|
||||||
|
level: 'info',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'me',
|
||||||
|
description: 'Invoke virtual action',
|
||||||
|
paramaters: ['{action}'],
|
||||||
|
usage: '/me {action}',
|
||||||
|
scope: 'global',
|
||||||
|
action: params => {
|
||||||
|
// eslint-disable-line
|
||||||
|
const actionMessage = params.join(' ');
|
||||||
|
if (!actionMessage.trim().length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.props.sendEncryptedMessage({
|
||||||
|
type: 'USER_ACTION',
|
||||||
|
payload: {
|
||||||
|
action: actionMessage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
command: 'clear',
|
||||||
|
description: 'Clears the chat screen',
|
||||||
|
paramaters: [],
|
||||||
|
usage: '/clear',
|
||||||
|
scope: 'local',
|
||||||
|
action: (params = null) => {
|
||||||
|
// eslint-disable-line
|
||||||
|
this.props.clearActivities();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
if (!hasTouchSupport) {
|
||||||
|
// Disable for now due to vary issues:
|
||||||
|
// Paste not working, shift+enter line breaks
|
||||||
|
// autosize(this.textInput);
|
||||||
|
this.textInput.addEventListener('autosize:resized', () => {
|
||||||
|
this.props.scrollToBottom();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
if (nextProps.focusChat) {
|
||||||
|
if (!getSelectedText()) {
|
||||||
|
// Don't focus for now, evaluate UX benfits
|
||||||
|
// this.textInput.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(nextProps, nextState) {
|
||||||
|
if (!nextState.message.trim().length) {
|
||||||
|
// autosize.update(this.textInput)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyUp(e) {
|
||||||
|
if (e.key === 'Shift') {
|
||||||
|
this.setState({
|
||||||
|
shiftKeyDown: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleKeyPress(e) {
|
||||||
|
if (e.key === 'Shift') {
|
||||||
|
this.setState({
|
||||||
|
shiftKeyDown: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Fix when autosize is enabled - line breaks require shift+enter twice
|
||||||
|
if (e.key === 'Enter' && !hasTouchSupport && !this.state.shiftKeyDown) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (this.canSend()) {
|
||||||
|
this.sendMessage();
|
||||||
|
} else {
|
||||||
|
this.setState({
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
executeCommand(command) {
|
||||||
|
const commandToExecute = this.commands.find(cmnd => cmnd.command === command.command);
|
||||||
|
|
||||||
|
if (commandToExecute) {
|
||||||
|
const { params } = command;
|
||||||
|
const commandResult = commandToExecute.action(params);
|
||||||
|
|
||||||
|
return commandResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSendClick() {
|
||||||
|
this.sendMessage.bind(this);
|
||||||
|
this.textInput.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFormSubmit(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
this.sendMessage();
|
||||||
|
}
|
||||||
|
|
||||||
|
parseCommand(message) {
|
||||||
|
const commandTrigger = {
|
||||||
|
command: null,
|
||||||
|
params: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
if (message.charAt(0) === '/') {
|
||||||
|
const parsedCommand = message.replace('/', '').split(' ');
|
||||||
|
commandTrigger.command = sanitizeHtml(parsedCommand[0]) || null;
|
||||||
|
// Get params
|
||||||
|
if (parsedCommand.length >= 2) {
|
||||||
|
for (let i = 1; i < parsedCommand.length; i++) {
|
||||||
|
commandTrigger.params.push(parsedCommand[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return commandTrigger;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
sendMessage() {
|
||||||
|
if (!this.canSend()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { message } = this.state;
|
||||||
|
const isCommand = this.parseCommand(message);
|
||||||
|
|
||||||
|
if (isCommand) {
|
||||||
|
const res = this.executeCommand(isCommand);
|
||||||
|
if (res === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.props.sendEncryptedMessage({
|
||||||
|
type: 'TEXT_MESSAGE',
|
||||||
|
payload: {
|
||||||
|
text: message,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
message: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleInputChange(evt) {
|
||||||
|
this.setState({
|
||||||
|
message: evt.target.value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
canSend() {
|
||||||
|
return this.state.message.trim().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const touchSupport = this.state.touchSupport;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={this.handleFormSubmit.bind(this)} className="chat-preflight-container">
|
||||||
|
<textarea
|
||||||
|
rows="1"
|
||||||
|
onKeyUp={this.handleKeyUp.bind(this)}
|
||||||
|
onKeyDown={this.handleKeyPress.bind(this)}
|
||||||
|
ref={input => {
|
||||||
|
this.textInput = input;
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
className="chat"
|
||||||
|
value={this.state.message}
|
||||||
|
placeholder={this.props.translations.typePlaceholder}
|
||||||
|
onChange={this.handleInputChange.bind(this)}
|
||||||
|
/>
|
||||||
|
<div className="input-controls">
|
||||||
|
<FileTransfer sendEncryptedMessage={this.props.sendEncryptedMessage} />
|
||||||
|
{touchSupport && (
|
||||||
|
<button
|
||||||
|
onClick={this.handleSendClick.bind(this)}
|
||||||
|
className={`icon is-right send btn btn-link ${this.canSend() ? 'active' : ''}`}
|
||||||
|
title="Send"
|
||||||
|
>
|
||||||
|
<CornerDownRight className={this.canSend() ? '' : 'disabled'} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Chat.propTypes = {
|
||||||
|
sendEncryptedMessage: PropTypes.func.isRequired,
|
||||||
|
showNotice: PropTypes.func.isRequired,
|
||||||
|
userId: PropTypes.string.isRequired,
|
||||||
|
username: PropTypes.string.isRequired,
|
||||||
|
clearActivities: PropTypes.func.isRequired,
|
||||||
|
focusChat: PropTypes.bool.isRequired,
|
||||||
|
scrollToBottom: PropTypes.func.isRequired,
|
||||||
|
translations: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Chat;
|
254
client/src/components/Chat/Chat.test.jsx
Normal file
254
client/src/components/Chat/Chat.test.jsx
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
import { Chat } from '@/components/Chat/Chat';
|
||||||
|
|
||||||
|
import * as dom from '@/utils/dom';
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
typePlaceholder: 'inputplaceholder',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fake date
|
||||||
|
vi.spyOn(global.Date, 'now').mockImplementation(() => new Date('2020-03-14T11:01:58.135Z').valueOf());
|
||||||
|
|
||||||
|
// To change touch support
|
||||||
|
vi.mock('@/utils/dom');
|
||||||
|
|
||||||
|
describe('Chat component', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
// Reset touch support
|
||||||
|
dom.hasTouchSupport = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display', () => {
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Chat
|
||||||
|
scrollToBottom={() => {}}
|
||||||
|
focusChat={false}
|
||||||
|
userId="foo"
|
||||||
|
username="user"
|
||||||
|
showNotice={() => {}}
|
||||||
|
clearActivities={() => {}}
|
||||||
|
sendEncryptedMessage={() => {}}
|
||||||
|
translations={{}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can send message', () => {
|
||||||
|
const sendEncryptedMessage = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Chat
|
||||||
|
scrollToBottom={() => {}}
|
||||||
|
focusChat={false}
|
||||||
|
userId="foo"
|
||||||
|
username="user"
|
||||||
|
showNotice={() => {}}
|
||||||
|
clearActivities={() => {}}
|
||||||
|
sendEncryptedMessage={sendEncryptedMessage}
|
||||||
|
translations={translations}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(translations.typePlaceholder);
|
||||||
|
|
||||||
|
// Validate but without text
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(sendEncryptedMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Type test
|
||||||
|
fireEvent.change(textarea, { target: { value: 'test' } });
|
||||||
|
// Validate
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(sendEncryptedMessage).toHaveBeenLastCalledWith({
|
||||||
|
payload: { text: 'test', timestamp: 1584183718135 },
|
||||||
|
type: 'TEXT_MESSAGE',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Validate (textarea should be empty)
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(sendEncryptedMessage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shouldn't send message with Shift+enter", () => {
|
||||||
|
const sendEncryptedMessage = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Chat
|
||||||
|
scrollToBottom={() => {}}
|
||||||
|
focusChat={false}
|
||||||
|
userId="foo"
|
||||||
|
username="user"
|
||||||
|
showNotice={() => {}}
|
||||||
|
clearActivities={() => {}}
|
||||||
|
sendEncryptedMessage={sendEncryptedMessage}
|
||||||
|
translations={translations}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(translations.typePlaceholder);
|
||||||
|
|
||||||
|
// Test shift effect
|
||||||
|
fireEvent.change(textarea, { target: { value: 'test2' } });
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Shift' });
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||||
|
fireEvent.keyUp(textarea, { key: 'Shift' });
|
||||||
|
|
||||||
|
expect(sendEncryptedMessage).toHaveBeenCalledTimes(0);
|
||||||
|
|
||||||
|
// Now we want to send the message
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(sendEncryptedMessage).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
expect(sendEncryptedMessage).toHaveBeenLastCalledWith({
|
||||||
|
payload: { text: 'test2', timestamp: 1584183718135 },
|
||||||
|
type: 'TEXT_MESSAGE',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send commands', () => {
|
||||||
|
const sendEncryptedMessage = vi.fn();
|
||||||
|
const showNotice = vi.fn();
|
||||||
|
const clearActivities = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Chat
|
||||||
|
scrollToBottom={() => {}}
|
||||||
|
focusChat={false}
|
||||||
|
userId="foo"
|
||||||
|
username="user"
|
||||||
|
showNotice={showNotice}
|
||||||
|
clearActivities={clearActivities}
|
||||||
|
sendEncryptedMessage={sendEncryptedMessage}
|
||||||
|
translations={translations}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(translations.typePlaceholder);
|
||||||
|
|
||||||
|
// Test /help
|
||||||
|
fireEvent.change(textarea, { target: { value: '/help' } });
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(showNotice).toHaveBeenLastCalledWith({
|
||||||
|
level: 'info',
|
||||||
|
message: 'Valid commands: /clear, /help, /me, /nick',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test /me
|
||||||
|
fireEvent.change(textarea, { target: { value: '/me' } });
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(sendEncryptedMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.change(textarea, { target: { value: '/me action' } });
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(sendEncryptedMessage).toHaveBeenLastCalledWith({
|
||||||
|
payload: { action: 'action' },
|
||||||
|
type: 'USER_ACTION',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test /clear
|
||||||
|
fireEvent.change(textarea, { target: { value: '/clear' } });
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(clearActivities).toHaveBeenLastCalledWith();
|
||||||
|
|
||||||
|
// Test /nick/clear
|
||||||
|
fireEvent.change(textarea, { target: { value: '/nick john!Th3Ripp&3r' } });
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(sendEncryptedMessage).toHaveBeenLastCalledWith({
|
||||||
|
payload: { currentUsername: 'user', id: 'foo', newUsername: 'john-Th3Ripp-3r' },
|
||||||
|
type: 'CHANGE_USERNAME',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test /nick
|
||||||
|
fireEvent.change(textarea, { target: { value: '/nick' } });
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(showNotice).toHaveBeenLastCalledWith({
|
||||||
|
level: 'error',
|
||||||
|
message: 'Username cannot be blank, Username must start with a letter',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test /nick
|
||||||
|
fireEvent.change(textarea, { target: { value: '/nick 3po' } });
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(showNotice).toHaveBeenLastCalledWith({
|
||||||
|
level: 'error',
|
||||||
|
message: 'Username must start with a letter',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test /nick
|
||||||
|
fireEvent.change(textarea, {
|
||||||
|
target: { value: '/nick 3po3ralotsofcrapscharactersforyourpleasureandnotmine' },
|
||||||
|
});
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||||
|
|
||||||
|
expect(sendEncryptedMessage).toHaveBeenLastCalledWith({
|
||||||
|
payload: { currentUsername: 'user', id: 'foo', newUsername: 'john-Th3Ripp-3r' },
|
||||||
|
type: 'CHANGE_USERNAME',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test badcommand
|
||||||
|
fireEvent.change(textarea, { target: { value: '/void' } });
|
||||||
|
fireEvent.keyDown(textarea, { key: 'Enter' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with touch support', () => {
|
||||||
|
// Enable touch support
|
||||||
|
dom.hasTouchSupport = true;
|
||||||
|
|
||||||
|
vi.mock('@/utils/dom', () => {
|
||||||
|
return {
|
||||||
|
getSelectedText: vi.fn(),
|
||||||
|
hasTouchSupport: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const sendEncryptedMessage = vi.fn();
|
||||||
|
|
||||||
|
const { getByTitle } = render(
|
||||||
|
<Chat
|
||||||
|
scrollToBottom={() => {}}
|
||||||
|
focusChat={false}
|
||||||
|
userId="foo"
|
||||||
|
username="user"
|
||||||
|
showNotice={() => {}}
|
||||||
|
clearActivities={() => {}}
|
||||||
|
sendEncryptedMessage={sendEncryptedMessage}
|
||||||
|
translations={translations}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const textarea = screen.getByPlaceholderText(translations.typePlaceholder);
|
||||||
|
|
||||||
|
// Type test
|
||||||
|
fireEvent.change(textarea, { target: { value: 'test' } });
|
||||||
|
|
||||||
|
// Touch send button
|
||||||
|
fireEvent.click(getByTitle('Send'));
|
||||||
|
|
||||||
|
expect(sendEncryptedMessage).toHaveBeenLastCalledWith({
|
||||||
|
payload: { text: 'test', timestamp: 1584183718135 },
|
||||||
|
type: 'TEXT_MESSAGE',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not send message
|
||||||
|
fireEvent.click(getByTitle('Send'));
|
||||||
|
|
||||||
|
expect(sendEncryptedMessage).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
50
client/src/components/Chat/__snapshots__/Chat.test.jsx.snap
Normal file
50
client/src/components/Chat/__snapshots__/Chat.test.jsx.snap
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`Chat component > should display 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<form
|
||||||
|
class="chat-preflight-container"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
class="chat"
|
||||||
|
rows="1"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="input-controls"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_styles_374fdd icon file-transfer btn btn-link"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="fileInput"
|
||||||
|
name="fileUploader"
|
||||||
|
placeholder="Choose a file..."
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="fileInput"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="#fff"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points="13 2 13 9 20 9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
17
client/src/components/Chat/index.jsx
Normal file
17
client/src/components/Chat/index.jsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import Chat from './Chat';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { clearActivities, showNotice, sendEncryptedMessage } from '@/actions';
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
username: state.user.username,
|
||||||
|
userId: state.user.id,
|
||||||
|
translations: state.app.translations,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
clearActivities,
|
||||||
|
showNotice,
|
||||||
|
sendEncryptedMessage,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default connect(mapStateToProps, mapDispatchToProps)(Chat);
|
10
client/src/components/Connecting/Connecting.test.jsx
Normal file
10
client/src/components/Connecting/Connecting.test.jsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import Connecting from '.';
|
||||||
|
import { test, expect } from 'vitest';
|
||||||
|
|
||||||
|
test('Connecting component is displaying', async () => {
|
||||||
|
const { asFragment } = render(<Connecting />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
@ -0,0 +1,9 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`Connecting component is displaying 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
Please wait while we secure a connection to Darkwire...
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
7
client/src/components/Connecting/index.jsx
Normal file
7
client/src/components/Connecting/index.jsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
|
export default class Connecting extends Component {
|
||||||
|
render() {
|
||||||
|
return <div>Please wait while we secure a connection to Darkwire...</div>;
|
||||||
|
}
|
||||||
|
}
|
146
client/src/components/FileTransfer/FileTransfer.jsx
Normal file
146
client/src/components/FileTransfer/FileTransfer.jsx
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { File } from 'react-feather';
|
||||||
|
|
||||||
|
import { MAX_FILE_SIZE } from '@/config/env';
|
||||||
|
import { sanitize } from '@/utils';
|
||||||
|
|
||||||
|
import classes from './styles.module.scss';
|
||||||
|
|
||||||
|
const VALID_FILE_TYPES = [
|
||||||
|
'png',
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'gif',
|
||||||
|
'zip',
|
||||||
|
'rar',
|
||||||
|
'gzip',
|
||||||
|
'pdf',
|
||||||
|
'txt',
|
||||||
|
'json',
|
||||||
|
'doc',
|
||||||
|
'docx',
|
||||||
|
'csv',
|
||||||
|
'js',
|
||||||
|
'html',
|
||||||
|
'css',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encode the given file to binary string
|
||||||
|
* @param {File} file
|
||||||
|
*/
|
||||||
|
const encodeFile = file => {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new window.FileReader();
|
||||||
|
|
||||||
|
if (!file) {
|
||||||
|
reject();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.onload = readerEvent => {
|
||||||
|
resolve(window.btoa(readerEvent.target.result));
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsBinaryString(file);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FileTransfer = ({ sendEncryptedMessage }) => {
|
||||||
|
const fileInput = React.useRef(null);
|
||||||
|
|
||||||
|
const supported = React.useMemo(
|
||||||
|
() =>
|
||||||
|
Boolean(window.File) &&
|
||||||
|
Boolean(window.FileReader) &&
|
||||||
|
Boolean(window.FileList) &&
|
||||||
|
Boolean(window.Blob) &&
|
||||||
|
Boolean(window.btoa) &&
|
||||||
|
Boolean(window.atob) &&
|
||||||
|
Boolean(window.URL),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const currentFileInput = fileInput.current;
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const handleFileTransfer = async event => {
|
||||||
|
const file = event.target.files && event.target.files[0];
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
const fileType = file.type || 'file';
|
||||||
|
const fileName = sanitize(file.name);
|
||||||
|
const fileExtension = file.name.split('.').pop().toLowerCase();
|
||||||
|
|
||||||
|
if (VALID_FILE_TYPES.indexOf(fileExtension) <= -1) {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
alert('File type not supported');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > MAX_FILE_SIZE * 1000000) {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
alert(`Max filesize is ${MAX_FILE_SIZE}MB`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = nanoid();
|
||||||
|
const fileData = {
|
||||||
|
id: fileId,
|
||||||
|
file,
|
||||||
|
fileName,
|
||||||
|
fileType,
|
||||||
|
encodedFile: await encodeFile(file),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mounted component guard
|
||||||
|
if (!isMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fileInput.current.value = '';
|
||||||
|
|
||||||
|
sendEncryptedMessage({
|
||||||
|
type: 'SEND_FILE',
|
||||||
|
payload: {
|
||||||
|
fileName: fileData.fileName,
|
||||||
|
encodedFile: fileData.encodedFile,
|
||||||
|
fileType: fileData.fileType,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
currentFileInput.addEventListener('change', handleFileTransfer);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
currentFileInput.removeEventListener('change', handleFileTransfer);
|
||||||
|
};
|
||||||
|
}, [sendEncryptedMessage]);
|
||||||
|
|
||||||
|
if (!supported) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${classes.styles} icon file-transfer btn btn-link`}>
|
||||||
|
<input placeholder="Choose a file..." type="file" name="fileUploader" id="fileInput" ref={fileInput} />
|
||||||
|
<label htmlFor="fileInput">
|
||||||
|
<File color="#fff" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
FileTransfer.propTypes = {
|
||||||
|
sendEncryptedMessage: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileTransfer;
|
105
client/src/components/FileTransfer/FileTransfer.test.jsx
Normal file
105
client/src/components/FileTransfer/FileTransfer.test.jsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, screen, fireEvent, createEvent } from '@testing-library/react';
|
||||||
|
import FileTransfer from '.';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
// Fake date
|
||||||
|
vi.spyOn(global.Date, 'now').mockImplementation(() => new Date('2020-03-14T11:01:58.135Z').valueOf());
|
||||||
|
|
||||||
|
describe('FileTransfer tests', () => {
|
||||||
|
const { File } = window;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
// Restore original
|
||||||
|
window.File = File;
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
// Restore original
|
||||||
|
window.File = File;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('FileTransfer component is displaying', async () => {
|
||||||
|
const { asFragment } = render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Skipped as broken in this component version. Should be fixed later.
|
||||||
|
it.skip('FileTransfer component detect bad browser support', () => {
|
||||||
|
const { asFragment } = render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Try to send file', async done => {
|
||||||
|
const sendEncryptedMessage = data => {
|
||||||
|
try {
|
||||||
|
expect(data).toMatchObject({
|
||||||
|
payload: {
|
||||||
|
encodedFile: 'dGV4dGZpbGU=',
|
||||||
|
fileName: 'filename.png',
|
||||||
|
fileType: 'text/plain',
|
||||||
|
timestamp: 1584183718135,
|
||||||
|
},
|
||||||
|
type: 'SEND_FILE',
|
||||||
|
});
|
||||||
|
done();
|
||||||
|
} catch (error) {
|
||||||
|
done(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<FileTransfer sendEncryptedMessage={sendEncryptedMessage} />);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Choose a file...');
|
||||||
|
|
||||||
|
const testFile = new File(['textfile'], 'filename.png', { type: 'text/plain' });
|
||||||
|
|
||||||
|
// Fire change event
|
||||||
|
fireEvent.change(input, { target: { files: [testFile] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Try to send no file', async () => {
|
||||||
|
render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Choose a file...');
|
||||||
|
|
||||||
|
// Fire change event
|
||||||
|
fireEvent.change(input, { target: { files: [] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Try to send unsupported file', async () => {
|
||||||
|
window.alert = vi.fn();
|
||||||
|
|
||||||
|
render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Choose a file...');
|
||||||
|
|
||||||
|
const testFile = new File(['textfile'], 'filename.fake', { type: 'text/plain' });
|
||||||
|
|
||||||
|
// Create thange event with fake file
|
||||||
|
const changeEvent = createEvent.change(input, { target: { files: [testFile] } });
|
||||||
|
|
||||||
|
// Fire change event
|
||||||
|
fireEvent(input, changeEvent);
|
||||||
|
|
||||||
|
expect(window.alert).toHaveBeenCalledWith('File type not supported');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Try to send too big file', async () => {
|
||||||
|
window.alert = vi.fn();
|
||||||
|
|
||||||
|
render(<FileTransfer sendEncryptedMessage={() => {}} />);
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText('Choose a file...');
|
||||||
|
|
||||||
|
var fileContent = new Uint8Array(4000001);
|
||||||
|
|
||||||
|
const testFile = new File([fileContent], 'filename.png', { type: 'text/plain' });
|
||||||
|
|
||||||
|
// Fire change event
|
||||||
|
fireEvent.change(input, { target: { files: [testFile] } });
|
||||||
|
|
||||||
|
expect(window.alert).toHaveBeenCalledWith('Max filesize is 4MB');
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,38 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`FileTransfer tests > FileTransfer component is displaying 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
class="_styles_374fdd icon file-transfer btn btn-link"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="fileInput"
|
||||||
|
name="fileUploader"
|
||||||
|
placeholder="Choose a file..."
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="fileInput"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="#fff"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points="13 2 13 9 20 9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
3
client/src/components/FileTransfer/index.jsx
Normal file
3
client/src/components/FileTransfer/index.jsx
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import FileTransfer from './FileTransfer';
|
||||||
|
|
||||||
|
export default FileTransfer;
|
16
client/src/components/FileTransfer/styles.module.scss
Normal file
16
client/src/components/FileTransfer/styles.module.scss
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
.styles {
|
||||||
|
input {
|
||||||
|
-ms-filter: 'alpha(opacity=0)';
|
||||||
|
width: 0.1px;
|
||||||
|
height: 0.1px;
|
||||||
|
opacity: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
}
|
||||||
|
label {
|
||||||
|
cursor: pointer;
|
||||||
|
height: 100%;
|
||||||
|
padding-top: 7px;
|
||||||
|
}
|
||||||
|
}
|
172
client/src/components/Home/Activity.jsx
Normal file
172
client/src/components/Home/Activity.jsx
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import Zoom from '@/utils/ImageZoom';
|
||||||
|
import { getObjectUrl } from '@/utils/file';
|
||||||
|
|
||||||
|
import Message from '@/components/Message';
|
||||||
|
import Username from '@/components/Username';
|
||||||
|
import Notice from '@/components/Notice';
|
||||||
|
import T from '@/components/T';
|
||||||
|
|
||||||
|
const FileDisplay = ({ activity: { fileType, encodedFile, fileName, username }, scrollToBottom }) => {
|
||||||
|
const zoomableImage = React.useRef(null);
|
||||||
|
|
||||||
|
const handleImageDisplay = () => {
|
||||||
|
Zoom(zoomableImage.current);
|
||||||
|
scrollToBottom();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (fileType.match('image.*')) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
ref={zoomableImage}
|
||||||
|
className="image-transfer zoomable"
|
||||||
|
src={`data:${fileType};base64,${encodedFile}`}
|
||||||
|
alt={`${fileName} from ${username}`}
|
||||||
|
onLoad={handleImageDisplay}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Activity = ({ activity, scrollToBottom }) => {
|
||||||
|
switch (activity.type) {
|
||||||
|
case 'TEXT_MESSAGE':
|
||||||
|
return <Message sender={activity.username} message={activity.text} timestamp={activity.timestamp} />;
|
||||||
|
case 'USER_ENTER':
|
||||||
|
return (
|
||||||
|
<Notice>
|
||||||
|
<div>
|
||||||
|
<T
|
||||||
|
data={{
|
||||||
|
username: <Username key={0} username={activity.username} />,
|
||||||
|
}}
|
||||||
|
path="userJoined"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Notice>
|
||||||
|
);
|
||||||
|
case 'USER_EXIT':
|
||||||
|
return (
|
||||||
|
<Notice>
|
||||||
|
<div>
|
||||||
|
<T
|
||||||
|
data={{
|
||||||
|
username: <Username key={0} username={activity.username} />,
|
||||||
|
}}
|
||||||
|
path="userLeft"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Notice>
|
||||||
|
);
|
||||||
|
case 'TOGGLE_LOCK_ROOM':
|
||||||
|
if (activity.locked) {
|
||||||
|
return (
|
||||||
|
<Notice>
|
||||||
|
<div>
|
||||||
|
<T
|
||||||
|
data={{
|
||||||
|
username: <Username key={0} username={activity.username} />,
|
||||||
|
}}
|
||||||
|
path="lockedRoom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Notice>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<Notice>
|
||||||
|
<div>
|
||||||
|
<T
|
||||||
|
data={{
|
||||||
|
username: <Username key={0} username={activity.username} />,
|
||||||
|
}}
|
||||||
|
path="unlockedRoom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Notice>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case 'NOTICE':
|
||||||
|
return (
|
||||||
|
<Notice>
|
||||||
|
<div>{activity.message}</div>
|
||||||
|
</Notice>
|
||||||
|
);
|
||||||
|
case 'CHANGE_USERNAME':
|
||||||
|
return (
|
||||||
|
<Notice>
|
||||||
|
<div>
|
||||||
|
<T
|
||||||
|
data={{
|
||||||
|
oldUsername: <Username key={0} username={activity.currentUsername} />,
|
||||||
|
newUsername: <Username key={1} username={activity.newUsername} />,
|
||||||
|
}}
|
||||||
|
path="nameChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Notice>
|
||||||
|
);
|
||||||
|
case 'USER_ACTION':
|
||||||
|
return (
|
||||||
|
<Notice>
|
||||||
|
<div>
|
||||||
|
* <Username username={activity.username} /> {activity.action}
|
||||||
|
</div>
|
||||||
|
</Notice>
|
||||||
|
);
|
||||||
|
case 'RECEIVE_FILE':
|
||||||
|
const downloadUrl = getObjectUrl(activity.encodedFile, activity.fileType);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<T
|
||||||
|
data={{
|
||||||
|
username: <Username key={0} username={activity.username} />,
|
||||||
|
}}
|
||||||
|
path="userSentFile"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<a target="_blank" href={downloadUrl} rel="noopener noreferrer" download={activity.fileName}>
|
||||||
|
<T
|
||||||
|
data={{
|
||||||
|
filename: activity.fileName,
|
||||||
|
}}
|
||||||
|
path="downloadFile"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
<FileDisplay activity={activity} scrollToBottom={scrollToBottom} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case 'SEND_FILE':
|
||||||
|
const url = getObjectUrl(activity.encodedFile, activity.fileType);
|
||||||
|
return (
|
||||||
|
<Notice>
|
||||||
|
<div>
|
||||||
|
<T
|
||||||
|
data={{
|
||||||
|
filename: (
|
||||||
|
<a key={0} target="_blank" href={url} rel="noopener noreferrer" download={activity.fileName}>
|
||||||
|
{activity.fileName}
|
||||||
|
</a>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
path="sentFile"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<FileDisplay activity={activity} scrollToBottom={scrollToBottom} />
|
||||||
|
</Notice>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Activity.propTypes = {
|
||||||
|
activity: PropTypes.object.isRequired,
|
||||||
|
scrollToBottom: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Activity;
|
201
client/src/components/Home/Activity.test.jsx
Normal file
201
client/src/components/Home/Activity.test.jsx
Normal file
@ -0,0 +1,201 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
|
||||||
|
import Activity from './Activity';
|
||||||
|
import configureStore from '@/store';
|
||||||
|
|
||||||
|
const store = configureStore();
|
||||||
|
|
||||||
|
describe('Activity component', () => {
|
||||||
|
it('should display', () => {
|
||||||
|
const activity = {
|
||||||
|
type: '',
|
||||||
|
};
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Activity activity={activity} scrollToBottom={vi.fn()} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display TEXT_MESSAGE', () => {
|
||||||
|
const activity = {
|
||||||
|
type: 'TEXT_MESSAGE',
|
||||||
|
username: 'alice',
|
||||||
|
timestamp: new Date('2020-03-14T11:01:58.135Z').valueOf(),
|
||||||
|
text: 'Hi!',
|
||||||
|
};
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Activity activity={activity} scrollToBottom={vi.fn()} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display USER_ENTER', () => {
|
||||||
|
const activity = {
|
||||||
|
type: 'USER_ENTER',
|
||||||
|
username: 'alice',
|
||||||
|
};
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Activity activity={activity} scrollToBottom={vi.fn()} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display USER_EXIT', () => {
|
||||||
|
const activity = {
|
||||||
|
type: 'USER_EXIT',
|
||||||
|
username: 'alice',
|
||||||
|
};
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Activity activity={activity} scrollToBottom={vi.fn()} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display TOGGLE_LOCK_ROOM', () => {
|
||||||
|
const activity = {
|
||||||
|
type: 'TOGGLE_LOCK_ROOM',
|
||||||
|
locked: true,
|
||||||
|
username: 'alice',
|
||||||
|
};
|
||||||
|
const { asFragment, rerender } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Activity activity={activity} scrollToBottom={vi.fn()} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
|
||||||
|
activity.locked = false;
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Activity activity={activity} scrollToBottom={vi.fn()} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display NOTICE', () => {
|
||||||
|
const activity = {
|
||||||
|
type: 'NOTICE',
|
||||||
|
message: 'Hello world!',
|
||||||
|
};
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Activity activity={activity} scrollToBottom={vi.fn()} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display CHANGE_USERNAME', () => {
|
||||||
|
const activity = {
|
||||||
|
type: 'CHANGE_USERNAME',
|
||||||
|
currentUsername: 'alice',
|
||||||
|
newUsername: 'alicette',
|
||||||
|
};
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Activity activity={activity} scrollToBottom={vi.fn()} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display USER_ACTION', () => {
|
||||||
|
const activity = {
|
||||||
|
type: 'USER_ACTION',
|
||||||
|
username: 'alice',
|
||||||
|
action: 'did right!',
|
||||||
|
};
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Activity activity={activity} scrollToBottom={vi.fn()} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display RECEIVE_FILE', () => {
|
||||||
|
const activity = {
|
||||||
|
type: 'RECEIVE_FILE',
|
||||||
|
username: 'alice',
|
||||||
|
fileName: 'alice.pdf',
|
||||||
|
encodedFile: 'dGV4dGZpbGU=',
|
||||||
|
fileType: 'text/plain',
|
||||||
|
};
|
||||||
|
global.URL.createObjectURL = vi.fn(data => `url:${data}`);
|
||||||
|
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Activity activity={activity} scrollToBottom={vi.fn()} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display RECEIVE_FILE with image', () => {
|
||||||
|
global.URL.createObjectURL = vi.fn(data => `url:${data}`);
|
||||||
|
|
||||||
|
const mockScrollToBottom = vi.fn();
|
||||||
|
|
||||||
|
const activity = {
|
||||||
|
type: 'RECEIVE_FILE',
|
||||||
|
username: 'alice',
|
||||||
|
fileName: 'alice.jpg',
|
||||||
|
encodedFile: 'dGV4dGZpbGU=',
|
||||||
|
fileType: 'image/jpg',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { asFragment, getByAltText } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Activity activity={activity} scrollToBottom={mockScrollToBottom} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
|
||||||
|
const image = getByAltText('alice.jpg from alice');
|
||||||
|
fireEvent.load(image);
|
||||||
|
expect(mockScrollToBottom).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display SEND_FILE', () => {
|
||||||
|
global.URL.createObjectURL = vi.fn(data => `url:${data}`);
|
||||||
|
const activity = {
|
||||||
|
type: 'SEND_FILE',
|
||||||
|
username: 'alice',
|
||||||
|
fileName: 'alice.pdf',
|
||||||
|
encodedFile: 'dGV4dGZpbGU=',
|
||||||
|
fileType: 'text/plain',
|
||||||
|
};
|
||||||
|
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Activity activity={activity} scrollToBottom={vi.fn()} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
});
|
98
client/src/components/Home/ActivityList.jsx
Normal file
98
client/src/components/Home/ActivityList.jsx
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { defer } from 'lodash';
|
||||||
|
|
||||||
|
import ChatInput from '@/components/Chat';
|
||||||
|
import T from '@/components/T';
|
||||||
|
|
||||||
|
import Activity from './Activity';
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
|
const ActivityList = ({ activities, openModal }) => {
|
||||||
|
const [focusChat, setFocusChat] = React.useState(false);
|
||||||
|
const [scrolledToBottom, setScrolledToBottom] = React.useState(true);
|
||||||
|
const messageStream = React.useRef(null);
|
||||||
|
const activitiesList = React.useRef(null);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const currentMessageStream = messageStream.current;
|
||||||
|
|
||||||
|
// Update scrolledToBottom state if we scroll the activity stream
|
||||||
|
const onScroll = () => {
|
||||||
|
const messageStreamHeight = messageStream.current.clientHeight;
|
||||||
|
const activitiesListHeight = activitiesList.current.clientHeight;
|
||||||
|
|
||||||
|
const bodyRect = document.body.getBoundingClientRect();
|
||||||
|
const elemRect = activitiesList.current.getBoundingClientRect();
|
||||||
|
const offset = elemRect.top - bodyRect.top;
|
||||||
|
const activitiesListYPos = offset;
|
||||||
|
|
||||||
|
const newScrolledToBottom = activitiesListHeight + (activitiesListYPos - 60) <= messageStreamHeight;
|
||||||
|
if (newScrolledToBottom) {
|
||||||
|
if (!scrolledToBottom) {
|
||||||
|
setScrolledToBottom(true);
|
||||||
|
}
|
||||||
|
} else if (scrolledToBottom) {
|
||||||
|
setScrolledToBottom(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
currentMessageStream.addEventListener('scroll', onScroll);
|
||||||
|
return () => {
|
||||||
|
// Unbind event if component unmounted
|
||||||
|
currentMessageStream.removeEventListener('scroll', onScroll);
|
||||||
|
};
|
||||||
|
}, [scrolledToBottom]);
|
||||||
|
|
||||||
|
const scrollToBottomIfShould = React.useCallback(() => {
|
||||||
|
if (scrolledToBottom) {
|
||||||
|
messageStream.current.scrollTop = messageStream.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [scrolledToBottom]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
scrollToBottomIfShould(); // Only if activities.length bigger
|
||||||
|
}, [scrollToBottomIfShould, activities]);
|
||||||
|
|
||||||
|
const scrollToBottom = React.useCallback(() => {
|
||||||
|
messageStream.current.scrollTop = messageStream.current.scrollHeight;
|
||||||
|
setScrolledToBottom(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChatClick = () => {
|
||||||
|
setFocusChat(true);
|
||||||
|
defer(() => setFocusChat(false));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="main-chat">
|
||||||
|
<div onClick={handleChatClick} className="message-stream h-100" ref={messageStream} data-testid="main-div">
|
||||||
|
<ul className="plain" ref={activitiesList}>
|
||||||
|
<li>
|
||||||
|
<p className={styles.tos}>
|
||||||
|
<button className="btn btn-link" onClick={() => openModal('About')}>
|
||||||
|
{' '}
|
||||||
|
<T path="agreement" />
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
{activities.map((activity, index) => (
|
||||||
|
<li key={index} className={`activity-item ${activity.type}`}>
|
||||||
|
<Activity activity={activity} scrollToBottom={scrollToBottomIfShould} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="chat-container">
|
||||||
|
<ChatInput scrollToBottom={scrollToBottom} focusChat={focusChat} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ActivityList.propTypes = {
|
||||||
|
activities: PropTypes.array.isRequired,
|
||||||
|
openModal: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActivityList;
|
139
client/src/components/Home/ActivityList.test.jsx
Normal file
139
client/src/components/Home/ActivityList.test.jsx
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
import configureStore from '@/store';
|
||||||
|
|
||||||
|
import ActivityList from './ActivityList';
|
||||||
|
|
||||||
|
const store = configureStore();
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
describe('ActivityList component', () => {
|
||||||
|
it('should display', () => {
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ActivityList openModal={vi.fn()} activities={[]} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display with activities', () => {
|
||||||
|
const activities = [
|
||||||
|
{
|
||||||
|
type: 'TEXT_MESSAGE',
|
||||||
|
username: 'alice',
|
||||||
|
timestamp: new Date('2020-03-14T11:01:58.135Z').valueOf(),
|
||||||
|
text: 'Hi!',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'USER_ENTER',
|
||||||
|
username: 'alice',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'CHANGE_USERNAME',
|
||||||
|
currentUsername: 'alice',
|
||||||
|
newUsername: 'alicette',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ActivityList openModal={vi.fn()} activities={activities} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should show About modal', async () => {
|
||||||
|
const mockOpenModal = vi.fn();
|
||||||
|
|
||||||
|
const { getByText } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ActivityList openModal={mockOpenModal} activities={[]} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(getByText('By using Darkwire, you are agreeing to our Acceptable Use Policy and Terms of Service'));
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(mockOpenModal.mock.calls[0][0]).toBe('About');
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should focus chat', () => {
|
||||||
|
const { getByTestId } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ActivityList openModal={vi.fn()} activities={[]} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
fireEvent.click(getByTestId('main-div'));
|
||||||
|
vi.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should scroll to bottom on new message if not scrolled', () => {
|
||||||
|
vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValueOnce(400).mockReturnValueOnce(200);
|
||||||
|
|
||||||
|
Element.prototype.getBoundingClientRect = vi.fn().mockReturnValueOnce({ top: 0 }).mockReturnValueOnce({ top: 261 });
|
||||||
|
|
||||||
|
vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(42);
|
||||||
|
const mockScrollTop = vi.spyOn(Element.prototype, 'scrollTop', 'set');
|
||||||
|
|
||||||
|
const { rerender, getByTestId } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ActivityList openModal={vi.fn()} activities={[]} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ActivityList
|
||||||
|
openModal={vi.fn()}
|
||||||
|
activities={[
|
||||||
|
{
|
||||||
|
type: 'TEXT_MESSAGE',
|
||||||
|
username: 'alice',
|
||||||
|
timestamp: new Date('2020-03-14T11:01:58.135Z').valueOf(),
|
||||||
|
text: 'Hi!',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(mockScrollTop).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mockScrollTop).toHaveBeenLastCalledWith(42);
|
||||||
|
|
||||||
|
fireEvent.scroll(getByTestId('main-div'));
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ActivityList
|
||||||
|
openModal={vi.fn()}
|
||||||
|
activities={[
|
||||||
|
{
|
||||||
|
type: 'TEXT_MESSAGE',
|
||||||
|
username: 'alice',
|
||||||
|
timestamp: new Date('2020-03-14T11:01:58.135Z').valueOf(),
|
||||||
|
text: 'Hi!',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'TEXT_MESSAGE',
|
||||||
|
username: 'alice',
|
||||||
|
timestamp: new Date('2020-03-14T11:01:59.135Z').valueOf(),
|
||||||
|
text: 'Hi! every body',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mockScrollTop).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
267
client/src/components/Home/Home.jsx
Normal file
267
client/src/components/Home/Home.jsx
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import Modal from 'react-modal';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { X, AlertCircle } from 'react-feather';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
import Crypto from '@/utils/crypto';
|
||||||
|
import { connect as connectSocket } from '@/utils/socket';
|
||||||
|
|
||||||
|
import Nav from '@/components/Nav';
|
||||||
|
import Connecting from '@/components/Connecting';
|
||||||
|
import About from '@/components/About';
|
||||||
|
import Settings from '@/components/Settings';
|
||||||
|
import Welcome from '@/components/Welcome';
|
||||||
|
import RoomLocked from '@/components/RoomLocked';
|
||||||
|
|
||||||
|
import ActivityList from './ActivityList';
|
||||||
|
|
||||||
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
|
const crypto = new Crypto();
|
||||||
|
|
||||||
|
Modal.setAppElement('#root');
|
||||||
|
|
||||||
|
class Home extends Component {
|
||||||
|
async componentWillMount() {
|
||||||
|
const user = await this.createUser();
|
||||||
|
|
||||||
|
const socket = connectSocket(this.props.socketId);
|
||||||
|
|
||||||
|
this.socket = socket;
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
this.props.toggleSocketConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('connect', () => {
|
||||||
|
this.initApp(user);
|
||||||
|
this.props.toggleSocketConnected(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('USER_ENTER', payload => {
|
||||||
|
this.props.receiveUnencryptedMessage('USER_ENTER', payload);
|
||||||
|
this.props.sendEncryptedMessage({
|
||||||
|
type: 'ADD_USER',
|
||||||
|
payload: {
|
||||||
|
username: this.props.username,
|
||||||
|
publicKey: this.props.publicKey,
|
||||||
|
isOwner: this.props.iAmOwner,
|
||||||
|
id: this.props.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (payload.users.length === 1) {
|
||||||
|
this.props.openModal('Welcome');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('USER_EXIT', payload => {
|
||||||
|
this.props.receiveUnencryptedMessage('USER_EXIT', payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('ENCRYPTED_MESSAGE', payload => {
|
||||||
|
this.props.receiveEncryptedMessage(payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('TOGGLE_LOCK_ROOM', payload => {
|
||||||
|
this.props.receiveUnencryptedMessage('TOGGLE_LOCK_ROOM', payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('ROOM_LOCKED', payload => {
|
||||||
|
this.props.openModal('Room Locked');
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', evt => {
|
||||||
|
socket.emit('USER_DISCONNECT');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
getModal() {
|
||||||
|
switch (this.props.modalComponent) {
|
||||||
|
case 'Connecting':
|
||||||
|
return {
|
||||||
|
component: <Connecting />,
|
||||||
|
title: 'Connecting...',
|
||||||
|
preventClose: true,
|
||||||
|
};
|
||||||
|
case 'About':
|
||||||
|
return {
|
||||||
|
component: <About roomId={this.props.roomId} />,
|
||||||
|
title: this.props.translations.aboutHeader,
|
||||||
|
};
|
||||||
|
case 'Settings':
|
||||||
|
return {
|
||||||
|
component: (
|
||||||
|
<Settings
|
||||||
|
roomId={this.props.roomId}
|
||||||
|
toggleSoundEnabled={this.props.toggleSoundEnabled}
|
||||||
|
togglePersistenceEnabled={this.props.togglePersistenceEnabled}
|
||||||
|
soundIsEnabled={this.props.soundIsEnabled}
|
||||||
|
persistenceIsEnabled={this.props.persistenceIsEnabled}
|
||||||
|
toggleNotificationEnabled={this.props.toggleNotificationEnabled}
|
||||||
|
toggleNotificationAllowed={this.props.toggleNotificationAllowed}
|
||||||
|
notificationIsEnabled={this.props.notificationIsEnabled}
|
||||||
|
notificationIsAllowed={this.props.notificationIsAllowed}
|
||||||
|
setLanguage={this.props.setLanguage}
|
||||||
|
language={this.props.language}
|
||||||
|
translations={this.props.translations}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
title: this.props.translations.settingsHeader,
|
||||||
|
};
|
||||||
|
case 'Welcome':
|
||||||
|
return {
|
||||||
|
component: (
|
||||||
|
<Welcome roomId={this.props.roomId} close={this.props.closeModal} translations={this.props.translations} />
|
||||||
|
),
|
||||||
|
title: this.props.translations.welcomeHeader,
|
||||||
|
};
|
||||||
|
case 'Room Locked':
|
||||||
|
return {
|
||||||
|
component: <RoomLocked modalContent={this.props.translations.lockedRoomHeader} />,
|
||||||
|
title: this.props.translations.lockedRoomHeader,
|
||||||
|
preventClose: true,
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
component: null,
|
||||||
|
title: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initApp(user) {
|
||||||
|
this.socket.emit('USER_ENTER', {
|
||||||
|
publicKey: user.publicKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
window.onfocus = () => {
|
||||||
|
this.props.toggleWindowFocus(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.onblur = () => {
|
||||||
|
this.props.toggleWindowFocus(false);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
createUser() {
|
||||||
|
return new Promise(async resolve => {
|
||||||
|
const username = this.props.username || nanoid();
|
||||||
|
|
||||||
|
const encryptDecryptKeys = await crypto.createEncryptDecryptKeys();
|
||||||
|
const exportedEncryptDecryptPrivateKey = await crypto.exportKey(encryptDecryptKeys.privateKey);
|
||||||
|
const exportedEncryptDecryptPublicKey = await crypto.exportKey(encryptDecryptKeys.publicKey);
|
||||||
|
|
||||||
|
this.props.createUser({
|
||||||
|
username,
|
||||||
|
publicKey: exportedEncryptDecryptPublicKey,
|
||||||
|
privateKey: exportedEncryptDecryptPrivateKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
publicKey: exportedEncryptDecryptPublicKey,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const modalOpts = this.getModal();
|
||||||
|
return (
|
||||||
|
<div className={classNames(styles.styles, 'h-100')}>
|
||||||
|
<div className="nav-container">
|
||||||
|
{!this.props.socketConnected && (
|
||||||
|
<div className="alert-banner">
|
||||||
|
<span className="icon">
|
||||||
|
<AlertCircle size="15" />
|
||||||
|
</span>{' '}
|
||||||
|
<span>Disconnected</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Nav
|
||||||
|
members={this.props.members}
|
||||||
|
roomId={this.props.roomId}
|
||||||
|
roomLocked={this.props.roomLocked}
|
||||||
|
toggleLockRoom={() => this.props.sendUnencryptedMessage('TOGGLE_LOCK_ROOM')}
|
||||||
|
openModal={this.props.openModal}
|
||||||
|
iAmOwner={this.props.iAmOwner}
|
||||||
|
userId={this.props.userId}
|
||||||
|
translations={this.props.translations}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ActivityList openModal={this.props.openModal} activities={this.props.activities} />
|
||||||
|
<Modal
|
||||||
|
isOpen={Boolean(this.props.modalComponent)}
|
||||||
|
contentLabel="Modal"
|
||||||
|
style={{ overlay: { zIndex: 10 } }}
|
||||||
|
className={{
|
||||||
|
base: 'react-modal-content',
|
||||||
|
afterOpen: 'react-modal-content_after-open',
|
||||||
|
beforeClose: 'react-modal-content_before-close',
|
||||||
|
}}
|
||||||
|
overlayClassName={{
|
||||||
|
base: 'react-modal-overlay',
|
||||||
|
afterOpen: 'react-modal-overlay_after-open',
|
||||||
|
beforeClose: 'react-modal-overlay_before-close',
|
||||||
|
}}
|
||||||
|
shouldCloseOnOverlayClick={!modalOpts.preventClose}
|
||||||
|
onRequestClose={this.props.closeModal}
|
||||||
|
>
|
||||||
|
<div className="react-modal-header">
|
||||||
|
{!modalOpts.preventClose && (
|
||||||
|
<button onClick={this.props.closeModal} className="btn btn-link btn-plain close-modal">
|
||||||
|
<X />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<h3 className="react-modal-title">{modalOpts.title}</h3>
|
||||||
|
</div>
|
||||||
|
<div className="react-modal-component">{modalOpts.component}</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Home.defaultProps = {
|
||||||
|
modalComponent: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
Home.propTypes = {
|
||||||
|
receiveEncryptedMessage: PropTypes.func.isRequired,
|
||||||
|
receiveUnencryptedMessage: PropTypes.func.isRequired,
|
||||||
|
createUser: PropTypes.func.isRequired,
|
||||||
|
activities: PropTypes.array.isRequired,
|
||||||
|
username: PropTypes.string.isRequired,
|
||||||
|
publicKey: PropTypes.object.isRequired,
|
||||||
|
members: PropTypes.array.isRequired,
|
||||||
|
socketId: PropTypes.string.isRequired,
|
||||||
|
roomId: PropTypes.string.isRequired,
|
||||||
|
roomLocked: PropTypes.bool.isRequired,
|
||||||
|
modalComponent: PropTypes.string,
|
||||||
|
openModal: PropTypes.func.isRequired,
|
||||||
|
closeModal: PropTypes.func.isRequired,
|
||||||
|
iAmOwner: PropTypes.bool.isRequired,
|
||||||
|
userId: PropTypes.string.isRequired,
|
||||||
|
toggleWindowFocus: PropTypes.func.isRequired,
|
||||||
|
faviconCount: PropTypes.number.isRequired,
|
||||||
|
soundIsEnabled: PropTypes.bool.isRequired,
|
||||||
|
persistenceIsEnabled: PropTypes.bool.isRequired,
|
||||||
|
toggleSoundEnabled: PropTypes.func.isRequired,
|
||||||
|
togglePersistenceEnabled: PropTypes.func.isRequired,
|
||||||
|
notificationIsEnabled: PropTypes.bool.isRequired,
|
||||||
|
notificationIsAllowed: PropTypes.bool,
|
||||||
|
toggleNotificationEnabled: PropTypes.func.isRequired,
|
||||||
|
toggleNotificationAllowed: PropTypes.func.isRequired,
|
||||||
|
toggleSocketConnected: PropTypes.func.isRequired,
|
||||||
|
socketConnected: PropTypes.bool.isRequired,
|
||||||
|
sendUnencryptedMessage: PropTypes.func.isRequired,
|
||||||
|
sendEncryptedMessage: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Home;
|
84
client/src/components/Home/Home.test.jsx
Normal file
84
client/src/components/Home/Home.test.jsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { test, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
import Home from './Home';
|
||||||
|
import configureStore from '@/store';
|
||||||
|
|
||||||
|
const store = configureStore();
|
||||||
|
|
||||||
|
vi.mock('react-modal'); // Cant load modal without root app element
|
||||||
|
|
||||||
|
vi.mock('@/RoomLink');
|
||||||
|
vi.mock('@/components/Nav');
|
||||||
|
|
||||||
|
vi.mock('@/utils/socket', () => {
|
||||||
|
// Avoid exception
|
||||||
|
return {
|
||||||
|
connect: vi.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
on: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}); //
|
||||||
|
|
||||||
|
vi.mock('../../utils/crypto', () => {
|
||||||
|
// Need window.crytpo.subtle
|
||||||
|
return {
|
||||||
|
default: vi.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
createEncryptDecryptKeys: () => {
|
||||||
|
return {
|
||||||
|
privateKey: 'private',
|
||||||
|
publicKey: 'public',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
exportKey: () => {
|
||||||
|
return 'exportedkey';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Home component is displaying', async () => {
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Home
|
||||||
|
translations={{}}
|
||||||
|
members={[]}
|
||||||
|
openModal={() => {}}
|
||||||
|
activities={[]}
|
||||||
|
match={{ params: { roomId: 'roomTest' } }}
|
||||||
|
createUser={() => {}}
|
||||||
|
toggleSocketConnected={() => {}}
|
||||||
|
receiveEncryptedMessage={() => {}}
|
||||||
|
receiveUnencryptedMessage={() => {}}
|
||||||
|
scrolledToBottom={true}
|
||||||
|
setScrolledToBottom={() => {}}
|
||||||
|
iAmOwner={true}
|
||||||
|
roomLocked={false}
|
||||||
|
userId={'userId'}
|
||||||
|
roomId={'testId'}
|
||||||
|
sendEncryptedMessage={() => {}}
|
||||||
|
sendUnencryptedMessage={() => {}}
|
||||||
|
socketConnected={false}
|
||||||
|
toggleSoundEnabled={() => {}}
|
||||||
|
soundIsEnabled={false}
|
||||||
|
toggleNotificationEnabled={() => {}}
|
||||||
|
toggleNotificationAllowed={() => {}}
|
||||||
|
notificationIsEnabled={false}
|
||||||
|
faviconCount={0}
|
||||||
|
toggleWindowFocus={() => {}}
|
||||||
|
closeModal={() => {}}
|
||||||
|
publicKey={{}}
|
||||||
|
username={'linus'}
|
||||||
|
/>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
127
client/src/components/Home/WithNewMessageNotification.jsx
Normal file
127
client/src/components/Home/WithNewMessageNotification.jsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import Tinycon from 'tinycon';
|
||||||
|
|
||||||
|
import { notify, beep } from '@/utils/notifications';
|
||||||
|
import { toggleNotificationAllowed, toggleNotificationEnabled } from '@/actions';
|
||||||
|
|
||||||
|
const mapStateToProps = state => {
|
||||||
|
return {
|
||||||
|
activities: state.activities.items,
|
||||||
|
unreadMessageCount: state.app.unreadMessageCount,
|
||||||
|
windowIsFocused: state.app.windowIsFocused,
|
||||||
|
soundIsEnabled: state.app.soundIsEnabled,
|
||||||
|
notificationIsEnabled: state.app.notificationIsEnabled,
|
||||||
|
notificationIsAllowed: state.app.notificationIsAllowed,
|
||||||
|
room: state.room,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
toggleNotificationAllowed,
|
||||||
|
toggleNotificationEnabled,
|
||||||
|
};
|
||||||
|
|
||||||
|
const WithNewMessageNotification = WrappedComponent => {
|
||||||
|
return connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps,
|
||||||
|
)(
|
||||||
|
class WithNotificationHOC extends Component {
|
||||||
|
state = { lastMessage: null, unreadMessageCount: 0 };
|
||||||
|
|
||||||
|
static getDerivedStateFromProps(nextProps, prevState) {
|
||||||
|
const {
|
||||||
|
room: { id: roomId },
|
||||||
|
activities,
|
||||||
|
notificationIsEnabled,
|
||||||
|
notificationIsAllowed,
|
||||||
|
soundIsEnabled,
|
||||||
|
unreadMessageCount,
|
||||||
|
windowIsFocused,
|
||||||
|
} = nextProps;
|
||||||
|
|
||||||
|
if (activities.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lastMessage = activities[activities.length - 1];
|
||||||
|
const { username, type, text, fileName, locked, newUsername, currentUsername, action } = lastMessage;
|
||||||
|
|
||||||
|
if (lastMessage !== prevState.lastMessage && !windowIsFocused) {
|
||||||
|
if (notificationIsAllowed && notificationIsEnabled) {
|
||||||
|
// Generate the proper notification according to the message type
|
||||||
|
switch (type) {
|
||||||
|
case 'USER_ENTER':
|
||||||
|
notify(`User ${username} joined`);
|
||||||
|
break;
|
||||||
|
case 'USER_EXIT':
|
||||||
|
notify(`User ${username} left`);
|
||||||
|
break;
|
||||||
|
case 'RECEIVE_FILE':
|
||||||
|
notify(`${username} sent file <${fileName}>`);
|
||||||
|
break;
|
||||||
|
case 'TEXT_MESSAGE':
|
||||||
|
notify(`${username} said:`, text);
|
||||||
|
break;
|
||||||
|
case 'USER_ACTION':
|
||||||
|
notify(`${username} ${action}`);
|
||||||
|
break;
|
||||||
|
case 'CHANGE_USERNAME':
|
||||||
|
notify(`${currentUsername} changed their name to ${newUsername}`);
|
||||||
|
break;
|
||||||
|
case 'TOGGLE_LOCK_ROOM':
|
||||||
|
if (locked) {
|
||||||
|
notify(`Room ${roomId} is now locked`);
|
||||||
|
} else {
|
||||||
|
notify(`Room ${roomId} is now unlocked`);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (soundIsEnabled) beep.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unreadMessageCount !== prevState.unreadMessageCount) {
|
||||||
|
Tinycon.setBubble(unreadMessageCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { lastMessage, unreadMessageCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
switch (Notification.permission) {
|
||||||
|
case 'granted':
|
||||||
|
this.props.toggleNotificationAllowed(true);
|
||||||
|
break;
|
||||||
|
case 'denied':
|
||||||
|
this.props.toggleNotificationAllowed(false);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
this.props.toggleNotificationAllowed(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
// Filter props
|
||||||
|
const {
|
||||||
|
room,
|
||||||
|
activities,
|
||||||
|
notificationIsEnabled,
|
||||||
|
motificationIsAllowed,
|
||||||
|
soundIsEnabled,
|
||||||
|
unreadMessageCount,
|
||||||
|
windowIsFocused,
|
||||||
|
toggleNotificationAllowed,
|
||||||
|
toggleNotificationnEnabled,
|
||||||
|
...rest
|
||||||
|
} = this.props;
|
||||||
|
return <WrappedComponent {...rest} />;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WithNewMessageNotification;
|
265
client/src/components/Home/__snapshots__/Activity.test.jsx.snap
Normal file
265
client/src/components/Home/__snapshots__/Activity.test.jsx.snap
Normal file
@ -0,0 +1,265 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`Activity component > should display 1`] = `<DocumentFragment />`;
|
||||||
|
|
||||||
|
exports[`Activity component > should display CHANGE_USERNAME 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="info"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(204, 241, 255);"
|
||||||
|
>
|
||||||
|
alice
|
||||||
|
</span>
|
||||||
|
changed their name to
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(235, 162, 242);"
|
||||||
|
>
|
||||||
|
alicette
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Activity component > should display NOTICE 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="info"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
Hello world!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Activity component > should display RECEIVE_FILE 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(204, 241, 255);"
|
||||||
|
>
|
||||||
|
alice
|
||||||
|
</span>
|
||||||
|
sent you a file.
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<a
|
||||||
|
download="alice.pdf"
|
||||||
|
href="url:[object Blob]"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Download alice.pdf
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Activity component > should display RECEIVE_FILE with image 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(204, 241, 255);"
|
||||||
|
>
|
||||||
|
alice
|
||||||
|
</span>
|
||||||
|
sent you a file.
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<a
|
||||||
|
download="alice.jpg"
|
||||||
|
href="url:[object Blob]"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
Download alice.jpg
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<img
|
||||||
|
alt="alice.jpg from alice"
|
||||||
|
class="image-transfer zoomable"
|
||||||
|
src="data:image/jpg;base64,dGV4dGZpbGU="
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Activity component > should display SEND_FILE 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="info"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
You sent
|
||||||
|
<a
|
||||||
|
download="alice.pdf"
|
||||||
|
href="url:[object Blob]"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
alice.pdf
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Activity component > should display TEXT_MESSAGE 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="chat-meta"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(204, 241, 255);"
|
||||||
|
>
|
||||||
|
alice
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="muted timestamp"
|
||||||
|
>
|
||||||
|
11:01 AM
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="chat"
|
||||||
|
>
|
||||||
|
Hi!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Activity component > should display TOGGLE_LOCK_ROOM 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="info"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(204, 241, 255);"
|
||||||
|
>
|
||||||
|
alice
|
||||||
|
</span>
|
||||||
|
locked the room
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Activity component > should display TOGGLE_LOCK_ROOM 2`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="info"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(204, 241, 255);"
|
||||||
|
>
|
||||||
|
alice
|
||||||
|
</span>
|
||||||
|
unlocked the room
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Activity component > should display USER_ACTION 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="info"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
*
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(204, 241, 255);"
|
||||||
|
>
|
||||||
|
alice
|
||||||
|
</span>
|
||||||
|
did right!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Activity component > should display USER_ENTER 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="info"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(204, 241, 255);"
|
||||||
|
>
|
||||||
|
alice
|
||||||
|
</span>
|
||||||
|
joined
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Activity component > should display USER_EXIT 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="info"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(204, 241, 255);"
|
||||||
|
>
|
||||||
|
alice
|
||||||
|
</span>
|
||||||
|
left
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
@ -0,0 +1,231 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`ActivityList component > should display 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
class="main-chat"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="message-stream h-100"
|
||||||
|
data-testid="main-div"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class="plain"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<p
|
||||||
|
class="_tos_0b54d3"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-link"
|
||||||
|
>
|
||||||
|
By using Darkwire, you are agreeing to our Acceptable Use Policy and Terms of Service
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="chat-container"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="chat-preflight-container"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
class="chat"
|
||||||
|
placeholder="Type here"
|
||||||
|
rows="1"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="input-controls"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_styles_374fdd icon file-transfer btn btn-link"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="fileInput"
|
||||||
|
name="fileUploader"
|
||||||
|
placeholder="Choose a file..."
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="fileInput"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="#fff"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points="13 2 13 9 20 9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`ActivityList component > should display with activities 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
class="main-chat"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="message-stream h-100"
|
||||||
|
data-testid="main-div"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class="plain"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<p
|
||||||
|
class="_tos_0b54d3"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-link"
|
||||||
|
>
|
||||||
|
By using Darkwire, you are agreeing to our Acceptable Use Policy and Terms of Service
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="activity-item TEXT_MESSAGE"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="chat-meta"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(204, 241, 255);"
|
||||||
|
>
|
||||||
|
alice
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="muted timestamp"
|
||||||
|
>
|
||||||
|
11:01 AM
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="chat"
|
||||||
|
>
|
||||||
|
Hi!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="activity-item USER_ENTER"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="info"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(204, 241, 255);"
|
||||||
|
>
|
||||||
|
alice
|
||||||
|
</span>
|
||||||
|
joined
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="activity-item CHANGE_USERNAME"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="info"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(204, 241, 255);"
|
||||||
|
>
|
||||||
|
alice
|
||||||
|
</span>
|
||||||
|
changed their name to
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(235, 162, 242);"
|
||||||
|
>
|
||||||
|
alicette
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="chat-container"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="chat-preflight-container"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
class="chat"
|
||||||
|
placeholder="Type here"
|
||||||
|
rows="1"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="input-controls"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_styles_374fdd icon file-transfer btn btn-link"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="fileInput"
|
||||||
|
name="fileUploader"
|
||||||
|
placeholder="Choose a file..."
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="fileInput"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="#fff"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points="13 2 13 9 20 9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
128
client/src/components/Home/__snapshots__/Home.test.jsx.snap
Normal file
128
client/src/components/Home/__snapshots__/Home.test.jsx.snap
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`Home component is displaying 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
class="_styles_0b54d3 h-100"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="nav-container"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="alert-banner"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="15"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="15"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12"
|
||||||
|
y1="8"
|
||||||
|
y2="12"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12.01"
|
||||||
|
y1="16"
|
||||||
|
y2="16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
Disconnected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="main-chat"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="message-stream h-100"
|
||||||
|
data-testid="main-div"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class="plain"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<p
|
||||||
|
class="_tos_0b54d3"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-link"
|
||||||
|
>
|
||||||
|
By using Darkwire, you are agreeing to our Acceptable Use Policy and Terms of Service
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="chat-container"
|
||||||
|
>
|
||||||
|
<form
|
||||||
|
class="chat-preflight-container"
|
||||||
|
>
|
||||||
|
<textarea
|
||||||
|
class="chat"
|
||||||
|
placeholder="Type here"
|
||||||
|
rows="1"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="input-controls"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="_styles_374fdd icon file-transfer btn btn-link"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
id="fileInput"
|
||||||
|
name="fileUploader"
|
||||||
|
placeholder="Choose a file..."
|
||||||
|
type="file"
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
for="fileInput"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="#fff"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M13 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9z"
|
||||||
|
/>
|
||||||
|
<polyline
|
||||||
|
points="13 2 13 9 20 9"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
299
client/src/components/Home/__snapshots__/index.test.jsx.snap
Normal file
299
client/src/components/Home/__snapshots__/index.test.jsx.snap
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`Connected Home component > should display 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
class="_styles_0b54d3 h-100"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="nav-container"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="alert-banner"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="icon"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="15"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="15"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12"
|
||||||
|
y1="8"
|
||||||
|
y2="12"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12.01"
|
||||||
|
y1="16"
|
||||||
|
y2="16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
Disconnected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<nav
|
||||||
|
class="navbar navbar-expand-md navbar-dark"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="meta"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="Darkwire"
|
||||||
|
class="logo"
|
||||||
|
src="/src/img/logo.png"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-plain btn-link clipboard-trigger room-id ellipsis"
|
||||||
|
data-clipboard-text="http://localhost:3000/"
|
||||||
|
data-placement="bottom"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
title="Copied"
|
||||||
|
>
|
||||||
|
/
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class="lock-room-container"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="lock-room btn btn-link btn-plain"
|
||||||
|
data-placement="bottom"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
title="You must be the owner to lock or unlock the room"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="muted"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
height="11"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
width="18"
|
||||||
|
x="3"
|
||||||
|
y="11"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7 11V7a5 5 0 0 1 9.9-1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="dropdown members-dropdown"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="dropdown__trigger "
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-link btn-plain members-action"
|
||||||
|
title="Users"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="users-icon"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="9"
|
||||||
|
cy="7"
|
||||||
|
r="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M23 21v-2a4 4 0 0 0-3-3.87"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16 3.13a4 4 0 0 1 0 7.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
0
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div
|
||||||
|
class="dropdown__content "
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class="plain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-controls="navbarSupportedContent"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
class="navbar-toggler"
|
||||||
|
data-target="#navbarSupportedContent"
|
||||||
|
data-toggle="collapse"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="navbar-toggler-icon"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="collapse navbar-collapse"
|
||||||
|
id="navbarSupportedContent"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class="navbar-nav ml-auto"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
class="nav-item"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-plain nav-link"
|
||||||
|
target="blank"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12"
|
||||||
|
y1="8"
|
||||||
|
y2="16"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="8"
|
||||||
|
x2="16"
|
||||||
|
y1="12"
|
||||||
|
y2="12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
New Room
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="nav-item"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-plain nav-link"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="3"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
Settings
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="nav-item"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-plain nav-link"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12"
|
||||||
|
y1="16"
|
||||||
|
y2="12"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12.01"
|
||||||
|
y1="8"
|
||||||
|
y2="8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
About
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
73
client/src/components/Home/index.jsx
Normal file
73
client/src/components/Home/index.jsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { useLoaderData } from 'react-router-dom';
|
||||||
|
|
||||||
|
import {
|
||||||
|
receiveEncryptedMessage,
|
||||||
|
createUser,
|
||||||
|
openModal,
|
||||||
|
closeModal,
|
||||||
|
toggleWindowFocus,
|
||||||
|
toggleSoundEnabled,
|
||||||
|
togglePersistenceEnabled,
|
||||||
|
toggleNotificationEnabled,
|
||||||
|
toggleNotificationAllowed,
|
||||||
|
toggleSocketConnected,
|
||||||
|
receiveUnencryptedMessage,
|
||||||
|
sendUnencryptedMessage,
|
||||||
|
sendEncryptedMessage,
|
||||||
|
setLanguage,
|
||||||
|
} from '@/actions';
|
||||||
|
|
||||||
|
import Home from './Home';
|
||||||
|
import WithNewMessageNotification from './WithNewMessageNotification';
|
||||||
|
|
||||||
|
const mapStateToProps = state => {
|
||||||
|
const me = state.room.members.find(m => m.id === state.user.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activities: state.activities.items,
|
||||||
|
userId: state.user.id,
|
||||||
|
username: state.user.username,
|
||||||
|
publicKey: state.user.publicKey,
|
||||||
|
privateKey: state.user.privateKey,
|
||||||
|
members: state.room.members.filter(m => m.username && m.publicKey),
|
||||||
|
roomId: state.room.id,
|
||||||
|
roomLocked: state.room.isLocked,
|
||||||
|
modalComponent: state.app.modalComponent,
|
||||||
|
iAmOwner: Boolean(me && me.isOwner),
|
||||||
|
faviconCount: state.app.unreadMessageCount,
|
||||||
|
soundIsEnabled: state.app.soundIsEnabled,
|
||||||
|
persistenceIsEnabled: state.app.persistenceIsEnabled,
|
||||||
|
notificationIsEnabled: state.app.notificationIsEnabled,
|
||||||
|
notificationIsAllowed: state.app.notificationIsAllowed,
|
||||||
|
socketConnected: state.app.socketConnected,
|
||||||
|
language: state.app.language,
|
||||||
|
translations: state.app.translations,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
receiveEncryptedMessage,
|
||||||
|
createUser,
|
||||||
|
openModal,
|
||||||
|
closeModal,
|
||||||
|
toggleWindowFocus,
|
||||||
|
toggleSoundEnabled,
|
||||||
|
togglePersistenceEnabled,
|
||||||
|
toggleNotificationEnabled,
|
||||||
|
toggleNotificationAllowed,
|
||||||
|
toggleSocketConnected,
|
||||||
|
receiveUnencryptedMessage,
|
||||||
|
sendUnencryptedMessage,
|
||||||
|
sendEncryptedMessage,
|
||||||
|
setLanguage,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ConnectedHome = WithNewMessageNotification(connect(mapStateToProps, mapDispatchToProps)(Home));
|
||||||
|
|
||||||
|
const HomeWithParams = ({ ...props }) => {
|
||||||
|
const socketId = useLoaderData();
|
||||||
|
return <ConnectedHome socketId={socketId} {...props} />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HomeWithParams;
|
211
client/src/components/Home/index.test.jsx
Normal file
211
client/src/components/Home/index.test.jsx
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import Tinycon from 'tinycon';
|
||||||
|
import Modal from 'react-modal';
|
||||||
|
import { expect, vi, describe, beforeEach, afterEach, it } from 'vitest';
|
||||||
|
|
||||||
|
import configureStore from '@/store';
|
||||||
|
import { toggleWindowFocus, toggleNotificationEnabled, toggleSoundEnabled } from '@/actions/app';
|
||||||
|
import { receiveEncryptedMessage } from '@/actions/encrypted_messages';
|
||||||
|
import { notify, beep } from '@/utils/notifications';
|
||||||
|
|
||||||
|
import { ConnectedHome } from './';
|
||||||
|
|
||||||
|
const store = configureStore();
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
vi.mock('react-modal'); // Cant load modal without root app element
|
||||||
|
|
||||||
|
vi.mock('nanoid', () => {
|
||||||
|
// Avoid exception
|
||||||
|
return {
|
||||||
|
nanoid: vi.fn().mockImplementation(() => {
|
||||||
|
return 'shortidgenerated';
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('tinycon', () => {
|
||||||
|
return {
|
||||||
|
default: { setBubble: vi.fn() },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// We don't test activity list here
|
||||||
|
vi.mock('./ActivityList', () => {
|
||||||
|
return { default: vi.fn().mockReturnValue(null) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/utils/socket', () => {
|
||||||
|
// Avoid exception
|
||||||
|
return {
|
||||||
|
connect: vi.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
on: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
getSocket: vi.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
on: vi.fn(),
|
||||||
|
emit: vi.fn(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/utils/crypto', () => {
|
||||||
|
// Need window.crytpo.subtle
|
||||||
|
return {
|
||||||
|
default: vi.fn().mockImplementation(() => {
|
||||||
|
return {
|
||||||
|
createEncryptDecryptKeys: () => {
|
||||||
|
return {
|
||||||
|
privateKey: { n: 'private' },
|
||||||
|
publicKey: { n: 'public' },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
exportKey: () => {
|
||||||
|
return { n: 'exportedKey' };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/utils/message', () => {
|
||||||
|
return {
|
||||||
|
process: vi.fn(async (payload, state) => ({
|
||||||
|
...payload,
|
||||||
|
payload: { payload: 'text', username: 'sender', text: 'new message' },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('@/utils/notifications', () => {
|
||||||
|
return {
|
||||||
|
notify: vi.fn(),
|
||||||
|
beep: { play: vi.fn() },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Connected Home component', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
global.Notification = {
|
||||||
|
permission: 'granted',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.Notification;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display', () => {
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ConnectedHome match={{ params: { roomId: 'roomTest' } }} userId="testUserId" />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set notification', () => {
|
||||||
|
render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ConnectedHome match={{ params: { roomId: 'roomTest' } }} userId="testUserId" />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(store.getState().app.notificationIsAllowed).toBe(true);
|
||||||
|
expect(store.getState().app.notificationIsEnabled).toBe(true);
|
||||||
|
|
||||||
|
global.Notification = {
|
||||||
|
permission: 'denied',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ConnectedHome match={{ params: { roomId: 'roomTest' } }} userId="testUserId" />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(store.getState().app.notificationIsAllowed).toBe(false);
|
||||||
|
|
||||||
|
global.Notification = {
|
||||||
|
permission: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ConnectedHome match={{ params: { roomId: 'roomTest' } }} userId="testUserId" />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(store.getState().app.notificationIsAllowed).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should send notifications', async () => {
|
||||||
|
Modal.prototype.getSnapshotBeforeUpdate = vi.fn().mockReturnValue(null);
|
||||||
|
const { rerender } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ConnectedHome match={{ params: { roomId: 'roomTest' } }} userId="testUserId" roomId={'testId'} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Test with window focused
|
||||||
|
await receiveEncryptedMessage({
|
||||||
|
type: 'TEXT_MESSAGE',
|
||||||
|
payload: {},
|
||||||
|
})(store.dispatch, store.getState);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ConnectedHome match={{ params: { roomId: 'roomTest' } }} userId="testUserId" roomId={'testId'} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(store.getState().app.unreadMessageCount).toBe(0);
|
||||||
|
expect(notify).not.toHaveBeenCalled();
|
||||||
|
expect(beep.play).not.toHaveBeenCalled();
|
||||||
|
expect(Tinycon.setBubble).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// Test with window unfocused
|
||||||
|
await toggleWindowFocus(false)(store.dispatch);
|
||||||
|
await receiveEncryptedMessage({
|
||||||
|
type: 'TEXT_MESSAGE',
|
||||||
|
payload: {},
|
||||||
|
})(store.dispatch, store.getState);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ConnectedHome match={{ params: { roomId: 'roomTest' } }} userId="testUserId" roomId={'testId'} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
expect(store.getState().app.unreadMessageCount).toBe(1);
|
||||||
|
expect(notify).toHaveBeenLastCalledWith('sender said:', 'new message');
|
||||||
|
expect(beep.play).toHaveBeenLastCalledWith();
|
||||||
|
expect(Tinycon.setBubble).toHaveBeenLastCalledWith(1);
|
||||||
|
|
||||||
|
// Test with sound and notification disabled
|
||||||
|
await toggleNotificationEnabled(false)(store.dispatch);
|
||||||
|
await toggleSoundEnabled(false)(store.dispatch);
|
||||||
|
await receiveEncryptedMessage({
|
||||||
|
type: 'TEXT_MESSAGE',
|
||||||
|
payload: {},
|
||||||
|
})(store.dispatch, store.getState);
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Provider store={store}>
|
||||||
|
<ConnectedHome match={{ params: { roomId: 'roomTest' } }} userId="testUserId" roomId={'testId'} />
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(store.getState().app.unreadMessageCount).toBe(2);
|
||||||
|
expect(notify).toHaveBeenCalledTimes(1);
|
||||||
|
expect(beep.play).toHaveBeenCalledTimes(1);
|
||||||
|
expect(Tinycon.setBubble).toHaveBeenLastCalledWith(2);
|
||||||
|
});
|
||||||
|
});
|
8
client/src/components/Home/styles.module.scss
Normal file
8
client/src/components/Home/styles.module.scss
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.styles {
|
||||||
|
.tos {
|
||||||
|
text-align: center;
|
||||||
|
a {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
10
client/src/components/Message/Message.test.jsx
Normal file
10
client/src/components/Message/Message.test.jsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import Message from '.';
|
||||||
|
import { test, expect } from 'vitest';
|
||||||
|
|
||||||
|
test('Message component is displaying', async () => {
|
||||||
|
const { asFragment } = render(<Message sender={'linus'} timestamp={1588794269074} message={'we come in peace'} />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
@ -0,0 +1,28 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`Message component is displaying 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="chat-meta"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(131, 239, 135);"
|
||||||
|
>
|
||||||
|
linus
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="muted timestamp"
|
||||||
|
>
|
||||||
|
7:44 PM
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="chat"
|
||||||
|
>
|
||||||
|
we come in peace
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
39
client/src/components/Message/index.jsx
Normal file
39
client/src/components/Message/index.jsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import moment from 'moment';
|
||||||
|
import Linkify from 'react-linkify';
|
||||||
|
|
||||||
|
import Username from '@/components/Username';
|
||||||
|
|
||||||
|
class Message extends Component {
|
||||||
|
render() {
|
||||||
|
const msg = decodeURI(this.props.message);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="chat-meta">
|
||||||
|
<Username username={this.props.sender} />
|
||||||
|
<span className="muted timestamp">{moment(this.props.timestamp).format('LT')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="chat">
|
||||||
|
<Linkify
|
||||||
|
properties={{
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{msg}
|
||||||
|
</Linkify>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Message.propTypes = {
|
||||||
|
sender: PropTypes.string.isRequired,
|
||||||
|
timestamp: PropTypes.number.isRequired,
|
||||||
|
message: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Message;
|
306
client/src/components/Nav/Nav.test.jsx
Normal file
306
client/src/components/Nav/Nav.test.jsx
Normal file
@ -0,0 +1,306 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import mock$ from 'jquery';
|
||||||
|
import { test, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
import Nav from '.';
|
||||||
|
|
||||||
|
const mockTooltip = vi.fn().mockImplementation(param => {
|
||||||
|
// console.log('tooltip', param);
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockCollapse = vi.fn().mockImplementation(param => {
|
||||||
|
// console.log('collapse', param);
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('jquery', () => {
|
||||||
|
return {
|
||||||
|
default: vi.fn().mockImplementation(param => {
|
||||||
|
if (typeof param === 'function') {
|
||||||
|
param();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tooltip: mockTooltip,
|
||||||
|
collapse: mockCollapse,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('nanoid', () => {
|
||||||
|
return {
|
||||||
|
nanoid: () => {
|
||||||
|
return 'fakeid';
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const mockTranslations = {
|
||||||
|
newRoomButton: 'new room',
|
||||||
|
settingsButton: 'settings',
|
||||||
|
aboutButton: 'about',
|
||||||
|
};
|
||||||
|
|
||||||
|
test('Nav component is displaying', async () => {
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Nav
|
||||||
|
members={[]}
|
||||||
|
roomId={'testRoom'}
|
||||||
|
userId={'userId__'}
|
||||||
|
roomLocked={false}
|
||||||
|
toggleLockRoom={() => {}}
|
||||||
|
openModal={() => {}}
|
||||||
|
iAmOwner={true}
|
||||||
|
translations={{}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
|
||||||
|
expect(mock$).toHaveBeenCalledWith('.room-id');
|
||||||
|
expect(mock$).toHaveBeenLastCalledWith('.lock-room');
|
||||||
|
expect(mockTooltip).toHaveBeenLastCalledWith({ trigger: 'manual' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Nav component is displaying with another configuration and can rerender', async () => {
|
||||||
|
const { asFragment, rerender } = render(
|
||||||
|
<Nav
|
||||||
|
members={[
|
||||||
|
{ id: 'id1', username: 'alan', isOwner: true },
|
||||||
|
{ id: 'id2', username: 'dan', isOwner: false },
|
||||||
|
]}
|
||||||
|
roomId={'testRoom_2'}
|
||||||
|
userId={'userId_2'}
|
||||||
|
roomLocked={true}
|
||||||
|
toggleLockRoom={() => {}}
|
||||||
|
openModal={() => {}}
|
||||||
|
iAmOwner={false}
|
||||||
|
translations={{}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Nav
|
||||||
|
members={[
|
||||||
|
{ id: 'id1', username: 'alan', isOwner: true },
|
||||||
|
{ id: 'id2', username: 'dan', isOwner: false },
|
||||||
|
]}
|
||||||
|
roomId={'testRoom_3'}
|
||||||
|
userId={'userId_3'}
|
||||||
|
roomLocked={true}
|
||||||
|
toggleLockRoom={() => {}}
|
||||||
|
openModal={() => {}}
|
||||||
|
iAmOwner={false}
|
||||||
|
translations={{}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(mock$).toHaveBeenCalledWith('.me-icon-wrap');
|
||||||
|
expect(mock$).toHaveBeenLastCalledWith('.owner-icon-wrap');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Can copy room url', async () => {
|
||||||
|
document.execCommand = vi.fn(() => true);
|
||||||
|
|
||||||
|
const toggleLockRoom = vi.fn();
|
||||||
|
|
||||||
|
const { getByText } = render(
|
||||||
|
<Nav
|
||||||
|
members={[
|
||||||
|
{ id: 'id1', username: 'alan', isOwner: true },
|
||||||
|
{ id: 'id2', username: 'dan', isOwner: false },
|
||||||
|
]}
|
||||||
|
roomId={'testRoom'}
|
||||||
|
userId={'userId'}
|
||||||
|
roomLocked={true}
|
||||||
|
toggleLockRoom={toggleLockRoom}
|
||||||
|
openModal={() => {}}
|
||||||
|
iAmOwner={false}
|
||||||
|
translations={{}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(getByText(`/testRoom`));
|
||||||
|
|
||||||
|
expect(document.execCommand).toHaveBeenLastCalledWith('copy');
|
||||||
|
expect(mock$).toHaveBeenCalledTimes(15);
|
||||||
|
expect(mockTooltip).toHaveBeenLastCalledWith('show');
|
||||||
|
|
||||||
|
// Wait tooltip closing
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(mock$).toHaveBeenCalledTimes(18);
|
||||||
|
expect(mockTooltip).toHaveBeenLastCalledWith('hide');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Can lock/unlock room is room owner only', async () => {
|
||||||
|
const toggleLockRoom = vi.fn();
|
||||||
|
|
||||||
|
const { rerender, getByTitle } = render(
|
||||||
|
<Nav
|
||||||
|
members={[
|
||||||
|
{ id: 'id1', username: 'alan', isOwner: true },
|
||||||
|
{ id: 'id2', username: 'dan', isOwner: false },
|
||||||
|
]}
|
||||||
|
roomId={'testRoom'}
|
||||||
|
userId={'userId'}
|
||||||
|
roomLocked={true}
|
||||||
|
toggleLockRoom={toggleLockRoom}
|
||||||
|
openModal={() => {}}
|
||||||
|
iAmOwner={true}
|
||||||
|
translations={{}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleLockRoomButton = getByTitle('You must be the owner to lock or unlock the room');
|
||||||
|
|
||||||
|
fireEvent.click(toggleLockRoomButton);
|
||||||
|
|
||||||
|
expect(toggleLockRoom).toHaveBeenCalledWith();
|
||||||
|
|
||||||
|
fireEvent.click(toggleLockRoomButton);
|
||||||
|
|
||||||
|
expect(toggleLockRoom).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
// We are not the room owner anymore
|
||||||
|
rerender(
|
||||||
|
<Nav
|
||||||
|
members={[
|
||||||
|
{ id: 'id1', username: 'alan', isOwner: true },
|
||||||
|
{ id: 'id2', username: 'dan', isOwner: false },
|
||||||
|
]}
|
||||||
|
roomId={'testRoom'}
|
||||||
|
userId={'userId'}
|
||||||
|
roomLocked={true}
|
||||||
|
toggleLockRoom={toggleLockRoom}
|
||||||
|
openModal={() => {}}
|
||||||
|
iAmOwner={false}
|
||||||
|
translations={{}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(toggleLockRoomButton);
|
||||||
|
|
||||||
|
expect(toggleLockRoom).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mock$).toHaveBeenLastCalledWith('.lock-room');
|
||||||
|
expect(mockTooltip).toHaveBeenLastCalledWith('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Can show user list', async () => {
|
||||||
|
// Test with one user owner and me
|
||||||
|
const { getByTitle, getByText, queryByTitle, rerender } = render(
|
||||||
|
<Nav
|
||||||
|
members={[{ id: 'id1', username: 'alan', isOwner: true }]}
|
||||||
|
roomId={'testRoom'}
|
||||||
|
userId={'id1'}
|
||||||
|
roomLocked={true}
|
||||||
|
toggleLockRoom={() => {}}
|
||||||
|
openModal={() => {}}
|
||||||
|
iAmOwner={true}
|
||||||
|
translations={{}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(getByTitle('Users'));
|
||||||
|
|
||||||
|
await waitFor(() => expect(getByText('alan')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(getByTitle('Owner')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(getByTitle('Me')).toBeInTheDocument());
|
||||||
|
|
||||||
|
// Test with two user not owner, not me
|
||||||
|
rerender(
|
||||||
|
<Nav
|
||||||
|
members={[
|
||||||
|
{ id: 'id1', username: 'alan', isOwner: false },
|
||||||
|
{ id: 'id2', username: 'dan', isOwner: false },
|
||||||
|
]}
|
||||||
|
roomId={'testRoom'}
|
||||||
|
userId={'otherId'}
|
||||||
|
roomLocked={true}
|
||||||
|
toggleLockRoom={() => {}}
|
||||||
|
openModal={() => {}}
|
||||||
|
iAmOwner={true}
|
||||||
|
translations={{}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(getByText('alan')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(getByText('dan')).toBeInTheDocument());
|
||||||
|
expect(queryByTitle('Owner')).not.toBeInTheDocument();
|
||||||
|
expect(queryByTitle('Me')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Can open settings', async () => {
|
||||||
|
const openModal = vi.fn();
|
||||||
|
|
||||||
|
// Test with one user owner and me
|
||||||
|
const { getByText } = render(
|
||||||
|
<Nav
|
||||||
|
members={[]}
|
||||||
|
roomId={'testRoom'}
|
||||||
|
userId={'id1'}
|
||||||
|
roomLocked={true}
|
||||||
|
toggleLockRoom={() => {}}
|
||||||
|
openModal={openModal}
|
||||||
|
iAmOwner={true}
|
||||||
|
translations={mockTranslations}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(getByText(mockTranslations.settingsButton));
|
||||||
|
|
||||||
|
expect(mock$).toHaveBeenLastCalledWith('.navbar-collapse');
|
||||||
|
expect(mockCollapse).toHaveBeenLastCalledWith('hide');
|
||||||
|
expect(openModal).toHaveBeenLastCalledWith('Settings');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Can open About', async () => {
|
||||||
|
const openModal = vi.fn();
|
||||||
|
|
||||||
|
// Test with one user owner and me
|
||||||
|
const { getByText } = render(
|
||||||
|
<Nav
|
||||||
|
members={[]}
|
||||||
|
roomId={'testRoom'}
|
||||||
|
userId={'id1'}
|
||||||
|
roomLocked={true}
|
||||||
|
toggleLockRoom={() => {}}
|
||||||
|
openModal={openModal}
|
||||||
|
iAmOwner={true}
|
||||||
|
translations={mockTranslations}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(getByText(mockTranslations.aboutButton));
|
||||||
|
|
||||||
|
expect(mock$).toHaveBeenLastCalledWith('.navbar-collapse');
|
||||||
|
expect(mockCollapse).toHaveBeenLastCalledWith('hide');
|
||||||
|
expect(openModal).toHaveBeenLastCalledWith('About');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Can open About', async () => {
|
||||||
|
window.open = vi.fn();
|
||||||
|
|
||||||
|
// Test with one user owner and me
|
||||||
|
const { getByText } = render(
|
||||||
|
<Nav
|
||||||
|
members={[]}
|
||||||
|
roomId={'testRoom'}
|
||||||
|
userId={'id1'}
|
||||||
|
roomLocked={true}
|
||||||
|
toggleLockRoom={() => {}}
|
||||||
|
openModal={() => {}}
|
||||||
|
iAmOwner={true}
|
||||||
|
translations={mockTranslations}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(getByText(mockTranslations.newRoomButton));
|
||||||
|
|
||||||
|
expect(mock$).toHaveBeenLastCalledWith('.navbar-collapse');
|
||||||
|
expect(mockCollapse).toHaveBeenLastCalledWith('hide');
|
||||||
|
expect(window.open).toHaveBeenLastCalledWith('/fakeid');
|
||||||
|
});
|
531
client/src/components/Nav/__snapshots__/Nav.test.jsx.snap
Normal file
531
client/src/components/Nav/__snapshots__/Nav.test.jsx.snap
Normal file
@ -0,0 +1,531 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`Nav component is displaying 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<nav
|
||||||
|
class="navbar navbar-expand-md navbar-dark"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="meta"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="Darkwire"
|
||||||
|
class="logo"
|
||||||
|
src="/src/img/logo.png"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-plain btn-link clipboard-trigger room-id ellipsis"
|
||||||
|
data-clipboard-text="http://localhost:3000/testRoom"
|
||||||
|
data-placement="bottom"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
>
|
||||||
|
/testRoom
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class="lock-room-container"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="lock-room btn btn-link btn-plain"
|
||||||
|
data-placement="bottom"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
title="You must be the owner to lock or unlock the room"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="muted"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
height="11"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
width="18"
|
||||||
|
x="3"
|
||||||
|
y="11"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7 11V7a5 5 0 0 1 9.9-1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="dropdown members-dropdown"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="dropdown__trigger "
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-link btn-plain members-action"
|
||||||
|
title="Users"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="users-icon"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="9"
|
||||||
|
cy="7"
|
||||||
|
r="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M23 21v-2a4 4 0 0 0-3-3.87"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16 3.13a4 4 0 0 1 0 7.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
0
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div
|
||||||
|
class="dropdown__content "
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class="plain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-controls="navbarSupportedContent"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
class="navbar-toggler"
|
||||||
|
data-target="#navbarSupportedContent"
|
||||||
|
data-toggle="collapse"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="navbar-toggler-icon"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="collapse navbar-collapse"
|
||||||
|
id="navbarSupportedContent"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class="navbar-nav ml-auto"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
class="nav-item"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-plain nav-link"
|
||||||
|
target="blank"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12"
|
||||||
|
y1="8"
|
||||||
|
y2="16"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="8"
|
||||||
|
x2="16"
|
||||||
|
y1="12"
|
||||||
|
y2="12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="nav-item"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-plain nav-link"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="3"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="nav-item"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-plain nav-link"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12"
|
||||||
|
y1="16"
|
||||||
|
y2="12"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12.01"
|
||||||
|
y1="8"
|
||||||
|
y2="8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Nav component is displaying with another configuration and can rerender 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<nav
|
||||||
|
class="navbar navbar-expand-md navbar-dark"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="meta"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
alt="Darkwire"
|
||||||
|
class="logo"
|
||||||
|
src="/src/img/logo.png"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="btn btn-plain btn-link clipboard-trigger room-id ellipsis"
|
||||||
|
data-clipboard-text="http://localhost:3000/testRoom_2"
|
||||||
|
data-placement="bottom"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
>
|
||||||
|
/testRoom_2
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
class="lock-room-container"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="lock-room btn btn-link btn-plain"
|
||||||
|
data-placement="bottom"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
title="You must be the owner to lock or unlock the room"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
height="11"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
width="18"
|
||||||
|
x="3"
|
||||||
|
y="11"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M7 11V7a5 5 0 0 1 10 0v4"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
class="dropdown members-dropdown"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="dropdown__trigger "
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-link btn-plain members-action"
|
||||||
|
title="Users"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="users-icon"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="9"
|
||||||
|
cy="7"
|
||||||
|
r="4"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M23 21v-2a4 4 0 0 0-3-3.87"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M16 3.13a4 4 0 0 1 0 7.75"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<span>
|
||||||
|
2
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<div
|
||||||
|
class="dropdown__content "
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class="plain"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(192, 202, 249);"
|
||||||
|
>
|
||||||
|
alan
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="icon-container"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="owner-icon-wrap"
|
||||||
|
data-placement="bottom"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
title="Owner"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
class="owner-icon"
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<polygon
|
||||||
|
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(156, 252, 223);"
|
||||||
|
>
|
||||||
|
dan
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="icon-container"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
aria-controls="navbarSupportedContent"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
class="navbar-toggler"
|
||||||
|
data-target="#navbarSupportedContent"
|
||||||
|
data-toggle="collapse"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="navbar-toggler-icon"
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
class="collapse navbar-collapse"
|
||||||
|
id="navbarSupportedContent"
|
||||||
|
>
|
||||||
|
<ul
|
||||||
|
class="navbar-nav ml-auto"
|
||||||
|
>
|
||||||
|
<li
|
||||||
|
class="nav-item"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-plain nav-link"
|
||||||
|
target="blank"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12"
|
||||||
|
y1="8"
|
||||||
|
y2="16"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="8"
|
||||||
|
x2="16"
|
||||||
|
y1="12"
|
||||||
|
y2="12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="nav-item"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-plain nav-link"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="3"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li
|
||||||
|
class="nav-item"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-plain nav-link"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
cx="12"
|
||||||
|
cy="12"
|
||||||
|
r="10"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12"
|
||||||
|
y1="16"
|
||||||
|
y2="12"
|
||||||
|
/>
|
||||||
|
<line
|
||||||
|
x1="12"
|
||||||
|
x2="12.01"
|
||||||
|
y1="8"
|
||||||
|
y2="8"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<span />
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
170
client/src/components/Nav/index.jsx
Normal file
170
client/src/components/Nav/index.jsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { nanoid } from 'nanoid';
|
||||||
|
import { Info, Settings, PlusCircle, User, Users, Lock, Unlock, Star } from 'react-feather';
|
||||||
|
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown';
|
||||||
|
import Clipboard from 'clipboard';
|
||||||
|
import $ from 'jquery';
|
||||||
|
|
||||||
|
import logoImg from '@/img/logo.png';
|
||||||
|
import Username from '@/components/Username';
|
||||||
|
|
||||||
|
class Nav extends Component {
|
||||||
|
componentDidMount() {
|
||||||
|
const clip = new Clipboard('.clipboard-trigger');
|
||||||
|
|
||||||
|
clip.on('success', () => {
|
||||||
|
$('.room-id').tooltip('show');
|
||||||
|
setTimeout(() => {
|
||||||
|
$('.room-id').tooltip('hide');
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(() => {
|
||||||
|
$('.room-id').tooltip({
|
||||||
|
trigger: 'manual',
|
||||||
|
});
|
||||||
|
$('.lock-room').tooltip({
|
||||||
|
trigger: 'manual',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate() {
|
||||||
|
$(() => {
|
||||||
|
$('.me-icon-wrap').tooltip();
|
||||||
|
$('.owner-icon-wrap').tooltip();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
newRoom() {
|
||||||
|
$('.navbar-collapse').collapse('hide');
|
||||||
|
window.open(`/${nanoid()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSettingsClick() {
|
||||||
|
$('.navbar-collapse').collapse('hide');
|
||||||
|
this.props.openModal('Settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAboutClick() {
|
||||||
|
$('.navbar-collapse').collapse('hide');
|
||||||
|
this.props.openModal('About');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleToggleLock() {
|
||||||
|
if (!this.props.iAmOwner) {
|
||||||
|
$('.lock-room').tooltip('show');
|
||||||
|
setTimeout(() => $('.lock-room').tooltip('hide'), 3000);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.props.toggleLockRoom();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<nav className="navbar navbar-expand-md navbar-dark">
|
||||||
|
<div className="meta">
|
||||||
|
<img src={logoImg} alt="Darkwire" className="logo" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
data-toggle="tooltip"
|
||||||
|
data-placement="bottom"
|
||||||
|
title={this.props.translations.copyButtonTooltip}
|
||||||
|
data-clipboard-text={`${window.location.origin}/${this.props.roomId}`}
|
||||||
|
className="btn btn-plain btn-link clipboard-trigger room-id ellipsis"
|
||||||
|
>
|
||||||
|
{`/${this.props.roomId}`}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="lock-room-container">
|
||||||
|
<button
|
||||||
|
onClick={this.handleToggleLock.bind(this)}
|
||||||
|
className="lock-room btn btn-link btn-plain"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
data-placement="bottom"
|
||||||
|
title="You must be the owner to lock or unlock the room"
|
||||||
|
>
|
||||||
|
{this.props.roomLocked && <Lock />}
|
||||||
|
{!this.props.roomLocked && <Unlock className="muted" />}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<Dropdown className="members-dropdown">
|
||||||
|
<DropdownTrigger>
|
||||||
|
<button className="btn btn-link btn-plain members-action" title="Users">
|
||||||
|
<Users className="users-icon" />
|
||||||
|
</button>
|
||||||
|
<span>{this.props.members.length}</span>
|
||||||
|
</DropdownTrigger>
|
||||||
|
<DropdownContent>
|
||||||
|
<ul className="plain">
|
||||||
|
{this.props.members.map((member, index) => (
|
||||||
|
<li key={`user-${index}`}>
|
||||||
|
<Username username={member.username} />
|
||||||
|
<span className="icon-container">
|
||||||
|
{member.id === this.props.userId && (
|
||||||
|
<span data-toggle="tooltip" data-placement="bottom" title="Me" className="me-icon-wrap">
|
||||||
|
<User className="me-icon" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{member.isOwner && (
|
||||||
|
<span data-toggle="tooltip" data-placement="bottom" title="Owner" className="owner-icon-wrap">
|
||||||
|
<Star className="owner-icon" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</DropdownContent>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="navbar-toggler"
|
||||||
|
type="button"
|
||||||
|
data-toggle="collapse"
|
||||||
|
data-target="#navbarSupportedContent"
|
||||||
|
aria-controls="navbarSupportedContent"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-label="Toggle navigation"
|
||||||
|
>
|
||||||
|
<span className="navbar-toggler-icon" />
|
||||||
|
</button>
|
||||||
|
<div className="collapse navbar-collapse" id="navbarSupportedContent">
|
||||||
|
<ul className="navbar-nav ml-auto">
|
||||||
|
<li className="nav-item">
|
||||||
|
<button className="btn btn-plain nav-link" onClick={this.newRoom.bind(this)} target="blank">
|
||||||
|
<PlusCircle /> <span>{this.props.translations.newRoomButton}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<button onClick={this.handleSettingsClick.bind(this)} className="btn btn-plain nav-link">
|
||||||
|
<Settings /> <span>{this.props.translations.settingsButton}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<button onClick={this.handleAboutClick.bind(this)} className="btn btn-plain nav-link">
|
||||||
|
<Info /> <span>{this.props.translations.aboutButton}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Nav.propTypes = {
|
||||||
|
members: PropTypes.array.isRequired,
|
||||||
|
roomId: PropTypes.string.isRequired,
|
||||||
|
userId: PropTypes.string.isRequired,
|
||||||
|
roomLocked: PropTypes.bool.isRequired,
|
||||||
|
toggleLockRoom: PropTypes.func.isRequired,
|
||||||
|
openModal: PropTypes.func.isRequired,
|
||||||
|
iAmOwner: PropTypes.bool.isRequired,
|
||||||
|
translations: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Nav;
|
14
client/src/components/Notice/Notice.test.jsx
Normal file
14
client/src/components/Notice/Notice.test.jsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import Notice from '.';
|
||||||
|
import { test, expect } from 'vitest';
|
||||||
|
|
||||||
|
test('Notice component is displaying', async () => {
|
||||||
|
const { asFragment } = render(
|
||||||
|
<Notice level={'warning'}>
|
||||||
|
<div>Hello world</div>
|
||||||
|
</Notice>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
@ -0,0 +1,15 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`Notice component is displaying 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
class="warning"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
Hello world
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
28
client/src/components/Notice/index.jsx
Normal file
28
client/src/components/Notice/index.jsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
|
const Notice = props => (
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
className={classNames({
|
||||||
|
info: props.level === 'info',
|
||||||
|
warning: props.level === 'warning',
|
||||||
|
danger: props.level === 'danger',
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
Notice.defaultProps = {
|
||||||
|
level: 'info',
|
||||||
|
};
|
||||||
|
|
||||||
|
Notice.propTypes = {
|
||||||
|
children: PropTypes.node.isRequired,
|
||||||
|
level: PropTypes.string,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Notice;
|
10
client/src/components/Notice/styles.module.css
Normal file
10
client/src/components/Notice/styles.module.css
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
.info {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.warning {
|
||||||
|
color: yellow;
|
||||||
|
}
|
||||||
|
|
||||||
|
.danger {
|
||||||
|
color: gold;
|
||||||
|
}
|
68
client/src/components/RoomLink/RoomLink.test.jsx
Normal file
68
client/src/components/RoomLink/RoomLink.test.jsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent } from '@testing-library/react';
|
||||||
|
import RoomLink from '.';
|
||||||
|
import mock$ from 'jquery';
|
||||||
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
const mockTooltip = vi.fn().mockImplementation(param => {});
|
||||||
|
|
||||||
|
vi.mock('jquery', () => {
|
||||||
|
return {
|
||||||
|
default: vi.fn().mockImplementation(param => {
|
||||||
|
if (typeof param === 'function') {
|
||||||
|
param();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tooltip: mockTooltip,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const mockTranslations = {
|
||||||
|
copyButtonTooltip: 'copyButton',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('RoomLink', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
mock$.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display', async () => {
|
||||||
|
const { asFragment, unmount } = render(<RoomLink roomId="roomId" translations={mockTranslations} />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
|
||||||
|
expect(mock$).toHaveBeenLastCalledWith('.copy-room');
|
||||||
|
expect(mockTooltip).toHaveBeenLastCalledWith({ trigger: 'manual' });
|
||||||
|
mock$.mockClear();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(mock$).toHaveBeenLastCalledWith('.copy-room');
|
||||||
|
expect(mockTooltip).toHaveBeenLastCalledWith('hide');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should copy link', async () => {
|
||||||
|
// Mock execCommand for paste
|
||||||
|
document.execCommand = vi.fn(() => true);
|
||||||
|
|
||||||
|
const { getByTitle } = render(<RoomLink roomId="roomId" translations={mockTranslations} />);
|
||||||
|
|
||||||
|
await fireEvent.click(getByTitle(mockTranslations.copyButtonTooltip));
|
||||||
|
|
||||||
|
expect(document.execCommand).toHaveBeenLastCalledWith('copy');
|
||||||
|
expect(mock$).toHaveBeenCalledTimes(4);
|
||||||
|
expect(mock$).toHaveBeenLastCalledWith('.copy-room');
|
||||||
|
expect(mockTooltip).toHaveBeenLastCalledWith('show');
|
||||||
|
|
||||||
|
// Wait for tooltip to close
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(mock$).toHaveBeenCalledTimes(6);
|
||||||
|
expect(mock$).toHaveBeenLastCalledWith('.copy-room');
|
||||||
|
expect(mockTooltip).toHaveBeenLastCalledWith('hide');
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,59 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`RoomLink > should display 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<form>
|
||||||
|
<div
|
||||||
|
class="form-group"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="input-group"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="form-control"
|
||||||
|
id="room-url"
|
||||||
|
readonly=""
|
||||||
|
type="text"
|
||||||
|
value="http://localhost:3000/roomId"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="input-group-append"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="copy-room btn btn-secondary"
|
||||||
|
data-clipboard-text="http://localhost:3000/roomId"
|
||||||
|
data-placement="bottom"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
title="copyButton"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
fill="none"
|
||||||
|
height="24"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="24"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
height="13"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
width="13"
|
||||||
|
x="9"
|
||||||
|
y="9"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
66
client/src/components/RoomLink/index.jsx
Normal file
66
client/src/components/RoomLink/index.jsx
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import { Copy } from 'react-feather';
|
||||||
|
import Clipboard from 'clipboard';
|
||||||
|
import $ from 'jquery';
|
||||||
|
|
||||||
|
class RoomLink extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
roomUrl: `${window.location.origin}/${props.roomId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
const clip = new Clipboard('.copy-room');
|
||||||
|
|
||||||
|
clip.on('success', () => {
|
||||||
|
$('.copy-room').tooltip('show');
|
||||||
|
setTimeout(() => {
|
||||||
|
$('.copy-room').tooltip('hide');
|
||||||
|
}, 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
$(() => {
|
||||||
|
$('.copy-room').tooltip({
|
||||||
|
trigger: 'manual',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillUnmount() {
|
||||||
|
if ($('.copy-room').tooltip) $('.copy-room').tooltip('hide');
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<form>
|
||||||
|
<div className="form-group">
|
||||||
|
<div className="input-group">
|
||||||
|
<input id="room-url" className="form-control" type="text" readOnly value={this.state.roomUrl} />
|
||||||
|
<div className="input-group-append">
|
||||||
|
<button
|
||||||
|
className="copy-room btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
data-placement="bottom"
|
||||||
|
data-clipboard-text={this.state.roomUrl}
|
||||||
|
title={this.props.translations.copyButtonTooltip}
|
||||||
|
>
|
||||||
|
<Copy />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RoomLink.propTypes = {
|
||||||
|
roomId: PropTypes.string.isRequired,
|
||||||
|
translations: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default RoomLink;
|
9
client/src/components/RoomLocked/RoomLocked.test.jsx
Normal file
9
client/src/components/RoomLocked/RoomLocked.test.jsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import RoomLocked from '.';
|
||||||
|
import { test, expect } from 'vitest';
|
||||||
|
|
||||||
|
test('RoomLocked component should display', () => {
|
||||||
|
const { asFragment } = render(<RoomLocked modalContent={'test'} />);
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
@ -0,0 +1,9 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`RoomLocked component should display 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
test
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
7
client/src/components/RoomLocked/index.jsx
Normal file
7
client/src/components/RoomLocked/index.jsx
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
|
||||||
|
export default class RoomLocked extends Component {
|
||||||
|
render() {
|
||||||
|
return <div>{this.props.modalContent}</div>;
|
||||||
|
}
|
||||||
|
}
|
183
client/src/components/Settings/Settings.test.jsx
Normal file
183
client/src/components/Settings/Settings.test.jsx
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
import configureStore from '@/store';
|
||||||
|
|
||||||
|
import Settings from '.';
|
||||||
|
|
||||||
|
const store = configureStore();
|
||||||
|
|
||||||
|
const mockTranslations = {
|
||||||
|
sound: 'soundCheck',
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
vi.mock('@/components/RoomLink');
|
||||||
|
|
||||||
|
const mockTooltip = vi.fn().mockImplementation(param => {});
|
||||||
|
|
||||||
|
vi.mock('jquery', () => {
|
||||||
|
return {
|
||||||
|
default: vi.fn().mockImplementation(param => {
|
||||||
|
if (typeof param === 'function') {
|
||||||
|
param();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tooltip: mockTooltip,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Settings component', () => {
|
||||||
|
it('should display', async () => {
|
||||||
|
const { asFragment, rerender } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Settings
|
||||||
|
soundIsEnabled={true}
|
||||||
|
toggleSoundEnabled={() => {}}
|
||||||
|
notificationIsEnabled={true}
|
||||||
|
toggleNotificationEnabled={() => {}}
|
||||||
|
toggleNotificationAllowed={vi.fn()}
|
||||||
|
roomId="roomId"
|
||||||
|
setLanguage={() => {}}
|
||||||
|
translations={{}}
|
||||||
|
/>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Settings
|
||||||
|
soundIsEnabled={true}
|
||||||
|
toggleSoundEnabled={() => {}}
|
||||||
|
notificationIsEnabled={true}
|
||||||
|
notificationIsAllowed={false}
|
||||||
|
toggleNotificationEnabled={() => {}}
|
||||||
|
toggleNotificationAllowed={vi.fn()}
|
||||||
|
roomId="roomId"
|
||||||
|
setLanguage={() => {}}
|
||||||
|
translations={{}}
|
||||||
|
/>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle sound', async () => {
|
||||||
|
const toggleSound = vi.fn();
|
||||||
|
const { getByText } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Settings
|
||||||
|
soundIsEnabled={true}
|
||||||
|
toggleSoundEnabled={toggleSound}
|
||||||
|
notificationIsEnabled={true}
|
||||||
|
notificationIsAllowed={true}
|
||||||
|
toggleNotificationEnabled={() => {}}
|
||||||
|
toggleNotificationAllowed={vi.fn()}
|
||||||
|
roomId="roomId"
|
||||||
|
setLanguage={() => {}}
|
||||||
|
translations={{}}
|
||||||
|
/>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
//console.log(getAllByText(mockTranslations.sound)[1]);
|
||||||
|
fireEvent.click(getByText('Sound'));
|
||||||
|
|
||||||
|
expect(toggleSound).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle notifications', async () => {
|
||||||
|
global.Notification = {
|
||||||
|
requestPermission: vi.fn().mockResolvedValue('granted'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleNotifications = vi.fn();
|
||||||
|
const { getByText } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Settings
|
||||||
|
soundIsEnabled={true}
|
||||||
|
toggleSoundEnabled={() => {}}
|
||||||
|
notificationIsEnabled={true}
|
||||||
|
notificationIsAllowed={true}
|
||||||
|
toggleNotificationEnabled={toggleNotifications}
|
||||||
|
toggleNotificationAllowed={vi.fn()}
|
||||||
|
roomId="roomId"
|
||||||
|
setLanguage={() => {}}
|
||||||
|
translations={{}}
|
||||||
|
/>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(getByText('Desktop Notification'));
|
||||||
|
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
delete global.Notification;
|
||||||
|
|
||||||
|
waitFor(() => expect(toggleNotifications).toHaveBeenCalledWith(false));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not toggle notifications', async () => {
|
||||||
|
global.Notification = {
|
||||||
|
requestPermission: vi.fn().mockResolvedValue('denied'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleNotifications = vi.fn();
|
||||||
|
const toggleAllowed = vi.fn();
|
||||||
|
const { getByText } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Settings
|
||||||
|
soundIsEnabled={true}
|
||||||
|
toggleSoundEnabled={() => {}}
|
||||||
|
notificationIsEnabled={true}
|
||||||
|
notificationIsAllowed={true}
|
||||||
|
toggleNotificationEnabled={toggleNotifications}
|
||||||
|
toggleNotificationAllowed={toggleAllowed}
|
||||||
|
roomId="roomId"
|
||||||
|
setLanguage={() => {}}
|
||||||
|
translations={{}}
|
||||||
|
/>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(getByText('Desktop Notification'));
|
||||||
|
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
delete global.Notification;
|
||||||
|
|
||||||
|
waitFor(() => expect(toggleAllowed).toHaveBeenCalledWith(false));
|
||||||
|
waitFor(() => expect(toggleNotifications).not.toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should change lang', async () => {
|
||||||
|
const changeLang = vi.fn();
|
||||||
|
|
||||||
|
const { getByDisplayValue } = render(
|
||||||
|
<Provider store={store}>
|
||||||
|
<Settings
|
||||||
|
soundIsEnabled={true}
|
||||||
|
toggleSoundEnabled={() => {}}
|
||||||
|
notificationIsEnabled={true}
|
||||||
|
toggleNotificationEnabled={() => {}}
|
||||||
|
toggleNotificationAllowed={vi.fn()}
|
||||||
|
roomId="roomId"
|
||||||
|
setLanguage={changeLang}
|
||||||
|
translations={{}}
|
||||||
|
/>
|
||||||
|
</Provider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(getByDisplayValue('English'), { target: { value: 'de' } });
|
||||||
|
|
||||||
|
expect(changeLang).toHaveBeenCalledWith('de');
|
||||||
|
});
|
||||||
|
});
|
@ -0,0 +1,429 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`Settings component > should display 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
class="_styles_23b490"
|
||||||
|
>
|
||||||
|
<section>
|
||||||
|
<h4>
|
||||||
|
New message notification
|
||||||
|
</h4>
|
||||||
|
<form>
|
||||||
|
<div
|
||||||
|
class="form-check"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="form-check-label"
|
||||||
|
for="sound-control"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
checked=""
|
||||||
|
class="form-check-input"
|
||||||
|
id="sound-control"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Sound
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="form-check"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="form-check-label"
|
||||||
|
for="notif-control"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
checked=""
|
||||||
|
class="form-check-input"
|
||||||
|
id="notif-control"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Desktop Notification
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="form-check"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="form-check-label"
|
||||||
|
for="persistence-control"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
id="persistence-control"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Persist configuration
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
This room
|
||||||
|
</h4>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
Language
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
<a
|
||||||
|
href="https://github.com/darkwire/darkwire.io/blob/master/client/README.md#translations"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Help us translate Darkwire!
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="form-group"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value="en"
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="fr"
|
||||||
|
>
|
||||||
|
Français
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="oc"
|
||||||
|
>
|
||||||
|
Occitan
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="de"
|
||||||
|
>
|
||||||
|
Deutsch
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="esAR"
|
||||||
|
>
|
||||||
|
Español (Argentina)
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="nl"
|
||||||
|
>
|
||||||
|
Nederlands
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="it"
|
||||||
|
>
|
||||||
|
Italiano
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="ru"
|
||||||
|
>
|
||||||
|
Русский
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="pl"
|
||||||
|
>
|
||||||
|
Polish
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="zhCN"
|
||||||
|
>
|
||||||
|
中文
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="ja"
|
||||||
|
>
|
||||||
|
日本語
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="tr"
|
||||||
|
>
|
||||||
|
Türkçe
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="ko"
|
||||||
|
>
|
||||||
|
한국어
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>
|
||||||
|
Room Ownership
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
The person who created the room is the room owner and has special privileges, like the ability to lock and unlock the room. If the owner leaves the room, the second person to join assumes ownership. If they leave, the third person becomes owner, and so on. The room owner has a star icon next to their username in the participants dropdown.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>
|
||||||
|
Lock Room
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
If you are the room owner, you can lock and unlock the room by clicking the lock icon in the nav bar. When a room is locked, no other participants will be able to join.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>
|
||||||
|
Slash Commands
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
The following slash commands are available:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
/nick [username]
|
||||||
|
<span
|
||||||
|
class="text-muted"
|
||||||
|
>
|
||||||
|
changes username
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
/me [action]
|
||||||
|
<span
|
||||||
|
class="text-muted"
|
||||||
|
>
|
||||||
|
performs an action
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
/clear
|
||||||
|
<span
|
||||||
|
class="text-muted"
|
||||||
|
>
|
||||||
|
clears your message history
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
/help
|
||||||
|
<span
|
||||||
|
class="text-muted"
|
||||||
|
>
|
||||||
|
lists all commands
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`Settings component > should display 2`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div
|
||||||
|
class="_styles_23b490"
|
||||||
|
>
|
||||||
|
<section>
|
||||||
|
<h4>
|
||||||
|
New message notification
|
||||||
|
</h4>
|
||||||
|
<form>
|
||||||
|
<div
|
||||||
|
class="form-check"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="form-check-label"
|
||||||
|
for="sound-control"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
checked=""
|
||||||
|
class="form-check-input"
|
||||||
|
id="sound-control"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Sound
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="form-check"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="form-check-label"
|
||||||
|
for="notif-control"
|
||||||
|
>
|
||||||
|
Desktop notifications have been denied
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="form-check"
|
||||||
|
>
|
||||||
|
<label
|
||||||
|
class="form-check-label"
|
||||||
|
for="persistence-control"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
id="persistence-control"
|
||||||
|
type="checkbox"
|
||||||
|
/>
|
||||||
|
Persist configuration
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
This room
|
||||||
|
</h4>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4
|
||||||
|
class="mb-3"
|
||||||
|
>
|
||||||
|
Language
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
<a
|
||||||
|
href="https://github.com/darkwire/darkwire.io/blob/master/client/README.md#translations"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Help us translate Darkwire!
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="form-group"
|
||||||
|
>
|
||||||
|
<select
|
||||||
|
class="form-control"
|
||||||
|
>
|
||||||
|
<option
|
||||||
|
value="en"
|
||||||
|
>
|
||||||
|
English
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="fr"
|
||||||
|
>
|
||||||
|
Français
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="oc"
|
||||||
|
>
|
||||||
|
Occitan
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="de"
|
||||||
|
>
|
||||||
|
Deutsch
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="esAR"
|
||||||
|
>
|
||||||
|
Español (Argentina)
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="nl"
|
||||||
|
>
|
||||||
|
Nederlands
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="it"
|
||||||
|
>
|
||||||
|
Italiano
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="ru"
|
||||||
|
>
|
||||||
|
Русский
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="pl"
|
||||||
|
>
|
||||||
|
Polish
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="zhCN"
|
||||||
|
>
|
||||||
|
中文
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="ja"
|
||||||
|
>
|
||||||
|
日本語
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="tr"
|
||||||
|
>
|
||||||
|
Türkçe
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="ko"
|
||||||
|
>
|
||||||
|
한국어
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>
|
||||||
|
Room Ownership
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
The person who created the room is the room owner and has special privileges, like the ability to lock and unlock the room. If the owner leaves the room, the second person to join assumes ownership. If they leave, the third person becomes owner, and so on. The room owner has a star icon next to their username in the participants dropdown.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>
|
||||||
|
Lock Room
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
If you are the room owner, you can lock and unlock the room by clicking the lock icon in the nav bar. When a room is locked, no other participants will be able to join.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>
|
||||||
|
Slash Commands
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
The following slash commands are available:
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
/nick [username]
|
||||||
|
<span
|
||||||
|
class="text-muted"
|
||||||
|
>
|
||||||
|
changes username
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
/me [action]
|
||||||
|
<span
|
||||||
|
class="text-muted"
|
||||||
|
>
|
||||||
|
performs an action
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
/clear
|
||||||
|
<span
|
||||||
|
class="text-muted"
|
||||||
|
>
|
||||||
|
clears your message history
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
/help
|
||||||
|
<span
|
||||||
|
class="text-muted"
|
||||||
|
>
|
||||||
|
lists all commands
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
199
client/src/components/Settings/index.jsx
Normal file
199
client/src/components/Settings/index.jsx
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
|
||||||
|
import RoomLink from '@/components/RoomLink';
|
||||||
|
import T from '@/components/T';
|
||||||
|
|
||||||
|
import classes from './styles.module.scss';
|
||||||
|
|
||||||
|
class Settings extends Component {
|
||||||
|
handleSoundToggle() {
|
||||||
|
this.props.toggleSoundEnabled(!this.props.soundIsEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePersistenceToggle() {
|
||||||
|
this.props.togglePersistenceEnabled(!this.props.persistenceIsEnabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
handleNotificationToggle() {
|
||||||
|
Notification.requestPermission().then(permission => {
|
||||||
|
if (permission === 'granted') {
|
||||||
|
this.props.toggleNotificationEnabled(!this.props.notificationIsEnabled);
|
||||||
|
this.props.toggleNotificationAllowed(true);
|
||||||
|
}
|
||||||
|
if (permission === 'denied') {
|
||||||
|
this.props.toggleNotificationAllowed(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
handleLanguageChange(evt) {
|
||||||
|
const language = evt.target.value;
|
||||||
|
this.props.setLanguage(language);
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div className={classes.styles}>
|
||||||
|
<section>
|
||||||
|
<h4>
|
||||||
|
<T path="newMessageNotification" />
|
||||||
|
</h4>
|
||||||
|
<form>
|
||||||
|
<div className="form-check">
|
||||||
|
<label className="form-check-label" htmlFor="sound-control">
|
||||||
|
<input
|
||||||
|
id="sound-control"
|
||||||
|
onChange={this.handleSoundToggle.bind(this)}
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={this.props.soundIsEnabled}
|
||||||
|
/>
|
||||||
|
<T path="sound" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-check">
|
||||||
|
<label className="form-check-label" htmlFor="notif-control">
|
||||||
|
{this.props.notificationIsAllowed !== false && (
|
||||||
|
<>
|
||||||
|
<input
|
||||||
|
id="notif-control"
|
||||||
|
onChange={this.handleNotificationToggle.bind(this)}
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={this.props.notificationIsEnabled}
|
||||||
|
disabled={this.props.notificationIsAllowed === false} // Important to keep '=== false' here
|
||||||
|
/>
|
||||||
|
<T path="desktopNotification" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{this.props.notificationIsAllowed === false && <T path="desktopNotificationBlocked" />}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="form-check">
|
||||||
|
<label className="form-check-label" htmlFor="persistence-control">
|
||||||
|
<input
|
||||||
|
id="persistence-control"
|
||||||
|
onChange={this.handlePersistenceToggle.bind(this)}
|
||||||
|
className="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
checked={this.props.persistenceIsEnabled}
|
||||||
|
/>
|
||||||
|
<T path="persistence" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4 className="mb-3">
|
||||||
|
<T path="copyRoomHeader" />
|
||||||
|
</h4>
|
||||||
|
<RoomLink roomId={this.props.roomId} translations={this.props.translations} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4 className="mb-3">
|
||||||
|
<T path="languageDropdownHeader" />
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
<a
|
||||||
|
href="https://github.com/darkwire/darkwire.io/blob/master/client/README.md#translations"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
<T path="helpTranslate" />
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
<div className="form-group">
|
||||||
|
<select
|
||||||
|
value={this.props.language}
|
||||||
|
className="form-control"
|
||||||
|
onChange={this.handleLanguageChange.bind(this)}
|
||||||
|
>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="fr">Français</option>
|
||||||
|
<option value="oc">Occitan</option>
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
<option value="esAR">Español (Argentina)</option>
|
||||||
|
<option value="nl">Nederlands</option>
|
||||||
|
<option value="it">Italiano</option>
|
||||||
|
<option value="ru">Русский</option>
|
||||||
|
<option value="pl">Polish</option>
|
||||||
|
<option value="zhCN">中文</option>
|
||||||
|
<option value="ja">日本語</option>
|
||||||
|
<option value="tr">Türkçe</option>
|
||||||
|
<option value="ko">한국어</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h4>
|
||||||
|
<T path="roomOwnerHeader" />
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
<T path="roomOwnerText" />
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>
|
||||||
|
<T path="lockRoomHeader" />
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
<T path="lockRoomText" />
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4>
|
||||||
|
<T path="slashCommandsHeader" />
|
||||||
|
</h4>
|
||||||
|
<p>
|
||||||
|
<T path="slashCommandsText" />
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
<li>
|
||||||
|
/nick [username]{' '}
|
||||||
|
<span className="text-muted">
|
||||||
|
<T path="slashCommandsBullets.0" />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
/me [action]{' '}
|
||||||
|
<span className="text-muted">
|
||||||
|
<T path="slashCommandsBullets.1" />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
/clear{' '}
|
||||||
|
<span className="text-muted">
|
||||||
|
<T path="slashCommandsBullets.2" />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
/help{' '}
|
||||||
|
<span className="text-muted">
|
||||||
|
<T path="slashCommandsBullets.3" />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings.propTypes = {
|
||||||
|
soundIsEnabled: PropTypes.bool.isRequired,
|
||||||
|
persistenceIsEnabled: PropTypes.bool.isRequired,
|
||||||
|
toggleSoundEnabled: PropTypes.func.isRequired,
|
||||||
|
notificationIsEnabled: PropTypes.bool.isRequired,
|
||||||
|
notificationIsAllowed: PropTypes.bool,
|
||||||
|
toggleNotificationEnabled: PropTypes.func.isRequired,
|
||||||
|
toggleNotificationAllowed: PropTypes.func.isRequired,
|
||||||
|
roomId: PropTypes.string.isRequired,
|
||||||
|
setLanguage: PropTypes.func.isRequired,
|
||||||
|
translations: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Settings;
|
5
client/src/components/Settings/styles.module.scss
Normal file
5
client/src/components/Settings/styles.module.scss
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.styles {
|
||||||
|
section {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
}
|
32
client/src/components/T/T.jsx
Normal file
32
client/src/components/T/T.jsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import _ from 'lodash';
|
||||||
|
|
||||||
|
import { getTranslations } from '@/i18n';
|
||||||
|
|
||||||
|
const regex = /{(.*?)}/g;
|
||||||
|
|
||||||
|
class T extends Component {
|
||||||
|
render() {
|
||||||
|
const t = getTranslations(this.props.language);
|
||||||
|
const englishT = getTranslations('en');
|
||||||
|
const str = _.get(t, this.props.path, '') || _.get(englishT, this.props.path, '');
|
||||||
|
let string = str.split(regex);
|
||||||
|
if (this.props.data) {
|
||||||
|
string = string.map(word => {
|
||||||
|
if (this.props.data[word]) {
|
||||||
|
return this.props.data[word];
|
||||||
|
}
|
||||||
|
return word;
|
||||||
|
});
|
||||||
|
return <span>{string}</span>;
|
||||||
|
}
|
||||||
|
return string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
T.propTypes = {
|
||||||
|
path: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default T;
|
34
client/src/components/T/T.test.jsx
Normal file
34
client/src/components/T/T.test.jsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { test, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
import T from './T';
|
||||||
|
|
||||||
|
// To avoid missing provider
|
||||||
|
vi.mock('components/T');
|
||||||
|
|
||||||
|
test('T component is displaying', async () => {
|
||||||
|
const { asFragment, rerender } = render(<T path="welcomeHeader" language="en" />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
|
||||||
|
rerender(<T path="welcomeHeader" language="fr" />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
|
||||||
|
rerender(<T path="welcomeHeader" language="xx" />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
|
||||||
|
rerender(<T path="missingKey" language="en" />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
|
||||||
|
rerender(<T path="userJoined" language="en" data={{ username: 'Alan' }} />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
|
||||||
|
rerender(<T path="userJoined" language="en" />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
35
client/src/components/T/__snapshots__/T.test.jsx.snap
Normal file
35
client/src/components/T/__snapshots__/T.test.jsx.snap
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`T component is displaying 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
Welcome to Darkwire v2.0
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`T component is displaying 2`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
Bienvenue sur Darkwire v2.0
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`T component is displaying 3`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
Welcome to Darkwire v2.0
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`T component is displaying 4`] = `<DocumentFragment />`;
|
||||||
|
|
||||||
|
exports[`T component is displaying 5`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<span>
|
||||||
|
Alan joined
|
||||||
|
</span>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
||||||
|
|
||||||
|
exports[`T component is displaying 6`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
username joined
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
6
client/src/components/T/index.jsx
Normal file
6
client/src/components/T/index.jsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { connect } from 'react-redux';
|
||||||
|
import T from './T';
|
||||||
|
|
||||||
|
export default connect((state, ownProps) => ({
|
||||||
|
language: state.app.language,
|
||||||
|
}))(T);
|
11
client/src/components/Username/Username.test.jsx
Normal file
11
client/src/components/Username/Username.test.jsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { test, expect } from 'vitest';
|
||||||
|
|
||||||
|
import Username from '.';
|
||||||
|
|
||||||
|
test('Username component is displaying', async () => {
|
||||||
|
const { asFragment } = render(<Username username="paul" />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
@ -0,0 +1,12 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`Username component is displaying 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<span
|
||||||
|
class="username"
|
||||||
|
style="color: rgb(157, 242, 249);"
|
||||||
|
>
|
||||||
|
paul
|
||||||
|
</span>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
19
client/src/components/Username/index.jsx
Normal file
19
client/src/components/Username/index.jsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import randomColor from 'randomcolor';
|
||||||
|
|
||||||
|
class Username extends Component {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<span className="username" style={{ color: randomColor({ seed: this.props.username, luminosity: 'light' }) }}>
|
||||||
|
{this.props.username}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Username.propTypes = {
|
||||||
|
username: PropTypes.string.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Username;
|
13
client/src/components/Welcome/Welcome.test.jsx
Normal file
13
client/src/components/Welcome/Welcome.test.jsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import { test, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
import Welcome from '.';
|
||||||
|
|
||||||
|
vi.mock('@/components/RoomLink');
|
||||||
|
|
||||||
|
test('Welcome component is displaying', async () => {
|
||||||
|
const { asFragment } = render(<Welcome roomId="roomtest" close={() => {}} translations={{}} />);
|
||||||
|
|
||||||
|
expect(asFragment()).toMatchSnapshot();
|
||||||
|
});
|
@ -0,0 +1,54 @@
|
|||||||
|
// Vitest Snapshot v1
|
||||||
|
|
||||||
|
exports[`Welcome component is displaying 1`] = `
|
||||||
|
<DocumentFragment>
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
v2.0 is a complete rewrite and includes several new features. Here are some highlights:
|
||||||
|
<ul
|
||||||
|
class="native"
|
||||||
|
>
|
||||||
|
<li>
|
||||||
|
Support on all modern browsers (Chrome, Firefox, Safari, Safari iOS, Android)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Slash commands (/nick, /me, /clear)
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Room owners can lock the room, preventing anyone else from joining
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Front-end rewritten in React.js and Redux
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Send files up to 4 MB
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div>
|
||||||
|
You can learn more
|
||||||
|
<a
|
||||||
|
href="https://github.com/darkwire/darkwire.io"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<p
|
||||||
|
class="mb-2"
|
||||||
|
>
|
||||||
|
Others can join this room using the following URL:
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="react-modal-footer"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="btn btn-primary btn-lg"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DocumentFragment>
|
||||||
|
`;
|
52
client/src/components/Welcome/index.jsx
Normal file
52
client/src/components/Welcome/index.jsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import React, { Component } from 'react';
|
||||||
|
import PropTypes from 'prop-types';
|
||||||
|
import RoomLink from '@/components/RoomLink';
|
||||||
|
|
||||||
|
class Welcome extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props);
|
||||||
|
this.state = {
|
||||||
|
roomUrl: `https://darkwire.io/${props.roomId}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
v2.0 is a complete rewrite and includes several new features. Here are some highlights:
|
||||||
|
<ul className="native">
|
||||||
|
<li>Support on all modern browsers (Chrome, Firefox, Safari, Safari iOS, Android)</li>
|
||||||
|
<li>Slash commands (/nick, /me, /clear)</li>
|
||||||
|
<li>Room owners can lock the room, preventing anyone else from joining</li>
|
||||||
|
<li>Front-end rewritten in React.js and Redux</li>
|
||||||
|
<li>Send files up to 4 MB</li>
|
||||||
|
</ul>
|
||||||
|
<div>
|
||||||
|
You can learn more{' '}
|
||||||
|
<a href="https://github.com/darkwire/darkwire.io" target="_blank" rel="noopener noreferrer">
|
||||||
|
here
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<p className="mb-2">Others can join this room using the following URL:</p>
|
||||||
|
<RoomLink roomId={this.props.roomId} translations={this.props.translations} />
|
||||||
|
<div className="react-modal-footer">
|
||||||
|
<button className="btn btn-primary btn-lg" onClick={this.props.close}>
|
||||||
|
{this.props.translations.welcomeModalCTA}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Welcome.propTypes = {
|
||||||
|
roomId: PropTypes.string.isRequired,
|
||||||
|
close: PropTypes.func.isRequired,
|
||||||
|
translations: PropTypes.object.isRequired,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Welcome;
|
5
client/src/config/env.js
Normal file
5
client/src/config/env.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/* istanbul ignore file */
|
||||||
|
export const MAX_FILE_SIZE = import.meta.VITE_MAX_FILE_SIZE || 4;
|
||||||
|
export const COMMIT_SHA = import.meta.env.VITE_COMMIT_SHA;
|
||||||
|
|
||||||
|
export default import.meta.env.NODE_ENV;
|
40
client/src/i18n/de.json
Normal file
40
client/src/i18n/de.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"aboutButton": "Über",
|
||||||
|
"aboutHeader": "Über",
|
||||||
|
"agreement": "Durch die Nutzung von Darkwire stimmen Sie unseren Richtlinien und Nutzungsbedingungen zu",
|
||||||
|
"copyButtonTooltip": "Kopiert",
|
||||||
|
"copyRoomHeader": "Dieser Raum",
|
||||||
|
"downloadFile": "{filename} herunterladen",
|
||||||
|
"languageDropdownHeader": "Sprache",
|
||||||
|
"lockRoomHeader": "Raum sperren",
|
||||||
|
"lockRoomText": "Wenn Sie der Raumbesitzer sind, können Sie den Raum sperren und entsperren, indem Sie auf das Schlosssymbol in der Navigationsleiste klicken. Wenn ein Raum gesperrt ist, können keine anderen Teilnehmer teilnehmen.",
|
||||||
|
"lockedRoom": "{username} hat den Raum abgeschlossen",
|
||||||
|
"lockedRoomHeader": "Dieser Raum ist verschlossen",
|
||||||
|
"nameChange": "{oldUsername} hat seinen Namen in {newUsername} geändert",
|
||||||
|
"newRoomButton": "Neuer Raum",
|
||||||
|
"roomOwnerHeader": "Raumeigentum",
|
||||||
|
"roomOwnerText": "Die Person, die den Raum erstellt hat, ist der Raumbesitzer und hat spezielle Rechte, wie die Möglichkeit, den Raum zu sperren und zu entsperren. Wenn der Eigentümer das Zimmer verlässt, übernimmt die zweite Person, die beitritt, das Eigentum. Wenn sie gehen, wird die dritte Person Eigentümer und so weiter. Der Rauminhaber hat ein Sternsymbol neben seinem Benutzernamen in der Teilnehmer-Dropdown-Liste.",
|
||||||
|
"sentFile": "Sie haben {filename} gesendet",
|
||||||
|
"settings": "Einstellungen",
|
||||||
|
"settingsButton": "Einstellungen",
|
||||||
|
"settingsHeader": "Einstellungen & Hilfe",
|
||||||
|
"slashCommandsBullets": [
|
||||||
|
"Ändert den Benutzernamen",
|
||||||
|
"führt eine Aktion aus",
|
||||||
|
"löscht den Nachrichtenverlauf",
|
||||||
|
"listet alle Befehle auf"
|
||||||
|
],
|
||||||
|
"slashCommandsHeader": "Slash-Befehle",
|
||||||
|
"slashCommandsText": "Die folgenden Schrägstrichbefehle sind verfügbar:",
|
||||||
|
"sound": "Klingen",
|
||||||
|
"newMessageNotification": "New message notification",
|
||||||
|
"desktopNotification": "Desktop Benachrichtigungen",
|
||||||
|
"typePlaceholder": "Tippen Sie hier",
|
||||||
|
"unlockedRoom": "{username} hat den Raum freigeschaltet",
|
||||||
|
"userJoined": "{username} ist beigetreten",
|
||||||
|
"userLeft": "{username} hat verlassen",
|
||||||
|
"userSentFile": "{username} hat Ihnen eine Datei gesendet.",
|
||||||
|
"welcomeHeader": "Willkommen bei Darkwire v2.0",
|
||||||
|
"welcomeModalCTA": "OK",
|
||||||
|
"helpTranslate": "Hilf uns, Darkwire zu übersetzen!"
|
||||||
|
}
|
42
client/src/i18n/en.json
Normal file
42
client/src/i18n/en.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"newRoomButton": "New Room",
|
||||||
|
"lockedRoom": "{username} locked the room",
|
||||||
|
"unlockedRoom": "{username} unlocked the room",
|
||||||
|
"agreement": "By using Darkwire, you are agreeing to our Acceptable Use Policy and Terms of Service",
|
||||||
|
"typePlaceholder": "Type here",
|
||||||
|
"aboutButton": "About",
|
||||||
|
"settingsButton": "Settings",
|
||||||
|
"settings": "Settings",
|
||||||
|
"aboutHeader": "About",
|
||||||
|
"copyButtonTooltip": "Copied",
|
||||||
|
"welcomeHeader": "Welcome to Darkwire v2.0",
|
||||||
|
"sentFile": "You sent {filename}",
|
||||||
|
"userJoined": "{username} joined",
|
||||||
|
"userLeft": "{username} left",
|
||||||
|
"userSentFile": "{username} sent you a file.",
|
||||||
|
"downloadFile": "Download {filename}",
|
||||||
|
"nameChange": "{oldUsername} changed their name to {newUsername}",
|
||||||
|
"settingsHeader": "Settings & Help",
|
||||||
|
"copyRoomHeader": "This room",
|
||||||
|
"languageDropdownHeader": "Language",
|
||||||
|
"roomOwnerHeader": "Room Ownership",
|
||||||
|
"roomOwnerText": "The person who created the room is the room owner and has special privileges, like the ability to lock and unlock the room. If the owner leaves the room, the second person to join assumes ownership. If they leave, the third person becomes owner, and so on. The room owner has a star icon next to their username in the participants dropdown.",
|
||||||
|
"lockRoomHeader": "Lock Room",
|
||||||
|
"lockRoomText": "If you are the room owner, you can lock and unlock the room by clicking the lock icon in the nav bar. When a room is locked, no other participants will be able to join.",
|
||||||
|
"slashCommandsHeader": "Slash Commands",
|
||||||
|
"slashCommandsText": "The following slash commands are available:",
|
||||||
|
"slashCommandsBullets": [
|
||||||
|
"changes username",
|
||||||
|
"performs an action",
|
||||||
|
"clears your message history",
|
||||||
|
"lists all commands"
|
||||||
|
],
|
||||||
|
"sound": "Sound",
|
||||||
|
"persistence": "Persist configuration",
|
||||||
|
"newMessageNotification": "New message notification",
|
||||||
|
"desktopNotification": "Desktop Notification",
|
||||||
|
"desktopNotificationBlocked": "Desktop notifications have been denied",
|
||||||
|
"welcomeModalCTA": "Ok",
|
||||||
|
"lockedRoomHeader": "This room is locked",
|
||||||
|
"helpTranslate": "Help us translate Darkwire!"
|
||||||
|
}
|
38
client/src/i18n/es-AR.json
Normal file
38
client/src/i18n/es-AR.json
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
{
|
||||||
|
"newRoomButton": "Nueva sala",
|
||||||
|
"lockedRoom": "{username} bloqueó la sala",
|
||||||
|
"unlockedRoom": "{username} desbloqueó la sala",
|
||||||
|
"agreement": "Al usar Darkwire, está de acuerdo con nuestros Aceptables condiciones de uso y Términos de servicio",
|
||||||
|
"typePlaceholder": "Escriba aquí",
|
||||||
|
"aboutButton": "Acerca de",
|
||||||
|
"settingsButton": "Configuración",
|
||||||
|
"settings": "Configuración",
|
||||||
|
"aboutHeader": "Acerca de",
|
||||||
|
"copyButtonTooltip": "Copiado",
|
||||||
|
"welcomeHeader": "Bienvenido a Darkwire v2.0",
|
||||||
|
"userJoined": "{username} se unió",
|
||||||
|
"userLeft": "{username} salió de la sala",
|
||||||
|
"userSentFile": "{username} te envió un archivo.",
|
||||||
|
"downloadFile": "Descargar {filename}",
|
||||||
|
"nameChange": "{oldUsername} cambió su apodo a {newUsername}",
|
||||||
|
"settingsHeader": "Configuración y Ayuda",
|
||||||
|
"copyRoomHeader": "Esta sala",
|
||||||
|
"sentFile": "Enviaste {filename}",
|
||||||
|
"languageDropdownHeader": "Idioma",
|
||||||
|
"roomOwnerHeader": "Propiedad de la sala",
|
||||||
|
"roomOwnerText": "La persona que creó esta sala es el propietario y posee privilegios especiales, como la habilidad de bloquear y desbloquear la sala. Si el propietario abandona la sala, la segunda persona en unirse se convierte en propietario. Si también se va, la tercera persona en unirse se convierte en propietaria, etcétera. El propietario de la sala tiene el ícono de una estrella junto a su nombre de usuario en la vista de participantes.",
|
||||||
|
"lockRoomHeader": "Bloquear sala",
|
||||||
|
"lockRoomText": "Si sos el propietario de la sala, puede bloquearla y desbloquearla haciendo click en el ícono del candado en la barra de navegación. Cuando una sala está bloqueada, no podrán ingresar nuevos participantes.",
|
||||||
|
"slashCommandsHeader": "Comandos",
|
||||||
|
"slashCommandsText": "Los siguientes comandos están disponibles:",
|
||||||
|
"slashCommandsBullets": [
|
||||||
|
"cambia el nombre de usuario",
|
||||||
|
"lleva adelante una acción",
|
||||||
|
"limpia el historial de mensajes",
|
||||||
|
"lista todos los comandos disponibles"
|
||||||
|
],
|
||||||
|
"sound": "Sonido",
|
||||||
|
"welcomeModalCTA": "Ok",
|
||||||
|
"lockedRoomHeader": "Esta sala está bloqueada",
|
||||||
|
"helpTranslate": "Ayudanos a traducir Darkwire!"
|
||||||
|
}
|
42
client/src/i18n/fr.json
Normal file
42
client/src/i18n/fr.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"newRoomButton": "Nouveau Salon",
|
||||||
|
"lockedRoom": "{username} a verrouillé le salon",
|
||||||
|
"unlockedRoom": "{username} a déverrouillé le salon",
|
||||||
|
"agreement": "En utilisant Darkwire, vous acceptez notre Politique d'Utilisation et nos Conditions d'Utilisation",
|
||||||
|
"typePlaceholder": "Écrivez ici",
|
||||||
|
"aboutButton": "À propos",
|
||||||
|
"settingsButton": "Paramètres",
|
||||||
|
"settings": "Paramètres",
|
||||||
|
"aboutHeader": "À propos",
|
||||||
|
"copyButtonTooltip": "Copié",
|
||||||
|
"welcomeHeader": "Bienvenue sur Darkwire v2.0",
|
||||||
|
"sentFile": "Vous avez envoyé {filename}",
|
||||||
|
"userJoined": "{username} a rejoint",
|
||||||
|
"userLeft": "{username} a quitté",
|
||||||
|
"userSentFile": "{username} vous a envoyé un fichier.",
|
||||||
|
"downloadFile": "Téléchargement de {filename}",
|
||||||
|
"nameChange": "{oldUsername} s'est renommé en {newUsername}",
|
||||||
|
"settingsHeader": "Paramètres et Aide",
|
||||||
|
"copyRoomHeader": "Ce salon",
|
||||||
|
"languageDropdownHeader": "Langue",
|
||||||
|
"roomOwnerHeader": "Propriété du Salon",
|
||||||
|
"roomOwnerText": "La personne qui a créé le salon en est propriétaire et possède des privilèges spéciaux, comme la possibilité de verrouiller et déverrouiller le salon. Si le propriétaire quitte le salon, la deuxième personne à s'y joindre devient propriétaire. En cas de départ, la troisième personne devient propriétaire, et ainsi de suite. Le propriétaire du salon a une icône en forme d'étoile à côté de son nom d'utilisateur dans le menu déroulant des participants.",
|
||||||
|
"lockRoomHeader": "Verrouiller le Salon",
|
||||||
|
"lockRoomText": "Si vous êtes le propriétaire du salon, vous pouvez le verrouiller et le déverrouiller en cliquant sur l'icône de cadenas située dans la barre de navigation. Quand un salon est verrouillé, aucun autre participant ne peut rejoindre.",
|
||||||
|
"slashCommandsHeader": "Commandes",
|
||||||
|
"slashCommandsText": "Les commandes suivantes sont disponibles :",
|
||||||
|
"slashCommandsBullets": [
|
||||||
|
"changer de pseudo",
|
||||||
|
"effectuer une action",
|
||||||
|
"effacer votre historique de messages",
|
||||||
|
"lister toutes les commandes"
|
||||||
|
],
|
||||||
|
"sound": "Son",
|
||||||
|
"persistence": "Mémoriser la configuration",
|
||||||
|
"newMessageNotification": "Notification lors d'un nouveau message",
|
||||||
|
"desktopNotification": "Notification Système",
|
||||||
|
"desktopNotificationBlocked": "Les notifications systèmes ont été refusée",
|
||||||
|
"welcomeModalCTA": "Ok",
|
||||||
|
"lockedRoomHeader": "Ce salon est verrouillé",
|
||||||
|
"helpTranslate": "Aidez-nous à traduire Darkwire!"
|
||||||
|
}
|
12
client/src/i18n/i18n.test.js
Normal file
12
client/src/i18n/i18n.test.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { getTranslations } from './';
|
||||||
|
|
||||||
|
import { test, expect } from 'vitest';
|
||||||
|
|
||||||
|
test('Get translation', () => {
|
||||||
|
expect(getTranslations('en').welcomeHeader).toBe('Welcome to Darkwire v2.0');
|
||||||
|
expect(getTranslations().welcomeHeader).toBe('Welcome to Darkwire v2.0');
|
||||||
|
expect(getTranslations('fr').welcomeHeader).toBe('Bienvenue sur Darkwire v2.0');
|
||||||
|
expect(getTranslations('zh-CN').welcomeHeader).toBe('欢迎来到Darkwire v2.0');
|
||||||
|
expect(getTranslations('en-US').welcomeHeader).toBe('Welcome to Darkwire v2.0');
|
||||||
|
expect(getTranslations('ru-CH').welcomeHeader).toBe('Добро пожаловать на Darkwire v2.0');
|
||||||
|
});
|
48
client/src/i18n/index.js
Normal file
48
client/src/i18n/index.js
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import en from './en';
|
||||||
|
import fr from './fr';
|
||||||
|
import oc from './oc';
|
||||||
|
import de from './de';
|
||||||
|
import it from './it';
|
||||||
|
import zhCN from './zh-CN';
|
||||||
|
import nl from './nl';
|
||||||
|
import ru from './ru';
|
||||||
|
import esAR from './es-AR';
|
||||||
|
import ja from './ja';
|
||||||
|
import tr from './tr';
|
||||||
|
import ko from './ko';
|
||||||
|
import pl from './pl';
|
||||||
|
|
||||||
|
const languagesMap = {
|
||||||
|
en,
|
||||||
|
fr,
|
||||||
|
oc,
|
||||||
|
de,
|
||||||
|
it,
|
||||||
|
zhCN,
|
||||||
|
nl,
|
||||||
|
ru,
|
||||||
|
esAR,
|
||||||
|
ja,
|
||||||
|
tr,
|
||||||
|
ko,
|
||||||
|
pl
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return best match for lang and variant.
|
||||||
|
* @param {string} language string from navigator configuration or cookie.
|
||||||
|
* @returns the translation dict
|
||||||
|
*/
|
||||||
|
export function getTranslations(language = '') {
|
||||||
|
const [lang, variant] = language.split('-');
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(languagesMap, `${lang}${variant}`)) {
|
||||||
|
return languagesMap[`${lang}${variant}`];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.prototype.hasOwnProperty.call(languagesMap, `${lang}`)) {
|
||||||
|
return languagesMap[lang];
|
||||||
|
}
|
||||||
|
|
||||||
|
return languagesMap['en'];
|
||||||
|
}
|
40
client/src/i18n/it.json
Normal file
40
client/src/i18n/it.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"aboutButton": "Su di noi",
|
||||||
|
"aboutHeader": "Su di noi",
|
||||||
|
"agreement": "Usando Darkwire, accetti la nostra Politica di utilizzo accettabile e i Termini di servizio",
|
||||||
|
"copyButtonTooltip": "Copiato",
|
||||||
|
"copyRoomHeader": "Questa stanza",
|
||||||
|
"downloadFile": "Scarica {filename}",
|
||||||
|
"languageDropdownHeader": "linguaggio",
|
||||||
|
"lockRoomHeader": "Chiudi la stanza",
|
||||||
|
"lockRoomText": "Se sei il proprietario della stanza, puoi chiuderla e aprirla facendo clic sull'icona del lucchetto nella barra di navigazione. Quando una stanza è chiusa, nessun altro partecipante potrà unirsi.",
|
||||||
|
"lockedRoom": "{username} ha chiuso la stanza",
|
||||||
|
"lockedRoomHeader": "Questa stanza è chiusa",
|
||||||
|
"nameChange": "{oldUsername} ha cambiato nome in {newUsername}",
|
||||||
|
"newRoomButton": "Nuova stanza",
|
||||||
|
"roomOwnerHeader": "Proprietario della stanza",
|
||||||
|
"roomOwnerText": "La persona che ha creato la stanza è il proprietario della stanza e ha privilegi speciali, come la possibilità di chiudere e aprire la stanza. Se il proprietario lascia la stanza, la seconda persona che si unisce diventa proprietario. Se lasciano, la terza persona diventa proprietario e così via. Il proprietario della stanza ha un'icona a forma di stella accanto al suo nome utente nel menu dei partecipanti.",
|
||||||
|
"sentFile": "Hai inviato {filename}",
|
||||||
|
"settings": "impostazioni",
|
||||||
|
"settingsButton": "impostazioni",
|
||||||
|
"settingsHeader": "Impostazioni e aiuto",
|
||||||
|
"slashCommandsBullets": [
|
||||||
|
"cambia nome utente",
|
||||||
|
"esegue un'azione",
|
||||||
|
"cancella la cronologia dei messaggi",
|
||||||
|
"elenca tutti i comandi"
|
||||||
|
],
|
||||||
|
"slashCommandsHeader": "Comandi",
|
||||||
|
"slashCommandsText": "Sono disponibili i seguenti comandi:",
|
||||||
|
"sound": "Suono",
|
||||||
|
"newMessageNotification": "New message notification",
|
||||||
|
"desktopNotification": "Desktop Notification",
|
||||||
|
"typePlaceholder": "Digitare qui",
|
||||||
|
"unlockedRoom": "{username} ha aperto la stanza",
|
||||||
|
"userJoined": "{username} si è unito",
|
||||||
|
"userLeft": "{username} è uscito",
|
||||||
|
"userSentFile": "{username} ha inviato un file.",
|
||||||
|
"welcomeHeader": "Benvenuto in Darkwire v2.0",
|
||||||
|
"welcomeModalCTA": "Ok",
|
||||||
|
"helpTranslate": "Aiutaci a tradurre Darkwire!"
|
||||||
|
}
|
41
client/src/i18n/ja.json
Normal file
41
client/src/i18n/ja.json
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"newRoomButton": "新規トークルーム",
|
||||||
|
"lockedRoom": "{username} がこのルームをロックしました。",
|
||||||
|
"unlockedRoom": "{username} がこのルームのロックを解除しました。",
|
||||||
|
"agreement": "Darkwireを使用することにより、利用規定と利用規約に同意したことになります。",
|
||||||
|
"typePlaceholder": "入力する",
|
||||||
|
"aboutButton": "詳細",
|
||||||
|
"settingsButton": "設定",
|
||||||
|
"settings": "設定",
|
||||||
|
"aboutHeader": "詳細",
|
||||||
|
"copyButtonTooltip": "コピーされた",
|
||||||
|
"welcomeHeader": "Darkwire v2.0へようこそ",
|
||||||
|
"sentFile": "{filename}が送信されました。",
|
||||||
|
"userJoined": "{username}さんが参加しました。",
|
||||||
|
"userLeft": "{username}さんが退室しました。",
|
||||||
|
"userSentFile": "{username}がファイルを送信しました。",
|
||||||
|
"downloadFile": "{filename}をダウンロードする",
|
||||||
|
"nameChange": "{oldUsername}さんが{newUsername}さんに変更されました。",
|
||||||
|
"settingsHeader": "設定 & ヘルプ",
|
||||||
|
"copyRoomHeader": "このルーム",
|
||||||
|
"languageDropdownHeader": "言語",
|
||||||
|
"roomOwnerHeader": "ルーム所有者",
|
||||||
|
"roomOwnerText": "ルームを作成した人はルームの所有者であり、ルームをロックおよびロック解除する機能などの特別な権限を持っています。所有者がルームを離れると、2番目に参加した人に所有権を引き渡されます。同様に、2番目の参加者が離れると3番目の人が所有者となります。リストのユーザー名の横に星のアイコンがあるのは、ルームの所有者となります。",
|
||||||
|
"lockRoomHeader": "ルームをロックする",
|
||||||
|
"lockRoomText": "ルーム所有者の場合は、ナビゲーションバーのロックアイコンをクリックして、部屋をロックおよびロック解除できます。ルームがロックされている場合、他の参加者は参加できません。",
|
||||||
|
"slashCommandsHeader": "コマンド",
|
||||||
|
"slashCommandsText": "利用できるコマンド:",
|
||||||
|
"slashCommandsBullets": [
|
||||||
|
"ユーザーネームを変更する",
|
||||||
|
"アクションを起こす",
|
||||||
|
"履歴をクレアする",
|
||||||
|
"全部のコマンドを表示する"
|
||||||
|
],
|
||||||
|
"sound": "サウンド",
|
||||||
|
"newMessageNotification": "新しいメッセージ通知",
|
||||||
|
"desktopNotification": "デスクトップ通知",
|
||||||
|
"desktopNotificationBlocked": "デスクトップ通知は拒否されました。",
|
||||||
|
"welcomeModalCTA": "OK",
|
||||||
|
"lockedRoomHeader": "このルームはロックされています",
|
||||||
|
"helpTranslate": "翻訳に貢献しよう"
|
||||||
|
}
|
42
client/src/i18n/ko.json
Normal file
42
client/src/i18n/ko.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"newRoomButton": "신규 토크룸",
|
||||||
|
"lockedRoom": "{username}가 이 룸을 잠궜습니다.",
|
||||||
|
"unlockedRoom": "{username}가 이 룸의 잠금을 해제했습니다.",
|
||||||
|
"agreement": "Darkwire을 사용하는 것에 의해, 이용규정과 이용규약에 동의한 것으로 간주됩니다.",
|
||||||
|
"typePlaceholder": "입력하기",
|
||||||
|
"aboutButton": "상세정보",
|
||||||
|
"settingsButton": "설정",
|
||||||
|
"settings": "설정",
|
||||||
|
"aboutHeader": "상세정보",
|
||||||
|
"copyButtonTooltip": "복사 되었습니다.",
|
||||||
|
"welcomeHeader": "Darkwire v2.0에 오신 것을 환영합니다.",
|
||||||
|
"sentFile": "{filename}가 전송되었습니다.",
|
||||||
|
"userJoined": "{username}가 참가하였습니다.",
|
||||||
|
"userLeft": "{username}가 퇴실하였습니다.",
|
||||||
|
"userSentFile": "{username}가 파일을 전송하였습니다.",
|
||||||
|
"downloadFile": "{filename}을 다운로드 하기",
|
||||||
|
"nameChange": "{oldUsername}가{newUsername}로 변경되었습니다.",
|
||||||
|
"settingsHeader": "설정&도움",
|
||||||
|
"copyRoomHeader": "이 룸",
|
||||||
|
"languageDropdownHeader": "언어",
|
||||||
|
"roomOwnerHeader": "룸 소유자",
|
||||||
|
"roomOwnerText": "룸을 작성한 이는 룸의 소유자이며, 소유자에게는 룸 잠금 및 잠금 해제 등의 특별한 권한이 부여됩니다. 소유자가 룸에서 퇴실하면, 2번째 참가자에게 소유권이 양도됩니다. 위와 같이, 2번째 참가자가 룸에서 퇴실하면, 3번째 참가자가 소유자로 지정됩니다. 리스트 유저명 옆에 있는 별 아이콘은, 룸의 소유자를 의미합니다.",
|
||||||
|
"lockRoomHeader": "룸을 잠금",
|
||||||
|
"lockRoomText": "룸 소유자의 경우,네비게이션 바의 잠금 아이콘을 클릭하여 룸을 잠금 및 잠금 해제가 가능합니다. 룸이 잠겨 있을 경우, 다른 참가자는 참가할 수 없습니다.",
|
||||||
|
"slashCommandsHeader": "명령",
|
||||||
|
"slashCommandsText": "이용 가능한 명령:",
|
||||||
|
"slashCommandsBullets": [
|
||||||
|
"유저명을 변경",
|
||||||
|
"액션 실행",
|
||||||
|
"이력 삭제",
|
||||||
|
"모든 명령을 표시"
|
||||||
|
],
|
||||||
|
"sound": "사운드",
|
||||||
|
"newMessageNotification": "새로운 메세지 알림",
|
||||||
|
"desktopNotification": "데스크톱 알림",
|
||||||
|
"desktopNotificationBlocked": "데스크톱 알림이 거부되었습니다.",
|
||||||
|
"welcomeModalCTA": "OK",
|
||||||
|
"lockedRoomHeader": "이 룸은 잠겨있습니다.",
|
||||||
|
"helpTranslate": "번역을 돕기"
|
||||||
|
}
|
||||||
|
|
40
client/src/i18n/nl.json
Normal file
40
client/src/i18n/nl.json
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
{
|
||||||
|
"aboutButton": "Wat betreft",
|
||||||
|
"aboutHeader": "Wat betreft",
|
||||||
|
"agreement": "Door Darkwire te gebruiken, gaat u akkoord met ons Beleid voor acceptabel gebruik en onze Servicevoorwaarden",
|
||||||
|
"copyButtonTooltip": "gekopieerde",
|
||||||
|
"copyRoomHeader": "Deze kamer",
|
||||||
|
"downloadFile": "Download {filename}",
|
||||||
|
"languageDropdownHeader": "Taal",
|
||||||
|
"lockRoomHeader": "Lock Room",
|
||||||
|
"lockRoomText": "Als u de eigenaar van de ruimte bent, kunt u de ruimte vergrendelen en ontgrendelen door op het vergrendelingspictogram in de navigatiebalk te klikken. Wanneer een ruimte is vergrendeld, kunnen geen andere deelnemers deelnemen.",
|
||||||
|
"lockedRoom": "{username} heeft de kamer vergrendeld",
|
||||||
|
"lockedRoomHeader": "Deze kamer is afgesloten",
|
||||||
|
"nameChange": "{oldUsername} heeft hun naam gewijzigd in {newUsername}",
|
||||||
|
"newRoomButton": "Nieuwe kamer",
|
||||||
|
"roomOwnerHeader": "Ruimte-eigendom",
|
||||||
|
"roomOwnerText": "De persoon die de ruimte heeft gemaakt, is de eigenaar van de ruimte en heeft speciale rechten, zoals de mogelijkheid om de ruimte te vergrendelen en ontgrendelen. Als de eigenaar de kamer verlaat, neemt de tweede persoon die deelneemt het eigendom over. Als ze vertrekken, wordt de derde persoon eigenaar, enzovoort. De eigenaar van de ruimte heeft een sterpictogram naast zijn gebruikersnaam in de vervolgkeuzelijst van de deelnemers.",
|
||||||
|
"sentFile": "U heeft {filemame} verzonden",
|
||||||
|
"settings": "instellingen",
|
||||||
|
"settingsButton": "instellingen",
|
||||||
|
"settingsHeader": "Instellingen & Help",
|
||||||
|
"slashCommandsBullets": [
|
||||||
|
"wijzigt gebruikersnaam",
|
||||||
|
"voert een actie uit",
|
||||||
|
"wist uw berichtgeschiedenis",
|
||||||
|
"geeft alle opdrachten weer"
|
||||||
|
],
|
||||||
|
"slashCommandsHeader": "Slash-opdrachten",
|
||||||
|
"slashCommandsText": "De volgende slash-opdrachten zijn beschikbaar:",
|
||||||
|
"sound": "Geluid",
|
||||||
|
"newMessageNotification": "New message notification",
|
||||||
|
"desktopNotification": "Desktop Notification",
|
||||||
|
"typePlaceholder": "Typ hier",
|
||||||
|
"unlockedRoom": "{username} heeft de kamer ontgrendeld",
|
||||||
|
"userJoined": "{username} is lid geworden",
|
||||||
|
"userLeft": "{username} over",
|
||||||
|
"userSentFile": "{username} heeft u een bestand gestuurd.",
|
||||||
|
"welcomeHeader": "Welkom bij Darkwire v2.0",
|
||||||
|
"welcomeModalCTA": "OK",
|
||||||
|
"helpTranslate": "Help ons Darkwire te vertalen!"
|
||||||
|
}
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user