mirror of
https://github.com/darkwire/darkwire.io.git
synced 2025-07-18 10:49:02 +00:00
Add client and server source to monorepo (#65)
This commit is contained in:
parent
8655983a3c
commit
4e038ec655
36
.circleci/config.yml
Normal file
36
.circleci/config.yml
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# Javascript Node CircleCI 2.0 configuration file
|
||||||
|
#
|
||||||
|
# Check https://circleci.com/docs/2.0/language-javascript/ for more details
|
||||||
|
#
|
||||||
|
version: 2
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
docker:
|
||||||
|
# specify the version you desire here
|
||||||
|
- image: circleci/node:8.10
|
||||||
|
|
||||||
|
# Specify service dependencies here if necessary
|
||||||
|
# CircleCI maintains a library of pre-built images
|
||||||
|
# documented at https://circleci.com/docs/2.0/circleci-images/
|
||||||
|
# - image: circleci/mongo:3.4.4
|
||||||
|
|
||||||
|
working_directory: ~/repo
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- checkout
|
||||||
|
|
||||||
|
# Download and cache dependencies
|
||||||
|
- restore_cache:
|
||||||
|
keys:
|
||||||
|
- v1-dependencies-{{ checksum "yarn.lock" }}
|
||||||
|
# fallback to using the latest cache if no exact match is found
|
||||||
|
- v1-dependencies-
|
||||||
|
|
||||||
|
- run: yarn install
|
||||||
|
|
||||||
|
- save_cache:
|
||||||
|
paths:
|
||||||
|
- node_modules
|
||||||
|
key: v1-dependencies-{{ checksum "yarn.lock" }}
|
||||||
|
|
||||||
|
- run: yarn test
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,5 +1,3 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
server
|
|
||||||
client
|
|
||||||
node_modules
|
node_modules
|
||||||
*.log
|
*.log
|
2
build.sh
2
build.sh
@ -1,5 +1,3 @@
|
|||||||
yarn setup
|
|
||||||
|
|
||||||
echo "building client..."
|
echo "building client..."
|
||||||
cd client
|
cd client
|
||||||
yarn --production=false
|
yarn --production=false
|
||||||
|
4
client/.env.example
Normal file
4
client/.env.example
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
REACT_APP_API_HOST=localhost
|
||||||
|
REACT_APP_API_PROTOCOL=http
|
||||||
|
REACT_APP_API_PORT=3001
|
||||||
|
REACT_APP_COMMIT_SHA=some_sha
|
9
client/.gitignore
vendored
Normal file
9
client/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
coverage
|
||||||
|
*.log
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
build/
|
||||||
|
*sublime*
|
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.
|
3
client/README.md
Normal file
3
client/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Darkwire Client
|
||||||
|
|
||||||
|
This is the client for [Darkwire](https://github.com/darkwire/darkwire.io). It requires [darkwire-server](../server) in order to run.
|
1
client/__mocks__/styles.js
Normal file
1
client/__mocks__/styles.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
module.exports = {};
|
8
client/jsconfig.json
Normal file
8
client/jsconfig.json
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": "src",
|
||||||
|
"paths": {
|
||||||
|
"@/*":["src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
69
client/package.json
Normal file
69
client/package.json
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
{
|
||||||
|
"name": "darkwire-client",
|
||||||
|
"version": "2.0.0-beta.12",
|
||||||
|
"main": "index.js",
|
||||||
|
"contributors": [
|
||||||
|
{
|
||||||
|
"name": "Daniel Seripap"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alan Friedman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"autosize": "^4.0.2",
|
||||||
|
"bootstrap": "^4.3.1",
|
||||||
|
"clipboard": "^2.0.4",
|
||||||
|
"feather-icons": "^4.21.0",
|
||||||
|
"jquery": "^3.4.1",
|
||||||
|
"lodash": "^4.17.11",
|
||||||
|
"moment": "^2.24.0",
|
||||||
|
"node-sass": "^4.12.0",
|
||||||
|
"popper.js": "^1.15.0",
|
||||||
|
"query-string": "^6.5.0",
|
||||||
|
"randomcolor": "^0.5.4",
|
||||||
|
"react": "^16.8.6",
|
||||||
|
"react-dom": "^16.8.6",
|
||||||
|
"react-feather": "^1.1.6",
|
||||||
|
"react-linkify": "^0.2.2",
|
||||||
|
"react-modal": "^3.8.1",
|
||||||
|
"react-redux": "^7.0.3",
|
||||||
|
"react-router-dom": "^5.0.0",
|
||||||
|
"react-scripts": "3.0.0",
|
||||||
|
"react-simple-dropdown": "^3.2.3",
|
||||||
|
"redux": "^4.0.1",
|
||||||
|
"redux-thunk": "^2.3.0",
|
||||||
|
"sanitize-html": "^1.20.1",
|
||||||
|
"shortid": "^2.2.14",
|
||||||
|
"socket.io-client": "^2.2.0",
|
||||||
|
"tinycon": "^0.6.8",
|
||||||
|
"webcrypto-shim": "^0.1.4"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"start": "react-scripts start",
|
||||||
|
"build": "REACT_APP_COMMIT_SHA=`git rev-parse --short HEAD` react-scripts build",
|
||||||
|
"test": "react-scripts test",
|
||||||
|
"eject": "react-scripts eject"
|
||||||
|
},
|
||||||
|
"eslintConfig": {
|
||||||
|
"extends": "react-app"
|
||||||
|
},
|
||||||
|
"browserslist": {
|
||||||
|
"production": [
|
||||||
|
">0.2%",
|
||||||
|
"not dead",
|
||||||
|
"not op_mini all"
|
||||||
|
],
|
||||||
|
"development": [
|
||||||
|
"last 1 chrome version",
|
||||||
|
"last 1 firefox version",
|
||||||
|
"last 1 safari version"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"enzyme": "^3.9.0",
|
||||||
|
"enzyme-adapter-react-16": "^1.12.1",
|
||||||
|
"enzyme-to-json": "^3.3.5"
|
||||||
|
}
|
||||||
|
}
|
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 |
41
client/public/index.html
Normal file
41
client/public/index.html
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en" class='h-100'>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.ico" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="theme-color" content="#000000" />
|
||||||
|
<!--
|
||||||
|
manifest.json provides metadata used when your web app is installed on a
|
||||||
|
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||||
|
-->
|
||||||
|
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||||
|
<!--
|
||||||
|
Notice the use of %PUBLIC_URL% in the tags above.
|
||||||
|
It will be replaced with the URL of the `public` folder during the build.
|
||||||
|
Only files inside the `public` folder can be referenced from the HTML.
|
||||||
|
|
||||||
|
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||||
|
work correctly both with client-side routing and a non-root public URL.
|
||||||
|
Learn how to configure a non-root public URL by running `npm run build`.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<meta name="robots" content="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'>
|
||||||
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
|
<div id="root" class="h-100"></div>
|
||||||
|
<!--
|
||||||
|
This HTML file is a template.
|
||||||
|
If you open it directly in the browser, you will see an empty page.
|
||||||
|
|
||||||
|
You can add webfonts, meta tags, or analytics to this file.
|
||||||
|
The build step will place the bundled scripts into the <body> tag.
|
||||||
|
|
||||||
|
To begin the development, run `npm start` or `yarn start`.
|
||||||
|
To create a production bundle, use `npm run build` or `yarn build`.
|
||||||
|
-->
|
||||||
|
</body>
|
||||||
|
</html>
|
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"
|
||||||
|
}
|
20
client/src/actions/app.js
Normal file
20
client/src/actions/app.js
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
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 toggleSocketConnected = payload => async (dispatch) => {
|
||||||
|
dispatch({ type: 'TOGGLE_SOCKET_CONNECTED', payload })
|
||||||
|
}
|
12
client/src/actions/fetch.js
Normal file
12
client/src/actions/fetch.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
const methodMap = {
|
||||||
|
GET: '',
|
||||||
|
POST: 'CREATE_',
|
||||||
|
PUT: 'UPDATE_',
|
||||||
|
DELETE: 'DELETE_',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const fetchStart = (name, method, resourceId, meta) => ({ type: `FETCH_${methodMap[method]}${name.toUpperCase()}_START`, payload: { resourceId, meta } })
|
||||||
|
|
||||||
|
export const fetchSuccess = (name, method, response) => ({ type: `FETCH_${methodMap[method]}${name.toUpperCase()}_SUCCESS`, payload: response })
|
||||||
|
|
||||||
|
export const fetchFailure = (name, method, response) => ({ type: `FETCH_${methodMap[method]}${name.toUpperCase()}_FAILURE`, payload: response })
|
4
client/src/actions/index.js
Normal file
4
client/src/actions/index.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export * from './fetch'
|
||||||
|
export * from './room'
|
||||||
|
export * from './app'
|
||||||
|
|
98
client/src/actions/room.js
Normal file
98
client/src/actions/room.js
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import fetch from 'api'
|
||||||
|
import isEqual from 'lodash/isEqual'
|
||||||
|
import {
|
||||||
|
process as processMessage,
|
||||||
|
prepare as prepareMessage,
|
||||||
|
} from 'utils/message'
|
||||||
|
import { getIO } from 'utils/socket'
|
||||||
|
|
||||||
|
export const createRoom = id => async dispatch => fetch({
|
||||||
|
resourceName: 'handshake',
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
roomId: id,
|
||||||
|
},
|
||||||
|
}, dispatch, 'handshake')
|
||||||
|
|
||||||
|
export const receiveSocketMessage = payload => async (dispatch, getState) => {
|
||||||
|
const state = getState()
|
||||||
|
const message = await processMessage(payload, state)
|
||||||
|
// Pass current state to all HANDLE_SOCKET_MESSAGE reducers for convenience, since each may have different needs
|
||||||
|
dispatch({ type: `HANDLE_SOCKET_MESSAGE_${message.type}`, payload: { payload: message.payload, state } })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createUser = payload => async (dispatch) => {
|
||||||
|
dispatch({ type: 'CREATE_USER', payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendUserEnter = payload => async () => {
|
||||||
|
getIO().emit('USER_ENTER', {
|
||||||
|
publicKey: payload.publicKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const receiveUserExit = payload => async (dispatch, getState) => {
|
||||||
|
const state = getState()
|
||||||
|
const exitingUser = state.room.members.find(m => !payload.map(p => JSON.stringify(p.publicKey)).includes(JSON.stringify(m.publicKey)))
|
||||||
|
const exitingUserId = exitingUser.id
|
||||||
|
const exitingUsername = exitingUser.username
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'USER_EXIT',
|
||||||
|
payload: {
|
||||||
|
members: payload,
|
||||||
|
id: exitingUserId,
|
||||||
|
username: exitingUsername,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const receiveUserEnter = payload => async (dispatch) => {
|
||||||
|
dispatch({ type: 'USER_ENTER', payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const onFileTransfer = payload => async (dispatch) => {
|
||||||
|
dispatch({ type: 'PREFLIGHT_FILE_TRANSFER', payload })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendSocketMessage = payload => async (dispatch, getState) => {
|
||||||
|
const state = getState()
|
||||||
|
const msg = await prepareMessage(payload, state)
|
||||||
|
dispatch({ type: `SEND_SOCKET_MESSAGE_${msg.original.type}`, payload: msg.original.payload })
|
||||||
|
getIO().emit('PAYLOAD', msg.toSend)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const toggleLockRoom = () => async (dispatch, getState) => {
|
||||||
|
const state = getState()
|
||||||
|
getIO().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 receiveToggleLockRoom = payload => async (dispatch, getState) => {
|
||||||
|
const state = getState()
|
||||||
|
|
||||||
|
const lockedByUser = state.room.members.find(m => isEqual(m.publicKey, payload.publicKey))
|
||||||
|
const lockedByUsername = lockedByUser.username
|
||||||
|
const lockedByUserId = lockedByUser.id
|
||||||
|
|
||||||
|
dispatch({
|
||||||
|
type: 'RECEIVE_TOGGLE_LOCK_ROOM',
|
||||||
|
payload: {
|
||||||
|
username: lockedByUsername,
|
||||||
|
locked: payload.locked,
|
||||||
|
id: lockedByUserId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const clearActivities = () => async (dispatch) => {
|
||||||
|
dispatch({ type: 'CLEAR_ACTIVITIES' })
|
||||||
|
}
|
26
client/src/api/config.js
Normal file
26
client/src/api/config.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
let host
|
||||||
|
let protocol
|
||||||
|
let port
|
||||||
|
|
||||||
|
switch (process.env.NODE_ENV) {
|
||||||
|
case 'staging':
|
||||||
|
host = process.env.REACT_APP_API_HOST
|
||||||
|
protocol = process.env.REACT_APP_API_PROTOCOL || 'https'
|
||||||
|
port = process.env.REACT_APP_API_PORT || 443
|
||||||
|
break
|
||||||
|
case 'production':
|
||||||
|
host = process.env.REACT_APP_API_HOST
|
||||||
|
protocol = process.env.REACT_APP_API_PROTOCOL || 'https'
|
||||||
|
port = process.env.REACT_APP_API_PORT || 443
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
host = process.env.REACT_APP_API_HOST || 'localhost'
|
||||||
|
protocol = process.env.REACT_APP_API_PROTOCOL || 'http'
|
||||||
|
port = process.env.REACT_APP_API_PORT || 3001
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
protocol,
|
||||||
|
}
|
13
client/src/api/generator.js
Normal file
13
client/src/api/generator.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import config from './config'
|
||||||
|
|
||||||
|
export default (resourceName = '') => {
|
||||||
|
const { port, protocol, host } = config
|
||||||
|
|
||||||
|
const resourcePath = resourceName
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
return `/${resourcePath}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${protocol}://${host}:${port}/${resourcePath}`
|
||||||
|
}
|
59
client/src/api/index.js
Normal file
59
client/src/api/index.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
fetchStart,
|
||||||
|
fetchSuccess,
|
||||||
|
fetchFailure,
|
||||||
|
} from 'actions'
|
||||||
|
import queryString from 'querystring'
|
||||||
|
import generateUrl from './generator'
|
||||||
|
|
||||||
|
export default (opts, dispatch, name, metaOpts = {}) => {
|
||||||
|
const method = opts.method || 'GET'
|
||||||
|
const resourceId = opts.resourceId
|
||||||
|
let url = generateUrl(opts.resourceName, resourceId)
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
method,
|
||||||
|
headers: {},
|
||||||
|
type: 'cors',
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.body) {
|
||||||
|
config.body = JSON.stringify(opts.body)
|
||||||
|
config.headers['Content-Type'] = 'application/json'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (opts.query) {
|
||||||
|
url = `${url}?${queryString.stringify(opts.query)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const meta = { ...metaOpts, timestamp: Date.now() }
|
||||||
|
dispatch(fetchStart(name, method, resourceId, meta))
|
||||||
|
return window.fetch(url, config)
|
||||||
|
.then(async (response) => {
|
||||||
|
let json = {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
json = await response.json()
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(e)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dispatchOps = {
|
||||||
|
response,
|
||||||
|
json,
|
||||||
|
resourceId,
|
||||||
|
meta,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
dispatch(fetchSuccess(name, method, dispatchOps))
|
||||||
|
return resolve(dispatchOps)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatch(fetchFailure(name, method, dispatchOps))
|
||||||
|
|
||||||
|
return reject(dispatchOps)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
BIN
client/src/audio/beep.mp3
Normal file
BIN
client/src/audio/beep.mp3
Normal file
Binary file not shown.
198
client/src/components/About/index.js
Normal file
198
client/src/components/About/index.js
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
import React, { Component } from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import apiUrlGenerator from '../../api/generator';
|
||||||
|
|
||||||
|
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>
|
||||||
|
<h4>Version</h4>
|
||||||
|
<p><strong>Client</strong>
|
||||||
|
Commit SHA: <a target="_blank" href={`https://github.com/darkwire/darkwire-client/commit/${process.env.REACT_APP_COMMIT_SHA}`}>{process.env.REACT_APP_COMMIT_SHA}</a></p>
|
||||||
|
<p><strong>Server</strong>
|
||||||
|
Commit SHA: <a target="_blank" href={`https://github.com/darkwire/darkwire-server/commit/${this.props.serverSHA}`}>{this.props.serverSHA}</a></p>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<h4>Report Abuse</h4>
|
||||||
|
<p>To report any content that violates our Acceptable Use Policy below, 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>
|
||||||
|
<input placeholder='Room ID' onChange={this.handleUpdateRoomId.bind(this)} value={this.state.roomId} type="text"/>
|
||||||
|
<button>Submit</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<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.
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<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 />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<br />
|
||||||
|
|
||||||
|
<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>0xD6e3D881036903999E2c0480fe9d2c20600C1c28</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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
About.propTypes = {
|
||||||
|
serverSHA: PropTypes.string.isRequired,
|
||||||
|
roomId: PropTypes.string.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default About
|
17
client/src/components/Chat/Chat.test.js
Normal file
17
client/src/components/Chat/Chat.test.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import { mount } from 'enzyme'
|
||||||
|
import toJson from 'enzyme-to-json'
|
||||||
|
import { Chat } from './index.js'
|
||||||
|
|
||||||
|
const sendSocketMessage = jest.fn()
|
||||||
|
|
||||||
|
test('Chat Component', () => {
|
||||||
|
const component = mount(
|
||||||
|
<Chat scrollToBottom={() => {}} focusChat={false} userId="foo" username="user" showNotice={() => {}} clearActivities={() => {}} sendSocketMessage={sendSocketMessage} />
|
||||||
|
)
|
||||||
|
|
||||||
|
const componentJSON = toJson(component)
|
||||||
|
|
||||||
|
expect(component).toMatchSnapshot()
|
||||||
|
expect(componentJSON.children.length).toBe(1)
|
||||||
|
})
|
@ -0,0 +1,3 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Chat Component 1`] = `ReactWrapper {}`;
|
294
client/src/components/Chat/index.js
Normal file
294
client/src/components/Chat/index.js
Normal file
@ -0,0 +1,294 @@
|
|||||||
|
import React, { Component } from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import sanitizeHtml from 'sanitize-html'
|
||||||
|
import FileTransfer from 'components/FileTransfer'
|
||||||
|
import { CornerDownRight } from 'react-feather'
|
||||||
|
import { connect } from 'react-redux'
|
||||||
|
import { clearActivities, showNotice } from '../../actions'
|
||||||
|
import { getSelectedText, hasTouchSupport } from '../../utils/dom'
|
||||||
|
// Disable for now
|
||||||
|
// import autosize from 'autosize'
|
||||||
|
|
||||||
|
export class Chat extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = {
|
||||||
|
message: '',
|
||||||
|
touchSupport: hasTouchSupport,
|
||||||
|
shiftKeyDown: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.commands = [{
|
||||||
|
command: 'nick',
|
||||||
|
description: 'Changes nickname.',
|
||||||
|
paramaters: ['{username}'],
|
||||||
|
usage: '/nick {username}',
|
||||||
|
scope: 'global',
|
||||||
|
action: (params) => { // eslint-disable-line
|
||||||
|
let newUsername = params.join(' ') || '' // eslint-disable-line
|
||||||
|
|
||||||
|
// Remove things that arent 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.sendSocketMessage({
|
||||||
|
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.sendSocketMessage({
|
||||||
|
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, evaulate 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.sendSocketMessage({
|
||||||
|
type: 'SEND_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="Type here"
|
||||||
|
onChange={this.handleInputChange.bind(this)} />
|
||||||
|
<div className="input-controls">
|
||||||
|
<FileTransfer sendSocketMessage={this.props.sendSocketMessage} />
|
||||||
|
{touchSupport &&
|
||||||
|
<button onClick={this.handleSendClick.bind(this)} className={`icon is-right send btn btn-link ${this.canSend() ? 'active' : ''}`}>
|
||||||
|
<CornerDownRight className={this.canSend() ? '' : 'disabled'} />
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Chat.propTypes = {
|
||||||
|
sendSocketMessage: 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapStateToProps = state => ({
|
||||||
|
username: state.user.username,
|
||||||
|
userId: state.user.id,
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
clearActivities,
|
||||||
|
showNotice,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(Chat)
|
11
client/src/components/Connecting/index.js
Normal file
11
client/src/components/Connecting/index.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React, { Component } from 'react'
|
||||||
|
|
||||||
|
export default class Connecting extends Component {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
Please wait while we secure a connection to Darkwire...
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
119
client/src/components/FileTransfer/index.js
Normal file
119
client/src/components/FileTransfer/index.js
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import React, { Component } from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import uuid from 'uuid'
|
||||||
|
import { File } from 'react-feather'
|
||||||
|
import { sanitize } from 'utils'
|
||||||
|
import {styles} from './styles.module.scss'
|
||||||
|
|
||||||
|
const VALID_FILE_TYPES = ['png', 'jpg', 'jpeg', 'gif', 'zip', 'rar', 'gzip', 'pdf', 'txt', 'json', 'doc', 'docx', 'csv', 'js', 'html', 'css']
|
||||||
|
|
||||||
|
export default class FileTransfer extends Component {
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
this.state = {
|
||||||
|
supported: true,
|
||||||
|
localFileQueue: [],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillMount() {
|
||||||
|
if (!window.File && !window.FileReader && !window.FileList && !window.Blob && !window.btoa && !window.atob && !window.Blob && !window.URL) {
|
||||||
|
this.setState({
|
||||||
|
supported: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this._fileInput.addEventListener('change', this.confirmFileTransfer.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
async 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))
|
||||||
|
this._fileInput.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
reader.readAsBinaryString(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async confirmFileTransfer(event) {
|
||||||
|
const file = event.target.files && event.target.files[0]
|
||||||
|
|
||||||
|
if (file) {
|
||||||
|
const fileType = file.type || 'file'
|
||||||
|
const fileName = sanitize(file.name)
|
||||||
|
const { localFileQueue } = this.state
|
||||||
|
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 > 4 * 1000000) {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
alert('Max filesize is 4MB')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileId = uuid.v4()
|
||||||
|
const fileData = {
|
||||||
|
id: fileId,
|
||||||
|
file,
|
||||||
|
fileName,
|
||||||
|
fileType,
|
||||||
|
encodedFile: await this.encodeFile(file),
|
||||||
|
}
|
||||||
|
|
||||||
|
localFileQueue.push(fileData)
|
||||||
|
|
||||||
|
this.setState({
|
||||||
|
localFileQueue,
|
||||||
|
}, async () => {
|
||||||
|
this.props.sendSocketMessage({
|
||||||
|
type: 'SEND_FILE',
|
||||||
|
payload: {
|
||||||
|
fileName: fileData.fileName,
|
||||||
|
encodedFile: fileData.encodedFile,
|
||||||
|
fileType: fileData.fileType,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
canSend() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!this.state.supported) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className={`${styles} icon file-transfer btn btn-link`}>
|
||||||
|
<input type="file" name="fileUploader" id="fileInput" ref={c => this._fileInput = c} />
|
||||||
|
<label htmlFor="fileInput">
|
||||||
|
<File className={this.canSend() ? '' : 'disabled'} />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileTransfer.propTypes = {
|
||||||
|
sendSocketMessage: PropTypes.func.isRequired,
|
||||||
|
}
|
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;
|
||||||
|
}
|
||||||
|
}
|
457
client/src/components/Home/index.js
Normal file
457
client/src/components/Home/index.js
Normal file
@ -0,0 +1,457 @@
|
|||||||
|
import React, { Component } from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import Crypto from 'utils/crypto'
|
||||||
|
import { connect } from 'utils/socket'
|
||||||
|
import Nav from 'components/Nav'
|
||||||
|
import shortId from 'shortid'
|
||||||
|
import ChatInput from 'containers/Chat'
|
||||||
|
import Connecting from 'components/Connecting'
|
||||||
|
import Message from 'components/Message'
|
||||||
|
import Username from 'components/Username'
|
||||||
|
import Notice from 'components/Notice'
|
||||||
|
import Modal from 'react-modal'
|
||||||
|
import About from 'components/About'
|
||||||
|
import Settings from 'components/Settings'
|
||||||
|
import Welcome from 'components/Welcome'
|
||||||
|
import RoomLocked from 'components/RoomLocked'
|
||||||
|
import { X, AlertCircle } from 'react-feather'
|
||||||
|
import { defer } from 'lodash'
|
||||||
|
import Tinycon from 'tinycon'
|
||||||
|
import beepFile from 'audio/beep.mp3'
|
||||||
|
import Zoom from 'utils/ImageZoom'
|
||||||
|
import classNames from 'classnames'
|
||||||
|
import { getObjectUrl } from 'utils/file'
|
||||||
|
|
||||||
|
import styles from './styles.module.scss'
|
||||||
|
|
||||||
|
const crypto = new Crypto()
|
||||||
|
|
||||||
|
Modal.setAppElement('#root');
|
||||||
|
|
||||||
|
export default class Home extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
zoomableImages: [],
|
||||||
|
focusChat: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.hasConnected = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async componentWillMount() {
|
||||||
|
const roomId = encodeURI(this.props.match.params.roomId)
|
||||||
|
|
||||||
|
const res = await this.props.createRoom(roomId)
|
||||||
|
|
||||||
|
if (res.json.isLocked) {
|
||||||
|
this.props.openModal('Room Locked')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.json.size === 1) {
|
||||||
|
this.props.openModal('Welcome')
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await this.createUser()
|
||||||
|
|
||||||
|
const io = connect(roomId)
|
||||||
|
|
||||||
|
const disconnectEvents = [
|
||||||
|
'reconnect_failed',
|
||||||
|
'connect_timeout',
|
||||||
|
'connect_error',
|
||||||
|
'disconnect',
|
||||||
|
'reconnect',
|
||||||
|
'reconnect_error',
|
||||||
|
'reconnecting',
|
||||||
|
'reconnect_attempt',
|
||||||
|
]
|
||||||
|
|
||||||
|
disconnectEvents.forEach((evt) => {
|
||||||
|
io.on(evt, () => {
|
||||||
|
this.props.toggleSocketConnected(false)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const connectEvents = [
|
||||||
|
'connect',
|
||||||
|
'reconnect',
|
||||||
|
]
|
||||||
|
|
||||||
|
connectEvents.forEach((evt) => {
|
||||||
|
io.on(evt, () => {
|
||||||
|
if (evt === 'connect') {
|
||||||
|
if (!this.hasConnected) {
|
||||||
|
this.initApp(user)
|
||||||
|
this.hasConnected = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.props.toggleSocketConnected(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
io.on('USER_ENTER', (payload) => {
|
||||||
|
this.props.receiveUserEnter(payload)
|
||||||
|
this.props.sendSocketMessage({
|
||||||
|
type: 'ADD_USER',
|
||||||
|
payload: {
|
||||||
|
username: this.props.username,
|
||||||
|
publicKey: this.props.publicKey,
|
||||||
|
isOwner: this.props.iAmOwner,
|
||||||
|
id: this.props.userId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
io.on('USER_EXIT', (payload) => {
|
||||||
|
this.props.receiveUserExit(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
io.on('PAYLOAD', (payload) => {
|
||||||
|
this.props.receiveSocketMessage(payload)
|
||||||
|
})
|
||||||
|
|
||||||
|
io.on('TOGGLE_LOCK_ROOM', (payload) => {
|
||||||
|
this.props.receiveToggleLockRoom(payload)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidMount() {
|
||||||
|
this.bindEvents()
|
||||||
|
|
||||||
|
if (this.props.joining) {
|
||||||
|
this.props.openModal('Connecting')
|
||||||
|
}
|
||||||
|
|
||||||
|
this.beep = window.Audio && new window.Audio(beepFile)
|
||||||
|
}
|
||||||
|
|
||||||
|
componentWillReceiveProps(nextProps) {
|
||||||
|
if (this.props.joining && !nextProps.joining) {
|
||||||
|
this.props.closeModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
Tinycon.setBubble(nextProps.faviconCount)
|
||||||
|
|
||||||
|
if (nextProps.faviconCount !== 0 && this.props.soundIsEnabled) {
|
||||||
|
this.beep.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
if (prevProps.activities.length < this.props.activities.length) {
|
||||||
|
this.scrollToBottomIfShould()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onScroll() {
|
||||||
|
const messageStreamHeight = this.messageStream.clientHeight
|
||||||
|
const activitiesListHeight = this.activitiesList.clientHeight
|
||||||
|
|
||||||
|
const bodyRect = document.body.getBoundingClientRect()
|
||||||
|
const elemRect = this.activitiesList.getBoundingClientRect()
|
||||||
|
const offset = elemRect.top - bodyRect.top
|
||||||
|
const activitiesListYPos = offset
|
||||||
|
|
||||||
|
const scrolledToBottom = (activitiesListHeight + (activitiesListYPos - 60)) <= messageStreamHeight
|
||||||
|
if (scrolledToBottom) {
|
||||||
|
if (!this.props.scrolledToBottom) {
|
||||||
|
this.props.setScrolledToBottom(true)
|
||||||
|
}
|
||||||
|
} else if (this.props.scrolledToBottom) {
|
||||||
|
this.props.setScrolledToBottom(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFileDisplay(activity) {
|
||||||
|
const type = activity.fileType
|
||||||
|
if (type.match('image.*')) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
ref={c => this._zoomableImage = c}
|
||||||
|
className="image-transfer zoomable"
|
||||||
|
src={`data:${activity.fileType};base64,${activity.encodedFile}`}
|
||||||
|
alt={`${activity.fileName} from ${activity.username}`}
|
||||||
|
onLoad={this.handleImageDisplay.bind(this)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
getActivityComponent(activity) {
|
||||||
|
switch (activity.type) {
|
||||||
|
case 'SEND_MESSAGE':
|
||||||
|
return (
|
||||||
|
<Message
|
||||||
|
sender={activity.username}
|
||||||
|
message={activity.text}
|
||||||
|
timestamp={activity.timestamp}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
case 'USER_ENTER':
|
||||||
|
return (
|
||||||
|
<Notice>
|
||||||
|
<div><Username username={activity.username} /> joined</div>
|
||||||
|
</Notice>
|
||||||
|
)
|
||||||
|
case 'USER_EXIT':
|
||||||
|
return (
|
||||||
|
<Notice>
|
||||||
|
<div><Username username={activity.username} /> left</div>
|
||||||
|
</Notice>
|
||||||
|
)
|
||||||
|
case 'TOGGLE_LOCK_ROOM':
|
||||||
|
const lockedWord = activity.locked ? 'locked' : 'unlocked'
|
||||||
|
return (
|
||||||
|
<Notice>
|
||||||
|
<div><Username username={activity.username} /> {lockedWord} the room</div>
|
||||||
|
</Notice>
|
||||||
|
)
|
||||||
|
case 'NOTICE':
|
||||||
|
return (
|
||||||
|
<Notice>
|
||||||
|
<div>{activity.message}</div>
|
||||||
|
</Notice>
|
||||||
|
)
|
||||||
|
case 'CHANGE_USERNAME':
|
||||||
|
return (
|
||||||
|
<Notice>
|
||||||
|
<div><Username username={activity.currentUsername} /> changed their name to <Username username={activity.newUsername} /></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>
|
||||||
|
<Username username={activity.username} /> sent you a file. <a target="_blank" href={downloadUrl} rel="noopener noreferrer">Download {activity.fileName}</a>
|
||||||
|
{this.getFileDisplay(activity)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
case 'SEND_FILE':
|
||||||
|
const url = getObjectUrl(activity.encodedFile, activity.fileType)
|
||||||
|
return (
|
||||||
|
<Notice>
|
||||||
|
<div>You sent <a target="_blank" href={url} rel="noopener noreferrer">{activity.fileName}</a></div>
|
||||||
|
{this.getFileDisplay(activity)}
|
||||||
|
</Notice>
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getModal() {
|
||||||
|
switch (this.props.modalComponent) {
|
||||||
|
case 'Connecting':
|
||||||
|
return {
|
||||||
|
component: <Connecting />,
|
||||||
|
title: 'Connecting...',
|
||||||
|
preventClose: true,
|
||||||
|
}
|
||||||
|
case 'About':
|
||||||
|
return {
|
||||||
|
component: <About roomId={this.props.roomId} serverSHA={this.props.serverSHA} />,
|
||||||
|
title: 'About',
|
||||||
|
}
|
||||||
|
case 'Settings':
|
||||||
|
return {
|
||||||
|
component: <Settings roomId={this.props.roomId} toggleSoundEnabled={this.props.toggleSoundEnabled} soundIsEnabled={this.props.soundIsEnabled} />,
|
||||||
|
title: 'Settings & Help',
|
||||||
|
}
|
||||||
|
case 'Welcome':
|
||||||
|
return {
|
||||||
|
component: <Welcome roomId={this.props.roomId} close={this.props.closeModal} />,
|
||||||
|
title: 'Welcome to Darkwire v2.0',
|
||||||
|
}
|
||||||
|
case 'Room Locked':
|
||||||
|
return {
|
||||||
|
component: <RoomLocked />,
|
||||||
|
title: 'This room is locked',
|
||||||
|
preventClose: true,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
component: null,
|
||||||
|
title: null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
initApp(user) {
|
||||||
|
this.props.sendUserEnter({
|
||||||
|
publicKey: user.publicKey,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleImageDisplay() {
|
||||||
|
Zoom(this._zoomableImage)
|
||||||
|
this.scrollToBottomIfShould()
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottomIfShould() {
|
||||||
|
if (this.props.scrolledToBottom) {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.messageStream.scrollTop = this.messageStream.scrollHeight
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToBottom() {
|
||||||
|
this.messageStream.scrollTop = this.messageStream.scrollHeight
|
||||||
|
this.props.setScrolledToBottom(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
this.messageStream.addEventListener('scroll', this.onScroll.bind(this))
|
||||||
|
|
||||||
|
window.onfocus = () => {
|
||||||
|
this.props.toggleWindowFocus(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.onblur = () => {
|
||||||
|
this.props.toggleWindowFocus(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createUser() {
|
||||||
|
return new Promise(async (resolve) => {
|
||||||
|
const username = shortId.generate()
|
||||||
|
|
||||||
|
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,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChatClick() {
|
||||||
|
this.setState({ focusChat: true })
|
||||||
|
defer(() => this.setState({ focusChat: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
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> Disconnected
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<Nav
|
||||||
|
members={this.props.members}
|
||||||
|
roomId={this.props.roomId}
|
||||||
|
roomLocked={this.props.roomLocked}
|
||||||
|
toggleLockRoom={this.props.toggleLockRoom}
|
||||||
|
openModal={this.props.openModal}
|
||||||
|
iAmOwner={this.props.iAmOwner}
|
||||||
|
userId={this.props.userId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="main-chat">
|
||||||
|
<div onClick={this.handleChatClick.bind(this)} className="message-stream h-100" ref={el => this.messageStream = el}>
|
||||||
|
<ul className="plain" ref={el => this.activitiesList = el}>
|
||||||
|
<li><p className={styles.tos}><button className='btn btn-link' onClick={this.props.openModal.bind(this, 'About')}> By using Darkwire, you are agreeing to our Acceptable Use Policy and Terms of Service.</button></p></li>
|
||||||
|
{this.props.activities.map((activity, index) => (
|
||||||
|
<li key={index} className={`activity-item ${activity.type}`}>
|
||||||
|
{this.getActivityComponent(activity)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="chat-container">
|
||||||
|
<ChatInput scrollToBottom={this.scrollToBottom.bind(this)} focusChat={this.state.focusChat} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<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 = {
|
||||||
|
createRoom: PropTypes.func.isRequired,
|
||||||
|
receiveSocketMessage: PropTypes.func.isRequired,
|
||||||
|
sendSocketMessage: PropTypes.func.isRequired,
|
||||||
|
createUser: PropTypes.func.isRequired,
|
||||||
|
receiveUserExit: PropTypes.func.isRequired,
|
||||||
|
receiveUserEnter: PropTypes.func.isRequired,
|
||||||
|
activities: PropTypes.array.isRequired,
|
||||||
|
username: PropTypes.string.isRequired,
|
||||||
|
publicKey: PropTypes.object.isRequired,
|
||||||
|
members: PropTypes.array.isRequired,
|
||||||
|
match: PropTypes.object.isRequired,
|
||||||
|
roomId: PropTypes.string.isRequired,
|
||||||
|
roomLocked: PropTypes.bool.isRequired,
|
||||||
|
toggleLockRoom: PropTypes.func.isRequired,
|
||||||
|
receiveToggleLockRoom: PropTypes.func.isRequired,
|
||||||
|
modalComponent: PropTypes.string,
|
||||||
|
openModal: PropTypes.func.isRequired,
|
||||||
|
closeModal: PropTypes.func.isRequired,
|
||||||
|
setScrolledToBottom: PropTypes.func.isRequired,
|
||||||
|
scrolledToBottom: PropTypes.bool.isRequired,
|
||||||
|
iAmOwner: PropTypes.bool.isRequired,
|
||||||
|
sendUserEnter: PropTypes.func.isRequired,
|
||||||
|
userId: PropTypes.string.isRequired,
|
||||||
|
joining: PropTypes.bool.isRequired,
|
||||||
|
toggleWindowFocus: PropTypes.func.isRequired,
|
||||||
|
faviconCount: PropTypes.number.isRequired,
|
||||||
|
soundIsEnabled: PropTypes.bool.isRequired,
|
||||||
|
toggleSoundEnabled: PropTypes.func.isRequired,
|
||||||
|
serverSHA: PropTypes.string.isRequired,
|
||||||
|
toggleSocketConnected: PropTypes.func.isRequired,
|
||||||
|
socketConnected: PropTypes.bool.isRequired,
|
||||||
|
}
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
36
client/src/components/Message/index.js
Normal file
36
client/src/components/Message/index.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import React, { Component } from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import Username from 'components/Username'
|
||||||
|
import moment from 'moment'
|
||||||
|
import Linkify from 'react-linkify'
|
||||||
|
|
||||||
|
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
|
164
client/src/components/Nav/index.js
Normal file
164
client/src/components/Nav/index.js
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
import React, { Component } from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import shortId from 'shortid'
|
||||||
|
import { Info, Settings, PlusCircle, User, Users, Lock, Unlock, Star } from 'react-feather'
|
||||||
|
import logoImg from 'img/logo.png'
|
||||||
|
import Dropdown, { DropdownTrigger, DropdownContent } from 'react-simple-dropdown'
|
||||||
|
import Username from 'components/Username'
|
||||||
|
import Clipboard from 'clipboard'
|
||||||
|
import $ from 'jquery';
|
||||||
|
|
||||||
|
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(`/${shortId.generate()}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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="Copied"
|
||||||
|
data-clipboard-text={`https://darkwire.io/${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">
|
||||||
|
<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>New Room</span></button>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<button onClick={this.handleSettingsClick.bind(this)} className="btn btn-plain nav-link"><Settings /> <span>Settings</span></button>
|
||||||
|
</li>
|
||||||
|
<li className="nav-item">
|
||||||
|
<button onClick={this.handleAboutClick.bind(this)} className="btn btn-plain nav-link"><Info /> <span>About</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,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Nav
|
14
client/src/components/Notice/Notice.test.js
Normal file
14
client/src/components/Notice/Notice.test.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import renderer from 'react-test-renderer'
|
||||||
|
import Notice from './index.js'
|
||||||
|
import { mount } from 'enzyme'
|
||||||
|
|
||||||
|
test.skip('Notice Component', () => {
|
||||||
|
const component = mount(
|
||||||
|
<Notice>
|
||||||
|
<div>Hello world</div>
|
||||||
|
</Notice>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(component).toMatchSnapshot()
|
||||||
|
})
|
@ -0,0 +1,15 @@
|
|||||||
|
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||||
|
|
||||||
|
exports[`Notice Component 1`] = `
|
||||||
|
<div
|
||||||
|
className={undefined}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="info"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
Hello world
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
26
client/src/components/Notice/index.js
Normal file
26
client/src/components/Notice/index.js
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
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
|
||||||
|
}
|
65
client/src/components/RoomLink/index.js
Normal file
65
client/src/components/RoomLink/index.js
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
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: `https://darkwire.io/${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() {
|
||||||
|
$('.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} />
|
||||||
|
<span className="input-group-btn">
|
||||||
|
<button
|
||||||
|
className="copy-room btn btn-secondary"
|
||||||
|
type="button"
|
||||||
|
data-toggle="tooltip"
|
||||||
|
data-placement="bottom"
|
||||||
|
data-clipboard-text={this.state.roomUrl}
|
||||||
|
title="Copied!"
|
||||||
|
>
|
||||||
|
<Copy className="mt-1" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
RoomLink.propTypes = {
|
||||||
|
roomId: PropTypes.string.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default RoomLink
|
11
client/src/components/RoomLocked/index.js
Normal file
11
client/src/components/RoomLocked/index.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import React, { Component } from 'react'
|
||||||
|
|
||||||
|
export default class RoomLocked extends Component {
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
This room is locked.
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
64
client/src/components/Settings/index.js
Normal file
64
client/src/components/Settings/index.js
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import React, { Component } from 'react'
|
||||||
|
import PropTypes from 'prop-types'
|
||||||
|
import RoomLink from 'components/RoomLink'
|
||||||
|
|
||||||
|
class Settings extends Component {
|
||||||
|
handleSoundToggle() {
|
||||||
|
this.props.toggleSoundEnabled(!this.props.soundIsEnabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div>
|
||||||
|
<h5>Sound</h5>
|
||||||
|
<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} />
|
||||||
|
Sound
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
<h5>This room</h5>
|
||||||
|
<RoomLink roomId={this.props.roomId} />
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
<h5>Room Ownership</h5>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
<h5>Lock Room</h5>
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<br />
|
||||||
|
<div>
|
||||||
|
<h5>Slash Commands</h5>
|
||||||
|
<p>The following slash commands are available:</p>
|
||||||
|
<ul>
|
||||||
|
<li>/nick [username] <span className="text-muted">changes username</span></li>
|
||||||
|
<li>/me [action] <span className="text-muted">performs an action</span></li>
|
||||||
|
<li>/clear <span className="text-muted">clears your message history</span></li>
|
||||||
|
<li>/help <span className="text-muted">lists all commands</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Settings.propTypes = {
|
||||||
|
soundIsEnabled: PropTypes.bool.isRequired,
|
||||||
|
toggleSoundEnabled: PropTypes.func.isRequired,
|
||||||
|
roomId: PropTypes.string.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Settings
|
19
client/src/components/Username/index.js
Normal file
19
client/src/components/Username/index.js
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
|
45
client/src/components/Welcome/index.js
Normal file
45
client/src/components/Welcome/index.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
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>Others can join this room using the following URL:</p>
|
||||||
|
<RoomLink roomId={this.props.roomId} />
|
||||||
|
<div className="react-modal-footer">
|
||||||
|
<button className="btn btn-primary btn-lg" onClick={this.props.close}>Ok</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Welcome.propTypes = {
|
||||||
|
roomId: PropTypes.string.isRequired,
|
||||||
|
close: PropTypes.func.isRequired,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Welcome
|
1
client/src/config/env.js
Normal file
1
client/src/config/env.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export default process.env.NODE_ENV
|
15
client/src/containers/Chat/index.js
Normal file
15
client/src/containers/Chat/index.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { connect } from 'react-redux'
|
||||||
|
import { sendSocketMessage } from 'actions'
|
||||||
|
import ChatInput from 'components/Chat'
|
||||||
|
|
||||||
|
const mapStateToProps = () => ({
|
||||||
|
})
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
sendSocketMessage,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(ChatInput)
|
66
client/src/containers/Home/index.js
Normal file
66
client/src/containers/Home/index.js
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
import { connect } from 'react-redux'
|
||||||
|
import Home from 'components/Home'
|
||||||
|
import {
|
||||||
|
createRoom,
|
||||||
|
receiveSocketMessage,
|
||||||
|
sendSocketMessage,
|
||||||
|
createUser,
|
||||||
|
receiveUserExit,
|
||||||
|
receiveUserEnter,
|
||||||
|
toggleLockRoom,
|
||||||
|
receiveToggleLockRoom,
|
||||||
|
openModal,
|
||||||
|
closeModal,
|
||||||
|
setScrolledToBottom,
|
||||||
|
sendUserEnter,
|
||||||
|
toggleWindowFocus,
|
||||||
|
toggleSoundEnabled,
|
||||||
|
toggleSocketConnected,
|
||||||
|
} from 'actions'
|
||||||
|
|
||||||
|
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,
|
||||||
|
scrolledToBottom: state.app.scrolledToBottom,
|
||||||
|
iAmOwner: Boolean(me && me.isOwner),
|
||||||
|
joining: state.room.joining,
|
||||||
|
faviconCount: state.app.unreadMessageCount,
|
||||||
|
soundIsEnabled: state.app.soundIsEnabled,
|
||||||
|
serverSHA: state.app.serverSHA,
|
||||||
|
socketConnected: state.app.socketConnected,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapDispatchToProps = {
|
||||||
|
createRoom,
|
||||||
|
receiveSocketMessage,
|
||||||
|
sendSocketMessage,
|
||||||
|
receiveUserExit,
|
||||||
|
receiveUserEnter,
|
||||||
|
createUser,
|
||||||
|
toggleLockRoom,
|
||||||
|
receiveToggleLockRoom,
|
||||||
|
openModal,
|
||||||
|
closeModal,
|
||||||
|
setScrolledToBottom,
|
||||||
|
sendUserEnter,
|
||||||
|
toggleWindowFocus,
|
||||||
|
toggleSoundEnabled,
|
||||||
|
toggleSocketConnected,
|
||||||
|
}
|
||||||
|
|
||||||
|
export default connect(
|
||||||
|
mapStateToProps,
|
||||||
|
mapDispatchToProps
|
||||||
|
)(Home)
|
||||||
|
|
BIN
client/src/img/logo-dark.png
Normal file
BIN
client/src/img/logo-dark.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.3 KiB |
BIN
client/src/img/logo.png
Normal file
BIN
client/src/img/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 7.4 KiB |
BIN
client/src/img/menu.png
Normal file
BIN
client/src/img/menu.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 598 B |
14
client/src/index.css
Normal file
14
client/src/index.css
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||||
|
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||||
|
sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||||
|
monospace;
|
||||||
|
}
|
15
client/src/index.html
Normal file
15
client/src/index.html
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html class='h-100'>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||||
|
<meta name="robots" content="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>
|
||||||
|
</body>
|
||||||
|
</html>
|
12
client/src/index.js
Normal file
12
client/src/index.js
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import ReactDOM from 'react-dom';
|
||||||
|
import './index.css';
|
||||||
|
import Root from './root';
|
||||||
|
import * as serviceWorker from './serviceWorker';
|
||||||
|
|
||||||
|
ReactDOM.render(<Root />, document.getElementById('root'));
|
||||||
|
|
||||||
|
// If you want your app to work offline and load faster, you can change
|
||||||
|
// unregister() to register() below. Note this comes with some pitfalls.
|
||||||
|
// Learn more about service workers: https://bit.ly/CRA-PWA
|
||||||
|
serviceWorker.unregister();
|
7
client/src/logo.svg
Normal file
7
client/src/logo.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
|
||||||
|
<g fill="#61DAFB">
|
||||||
|
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
|
||||||
|
<circle cx="420.9" cy="296.5" r="45.7"/>
|
||||||
|
<path d="M520.5 78.1z"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.6 KiB |
242
client/src/reducers/activities.js
Normal file
242
client/src/reducers/activities.js
Normal file
@ -0,0 +1,242 @@
|
|||||||
|
const initialState = {
|
||||||
|
items: [
|
||||||
|
// {
|
||||||
|
// type: 'message | file | isTyping | usernameChange | slashCommand',
|
||||||
|
// data,
|
||||||
|
// username,
|
||||||
|
// timestamp
|
||||||
|
// }
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
const activities = (state = initialState, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'CLEAR_ACTIVITIES':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [],
|
||||||
|
}
|
||||||
|
case 'SEND_SOCKET_MESSAGE_SLASH_COMMAND':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
...action.payload,
|
||||||
|
type: 'SLASH_COMMAND',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'SEND_SOCKET_MESSAGE_FILE_TRANSFER':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
...action.payload,
|
||||||
|
type: 'FILE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'SEND_SOCKET_MESSAGE_SEND_MESSAGE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
...action.payload,
|
||||||
|
type: 'SEND_MESSAGE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'HANDLE_SOCKET_MESSAGE_SEND_MESSAGE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
...action.payload.payload,
|
||||||
|
type: 'SEND_MESSAGE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'SEND_SOCKET_MESSAGE_SEND_FILE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
fileName: action.payload.fileName,
|
||||||
|
encodedFile: action.payload.encodedFile,
|
||||||
|
fileType: action.payload.fileType,
|
||||||
|
username: action.payload.username,
|
||||||
|
type: 'SEND_FILE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'HANDLE_SOCKET_MESSAGE_SEND_FILE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
encodedFile: action.payload.payload.encodedFile,
|
||||||
|
fileType: action.payload.payload.fileType,
|
||||||
|
fileName: action.payload.payload.fileName,
|
||||||
|
username: action.payload.payload.username,
|
||||||
|
type: 'RECEIVE_FILE',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'HANDLE_SOCKET_MESSAGE_ADD_USER':
|
||||||
|
const newUserId = action.payload.payload.id
|
||||||
|
|
||||||
|
const haveUser = action.payload.state.room.members.find(m => m.id === newUserId)
|
||||||
|
if (haveUser) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Duplicate "user entered" can happen when >2 users join
|
||||||
|
// in quick succession
|
||||||
|
const alreadyEntered = state.items.find(act => act.type === 'USER_ENTER' && act.userId === newUserId)
|
||||||
|
if (alreadyEntered) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.payload.state.room.joining) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
userId: newUserId,
|
||||||
|
type: 'USER_ENTER',
|
||||||
|
username: action.payload.payload.username,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'USER_EXIT':
|
||||||
|
if (!action.payload.id) {
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
userId: action.payload.id,
|
||||||
|
type: 'USER_EXIT',
|
||||||
|
username: action.payload.username,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'TOGGLE_LOCK_ROOM':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
username: action.payload.username,
|
||||||
|
userId: action.payload.id,
|
||||||
|
type: 'TOGGLE_LOCK_ROOM',
|
||||||
|
locked: action.payload.locked,
|
||||||
|
sender: action.payload.sender,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'RECEIVE_TOGGLE_LOCK_ROOM':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
username: action.payload.username,
|
||||||
|
userId: action.payload.id,
|
||||||
|
type: 'TOGGLE_LOCK_ROOM',
|
||||||
|
locked: action.payload.locked,
|
||||||
|
sender: action.payload.sender,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'SHOW_NOTICE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
type: 'NOTICE',
|
||||||
|
message: action.payload.message,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'SEND_SOCKET_MESSAGE_CHANGE_USERNAME':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
type: 'CHANGE_USERNAME',
|
||||||
|
currentUsername: action.payload.currentUsername,
|
||||||
|
newUsername: action.payload.newUsername,
|
||||||
|
},
|
||||||
|
].map((item) => {
|
||||||
|
if (item.sender === action.payload.sender && item.type !== 'CHANGE_USERNAME') {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
username: action.payload.newUsername,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
case 'HANDLE_SOCKET_MESSAGE_CHANGE_USERNAME':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
type: 'CHANGE_USERNAME',
|
||||||
|
currentUsername: action.payload.payload.currentUsername,
|
||||||
|
newUsername: action.payload.payload.newUsername,
|
||||||
|
},
|
||||||
|
].map((item) => {
|
||||||
|
if (['SEND_MESSAGE', 'USER_ACTION'].includes(item.type) && item.sender === action.payload.payload.sender) {
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
username: action.payload.payload.newUsername,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return item
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
case 'SEND_SOCKET_MESSAGE_USER_ACTION':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
type: 'USER_ACTION',
|
||||||
|
...action.payload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'HANDLE_SOCKET_MESSAGE_USER_ACTION':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
items: [
|
||||||
|
...state.items,
|
||||||
|
{
|
||||||
|
type: 'USER_ACTION',
|
||||||
|
...action.payload.payload,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default activities
|
59
client/src/reducers/app.js
Normal file
59
client/src/reducers/app.js
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
const initialState = {
|
||||||
|
modalComponent: null,
|
||||||
|
scrolledToBottom: true,
|
||||||
|
windowIsFocused: true,
|
||||||
|
unreadMessageCount: 0,
|
||||||
|
soundIsEnabled: true,
|
||||||
|
serverSHA: '',
|
||||||
|
socketConnected: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = (state = initialState, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'FETCH_CREATE_HANDSHAKE_SUCCESS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
serverSHA: action.payload.json.sha,
|
||||||
|
}
|
||||||
|
case 'OPEN_MODAL':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
modalComponent: action.payload,
|
||||||
|
}
|
||||||
|
case 'CLOSE_MODAL':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
modalComponent: null,
|
||||||
|
}
|
||||||
|
case 'SET_SCROLLED_TO_BOTTOM':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
scrolledToBottom: action.payload,
|
||||||
|
}
|
||||||
|
case 'TOGGLE_WINDOW_FOCUS':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
windowIsFocused: action.payload,
|
||||||
|
unreadMessageCount: 0,
|
||||||
|
}
|
||||||
|
case 'HANDLE_SOCKET_MESSAGE_SEND_MESSAGE':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
unreadMessageCount: state.windowIsFocused ? 0 : state.unreadMessageCount + 1,
|
||||||
|
}
|
||||||
|
case 'TOGGLE_SOUND_ENABLED':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
soundIsEnabled: action.payload,
|
||||||
|
}
|
||||||
|
case 'TOGGLE_SOCKET_CONNECTED':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
socketConnected: action.payload,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default app
|
16
client/src/reducers/index.js
Normal file
16
client/src/reducers/index.js
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import { combineReducers } from 'redux'
|
||||||
|
import app from './app'
|
||||||
|
import activities from './activities'
|
||||||
|
import user from './user'
|
||||||
|
import room from './room'
|
||||||
|
|
||||||
|
const appReducer = combineReducers({
|
||||||
|
app,
|
||||||
|
user,
|
||||||
|
room,
|
||||||
|
activities,
|
||||||
|
})
|
||||||
|
|
||||||
|
const rootReducer = (state, action) => appReducer(state, action)
|
||||||
|
|
||||||
|
export default rootReducer
|
142
client/src/reducers/room.js
Normal file
142
client/src/reducers/room.js
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import _ from 'lodash'
|
||||||
|
|
||||||
|
const initialState = {
|
||||||
|
members: [
|
||||||
|
// {
|
||||||
|
// username,
|
||||||
|
// publicKey
|
||||||
|
// }
|
||||||
|
],
|
||||||
|
id: '',
|
||||||
|
isLocked: false,
|
||||||
|
joining: true,
|
||||||
|
size: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = (state = initialState, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'FETCH_CREATE_HANDSHAKE_SUCCESS':
|
||||||
|
const isLocked = action.payload.json.isLocked
|
||||||
|
// Handle "room is locked" message for new members here
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
id: action.payload.json.id,
|
||||||
|
isLocked,
|
||||||
|
size: action.payload.json.size,
|
||||||
|
joining: !(action.payload.json.size === 1),
|
||||||
|
}
|
||||||
|
case 'USER_EXIT':
|
||||||
|
const memberPubKeys = action.payload.members.map(m => JSON.stringify(m.publicKey))
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
members: state.members
|
||||||
|
.filter(m => memberPubKeys.includes(JSON.stringify(m.publicKey)))
|
||||||
|
.map((m) => {
|
||||||
|
const payloadMember = action.payload.members.find(member => _.isEqual(m.publicKey, member.publicKey))
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
...payloadMember,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
case 'HANDLE_SOCKET_MESSAGE_ADD_USER':
|
||||||
|
const membersWithId = state.members.filter(m => m.id)
|
||||||
|
const joining = state.joining ? membersWithId.length + 1 < state.size : false
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
members: state.members.map((member) => {
|
||||||
|
if (_.isEqual(member.publicKey, action.payload.payload.publicKey)) {
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
username: action.payload.payload.username,
|
||||||
|
isOwner: action.payload.payload.isOwner,
|
||||||
|
id: action.payload.payload.publicKey.n,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return member
|
||||||
|
}),
|
||||||
|
joining,
|
||||||
|
}
|
||||||
|
case 'CREATE_USER':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
members: [
|
||||||
|
...state.members,
|
||||||
|
{
|
||||||
|
username: action.payload.username,
|
||||||
|
publicKey: action.payload.publicKey,
|
||||||
|
id: action.payload.publicKey.n,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
case 'USER_ENTER':
|
||||||
|
/*
|
||||||
|
In this payload the server sends all users' public keys. Normally the server
|
||||||
|
will have all the users the client does, but in some cases - such as when
|
||||||
|
new users join before this client has registered with the server (this can
|
||||||
|
happen when lots of users join in quick succession) - the client
|
||||||
|
will receive a USER_ENTER event that doesn't contain itself. In that case we
|
||||||
|
want to prepend "me" to the members payload
|
||||||
|
*/
|
||||||
|
const diff = _.differenceBy(state.members, action.payload, m => m.publicKey.n)
|
||||||
|
const members = diff.length ? state.members.concat(action.payload) : action.payload
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
members: members.map((user) => {
|
||||||
|
const exists = state.members.find(m => _.isEqual(m.publicKey, user.publicKey))
|
||||||
|
if (exists) {
|
||||||
|
return {
|
||||||
|
...user,
|
||||||
|
...exists,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
publicKey: user.publicKey,
|
||||||
|
isOwner: user.isOwner,
|
||||||
|
id: user.id,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
case 'TOGGLE_LOCK_ROOM':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isLocked: !state.isLocked,
|
||||||
|
}
|
||||||
|
case 'RECEIVE_TOGGLE_LOCK_ROOM':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isLocked: action.payload.locked,
|
||||||
|
}
|
||||||
|
case 'SEND_SOCKET_MESSAGE_CHANGE_USERNAME':
|
||||||
|
const newUsername = action.payload.newUsername
|
||||||
|
const userId = action.payload.id
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
members: state.members.map(member => (
|
||||||
|
member.id === userId ?
|
||||||
|
{
|
||||||
|
...member,
|
||||||
|
username: newUsername,
|
||||||
|
} : member
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
case 'HANDLE_SOCKET_MESSAGE_CHANGE_USERNAME':
|
||||||
|
const newUsername2 = action.payload.payload.newUsername
|
||||||
|
const userId2 = action.payload.payload.id
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
members: state.members.map(member => (
|
||||||
|
member.id === userId2 ?
|
||||||
|
{
|
||||||
|
...member,
|
||||||
|
username: newUsername2,
|
||||||
|
} : member
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default room
|
25
client/src/reducers/user.js
Normal file
25
client/src/reducers/user.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
const initialState = {
|
||||||
|
privateKey: {},
|
||||||
|
publicKey: {},
|
||||||
|
username: '',
|
||||||
|
id: '',
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = (state = initialState, action) => {
|
||||||
|
switch (action.type) {
|
||||||
|
case 'CREATE_USER':
|
||||||
|
return {
|
||||||
|
...action.payload,
|
||||||
|
id: action.payload.publicKey.n,
|
||||||
|
}
|
||||||
|
case 'SEND_SOCKET_MESSAGE_CHANGE_USERNAME':
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
username: action.payload.newUsername,
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default user
|
37
client/src/root.js
Normal file
37
client/src/root.js
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import 'bootstrap/dist/css/bootstrap.min.css'
|
||||||
|
import 'react-simple-dropdown/styles/Dropdown.css'
|
||||||
|
import 'stylesheets/app.sass'
|
||||||
|
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
|
||||||
|
import React, { Component } from 'react'
|
||||||
|
import { Redirect } from 'react-router'
|
||||||
|
import { Provider } from 'react-redux'
|
||||||
|
import configureStore from 'store'
|
||||||
|
import { BrowserRouter, Route, Switch } from 'react-router-dom'
|
||||||
|
import shortId from 'shortid'
|
||||||
|
import Home from 'containers/Home'
|
||||||
|
import { hasTouchSupport } from './utils/dom'
|
||||||
|
|
||||||
|
const store = configureStore()
|
||||||
|
|
||||||
|
export default class Root extends Component {
|
||||||
|
componentWillMount() {
|
||||||
|
if (hasTouchSupport) {
|
||||||
|
document.body.classList.add('touch')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
return (
|
||||||
|
<Provider store={store}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<div className="h-100">
|
||||||
|
<Switch>
|
||||||
|
<Route exact path="/" render={() => <Redirect to={`/${shortId.generate()}`} />} />
|
||||||
|
<Route path="/:roomId" component={Home} />
|
||||||
|
</Switch>
|
||||||
|
</div>
|
||||||
|
</BrowserRouter>
|
||||||
|
</Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
135
client/src/serviceWorker.js
Normal file
135
client/src/serviceWorker.js
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
// This optional code is used to register a service worker.
|
||||||
|
// register() is not called by default.
|
||||||
|
|
||||||
|
// This lets the app load faster on subsequent visits in production, and gives
|
||||||
|
// it offline capabilities. However, it also means that developers (and users)
|
||||||
|
// will only see deployed updates on subsequent visits to a page, after all the
|
||||||
|
// existing tabs open on the page have been closed, since previously cached
|
||||||
|
// resources are updated in the background.
|
||||||
|
|
||||||
|
// To learn more about the benefits of this model and instructions on how to
|
||||||
|
// opt-in, read https://bit.ly/CRA-PWA
|
||||||
|
|
||||||
|
const isLocalhost = Boolean(
|
||||||
|
window.location.hostname === 'localhost' ||
|
||||||
|
// [::1] is the IPv6 localhost address.
|
||||||
|
window.location.hostname === '[::1]' ||
|
||||||
|
// 127.0.0.1/8 is considered localhost for IPv4.
|
||||||
|
window.location.hostname.match(
|
||||||
|
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
export function register(config) {
|
||||||
|
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||||
|
// The URL constructor is available in all browsers that support SW.
|
||||||
|
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
|
||||||
|
if (publicUrl.origin !== window.location.origin) {
|
||||||
|
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||||
|
// from what our page is served on. This might happen if a CDN is used to
|
||||||
|
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||||
|
|
||||||
|
if (isLocalhost) {
|
||||||
|
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||||
|
checkValidServiceWorker(swUrl, config);
|
||||||
|
|
||||||
|
// Add some additional logging to localhost, pointing developers to the
|
||||||
|
// service worker/PWA documentation.
|
||||||
|
navigator.serviceWorker.ready.then(() => {
|
||||||
|
console.log(
|
||||||
|
'This web app is being served cache-first by a service ' +
|
||||||
|
'worker. To learn more, visit https://bit.ly/CRA-PWA'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Is not localhost. Just register service worker
|
||||||
|
registerValidSW(swUrl, config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerValidSW(swUrl, config) {
|
||||||
|
navigator.serviceWorker
|
||||||
|
.register(swUrl)
|
||||||
|
.then(registration => {
|
||||||
|
registration.onupdatefound = () => {
|
||||||
|
const installingWorker = registration.installing;
|
||||||
|
if (installingWorker == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
installingWorker.onstatechange = () => {
|
||||||
|
if (installingWorker.state === 'installed') {
|
||||||
|
if (navigator.serviceWorker.controller) {
|
||||||
|
// At this point, the updated precached content has been fetched,
|
||||||
|
// but the previous service worker will still serve the older
|
||||||
|
// content until all client tabs are closed.
|
||||||
|
console.log(
|
||||||
|
'New content is available and will be used when all ' +
|
||||||
|
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Execute callback
|
||||||
|
if (config && config.onUpdate) {
|
||||||
|
config.onUpdate(registration);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// At this point, everything has been precached.
|
||||||
|
// It's the perfect time to display a
|
||||||
|
// "Content is cached for offline use." message.
|
||||||
|
console.log('Content is cached for offline use.');
|
||||||
|
|
||||||
|
// Execute callback
|
||||||
|
if (config && config.onSuccess) {
|
||||||
|
config.onSuccess(registration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error during service worker registration:', error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkValidServiceWorker(swUrl, config) {
|
||||||
|
// Check if the service worker can be found. If it can't reload the page.
|
||||||
|
fetch(swUrl)
|
||||||
|
.then(response => {
|
||||||
|
// Ensure service worker exists, and that we really are getting a JS file.
|
||||||
|
const contentType = response.headers.get('content-type');
|
||||||
|
if (
|
||||||
|
response.status === 404 ||
|
||||||
|
(contentType != null && contentType.indexOf('javascript') === -1)
|
||||||
|
) {
|
||||||
|
// No service worker found. Probably a different app. Reload the page.
|
||||||
|
navigator.serviceWorker.ready.then(registration => {
|
||||||
|
registration.unregister().then(() => {
|
||||||
|
window.location.reload();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Service worker found. Proceed as normal.
|
||||||
|
registerValidSW(swUrl, config);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
console.log(
|
||||||
|
'No internet connection found. App is running in offline mode.'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unregister() {
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.ready.then(registration => {
|
||||||
|
registration.unregister();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
4
client/src/setupTests.js
Normal file
4
client/src/setupTests.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import { configure } from 'enzyme';
|
||||||
|
import Adapter from 'enzyme-adapter-react-16';
|
||||||
|
|
||||||
|
configure({ adapter: new Adapter() });
|
27
client/src/store/index.js
Normal file
27
client/src/store/index.js
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { createStore, applyMiddleware, compose } from 'redux'
|
||||||
|
import reducers from 'reducers'
|
||||||
|
import thunk from 'redux-thunk'
|
||||||
|
|
||||||
|
const composeEnhancers = process.env.NODE_ENV === 'production' ? compose : (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose)
|
||||||
|
|
||||||
|
const enabledMiddlewares = [thunk]
|
||||||
|
|
||||||
|
const middlewares = applyMiddleware(...enabledMiddlewares)
|
||||||
|
|
||||||
|
export default function configureStore(preloadedState) {
|
||||||
|
const store = createStore(
|
||||||
|
reducers,
|
||||||
|
preloadedState,
|
||||||
|
composeEnhancers(middlewares)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (module.hot) {
|
||||||
|
module.hot.accept('../reducers', () => {
|
||||||
|
// eslint-disable-next-line global-require
|
||||||
|
const nextRootReducer = require('../reducers/index')
|
||||||
|
store.replaceReducer(nextRootReducer)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return store
|
||||||
|
}
|
228
client/src/stylesheets/app.sass
Normal file
228
client/src/stylesheets/app.sass
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
@import 'colors'
|
||||||
|
@import 'typography'
|
||||||
|
@import 'nav'
|
||||||
|
@import 'footer'
|
||||||
|
|
||||||
|
body
|
||||||
|
background-color: $primary
|
||||||
|
color: $body
|
||||||
|
|
||||||
|
ul.plain
|
||||||
|
list-style: none
|
||||||
|
margin: 0px
|
||||||
|
padding: 0px
|
||||||
|
|
||||||
|
a
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
button
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
button.btn-link.btn-plain
|
||||||
|
padding: 0px
|
||||||
|
color: white
|
||||||
|
text-decoration: none
|
||||||
|
color: white
|
||||||
|
&:hover, &:focus
|
||||||
|
color: white
|
||||||
|
|
||||||
|
button.btn-link
|
||||||
|
|
||||||
|
.page-header
|
||||||
|
height: 50px
|
||||||
|
|
||||||
|
svg.disabled
|
||||||
|
color: $inactive
|
||||||
|
|
||||||
|
svg text
|
||||||
|
user-select: none
|
||||||
|
|
||||||
|
svg:hover
|
||||||
|
background: none
|
||||||
|
|
||||||
|
svg text::selection
|
||||||
|
background: none
|
||||||
|
|
||||||
|
textarea.chat
|
||||||
|
border-radius: 0px
|
||||||
|
resize: none !important
|
||||||
|
width: 100%
|
||||||
|
max-height: 200px
|
||||||
|
min-height: 50px
|
||||||
|
background-color: $secondary
|
||||||
|
border-top: 1px solid $inactive
|
||||||
|
border-left: 0px
|
||||||
|
border-right: 0px
|
||||||
|
border-bottom: 0px
|
||||||
|
color: $body
|
||||||
|
outline: 0
|
||||||
|
font-size: 16px
|
||||||
|
padding: 12px 50px 12px 12px
|
||||||
|
|
||||||
|
body.touch textarea.chat
|
||||||
|
padding-right: 100px
|
||||||
|
|
||||||
|
input[type='text'],
|
||||||
|
textarea
|
||||||
|
font-size: 16px
|
||||||
|
|
||||||
|
.right-border
|
||||||
|
border-right: 1px solid $inactive
|
||||||
|
|
||||||
|
.bottom-border
|
||||||
|
border-bottom: 1px solid $inactive
|
||||||
|
|
||||||
|
.sidebar
|
||||||
|
top: 0
|
||||||
|
bottom: 0
|
||||||
|
left: 0
|
||||||
|
z-index: 2
|
||||||
|
overflow-x: hidden
|
||||||
|
overflow-y: auto
|
||||||
|
border-right: 1px solid $inactive
|
||||||
|
background-color: $secondary
|
||||||
|
|
||||||
|
.online-users
|
||||||
|
font-size: 11px
|
||||||
|
font-weight: bold
|
||||||
|
text-transform: uppercase
|
||||||
|
color: $inactive
|
||||||
|
margin: 10px 0
|
||||||
|
|
||||||
|
.chat-container
|
||||||
|
margin-top: auto
|
||||||
|
width: 100%
|
||||||
|
.chat-preflight-container
|
||||||
|
position: relative
|
||||||
|
.input-controls-left
|
||||||
|
position: absolute
|
||||||
|
left: 0px
|
||||||
|
bottom: 0px
|
||||||
|
height: 100%
|
||||||
|
display: flex
|
||||||
|
width: 45px
|
||||||
|
.send
|
||||||
|
padding: 0px
|
||||||
|
padding-top: 12px
|
||||||
|
width: 100%
|
||||||
|
.input-controls
|
||||||
|
position: absolute
|
||||||
|
right: 0px
|
||||||
|
bottom: 0px
|
||||||
|
height: 100%
|
||||||
|
display: flex
|
||||||
|
button
|
||||||
|
&.send
|
||||||
|
padding-top: 12px
|
||||||
|
height: 50px
|
||||||
|
border-radius: 0px
|
||||||
|
&.active
|
||||||
|
background: #007bff
|
||||||
|
color: white
|
||||||
|
|
||||||
|
.message-stream
|
||||||
|
padding: 0px 15px
|
||||||
|
overflow: auto
|
||||||
|
overflow-y: scroll
|
||||||
|
-webkit-overflow-scrolling: touch
|
||||||
|
flex: 1
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
ul
|
||||||
|
width: 100%
|
||||||
|
margin-top: auto
|
||||||
|
li:last-child
|
||||||
|
border-bottom: 0
|
||||||
|
|
||||||
|
.activity-item
|
||||||
|
margin-bottom: 15px
|
||||||
|
&.SEND_MESSAGE
|
||||||
|
.chat-meta
|
||||||
|
font-size: 13px
|
||||||
|
.timestamp
|
||||||
|
font-size: 11px
|
||||||
|
padding-left: 10px
|
||||||
|
.chat
|
||||||
|
word-wrap: break-word
|
||||||
|
font-size: 16px
|
||||||
|
white-space: pre-line
|
||||||
|
.image-transfer
|
||||||
|
display: block
|
||||||
|
margin-top: 10px
|
||||||
|
max-width: 100%
|
||||||
|
|
||||||
|
@media(min-width: 576px)
|
||||||
|
.activity-item
|
||||||
|
.image-transfer
|
||||||
|
max-width: 500px
|
||||||
|
|
||||||
|
.react-modal-overlay
|
||||||
|
position: fixed
|
||||||
|
top: 0px
|
||||||
|
left: 0px
|
||||||
|
right: 0px
|
||||||
|
bottom: 0px
|
||||||
|
background-color: rgba(0, 0, 0, 0.5)
|
||||||
|
z-index: 10
|
||||||
|
.react-modal-content
|
||||||
|
-webkit-overflow-scrolling: touch
|
||||||
|
border: 1px solid rgb(204, 204, 204)
|
||||||
|
background: rgb(0, 0, 0)
|
||||||
|
overflow: auto
|
||||||
|
border-radius: 4px
|
||||||
|
outline: none
|
||||||
|
padding: 20px
|
||||||
|
max-width: 600px
|
||||||
|
width: calc(100% - 30px)
|
||||||
|
margin: 20px auto 0px auto
|
||||||
|
max-height: 75%
|
||||||
|
.close-modal
|
||||||
|
float: right
|
||||||
|
padding: 0px
|
||||||
|
.react-modal-header
|
||||||
|
overflow: auto
|
||||||
|
margin-bottom: 20px
|
||||||
|
.react-modal-footer
|
||||||
|
margin-top: 25px
|
||||||
|
button
|
||||||
|
float: right
|
||||||
|
|
||||||
|
.ellipsis
|
||||||
|
text-overflow: ellipsis
|
||||||
|
overflow: hidden
|
||||||
|
white-space: nowrap
|
||||||
|
|
||||||
|
.main-chat
|
||||||
|
padding-top: 55px
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
height: 100%
|
||||||
|
|
||||||
|
.zoom-overlay
|
||||||
|
z-index: 420
|
||||||
|
background: rgba(255, 255, 255, 0.9)
|
||||||
|
position: fixed
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
bottom: 0
|
||||||
|
pointer-events: none
|
||||||
|
filter: "alpha(opacity=0)"
|
||||||
|
opacity: 0
|
||||||
|
transition: opacity 300ms
|
||||||
|
|
||||||
|
.zoom-overlay-open .zoom-overlay
|
||||||
|
filter: "alpha(opacity=100)"
|
||||||
|
opacity: 1
|
||||||
|
|
||||||
|
.zoom-img,
|
||||||
|
.zoom-img-wrap
|
||||||
|
position: relative
|
||||||
|
z-index: 666
|
||||||
|
transition: all 300ms
|
||||||
|
|
||||||
|
img.zoom-img
|
||||||
|
cursor: zoom-out
|
||||||
|
|
||||||
|
img.zoomable
|
||||||
|
cursor: zoom-in
|
10
client/src/stylesheets/colors.sass
Normal file
10
client/src/stylesheets/colors.sass
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
$primary: #242424
|
||||||
|
$secondary: #181818
|
||||||
|
$inactive: #424242
|
||||||
|
$body: #E7E7E7
|
||||||
|
$md-gray: #999
|
||||||
|
$dk-gray: #666
|
||||||
|
$error: #d00000
|
||||||
|
|
||||||
|
.muted
|
||||||
|
color: $dk-gray
|
0
client/src/stylesheets/footer.sass
Normal file
0
client/src/stylesheets/footer.sass
Normal file
104
client/src/stylesheets/nav.sass
Normal file
104
client/src/stylesheets/nav.sass
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
@import 'colors'
|
||||||
|
|
||||||
|
.logo
|
||||||
|
height: 30px
|
||||||
|
margin-right: 10px
|
||||||
|
|
||||||
|
.nav-container
|
||||||
|
position: fixed
|
||||||
|
top: 0px
|
||||||
|
width: 100%
|
||||||
|
z-index: 5
|
||||||
|
background: black
|
||||||
|
.alert-banner
|
||||||
|
width: 100%
|
||||||
|
height: 30px
|
||||||
|
background: $error
|
||||||
|
color: white
|
||||||
|
padding: 5px 15px
|
||||||
|
.icon
|
||||||
|
top: 3px
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
nav
|
||||||
|
border-bottom: 1px solid $inactive
|
||||||
|
.meta
|
||||||
|
padding-left: 1rem
|
||||||
|
height: 100%
|
||||||
|
align-items: center
|
||||||
|
display: flex
|
||||||
|
flex-basis: 80%
|
||||||
|
@media(min-width: 768px)
|
||||||
|
nav .meta
|
||||||
|
flex-basis: 40%
|
||||||
|
|
||||||
|
.navbar-toggler
|
||||||
|
border: 0px
|
||||||
|
|
||||||
|
.navbar
|
||||||
|
padding-left: 0
|
||||||
|
padding-right: 0
|
||||||
|
padding-top: 0px
|
||||||
|
padding-bottom: 0px
|
||||||
|
height: 55px
|
||||||
|
.room-id
|
||||||
|
margin-right: 15px
|
||||||
|
max-width: 95px
|
||||||
|
|
||||||
|
ul.navbar-nav
|
||||||
|
li
|
||||||
|
padding-bottom: 10px
|
||||||
|
svg
|
||||||
|
width: 20px
|
||||||
|
margin-right: 10px
|
||||||
|
span
|
||||||
|
width: auto
|
||||||
|
|
||||||
|
button.lock-room
|
||||||
|
margin-right: 15px
|
||||||
|
display: flex
|
||||||
|
|
||||||
|
@media(min-width: 768px)
|
||||||
|
ul.navbar-nav li
|
||||||
|
margin-left: 25px
|
||||||
|
padding-bottom: 0px
|
||||||
|
|
||||||
|
.nav-link
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
color: $md-gray !important
|
||||||
|
&:hover
|
||||||
|
color: white !important
|
||||||
|
|
||||||
|
.navbar-collapse
|
||||||
|
padding: 0px 1rem
|
||||||
|
background: black
|
||||||
|
|
||||||
|
.members-dropdown
|
||||||
|
margin-left: 10px
|
||||||
|
.dropdown__trigger
|
||||||
|
position: relative
|
||||||
|
top: 3px
|
||||||
|
.members-action
|
||||||
|
margin-right: 5px
|
||||||
|
.dropdown__content
|
||||||
|
background: black
|
||||||
|
border-radius: 3px
|
||||||
|
border: 1px solid $inactive
|
||||||
|
padding: 15px
|
||||||
|
width: 175px
|
||||||
|
left: -110px
|
||||||
|
ul
|
||||||
|
li
|
||||||
|
overflow: auto
|
||||||
|
height: 25px
|
||||||
|
.username
|
||||||
|
overflow: hidden
|
||||||
|
text-overflow: ellipsis
|
||||||
|
display: inline-block
|
||||||
|
width: 100px
|
||||||
|
white-space: nowrap
|
||||||
|
.icon-container
|
||||||
|
float: right
|
||||||
|
.me-icon, .owner-icon
|
||||||
|
width: 15px
|
5
client/src/stylesheets/postcss.config.js
Normal file
5
client/src/stylesheets/postcss.config.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: [
|
||||||
|
require('autoprefixer')({}), // eslint-disable-line
|
||||||
|
],
|
||||||
|
}
|
19
client/src/stylesheets/typography.sass
Normal file
19
client/src/stylesheets/typography.sass
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
html, body
|
||||||
|
font-size: 14px
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6
|
||||||
|
margin: 0px
|
||||||
|
|
||||||
|
h4
|
||||||
|
font-weight: normal
|
||||||
|
|
||||||
|
h5
|
||||||
|
font-size: 1.15rem
|
||||||
|
line-height: 1.3
|
||||||
|
|
||||||
|
h6
|
||||||
|
text-transform: uppercase
|
||||||
|
font-size: 0.75rem
|
||||||
|
|
||||||
|
.bold
|
||||||
|
font-weight: bold
|
4
client/src/test/setup.js
Normal file
4
client/src/test/setup.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import Enzyme from 'enzyme'
|
||||||
|
import Adapter from 'enzyme-adapter-react-16'
|
||||||
|
|
||||||
|
Enzyme.configure({ adapter: new Adapter() })
|
250
client/src/utils/ImageZoom.js
Normal file
250
client/src/utils/ImageZoom.js
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
/* eslint-disable */
|
||||||
|
function Zoom(domContent) {
|
||||||
|
const OFFSET = 80
|
||||||
|
|
||||||
|
// From http://youmightnotneedjquery.com/#offset
|
||||||
|
function offset(element) {
|
||||||
|
const rect = element.getBoundingClientRect()
|
||||||
|
|
||||||
|
return {
|
||||||
|
top: rect.top + document.body.scrollTop,
|
||||||
|
left: rect.left + document.body.scrollLeft,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomListener() {
|
||||||
|
let activeZoom = null
|
||||||
|
let initialScrollPosition = null
|
||||||
|
let initialTouchPosition = null
|
||||||
|
|
||||||
|
function listen() {
|
||||||
|
domContent.addEventListener('click', (event) => {
|
||||||
|
if (event.target.tagName !== 'IMG') return
|
||||||
|
|
||||||
|
zoom(event)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoom(event) {
|
||||||
|
event.stopPropagation()
|
||||||
|
|
||||||
|
if (document.body.classList.contains('zoom-overlay-open')) return
|
||||||
|
if (event.target.width >= (window.innerWidth - OFFSET)) return
|
||||||
|
|
||||||
|
if (event.metaKey || event.ctrlKey) return openInNewWindow()
|
||||||
|
|
||||||
|
closeActiveZoom({ forceDispose: true })
|
||||||
|
|
||||||
|
activeZoom = vanillaZoom(event.target)
|
||||||
|
activeZoom.zoomImage()
|
||||||
|
|
||||||
|
addCloseActiveZoomListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
function openInNewWindow() {
|
||||||
|
window.open(event.target.getAttribute('data-original') ||
|
||||||
|
event.target.currentSrc ||
|
||||||
|
event.target.src,
|
||||||
|
'_blank')
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeActiveZoom(options) {
|
||||||
|
options = options || { forceDispose: false }
|
||||||
|
if (!activeZoom) return
|
||||||
|
|
||||||
|
activeZoom[options.forceDispose ? 'dispose' : 'close']()
|
||||||
|
removeCloseActiveZoomListeners()
|
||||||
|
activeZoom = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCloseActiveZoomListeners() {
|
||||||
|
// todo(fat): probably worth throttling this
|
||||||
|
window.addEventListener('scroll', handleScroll)
|
||||||
|
document.addEventListener('click', handleClick)
|
||||||
|
document.addEventListener('keyup', handleEscPressed)
|
||||||
|
document.addEventListener('touchstart', handleTouchStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCloseActiveZoomListeners() {
|
||||||
|
window.removeEventListener('scroll', handleScroll)
|
||||||
|
document.removeEventListener('keyup', handleEscPressed)
|
||||||
|
document.removeEventListener('click', handleClick)
|
||||||
|
document.removeEventListener('touchstart', handleTouchStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleScroll(event) {
|
||||||
|
if (initialScrollPosition === null) initialScrollPosition = window.scrollY
|
||||||
|
const deltaY = initialScrollPosition - window.scrollY
|
||||||
|
if (Math.abs(deltaY) >= 40) closeActiveZoom()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleEscPressed(event) {
|
||||||
|
if (event.keyCode == 27) closeActiveZoom()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(event) {
|
||||||
|
event.stopPropagation()
|
||||||
|
event.preventDefault()
|
||||||
|
closeActiveZoom()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchStart(event) {
|
||||||
|
initialTouchPosition = event.touches[0].pageY
|
||||||
|
event.target.addEventListener('touchmove', handleTouchMove)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTouchMove(event) {
|
||||||
|
if (Math.abs(event.touches[0].pageY - initialTouchPosition) <= 10) return
|
||||||
|
closeActiveZoom()
|
||||||
|
event.target.removeEventListener('touchmove', handleTouchMove)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { listen }
|
||||||
|
}
|
||||||
|
|
||||||
|
var vanillaZoom = (function () {
|
||||||
|
let fullHeight = null
|
||||||
|
let fullWidth = null
|
||||||
|
let overlay = null
|
||||||
|
let imgScaleFactor = null
|
||||||
|
|
||||||
|
let targetImage = null
|
||||||
|
let targetImageWrap = null
|
||||||
|
let targetImageClone = null
|
||||||
|
|
||||||
|
function zoomImage() {
|
||||||
|
const img = document.createElement('img')
|
||||||
|
img.onload = function () {
|
||||||
|
fullHeight = Number(img.height)
|
||||||
|
fullWidth = Number(img.width)
|
||||||
|
zoomOriginal()
|
||||||
|
}
|
||||||
|
img.src = targetImage.currentSrc || targetImage.src
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomOriginal() {
|
||||||
|
targetImageWrap = document.createElement('div')
|
||||||
|
targetImageWrap.className = 'zoom-img-wrap'
|
||||||
|
targetImageWrap.style.position = 'absolute'
|
||||||
|
targetImageWrap.style.top = `${offset(targetImage).top}px`
|
||||||
|
targetImageWrap.style.left = `${offset(targetImage).left}px`
|
||||||
|
|
||||||
|
targetImageClone = targetImage.cloneNode()
|
||||||
|
targetImageClone.style.visibility = 'hidden'
|
||||||
|
|
||||||
|
targetImage.style.width = `${targetImage.offsetWidth}px`
|
||||||
|
targetImage.parentNode.replaceChild(targetImageClone, targetImage)
|
||||||
|
|
||||||
|
document.body.appendChild(targetImageWrap)
|
||||||
|
targetImageWrap.appendChild(targetImage)
|
||||||
|
|
||||||
|
targetImage.classList.add('zoom-img')
|
||||||
|
targetImage.setAttribute('data-action', 'zoom-out')
|
||||||
|
|
||||||
|
overlay = document.createElement('div')
|
||||||
|
overlay.className = 'zoom-overlay'
|
||||||
|
|
||||||
|
document.body.appendChild(overlay)
|
||||||
|
|
||||||
|
calculateZoom()
|
||||||
|
triggerAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateZoom() {
|
||||||
|
targetImage.offsetWidth // repaint before animating
|
||||||
|
|
||||||
|
const originalFullImageWidth = fullWidth
|
||||||
|
const originalFullImageHeight = fullHeight
|
||||||
|
|
||||||
|
const maxScaleFactor = originalFullImageWidth / targetImage.width
|
||||||
|
|
||||||
|
const viewportHeight = window.innerHeight - OFFSET
|
||||||
|
const viewportWidth = window.innerWidth - OFFSET
|
||||||
|
|
||||||
|
const imageAspectRatio = originalFullImageWidth / originalFullImageHeight
|
||||||
|
const viewportAspectRatio = viewportWidth / viewportHeight
|
||||||
|
|
||||||
|
if (originalFullImageWidth < viewportWidth && originalFullImageHeight < viewportHeight) {
|
||||||
|
imgScaleFactor = maxScaleFactor
|
||||||
|
} else if (imageAspectRatio < viewportAspectRatio) {
|
||||||
|
imgScaleFactor = (viewportHeight / originalFullImageHeight) * maxScaleFactor
|
||||||
|
} else {
|
||||||
|
imgScaleFactor = (viewportWidth / originalFullImageWidth) * maxScaleFactor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerAnimation() {
|
||||||
|
targetImage.offsetWidth // repaint before animating
|
||||||
|
|
||||||
|
const imageOffset = offset(targetImage)
|
||||||
|
const scrollTop = window.scrollY
|
||||||
|
|
||||||
|
const viewportY = scrollTop + (window.innerHeight / 2)
|
||||||
|
const viewportX = (window.innerWidth / 2)
|
||||||
|
|
||||||
|
const imageCenterY = imageOffset.top + (targetImage.height / 2)
|
||||||
|
const imageCenterX = imageOffset.left + (targetImage.width / 2)
|
||||||
|
|
||||||
|
const translateY = viewportY - imageCenterY
|
||||||
|
const translateX = viewportX - imageCenterX
|
||||||
|
|
||||||
|
const targetImageTransform = `scale(${imgScaleFactor})`
|
||||||
|
const targetImageWrapTransform =
|
||||||
|
`translate(${translateX}px, ${translateY}px) translateZ(0)`
|
||||||
|
|
||||||
|
targetImage.style.webkitTransform = targetImageTransform
|
||||||
|
targetImage.style.msTransform = targetImageTransform
|
||||||
|
targetImage.style.transform = targetImageTransform
|
||||||
|
|
||||||
|
targetImageWrap.style.webkitTransform = targetImageWrapTransform
|
||||||
|
targetImageWrap.style.msTransform = targetImageWrapTransform
|
||||||
|
targetImageWrap.style.transform = targetImageWrapTransform
|
||||||
|
|
||||||
|
document.body.classList.add('zoom-overlay-open')
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
document.body.classList.remove('zoom-overlay-open')
|
||||||
|
document.body.classList.add('zoom-overlay-transitioning')
|
||||||
|
|
||||||
|
targetImage.style.webkitTransform = ''
|
||||||
|
targetImage.style.msTransform = ''
|
||||||
|
targetImage.style.transform = ''
|
||||||
|
|
||||||
|
targetImageWrap.style.webkitTransform = ''
|
||||||
|
targetImageWrap.style.msTransform = ''
|
||||||
|
targetImageWrap.style.transform = ''
|
||||||
|
|
||||||
|
if (!('transition' in document.body.style)) return dispose()
|
||||||
|
|
||||||
|
targetImage.addEventListener('transitionend', dispose)
|
||||||
|
targetImage.addEventListener('webkitTransitionEnd', dispose)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
targetImage.removeEventListener('transitionend', dispose)
|
||||||
|
targetImage.removeEventListener('webkitTransitionEnd', dispose)
|
||||||
|
|
||||||
|
if (!targetImageWrap || !targetImageWrap.parentNode) return
|
||||||
|
|
||||||
|
targetImage.classList.remove('zoom-img')
|
||||||
|
targetImage.style.width = ''
|
||||||
|
targetImage.setAttribute('data-action', 'zoom')
|
||||||
|
|
||||||
|
targetImageClone.parentNode.replaceChild(targetImage, targetImageClone)
|
||||||
|
targetImageWrap.parentNode.removeChild(targetImageWrap)
|
||||||
|
overlay.parentNode.removeChild(overlay)
|
||||||
|
|
||||||
|
document.body.classList.remove('zoom-overlay-transitioning')
|
||||||
|
}
|
||||||
|
|
||||||
|
return function (target) {
|
||||||
|
targetImage = target
|
||||||
|
return { zoomImage, close, dispose }
|
||||||
|
}
|
||||||
|
}())
|
||||||
|
|
||||||
|
zoomListener().listen()
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Zoom
|
169
client/src/utils/crypto.js
Normal file
169
client/src/utils/crypto.js
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
export default class Crypto {
|
||||||
|
constructor() {
|
||||||
|
this._crypto = window.crypto || false
|
||||||
|
|
||||||
|
if (!this._crypto || (!this._crypto.subtle && !this._crypto.webkitSubtle)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get crypto() {
|
||||||
|
return this._crypto
|
||||||
|
}
|
||||||
|
|
||||||
|
convertStringToArrayBufferView(str) {
|
||||||
|
const bytes = new Uint8Array(str.length)
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
bytes[i] = str.charCodeAt(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
return bytes
|
||||||
|
}
|
||||||
|
|
||||||
|
convertArrayBufferViewToString(buffer) {
|
||||||
|
let str = ''
|
||||||
|
for (let i = 0; i < buffer.byteLength; i++) {
|
||||||
|
str += String.fromCharCode(buffer[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
|
||||||
|
createEncryptDecryptKeys() {
|
||||||
|
return this.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: 'RSA-OAEP',
|
||||||
|
modulusLength: 2048, // can be 1024, 2048, or 4096
|
||||||
|
publicExponent: new Uint8Array([0x01, 0x00, 0x01]),
|
||||||
|
hash: { name: 'SHA-1' },
|
||||||
|
},
|
||||||
|
true, // whether the key is extractable (i.e. can be used in exportKey)
|
||||||
|
['encrypt', 'decrypt', 'wrapKey', 'unwrapKey'] // must be ['encrypt', 'decrypt'] or ['wrapKey', 'unwrapKey']
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createSecretKey() {
|
||||||
|
return this.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: 'AES-CBC',
|
||||||
|
length: 256, // can be 128, 192, or 256
|
||||||
|
},
|
||||||
|
true, // whether the key is extractable (i.e. can be used in exportKey)
|
||||||
|
['encrypt', 'decrypt'] // can be 'encrypt', 'decrypt', 'wrapKey', or 'unwrapKey'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createSigningKey() {
|
||||||
|
return this.crypto.subtle.generateKey(
|
||||||
|
{
|
||||||
|
name: 'HMAC',
|
||||||
|
hash: { name: 'SHA-256' },
|
||||||
|
},
|
||||||
|
true, // whether the key is extractable (i.e. can be used in exportKey)
|
||||||
|
['sign', 'verify'] // can be 'encrypt', 'decrypt', 'wrapKey', or 'unwrapKey'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
encryptMessage(data, secretKey, iv) {
|
||||||
|
return this.crypto.subtle.encrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-CBC',
|
||||||
|
// Don't re-use initialization vectors!
|
||||||
|
// Always generate a new iv every time your encrypt!
|
||||||
|
iv,
|
||||||
|
},
|
||||||
|
secretKey, // from generateKey or importKey above
|
||||||
|
data // ArrayBuffer of data you want to encrypt
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
decryptMessage(data, secretKey, iv) {
|
||||||
|
return this.crypto.subtle.decrypt(
|
||||||
|
{
|
||||||
|
name: 'AES-CBC',
|
||||||
|
iv, // The initialization vector you used to encrypt
|
||||||
|
},
|
||||||
|
secretKey, // from generateKey or importKey above
|
||||||
|
data // ArrayBuffer of the data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
importEncryptDecryptKey(jwkData, format = 'jwk', ops) {
|
||||||
|
const hashObj = {
|
||||||
|
name: 'RSA-OAEP',
|
||||||
|
hash: { name: 'SHA-1' },
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.crypto.subtle.importKey(
|
||||||
|
format, // can be 'jwk' (public or private), 'spki' (public only), or 'pkcs8' (private only)
|
||||||
|
jwkData,
|
||||||
|
hashObj,
|
||||||
|
true, // whether the key is extractable (i.e. can be used in exportKey)
|
||||||
|
ops || ['encrypt', 'wrapKey'] // 'encrypt' or 'wrapKey' for public key import or
|
||||||
|
// 'decrypt' or 'unwrapKey' for private key imports
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
exportKey(key, format) {
|
||||||
|
return this.crypto.subtle.exportKey(
|
||||||
|
format || 'jwk', // can be 'jwk' (public or private), 'spki' (public only), or 'pkcs8' (private only)
|
||||||
|
key // can be a publicKey or privateKey, as long as extractable was true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
signMessage(data, keyToSignWith) {
|
||||||
|
return this.crypto.subtle.sign(
|
||||||
|
{
|
||||||
|
name: 'HMAC',
|
||||||
|
hash: { name: 'SHA-256' },
|
||||||
|
},
|
||||||
|
keyToSignWith, // from generateKey or importKey above
|
||||||
|
data // ArrayBuffer of data you want to sign
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
verifyPayload(signature, data, keyToVerifyWith) {
|
||||||
|
// Will verify with sender's public key
|
||||||
|
return this.crypto.subtle.verify(
|
||||||
|
{
|
||||||
|
name: 'HMAC',
|
||||||
|
hash: { name: 'SHA-256' },
|
||||||
|
},
|
||||||
|
keyToVerifyWith, // from generateKey or importKey above
|
||||||
|
signature, // ArrayBuffer of the signature
|
||||||
|
data // ArrayBuffer of the data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapKey(keyToWrap, keyToWrapWith, format = 'jwk') {
|
||||||
|
return this.crypto.subtle.wrapKey(
|
||||||
|
format,
|
||||||
|
keyToWrap,
|
||||||
|
keyToWrapWith,
|
||||||
|
{
|
||||||
|
name: 'RSA-OAEP',
|
||||||
|
hash: { name: 'SHA-1' },
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
unwrapKey(
|
||||||
|
format = 'jwk',
|
||||||
|
wrappedKey,
|
||||||
|
unwrappingKey,
|
||||||
|
unwrapAlgo,
|
||||||
|
unwrappedKeyAlgo, // AES-CBC for session, HMAC for signing
|
||||||
|
extractable = true,
|
||||||
|
keyUsages// verify for signing // decrypt for session
|
||||||
|
) {
|
||||||
|
return this.crypto.subtle.unwrapKey(
|
||||||
|
format,
|
||||||
|
wrappedKey,
|
||||||
|
unwrappingKey,
|
||||||
|
unwrapAlgo,
|
||||||
|
unwrappedKeyAlgo,
|
||||||
|
extractable,
|
||||||
|
keyUsages
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
14
client/src/utils/dom.js
Normal file
14
client/src/utils/dom.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
export const getSelectedText = () => {
|
||||||
|
let text = ''
|
||||||
|
if (typeof window.getSelection !== 'undefined') {
|
||||||
|
text = window.getSelection().toString()
|
||||||
|
} else if (typeof document.selection !== 'undefined' && document.selection.type === 'Text') {
|
||||||
|
text = document.selection.createRange().text
|
||||||
|
}
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasTouchSupport =
|
||||||
|
('ontouchstart' in window)
|
||||||
|
|| (window.DocumentTouch && document instanceof window.DocumentTouch)
|
29
client/src/utils/file.js
Normal file
29
client/src/utils/file.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
export const getObjectUrl = (encodedFile, fileType) => {
|
||||||
|
const b64 = unescape(encodedFile)
|
||||||
|
const sliceSize = 1024
|
||||||
|
const byteCharacters = window.atob(b64)
|
||||||
|
const byteArrays = []
|
||||||
|
|
||||||
|
for (let offset = 0; offset < byteCharacters.length; offset += sliceSize) {
|
||||||
|
const slice = byteCharacters.slice(offset, offset + sliceSize)
|
||||||
|
|
||||||
|
const byteNumbers = new Array(slice.length)
|
||||||
|
for (let i = 0; i < slice.length; i++) {
|
||||||
|
byteNumbers[i] = slice.charCodeAt(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
const byteArray = new Uint8Array(byteNumbers)
|
||||||
|
|
||||||
|
byteArrays.push(byteArray)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (byteArrays.length <= 0) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new window.Blob(byteArrays, { type: fileType })
|
||||||
|
|
||||||
|
const url = window.URL.createObjectURL(blob)
|
||||||
|
return url
|
||||||
|
}
|
4
client/src/utils/index.js
Normal file
4
client/src/utils/index.js
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
/* eslint-disable import/prefer-default-export */
|
||||||
|
export function sanitize(str) {
|
||||||
|
return str.replace(/[^A-Za-z0-9]/g, '-').replace(/[<>]/ig, '')
|
||||||
|
}
|
122
client/src/utils/message.js
Normal file
122
client/src/utils/message.js
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
import Crypto from './crypto'
|
||||||
|
|
||||||
|
const crypto = new Crypto()
|
||||||
|
|
||||||
|
export const process = (payload, state) => new Promise(async (resolve, reject) => {
|
||||||
|
const privateKeyJson = state.user.privateKey
|
||||||
|
const privateKey = await crypto.importEncryptDecryptKey(privateKeyJson, 'jwk', ['decrypt', 'unwrapKey'])
|
||||||
|
|
||||||
|
let sessionKey
|
||||||
|
let signingKey
|
||||||
|
|
||||||
|
const iv = await crypto.convertStringToArrayBufferView(payload.iv)
|
||||||
|
const signature = await crypto.convertStringToArrayBufferView(payload.signature)
|
||||||
|
const payloadBuffer = await crypto.convertStringToArrayBufferView(payload.payload)
|
||||||
|
|
||||||
|
await new Promise((resolvePayload) => {
|
||||||
|
payload.keys.forEach(async (key) => {
|
||||||
|
try {
|
||||||
|
sessionKey = await crypto.unwrapKey(
|
||||||
|
'jwk',
|
||||||
|
key.sessionKey,
|
||||||
|
privateKey,
|
||||||
|
{
|
||||||
|
name: 'RSA-OAEP',
|
||||||
|
hash: { name: 'SHA-1' },
|
||||||
|
},
|
||||||
|
{ name: 'AES-CBC' },
|
||||||
|
true,
|
||||||
|
['decrypt']
|
||||||
|
)
|
||||||
|
|
||||||
|
signingKey = await crypto.unwrapKey(
|
||||||
|
'jwk',
|
||||||
|
key.signingKey,
|
||||||
|
privateKey,
|
||||||
|
{
|
||||||
|
name: 'RSA-OAEP',
|
||||||
|
hash: { name: 'SHA-1' },
|
||||||
|
},
|
||||||
|
{ name: 'HMAC', hash: { name: 'SHA-256' } },
|
||||||
|
true,
|
||||||
|
['verify']
|
||||||
|
)
|
||||||
|
resolvePayload()
|
||||||
|
} catch (e) { } // eslint-disable-line
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const verified = await crypto.verifyPayload(
|
||||||
|
signature,
|
||||||
|
payloadBuffer,
|
||||||
|
signingKey
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
reject()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedPayload = await crypto.decryptMessage(
|
||||||
|
payloadBuffer,
|
||||||
|
sessionKey,
|
||||||
|
iv
|
||||||
|
)
|
||||||
|
|
||||||
|
const payloadJson = JSON.parse(crypto.convertArrayBufferViewToString(new Uint8Array(decryptedPayload)))
|
||||||
|
|
||||||
|
resolve(payloadJson)
|
||||||
|
})
|
||||||
|
|
||||||
|
export const prepare = (payload, state) => new Promise(async (resolve) => {
|
||||||
|
const myUsername = state.user.username
|
||||||
|
const myId = state.user.id
|
||||||
|
|
||||||
|
const sessionKey = await crypto.createSecretKey()
|
||||||
|
const signingKey = await crypto.createSigningKey()
|
||||||
|
const iv = await crypto.crypto.getRandomValues(new Uint8Array(16))
|
||||||
|
|
||||||
|
const jsonToSend = {
|
||||||
|
...payload,
|
||||||
|
payload: {
|
||||||
|
...payload.payload,
|
||||||
|
sender: myId,
|
||||||
|
username: myUsername,
|
||||||
|
text: encodeURI(payload.payload.text),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const payloadBuffer = crypto.convertStringToArrayBufferView(JSON.stringify(jsonToSend))
|
||||||
|
|
||||||
|
const encryptedPayload = await crypto.encryptMessage(payloadBuffer, sessionKey, iv)
|
||||||
|
const payloadString = await crypto.convertArrayBufferViewToString(new Uint8Array(encryptedPayload))
|
||||||
|
|
||||||
|
const signature = await crypto.signMessage(encryptedPayload, signingKey)
|
||||||
|
|
||||||
|
const encryptedKeys = await Promise.all(state.room.members
|
||||||
|
.map(async (member) => {
|
||||||
|
const key = await crypto.importEncryptDecryptKey(member.publicKey)
|
||||||
|
const enc = await Promise.all([
|
||||||
|
crypto.wrapKey(sessionKey, key),
|
||||||
|
crypto.wrapKey(signingKey, key),
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
sessionKey: enc[0],
|
||||||
|
signingKey: enc[1],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
const ivString = await crypto.convertArrayBufferViewToString(new Uint8Array(iv))
|
||||||
|
const signatureString = await crypto.convertArrayBufferViewToString(new Uint8Array(signature))
|
||||||
|
|
||||||
|
resolve({
|
||||||
|
toSend: {
|
||||||
|
payload: payloadString,
|
||||||
|
signature: signatureString,
|
||||||
|
iv: ivString,
|
||||||
|
keys: encryptedKeys,
|
||||||
|
},
|
||||||
|
original: jsonToSend,
|
||||||
|
})
|
||||||
|
})
|
17
client/src/utils/socket.js
Normal file
17
client/src/utils/socket.js
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import socketIO from 'socket.io-client'
|
||||||
|
import apiConfig from '../api/config'
|
||||||
|
import generateUrl from '../api/generator';
|
||||||
|
|
||||||
|
let socket
|
||||||
|
|
||||||
|
export const connect = (roomId) => {
|
||||||
|
socket = socketIO(generateUrl(), {
|
||||||
|
query: {
|
||||||
|
roomId,
|
||||||
|
},
|
||||||
|
forceNew: true,
|
||||||
|
})
|
||||||
|
return socket
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getIO = () => socket
|
11228
client/yarn.lock
Normal file
11228
client/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
@ -20,7 +20,7 @@
|
|||||||
"build": "./build.sh",
|
"build": "./build.sh",
|
||||||
"start": "cd server && CLIENT_DIST_DIRECTORY='../client/build' yarn start",
|
"start": "cd server && CLIENT_DIST_DIRECTORY='../client/build' yarn start",
|
||||||
"dev": "concurrently 'cd client && yarn start' 'cd server && yarn dev'",
|
"dev": "concurrently 'cd client && yarn start' 'cd server && yarn dev'",
|
||||||
"setup": "./setup.sh"
|
"test": "echo 'tests'"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^4.1.0"
|
"concurrently": "^4.1.0"
|
||||||
|
17
readme.md
17
readme.md
@ -6,15 +6,11 @@ Simple encrypted web chat. Powered by [socket.io](http://socket.io), the [web cr
|
|||||||
|
|
||||||
### Darkwire Server
|
### Darkwire Server
|
||||||
|
|
||||||
Darkwire server is a Node.js application that requires redis.
|
[Darkwire server](/server) is a Node.js application that requires redis.
|
||||||
|
|
||||||
[darkwire-server](https://github.com/darkwire/darkwire-server)
|
|
||||||
|
|
||||||
### Darkwire Web Client
|
### Darkwire Web Client
|
||||||
|
|
||||||
The Darkwire.io web client is written in JavaScript with React JS and Redux.
|
The Darkwire.io [web client](/client) is written in JavaScript with React JS and Redux.
|
||||||
|
|
||||||
[darkwire-client](https://github.com/darkwire/darkwire-client)
|
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
@ -26,12 +22,6 @@ Install dependencies
|
|||||||
$ yarn
|
$ yarn
|
||||||
```
|
```
|
||||||
|
|
||||||
Pull down the client and server repos
|
|
||||||
|
|
||||||
```
|
|
||||||
$ yarn setup
|
|
||||||
```
|
|
||||||
|
|
||||||
Start server and client
|
Start server and client
|
||||||
|
|
||||||
```
|
```
|
||||||
@ -40,10 +30,9 @@ $ yarn dev
|
|||||||
|
|
||||||
### Production
|
### Production
|
||||||
|
|
||||||
Pull server and client repos down and create production builds of each.
|
Create server and client production builds
|
||||||
|
|
||||||
```
|
```
|
||||||
$ yarn setup
|
|
||||||
$ yarn build
|
$ yarn build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
7
server/.babelrc
Normal file
7
server/.babelrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"presets": ["@babel/preset-env"],
|
||||||
|
"plugins": [
|
||||||
|
"@babel/plugin-transform-async-to-generator",
|
||||||
|
"@babel/plugin-transform-runtime"
|
||||||
|
]
|
||||||
|
}
|
7
server/.env.sample
Normal file
7
server/.env.sample
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
MAILGUN_API_KEY=api-key
|
||||||
|
MAILGUN_DOMAIN=darkwire.io
|
||||||
|
ABUSE_TO_EMAIL_ADDRESS=abuse@darkwire.io
|
||||||
|
ABUSE_FROM_EMAIL_ADDRESS=Darkwire <no-reply@darkwire.io>
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
CLIENT_DIST_DIRECTORY='client/dist/path'
|
||||||
|
ROOM_HASH_SECRET='some-uuid'
|
7
server/.eslintrc
Normal file
7
server/.eslintrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"parser": "babel-eslint",
|
||||||
|
"rules": {
|
||||||
|
"semi": ["error", "always"],
|
||||||
|
"quotes": ["error", "single"]
|
||||||
|
}
|
||||||
|
}
|
7
server/.gitignore
vendored
Normal file
7
server/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
build
|
||||||
|
*.log
|
||||||
|
*.rdb
|
||||||
|
.env
|
||||||
|
*sublime*
|
21
server/LICENSE
Normal file
21
server/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.
|
3
server/README.md
Normal file
3
server/README.md
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# Darkwire Server
|
||||||
|
|
||||||
|
This is the backend for [Darkwire](https://github.com/darkwire/darkwire.io). It requires [darkwire-client](../client) in order to run.
|
9
server/build.sh
Executable file
9
server/build.sh
Executable file
@ -0,0 +1,9 @@
|
|||||||
|
rm -rf build
|
||||||
|
rm -rf dist
|
||||||
|
npx babel src -d dist/src --copy-files
|
||||||
|
|
||||||
|
sha=`git rev-parse HEAD`
|
||||||
|
|
||||||
|
echo $sha
|
||||||
|
|
||||||
|
perl -pi -e "s/SHA/$sha/g" dist/src/config.json
|
50
server/package.json
Normal file
50
server/package.json
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"name": "darkwire-server",
|
||||||
|
"version": "2.0.0-beta.8",
|
||||||
|
"main": "index.js",
|
||||||
|
"license": "MIT",
|
||||||
|
"contributors": [
|
||||||
|
{
|
||||||
|
"name": "Daniel Seripap"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Alan Friedman"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/plugin-transform-runtime": "^7.4.4",
|
||||||
|
"@babel/runtime": "^7.4.4",
|
||||||
|
"bluebird": "^3.5.1",
|
||||||
|
"dotenv": "^8.0.0",
|
||||||
|
"kcors": "2",
|
||||||
|
"koa": "^2.3.0",
|
||||||
|
"koa-body": "^2.3.0",
|
||||||
|
"koa-router": "^7.2.1",
|
||||||
|
"koa-send": "^5.0.0",
|
||||||
|
"koa-static": "^5.0.0",
|
||||||
|
"lodash": "^4.17.4",
|
||||||
|
"mailgun-js": "^0.22.0",
|
||||||
|
"moment": "^2.18.1",
|
||||||
|
"redis": "^2.8.0",
|
||||||
|
"socket.io": "^2.0.3",
|
||||||
|
"socket.io-redis": "^5.2.0",
|
||||||
|
"uuid": "^3.3.2"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "./build.sh",
|
||||||
|
"dev": "nodemon src/index.js --exec babel-node",
|
||||||
|
"test": "jest",
|
||||||
|
"test:watch": "jest --watch",
|
||||||
|
"start": "node dist/src/index.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/cli": "^7.4.4",
|
||||||
|
"@babel/core": "^7.4.4",
|
||||||
|
"@babel/node": "^7.2.2",
|
||||||
|
"@babel/plugin-transform-async-to-generator": "^7.4.4",
|
||||||
|
"@babel/preset-env": "^7.4.4",
|
||||||
|
"eslint": "^4.6.1",
|
||||||
|
"jest": "^21.0.2",
|
||||||
|
"nodemon": "^1.19.0"
|
||||||
|
}
|
||||||
|
}
|
4
server/src/config.json
Normal file
4
server/src/config.json
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"version": "VERSION",
|
||||||
|
"sha": "SHA"
|
||||||
|
}
|
143
server/src/index.js
Normal file
143
server/src/index.js
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
require('dotenv').config()
|
||||||
|
import http from 'http';
|
||||||
|
import https from 'https';
|
||||||
|
import Koa from 'koa';
|
||||||
|
import Io from 'socket.io';
|
||||||
|
import KoaBody from 'koa-body';
|
||||||
|
import cors from 'kcors';
|
||||||
|
import Router from 'koa-router';
|
||||||
|
import config from './config';
|
||||||
|
import bluebird from 'bluebird';
|
||||||
|
import Redis from 'redis';
|
||||||
|
import socketRedis from 'socket.io-redis';
|
||||||
|
import Socket from './socket';
|
||||||
|
import crypto from 'crypto'
|
||||||
|
import mailer from './utils/mailer';
|
||||||
|
import koaStatic from 'koa-static';
|
||||||
|
import koaSend from 'koa-send';
|
||||||
|
|
||||||
|
bluebird.promisifyAll(Redis.RedisClient.prototype);
|
||||||
|
bluebird.promisifyAll(Redis.Multi.prototype);
|
||||||
|
|
||||||
|
const redis = Redis.createClient(process.env.REDIS_URL)
|
||||||
|
|
||||||
|
export const getRedis = () => redis
|
||||||
|
|
||||||
|
const env = process.env.NODE_ENV || 'development';
|
||||||
|
|
||||||
|
const app = new Koa();
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
|
||||||
|
const router = new Router();
|
||||||
|
const koaBody = new KoaBody();
|
||||||
|
|
||||||
|
app.use(cors({
|
||||||
|
credentials: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
router.post('/handshake', koaBody, async (ctx) => {
|
||||||
|
const { body } = ctx.request;
|
||||||
|
const { roomId } = body;
|
||||||
|
|
||||||
|
const roomIdHash = getRoomIdHash(roomId)
|
||||||
|
|
||||||
|
let roomExists = await redis.hgetAsync('rooms', roomIdHash)
|
||||||
|
if (roomExists) {
|
||||||
|
roomExists = JSON.parse(roomExists)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.body = {
|
||||||
|
id: roomId,
|
||||||
|
ready: true,
|
||||||
|
isLocked: Boolean(roomExists && roomExists.isLocked),
|
||||||
|
size: ((roomExists && roomExists.users.length) || 0) + 1,
|
||||||
|
version: config.version,
|
||||||
|
sha: config.sha,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/abuse/:roomId', koaBody, async (ctx) => {
|
||||||
|
let { roomId } = ctx.params;
|
||||||
|
|
||||||
|
roomId = roomId.trim();
|
||||||
|
|
||||||
|
if (process.env.ABUSE_FROM_EMAIL_ADDRESS && process.env.ABUSE_TO_EMAIL_ADDRESS) {
|
||||||
|
const abuseForRoomExists = await redis.hgetAsync('abuse', roomId);
|
||||||
|
if (!abuseForRoomExists) {
|
||||||
|
mailer.send({
|
||||||
|
from: process.env.ABUSE_FROM_EMAIL_ADDRESS,
|
||||||
|
to: process.env.ABUSE_TO_EMAIL_ADDRESS,
|
||||||
|
subject: 'Darkwire Abuse Notification',
|
||||||
|
text: `Room ID: ${roomId}`
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await redis.hincrbyAsync('abuse', roomId, 1);
|
||||||
|
|
||||||
|
ctx.status = 200;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(router.routes());
|
||||||
|
|
||||||
|
const clientDistDirectory = process.env.CLIENT_DIST_DIRECTORY;
|
||||||
|
if (clientDistDirectory) {
|
||||||
|
app.use(koaStatic(clientDistDirectory));
|
||||||
|
|
||||||
|
app.use(async (ctx) => {
|
||||||
|
await koaSend(ctx, 'index.html', { root: clientDistDirectory });
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
app.use(async ctx => {
|
||||||
|
ctx.body = { ready: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const protocol = (process.env.PROTOCOL || 'http') === 'http' ? http : https;
|
||||||
|
|
||||||
|
const server = protocol.createServer(app.callback());
|
||||||
|
const io = Io(server);
|
||||||
|
io.adapter(socketRedis(process.env.REDIS_URL));
|
||||||
|
|
||||||
|
const roomHashSecret = process.env.ROOM_HASH_SECRET;
|
||||||
|
|
||||||
|
const getRoomIdHash = (id) => {
|
||||||
|
if (env === 'development') {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
if (roomHashSecret) {
|
||||||
|
return crypto
|
||||||
|
.createHmac('sha256', roomHashSecret)
|
||||||
|
.update(id)
|
||||||
|
.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
return crypto.createHash('sha256').update(id).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getIO = () => io
|
||||||
|
|
||||||
|
io.on('connection', async (socket) => {
|
||||||
|
const roomId = socket.handshake.query.roomId
|
||||||
|
|
||||||
|
const roomIdHash = getRoomIdHash(roomId)
|
||||||
|
|
||||||
|
let room = await redis.hgetAsync('rooms', roomIdHash)
|
||||||
|
room = JSON.parse(room || '{}')
|
||||||
|
|
||||||
|
new Socket({
|
||||||
|
roomId: roomIdHash,
|
||||||
|
socket,
|
||||||
|
room,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Darkwire is online at port ${PORT}`);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
init()
|
||||||
|
|
134
server/src/socket.js
Normal file
134
server/src/socket.js
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import _ from 'lodash';
|
||||||
|
import uuid from 'uuid/v4';
|
||||||
|
import { getIO, getRedis } from './index'
|
||||||
|
|
||||||
|
export default class Socket {
|
||||||
|
constructor(opts) {
|
||||||
|
const { roomId, socket, room } = opts
|
||||||
|
|
||||||
|
this._roomId = roomId
|
||||||
|
if (room.isLocked) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.init(opts)
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(opts) {
|
||||||
|
const { roomId, socket, room } = opts
|
||||||
|
await this.joinRoom(roomId, socket.id)
|
||||||
|
this.handleSocket(socket)
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveRoom(room) {
|
||||||
|
const json = {
|
||||||
|
...room,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
return getRedis().hsetAsync('rooms', this._roomId, JSON.stringify(json))
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroyRoom() {
|
||||||
|
return getRedis().hdel('rooms', this._roomId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchRoom() {
|
||||||
|
return new Promise(async (resolve, reject) => {
|
||||||
|
const res = await getRedis().hgetAsync('rooms', this._roomId)
|
||||||
|
resolve(JSON.parse(res || '{}'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
joinRoom(roomId, socketId) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
getIO().of('/').adapter.remoteJoin(socketId, roomId, (err) => {
|
||||||
|
if (err) {
|
||||||
|
reject()
|
||||||
|
}
|
||||||
|
resolve()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSocket(socket) {
|
||||||
|
socket.on('PAYLOAD', (payload) => {
|
||||||
|
socket.to(this._roomId).emit('PAYLOAD', payload);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('USER_ENTER', async payload => {
|
||||||
|
let room = await this.fetchRoom()
|
||||||
|
if (_.isEmpty(room)) {
|
||||||
|
room = {
|
||||||
|
id: this._roomId,
|
||||||
|
users: [],
|
||||||
|
isLocked: false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRoom = {
|
||||||
|
...room,
|
||||||
|
users: [...(room.users || []), {
|
||||||
|
socketId: socket.id,
|
||||||
|
publicKey: payload.publicKey,
|
||||||
|
isOwner: (room.users || []).length === 0,
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
await this.saveRoom(newRoom)
|
||||||
|
getIO().to(this._roomId).emit('USER_ENTER', newRoom.users.map(u => ({
|
||||||
|
publicKey: u.publicKey,
|
||||||
|
isOwner: u.isOwner,
|
||||||
|
})));
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('TOGGLE_LOCK_ROOM', async (data, callback) => {
|
||||||
|
const room = await this.fetchRoom()
|
||||||
|
const user = (room.users || []).find(u => u.socketId === socket.id && u.isOwner)
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
callback({
|
||||||
|
isLocked: room.isLocked,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.saveRoom({
|
||||||
|
...room,
|
||||||
|
isLocked: !room.isLocked,
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.to(this._roomId).emit('TOGGLE_LOCK_ROOM', {
|
||||||
|
locked: !room.isLocked,
|
||||||
|
publicKey: user && user.publicKey
|
||||||
|
});
|
||||||
|
|
||||||
|
callback({
|
||||||
|
isLocked: !room.isLocked,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', () => this.handleDisconnect(socket));
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleDisconnect(socket) {
|
||||||
|
let room = await this.fetchRoom()
|
||||||
|
|
||||||
|
const newRoom = {
|
||||||
|
...room,
|
||||||
|
users: (room.users || []).filter(u => u.socketId !== socket.id).map((u, index) => ({
|
||||||
|
...u,
|
||||||
|
isOwner: index === 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.saveRoom(newRoom)
|
||||||
|
|
||||||
|
getIO().to(this._roomId).emit('USER_EXIT', newRoom.users);
|
||||||
|
|
||||||
|
if (newRoom.users && newRoom.users.length === 0) {
|
||||||
|
await this.destroyRoom()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
3
server/src/utils/index.js
Normal file
3
server/src/utils/index.js
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export function sanitize(str) {
|
||||||
|
return str.replace(/[^A-Za-z0-9]/g, '-');
|
||||||
|
}
|
24
server/src/utils/mailer.js
Normal file
24
server/src/utils/mailer.js
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
require('dotenv').config()
|
||||||
|
|
||||||
|
const {
|
||||||
|
MAILGUN_API_KEY,
|
||||||
|
MAILGUN_DOMAIN,
|
||||||
|
} = process.env;
|
||||||
|
|
||||||
|
const apiKey = MAILGUN_API_KEY;
|
||||||
|
const domain = MAILGUN_DOMAIN;
|
||||||
|
|
||||||
|
let mailgun;
|
||||||
|
|
||||||
|
if (apiKey && domain) {
|
||||||
|
mailgun = require('mailgun-js')({apiKey, domain});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.send = (data) => {
|
||||||
|
if (!mailgun) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
mailgun.messages().send(data, function (error, body) {
|
||||||
|
|
||||||
|
});
|
||||||
|
}
|
5
server/src/utils/utils.test.js
Normal file
5
server/src/utils/utils.test.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
import { sanitize } from './index.js';
|
||||||
|
|
||||||
|
test('sanitizes should strip bad characters', () => {
|
||||||
|
expect(sanitize('d@rkW1r# e is L3git&&!&*A*')).toBe('d-rkW1r--e-is-L3git-----A-');
|
||||||
|
});
|
5988
server/yarn.lock
Normal file
5988
server/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user