Compare commits
32 Commits
v22.7.22-1
...
main
| Author | SHA1 | Date |
|---|---|---|
|
|
13aff451a8 | |
|
|
d595cc7373 | |
|
|
7bea8c08ca | |
|
|
91a81d6699 | |
|
|
b73ab2c691 | |
|
|
1aead4cc6b | |
|
|
46d3f00280 | |
|
|
c0289d92e3 | |
|
|
e775e4a240 | |
|
|
ea0cb96a24 | |
|
|
64b5d092a4 | |
|
|
0bfe2d9719 | |
|
|
f3c8a2e482 | |
|
|
98a1906860 | |
|
|
92913efdc9 | |
|
|
d7addbb496 | |
|
|
fd180cca7a | |
|
|
277d92d97a | |
|
|
56b71709e2 | |
|
|
95bb2c6b46 | |
|
|
93ad0dfb1a | |
|
|
30ebc0fcf1 | |
|
|
a1784f5cb2 | |
|
|
6b496cc045 | |
|
|
0be5d1b3dd | |
|
|
00f6ecae52 | |
|
|
3cbac09f57 | |
|
|
e2ae16bd2c | |
|
|
0fcf5796f7 | |
|
|
1948117ce7 | |
|
|
cc1f84cadc | |
|
|
2e89be3b84 |
|
|
@ -20,12 +20,15 @@
|
|||
],
|
||||
"ignorePatterns": [
|
||||
"node_modules/**",
|
||||
"**/dist/**"
|
||||
"**/dist/**",
|
||||
"cordova/**",
|
||||
"scripts/**"
|
||||
],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-var-requires": "off",
|
||||
"@typescript-eslint/consistent-type-imports": "error",
|
||||
"no-undef": "off",
|
||||
|
||||
/**
|
||||
* Having a semicolon helps the optimizer interpret your code correctly.
|
||||
|
|
|
|||
|
|
@ -59,3 +59,5 @@ thumbs.db
|
|||
|
||||
# docker data
|
||||
docker-volume
|
||||
|
||||
storage
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import {resolve, sep} from 'path';
|
||||
|
||||
export default {
|
||||
'*.{js,ts,tsx}': 'eslint --cache --fix',
|
||||
|
||||
/**
|
||||
* Run typechecking if any type-sensitive files was staged
|
||||
* @param {string[]} filenames
|
||||
* @return {string[]}
|
||||
*/
|
||||
'packages/**/{*.ts,*.vue,tsconfig.json}': ({filenames}) => {
|
||||
const pathToPackages = resolve(process.cwd(), 'packages') + sep;
|
||||
return Array.from(
|
||||
filenames.reduce((set, filename) => {
|
||||
const pack = filename.replace(pathToPackages, '').split(sep)[0];
|
||||
set.add(`npm run typecheck:${pack} --if-present`);
|
||||
return set;
|
||||
}, new Set),
|
||||
);
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
#
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
.DS_Store
|
||||
|
||||
# Generated by package manager
|
||||
node_modules/
|
||||
|
||||
# Generated by Cordova
|
||||
/plugins/
|
||||
/platforms/
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version='1.0' encoding='utf-8'?>
|
||||
<widget id="io.cordova.hellocordova" version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
|
||||
<name>HelloCordova</name>
|
||||
<description>Sample Apache Cordova App</description>
|
||||
<author email="dev@cordova.apache.org" href="https://cordova.apache.org">
|
||||
Apache Cordova Team
|
||||
</author>
|
||||
<content src="index.html" />
|
||||
<allow-intent href="http://*/*" />
|
||||
<allow-intent href="https://*/*" />
|
||||
</widget>
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,31 @@
|
|||
{
|
||||
"name": "io.cordova.hellocordova",
|
||||
"displayName": "HelloCordova",
|
||||
"version": "1.0.0",
|
||||
"description": "A sample Apache Cordova application that responds to the deviceready event.",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1",
|
||||
"cordova": "cordova"
|
||||
},
|
||||
"keywords": [
|
||||
"ecosystem:cordova"
|
||||
],
|
||||
"author": "Apache Cordova Team",
|
||||
"license": "Apache-2.0",
|
||||
"cordova": {
|
||||
"platforms": [
|
||||
"android"
|
||||
],
|
||||
"plugins": {
|
||||
"cordova-plugin-qrscanner": {}
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"cordova": "^11.0.0",
|
||||
"cordova-plugin-qrscanner-mm": "^4.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cordova-android": "^10.1.2"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,35 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<!--
|
||||
Customize this policy to fit your own app's needs. For more guidance, please refer to the docs:
|
||||
https://cordova.apache.org/docs/en/latest/
|
||||
Some notes:
|
||||
* https://ssl.gstatic.com is required only on Android and is needed for TalkBack to function properly
|
||||
* Disables use of inline scripts in order to mitigate risk of XSS vulnerabilities. To change this:
|
||||
* Enable inline JS: add 'unsafe-inline' to default-src
|
||||
-->
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self' data: https://ssl.gstatic.com 'unsafe-eval'; style-src 'self' 'unsafe-inline'; media-src *; img-src 'self' data: content:; connect-src *;">
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="msapplication-tap-highlight" content="no">
|
||||
<meta name="viewport" content="user-scalable=no, width=device-width, initial-scale=1.0" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>Hello World</title>
|
||||
<style>
|
||||
html, body {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
body {
|
||||
position: relative
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0px; overflow: hidden;">
|
||||
<div id="app" style="width: 100vw; height: 100vh;"></div>
|
||||
|
||||
<script src="cordova.js"></script>
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
|
||||
|
||||
|
||||
|
||||
|
||||
document.addEventListener('deviceready', onDeviceReady, false);
|
||||
|
||||
function onDeviceReady() {
|
||||
// alert(cordova.platformId + " " + cordova.version);
|
||||
|
||||
var my_awesome_script = document.createElement('script');
|
||||
my_awesome_script.setAttribute('src', 'app.js');
|
||||
my_awesome_script.setAttribute('type', 'module');
|
||||
my_awesome_script.setAttribute('crossorigin', true);
|
||||
|
||||
document.head.appendChild(my_awesome_script);
|
||||
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ services:
|
|||
db:
|
||||
image: mariadb
|
||||
restart: always
|
||||
command: --max_allowed_packet=200M
|
||||
environment:
|
||||
MARIADB_ROOT_PASSWORD: example
|
||||
MARIADB_DATABASE: corner
|
||||
|
|
@ -19,3 +20,18 @@ services:
|
|||
restart: always
|
||||
ports:
|
||||
- 8080:8080
|
||||
|
||||
# coturn:
|
||||
# image: coturn/coturn
|
||||
# restart: always
|
||||
# ports:
|
||||
# - 3478:3478
|
||||
# - 3478:3478/udp
|
||||
# - 5349:5349
|
||||
# - 5349:5349/udp
|
||||
# - 49160-49200:49160-49200/udp
|
||||
|
||||
# docker run -d -p 3478:3478 -p 3478:3478/udp -p 5349:5349 -p 5349:5349/udp -p 49160-49200:49160-49200/udp \
|
||||
# coturn/coturn -n --log-file=stdout \
|
||||
# --external-ip='$(detect-external-ip)' \
|
||||
# --min-port=49160 --max-port=49200
|
||||
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
|
|
@ -32,7 +32,8 @@
|
|||
"typecheck:renderer": "vue-tsc --noEmit -p packages/renderer/tsconfig.json",
|
||||
"typecheck:server": "tsc --noEmit -p packages/server/tsconfig.json",
|
||||
"typecheck": "npm run typecheck:main && npm run typecheck:preload && npm run typecheck:renderer && npm run typecheck:server",
|
||||
"postinstall": "cross-env ELECTRON_RUN_AS_NODE=1 npx --no-install electron ./scripts/update-electron-vendors.js "
|
||||
"postinstall": "cross-env ELECTRON_RUN_AS_NODE=1 npx --no-install electron ./scripts/update-electron-vendors.js ",
|
||||
"cordova": "cordova"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "5.30.6",
|
||||
|
|
@ -47,7 +48,6 @@
|
|||
"happy-dom": "6.0.4",
|
||||
"nano-staged": "0.8.0",
|
||||
"playwright": "1.23.4",
|
||||
"simple-git-hooks": "2.8.0",
|
||||
"typescript": "4.7.4",
|
||||
"unplugin-auto-expose": "0.0.1",
|
||||
"vite": "3.0.0",
|
||||
|
|
@ -55,22 +55,40 @@
|
|||
"vue-tsc": "0.38.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/express": "^4.17.13",
|
||||
"@types/express-ws": "^3.0.1",
|
||||
"@types/mysql": "^2.15.21",
|
||||
"@types/qrcode": "^1.4.2",
|
||||
"@types/react": "^18.0.15",
|
||||
"@types/react-dom": "^18.0.6",
|
||||
"@types/react-timeago": "^4.1.3",
|
||||
"@types/tmp": "^0.2.3",
|
||||
"@types/totp-generator": "^0.0.4",
|
||||
"@types/uuid": "^8.3.4",
|
||||
"@types/ws": "^8.5.3",
|
||||
"@vitejs/plugin-react": "^2.0.0",
|
||||
"chalk": "^4.1.2",
|
||||
"cordova": "^11.0.0",
|
||||
"electron-updater": "5.0.5",
|
||||
"eslint-plugin-react": "^7.30.1",
|
||||
"express": "^4.18.1",
|
||||
"express-ws": "^5.0.2",
|
||||
"fs-extra": "^10.1.0",
|
||||
"get-port": "^6.1.2",
|
||||
"local-storage": "^2.0.0",
|
||||
"mysql": "^2.18.1",
|
||||
"peerjs": "^1.4.6",
|
||||
"qrcode": "^1.5.1",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-icons": "^4.4.0",
|
||||
"react-spinners": "^0.13.4",
|
||||
"react-time-ago": "^7.2.1",
|
||||
"react-timeago": "^7.1.0",
|
||||
"react-toastify": "^9.0.8",
|
||||
"reactjs-popup": "^2.0.5",
|
||||
"tmp": "^0.2.1",
|
||||
"totp-generator": "^0.0.13",
|
||||
"uuid": "^8.3.2",
|
||||
"vue": "3.2.37",
|
||||
"ws": "^8.8.1"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
import {BrowserWindow} from 'electron';
|
||||
import {
|
||||
BrowserWindow,
|
||||
session
|
||||
} from 'electron';
|
||||
import {join} from 'path';
|
||||
import {URL} from 'url';
|
||||
|
||||
|
|
@ -10,8 +13,11 @@ async function createWindow() {
|
|||
webviewTag: false, // The webview tag is not recommended. Consider alternatives like iframe or Electron's BrowserView. https://www.electronjs.org/docs/latest/api/webview-tag#warning
|
||||
preload: join(__dirname, '../../preload/dist/index.cjs'),
|
||||
},
|
||||
|
||||
});
|
||||
|
||||
browserWindow.setMenu(null);
|
||||
|
||||
/**
|
||||
* If you install `show: true` then it can cause issues when trying to close the window.
|
||||
* Use `show: false` and listener events `ready-to-show` to fix these issues.
|
||||
|
|
@ -38,6 +44,11 @@ async function createWindow() {
|
|||
|
||||
await browserWindow.loadURL(pageUrl);
|
||||
|
||||
// session.fromPartition('default').setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
// console.log('requested permission', permission);
|
||||
|
||||
// })
|
||||
|
||||
return browserWindow;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,11 +6,27 @@ import {URL} from 'url';
|
|||
*
|
||||
* In development mode you need allow open `VITE_DEV_SERVER_URL`
|
||||
*/
|
||||
const ALLOWED_ORIGINS_AND_PERMISSIONS = new Map<string, Set<'clipboard-read' | 'media' | 'display-capture' | 'mediaKeySystem' | 'geolocation' | 'notifications' | 'midi' | 'midiSysex' | 'pointerLock' | 'fullscreen' | 'openExternal' | 'unknown'>>(
|
||||
type PermissionScope =
|
||||
'clipboard-read'
|
||||
| 'media'
|
||||
| 'display-capture'
|
||||
| 'mediaKeySystem'
|
||||
| 'geolocation'
|
||||
| 'notifications'
|
||||
| 'midi'
|
||||
| 'midiSysex'
|
||||
| 'pointerLock'
|
||||
| 'fullscreen'
|
||||
| 'openExternal'
|
||||
| 'unknown';
|
||||
const ALLOWED_ORIGINS = new Set<string>(
|
||||
import.meta.env.DEV && import.meta.env.VITE_DEV_SERVER_URL
|
||||
? [[new URL(import.meta.env.VITE_DEV_SERVER_URL).origin, new Set]]
|
||||
? [new URL(import.meta.env.VITE_DEV_SERVER_URL).origin]
|
||||
: [],
|
||||
);
|
||||
const ALLOWED_PERMISSIONS: PermissionScope[] = [
|
||||
'media'
|
||||
]
|
||||
|
||||
/**
|
||||
* List of origins that you allow open IN BROWSER.
|
||||
|
|
@ -39,7 +55,7 @@ app.on('web-contents-created', (_, contents) => {
|
|||
*/
|
||||
contents.on('will-navigate', (event, url) => {
|
||||
const {origin} = new URL(url);
|
||||
if (ALLOWED_ORIGINS_AND_PERMISSIONS.has(origin)) {
|
||||
if (ALLOWED_ORIGINS.has(origin)) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +77,7 @@ app.on('web-contents-created', (_, contents) => {
|
|||
contents.session.setPermissionRequestHandler((webContents, permission, callback) => {
|
||||
const {origin} = new URL(webContents.getURL());
|
||||
|
||||
const permissionGranted = !!ALLOWED_ORIGINS_AND_PERMISSIONS.get(origin)?.has(permission);
|
||||
const permissionGranted = !!ALLOWED_PERMISSIONS.includes(permission)
|
||||
callback(permissionGranted);
|
||||
|
||||
if (!permissionGranted && import.meta.env.DEV) {
|
||||
|
|
@ -106,7 +122,7 @@ app.on('web-contents-created', (_, contents) => {
|
|||
*/
|
||||
contents.on('will-attach-webview', (event, webPreferences, params) => {
|
||||
const {origin} = new URL(params.src);
|
||||
if (!ALLOWED_ORIGINS_AND_PERMISSIONS.has(origin)) {
|
||||
if (!ALLOWED_ORIGINS.has(origin)) {
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
console.warn(`A webview tried to attach ${params.src}, but was blocked.`);
|
||||
|
|
|
|||
|
|
@ -2,5 +2,12 @@
|
|||
* @module preload
|
||||
*/
|
||||
|
||||
export {sha256sum} from './nodeCrypto';
|
||||
export {
|
||||
getClientId,
|
||||
setClientId,
|
||||
getHomeServer,
|
||||
setHomeServer,
|
||||
getSessionToken,
|
||||
setSessionToken
|
||||
} from './settings';
|
||||
export {versions} from './versions';
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
import { resolve } from 'path';
|
||||
import {
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
mkdirSync,
|
||||
existsSync,
|
||||
} from 'fs';
|
||||
import { URL } from 'url';
|
||||
|
||||
const appdataPath = process.env.APPDATA || // windows
|
||||
(process.platform == 'darwin' ?
|
||||
process.env.HOME + '/Library/Preferences' : //macos
|
||||
process.env.HOME + '/.local/share'); // linux
|
||||
|
||||
const cornerDataPath = resolve(appdataPath, 'corner');
|
||||
const clientIdPath = resolve(cornerDataPath, 'clientId');
|
||||
const homeServerPath = resolve(cornerDataPath, 'homeServer');
|
||||
const sessionTokenPath = resolve(cornerDataPath, 'sessionToken');
|
||||
|
||||
// --- setup ---
|
||||
|
||||
if(!existsSync(cornerDataPath))
|
||||
mkdirSync(cornerDataPath);
|
||||
if(!existsSync(clientIdPath))
|
||||
writeFileSync(clientIdPath, '');
|
||||
if(!existsSync(homeServerPath))
|
||||
writeFileSync(homeServerPath, '');
|
||||
if(!existsSync(sessionTokenPath))
|
||||
writeFileSync(sessionTokenPath, '');
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
function validUuid(uuid: string) {
|
||||
return uuid.length === 36;
|
||||
}
|
||||
|
||||
// --- export ---
|
||||
|
||||
export function getClientId() {
|
||||
const fileContents = readFileSync(clientIdPath).toString();
|
||||
if(!validUuid(fileContents)) return null;
|
||||
return fileContents;
|
||||
}
|
||||
|
||||
export function setClientId(id: string | null) {
|
||||
writeFileSync(clientIdPath, id ?? '');
|
||||
}
|
||||
|
||||
export function getHomeServer() {
|
||||
const url = readFileSync(homeServerPath).toString()
|
||||
try {
|
||||
new URL(url);
|
||||
return url;
|
||||
} catch(e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function setHomeServer(url: string | null) {
|
||||
writeFileSync(homeServerPath, url ?? '');
|
||||
}
|
||||
|
||||
export function getSessionToken() {
|
||||
const token = readFileSync(sessionTokenPath).toString();
|
||||
if(token.length !== 512) return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
export function setSessionToken(token: string | null) {
|
||||
writeFileSync(sessionTokenPath, token ?? '');
|
||||
}
|
||||
|
|
@ -1,20 +1,5 @@
|
|||
import {createHash} from 'crypto';
|
||||
import {expect, test} from 'vitest';
|
||||
import {versions, sha256sum} from '../src';
|
||||
|
||||
test('versions', async () => {
|
||||
expect(versions).toBe(process.versions);
|
||||
});
|
||||
|
||||
|
||||
test('nodeCrypto', async () => {
|
||||
/**
|
||||
* Random string to test hashing
|
||||
*/
|
||||
const testString = Math.random().toString(36).slice(2, 7);
|
||||
const expectedHash = createHash('sha256')
|
||||
.update(testString)
|
||||
.digest('hex');
|
||||
|
||||
expect(sha256sum(testString)).toBe(expectedHash);
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 366 KiB |
|
|
@ -3,11 +3,18 @@
|
|||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<!-- <meta http-equiv="Content-Security-Policy" content="script-src 'self' blob:"> -->
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
default-src 'self' data: https://ssl.gstatic.com https://fonts.gstatic.com;
|
||||
style-src 'self' 'unsafe-inline' https://fonts.googleapis.com;
|
||||
media-src * data:;
|
||||
img-src 'self' data: content: *;
|
||||
connect-src *;">
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport">
|
||||
<title>Vite App</title>
|
||||
</head>
|
||||
<body style="margin: 0px; overflow: hidden; color: #f8f8f2; font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif">
|
||||
<div id="app" style="width: 100vw; height: 100vh; background: #282a36"></div>
|
||||
<body style=" margin: 0px; overflow: hidden;">
|
||||
<div id="app" style="width: 100vw; height: 100vh;"></div>
|
||||
<div id="portal-root"></div>
|
||||
<script src="./src/index.tsx" type="module"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
import { createContext, useCallback, useEffect, useState, useMemo } from 'react';
|
||||
import Channels from './pages/Channels';
|
||||
import Chat from './pages/Chat';
|
||||
import Sidebar from './components/TwoPanel';
|
||||
import NewAccount from './pages/NewAccount';
|
||||
import ServerConnection from './components/ServerConnection';
|
||||
import EphemeralState from './contexts/EphemeralState/EphemeralState';
|
||||
import PersistentState from '/@/contexts/PersistentState/PersistentState';
|
||||
import Router from './Router';
|
||||
import { ToastContainer } from 'react-toastify';
|
||||
|
||||
export default function App() {
|
||||
const [transparent, setTransparent] = useState(false);
|
||||
|
||||
|
||||
// font-size: 16px;
|
||||
// font-family: 'Lato', sans-serif;
|
||||
// font-family: 'Red Hat Text', sans-serif;
|
||||
// font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
|
||||
// color: #f8f8f2;
|
||||
// background: #282a36;
|
||||
|
||||
return (
|
||||
<>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin='' />
|
||||
<link href={"https://fonts.googleapis.com/css2?family=Fira+Sans&family=Josefin+Sans&family=Lato&family=Radio+Canada&family=Readex+Pro&family=Red+Hat+Text:wght@200;300;400;500;600;700;800;900&family=Rubik&family=Signika&family=Telex&display=swap"} rel="stylesheet" />
|
||||
<style>{`
|
||||
html {
|
||||
--background: #282a36;
|
||||
--current-line: #44475a;
|
||||
--foreground: #f8f8f2;
|
||||
--comment: #6272a4;
|
||||
--cyan: #8be9fd;
|
||||
--green: #50fa7b;
|
||||
--orange: #ffb86c;
|
||||
--pink: #ff79c6;
|
||||
--purple: #bd93f9;
|
||||
--red: #ff5555;
|
||||
--yellow: #f1fa8c;
|
||||
--primary: var(--purple);
|
||||
--neutral-1: #191a21;
|
||||
--neutral-2: #21222c;
|
||||
--neutral-3: #282a36;
|
||||
--neutral-4: #343746;
|
||||
--neutral-5: #44475a;
|
||||
--neutral-6: #717380;
|
||||
--neutral-7: #9ea0a6;
|
||||
--neutral-8: #cbcccc;
|
||||
--neutral-9: #f8f8f2;
|
||||
|
||||
--green: #4db560;
|
||||
}
|
||||
a {
|
||||
color: var(--cyan);
|
||||
}
|
||||
fieldset {
|
||||
margin: 8px;
|
||||
border-radius: 16px;
|
||||
border-style: solid;
|
||||
}
|
||||
legend {
|
||||
border-width: 2px;
|
||||
border-style: solid;
|
||||
border-radius: 16px;
|
||||
padding: 0px 8px;
|
||||
}
|
||||
/* width */
|
||||
::-webkit-scrollbar {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* Track */
|
||||
::-webkit-scrollbar-track {
|
||||
background: unset
|
||||
}
|
||||
|
||||
/* Handle */
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
box-shadow: inset 0px -10px -10px 0px var(--neutral-3);
|
||||
}
|
||||
|
||||
/* Handle on hover */
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #555;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:active {
|
||||
background: var(--neutral-9);
|
||||
}
|
||||
`}</style>
|
||||
<div style={{
|
||||
background: transparent ? 'rgba(0, 0, 0, 0)' : 'var(--neutral-3)',
|
||||
color: transparent ? 'black' : 'var(--foreground)',
|
||||
fontSize: '16px',
|
||||
fontFamily: "'Red Hat Text', sans-serif",
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}>
|
||||
<PersistentState>
|
||||
<EphemeralState onTransparencyChange={setTransparent}>
|
||||
<Router></Router>
|
||||
</EphemeralState>
|
||||
</PersistentState>
|
||||
|
||||
<ToastContainer />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import { useContext, useEffect } from "react";
|
||||
import ServerConnection from "./components/ServerConnection";
|
||||
import Sidebar from "./components/Sidebar";
|
||||
import TwoPanel from "./components/TwoPanel";
|
||||
import Voice from "./components/Voice";
|
||||
import ClientsListState from "./contexts/EphemeralState/ClientsListState";
|
||||
import { SettingsContext } from "./contexts/EphemeralState/EphemeralState";
|
||||
import useHomeServer from "./contexts/PersistentState/useHomeServerNative";
|
||||
import useChannel from "./hooks/useChannel";
|
||||
import useClientId from "./hooks/useClientId";
|
||||
import useSessionToken from "./hooks/useSessionToken";
|
||||
import Channels from "./pages/Channels";
|
||||
import Chat from "./pages/Chat";
|
||||
import NewAccount from "./pages/NewAccount";
|
||||
import Settings from "./pages/Settings";
|
||||
|
||||
interface RouterProps {
|
||||
[name: string]: React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function Router(props: RouterProps) {
|
||||
|
||||
const { clientId } = useClientId();
|
||||
const { sessionToken } = useSessionToken();
|
||||
const { homeServer } = useHomeServer();
|
||||
const { isSettingsOpen, closeSettings } = useContext(SettingsContext);
|
||||
const { voice, text } = useChannel();
|
||||
|
||||
const configured =
|
||||
homeServer !== null &&
|
||||
clientId !== null &&
|
||||
sessionToken !== null;
|
||||
|
||||
useEffect(() => {
|
||||
if(!configured) closeSettings();
|
||||
}, [configured])
|
||||
|
||||
return (
|
||||
configured ? (
|
||||
<ServerConnection url={homeServer}>
|
||||
{isSettingsOpen ? (
|
||||
<Settings></Settings>
|
||||
) : (
|
||||
<TwoPanel
|
||||
threshold={800}
|
||||
sidebar={300}
|
||||
>
|
||||
<Sidebar></Sidebar>
|
||||
{voice ? (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}>
|
||||
<div style={{
|
||||
height: '50%',
|
||||
width: '100%',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<Voice></Voice>
|
||||
</div>
|
||||
<div style={{
|
||||
height: '50%',
|
||||
width: '100%'
|
||||
}}>
|
||||
<Chat></Chat>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Chat></Chat>
|
||||
)}
|
||||
</TwoPanel>
|
||||
)}
|
||||
</ServerConnection>
|
||||
) : (
|
||||
<NewAccount></NewAccount>
|
||||
)
|
||||
)
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
export function Audio(props: React.AudioHTMLAttributes<HTMLAudioElement> & {
|
||||
srcObject?: MediaStream;
|
||||
}) {
|
||||
|
||||
const [ref, setRef] = useState<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref === null)
|
||||
return;
|
||||
if (props.srcObject === undefined || props.srcObject === null)
|
||||
return;
|
||||
ref.srcObject = props.srcObject;
|
||||
}, [props.srcObject, ref]);
|
||||
|
||||
const filteredProps = Object.fromEntries(Object.entries(props).filter(([key, value]) => key !== 'srcObject'));
|
||||
|
||||
return <audio ref={setRef} {...filteredProps}>{props.children}</audio>;
|
||||
}
|
||||
|
|
@ -0,0 +1,245 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { v4 } from 'uuid';
|
||||
import { GrFormClose } from 'react-icons/gr';
|
||||
import { IoMdClose } from "react-icons/io";
|
||||
import testImage from '../../assets/pfp.jpg'
|
||||
import useFileUpload, { Upload } from "../hooks/useFileUpload";
|
||||
import useChannel from "../hooks/useChannel";
|
||||
import { useApi } from "../lib/useApi";
|
||||
|
||||
const exampleImages = [
|
||||
{
|
||||
id: v4(),
|
||||
name: 'image.jpg',
|
||||
data: testImage,
|
||||
}
|
||||
]
|
||||
|
||||
export default function ChatInput() {
|
||||
const textBoxRef = useRef<HTMLDivElement>(null);
|
||||
const { channel } = useChannel();
|
||||
|
||||
const [attachments, setAttachments] = useState<string[]>([]);
|
||||
|
||||
const { newFile, getFileInfo } = useFileUpload();
|
||||
|
||||
const PADDING = 8;
|
||||
const CHATBOX_SIZE = 64;
|
||||
|
||||
const addAttachment = (id: string) => {
|
||||
setAttachments(attachments => [...attachments, id])
|
||||
}
|
||||
|
||||
const removeAttachment = (id: string) => {
|
||||
setAttachments(attachments => attachments.filter(a => a !== id));
|
||||
}
|
||||
|
||||
// useEffect(() => {
|
||||
// addAttachment(newFile('image.jpg', testImage));
|
||||
// }, []);
|
||||
|
||||
const pasta: React.ClipboardEventHandler<HTMLDivElement> = (event) => {
|
||||
|
||||
let pastedFiles = false;
|
||||
|
||||
for (let idx = 0; idx < event.clipboardData.items.length; idx ++) {
|
||||
const item = event.clipboardData.items[idx];
|
||||
const file = event.clipboardData.files[idx];
|
||||
const type = event.clipboardData.types[idx]
|
||||
|
||||
if (item.kind === 'file') {
|
||||
var blob = item.getAsFile();
|
||||
if(blob === null) continue;
|
||||
|
||||
addAttachment(newFile(file.name, file.type, blob));
|
||||
|
||||
pastedFiles = true;
|
||||
}
|
||||
}
|
||||
|
||||
if(pastedFiles) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
const { send } = useApi({});
|
||||
|
||||
const keyPress = (event: any) => {
|
||||
if(event.code === 'Enter' && !event.shiftKey) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
event.bubbles = false;
|
||||
|
||||
for(const attachment of attachments) {
|
||||
const info = getFileInfo(attachment);
|
||||
if(!info?.processed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const [file, ...restFiles] = attachments.map(a => getFileInfo(a)?.externalId) as string[];
|
||||
const text = textBoxRef.current?.innerHTML ?? '';
|
||||
|
||||
if(text === '' && file === undefined) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if(channel === null) return true;
|
||||
|
||||
const newMessage: NewMessageRequest = {
|
||||
uid: v4(),
|
||||
text,
|
||||
channel,
|
||||
timestamp: new Date().getTime(),
|
||||
file
|
||||
}
|
||||
|
||||
console.log(file, restFiles);
|
||||
|
||||
send('message:message', newMessage);
|
||||
|
||||
for(const file of restFiles) {
|
||||
const newMessage: NewMessageRequest = {
|
||||
uid: v4(),
|
||||
text: '',
|
||||
channel,
|
||||
timestamp: new Date().getTime(),
|
||||
file
|
||||
}
|
||||
send('message:message', newMessage);
|
||||
}
|
||||
|
||||
setAttachments([]);
|
||||
if(textBoxRef.current !== null) {
|
||||
textBoxRef.current.innerHTML = '';
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style ={{
|
||||
minWidth: '0px',
|
||||
}}>
|
||||
<div style={{
|
||||
overflowX: 'auto',
|
||||
}}>
|
||||
{attachments.length > 0 && (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
padding: '8px',
|
||||
boxSizing: 'border-box',
|
||||
paddingBottom: '0px',
|
||||
}}>
|
||||
<div style={{
|
||||
whiteSpace: 'nowrap'
|
||||
}}>
|
||||
{attachments.map(attachment => {
|
||||
const info = getFileInfo(attachment);
|
||||
if(!info) return <span>Poop</span>;
|
||||
return (
|
||||
<AttachmentBox
|
||||
key={attachment}
|
||||
attachment={info}
|
||||
onClose={() => removeAttachment(attachment)}
|
||||
></AttachmentBox>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => {
|
||||
textBoxRef.current?.focus();
|
||||
}}
|
||||
style={{
|
||||
margin: PADDING + 'px',
|
||||
marginRight: '0px',
|
||||
borderRadius: ((CHATBOX_SIZE - PADDING*2) / 2) + 'px',
|
||||
background: 'var(--neutral-5)',
|
||||
gridArea: 'message',
|
||||
display: 'grid',
|
||||
placeItems: 'center center',
|
||||
padding: '8px 16px',
|
||||
minHeight: '48px',
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'text',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
onPaste={pasta}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
// outline: '1px solid white',
|
||||
outline: 'none',
|
||||
}}
|
||||
onKeyPress={keyPress}
|
||||
ref={textBoxRef}
|
||||
contentEditable
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AttachmentBox(props: {
|
||||
attachment: Upload,
|
||||
onClose: React.MouseEventHandler<SVGElement>
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
verticalAlign: 'top',
|
||||
// padding: '8px',
|
||||
background: 'var(--neutral-3)',
|
||||
// border: '1px solid var(--neutral-6)',
|
||||
fontSize: '0.8em',
|
||||
display: 'inline-block',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '8px',
|
||||
marginRight: '8px',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
// textAlign: 'center',
|
||||
}}>
|
||||
<div style={{
|
||||
transition: 'width 300ms, background 300ms, opacity 800ms',
|
||||
width: `${props.attachment.progress * 100}%`,
|
||||
height: '100%',
|
||||
background: props.attachment.uploaded ? 'white' : 'var(--green)',
|
||||
position: 'absolute',
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
zIndex: '0',
|
||||
opacity: props.attachment.uploaded ? '0' : '0.3'
|
||||
}}></div>
|
||||
<div style={{
|
||||
zIndex: '1',
|
||||
padding: '8px',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<IoMdClose
|
||||
size={16}
|
||||
style={{
|
||||
float: 'right',
|
||||
color: 'var(--neutral-9)',
|
||||
padding: '1px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={props.onClose}
|
||||
></IoMdClose>
|
||||
<div style={{
|
||||
paddingBottom: '8px',
|
||||
paddingRight: '24px',
|
||||
}}>{props.attachment.name}</div>
|
||||
{/* <img style={{
|
||||
display: 'block',
|
||||
height: '100px'
|
||||
}} src={props.attachment.blob}></img> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
|
||||
|
||||
|
||||
export function ColorTest() {
|
||||
return (
|
||||
<div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(9, 1fr)'
|
||||
}}>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map(n => (
|
||||
<div key={n} style={{
|
||||
background: `var(--neutral-${n})`,
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
display: 'inline-block'
|
||||
}}></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(7, 1fr)'
|
||||
}}>
|
||||
{['green','orange','cyan','pink','purple','red','yellow'].map((c) => (
|
||||
<div key={c} style={{
|
||||
backgroundColor: `var(--${c})`,
|
||||
width: '100%',
|
||||
height: '32px',
|
||||
display: 'inline-block'
|
||||
}}></div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import useSessionToken from "../hooks/useSessionToken"
|
||||
|
||||
export default function Logout() {
|
||||
|
||||
const { setSessionToken } = useSessionToken();
|
||||
|
||||
return (
|
||||
<button onClick={() => setSessionToken(null)}>LOGOUT</button>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
|
||||
interface MarkdownInputProps {
|
||||
|
||||
}
|
||||
|
||||
export function MarkdownInput(props: MarkdownInputProps) {
|
||||
|
||||
return (
|
||||
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
import { useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const Portal = ({children}: {children: React.ReactNode}) => {
|
||||
const mount = document.getElementById("portal-root");
|
||||
const el = document.createElement("div");
|
||||
|
||||
useEffect(() => {
|
||||
if(mount === null) return;
|
||||
mount.appendChild(el);
|
||||
return () => {
|
||||
mount.removeChild(el);
|
||||
}
|
||||
}, [el, mount]);
|
||||
|
||||
return createPortal(children, el)
|
||||
};
|
||||
|
||||
export default Portal;
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import { createContext, PropsWithChildren, ReactNode, useEffect, useMemo } from "react";
|
||||
import ClientsListState from "../contexts/EphemeralState/ClientsListState";
|
||||
import PeerState from "../contexts/EphemeralState/PeerState";
|
||||
import { connectApi } from "../lib/api";
|
||||
|
||||
interface ServerConnectionProps {
|
||||
children: ReactNode,
|
||||
url: string
|
||||
}
|
||||
|
||||
export const ServerConnectionContext = createContext<ReturnType<typeof connectApi>>({
|
||||
async send() {
|
||||
throw new Error('attempted to send an api call with no connection context');
|
||||
},
|
||||
registerRouter() {
|
||||
throw new Error('attempted to register an api listener with no connection context');
|
||||
},
|
||||
unregisterRouter() {},
|
||||
destroy() {}
|
||||
})
|
||||
|
||||
export default function ServerConnection(props: ServerConnectionProps) {
|
||||
|
||||
const serverConnection = useMemo(() => {
|
||||
return connectApi(props.url);
|
||||
}, [props.url]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if(!serverConnection) return;
|
||||
serverConnection.destroy();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <ServerConnectionContext.Provider value={serverConnection}>
|
||||
<ClientsListState>
|
||||
<PeerState>
|
||||
{props.children}
|
||||
</PeerState>
|
||||
</ClientsListState>
|
||||
</ServerConnectionContext.Provider>
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import useHomeServer from "../hooks/useHomeServer";
|
||||
import Channels from "../pages/Channels";
|
||||
import pfp from '../../assets/pfp.jpg';
|
||||
import { IoMdSettings } from 'react-icons/io';
|
||||
import useHover from "../hooks/useHover";
|
||||
import { useContext } from "react";
|
||||
import { SettingsContext } from "../contexts/EphemeralState/EphemeralState";
|
||||
import { ClientsListContext } from "../contexts/EphemeralState/ClientsListState";
|
||||
import useClientId from "../hooks/useClientId";
|
||||
|
||||
export default function Sidebar() {
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
display: 'grid',
|
||||
gridTemplateRows: 'min-content 1fr min-content'
|
||||
}}>
|
||||
<TopSidebar></TopSidebar>
|
||||
<Channels></Channels>
|
||||
<MiniProfile></MiniProfile>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TopSidebar() {
|
||||
|
||||
const { homeServer } = useHomeServer();
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
lineHeight: '48px',
|
||||
paddingLeft: '16px',
|
||||
fontSize: '16px',
|
||||
background: 'var(--neutral-3)',
|
||||
boxShadow: 'black 0px 0px 3px 0px',
|
||||
zIndex: '100',
|
||||
fontWeight: '500',
|
||||
}}>
|
||||
{homeServer && new URL(homeServer).hostname.toLocaleLowerCase()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniProfile() {
|
||||
|
||||
const { clientName } = useContext(ClientsListContext);
|
||||
const { clientId } = useClientId();
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
fontSize: '16px',
|
||||
background: 'var(--neutral-2)',
|
||||
// boxShadow: 'black 0px 0px 3px 0px',
|
||||
zIndex: '100',
|
||||
fontWeight: '500',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'min-content 1fr min-content'
|
||||
}}>
|
||||
<ProfilePicture></ProfilePicture>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
placeItems: 'center left',
|
||||
}}>
|
||||
<div>
|
||||
<div style={{
|
||||
fontWeight: '400',
|
||||
fontSize: '15px',
|
||||
}}>{clientId && clientName[clientId]}</div>
|
||||
<div style={{
|
||||
fontWeight: '300',
|
||||
fontSize: '13px',
|
||||
}}>dev.valnet.xyz</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
whiteSpace: 'nowrap',
|
||||
display: 'grid',
|
||||
gridAutoFlow: 'column',
|
||||
placeItems: 'center right',
|
||||
paddingRight: '8px',
|
||||
}}>
|
||||
<SettingsButton></SettingsButton>
|
||||
{/* <SettingsButton></SettingsButton>
|
||||
<SettingsButton></SettingsButton> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SettingsButton() {
|
||||
const [ref, hover] = useHover<HTMLDivElement>();
|
||||
const { openSettings } = useContext(SettingsContext);
|
||||
|
||||
return <div ref={ref} className="settings" style={{
|
||||
display: 'flex',
|
||||
padding: '8px',
|
||||
background: hover ?
|
||||
'var(--neutral-4)' :
|
||||
'initial',
|
||||
borderRadius: '5px',
|
||||
cursor: 'pointer',
|
||||
}} onClick={openSettings}>
|
||||
<IoMdSettings size="16"></IoMdSettings>
|
||||
</div>
|
||||
}
|
||||
|
||||
function ProfilePicture() {
|
||||
const name = 'Val';
|
||||
return <div style={{
|
||||
backgroundImage: `url(${pfp})`,
|
||||
width: '40px',
|
||||
height: '40px',
|
||||
margin: '12px',
|
||||
backgroundSize: 'cover',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
backgroundPosition: 'center',
|
||||
borderRadius: '50%',
|
||||
}}></div>
|
||||
}
|
||||
|
|
@ -0,0 +1,64 @@
|
|||
import React, { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import 'reactjs-popup/dist/index.css';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import QR from 'qrcode';
|
||||
import { usePrevious } from '../hooks/usePrevious';
|
||||
import useClientId from '../hooks/useClientId';
|
||||
|
||||
export default function Totp () {
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
const previousOpen = usePrevious(open);
|
||||
const { clientId } = useClientId()
|
||||
const [qr, setQr] = useState<string | null>(null);
|
||||
const [key, setKey] = useState<string>('');
|
||||
|
||||
const { send } = useApi({
|
||||
'totp:propose'(key: string) {
|
||||
setKey(key);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onOpen = useCallback(() => {
|
||||
send('totp:propose', clientId);
|
||||
}, [send, clientId]);
|
||||
|
||||
useEffect(() => {
|
||||
if(open && !previousOpen) {
|
||||
onOpen();
|
||||
}
|
||||
}, [open, onOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if(key === null) return;
|
||||
if(clientId === null) return;
|
||||
(async () => {
|
||||
setQr(await QR.toDataURL(
|
||||
'otpauth://totp/Valerie?secret=' +
|
||||
key +
|
||||
'&issuer=corner'
|
||||
));
|
||||
})()
|
||||
}, [key, clientId])
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const submit = useCallback(() => {
|
||||
if(inputRef.current === null) return;
|
||||
send('totp:confirm', {
|
||||
clientId,
|
||||
code: inputRef.current.value
|
||||
})
|
||||
}, [])
|
||||
|
||||
return <div>
|
||||
<button onClick={() => setOpen(!open)}>TOTP</button>
|
||||
{open && (
|
||||
<div>
|
||||
<img src={qr ?? undefined} />
|
||||
<input ref={inputRef}></input>
|
||||
<button onClick={submit}>CHECK</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import useMediaQuery from '../lib/useMediaQueries';
|
||||
|
||||
|
||||
|
||||
export default function TwoPanel(props: {
|
||||
threshold: number,
|
||||
sidebar: number,
|
||||
children: any
|
||||
}) {
|
||||
const bigScreen = useMediaQuery('(min-width:' + props.threshold + 'px)');
|
||||
const [screenRef, setScreenRef] = useState<HTMLDivElement | null>(null);
|
||||
const [startDrag, setStartDrag] = useState(0);
|
||||
const [currentDrag, setCurrentDrag] = useState(0);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [opened, setOpened] = useState(false);
|
||||
|
||||
const difference = opened ?
|
||||
Math.min(currentDrag - startDrag, 0) :
|
||||
Math.max(currentDrag - startDrag, 0);
|
||||
|
||||
const pointerDown = useCallback((e: any) => {
|
||||
setDragging(true);
|
||||
setStartDrag(e.touches[0].clientX);
|
||||
setCurrentDrag(e.touches[0].clientX);
|
||||
}, [dragging, startDrag, currentDrag]);
|
||||
|
||||
const pointerUp = useCallback(() => {
|
||||
setDragging(false);
|
||||
if(difference > 0) {
|
||||
setOpened(true);
|
||||
} else if (difference < 0) {
|
||||
setOpened(false);
|
||||
}
|
||||
}, [dragging, currentDrag, startDrag, opened]);
|
||||
|
||||
const pointerMove = useCallback((e: any) => {
|
||||
setCurrentDrag(e.touches[0].clientX);
|
||||
}, [dragging, currentDrag]);
|
||||
|
||||
useEffect(() => {
|
||||
if(screenRef === null) return;
|
||||
screenRef.addEventListener('touchstart', pointerDown, { passive: true });
|
||||
screenRef.addEventListener('touchend', pointerUp, { passive: true });
|
||||
screenRef.addEventListener('touchmove', pointerMove, { passive: true });
|
||||
// screenRef.addEventListener('pointercancel', pointerUp);
|
||||
return () => {
|
||||
screenRef.removeEventListener('touchstart', pointerDown);
|
||||
screenRef.removeEventListener('touchend', pointerUp);
|
||||
screenRef.removeEventListener('touchmove', pointerMove);
|
||||
// screenRef.removeEventListener('pointercancel', pointerUp);
|
||||
};
|
||||
}, [screenRef, pointerUp, pointerDown]);
|
||||
|
||||
return <div ref={setScreenRef} style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
// overflow: 'hidden',
|
||||
}}>
|
||||
<div
|
||||
style={{
|
||||
// background: 'red',
|
||||
width: bigScreen ? (props.sidebar + 'px') : '100%',
|
||||
height: '100%',
|
||||
display: 'inline-block',
|
||||
position: 'absolute',
|
||||
top: '0px',
|
||||
left: bigScreen ? '0px' : !dragging ? (opened ? '0px' : '-100%') : `calc(${difference}px ${opened ? '' : '- 100%'})`,
|
||||
zIndex: '1',
|
||||
overflow: 'hidden',
|
||||
transition: dragging ? 'none' : 'left 300ms linear, width 300ms linear',
|
||||
}}
|
||||
>{props.children[0]}</div>
|
||||
<div
|
||||
style={{
|
||||
// background: 'green',
|
||||
width: bigScreen ? 'calc(100% - ' + props.sidebar + 'px)' : '100%',
|
||||
height: '100%',
|
||||
display: 'inline-block',
|
||||
position: 'absolute',
|
||||
top: '0px',
|
||||
left: bigScreen ? (props.sidebar + 'px') : '0px',
|
||||
zIndex: '0',
|
||||
overflow: 'hidden',
|
||||
transition: 'left 300ms linear, width 300ms linear',
|
||||
}}
|
||||
>{props.children[1]}</div>
|
||||
</div>;
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import React from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function Video(props: React.VideoHTMLAttributes<HTMLVideoElement> & {
|
||||
srcObject?: MediaStream;
|
||||
}) {
|
||||
|
||||
const [ref, setRef] = useState<HTMLVideoElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref === null)
|
||||
return;
|
||||
if (props.srcObject === undefined || props.srcObject === null)
|
||||
return;
|
||||
ref.srcObject = props.srcObject;
|
||||
}, [props.srcObject, ref]);
|
||||
|
||||
const filteredProps = Object.fromEntries(Object.entries(props).filter(([key, value]) => key !== 'srcObject'));
|
||||
|
||||
return <video ref={setRef} {...filteredProps}>{props.children}</video>;
|
||||
}
|
||||
|
|
@ -0,0 +1,278 @@
|
|||
import React, { useLayoutEffect } from "react";
|
||||
import { MouseEventHandler, ReactNode, useCallback, useContext, useEffect, useState } from "react"
|
||||
import { MdHeadphones, MdMic, MdMicOff, MdScreenShare, MdSend, MdVideoCall, MdVideocam, MdVideocamOff } from "react-icons/md";
|
||||
import { FiLogOut, FiLogIn } from 'react-icons/fi';
|
||||
import { ClientsListContext } from "../contexts/EphemeralState/ClientsListState";
|
||||
import { IParticipant, IConnection, PeerContext } from "../contexts/EphemeralState/PeerState";
|
||||
import { UserMediaContext } from "../contexts/EphemeralState/UserMediaState";
|
||||
import useChannel from "../hooks/useChannel";
|
||||
import { useApi } from "../lib/useApi";
|
||||
import { IconType } from 'react-icons/lib/cjs/iconBase';
|
||||
import useClientId from "../hooks/useClientId";
|
||||
|
||||
export default function Voice() {
|
||||
const {
|
||||
connected,
|
||||
peerId,
|
||||
join,
|
||||
leave,
|
||||
connections,
|
||||
inCall,
|
||||
connectedChannel
|
||||
} = useContext(PeerContext);
|
||||
const { channel } = useChannel();
|
||||
const { clientName } = useContext(ClientsListContext);
|
||||
const { clientId } = useClientId();
|
||||
|
||||
const {
|
||||
mute,
|
||||
unmute,
|
||||
muted,
|
||||
enable,
|
||||
disable,
|
||||
mediaStream,
|
||||
cameraEnabled,
|
||||
enableCamera,
|
||||
disableCamera
|
||||
} = useContext(UserMediaContext);
|
||||
|
||||
const [connectedVoiceClientIds, setConnectedVoiceClientIds] = useState<string[]>([])
|
||||
const [participants, setParticipants] = useState<IParticipant[]>([]);
|
||||
|
||||
const { send } = useApi({
|
||||
'voice:list'(data: { uid: string, participants: IParticipant[] }) {
|
||||
if(data.uid !== channel) return;
|
||||
setParticipants(data.participants);
|
||||
},
|
||||
'voice:join'(data: IParticipant) {
|
||||
if(data.channelId !== channel) return;
|
||||
setParticipants(ps => ([...ps, data]));
|
||||
},
|
||||
'voice:leave'(data: IParticipant) {
|
||||
if(data.channelId !== channel) return;
|
||||
setParticipants(ps => ps.filter(p => p.peerId !== data.peerId));
|
||||
}
|
||||
}, [channel]);
|
||||
|
||||
useEffect(() => {
|
||||
send('voice:list', { channelId: channel })
|
||||
}, [channel]);
|
||||
|
||||
const joinCall = useCallback(() => {
|
||||
if(peerId === null || connected === false || channel === null) return;
|
||||
disableCamera();
|
||||
enable();
|
||||
join(channel);
|
||||
send('voice:join', { peerId, channelId: channel });
|
||||
}, [connected, peerId, channel]);
|
||||
|
||||
const joinCallWithVideo = useCallback(() => {
|
||||
if(peerId === null || connected === false || channel === null) return;
|
||||
enableCamera();
|
||||
enable();
|
||||
join(channel);
|
||||
send('voice:join', { peerId, channelId: channel });
|
||||
}, [connected, peerId, channel]);
|
||||
|
||||
const leaveCall = useCallback(() => {
|
||||
if(peerId === null || connected === false) return;
|
||||
leave();
|
||||
disable();
|
||||
send('voice:leave', { peerId, channelId: channel });
|
||||
}, [connected, peerId, channel]);
|
||||
|
||||
const inThisCall = inCall && channel === connectedChannel;
|
||||
|
||||
return <div style={{
|
||||
background: 'var(--neutral-1)',
|
||||
height: '100%',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '0px',
|
||||
width: '100%',
|
||||
display: 'grid',
|
||||
placeItems: 'center center'
|
||||
}}>
|
||||
<div style={{
|
||||
margin: '0px auto',
|
||||
display: 'inline',
|
||||
}}>
|
||||
{(!inThisCall) ? (
|
||||
<>
|
||||
<CircleButton
|
||||
icon={FiLogIn}
|
||||
onClick={joinCall}
|
||||
color="var(--green)"
|
||||
></CircleButton>
|
||||
<CircleButton
|
||||
icon={MdVideoCall}
|
||||
onClick={joinCallWithVideo}
|
||||
color="var(--green)"
|
||||
></CircleButton>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CircleButton
|
||||
icon={muted ? MdMicOff : MdMic}
|
||||
onClick={() => muted ? unmute() : mute()}
|
||||
inverted={muted}
|
||||
></CircleButton>
|
||||
<CircleButton
|
||||
icon={MdHeadphones}
|
||||
onClick={leaveCall}
|
||||
></CircleButton>
|
||||
<CircleButton
|
||||
icon={MdScreenShare}
|
||||
onClick={leaveCall}
|
||||
></CircleButton>
|
||||
<CircleButton
|
||||
icon={cameraEnabled ? MdVideocam : MdVideocamOff}
|
||||
onClick={() => cameraEnabled ? disableCamera() : enableCamera()}
|
||||
inverted={!cameraEnabled}
|
||||
></CircleButton>
|
||||
<CircleButton
|
||||
icon={FiLogOut}
|
||||
onClick={leaveCall}
|
||||
color="var(--red)"
|
||||
></CircleButton>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
placeItems: 'center center',
|
||||
height: '100%',
|
||||
width: '100%'
|
||||
}}>
|
||||
|
||||
{participants.length === 0 ? (
|
||||
<span style={{ color: 'var(--neutral-6)', fontWeight: '600' }}>No one is here right now</span>
|
||||
) : (
|
||||
<div style={{
|
||||
|
||||
}}>
|
||||
{participants.map(participant => {
|
||||
const connection = connections.find(c => c.clientId === participant.clientId);
|
||||
|
||||
// if(participant.clientId !== clientId) return <div key={participant.peerId}></div>;
|
||||
|
||||
return (
|
||||
<Participant
|
||||
key={participant.peerId}
|
||||
data={connection ?? participant}
|
||||
></Participant>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function Participant(props: {
|
||||
data: IParticipant | IConnection
|
||||
}) {
|
||||
|
||||
const [videoRoot, setVideoRoot] = useState<HTMLDivElement | null>(null);
|
||||
const { videoElement } = useContext(UserMediaContext);
|
||||
const { clientName } = useContext(ClientsListContext);
|
||||
|
||||
const isSelf = useClientId().clientId === props.data.clientId;
|
||||
const remoteVideoElement = isSelf ? (
|
||||
videoElement
|
||||
) : (
|
||||
('videoElement' in props.data) ? (
|
||||
props.data.videoElement
|
||||
) : (
|
||||
null
|
||||
)
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if(videoRoot === null) return;
|
||||
if(remoteVideoElement === null) return;
|
||||
|
||||
const alreadyThere = [...videoRoot.childNodes].includes(remoteVideoElement);
|
||||
|
||||
if(!alreadyThere) {
|
||||
while(!!videoRoot.firstChild) {
|
||||
videoRoot.firstChild.remove();
|
||||
}
|
||||
videoRoot.appendChild(remoteVideoElement);
|
||||
}
|
||||
remoteVideoElement.play();
|
||||
}, [videoRoot, remoteVideoElement]);
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'top',
|
||||
margin: '4px',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '200px',
|
||||
height: '150px',
|
||||
display: 'inline-block',
|
||||
placeItems: 'center center',
|
||||
borderRadius: '8px',
|
||||
background: isSelf ? 'var(--orange)' : 'var(--neutral-4)',
|
||||
color: 'var(--neutral-8)',
|
||||
fontStyle: '500',
|
||||
overflow: 'hidden',
|
||||
}}>
|
||||
<div ref={setVideoRoot} style={{
|
||||
height: '100%'
|
||||
}}></div>
|
||||
</div>
|
||||
<div style={{
|
||||
textAlign: 'center'
|
||||
}}>{clientName[props.data.clientId]}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CircleButton(props: {
|
||||
onClick: MouseEventHandler<HTMLDivElement>,
|
||||
icon: IconType,
|
||||
color?: string,
|
||||
inverted?: boolean,
|
||||
}) {
|
||||
|
||||
const primaryColor = props.inverted ? 'var(--neutral-9)' : (props.color ?? 'var(--neutral-4)');
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
width: '56px',
|
||||
height: '64px',
|
||||
padding: '8px',
|
||||
paddingRight: '0px',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
<div onClick={props.onClick} style={{
|
||||
background: primaryColor,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
cursor: 'pointer',
|
||||
display: 'grid',
|
||||
placeItems: 'center center',
|
||||
// paddingLeft: '4px',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
{React.createElement(
|
||||
props.icon,
|
||||
{
|
||||
size: 24,
|
||||
color: props.inverted ? 'var(--neutral-1)' : 'inherit'
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
// MdPhoneInTalk
|
||||
// MdPhoneDisabled
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { useEffect } from "react";
|
||||
|
||||
export const useLog = (v: any, prefix = '') => {
|
||||
useEffect(() => {
|
||||
console.log(prefix, v);
|
||||
}, [v]);
|
||||
};
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { createContext, useEffect, useMemo, useState } from "react";
|
||||
import { useApi } from "/@/lib/useApi";
|
||||
|
||||
|
||||
|
||||
export const ClientsListContext = createContext<{
|
||||
clientName: { [clientId: string]: string }
|
||||
}>({
|
||||
clientName: {}
|
||||
});
|
||||
|
||||
|
||||
// export function useClientList() {
|
||||
// const
|
||||
// }
|
||||
|
||||
export default function ClientsListState(props: any) {
|
||||
const [clients, setClients] = useState<{
|
||||
[id: string]: string
|
||||
}>({});
|
||||
|
||||
const { send } = useApi({
|
||||
'clients:list'(data: any) {
|
||||
const obj: any = {};
|
||||
for(const client of data.clients) {
|
||||
obj[client.clientId] = client.displayName;
|
||||
}
|
||||
setClients(obj);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
send('clients:list')
|
||||
}, []);
|
||||
|
||||
const value = useMemo(() => ({
|
||||
clientName: clients
|
||||
}), [clients]);
|
||||
|
||||
return <ClientsListContext.Provider value={value}>
|
||||
{props.children}
|
||||
</ClientsListContext.Provider>
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
import { createContext, useState, useMemo, useEffect } from "react";
|
||||
import UserMediaState from "./UserMediaState";
|
||||
import PeerState from "./PeerState";
|
||||
import ClientsListState from "./ClientsListState";
|
||||
|
||||
export type ChannelType = 'text' | 'voice';
|
||||
|
||||
export const ChannelContext = createContext<{
|
||||
channel: string | null,
|
||||
text: boolean,
|
||||
voice: boolean,
|
||||
setChannel: (uid: string, type: ChannelType) => void
|
||||
}>({
|
||||
channel: null,
|
||||
setChannel: () => {},
|
||||
text: false,
|
||||
voice: false,
|
||||
});
|
||||
export const TransparencyContext = createContext<(transparent: boolean) => void>(() => {});
|
||||
export const SettingsContext = createContext<{
|
||||
openSettings: () => void,
|
||||
closeSettings: () => void,
|
||||
isSettingsOpen: boolean
|
||||
}>({
|
||||
openSettings() {},
|
||||
closeSettings() {},
|
||||
isSettingsOpen: false
|
||||
});
|
||||
|
||||
export default function EphemeralState(props: {
|
||||
onTransparencyChange: (value: boolean) => void,
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
|
||||
const [channel, setChannel] = useState<string | null>(null);
|
||||
const [voice, setVoice] = useState(false);
|
||||
const [text, setText] = useState(false);
|
||||
const [transparent, setTransparent] = useState(false);
|
||||
|
||||
const [settings, setSettings] = useState(false);
|
||||
|
||||
const channelContextValue = useMemo(() => {
|
||||
return {
|
||||
channel,
|
||||
setChannel: (uid: string, channelType: ChannelType) => {
|
||||
setChannel(uid);
|
||||
switch(channelType) {
|
||||
case 'text': {
|
||||
setVoice(false);
|
||||
setText(true);
|
||||
break;
|
||||
}
|
||||
case 'voice': {
|
||||
setVoice(true);
|
||||
setText(false);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
setVoice(false);
|
||||
setText(false);
|
||||
break;
|
||||
}
|
||||
}
|
||||
},
|
||||
voice,
|
||||
text
|
||||
};
|
||||
}, [channel, setChannel]);
|
||||
|
||||
useEffect(() => {
|
||||
if('onTransparencyChange' in props) {
|
||||
props.onTransparencyChange(transparent)
|
||||
}
|
||||
}, [transparent])
|
||||
|
||||
return (
|
||||
<ChannelContext.Provider value={channelContextValue}>
|
||||
<TransparencyContext.Provider value={setTransparent}>
|
||||
<SettingsContext.Provider value={{
|
||||
openSettings: () => setSettings(true),
|
||||
closeSettings: () => setSettings(false),
|
||||
isSettingsOpen: settings,
|
||||
}}>
|
||||
<UserMediaState>
|
||||
{props.children}
|
||||
</UserMediaState>
|
||||
</SettingsContext.Provider>
|
||||
</TransparencyContext.Provider>
|
||||
</ChannelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,307 @@
|
|||
import { createContext, ReactNode, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Peer, MediaConnection } from "peerjs";
|
||||
import { UserMediaContext } from "./UserMediaState";
|
||||
import { useApi } from "/@/lib/useApi";
|
||||
import { Audio } from "/@/components/Audio";
|
||||
import { sfx } from "/@/lib/sound";
|
||||
import { Video } from '../../components/Video';
|
||||
import React from "react";
|
||||
import { useLog } from "/@/components/useLog";
|
||||
|
||||
export const PeerContext = createContext<{
|
||||
connected: boolean;
|
||||
inCall: boolean;
|
||||
peerId: string | null;
|
||||
join: (channelId: string) => void;
|
||||
leave: () => void;
|
||||
connections: IConnection[];
|
||||
connectedChannel: string | null;
|
||||
}>({
|
||||
connected: false,
|
||||
peerId: null,
|
||||
inCall: false,
|
||||
join: () => {},
|
||||
leave: () => {},
|
||||
connections: [],
|
||||
connectedChannel: null
|
||||
});
|
||||
|
||||
function useCurrent<T>(thing: T) {
|
||||
const thingRef = useRef<T>(thing);
|
||||
|
||||
useEffect(() => {
|
||||
thingRef.current = thing;
|
||||
}, [thing]);
|
||||
|
||||
return thingRef.current;
|
||||
}
|
||||
|
||||
export interface IParticipant {
|
||||
peerId: string;
|
||||
clientId: string;
|
||||
channelId: string;
|
||||
}
|
||||
|
||||
export interface IConnection extends IParticipant {
|
||||
call: MediaConnection | null;
|
||||
isCaller: boolean;
|
||||
mediaStream: MediaStream | null;
|
||||
connected: boolean;
|
||||
videoElement: HTMLVideoElement | null;
|
||||
}
|
||||
|
||||
function isCaller(a: string, b:string) {
|
||||
return [a, b].sort()[0] === a;
|
||||
}
|
||||
|
||||
export default function PeerState(props: any) {
|
||||
|
||||
const { mediaStream } = useContext(UserMediaContext);
|
||||
// TODO ability to disable until needed
|
||||
// const [enabled, setEnabled] = useState(true);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [peer, setPeer] = useState<Peer | null>(null);
|
||||
const [peerId, setPeerId] = useState<string | null>(null);
|
||||
|
||||
const [incomingCalls, setIncomingCalls] = useState<MediaConnection[]>([]);
|
||||
const [outgoingCalls, setOutgoingCalls] = useState<string[]>([]);
|
||||
|
||||
const [connections, setConnections] = useState<IConnection[]>([]);
|
||||
const [channel, setChannel] = useState<string | null>(null);
|
||||
|
||||
const addIncomingCall = useCurrent(useCallback((call: MediaConnection) => {
|
||||
// TODO filter out incoming calls from the same peerId.
|
||||
setIncomingCalls(incomingCalls => ([...incomingCalls, call]));
|
||||
}, []));
|
||||
|
||||
const removeIncomingCalls = (peerIds: string[]) => {
|
||||
setIncomingCalls(calls => calls.filter(call => !peerIds.includes(call.peer)));
|
||||
}
|
||||
|
||||
const updateConnection = (peerId: string, data: Partial<IConnection>) => {
|
||||
setConnections(connections => connections.map(connection => {
|
||||
if(connection.peerId !== peerId) return connection;
|
||||
return {
|
||||
...connection,
|
||||
...data
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
const removeConnection = (peerId: string) => {
|
||||
setConnections(connections => connections.filter(connection => connection.peerId !== peerId));
|
||||
}
|
||||
|
||||
const destroyConnection = (peerId: string) => {
|
||||
setConnections(connections => {
|
||||
const conn = connections.find(c => c.peerId === peerId)
|
||||
if(conn && conn.call) {
|
||||
conn.call.close();
|
||||
}
|
||||
return connections;
|
||||
})
|
||||
removeConnection(peerId);
|
||||
}
|
||||
|
||||
const addStream = (id: string, stream: MediaStream) => {
|
||||
// DE BOUNCE THE INCOMING STREAMS, CAUSE WTF?!
|
||||
setConnections(connections => {
|
||||
const connection = connections.find(c => c.peerId === id);
|
||||
if(!!connection && connection.mediaStream === null) {
|
||||
return connections.map(connection => {
|
||||
if(connection.peerId !== id) return connection;
|
||||
if(connection.mediaStream !== null) return connection;
|
||||
console.log('CREATED VIDEO ELEMENT');
|
||||
const videoElement = document.createElement('video');
|
||||
videoElement.srcObject = stream;
|
||||
videoElement.autoplay = true;
|
||||
videoElement.muted = true;
|
||||
videoElement.style.height = '100%';
|
||||
|
||||
return {
|
||||
...connection,
|
||||
connected: true,
|
||||
mediaStream: stream,
|
||||
videoElement
|
||||
}
|
||||
})
|
||||
} else {
|
||||
return connections;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// replace mediastream in connections when mediaStream changes.
|
||||
useEffect(() => {
|
||||
if(mediaStream === null) return;
|
||||
setConnections(connections => {
|
||||
for(const conn of connections) {
|
||||
if(conn.call === null) continue;
|
||||
for(const sender of conn.call.peerConnection.getSenders()) {
|
||||
if(sender.track === null) continue;
|
||||
if(sender.track.kind === 'audio') {
|
||||
sender.replaceTrack(mediaStream.getAudioTracks()[0]);
|
||||
} else if(sender.track.kind === 'video') {
|
||||
sender.replaceTrack(mediaStream.getVideoTracks()[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return connections;
|
||||
})
|
||||
}, [mediaStream])
|
||||
|
||||
// accept / reject incoming calls
|
||||
useEffect(() => {
|
||||
if(incomingCalls.length === 0) return;
|
||||
if(mediaStream === null) return;
|
||||
const possiblePeerIds = connections.map(c => c.peerId);
|
||||
for(const call of incomingCalls) {
|
||||
if(!possiblePeerIds.includes(call.peer))
|
||||
continue;
|
||||
call.on('stream', (stream) => addStream(call.peer, stream));
|
||||
call.answer(mediaStream);
|
||||
call.on('close', () => removeConnection(call.peer));
|
||||
updateConnection(call.peer, { call });
|
||||
}
|
||||
removeIncomingCalls(possiblePeerIds);
|
||||
}, [incomingCalls, connections, mediaStream])
|
||||
|
||||
// call peers that we should call
|
||||
useEffect(() => {
|
||||
if(outgoingCalls.length === 0) return;
|
||||
if(mediaStream === null) return;
|
||||
if(peer === null) return;
|
||||
for(const id of outgoingCalls) {
|
||||
const call = peer.call(id, mediaStream);
|
||||
call.on('close', () => removeConnection(id));
|
||||
call.on('stream', (stream) => addStream(call.peer, stream));
|
||||
updateConnection(id, { call });
|
||||
}
|
||||
setOutgoingCalls(prev => prev.filter(id => !outgoingCalls.includes(id)));
|
||||
}, [outgoingCalls, mediaStream, peer]);
|
||||
|
||||
const { send } = useApi({
|
||||
'voice:join'(data: IParticipant) {
|
||||
if(data.channelId !== channel) return;
|
||||
if(data.peerId === peerId) return;
|
||||
if(peerId === null) return;
|
||||
sfx.joinCall();
|
||||
const newConn: IConnection = {
|
||||
call: null,
|
||||
connected: false,
|
||||
clientId: data.clientId,
|
||||
peerId: data.peerId,
|
||||
channelId: data.channelId,
|
||||
isCaller: isCaller(peerId, data.peerId),
|
||||
mediaStream: null,
|
||||
videoElement: null
|
||||
};
|
||||
if(newConn.isCaller) {
|
||||
setOutgoingCalls(c => [...c, data.peerId]);
|
||||
}
|
||||
setConnections((connections) => ([
|
||||
...connections,
|
||||
newConn
|
||||
]))
|
||||
},
|
||||
'voice:leave'(data: IParticipant) {
|
||||
sfx.leaveCall();
|
||||
if(data.channelId !== channel) return;
|
||||
if(data.peerId === peerId) return;
|
||||
destroyConnection(data.peerId);
|
||||
},
|
||||
'voice:list'(data: { uid: string, participants: IParticipant[]}) {
|
||||
if(data.uid !== channel) return;
|
||||
if(peerId === null) return;
|
||||
if(connections.length !== 0) return;
|
||||
|
||||
setConnections(connections => {
|
||||
console.log(connections);
|
||||
return data.participants
|
||||
.filter((p) => p.peerId !== peerId)
|
||||
.map((participant) => {
|
||||
const previousCall = null;
|
||||
const caller = isCaller(peerId, participant.peerId);
|
||||
if(caller) {
|
||||
setOutgoingCalls(c => [...c, participant.peerId]);
|
||||
}
|
||||
const newConnection: IConnection = {
|
||||
...participant,
|
||||
call: null,
|
||||
isCaller: caller,
|
||||
mediaStream: null,
|
||||
connected: false,
|
||||
videoElement: null
|
||||
}
|
||||
return newConnection
|
||||
})
|
||||
});
|
||||
}
|
||||
}, [channel, peerId, connections]);
|
||||
|
||||
useLog(connections[0], 'connections');
|
||||
|
||||
// create and maintain a peer connection
|
||||
useEffect(() => {
|
||||
if(connected) return;
|
||||
if(peer !== null) return;
|
||||
{
|
||||
const peer = new Peer();
|
||||
setPeer(peer);
|
||||
|
||||
peer.on('open', (id: string) => {
|
||||
setConnected(true);
|
||||
setPeerId(id);
|
||||
});
|
||||
|
||||
peer.on('close', () => {
|
||||
setConnected(false);
|
||||
setPeerId(null);
|
||||
setPeer(null);
|
||||
});
|
||||
|
||||
peer.on('call', (call: MediaConnection) => {
|
||||
addIncomingCall(call);
|
||||
});
|
||||
}
|
||||
}, [connected, peer]);
|
||||
|
||||
const joinChannel = (channelId: string) => {
|
||||
sfx.joinCall();
|
||||
setChannel(channelId);
|
||||
setConnections([]);
|
||||
send('voice:list', { channelId });
|
||||
}
|
||||
|
||||
const leaveChannel = () => {
|
||||
setChannel(null);
|
||||
}
|
||||
|
||||
const value = useMemo(() => ({
|
||||
connected,
|
||||
peerId,
|
||||
inCall: channel !== null,
|
||||
join: joinChannel,
|
||||
leave: leaveChannel,
|
||||
connections,
|
||||
connectedChannel: channel
|
||||
}), [connected, peerId, connections, channel]);
|
||||
|
||||
return <PeerContext.Provider value={value}>
|
||||
<div>
|
||||
{connections.map(conn => (
|
||||
(conn.mediaStream !== null) && (
|
||||
<Audio
|
||||
key={conn.peerId}
|
||||
autoPlay
|
||||
hidden
|
||||
srcObject={conn.mediaStream}
|
||||
></Audio>
|
||||
)
|
||||
))}
|
||||
</div>
|
||||
{props.children}
|
||||
</PeerContext.Provider>
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import React from "react";
|
||||
import { createContext, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Video } from "/@/components/Video";
|
||||
|
||||
export const UserMediaContext = createContext<{
|
||||
enabled: boolean;
|
||||
mediaStream: MediaStream | null;
|
||||
enable: () => void;
|
||||
disable: () => void;
|
||||
mute: () => void;
|
||||
unmute: () => void;
|
||||
muted: boolean;
|
||||
enableCamera: () => void;
|
||||
disableCamera: () => void;
|
||||
cameraEnabled: boolean;
|
||||
videoElement: HTMLVideoElement | null;
|
||||
}>({
|
||||
enabled: false,
|
||||
mediaStream: null,
|
||||
enable: () => {},
|
||||
disable: () => {},
|
||||
mute: () => {},
|
||||
unmute: () => {},
|
||||
muted: false,
|
||||
enableCamera: () => {},
|
||||
disableCamera: () => {},
|
||||
cameraEnabled: false,
|
||||
videoElement: null,
|
||||
});
|
||||
|
||||
export default function UserMediaState(props: any) {
|
||||
|
||||
const [mediaStream, setMediaStream] = useState<MediaStream | null>(null);
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [muted, setMuted] = useState(false);
|
||||
const [cameraEnabled, setCameraEnabled] = useState(false);
|
||||
const [videoElement, setVideoElement] = useState<HTMLVideoElement | null>(null);
|
||||
|
||||
const createBlankVideoTrack = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 40;
|
||||
canvas.height = 30;
|
||||
|
||||
return canvas.captureStream(60).getVideoTracks()[0];
|
||||
}
|
||||
|
||||
const updateMediaStream = (mediaStream: MediaStream | null) => {
|
||||
setMediaStream(old => {
|
||||
if(old !== null) {
|
||||
for(const track of old.getTracks()) {
|
||||
track.stop();
|
||||
}
|
||||
}
|
||||
return mediaStream;
|
||||
});
|
||||
if(mediaStream !== null) {
|
||||
const videoElement = document.createElement('video');
|
||||
videoElement.muted = true;
|
||||
videoElement.autoplay = true;
|
||||
videoElement.srcObject = mediaStream;
|
||||
videoElement.style.height = '100%';
|
||||
setVideoElement(videoElement);
|
||||
} else {
|
||||
setVideoElement(null);
|
||||
}
|
||||
}
|
||||
|
||||
// maintaining the mediaStream...
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if(enabled) {
|
||||
const newStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: true,
|
||||
video: cameraEnabled,
|
||||
});
|
||||
|
||||
if(!cameraEnabled) {
|
||||
newStream.addTrack(createBlankVideoTrack());
|
||||
}
|
||||
|
||||
if(muted) {
|
||||
newStream.getAudioTracks()[0].enabled = false;
|
||||
}
|
||||
|
||||
updateMediaStream(newStream);
|
||||
} else {
|
||||
updateMediaStream(null);
|
||||
}
|
||||
})()
|
||||
}, [enabled, cameraEnabled]);
|
||||
|
||||
const mute = () => {
|
||||
if(mediaStream === null) return;
|
||||
mediaStream.getAudioTracks()[0].enabled = false;
|
||||
setMuted(true);
|
||||
}
|
||||
|
||||
const unmute = () => {
|
||||
if(mediaStream === null) return;
|
||||
mediaStream.getAudioTracks()[0].enabled = true;
|
||||
setMuted(false);
|
||||
}
|
||||
|
||||
const value = useMemo(() => ({
|
||||
enabled,
|
||||
mediaStream,
|
||||
enable: () => setEnabled(true),
|
||||
disable: () => setEnabled(false),
|
||||
mute,
|
||||
unmute,
|
||||
muted,
|
||||
enableCamera: () => setCameraEnabled(true),
|
||||
disableCamera: () => setCameraEnabled(false),
|
||||
cameraEnabled,
|
||||
videoElement
|
||||
}), [enabled, mediaStream, muted]);
|
||||
|
||||
return <UserMediaContext.Provider value={value}>
|
||||
{props.children}
|
||||
</UserMediaContext.Provider>
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { createContext, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { PeerContext } from "./PeerState";
|
||||
import { useApi } from "/@/lib/useApi";
|
||||
|
||||
interface RemotePeer {
|
||||
mediaStream: MediaStream | null;
|
||||
peerId: string;
|
||||
}
|
||||
|
||||
export const VoiceChannelContext = createContext<{
|
||||
voiceChannelId: string | null;
|
||||
setVoiceChannelId: (channelId: string | null) => void
|
||||
}>({
|
||||
voiceChannelId: null,
|
||||
setVoiceChannelId: () => {}
|
||||
});
|
||||
|
||||
export default function VoiceChannelState(props: any) {
|
||||
|
||||
const [voiceChannelId, setVoiceChannelId] = useState<string | null>(null);
|
||||
const { peerId, incommingCalls } = useContext(PeerContext);
|
||||
const [peers, setPeers] = useState<RemotePeer[]>([]);
|
||||
|
||||
const { send } = useApi({
|
||||
'voice:list'() {
|
||||
|
||||
}
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
}, [voiceChannelId])
|
||||
|
||||
|
||||
const value = useMemo(() => ({
|
||||
voiceChannelId,
|
||||
setVoiceChannelId,
|
||||
}), [voiceChannelId, setVoiceChannelId])
|
||||
|
||||
return <VoiceChannelContext.Provider value={value}>
|
||||
{props.children}
|
||||
</VoiceChannelContext.Provider>
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
import { createContext } from "react";
|
||||
import useHomeServerNative from "./useHomeServerNative";
|
||||
import useClientIdNative from "./useClientIdNative";
|
||||
import useSessionTokenNative from "./useSessionTokenNative";
|
||||
|
||||
|
||||
|
||||
export const ClientIdContext = createContext<{
|
||||
clientId: string | null,
|
||||
setClientId: (id: string | null) => void
|
||||
}>({
|
||||
clientId: null,
|
||||
setClientId: () => {}
|
||||
});
|
||||
export const HomeServerContext = createContext<{
|
||||
homeServer: string | null,
|
||||
setHomeServer: (uid: string | null) => void
|
||||
}>({
|
||||
homeServer: null,
|
||||
setHomeServer: () => {}
|
||||
});
|
||||
export const SessionTokenContext = createContext<{
|
||||
sessionToken: string | null,
|
||||
setSessionToken: (token: string | null) => void
|
||||
}>({
|
||||
sessionToken: null,
|
||||
setSessionToken() {}
|
||||
})
|
||||
|
||||
|
||||
export default function PersistentState(props: any) {
|
||||
|
||||
const homeServerContextValue = useHomeServerNative();
|
||||
const clientIdContextValue = useClientIdNative();
|
||||
const sessionTokenContextValue = useSessionTokenNative();
|
||||
|
||||
return (
|
||||
<HomeServerContext.Provider value={homeServerContextValue}>
|
||||
<ClientIdContext.Provider value={clientIdContextValue}>
|
||||
<SessionTokenContext.Provider value={sessionTokenContextValue}>
|
||||
{props.children}
|
||||
</SessionTokenContext.Provider>
|
||||
</ClientIdContext.Provider>
|
||||
</HomeServerContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
getClientId,
|
||||
setClientId
|
||||
} from '/@/lib/native';
|
||||
|
||||
export default function useClientIdNative() {
|
||||
const [cachedClientId, setCachedClientId] =
|
||||
useState<string | null>(getClientId());
|
||||
|
||||
const setClientIdCallback = useCallback((id: string | null) => {
|
||||
setClientId(id);
|
||||
setCachedClientId(getClientId());
|
||||
}, [cachedClientId]);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
clientId: cachedClientId,
|
||||
setClientId: setClientIdCallback
|
||||
};
|
||||
}, [cachedClientId, setClientIdCallback]);
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
setHomeServer,
|
||||
getHomeServer
|
||||
} from '/@/lib/native';
|
||||
|
||||
export default function useHomeServer() {
|
||||
const [cachedHomeServer, setCachedHomeServer] =
|
||||
useState<string | null>(getHomeServer());
|
||||
|
||||
const setHomeServerCallback = useCallback((url: string | null) => {
|
||||
setHomeServer(url);
|
||||
setCachedHomeServer(getHomeServer());
|
||||
}, [cachedHomeServer]);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
homeServer: cachedHomeServer,
|
||||
setHomeServer: setHomeServerCallback
|
||||
};
|
||||
}, [cachedHomeServer, setHomeServerCallback])
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import {
|
||||
getSessionToken,
|
||||
setSessionToken
|
||||
} from '/@/lib/native';
|
||||
|
||||
export default function useSessionTokenNative() {
|
||||
const [cachedSessionToken, setCachedSessionToken] =
|
||||
useState<string | null>(getSessionToken());
|
||||
|
||||
const setSessionTokenCallback = useCallback((token: string | null) => {
|
||||
setSessionToken(token);
|
||||
setCachedSessionToken(getSessionToken());
|
||||
}, [cachedSessionToken]);
|
||||
|
||||
return useMemo(() => {
|
||||
return {
|
||||
sessionToken: cachedSessionToken,
|
||||
setSessionToken: setSessionTokenCallback
|
||||
};
|
||||
}, [cachedSessionToken, setSessionTokenCallback])
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { useContext } from "react";
|
||||
import { ChannelContext } from "../contexts/EphemeralState/EphemeralState";
|
||||
|
||||
export default function useChannel() {
|
||||
return useContext(ChannelContext);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { useContext } from "react";
|
||||
import { ClientIdContext } from "../contexts/PersistentState/PersistentState";
|
||||
|
||||
|
||||
export default function useClientId() {
|
||||
return useContext(ClientIdContext);
|
||||
}
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useApi } from "../lib/useApi";
|
||||
import { v4 } from 'uuid';
|
||||
import { useLog } from "../components/useLog";
|
||||
|
||||
const b64 = async (data: Uint8Array) => {
|
||||
const base64url = await new Promise((r) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => r(reader.result);
|
||||
reader.readAsDataURL(new Blob([data]));
|
||||
});
|
||||
// @ts-ignore
|
||||
return base64url.split(",", 2)[1];
|
||||
};
|
||||
|
||||
export interface Upload {
|
||||
internalId: string;
|
||||
externalId: string | null;
|
||||
data: Blob;
|
||||
reader: any;
|
||||
progress: number;
|
||||
name: string;
|
||||
sent: number;
|
||||
rcvd: number;
|
||||
uploaded: boolean;
|
||||
processed: boolean;
|
||||
}
|
||||
|
||||
// 400 kb
|
||||
const CHUNK_SIZE = 4_000;
|
||||
|
||||
export default function useFileUpload() {
|
||||
const [uploads, setUploads] = useState<Upload[]>([]);
|
||||
|
||||
const updateUpload = (clientId: string, newData: Partial<Upload>) => {
|
||||
setUploads(uploads => uploads.map(upload => {
|
||||
if(upload.internalId !== clientId) return upload;
|
||||
return {
|
||||
...upload,
|
||||
...newData,
|
||||
};
|
||||
}));
|
||||
};
|
||||
|
||||
const { send } = useApi({
|
||||
'file:new'(data: NewFileResponse) {
|
||||
updateUpload(data.clientId, { externalId: data.serverId });
|
||||
},
|
||||
'file:chunk'(data: FileChunkResponse) {
|
||||
updateUpload(data.clientId, { rcvd: data.chunk, progress: data.progress });
|
||||
},
|
||||
'file:end'(data: FileEndResponse) {
|
||||
updateUpload(data.clientId, { processed: true });
|
||||
}
|
||||
}, []);
|
||||
|
||||
type Shit = {
|
||||
chunk: Uint8Array;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
const sendNextChunk = async (upload: Upload) => {
|
||||
if (upload.externalId === null) return;
|
||||
|
||||
const {chunk, done}: Shit = await new Promise(async (res) => {
|
||||
const { value, done } = await upload.reader.read();
|
||||
res({
|
||||
chunk: value,
|
||||
done
|
||||
})
|
||||
});
|
||||
|
||||
if(chunk === undefined && done) {
|
||||
updateUpload(upload.internalId, { uploaded: true });
|
||||
const fileEndRequest: FileEndRequest = {
|
||||
serverId: upload.externalId,
|
||||
}
|
||||
send('file:end', fileEndRequest)
|
||||
return;
|
||||
}
|
||||
|
||||
const chunkb64 = await b64(chunk);
|
||||
|
||||
updateUpload(upload.internalId, { sent: upload.sent + 1 });
|
||||
|
||||
const chunkReq: FileChunkRequest = {
|
||||
chunk: upload.sent,
|
||||
data: chunkb64,
|
||||
serverId: upload.externalId,
|
||||
};
|
||||
|
||||
send('file:chunk', chunkReq);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
for(const upload of uploads) {
|
||||
if(upload.rcvd === upload.sent && !upload.uploaded) {
|
||||
sendNextChunk(upload);
|
||||
}
|
||||
}
|
||||
}, [uploads])
|
||||
|
||||
const newFile = useCallback((name: string, type: string, blob: Blob) => {
|
||||
const id = v4();
|
||||
const newFileReq: NewFileRequest = {
|
||||
clientId: id,
|
||||
length: blob.size,
|
||||
name,
|
||||
type,
|
||||
};
|
||||
|
||||
// @ts-ignore
|
||||
const reader = blob.stream().getReader();
|
||||
|
||||
setUploads(uploads => [...uploads, {
|
||||
internalId: id,
|
||||
externalId: null,
|
||||
data: blob,
|
||||
reader: reader,
|
||||
progress: 0,
|
||||
name,
|
||||
sent: 0,
|
||||
rcvd: 0,
|
||||
uploaded: false,
|
||||
processed: false,
|
||||
}]);
|
||||
send('file:new', newFileReq);
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const getInfo = useCallback((clientId: string) => {
|
||||
const file = uploads.find(upload => upload.internalId === clientId);
|
||||
return file ?? null;
|
||||
}, [uploads])
|
||||
|
||||
// useLog(uploads, 'uploads');
|
||||
|
||||
return {
|
||||
newFile,
|
||||
getFileInfo: getInfo
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { useContext } from "react";
|
||||
import { HomeServerContext } from "../contexts/PersistentState/PersistentState";
|
||||
|
||||
export default function useHomeServer() {
|
||||
return useContext(HomeServerContext);
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
|
||||
|
||||
export default function useHover<T extends HTMLElement>(): [
|
||||
(t: T) => void,
|
||||
boolean
|
||||
] {
|
||||
const [value, setValue] = useState(false);
|
||||
const [ref, setRef] = useState<T | null>(null);
|
||||
|
||||
const handleMouseOver = () => setValue(true);
|
||||
const handleMouseOut = () => setValue(false);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
const node = ref;
|
||||
if (node) {
|
||||
node.addEventListener("mouseover", handleMouseOver);
|
||||
node.addEventListener("mouseout", handleMouseOut);
|
||||
return () => {
|
||||
node.removeEventListener("mouseover", handleMouseOver);
|
||||
node.removeEventListener("mouseout", handleMouseOut);
|
||||
};
|
||||
}
|
||||
},
|
||||
[ref] // Recall only if ref changes
|
||||
);
|
||||
return [setRef, value];
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export function usePrevious(value: any) {
|
||||
// The ref object is a generic container whose current property is mutable ...
|
||||
// ... and can hold any value, similar to an instance property on a class
|
||||
const ref = useRef<any>();
|
||||
// Store current value in ref
|
||||
useEffect(() => {
|
||||
ref.current = value;
|
||||
}, [value]); // Only re-run if value changes
|
||||
|
||||
// Return previous value (happens before update in useEffect above)
|
||||
return ref.current;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { useContext } from "react";
|
||||
import { SessionTokenContext } from "../contexts/PersistentState/PersistentState";
|
||||
|
||||
export default function useSessionToken() {
|
||||
return useContext(SessionTokenContext);
|
||||
}
|
||||
|
|
@ -1,8 +1,14 @@
|
|||
import React, { createContext } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import App from './pages/App';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import Sidebar from './components/TwoPanel';
|
||||
import App from './App';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
ReactDOM.render(
|
||||
<App></App>,
|
||||
document.getElementById('app'),
|
||||
);
|
||||
const container = document.getElementById('app');
|
||||
if(container !== null) {
|
||||
const root = ReactDOM.createRoot(container);
|
||||
// const portal = createPortal()
|
||||
root.render(<App></App>);
|
||||
} else {
|
||||
throw new Error('Failed to initialize app, container not found!');
|
||||
}
|
||||
|
|
@ -1,91 +1,128 @@
|
|||
|
||||
export function connectApi(url: string) {
|
||||
let socket: WebSocket | null = null;
|
||||
let connectionAttempts = 0;
|
||||
let destroy = false;
|
||||
let routers: any[] = [];
|
||||
let keepalive: NodeJS.Timer | null = null;
|
||||
|
||||
|
||||
let socket: WebSocket | null = null;
|
||||
let connectionAttempts = 0;
|
||||
const url = 'wss://dev.valnet.xyz';
|
||||
|
||||
let routers: any[] = [];
|
||||
|
||||
const connect = async () => {
|
||||
try {
|
||||
connectionAttempts ++;
|
||||
console.log('attempting api connection...');
|
||||
socket = new WebSocket(url);
|
||||
} catch (e) {
|
||||
if(connectionAttempts === 1)
|
||||
connect();
|
||||
else {
|
||||
const seconds = 2 ** connectionAttempts;
|
||||
console.log(`waiting ${seconds} seconds before reconnecting`);
|
||||
setTimeout(connect, 1000 * seconds);
|
||||
}
|
||||
return;
|
||||
function startKeepalive() {
|
||||
keepalive = setInterval(() => {
|
||||
if(socket !== null) {
|
||||
socket.send(JSON.stringify({
|
||||
action: 'up',
|
||||
data: {}
|
||||
}))
|
||||
} else {
|
||||
stopKeepalive();
|
||||
}
|
||||
}, 30_000);
|
||||
}
|
||||
|
||||
socket.addEventListener('open', () => {
|
||||
if(socket === null) return;
|
||||
connectionAttempts = 0;
|
||||
// socket.send('Hello Server!');
|
||||
});
|
||||
function stopKeepalive() {
|
||||
if(keepalive !== null)
|
||||
clearInterval(keepalive);
|
||||
}
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
const {action, data} = JSON.parse(event.data);
|
||||
console.log('[IN]', action, data);
|
||||
for(const router of routers) {
|
||||
router(action, data);
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
socket = null;
|
||||
connect();
|
||||
});
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
export async function send(action: string, data?: any) {
|
||||
if(socket === null) return;
|
||||
if(socket && socket.readyState === socket.CONNECTING) {
|
||||
const connect = async () => {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
socket?.addEventListener('open', resolve);
|
||||
socket?.addEventListener('close', reject);
|
||||
});
|
||||
} catch(e) {
|
||||
connectionAttempts ++;
|
||||
socket = new WebSocket(url);
|
||||
} catch (e) {
|
||||
if(destroy) return;
|
||||
if(connectionAttempts === 1)
|
||||
connect();
|
||||
else {
|
||||
const seconds = 2 ** connectionAttempts;
|
||||
console.log(`waiting ${seconds} seconds before reconnecting`);
|
||||
setTimeout(connect, 1000 * seconds);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if(socket.readyState !== socket.OPEN) return;
|
||||
}
|
||||
const message = JSON.stringify({ action, data });
|
||||
socket.send(message);
|
||||
}
|
||||
|
||||
export function router(routes: any) {
|
||||
for(const routeName in routes) {
|
||||
const route = routes[routeName];
|
||||
if(typeof route === 'object') {
|
||||
for(const suffix in route) {
|
||||
const combinedRouteName = routeName + ':' + suffix;
|
||||
routes[combinedRouteName] = route[suffix];
|
||||
socket.addEventListener('open', () => {
|
||||
if(socket === null) return;
|
||||
connectionAttempts = 0;
|
||||
console.log('connected to', url);
|
||||
startKeepalive();
|
||||
// socket.send('Hello Server!');
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
const {action, data} = JSON.parse(event.data);
|
||||
// console.debug('[IN]', action, data);
|
||||
const routeFound = routers
|
||||
.map(router => router(action, data))
|
||||
.reduce((a, b) => a + b, 0);
|
||||
if(routeFound === 0 && action !== 'up') {
|
||||
console.warn(`route <${action}> not found`);
|
||||
}
|
||||
delete routes[routeName];
|
||||
});
|
||||
|
||||
socket.addEventListener('close', () => {
|
||||
stopKeepalive();
|
||||
socket = null;
|
||||
if(destroy) return;
|
||||
|
||||
connect();
|
||||
});
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
async function send(action: string, data?: any) {
|
||||
if(socket === null) return;
|
||||
if(socket && socket.readyState === socket.CONNECTING) {
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
socket?.addEventListener('open', resolve);
|
||||
socket?.addEventListener('close', reject);
|
||||
});
|
||||
} catch(e) {
|
||||
return;
|
||||
}
|
||||
if(socket.readyState !== socket.OPEN) return;
|
||||
}
|
||||
const message = JSON.stringify({ action, data });
|
||||
socket.send(message);
|
||||
}
|
||||
|
||||
function registerRouter(router: any) {
|
||||
routers.push(router);
|
||||
}
|
||||
|
||||
function unregisterRouter(router: any) {
|
||||
routers = routers.filter(r => r !== router);
|
||||
}
|
||||
|
||||
function close() {
|
||||
destroy = true;
|
||||
if(socket) {
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
registerRouter,
|
||||
unregisterRouter,
|
||||
send,
|
||||
destroy: close
|
||||
}
|
||||
}
|
||||
|
||||
export interface RouterObject {
|
||||
[route: string]: (data: any) => void
|
||||
}
|
||||
|
||||
export type Router = (route: string, data: any) => boolean
|
||||
|
||||
export function router(routes: RouterObject): Router {
|
||||
return function(route: string, data: any) {
|
||||
if(route in routes) {
|
||||
routes[route](data);
|
||||
return true;
|
||||
} else {
|
||||
console.warn(`route <${route}> not found`);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function registerRouter(router: any) {
|
||||
routers.push(router);
|
||||
}
|
||||
|
||||
export function unregisterRouter(router: any) {
|
||||
routers = routers.filter(r => r !== router);
|
||||
}
|
||||
|
|
@ -0,0 +1,52 @@
|
|||
import * as preload from '#preload';
|
||||
// console.log('#preload', preload);
|
||||
|
||||
function ls(key: string, value?: string) {
|
||||
if(value === undefined) {
|
||||
return localStorage.getItem(key);
|
||||
} else {
|
||||
localStorage.setItem(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const functions: any = (function() {
|
||||
const electron = !!preload.versions;
|
||||
const cordova = 'cordova' in globalThis;
|
||||
|
||||
if(electron) {
|
||||
return preload;
|
||||
} else {
|
||||
let cid: string | null = null;
|
||||
let homeServer: string | null = null;
|
||||
return {
|
||||
getClientId() {
|
||||
return ls('clientId');
|
||||
},
|
||||
setClientId(id: any) {
|
||||
ls('clientId', id);
|
||||
},
|
||||
getHomeServer() {
|
||||
return ls('homeServer');
|
||||
},
|
||||
setHomeServer(str: string) {
|
||||
ls('homeServer', str);
|
||||
},
|
||||
getSessionToken() {
|
||||
return ls('sessionToken');
|
||||
},
|
||||
setSessionToken(str: string) {
|
||||
ls('sessionToken', str);
|
||||
},
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
// console.log('native functions loaded', functions);
|
||||
|
||||
export const getClientId = functions.getClientId;
|
||||
export const setClientId = functions.setClientId;
|
||||
export const getHomeServer = functions.getHomeServer;
|
||||
export const setHomeServer = functions.setHomeServer;
|
||||
export const getSessionToken = functions.getSessionToken;
|
||||
export const setSessionToken = functions.setSessionToken;
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import joinCallSound from '../../assets/join-call.wav';
|
||||
import leaveCallSound from '../../assets/leave-call.wav';
|
||||
const beep = "data:audio/wav;base64,//uQRAAAAWMSLwUIYAAsYkXgoQwAEaYLWfkWgAI0wWs/ItAAAGDgYtAgAyN+QWaAAihwMWm4G8QQRDiMcCBcH3Cc+CDv/7xA4Tvh9Rz/y8QADBwMWgQAZG/ILNAARQ4GLTcDeIIIhxGOBAuD7hOfBB3/94gcJ3w+o5/5eIAIAAAVwWgQAVQ2ORaIQwEMAJiDg95G4nQL7mQVWI6GwRcfsZAcsKkJvxgxEjzFUgfHoSQ9Qq7KNwqHwuB13MA4a1q/DmBrHgPcmjiGoh//EwC5nGPEmS4RcfkVKOhJf+WOgoxJclFz3kgn//dBA+ya1GhurNn8zb//9NNutNuhz31f////9vt///z+IdAEAAAK4LQIAKobHItEIYCGAExBwe8jcToF9zIKrEdDYIuP2MgOWFSE34wYiR5iqQPj0JIeoVdlG4VD4XA67mAcNa1fhzA1jwHuTRxDUQ//iYBczjHiTJcIuPyKlHQkv/LHQUYkuSi57yQT//uggfZNajQ3Vmz+Zt//+mm3Wm3Q576v////+32///5/EOgAAADVghQAAAAA//uQZAUAB1WI0PZugAAAAAoQwAAAEk3nRd2qAAAAACiDgAAAAAAABCqEEQRLCgwpBGMlJkIz8jKhGvj4k6jzRnqasNKIeoh5gI7BJaC1A1AoNBjJgbyApVS4IDlZgDU5WUAxEKDNmmALHzZp0Fkz1FMTmGFl1FMEyodIavcCAUHDWrKAIA4aa2oCgILEBupZgHvAhEBcZ6joQBxS76AgccrFlczBvKLC0QI2cBoCFvfTDAo7eoOQInqDPBtvrDEZBNYN5xwNwxQRfw8ZQ5wQVLvO8OYU+mHvFLlDh05Mdg7BT6YrRPpCBznMB2r//xKJjyyOh+cImr2/4doscwD6neZjuZR4AgAABYAAAABy1xcdQtxYBYYZdifkUDgzzXaXn98Z0oi9ILU5mBjFANmRwlVJ3/6jYDAmxaiDG3/6xjQQCCKkRb/6kg/wW+kSJ5//rLobkLSiKmqP/0ikJuDaSaSf/6JiLYLEYnW/+kXg1WRVJL/9EmQ1YZIsv/6Qzwy5qk7/+tEU0nkls3/zIUMPKNX/6yZLf+kFgAfgGyLFAUwY//uQZAUABcd5UiNPVXAAAApAAAAAE0VZQKw9ISAAACgAAAAAVQIygIElVrFkBS+Jhi+EAuu+lKAkYUEIsmEAEoMeDmCETMvfSHTGkF5RWH7kz/ESHWPAq/kcCRhqBtMdokPdM7vil7RG98A2sc7zO6ZvTdM7pmOUAZTnJW+NXxqmd41dqJ6mLTXxrPpnV8avaIf5SvL7pndPvPpndJR9Kuu8fePvuiuhorgWjp7Mf/PRjxcFCPDkW31srioCExivv9lcwKEaHsf/7ow2Fl1T/9RkXgEhYElAoCLFtMArxwivDJJ+bR1HTKJdlEoTELCIqgEwVGSQ+hIm0NbK8WXcTEI0UPoa2NbG4y2K00JEWbZavJXkYaqo9CRHS55FcZTjKEk3NKoCYUnSQ0rWxrZbFKbKIhOKPZe1cJKzZSaQrIyULHDZmV5K4xySsDRKWOruanGtjLJXFEmwaIbDLX0hIPBUQPVFVkQkDoUNfSoDgQGKPekoxeGzA4DUvnn4bxzcZrtJyipKfPNy5w+9lnXwgqsiyHNeSVpemw4bWb9psYeq//uQZBoABQt4yMVxYAIAAAkQoAAAHvYpL5m6AAgAACXDAAAAD59jblTirQe9upFsmZbpMudy7Lz1X1DYsxOOSWpfPqNX2WqktK0DMvuGwlbNj44TleLPQ+Gsfb+GOWOKJoIrWb3cIMeeON6lz2umTqMXV8Mj30yWPpjoSa9ujK8SyeJP5y5mOW1D6hvLepeveEAEDo0mgCRClOEgANv3B9a6fikgUSu/DmAMATrGx7nng5p5iimPNZsfQLYB2sDLIkzRKZOHGAaUyDcpFBSLG9MCQALgAIgQs2YunOszLSAyQYPVC2YdGGeHD2dTdJk1pAHGAWDjnkcLKFymS3RQZTInzySoBwMG0QueC3gMsCEYxUqlrcxK6k1LQQcsmyYeQPdC2YfuGPASCBkcVMQQqpVJshui1tkXQJQV0OXGAZMXSOEEBRirXbVRQW7ugq7IM7rPWSZyDlM3IuNEkxzCOJ0ny2ThNkyRai1b6ev//3dzNGzNb//4uAvHT5sURcZCFcuKLhOFs8mLAAEAt4UWAAIABAAAAAB4qbHo0tIjVkUU//uQZAwABfSFz3ZqQAAAAAngwAAAE1HjMp2qAAAAACZDgAAAD5UkTE1UgZEUExqYynN1qZvqIOREEFmBcJQkwdxiFtw0qEOkGYfRDifBui9MQg4QAHAqWtAWHoCxu1Yf4VfWLPIM2mHDFsbQEVGwyqQoQcwnfHeIkNt9YnkiaS1oizycqJrx4KOQjahZxWbcZgztj2c49nKmkId44S71j0c8eV9yDK6uPRzx5X18eDvjvQ6yKo9ZSS6l//8elePK/Lf//IInrOF/FvDoADYAGBMGb7FtErm5MXMlmPAJQVgWta7Zx2go+8xJ0UiCb8LHHdftWyLJE0QIAIsI+UbXu67dZMjmgDGCGl1H+vpF4NSDckSIkk7Vd+sxEhBQMRU8j/12UIRhzSaUdQ+rQU5kGeFxm+hb1oh6pWWmv3uvmReDl0UnvtapVaIzo1jZbf/pD6ElLqSX+rUmOQNpJFa/r+sa4e/pBlAABoAAAAA3CUgShLdGIxsY7AUABPRrgCABdDuQ5GC7DqPQCgbbJUAoRSUj+NIEig0YfyWUho1VBBBA//uQZB4ABZx5zfMakeAAAAmwAAAAF5F3P0w9GtAAACfAAAAAwLhMDmAYWMgVEG1U0FIGCBgXBXAtfMH10000EEEEEECUBYln03TTTdNBDZopopYvrTTdNa325mImNg3TTPV9q3pmY0xoO6bv3r00y+IDGid/9aaaZTGMuj9mpu9Mpio1dXrr5HERTZSmqU36A3CumzN/9Robv/Xx4v9ijkSRSNLQhAWumap82WRSBUqXStV/YcS+XVLnSS+WLDroqArFkMEsAS+eWmrUzrO0oEmE40RlMZ5+ODIkAyKAGUwZ3mVKmcamcJnMW26MRPgUw6j+LkhyHGVGYjSUUKNpuJUQoOIAyDvEyG8S5yfK6dhZc0Tx1KI/gviKL6qvvFs1+bWtaz58uUNnryq6kt5RzOCkPWlVqVX2a/EEBUdU1KrXLf40GoiiFXK///qpoiDXrOgqDR38JB0bw7SoL+ZB9o1RCkQjQ2CBYZKd/+VJxZRRZlqSkKiws0WFxUyCwsKiMy7hUVFhIaCrNQsKkTIsLivwKKigsj8XYlwt/WKi2N4d//uQRCSAAjURNIHpMZBGYiaQPSYyAAABLAAAAAAAACWAAAAApUF/Mg+0aohSIRobBAsMlO//Kk4soosy1JSFRYWaLC4qZBYWFRGZdwqKiwkNBVmoWFSJkWFxX4FFRQWR+LsS4W/rFRb/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////VEFHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAU291bmRib3kuZGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMjAwNGh0dHA6Ly93d3cuc291bmRib3kuZGUAAAAAAAAAACU=";
|
||||
|
||||
function play(sound: string) {
|
||||
var snd = new Audio(sound);
|
||||
snd.play();
|
||||
}
|
||||
|
||||
export const sfx = {
|
||||
message: () => play(beep),
|
||||
joinCall: () => play(joinCallSound),
|
||||
leaveCall: () => play(leaveCallSound)
|
||||
}
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { useContext, useEffect, useMemo } from 'react';
|
||||
import { ServerConnectionContext } from '../components/ServerConnection';
|
||||
import useSessionToken from '../hooks/useSessionToken';
|
||||
import { Router, router, RouterObject } from './api';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
export function useApi(actions: Router | RouterObject = {}, deps: any[] = []) {
|
||||
const connection = useContext(ServerConnectionContext);
|
||||
const _router = typeof actions === 'object' ? router(actions) : actions;
|
||||
const { sessionToken } = useSessionToken();
|
||||
const componentId = useMemo(() => { return v4() }, []);
|
||||
|
||||
useEffect(() => {
|
||||
connection.registerRouter(_router);
|
||||
return () => {
|
||||
connection.unregisterRouter(_router);
|
||||
};
|
||||
}, deps);
|
||||
|
||||
return {
|
||||
send(action: string, data: Object = {}) {
|
||||
if('sessionToken' in data) {
|
||||
console.warn('sessionToken already present in action. this is deprecated.')
|
||||
console.trace();
|
||||
}
|
||||
connection.send(action, {
|
||||
...(data ?? {}),
|
||||
sessionToken,
|
||||
$componentId: componentId
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
import { useEffect, useState } from 'react';
|
||||
|
||||
function useMediaQuery(query: string): boolean {
|
||||
const getMatches = (query: string): boolean => {
|
||||
// Prevents SSR issues
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.matchMedia(query).matches;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const [matches, setMatches] = useState<boolean>(getMatches(query));
|
||||
|
||||
function handleChange() {
|
||||
setMatches(getMatches(query));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const matchMedia = window.matchMedia(query);
|
||||
|
||||
// Triggered at the first client-side load and if query changes
|
||||
handleChange();
|
||||
|
||||
// Listen matchMedia
|
||||
if (matchMedia.addListener) {
|
||||
matchMedia.addListener(handleChange);
|
||||
} else {
|
||||
matchMedia.addEventListener('change', handleChange);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (matchMedia.removeListener) {
|
||||
matchMedia.removeListener(handleChange);
|
||||
} else {
|
||||
matchMedia.removeEventListener('change', handleChange);
|
||||
}
|
||||
};
|
||||
}, [query]);
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
export default useMediaQuery;
|
||||
|
|
@ -1,39 +0,0 @@
|
|||
import { createContext, useState } from 'react';
|
||||
import Channels from './Channels';
|
||||
import Chat from './Chat';
|
||||
|
||||
|
||||
export const channelContext = createContext<{
|
||||
channel: string | null,
|
||||
setChannel: (uid: string) => void
|
||||
}>({
|
||||
channel: null,
|
||||
setChannel: () => {},
|
||||
});
|
||||
|
||||
export default function App() {
|
||||
const [channel, setChannel] = useState<string | null>(null);
|
||||
|
||||
const channelContextValue = { channel, setChannel };
|
||||
|
||||
return (
|
||||
<channelContext.Provider value={channelContextValue}>
|
||||
<div style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '200px 1fr',
|
||||
gridTemplateRows: '1fr',
|
||||
height: '100%',
|
||||
}}>
|
||||
<div style={{
|
||||
background: 'rgba(25, 26, 33)',
|
||||
borderRight: '1px solid #bd93f9',
|
||||
}}>
|
||||
<Channels></Channels>
|
||||
</div>
|
||||
<div>
|
||||
<Chat></Chat>
|
||||
</div>
|
||||
</div>
|
||||
</channelContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,70 @@
|
|||
import useHover from "../hooks/useHover";
|
||||
|
||||
export function BigButton(props: any) {
|
||||
const [ref, hover] = useHover<HTMLDivElement>();
|
||||
|
||||
const angle = props.angle ?? 20;
|
||||
const width = props.width ?? 'auto';
|
||||
const display = !!props.inline ? 'inline-grid' : 'grid';
|
||||
|
||||
return (
|
||||
<div ref={ref} onClick={props.onClick ?? (() => { })} style={{
|
||||
cursor: 'pointer',
|
||||
width: width,
|
||||
// margin: '4px',
|
||||
display: display,
|
||||
padding: '8px',
|
||||
borderRadius: '8px',
|
||||
gridAutoFlow: 'column',
|
||||
gridTemplateColumns: 'min-content 1fr',
|
||||
background: (!!props.bright) ? (
|
||||
props.selected ? 'var(--neutral-6)' :
|
||||
hover ? 'var(--neutral-6)' :
|
||||
'var(--neutral-5)'
|
||||
) : (
|
||||
props.selected ? 'var(--neutral-5)' :
|
||||
hover ? 'var(--neutral-4)' :
|
||||
'inherit'
|
||||
),
|
||||
transform: `skew(-${angle}deg, 0deg)`,
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
<div style={{
|
||||
padding: '4px',
|
||||
display: 'flex',
|
||||
transform: `skew(${angle}deg, 0deg)`,
|
||||
}}>
|
||||
{props.icon({
|
||||
size: 16,
|
||||
color: !!props.color ? props.color : (!!props.bright) ? (
|
||||
props.selected ? 'var(--neutral-9)' :
|
||||
hover ? 'var(--neutral-8)' :
|
||||
'var(--neutral-8)'
|
||||
) : (
|
||||
props.selected ? 'var(--neutral-9)' :
|
||||
hover ? 'var(--neutral-7)' :
|
||||
'var(--neutral-7)'
|
||||
),
|
||||
})}
|
||||
</div>
|
||||
|
||||
<span style={{
|
||||
lineHeight: '24px',
|
||||
paddingLeft: '4px',
|
||||
paddingRight: '4px',
|
||||
color: !!props.color ? props.color : (!!props.bright) ? (
|
||||
props.selected ? 'var(--neutral-9)' :
|
||||
hover ? 'var(--neutral-9)' :
|
||||
'var(--neutral-9)'
|
||||
) : (
|
||||
props.selected ? 'var(--neutral-9)' :
|
||||
hover ? 'var(--neutral-9)' :
|
||||
'var(--neutral-7)'
|
||||
),
|
||||
transform: `skew(${angle}deg, 0deg)`,
|
||||
}}>
|
||||
{props.text}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,154 @@
|
|||
import { CgHashtag } from "react-icons/cg";
|
||||
import { MdVolumeUp } from "react-icons/md";
|
||||
import { BsQuestionLg } from 'react-icons/bs';
|
||||
import { ChannelType } from "../contexts/EphemeralState/EphemeralState";
|
||||
import useChannel from "../hooks/useChannel";
|
||||
import useHover from "../hooks/useHover";
|
||||
import { useApi } from "../lib/useApi";
|
||||
import { useContext, useEffect, useState } from "react";
|
||||
import { VoiceChannelContext } from "../contexts/EphemeralState/VoiceChannelState";
|
||||
import { ClientsListContext } from "../contexts/EphemeralState/ClientsListState";
|
||||
import { sfx } from "../lib/sound";
|
||||
|
||||
interface ChannelProps {
|
||||
unread: number;
|
||||
uid: string;
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
}
|
||||
|
||||
interface Participant {
|
||||
peerId: string;
|
||||
channelId: string;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
export default function Channel(props: ChannelProps) {
|
||||
const { clientName } = useContext(ClientsListContext);
|
||||
const { channel, setChannel } = useChannel();
|
||||
const { unread, uid, name, type } = props;
|
||||
const [ref, hover] = useHover<HTMLDivElement>();
|
||||
const selected = channel === uid;
|
||||
const { voiceChannelId } = useContext(VoiceChannelContext);
|
||||
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
|
||||
const { send } = useApi({
|
||||
'voice:join'(data: any) {
|
||||
if(type !== 'voice' || data.channelId !== uid) return;
|
||||
setParticipants([...participants, {
|
||||
clientId: data.clientId,
|
||||
peerId: data.peerId,
|
||||
channelId: data.channelId
|
||||
}])
|
||||
},
|
||||
'voice:list'(data: any) {
|
||||
if(type !== 'voice') return;
|
||||
if(uid !== data.uid) return;
|
||||
setParticipants(data.participants);
|
||||
},
|
||||
'voice:leave'(data: any) {
|
||||
setParticipants(participants => participants.filter(p => (
|
||||
p.channelId !== data.channelId ||
|
||||
p.clientId !== data.clientId ||
|
||||
p.peerId !== data.peerId
|
||||
)));
|
||||
},
|
||||
}, [uid, type, participants])
|
||||
|
||||
useEffect(() => {
|
||||
if(type !== 'voice') return;
|
||||
|
||||
setParticipants([]);
|
||||
send('voice:list', { uid });
|
||||
}, [uid]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
margin: '2px 2px',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'min-content 1fr',
|
||||
cursor: 'pointer',
|
||||
background: selected ? 'var(--neutral-5)' :
|
||||
hover ? 'var(--neutral-4)' :
|
||||
'inherit',
|
||||
borderRadius: '8px',
|
||||
transform:'skew(-20deg, 0deg)',
|
||||
transition: 'background 300ms, color 300ms',
|
||||
}}
|
||||
onClick={() => {
|
||||
setChannel(uid, type);
|
||||
}}
|
||||
ref={ref}
|
||||
>
|
||||
{(type === 'text') ? (
|
||||
<CgHashtag color={
|
||||
selected ? 'var(--neutral-9)' :
|
||||
hover ? 'var(--neutral-7)' :
|
||||
'var(--neutral-7)'
|
||||
} size={24} style={{
|
||||
margin: '4px',
|
||||
transition: 'background 300ms, color 300ms',
|
||||
transform:'skew(-5deg, 0deg)',
|
||||
}}></CgHashtag>
|
||||
) : ((type === 'voice') ? (
|
||||
<MdVolumeUp color={
|
||||
selected ? 'var(--neutral-9)' :
|
||||
hover ? 'var(--neutral-7)' :
|
||||
'var(--neutral-7)'
|
||||
} size={24} style={{
|
||||
margin: '4px',
|
||||
transition: 'background 300ms, color 300ms',
|
||||
transform:'skew(20deg, 0deg)',
|
||||
}}></MdVolumeUp>
|
||||
) : (
|
||||
<BsQuestionLg color={
|
||||
selected ? 'var(--neutral-9)' :
|
||||
hover ? 'var(--neutral-7)' :
|
||||
'var(--neutral-7)'
|
||||
} size={24} style={{
|
||||
margin: '4px',
|
||||
transition: 'background 300ms, color 300ms',
|
||||
transform:'skew(20deg, 0deg)',
|
||||
}}></BsQuestionLg>
|
||||
))}
|
||||
<div style={{
|
||||
lineHeight: '32px',
|
||||
color: selected ? 'var(--neutral-9)' :
|
||||
hover ? 'var(--neutral-9)' :
|
||||
'var(--neutral-7)',
|
||||
transform:'skew(20deg, 0deg)',
|
||||
transition: 'background 300ms, color 300ms',
|
||||
}}>
|
||||
{name.toLowerCase().replaceAll(' ', '-').trim()}
|
||||
</div>
|
||||
{/* {(unread > 0) && (
|
||||
<span style={{ paddingRight: '8px' }}>({unread})</span>
|
||||
)}
|
||||
<span style={{
|
||||
fontWeight: (unread ?? 0) > 0 ? 'bold' : '300',
|
||||
}}>
|
||||
{name}
|
||||
</span>
|
||||
<a style={{
|
||||
color: 'rgba(0, 100, 200, 1)',
|
||||
marginLeft: '8px',
|
||||
fontSize: '10px',
|
||||
}} href="#" onClick={() => {}}>Delete</a> */}
|
||||
</div>
|
||||
|
||||
<div style={{
|
||||
// outline: '1px solid white',
|
||||
paddingLeft: '32px',
|
||||
}}>
|
||||
{participants.map(participant => (
|
||||
<div key={participant.clientId}>
|
||||
{clientName[participant.clientId]}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,48 +1,53 @@
|
|||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { registerRouter, router, send, unregisterRouter } from '../lib/api';
|
||||
import { channelContext } from './App';
|
||||
|
||||
function useRouter(actions: Function | object, deps: any[]) {
|
||||
const _router = typeof actions === 'object' ? router(actions) : actions;
|
||||
useEffect(() => {
|
||||
registerRouter(_router);
|
||||
return () => {
|
||||
unregisterRouter(_router);
|
||||
};
|
||||
}, deps);
|
||||
|
||||
return {
|
||||
send: send,
|
||||
};
|
||||
}
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import type { IMessage } from './Message';
|
||||
import useChannel from '../hooks/useChannel';
|
||||
import useClientId from '../hooks/useClientId';
|
||||
import useHomeServer from '../contexts/PersistentState/useHomeServerNative';
|
||||
import Channel from './Channel';
|
||||
import { ChannelType } from '../contexts/EphemeralState/EphemeralState';
|
||||
import { sfx } from '../lib/sound';
|
||||
|
||||
interface IChannel {
|
||||
uid: string;
|
||||
name: string;
|
||||
type: ChannelType;
|
||||
}
|
||||
|
||||
function Hashmark() {
|
||||
return <span style={{
|
||||
fontWeight: 'bold',
|
||||
marginRight: '8px',
|
||||
marginLeft: '8px',
|
||||
}}>#</span>;
|
||||
interface IUnreads {
|
||||
[uid: string]: number
|
||||
}
|
||||
|
||||
export default function Channels() {
|
||||
|
||||
const [channels, setChannels] = useState<IChannel[]>([]);
|
||||
const {channel, setChannel} = useContext(channelContext);
|
||||
const [unreads, setUnreads] = useState<IUnreads>({});
|
||||
|
||||
const { send } = useRouter({
|
||||
'channels:list'(data: IChannel[]) {
|
||||
// console.log(data)
|
||||
setChannels(data);
|
||||
const { channel, setChannel } = useChannel();
|
||||
const { clientId } = useClientId();
|
||||
|
||||
const { send } = useApi({
|
||||
'channels:list'(data: any) {
|
||||
setChannels(data.channels);
|
||||
},
|
||||
'channel:add'(channel: IChannel) {
|
||||
setChannels([...channels, channel]);
|
||||
},
|
||||
}, [channels]);
|
||||
'message:message'(message: IMessage) {
|
||||
sfx.message();
|
||||
if(channel === message.channel) return;
|
||||
|
||||
setUnreads({
|
||||
...unreads,
|
||||
[message.channel]: (unreads[message.channel] ?? 0) + 1,
|
||||
});
|
||||
},
|
||||
}, [channels, unreads]);
|
||||
|
||||
useEffect(() => {
|
||||
if(channels.length === 0) {
|
||||
|
|
@ -53,9 +58,22 @@ export default function Channels() {
|
|||
useEffect(() => {
|
||||
if(channels.length === 0) return;
|
||||
if(channel !== null) return;
|
||||
setChannel(channels[0].uid);
|
||||
setChannel(channels[0].uid, channels[0].type);
|
||||
}, [channel, channels]);
|
||||
|
||||
useEffect(() => {
|
||||
if(!channel) return;
|
||||
setUnreads({
|
||||
...unreads,
|
||||
[channel]: 0,
|
||||
});
|
||||
}, [channel]);
|
||||
|
||||
useEffect(() => {
|
||||
if(clientId === null) return;
|
||||
// send('client:get', { clientId });
|
||||
}, [clientId]);
|
||||
|
||||
const textbox = useRef<HTMLInputElement>(null);
|
||||
const add = useCallback(() => {
|
||||
if(textbox.current === null) return;
|
||||
|
|
@ -65,43 +83,23 @@ export default function Channels() {
|
|||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{
|
||||
height: '100%',
|
||||
background: 'var(--neutral-3)',
|
||||
padding: '0px 8px',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
}}>
|
||||
<br></br>
|
||||
{channels.map(c => (
|
||||
<div key={c.uid} style={{
|
||||
margin: '8px 0px',
|
||||
color: channel === c.uid ? 'cyan' : 'inherit',
|
||||
cursor: 'pointer',
|
||||
}} onClick={() => {
|
||||
setChannel(c.uid);
|
||||
}}>
|
||||
<Hashmark></Hashmark>{c.name}
|
||||
<a style={{ color: 'rgba(0, 100, 200, 1)', marginLeft: '8px', fontSize: '10px' }} href="#" onClick={() => {}}>Delete</a>
|
||||
</div>
|
||||
<Channel
|
||||
key={c.uid}
|
||||
uid={c.uid}
|
||||
type={c.type}
|
||||
unread={unreads[c.uid] ?? 0}
|
||||
name={c.name}
|
||||
></Channel>
|
||||
))}
|
||||
<Hashmark></Hashmark><input
|
||||
ref={textbox}
|
||||
style={{
|
||||
background: '#343746',
|
||||
border: 'none',
|
||||
padding: '8px',
|
||||
borderRadius: '8px',
|
||||
outline: 'none',
|
||||
color: 'white',
|
||||
fontSize: '16px',
|
||||
width: '90px',
|
||||
}}
|
||||
/><button onClick={add} style={{
|
||||
marginLeft: '8px',
|
||||
background: '#bd93f9',
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
padding: '8px',
|
||||
fontSize: '16px',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '8px',
|
||||
// lineHeight: '20px'
|
||||
}}>ADD</button>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,12 +1,16 @@
|
|||
import { useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import TimeAgo from 'react-timeago';
|
||||
import { v4 } from 'uuid';
|
||||
import { registerRouter, router, send, unregisterRouter } from '../lib/api';
|
||||
import { channelContext } from './App';
|
||||
import { useApi } from '../lib/useApi';
|
||||
import type { IMessage} from './Message';
|
||||
import { Message } from './Message';
|
||||
import { MdSend } from 'react-icons/md';
|
||||
import useChannel from '../hooks/useChannel';
|
||||
import useClientId from '../hooks/useClientId';
|
||||
import useSessionToken from '../hooks/useSessionToken';
|
||||
import ChatInput from '../components/ChatInput';
|
||||
|
||||
function createMessage(from: string, text: string, channel: string, t = 0): IMessage {
|
||||
function createMessage(from: string, text: string,
|
||||
channel: string, t = 0): IMessage {
|
||||
return {
|
||||
text,
|
||||
from,
|
||||
|
|
@ -18,123 +22,116 @@ function createMessage(from: string, text: string, channel: string, t = 0): IMes
|
|||
|
||||
export default () => {
|
||||
const [messages, setMessages] = useState<IMessage[]>([]);
|
||||
const [hist, setHist] = useState(false);
|
||||
const { sessionToken } = useSessionToken();
|
||||
|
||||
const textBoxRef = useRef<HTMLDivElement>(null);
|
||||
const { channel, setChannel } = useContext(channelContext);
|
||||
const PADDING = 8;
|
||||
|
||||
const { channel, setChannel } = useChannel();
|
||||
const { clientId } = useClientId();
|
||||
|
||||
const { send } = useApi({
|
||||
'message:message'(data: IMessage) {
|
||||
if(data.channel !== channel) return;
|
||||
|
||||
setMessages(messages => ([...messages, data]));
|
||||
},
|
||||
'message:recent'(data: { messages: IMessage[] }) {
|
||||
setMessages(data.messages.reverse());
|
||||
},
|
||||
}, [channel]);
|
||||
|
||||
useEffect(() => {
|
||||
const actions = router({
|
||||
'message:message'(data: IMessage) {
|
||||
setMessages([...messages, data]);
|
||||
},
|
||||
'message:recent'(data: { messages: IMessage[] }) {
|
||||
setMessages(data.messages);
|
||||
},
|
||||
});
|
||||
registerRouter(actions);
|
||||
return () => {
|
||||
unregisterRouter(actions);
|
||||
};
|
||||
}, [messages]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('sending recents request');
|
||||
send('message:recent', { channel });
|
||||
}, [channel]);
|
||||
|
||||
const sendMessage = useCallback(() => {
|
||||
if(textBoxRef.current === null) return;
|
||||
if(channel === null) return;
|
||||
send('message:message', createMessage('Val', textBoxRef.current.innerText, channel));
|
||||
textBoxRef.current.innerText = '';
|
||||
}, [channel]);
|
||||
// const sendMessage = useCallback(() => {
|
||||
// if(textBoxRef.current === null) return;
|
||||
// if(channel === null) return;
|
||||
// if(clientId === null) return;
|
||||
// if(sessionToken === null) return;
|
||||
// send(
|
||||
// 'message:message',
|
||||
// createMessage(
|
||||
// clientId,
|
||||
// textBoxRef.current.innerText,
|
||||
// channel,
|
||||
// )
|
||||
// );
|
||||
// textBoxRef.current.innerText = '';
|
||||
// }, [channel, sessionToken]);
|
||||
|
||||
const keyDown = useCallback((evt: any) => {
|
||||
console.log(evt);
|
||||
if(evt.key === 'Enter') {
|
||||
sendMessage();
|
||||
}
|
||||
}, [sendMessage]);
|
||||
// const keyDown = useCallback((evt: any) => {
|
||||
// if(evt.key === 'Enter') {
|
||||
// sendMessage();
|
||||
// }
|
||||
// }, [sendMessage]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 64px',
|
||||
gridTemplateRows: '1fr 64px',
|
||||
gridTemplateAreas: '"content content" "message send"',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
// borderBottom: '1px solid #bd93f9',
|
||||
gridArea: 'content',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '0px',
|
||||
width: '100%',
|
||||
}}>
|
||||
{messages.map(message => (
|
||||
<Message key={message.uid} message={message}></Message>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div onClick={() => {
|
||||
textBoxRef.current?.focus();
|
||||
}}style={{
|
||||
margin: '8px',
|
||||
marginRight: '3px',
|
||||
borderRadius: '8px',
|
||||
background: '#343746',
|
||||
gridArea: 'message',
|
||||
display: 'grid',
|
||||
placeItems: 'center center',
|
||||
padding: '0px 16px',
|
||||
cursor: 'text',
|
||||
overflow: 'auto',
|
||||
}}>
|
||||
<div
|
||||
ref={textBoxRef}
|
||||
onKeyDown={keyDown}
|
||||
className="input"
|
||||
role="textbox"
|
||||
contentEditable
|
||||
style={{
|
||||
background: 'inherit',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
borderRadius: '8px',
|
||||
width: '100%',
|
||||
resize: 'none',
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
display: 'grid',
|
||||
background: 'var(--neutral-4)',
|
||||
gridTemplateColumns: `1fr min-content`,
|
||||
gridTemplateRows: `1fr min-content`,
|
||||
gridTemplateAreas: '"content content" "message send"',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
// borderBottom: '1px solid #bd93f9',
|
||||
gridArea: 'content',
|
||||
position: 'relative',
|
||||
// borderBottom: '1px solid white'
|
||||
}}>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '0px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
padding: '8px',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
<div onClick={sendMessage} style={{
|
||||
background: '#bd93f9',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
// borderRadius: '50%',
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
display: 'grid',
|
||||
placeItems: 'center center',
|
||||
fontSize: '32px',
|
||||
}}>
|
||||
|
||||
</div>
|
||||
{messages.map(message => (
|
||||
<Message key={message.uid} message={message}></Message>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
<ChatInput></ChatInput>
|
||||
<SendButton></SendButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
function SendButton() {
|
||||
return (
|
||||
<div style={{
|
||||
height: '100%',
|
||||
width: '64px',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '64px',
|
||||
height: '64px',
|
||||
padding: '8px',
|
||||
boxSizing: 'border-box',
|
||||
position: 'absolute',
|
||||
bottom: '0px',
|
||||
}}>
|
||||
<div style={{
|
||||
background: '#bd93f9',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
borderRadius: '50%',
|
||||
// borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
display: 'grid',
|
||||
placeItems: 'center center',
|
||||
paddingLeft: '4px',
|
||||
// paddingTop: '2px',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
<MdSend size={24}></MdSend>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { useContext, useEffect, useState } from "react";
|
||||
import QR from 'qrcode';
|
||||
import useHomeServer from "../contexts/PersistentState/useHomeServerNative";
|
||||
import useClientId from "../hooks/useClientId";
|
||||
|
||||
export default function LoginQR() {
|
||||
const { homeServer } = useHomeServer()
|
||||
const { clientId } = useClientId();
|
||||
const [qr, setQr] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setQr(await QR.toDataURL(
|
||||
'loginv1|' + homeServer + '|' + clientId
|
||||
));
|
||||
})()
|
||||
}, [clientId, homeServer])
|
||||
|
||||
return <img src={qr ?? undefined} />
|
||||
}
|
||||
|
|
@ -1,12 +1,9 @@
|
|||
import { useContext, useEffect } from 'react';
|
||||
import TimeAgo from 'react-timeago';
|
||||
import { ClientsListContext } from '../contexts/EphemeralState/ClientsListState';
|
||||
import useHover from '../hooks/useHover';
|
||||
|
||||
export interface IMessage {
|
||||
uid: string;
|
||||
timestamp: number;
|
||||
from: string;
|
||||
text: string;
|
||||
channel: string;
|
||||
}
|
||||
export type IMessage = NewMessageResponse;
|
||||
|
||||
interface MessageProps {
|
||||
message: IMessage
|
||||
|
|
@ -14,55 +11,71 @@ interface MessageProps {
|
|||
|
||||
const firstLineIndent = '10px';
|
||||
const multiLineIndent = '16px';
|
||||
const rightMessagePagging = '16px';
|
||||
const rightMessagePadding = '16px';
|
||||
|
||||
export function Message({
|
||||
message,
|
||||
}: MessageProps) {
|
||||
|
||||
const { clientName } = useContext(ClientsListContext);
|
||||
const [hoverRef, hover] = useHover<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div key={message.uid} style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '128px 1fr',
|
||||
<div ref={hoverRef} style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '4em 1fr',
|
||||
width: '100%',
|
||||
padding: '1px 0px',
|
||||
position: 'relative',
|
||||
}}>
|
||||
<div style={{
|
||||
width: '100%',
|
||||
padding: '1px 0px',
|
||||
height: '100%',
|
||||
background: hover ? 'var(--neutral-3)' : undefined,
|
||||
position: 'absolute',
|
||||
opacity: '0.5',
|
||||
}}></div>
|
||||
<span style={{
|
||||
fontStyle: 'italic',
|
||||
color: 'var(--neutral-6)',
|
||||
textAlign: 'right',
|
||||
userSelect: 'none',
|
||||
marginRight: '16px',
|
||||
position: 'relative'
|
||||
}}>
|
||||
<span style={{
|
||||
fontStyle: 'italic',
|
||||
color: '#596793',
|
||||
textAlign: 'right',
|
||||
userSelect: 'none',
|
||||
marginRight: '16px',
|
||||
}}>
|
||||
<TimeAgo
|
||||
date={message.timestamp}
|
||||
formatter={(t, u) => u === 'second' ? 'Just Now' : ('' + t + u[0])}
|
||||
></TimeAgo>
|
||||
</span>
|
||||
<span style={{
|
||||
}}>
|
||||
<div style={{
|
||||
fontWeight: 'bold',
|
||||
float: 'left',
|
||||
paddingRight: firstLineIndent,
|
||||
// marginRight: '16px',
|
||||
// height: '100%'
|
||||
// borderBottom: '1px solid white'
|
||||
<TimeAgo
|
||||
date={message.timestamp}
|
||||
formatter={(t, u) => u === 'second' ? 'Now' : ('' + t + u[0])}
|
||||
></TimeAgo>
|
||||
</span>
|
||||
<div style={{
|
||||
// outline: '1px solid white',
|
||||
marginRight: '16px',
|
||||
position: 'relative',
|
||||
paddingLeft: '1em',
|
||||
textIndent: '-1em',
|
||||
}}>
|
||||
<div>
|
||||
<span style={{
|
||||
fontWeight: '500',
|
||||
paddingRight: '8px',
|
||||
}}>
|
||||
{message.from}
|
||||
</div>
|
||||
<div style={{
|
||||
marginRight: rightMessagePagging,
|
||||
paddingLeft: multiLineIndent,
|
||||
boxSizing: 'border-box',
|
||||
position: 'relative',
|
||||
{clientName[message.from]}
|
||||
</span>
|
||||
<span style={{
|
||||
}}>
|
||||
{message.text}
|
||||
</span>
|
||||
</div>
|
||||
{!!message.file && (
|
||||
<div>
|
||||
<img style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '20vh',
|
||||
}} src={message.file.url}></img>
|
||||
</div>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import {
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import useClientId from '../hooks/useClientId';
|
||||
import { useApi } from '../lib/useApi';
|
||||
|
||||
|
||||
export default function NameTextbox() {
|
||||
const { clientId } = useClientId()
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [inputElement, setInputElement] = useState<HTMLInputElement | null>(null);
|
||||
|
||||
const { send } = useApi({
|
||||
'client:get'(data: any) {
|
||||
setName(data.name);
|
||||
},
|
||||
}, [name, clientId]);
|
||||
|
||||
const update = useCallback(() => {
|
||||
if(inputElement === null) return;
|
||||
if(clientId === null) return;
|
||||
send('client:rename', {
|
||||
clientId: clientId,
|
||||
name: inputElement.value,
|
||||
});
|
||||
setName(inputElement.value);
|
||||
}, [clientId, name]);
|
||||
|
||||
useEffect(() => {
|
||||
if(clientId === null) return;
|
||||
if(inputElement === null) return;
|
||||
send('client:get', { clientId });
|
||||
}, [inputElement, clientId]);
|
||||
|
||||
return <input
|
||||
ref={setInputElement}
|
||||
value={name ?? ''}
|
||||
onChange={update}
|
||||
/>;
|
||||
}
|
||||
|
|
@ -0,0 +1,603 @@
|
|||
import { forwardRef, useEffect, useState } from "react";
|
||||
import { useCallback, useContext, useRef } from "react"
|
||||
import { BiLogIn } from "react-icons/bi";
|
||||
import { FaUserPlus } from 'react-icons/fa';
|
||||
import ServerConnection from "../components/ServerConnection";
|
||||
import useHomeServer from "../contexts/PersistentState/useHomeServerNative";
|
||||
import { BigButton } from "./BigButton";
|
||||
import { MdOutlineNavigateNext } from 'react-icons/md';
|
||||
import useHover from "../hooks/useHover";
|
||||
import { AiOutlineEdit } from "react-icons/ai";
|
||||
import { useApi } from "../lib/useApi";
|
||||
import useSessionToken from "../hooks/useSessionToken";
|
||||
import useClientId from "../hooks/useClientId";
|
||||
import QR from 'qrcode';
|
||||
|
||||
export default function NewAccount() {
|
||||
|
||||
// const [data, setData] = useState('');
|
||||
// const [scanning, setScanning] = useState(false);
|
||||
|
||||
|
||||
// const inputRef = useRef<HTMLInputElement>(null);
|
||||
// const { setClientId } = useContext(ClientIdContext);
|
||||
|
||||
// const setTransparent = useContext(TransparencyContext);
|
||||
|
||||
// useEffect(() => {
|
||||
// setTransparent(scanning);
|
||||
// }, [scanning, setTransparent]);
|
||||
|
||||
// const go = useCallback(() => {
|
||||
// if(inputRef.current === null) return;
|
||||
// setHomeServer(inputRef.current.value)
|
||||
// }, [HomeServerContext]);
|
||||
|
||||
// const scanQr = useCallback(() => {
|
||||
// //@ts-ignore
|
||||
// window.QRScanner.prepare((err: any, status: any) => {
|
||||
// if(!err && status.authorized) {
|
||||
// setScanning(true);
|
||||
// //@ts-ignore
|
||||
// window.QRScanner.hide();
|
||||
// //@ts-ignore
|
||||
// window.QRScanner.scan((err, text) => {
|
||||
// if (err) return alert(err);
|
||||
// // alert(text);
|
||||
// setData(text);
|
||||
// setScanning(false);
|
||||
// //@ts-ignore
|
||||
// window.QRScanner.show();
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
// }, [data]);
|
||||
|
||||
// useEffect(() => {
|
||||
// // this avoids a timing issue whereby the component
|
||||
// // gets removed before it has a chance to clean up
|
||||
// // its setting of transparency...
|
||||
// if(scanning) return;
|
||||
// if(!data) return;
|
||||
// const [action, homeServer, clientId] = data.split('|');
|
||||
// switch(action) {
|
||||
// case 'loginv1': {
|
||||
// setHomeServer(homeServer);
|
||||
// setClientId(clientId);
|
||||
// break;
|
||||
// }
|
||||
// }
|
||||
// }, [data, scanning])
|
||||
|
||||
// const [homeServer, setHomeServer] = useState<string | null>(null);
|
||||
// const homeServerInputRef = useRef<HTMLInputElement>(null);
|
||||
const { setHomeServer, homeServer } = useHomeServer();
|
||||
const [homeServerInput, setHomeServerInput] = useState<string>(homeServer ?? '');
|
||||
const [returning, setReturning] = useState(true);
|
||||
// const [connection, setConnection] = useState<WebSocket | null>(null);
|
||||
const [connectionError, setConnectionError] = useState('');
|
||||
const [edittingHomeServer, setEdittingHomeServer] = useState(false);
|
||||
const [homeServerInputRef, homeServerHovered] = useHover<HTMLInputElement>();
|
||||
const [homeServerEditButtonHoverRef, homeServerEditButtonHovered] = useHover<HTMLDivElement>();
|
||||
|
||||
useEffect(() => {
|
||||
if(homeServer === null) {
|
||||
setEdittingHomeServer(true)
|
||||
} else {
|
||||
setEdittingHomeServer(false)
|
||||
}
|
||||
}, [homeServer]);
|
||||
|
||||
const [connecting, setConnecting] = useState(false);
|
||||
const [connectionSucceeded, setConnectionSucceeded] = useState(false);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if(connecting) return;
|
||||
const url = homeServerInput;
|
||||
setConnecting(true);
|
||||
try {
|
||||
const ws = new WebSocket(url);
|
||||
|
||||
ws.addEventListener('open', () => {
|
||||
setConnecting(false);
|
||||
setConnectionSucceeded(true);
|
||||
setHomeServer(homeServerInput);
|
||||
setEdittingHomeServer(false);
|
||||
});
|
||||
|
||||
ws.addEventListener('error', (e) => {
|
||||
setConnecting(false);
|
||||
setConnectionSucceeded(false);
|
||||
setConnectionError('Connection failed')
|
||||
});
|
||||
} catch (e) {
|
||||
setConnecting(false)
|
||||
setConnectionSucceeded(false);
|
||||
setConnectionError('Connection failed in catch');
|
||||
}
|
||||
}, [connecting, homeServerInput]);
|
||||
|
||||
const next = useCallback(() => {
|
||||
// debugger;
|
||||
if(edittingHomeServer) {
|
||||
connect()
|
||||
} else {
|
||||
console.log('do login');
|
||||
}
|
||||
}, [homeServer, homeServerInput, edittingHomeServer, connect])
|
||||
|
||||
// return (
|
||||
// <div style={{
|
||||
// display: 'grid',
|
||||
// placeContent: 'center center',
|
||||
// height: '100%',
|
||||
// textAlign: 'center'
|
||||
// }}>
|
||||
// {returning ? (
|
||||
// <div>
|
||||
// <span>
|
||||
// Login
|
||||
// </span>
|
||||
//
|
||||
//
|
||||
//
|
||||
// <a href="#" onClick={() => setReturning(false)}>Sign up</a>
|
||||
// </div>
|
||||
// ) : (
|
||||
// <>
|
||||
// <div>
|
||||
// <a href="#" onClick={() => setReturning(true)}>
|
||||
// Login
|
||||
// </a>
|
||||
//
|
||||
//
|
||||
//
|
||||
// <span>
|
||||
// Sign up
|
||||
// </span>
|
||||
// </div>
|
||||
// <br></br>
|
||||
// <label>Home Server URL</label>
|
||||
// <input style={{textAlign: 'center'}} ref={homeServerInputRef} defaultValue="wss://macos.valnet.xyz" disabled={connection !== null || connecting}></input>
|
||||
// <button onClick={() => connect(homeServerInputRef.current?.value ?? '')} disabled={connection !== null || connecting}>Next</button>
|
||||
// {connecting ? `Connecting...` : connectionError}
|
||||
// <br></br>
|
||||
// {connection !== null && (
|
||||
// <ServerConnection url={homeServer ?? ''}>
|
||||
// <SignUp>
|
||||
// </SignUp>
|
||||
// </ServerConnection>
|
||||
// )}
|
||||
// {/* Create New Account!! <br />
|
||||
// Enter Home Server URL <br />
|
||||
// <input defaultValue="wss://dev.valnet.xyz" ref={inputRef}></input> <br />
|
||||
// <button onClick={go}> GO </button> <br />
|
||||
// <br />
|
||||
// or scan a QR! <br />
|
||||
// <button onClick={scanQr}>SCAN</button><br></br>
|
||||
// <pre>
|
||||
// {data}
|
||||
// {scanning ? 'SCANNING' : 'NOT SCANNING'}
|
||||
// </pre> */}
|
||||
// </>
|
||||
// )}
|
||||
// </div>
|
||||
// );
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
display: 'grid',
|
||||
placeItems: 'center center',
|
||||
background: 'var(--neutral-3)',
|
||||
}}>
|
||||
<div style={{
|
||||
width: 'calc(100% - 40px)',
|
||||
maxWidth: '450px',
|
||||
background: 'var(--neutral-4)',
|
||||
boxShadow: '0px 4px 20px 0px var(--neutral-1)',
|
||||
borderRadius: '8px',
|
||||
transform: 'skew(-6deg, 0deg)',
|
||||
}}>
|
||||
<div style={{
|
||||
transform: 'skew(6deg, 0deg)',
|
||||
margin: '8px',
|
||||
}}>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
width: '50%',
|
||||
paddingRight: '4px',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
<BigButton
|
||||
icon={BiLogIn}
|
||||
text="Login"
|
||||
selected={returning}
|
||||
angle={6}
|
||||
width="100%"
|
||||
inline={true}
|
||||
onClick={() => setReturning(true)}
|
||||
></BigButton>
|
||||
</div>
|
||||
<div style={{
|
||||
display: 'inline-block',
|
||||
width: '50%',
|
||||
paddingLeft: '4px',
|
||||
boxSizing: 'border-box',
|
||||
}}>
|
||||
<BigButton
|
||||
icon={FaUserPlus}
|
||||
text="Sign up"
|
||||
selected={!returning}
|
||||
angle={6}
|
||||
width="100%"
|
||||
inline={true}
|
||||
onClick={() => setReturning(false)}
|
||||
></BigButton>
|
||||
</div>
|
||||
</div>
|
||||
<Label>Home Server</Label>
|
||||
<div style={{
|
||||
transform: 'skew(6deg, 0deg)',
|
||||
margin: '8px',
|
||||
}}>
|
||||
<div
|
||||
ref={homeServerEditButtonHoverRef}
|
||||
style={{
|
||||
display: (homeServerHovered || homeServerEditButtonHovered) ? 'initial' : 'none',
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '12px',
|
||||
zIndex: '1',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => setEdittingHomeServer(true)}
|
||||
>
|
||||
<AiOutlineEdit
|
||||
size={24}
|
||||
></AiOutlineEdit>
|
||||
</div>
|
||||
<Input
|
||||
focusOnEenable={true}
|
||||
hoverRef={homeServerInputRef}
|
||||
disabled={!edittingHomeServer}
|
||||
value={homeServerInput}
|
||||
setValue={setHomeServerInput}
|
||||
onClick={(e) => {
|
||||
setEdittingHomeServer(true);
|
||||
}}
|
||||
onKeyPress={(e: any) => e.code === 'Enter' && next()}
|
||||
></Input>
|
||||
<div style={{
|
||||
paddingLeft: '16px'
|
||||
}}>
|
||||
{(connecting) ? (
|
||||
<div style={{ color: 'var(--neutral-7)'}}>
|
||||
Connecting...
|
||||
</div>
|
||||
) : (
|
||||
(!connectionSucceeded) && (
|
||||
<div style={{ color: 'var(--red)'}}>
|
||||
{connectionError}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ServerConnection url={homeServer ?? ''}>
|
||||
{(returning) ? (
|
||||
<Login disabled={edittingHomeServer}></Login>
|
||||
) : (
|
||||
<SignUp disabled={edittingHomeServer}></SignUp>
|
||||
)}
|
||||
</ServerConnection>
|
||||
{edittingHomeServer && <Next onClick={next}></Next>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Next(props: {
|
||||
onClick?: (e: any) => void
|
||||
}) {
|
||||
return (
|
||||
<div style={{
|
||||
transform: 'skew(6deg, 0deg)',
|
||||
margin: '8px',
|
||||
textAlign: 'right'
|
||||
}}>
|
||||
<BigButton
|
||||
icon={MdOutlineNavigateNext}
|
||||
text="Next"
|
||||
selected={false}
|
||||
angle={6}
|
||||
width="auto"
|
||||
inline={true}
|
||||
onClick={props.onClick}
|
||||
></BigButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LoginProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function Login(props: LoginProps) {
|
||||
const [usernameInput, setUsernameInput] = useState('');
|
||||
const [authCodeInput, setAuthCodeInput] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
const { setSessionToken } = useSessionToken();
|
||||
const { setClientId } = useClientId();
|
||||
|
||||
const { send } = useApi({
|
||||
'session:login'({ err, sessionToken, clientId }) {
|
||||
if(err) {
|
||||
setSuccess(null);
|
||||
setError(err);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setSuccess('Success!');
|
||||
setTimeout(() => {
|
||||
setClientId(clientId);
|
||||
setSessionToken(sessionToken);
|
||||
}, 1000)
|
||||
}
|
||||
})
|
||||
|
||||
const next = () => {
|
||||
send('session:login', {
|
||||
username: usernameInput,
|
||||
totp: authCodeInput
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Label>Username</Label>
|
||||
<div style={{
|
||||
transform: 'skew(6deg, 0deg)',
|
||||
margin: '8px',
|
||||
}}>
|
||||
<Input
|
||||
disabled={props.disabled}
|
||||
value={usernameInput}
|
||||
setValue={setUsernameInput}
|
||||
focusOnEenable={true}
|
||||
></Input>
|
||||
</div>
|
||||
<Label>Auth Code</Label>
|
||||
<div style={{
|
||||
transform: 'skew(6deg, 0deg)',
|
||||
margin: '8px',
|
||||
}}>
|
||||
<Input
|
||||
disabled={props.disabled}
|
||||
value={authCodeInput}
|
||||
setValue={setAuthCodeInput}
|
||||
></Input>
|
||||
</div>
|
||||
{error && <div style={{ color: 'var(--red)', textAlign: 'center' }}>
|
||||
{error}
|
||||
</div>}
|
||||
{success && <div style={{ color: 'var(--green)', textAlign: 'center' }}>
|
||||
{success}
|
||||
</div>}
|
||||
{!props.disabled && <Next onClick={next}></Next>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
interface SignUpProps {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function SignUp(props: SignUpProps) {
|
||||
const [usernameInput, setUsernameInput] = useState('');
|
||||
const [displayNameInput, setDisplayNameInput] = useState('');
|
||||
const [authCodeInput, setAuthCodeInput] = useState('');
|
||||
const [tempClientId, setTempClientId] = useState<string | null>(null);
|
||||
const [qr, setQr] = useState<string | null>(null);
|
||||
|
||||
const { setClientId } = useClientId();
|
||||
const { setSessionToken } = useSessionToken();
|
||||
|
||||
useEffect(() => {
|
||||
send('totp:propose', {
|
||||
clientId: tempClientId
|
||||
})
|
||||
}, [tempClientId])
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayNameInput(usernameInput);
|
||||
}, [usernameInput])
|
||||
|
||||
const { send } = useApi({
|
||||
'client:new'({clientId}) {
|
||||
setTempClientId(clientId)
|
||||
},
|
||||
async 'totp:propose'(data: any) {
|
||||
const { key } = data;
|
||||
const totpUrl =
|
||||
'otpauth://totp/' +
|
||||
(usernameInput ?? '') +
|
||||
'?secret=' +
|
||||
key +
|
||||
'&issuer=xyz-valnet-corner';
|
||||
console.log(totpUrl);
|
||||
setQr(await QR.toDataURL(totpUrl));
|
||||
},
|
||||
'totp:confirm'(data: any) {
|
||||
console.log(data);
|
||||
const { sessionToken } = data;
|
||||
setClientId(tempClientId);
|
||||
setSessionToken(sessionToken);
|
||||
console.log(sessionToken, tempClientId);
|
||||
}
|
||||
}, [usernameInput, tempClientId]);
|
||||
|
||||
|
||||
|
||||
const next = useCallback(() => {
|
||||
if(!tempClientId) {
|
||||
send('client:new', {
|
||||
displayName: displayNameInput,
|
||||
username: usernameInput
|
||||
})
|
||||
} else {
|
||||
send('totp:confirm', {
|
||||
clientId: tempClientId,
|
||||
code: authCodeInput
|
||||
})
|
||||
}
|
||||
}, [tempClientId, displayNameInput, usernameInput, authCodeInput]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!tempClientId ? (
|
||||
<>
|
||||
<Label>Username</Label>
|
||||
<div style={{
|
||||
transform: 'skew(6deg, 0deg)',
|
||||
margin: '8px',
|
||||
}}>
|
||||
<Input
|
||||
disabled={props.disabled}
|
||||
value={usernameInput}
|
||||
setValue={setUsernameInput}
|
||||
focusOnEenable={true}
|
||||
></Input>
|
||||
</div>
|
||||
<Label>Display Name</Label>
|
||||
<div style={{
|
||||
transform: 'skew(6deg, 0deg)',
|
||||
margin: '8px',
|
||||
}}>
|
||||
<Input
|
||||
disabled={props.disabled}
|
||||
value={displayNameInput}
|
||||
setValue={setDisplayNameInput}
|
||||
></Input>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{qr && (
|
||||
<div style={{
|
||||
textAlign: 'center',
|
||||
transform: 'skew(6deg, 0deg)',
|
||||
}}>
|
||||
<img
|
||||
src={qr}
|
||||
></img>
|
||||
<br></br>
|
||||
</div>
|
||||
)}
|
||||
<Label>Auth Code</Label>
|
||||
<div style={{
|
||||
transform: 'skew(6deg, 0deg)',
|
||||
margin: '8px',
|
||||
}}>
|
||||
<Input
|
||||
disabled={props.disabled}
|
||||
value={authCodeInput}
|
||||
setValue={setAuthCodeInput}
|
||||
focusOnEenable={true}
|
||||
></Input>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!props.disabled && <Next onClick={next}></Next>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Label(props: any) {
|
||||
return <label style={{
|
||||
paddingLeft: '24px',
|
||||
fontWeight: 700,
|
||||
fontSize: '12px',
|
||||
textTransform: 'uppercase',
|
||||
}}>{props.children}</label>
|
||||
}
|
||||
|
||||
interface InputProps {
|
||||
value: string;
|
||||
setValue: (s: string) => void;
|
||||
default?: string;
|
||||
onKeyPress?: (e: any) => void;
|
||||
disabled?: boolean;
|
||||
hoverRef?: React.LegacyRef<HTMLInputElement>;
|
||||
onClick?: (e: any) => void;
|
||||
focusOnEenable?: boolean
|
||||
}
|
||||
|
||||
const Input = (props: InputProps) => {
|
||||
|
||||
const _default = props.default ?? '';
|
||||
const [focused, setFocused] = useState(false);
|
||||
const disabled = props.disabled ?? false;
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const focusOnEenable = props.focusOnEenable ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if(!focusOnEenable) return;
|
||||
if(!disabled) {
|
||||
setFocused(true);
|
||||
inputRef.current?.focus()
|
||||
inputRef.current?.setSelectionRange(inputRef.current?.value.length, inputRef.current?.value.length)
|
||||
}
|
||||
}, [disabled, focusOnEenable, inputRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if(disabled) setFocused(false);
|
||||
}, [disabled])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={props.hoverRef}
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={(e: any) => {
|
||||
if(props.onClick !== undefined) {
|
||||
props.onClick(e);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
onKeyPress={props.onKeyPress ?? (() => {})}
|
||||
onFocus={(e) => !!props.disabled ? e.target.blur() : setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
disabled={disabled}
|
||||
style={{
|
||||
height: '40px',
|
||||
width: '100%',
|
||||
padding: '0px',
|
||||
margin: '0px',
|
||||
border: focused ? '1px solid var(--neutral-7)' : '1px solid rgba(0, 0, 0, 0)',
|
||||
transform: 'skew(-6deg, 0deg)',
|
||||
borderRadius: '8px',
|
||||
outline: 'none',
|
||||
fontSize: '20px',
|
||||
paddingLeft: '12px',
|
||||
paddingRight: '12px',
|
||||
boxSizing: 'border-box',
|
||||
background: disabled ? 'var(--neutral-3)' : focused ? 'var(--neutral-2)' : 'var(--neutral-1)',
|
||||
color: disabled ? 'var(--neutral-6)' : 'var(--neutral-8)'
|
||||
}}
|
||||
spellCheck="false"
|
||||
onChange={(e) => props.setValue(e.target.value)}
|
||||
value={props.value}
|
||||
></input>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
import { useCallback, useContext, useState } from "react";
|
||||
import { MdManageAccounts } from "react-icons/md";
|
||||
import TwoPanel from "../components/TwoPanel";
|
||||
import { SettingsContext } from "../contexts/EphemeralState/EphemeralState";
|
||||
import { AiOutlineCloseCircle } from 'react-icons/ai';
|
||||
import { BiLogOut } from 'react-icons/bi';
|
||||
import { useApi } from "../lib/useApi";
|
||||
import useSessionToken from "../hooks/useSessionToken";
|
||||
import { BigButton } from "./BigButton";
|
||||
|
||||
const pages = [
|
||||
['General', MdManageAccounts],
|
||||
['Appearance', MdManageAccounts],
|
||||
['Voice & Video', MdManageAccounts],
|
||||
['Notifications', MdManageAccounts],
|
||||
];
|
||||
|
||||
export default function Settings() {
|
||||
|
||||
const [page, setPage] = useState(0);
|
||||
const { closeSettings } = useContext(SettingsContext);
|
||||
const { setSessionToken } = useSessionToken()
|
||||
|
||||
const { send } = useApi();
|
||||
|
||||
const logout = useCallback(() => {
|
||||
send('session:invalidate');
|
||||
setSessionToken(null);
|
||||
}, [send])
|
||||
|
||||
return <>
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '32px',
|
||||
right: '32px',
|
||||
zIndex: '1',
|
||||
display: 'flex',
|
||||
cursor: 'pointer',
|
||||
borderRadius: '50%',
|
||||
}} onClick={closeSettings}>
|
||||
<AiOutlineCloseCircle
|
||||
size={32}
|
||||
></AiOutlineCloseCircle>
|
||||
</div>
|
||||
<TwoPanel
|
||||
threshold={800}
|
||||
sidebar={300}
|
||||
>
|
||||
<div style={{
|
||||
background: 'var(--neutral-3)',
|
||||
height: '100%',
|
||||
marginLeft: '40%',
|
||||
marginRight: '8px',
|
||||
}}>
|
||||
<br></br>
|
||||
<br></br>
|
||||
<br></br>
|
||||
<br></br>
|
||||
{pages.map((v, i) => (
|
||||
<BigButton
|
||||
key={i}
|
||||
icon={pages[i][1]}
|
||||
text={pages[i][0]}
|
||||
selected={i === page}
|
||||
onClick={() => setPage(i)}
|
||||
></BigButton>
|
||||
))}
|
||||
<br></br>
|
||||
<BigButton
|
||||
icon={BiLogOut}
|
||||
text="Logout"
|
||||
selected={false}
|
||||
color="var(--red)"
|
||||
onClick={logout}
|
||||
></BigButton>
|
||||
</div>
|
||||
<div style={{
|
||||
background: 'var(--neutral-4)',
|
||||
height: '100%',
|
||||
paddingLeft: '32px'
|
||||
}}>
|
||||
<br></br>
|
||||
<br></br>
|
||||
{/* <br></br> */}
|
||||
<div style={{
|
||||
fontWeight: 700,
|
||||
fontSize: '12px',
|
||||
}}>
|
||||
{pages[page][0].toString().toUpperCase()}
|
||||
</div>
|
||||
<br></br>
|
||||
{(() => {
|
||||
switch(page) {
|
||||
case 0: return <GeneralSettings></GeneralSettings>
|
||||
default: return <GeneralSettings></GeneralSettings>
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
</TwoPanel>
|
||||
</>
|
||||
}
|
||||
|
||||
function GeneralSettings() {
|
||||
return (
|
||||
<div>THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE THIS IS A PAGE </div>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useRef } from "react";
|
||||
import { useApi } from "../lib/useApi";
|
||||
import QR from 'qrcode';
|
||||
import useSessionToken from "../hooks/useSessionToken";
|
||||
|
||||
export const SignUp = (props: any) => {
|
||||
|
||||
const usernameRef = useRef<HTMLInputElement>(null);
|
||||
const displayNameRef = useRef<HTMLInputElement>(null);
|
||||
const totpRef = useRef<HTMLInputElement>(null);
|
||||
const [clientId, setClientId] = useState<string | null>(null);
|
||||
// const [totpToken, setTotpToken] = useState<string | null>(null);
|
||||
const [qr, setQr] = useState<string | null>(null);
|
||||
const { setSessionToken } = useSessionToken();
|
||||
|
||||
const { send } = useApi({
|
||||
'client:new'(data: any) {
|
||||
setClientId(data);
|
||||
},
|
||||
async 'totp:propose'(data: any) {
|
||||
setQr(await QR.toDataURL(
|
||||
'otpauth://totp/' +
|
||||
(usernameRef.current?.value ?? '') +
|
||||
'?secret=' +
|
||||
data +
|
||||
'&issuer=valnet-corner'
|
||||
));
|
||||
},
|
||||
'totp:confirm'(data: any) {
|
||||
setSessionToken(data.token);
|
||||
console.log(data);
|
||||
}
|
||||
}, [setSessionToken]);
|
||||
|
||||
const createAccount = useCallback(() => {
|
||||
send('client:new', {
|
||||
username: usernameRef.current?.value,
|
||||
displayName: displayNameRef.current?.value,
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (clientId === null)
|
||||
return;
|
||||
send('totp:propose', clientId);
|
||||
}, [clientId]);
|
||||
|
||||
const changeTotp = useCallback(() => {
|
||||
const value = totpRef.current?.value ?? '';
|
||||
if (!(/[0-9]{6}/.test(value)))
|
||||
return;
|
||||
send('totp:confirm', {
|
||||
clientId,
|
||||
code: value
|
||||
});
|
||||
}, [clientId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<label>Username</label>
|
||||
<input defaultValue={'Test' + Math.floor(Math.random() * 1000)} disabled={clientId !== null} ref={usernameRef}></input>
|
||||
<label>Display Name</label>
|
||||
<input defaultValue="Val" disabled={clientId !== null} ref={displayNameRef}></input>
|
||||
<button disabled={clientId !== null} onClick={createAccount}>Next</button>
|
||||
{clientId && (
|
||||
<>
|
||||
<br></br>
|
||||
<img src={qr ?? ''}></img>
|
||||
<br></br>
|
||||
<label>TOTP Code</label>
|
||||
<input onChange={changeTotp} ref={totpRef}></input>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
|
@ -8,7 +8,7 @@
|
|||
"strict": true,
|
||||
"isolatedModules": true,
|
||||
|
||||
"types" : ["node"],
|
||||
"types" : [],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"#preload": [
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const config = {
|
|||
},
|
||||
base: '',
|
||||
server: {
|
||||
host: true,
|
||||
fs: {
|
||||
strict: true,
|
||||
},
|
||||
|
|
@ -40,7 +41,9 @@ const config = {
|
|||
environment: 'happy-dom',
|
||||
},
|
||||
plugins: [
|
||||
react(),
|
||||
react({
|
||||
fastRefresh: false
|
||||
}),
|
||||
renderer.vite({
|
||||
preloadEntry: join(PACKAGE_ROOT, '../preload/src/index.ts'),
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
CREATE TABLE `files` (
|
||||
`id` int NOT NULL AUTO_INCREMENT PRIMARY KEY,
|
||||
`data` longblob NOT NULL,
|
||||
`author` varchar(36) NOT NULL,
|
||||
`t_uploaded` bigint unsigned NOT NULL,
|
||||
`t_created` bigint unsigned NOT NULL,
|
||||
`t_deleted` bigint unsigned NULL,
|
||||
`type` varchar(255) NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE `files`
|
||||
ADD `uid` varchar(36) NOT NULL AFTER `id`;
|
||||
|
||||
ALTER TABLE `files`
|
||||
ADD UNIQUE `uid` (`uid`);
|
||||
|
||||
ALTER TABLE `files`
|
||||
ADD FOREIGN KEY (`author`) REFERENCES `clients` (`uid`);
|
||||
|
||||
ALTER TABLE `messages`
|
||||
ADD `attachment` varchar(36) COLLATE 'utf8mb4_general_ci' NULL AFTER `text`;
|
||||
|
||||
ALTER TABLE `files`
|
||||
CHANGE `data` `data` longblob NULL AFTER `uid`;
|
||||
|
||||
ALTER TABLE `messages`
|
||||
ADD FOREIGN KEY (`attachment`) REFERENCES `files` (`uid`);
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
|
||||
CREATE TABLE `clients` (
|
||||
`id` int(11) NOT NULL AUTO_INCREMENT,
|
||||
`uid` varchar(36) NOT NULL,
|
||||
`name` tinytext NOT NULL,
|
||||
PRIMARY KEY (`id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
CREATE PROCEDURE new_client (in name TINYTEXT) BEGIN
|
||||
DECLARE client_id INT UNSIGNED DEFAULT 0;
|
||||
INSERT INTO clients (uid, clients.name) VALUES (UUID(), name);
|
||||
SET client_id = last_insert_id();
|
||||
UPDATE clients
|
||||
SET clients.name=name
|
||||
WHERE clients.id=client_id;
|
||||
|
||||
SELECT clients.uid FROM clients WHERE clients.id=client_id;
|
||||
END;
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
|
||||
-- make uid unique in clients
|
||||
ALTER TABLE `clients`
|
||||
ADD UNIQUE `uid` (`uid`);
|
||||
|
||||
-- add a sender column for foreign key reference
|
||||
ALTER TABLE `messages`
|
||||
ADD `sender_uid` varchar(36) COLLATE 'utf8mb4_general_ci' NOT NULL;
|
||||
|
||||
-- create an anonymous user for all previous messages
|
||||
SELECT @anon_uid := UUID();
|
||||
|
||||
INSERT INTO clients (name, uid)
|
||||
VALUES ('Anonymous', @anon_uid);
|
||||
|
||||
UPDATE messages
|
||||
SET sender_uid=@anon_uid
|
||||
WHERE sender_uid='';
|
||||
|
||||
-- create the foreign key relationship
|
||||
ALTER TABLE `messages`
|
||||
ADD FOREIGN KEY (`sender_uid`) REFERENCES `clients` (`uid`);
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE `messages`
|
||||
DROP `from`;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE `clients`
|
||||
ADD `totp` varchar(16) COLLATE 'utf8mb4_general_ci' NULL;
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
|
||||
-- add usernames, separate
|
||||
-- from display name! (this is for uniqueness)
|
||||
ALTER TABLE `clients`
|
||||
ADD `username` varchar(256) COLLATE 'utf8mb4_general_ci' NOT NULL;
|
||||
|
||||
-- set all previous accounts usernames to their uid
|
||||
-- as its unique, and now powerless for authentication.
|
||||
UPDATE clients
|
||||
SET clients.username=clients.uid;
|
||||
|
||||
-- make username unique
|
||||
ALTER TABLE `clients`
|
||||
ADD UNIQUE `username` (`username`);
|
||||
|
||||
-- create sessions w FK to clients
|
||||
CREATE TABLE `sessions` (
|
||||
`id` int NOT NULL,
|
||||
`client_uid` varchar(36) NOT NULL,
|
||||
`expires` bigint(20) NOT NULL,
|
||||
`token` varchar(512) NOT NULL
|
||||
);
|
||||
|
||||
ALTER TABLE `sessions`
|
||||
ADD FOREIGN KEY (`client_uid`) REFERENCES `clients` (`uid`);
|
||||
|
||||
ALTER TABLE `sessions`
|
||||
CHANGE `id` `id` int(11) NOT NULL AUTO_INCREMENT PRIMARY KEY FIRST;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
DROP PROCEDURE IF EXISTS new_client;
|
||||
|
||||
CREATE PROCEDURE new_client (IN name TINYTEXT, IN username VARCHAR(256)) BEGIN
|
||||
DECLARE client_id INT UNSIGNED DEFAULT 0;
|
||||
INSERT INTO clients (uid, name, username) VALUES (UUID(), name, username);
|
||||
SET client_id = last_insert_id();
|
||||
UPDATE clients
|
||||
SET clients.name=name
|
||||
WHERE clients.id=client_id;
|
||||
|
||||
SELECT clients.uid, clients.name, clients.username FROM clients WHERE clients.id=client_id;
|
||||
END;
|
||||
|
|
@ -1,6 +1,13 @@
|
|||
import { ensureDirSync } from 'fs-extra';
|
||||
import { resolve } from 'path';
|
||||
|
||||
export const STORAGE_PATH = resolve('../../../storage');
|
||||
|
||||
export const DB_HOST = 'localhost';
|
||||
export const DB_USER = 'root';
|
||||
export const DB_PASSWORD = 'example';
|
||||
export const DB_NAME = 'corner';
|
||||
|
||||
|
||||
|
||||
ensureDirSync(STORAGE_PATH);
|
||||
|
|
@ -46,6 +46,12 @@ const migrationConnection = createConnection({
|
|||
multipleStatements: true,
|
||||
});
|
||||
|
||||
function keepAlive() {
|
||||
connection.ping();
|
||||
migrationConnection.ping();
|
||||
}
|
||||
setInterval(keepAlive, 60000); // ping to DB every minute
|
||||
|
||||
const connected: Promise<null> = new Promise((res, rej) => {
|
||||
migrationConnection.connect((err) => {
|
||||
if(err === null) {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,15 @@
|
|||
import { connection } from './migrate';
|
||||
|
||||
|
||||
|
||||
|
||||
export default async function(a: any, ...opts: any[]): Promise<any[] | null> {
|
||||
const b64 = a.split('base64,')[1];
|
||||
export default function(sqlFile: any, ...args: any[]): Promise<any[] | null> {
|
||||
const b64 = sqlFile.split('base64,')[1];
|
||||
const text = Buffer.from(b64, 'base64').toString();
|
||||
try {
|
||||
return await new Promise((resolve, reject) => {
|
||||
connection.query(text, [...opts], (err, results) => {
|
||||
if(!err) return resolve(results);
|
||||
console.error(err.errno, err.sqlMessage);
|
||||
console.error('--- Query ---');
|
||||
console.error(err.sql);
|
||||
reject(err);
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
connection.query(text, [...args], (err, results) => {
|
||||
if(!err) return resolve(results);
|
||||
console.error(err.errno, err.sqlMessage);
|
||||
console.error('--- Query ---');
|
||||
console.error(err.sql?.substring(0, 10000));
|
||||
reject(err);
|
||||
});
|
||||
} catch(e) {
|
||||
return null;
|
||||
}
|
||||
// console.log(...opts)
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
SELECT name, username, uid FROM clients WHERE uid=?
|
||||
|
|
@ -0,0 +1 @@
|
|||
SELECT name, username, uid FROM clients WHERE username=?
|
||||
|
|
@ -0,0 +1 @@
|
|||
SELECT totp FROM clients WHERE uid=?;
|
||||
|
|
@ -0,0 +1 @@
|
|||
CALL new_client(?, ?);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
UPDATE clients
|
||||
SET name=?
|
||||
WHERE uid=?;
|
||||
|
|
@ -1,10 +1,9 @@
|
|||
INSERT INTO messages
|
||||
(`text`, `from`, `uid`, `t_sent`, channel_uid)
|
||||
VALUES (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
/* UNIX_TIMESTAMP(), */
|
||||
?,
|
||||
?
|
||||
)
|
||||
INSERT INTO messages (
|
||||
`text`,
|
||||
sender_uid,
|
||||
`uid`,
|
||||
`t_sent`,
|
||||
channel_uid,
|
||||
attachment
|
||||
)
|
||||
VALUES ( ?, ?, ?, ?, ?, ? );
|
||||
|
|
@ -1,4 +1,15 @@
|
|||
SELECT * FROM messages
|
||||
WHERE channel_uid=?
|
||||
ORDER BY t_sent
|
||||
LIMIT 100;
|
||||
|
||||
SELECT
|
||||
messages.t_sent,
|
||||
clients.uid as 'from',
|
||||
messages.`text` as 'text',
|
||||
messages.channel_uid,
|
||||
messages.uid as uid,
|
||||
files.type as file_type,
|
||||
files.uid as file_uid
|
||||
FROM messages
|
||||
JOIN clients ON messages.sender_uid=clients.uid
|
||||
LEFT JOIN files ON messages.attachment=files.uid
|
||||
WHERE messages.channel_uid=?
|
||||
ORDER BY -messages.t_sent
|
||||
LIMIT 100;
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
SELECT
|
||||
sessions.client_uid as client_uid,
|
||||
sessions.expires as expires,
|
||||
clients.username as username
|
||||
FROM sessions
|
||||
JOIN clients
|
||||
ON sessions.client_uid=clients.uid
|
||||
WHERE
|
||||
sessions.expires > UNIX_TIMESTAMP() * 1000
|
||||
AND sessions.token=?
|
||||
|
||||
LIMIT 1;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
UPDATE sessions
|
||||
SET expires=UNIX_TIMESTAMP() * 1000
|
||||
WHERE token=?;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
INSERT INTO sessions (client_uid, expires, token)
|
||||
VALUES (?, ?, ?);
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
UPDATE clients
|
||||
SET totp=?
|
||||
WHERE uid=?;
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { Router } from 'express';
|
||||
import database from '../lib/dbHelpers/database';
|
||||
import { resolve } from 'path';
|
||||
import { STORAGE_PATH } from '../constants';
|
||||
import { createReadStream, lstatSync } from 'fs';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get('/:uid', async (req, res) => {
|
||||
const info = await database.get.file.by.uid(req.params.uid);
|
||||
res.contentType = info.type;
|
||||
if(info.data !== null) {
|
||||
res.end(info.data);
|
||||
return;
|
||||
} else {
|
||||
// res.end('new hype');
|
||||
const path = resolve(STORAGE_PATH, req.params.uid);
|
||||
const size = lstatSync(path).size;
|
||||
const stream = createReadStream(path);
|
||||
res.header('Content-Length', '' + size);
|
||||
stream.pipe(res);
|
||||
}
|
||||
})
|
||||
|
||||
export default router;
|
||||
|
|
@ -1,23 +1,56 @@
|
|||
import router from './lib/router';
|
||||
import { expose, reply } from './lib/WebSocketServer';
|
||||
|
||||
// ws
|
||||
import message from './routers/message';
|
||||
import { expose } from './lib/WebSocketServer';
|
||||
import channel from './routers/channel';
|
||||
import client from './routers/client';
|
||||
import totp from './routers/totp';
|
||||
import session from './routers/session';
|
||||
import voice from './routers/voice';
|
||||
import file from './routers/file';
|
||||
import express from 'express';
|
||||
import expressWs from 'express-ws';
|
||||
|
||||
// http
|
||||
import fileRouter from './http/file';
|
||||
|
||||
const api = router({
|
||||
up() {
|
||||
console.log(Date.now());
|
||||
return reply({
|
||||
time: Date.now()
|
||||
});
|
||||
},
|
||||
message: message,
|
||||
messages: message,
|
||||
channel: channel,
|
||||
channels: channel,
|
||||
client: client,
|
||||
clients: client,
|
||||
totp: totp,
|
||||
session: session,
|
||||
sessions: session,
|
||||
voice: voice,
|
||||
file: file,
|
||||
files: file,
|
||||
});
|
||||
|
||||
expose(api, 3000);
|
||||
// var express = require('express');
|
||||
// var app = express();
|
||||
// var expressWs = require('express-ws')(app);
|
||||
|
||||
const app = express();
|
||||
const ewss = expressWs(app);
|
||||
|
||||
// @ts-ignore
|
||||
app.ws('/', expose(api, ewss.getWss()));
|
||||
app.use('/file', fileRouter);
|
||||
|
||||
app.listen(3000);
|
||||
|
||||
// -------------
|
||||
|
||||
import { update } from './db/migrate';
|
||||
import channel from './routers/channel';
|
||||
|
||||
try {
|
||||
update();
|
||||
|
|
|
|||
|
|
@ -1,13 +1,40 @@
|
|||
import { WebSocketServer } from 'ws';
|
||||
import { WebSocketServer, WebSocket } from 'ws';
|
||||
import { inspect } from 'util';
|
||||
import { validateSessionToken } from '../routers/session';
|
||||
import chalk from 'chalk';
|
||||
|
||||
export function expose(router: Function, port: number) {
|
||||
const wss = new WebSocketServer({
|
||||
port: 3000,
|
||||
}, () => {
|
||||
console.log('ws chat server started on dev.valnet.xyz');
|
||||
});
|
||||
function str2color(str: string) {
|
||||
const v = str.split('').reduce((acc, val) => acc + val.charCodeAt(0), 0);
|
||||
return (v % 213) + 17
|
||||
}
|
||||
|
||||
wss.on('connection', (ws) => {
|
||||
function log(prefix: string, action: string, data: object) {
|
||||
if(action === 'up') return;
|
||||
const strAction = action.split(':').map(v => chalk.ansi256(str2color(v))(v)).join(':');
|
||||
const filteredObject = Object.entries(data)
|
||||
.filter(e => !e[0].startsWith('$'));
|
||||
const keyCount = Object.keys(filteredObject).length;
|
||||
if(keyCount === 0) return console.log(prefix, strAction);
|
||||
|
||||
const stringify = (key: string, value: any) => {
|
||||
switch(typeof value) {
|
||||
case 'string': return chalk.green(key);
|
||||
case 'object':
|
||||
if(value === null) return chalk.blackBright(key);
|
||||
else if(Array.isArray(value)) return chalk.cyanBright(`[${key}]`);
|
||||
else return chalk.magenta(key);
|
||||
case 'number': return chalk.yellow(key);
|
||||
default: return key;
|
||||
}
|
||||
}
|
||||
|
||||
const params = filteredObject.map(([k, v]) => stringify(k, v))
|
||||
|
||||
console.log(prefix, strAction, params.join(', '));
|
||||
}
|
||||
|
||||
export function expose(router: Function, wss: WebSocketServer) {
|
||||
return function(ws: WebSocket) {
|
||||
ws.on('message', async (str) => {
|
||||
try {
|
||||
const message = JSON.parse(str.toString());
|
||||
|
|
@ -17,21 +44,31 @@ export function expose(router: Function, port: number) {
|
|||
}
|
||||
const {action, data} = message;
|
||||
try {
|
||||
console.log('[IN]', action, data);
|
||||
const _return = await (router(action, data) as unknown as Promise<any>);
|
||||
// console.log(_return);
|
||||
if(typeof data === 'object' && 'sessionToken' in data && data.sessionToken !== null) {
|
||||
const auth = await validateSessionToken(data.sessionToken);
|
||||
data.$sessionToken = data.sessionToken;
|
||||
delete data['sessionToken'];
|
||||
if(auth === null) return;
|
||||
data.$clientId = auth;
|
||||
}
|
||||
if(typeof data !== 'object') {
|
||||
throw new Error('action ' + action + ' payload not an object');
|
||||
}
|
||||
log(chalk.green('>>>'), action, data);
|
||||
const _return = await (router(action, data, ws, wss) as unknown as Promise<any>);
|
||||
if(_return) {
|
||||
try {
|
||||
switch(_return.type) {
|
||||
case ResponseType.BROADCAST: {
|
||||
console.log('[OUT_BROADCAST]', action, _return.data);
|
||||
log(chalk.cyan('(\u25CF)'), action, _return.data)
|
||||
// console.log('[OUT_BROADCAST]', action, _return.data);
|
||||
for(const client of wss.clients) {
|
||||
send(client, action, _return.data);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case ResponseType.REPLY: {
|
||||
console.log('[OUT]', action, _return.data);
|
||||
log(chalk.cyan('<<<'), action, _return.data);
|
||||
send(ws, action, _return.data);
|
||||
break;
|
||||
}
|
||||
|
|
@ -49,7 +86,7 @@ export function expose(router: Function, port: number) {
|
|||
console.error(e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
enum ResponseType {
|
||||
|
|
@ -57,8 +94,17 @@ enum ResponseType {
|
|||
REPLY
|
||||
}
|
||||
|
||||
function send(client: any, action: string, data?: any) {
|
||||
client.send(JSON.stringify({action, data}));
|
||||
// var snd = new Audio("data:audio/wav;base64,//uQRAAAAWMSLwUIYAAsYkXgoQwAEaYLWfkWgAI0wWs/ItAAAGDgYtAgAyN+QWaAAihwMWm4G8QQRDiMcCBcH3Cc+CDv/7xA4Tvh9Rz/y8QADBwMWgQAZG/ILNAARQ4GLTcDeIIIhxGOBAuD7hOfBB3/94gcJ3w+o5/5eIAIAAAVwWgQAVQ2ORaIQwEMAJiDg95G4nQL7mQVWI6GwRcfsZAcsKkJvxgxEjzFUgfHoSQ9Qq7KNwqHwuB13MA4a1q/DmBrHgPcmjiGoh//EwC5nGPEmS4RcfkVKOhJf+WOgoxJclFz3kgn//dBA+ya1GhurNn8zb//9NNutNuhz31f////9vt///z+IdAEAAAK4LQIAKobHItEIYCGAExBwe8jcToF9zIKrEdDYIuP2MgOWFSE34wYiR5iqQPj0JIeoVdlG4VD4XA67mAcNa1fhzA1jwHuTRxDUQ//iYBczjHiTJcIuPyKlHQkv/LHQUYkuSi57yQT//uggfZNajQ3Vmz+Zt//+mm3Wm3Q576v////+32///5/EOgAAADVghQAAAAA//uQZAUAB1WI0PZugAAAAAoQwAAAEk3nRd2qAAAAACiDgAAAAAAABCqEEQRLCgwpBGMlJkIz8jKhGvj4k6jzRnqasNKIeoh5gI7BJaC1A1AoNBjJgbyApVS4IDlZgDU5WUAxEKDNmmALHzZp0Fkz1FMTmGFl1FMEyodIavcCAUHDWrKAIA4aa2oCgILEBupZgHvAhEBcZ6joQBxS76AgccrFlczBvKLC0QI2cBoCFvfTDAo7eoOQInqDPBtvrDEZBNYN5xwNwxQRfw8ZQ5wQVLvO8OYU+mHvFLlDh05Mdg7BT6YrRPpCBznMB2r//xKJjyyOh+cImr2/4doscwD6neZjuZR4AgAABYAAAABy1xcdQtxYBYYZdifkUDgzzXaXn98Z0oi9ILU5mBjFANmRwlVJ3/6jYDAmxaiDG3/6xjQQCCKkRb/6kg/wW+kSJ5//rLobkLSiKmqP/0ikJuDaSaSf/6JiLYLEYnW/+kXg1WRVJL/9EmQ1YZIsv/6Qzwy5qk7/+tEU0nkls3/zIUMPKNX/6yZLf+kFgAfgGyLFAUwY//uQZAUABcd5UiNPVXAAAApAAAAAE0VZQKw9ISAAACgAAAAAVQIygIElVrFkBS+Jhi+EAuu+lKAkYUEIsmEAEoMeDmCETMvfSHTGkF5RWH7kz/ESHWPAq/kcCRhqBtMdokPdM7vil7RG98A2sc7zO6ZvTdM7pmOUAZTnJW+NXxqmd41dqJ6mLTXxrPpnV8avaIf5SvL7pndPvPpndJR9Kuu8fePvuiuhorgWjp7Mf/PRjxcFCPDkW31srioCExivv9lcwKEaHsf/7ow2Fl1T/9RkXgEhYElAoCLFtMArxwivDJJ+bR1HTKJdlEoTELCIqgEwVGSQ+hIm0NbK8WXcTEI0UPoa2NbG4y2K00JEWbZavJXkYaqo9CRHS55FcZTjKEk3NKoCYUnSQ0rWxrZbFKbKIhOKPZe1cJKzZSaQrIyULHDZmV5K4xySsDRKWOruanGtjLJXFEmwaIbDLX0hIPBUQPVFVkQkDoUNfSoDgQGKPekoxeGzA4DUvnn4bxzcZrtJyipKfPNy5w+9lnXwgqsiyHNeSVpemw4bWb9psYeq//uQZBoABQt4yMVxYAIAAAkQoAAAHvYpL5m6AAgAACXDAAAAD59jblTirQe9upFsmZbpMudy7Lz1X1DYsxOOSWpfPqNX2WqktK0DMvuGwlbNj44TleLPQ+Gsfb+GOWOKJoIrWb3cIMeeON6lz2umTqMXV8Mj30yWPpjoSa9ujK8SyeJP5y5mOW1D6hvLepeveEAEDo0mgCRClOEgANv3B9a6fikgUSu/DmAMATrGx7nng5p5iimPNZsfQLYB2sDLIkzRKZOHGAaUyDcpFBSLG9MCQALgAIgQs2YunOszLSAyQYPVC2YdGGeHD2dTdJk1pAHGAWDjnkcLKFymS3RQZTInzySoBwMG0QueC3gMsCEYxUqlrcxK6k1LQQcsmyYeQPdC2YfuGPASCBkcVMQQqpVJshui1tkXQJQV0OXGAZMXSOEEBRirXbVRQW7ugq7IM7rPWSZyDlM3IuNEkxzCOJ0ny2ThNkyRai1b6ev//3dzNGzNb//4uAvHT5sURcZCFcuKLhOFs8mLAAEAt4UWAAIABAAAAAB4qbHo0tIjVkUU//uQZAwABfSFz3ZqQAAAAAngwAAAE1HjMp2qAAAAACZDgAAAD5UkTE1UgZEUExqYynN1qZvqIOREEFmBcJQkwdxiFtw0qEOkGYfRDifBui9MQg4QAHAqWtAWHoCxu1Yf4VfWLPIM2mHDFsbQEVGwyqQoQcwnfHeIkNt9YnkiaS1oizycqJrx4KOQjahZxWbcZgztj2c49nKmkId44S71j0c8eV9yDK6uPRzx5X18eDvjvQ6yKo9ZSS6l//8elePK/Lf//IInrOF/FvDoADYAGBMGb7FtErm5MXMlmPAJQVgWta7Zx2go+8xJ0UiCb8LHHdftWyLJE0QIAIsI+UbXu67dZMjmgDGCGl1H+vpF4NSDckSIkk7Vd+sxEhBQMRU8j/12UIRhzSaUdQ+rQU5kGeFxm+hb1oh6pWWmv3uvmReDl0UnvtapVaIzo1jZbf/pD6ElLqSX+rUmOQNpJFa/r+sa4e/pBlAABoAAAAA3CUgShLdGIxsY7AUABPRrgCABdDuQ5GC7DqPQCgbbJUAoRSUj+NIEig0YfyWUho1VBBBA//uQZB4ABZx5zfMakeAAAAmwAAAAF5F3P0w9GtAAACfAAAAAwLhMDmAYWMgVEG1U0FIGCBgXBXAtfMH10000EEEEEECUBYln03TTTdNBDZopopYvrTTdNa325mImNg3TTPV9q3pmY0xoO6bv3r00y+IDGid/9aaaZTGMuj9mpu9Mpio1dXrr5HERTZSmqU36A3CumzN/9Robv/Xx4v9ijkSRSNLQhAWumap82WRSBUqXStV/YcS+XVLnSS+WLDroqArFkMEsAS+eWmrUzrO0oEmE40RlMZ5+ODIkAyKAGUwZ3mVKmcamcJnMW26MRPgUw6j+LkhyHGVGYjSUUKNpuJUQoOIAyDvEyG8S5yfK6dhZc0Tx1KI/gviKL6qvvFs1+bWtaz58uUNnryq6kt5RzOCkPWlVqVX2a/EEBUdU1KrXLf40GoiiFXK///qpoiDXrOgqDR38JB0bw7SoL+ZB9o1RCkQjQ2CBYZKd/+VJxZRRZlqSkKiws0WFxUyCwsKiMy7hUVFhIaCrNQsKkTIsLivwKKigsj8XYlwt/WKi2N4d//uQRCSAAjURNIHpMZBGYiaQPSYyAAABLAAAAAAAACWAAAAApUF/Mg+0aohSIRobBAsMlO//Kk4soosy1JSFRYWaLC4qZBYWFRGZdwqKiwkNBVmoWFSJkWFxX4FFRQWR+LsS4W/rFRb/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////VEFHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAU291bmRib3kuZGUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMjAwNGh0dHA6Ly93d3cuc291bmRib3kuZGUAAAAAAAAAACU=");
|
||||
// snd.play();
|
||||
|
||||
export function send(client: WebSocket | Set<WebSocket>, action: string, data?: any) {
|
||||
if(client instanceof Set) {
|
||||
for(const c of client) {
|
||||
c.send(JSON.stringify({action, data}));
|
||||
}
|
||||
} else {
|
||||
client.send(JSON.stringify({action, data}));
|
||||
}
|
||||
}
|
||||
|
||||
export function broadcast(data: any) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
INSERT INTO files (
|
||||
uid,
|
||||
author,
|
||||
type,
|
||||
data,
|
||||
t_created,
|
||||
t_uploaded,
|
||||
t_deleted
|
||||
)
|
||||
VALUES (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
NULL,
|
||||
?,
|
||||
?,
|
||||
NULL
|
||||
)
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import query from "/@/db/query";
|
||||
import sql from './addFilePath.sql';
|
||||
|
||||
export default function addFile(uid: string, author: string, type: string) {
|
||||
const now = new Date().getTime();
|
||||
return query(sql, uid, author, type, now, now);
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
INSERT INTO files (
|
||||
uid,
|
||||
author,
|
||||
type,
|
||||
data,
|
||||
t_created,
|
||||
t_uploaded,
|
||||
t_deleted
|
||||
)
|
||||
VALUES (
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
?,
|
||||
NULL
|
||||
)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue