Compare commits

..

29 Commits

Author SHA1 Message Date
Valerie 13aff451a8 pasting attachments 2022-08-24 22:02:29 -04:00
Valerie d595cc7373 video is working! some features missing 2022-08-14 18:04:04 -04:00
Valerie 7bea8c08ca video first pass 2022-08-14 07:37:35 -04:00
Bronwen 91a81d6699 connections 2022-08-11 19:53:49 -04:00
Valerie b73ab2c691 i deadass just dont care 2022-08-11 14:29:24 -04:00
Valerie 1aead4cc6b some more basic voice updates 2022-08-08 03:15:29 -04:00
Valerie 46d3f00280 bad cyan! bad! 2022-08-07 20:11:29 -04:00
Valerie c0289d92e3 users in channels, kindof 2022-08-07 20:10:11 -04:00
Bronwen e775e4a240 blank video stream 2022-08-06 17:28:43 -04:00
Valerie ea0cb96a24 no video to start 2022-08-06 05:19:48 -04:00
Valerie 64b5d092a4 i hate that this is making me use windows. 2022-08-05 23:24:58 -04:00
Valerie 0bfe2d9719 users complete, i hope. 2022-08-04 03:40:59 -04:00
Valerie f3c8a2e482 login 2022-08-03 20:01:51 -04:00
Bronwen 98a1906860 edithomeserver 2022-08-03 11:59:25 -04:00
Valerie 92913efdc9 some changes 2022-08-03 01:05:22 -04:00
Valerie d7addbb496 basic styles 2022-08-01 05:53:14 -04:00
Valerie fd180cca7a good shet eh? 2022-07-30 21:27:56 -04:00
Valerie 277d92d97a begin authentication 2022-07-30 04:07:38 -04:00
Valerie 56b71709e2 organization 2022-07-29 13:52:59 -04:00
Bronwen 95bb2c6b46 remove git hooks bullshit 2022-07-29 00:05:33 -04:00
Bronwen 93ad0dfb1a session tokens! 2022-07-29 00:01:01 -04:00
Valerie 30ebc0fcf1 some token SQL 2022-07-28 02:47:40 -04:00
Valerie a1784f5cb2 totp in database 2022-07-26 21:05:26 -04:00
Valerie 6b496cc045 Merge branch 'cordova' 2022-07-25 21:25:14 -04:00
Valerie 0be5d1b3dd qr codes are ... STILL ... working 2022-07-25 21:23:40 -04:00
Valerie 00f6ecae52 qr codes are working! 2022-07-25 20:54:13 -04:00
Valerie 3cbac09f57 idk man 2022-07-25 03:33:49 -04:00
Valerie e2ae16bd2c e z 2022-07-24 15:41:21 -04:00
Valerie 0fcf5796f7 e z 2022-07-24 15:41:09 -04:00
127 changed files with 19446 additions and 1369 deletions

View File

@ -20,12 +20,15 @@
], ],
"ignorePatterns": [ "ignorePatterns": [
"node_modules/**", "node_modules/**",
"**/dist/**" "**/dist/**",
"cordova/**",
"scripts/**"
], ],
"rules": { "rules": {
"@typescript-eslint/no-unused-vars": "error", "@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-var-requires": "off", "@typescript-eslint/no-var-requires": "off",
"@typescript-eslint/consistent-type-imports": "error", "@typescript-eslint/consistent-type-imports": "error",
"no-undef": "off",
/** /**
* Having a semicolon helps the optimizer interpret your code correctly. * Having a semicolon helps the optimizer interpret your code correctly.

2
.gitignore vendored
View File

@ -59,3 +59,5 @@ thumbs.db
# docker data # docker data
docker-volume docker-volume
storage

View File

@ -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),
);
},
};

26
cordova/.gitignore vendored 100644
View File

@ -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/

11
cordova/config.xml 100644
View File

@ -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>

8899
cordova/package-lock.json generated 100644

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

48
cordova/www/app.js vendored 100644

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -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>

18
cordova/www/index.js vendored 100644
View File

@ -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);
}

View File

@ -4,6 +4,7 @@ services:
db: db:
image: mariadb image: mariadb
restart: always restart: always
command: --max_allowed_packet=200M
environment: environment:
MARIADB_ROOT_PASSWORD: example MARIADB_ROOT_PASSWORD: example
MARIADB_DATABASE: corner MARIADB_DATABASE: corner
@ -19,3 +20,18 @@ services:
restart: always restart: always
ports: ports:
- 8080:8080 - 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

6212
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -32,7 +32,8 @@
"typecheck:renderer": "vue-tsc --noEmit -p packages/renderer/tsconfig.json", "typecheck:renderer": "vue-tsc --noEmit -p packages/renderer/tsconfig.json",
"typecheck:server": "tsc --noEmit -p packages/server/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", "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": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "5.30.6", "@typescript-eslint/eslint-plugin": "5.30.6",
@ -47,7 +48,6 @@
"happy-dom": "6.0.4", "happy-dom": "6.0.4",
"nano-staged": "0.8.0", "nano-staged": "0.8.0",
"playwright": "1.23.4", "playwright": "1.23.4",
"simple-git-hooks": "2.8.0",
"typescript": "4.7.4", "typescript": "4.7.4",
"unplugin-auto-expose": "0.0.1", "unplugin-auto-expose": "0.0.1",
"vite": "3.0.0", "vite": "3.0.0",
@ -55,22 +55,40 @@
"vue-tsc": "0.38.8" "vue-tsc": "0.38.8"
}, },
"dependencies": { "dependencies": {
"@types/express": "^4.17.13",
"@types/express-ws": "^3.0.1",
"@types/mysql": "^2.15.21", "@types/mysql": "^2.15.21",
"@types/qrcode": "^1.4.2",
"@types/react": "^18.0.15", "@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@types/react-timeago": "^4.1.3", "@types/react-timeago": "^4.1.3",
"@types/tmp": "^0.2.3",
"@types/totp-generator": "^0.0.4",
"@types/uuid": "^8.3.4", "@types/uuid": "^8.3.4",
"@types/ws": "^8.5.3", "@types/ws": "^8.5.3",
"@vitejs/plugin-react": "^2.0.0", "@vitejs/plugin-react": "^2.0.0",
"chalk": "^4.1.2",
"cordova": "^11.0.0",
"electron-updater": "5.0.5", "electron-updater": "5.0.5",
"eslint-plugin-react": "^7.30.1", "eslint-plugin-react": "^7.30.1",
"express": "^4.18.1", "express": "^4.18.1",
"express-ws": "^5.0.2",
"fs-extra": "^10.1.0",
"get-port": "^6.1.2", "get-port": "^6.1.2",
"local-storage": "^2.0.0",
"mysql": "^2.18.1", "mysql": "^2.18.1",
"peerjs": "^1.4.6",
"qrcode": "^1.5.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^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-time-ago": "^7.2.1",
"react-timeago": "^7.1.0", "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", "uuid": "^8.3.2",
"vue": "3.2.37", "vue": "3.2.37",
"ws": "^8.8.1" "ws": "^8.8.1"

View File

@ -1,4 +1,7 @@
import {BrowserWindow} from 'electron'; import {
BrowserWindow,
session
} from 'electron';
import {join} from 'path'; import {join} from 'path';
import {URL} from 'url'; 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 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'), 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. * 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. * Use `show: false` and listener events `ready-to-show` to fix these issues.
@ -38,6 +44,11 @@ async function createWindow() {
await browserWindow.loadURL(pageUrl); await browserWindow.loadURL(pageUrl);
// session.fromPartition('default').setPermissionRequestHandler((webContents, permission, callback) => {
// console.log('requested permission', permission);
// })
return browserWindow; return browserWindow;
} }

View File

@ -6,11 +6,27 @@ import {URL} from 'url';
* *
* In development mode you need allow open `VITE_DEV_SERVER_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 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. * 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) => { contents.on('will-navigate', (event, url) => {
const {origin} = new URL(url); const {origin} = new URL(url);
if (ALLOWED_ORIGINS_AND_PERMISSIONS.has(origin)) { if (ALLOWED_ORIGINS.has(origin)) {
return; return;
} }
@ -61,7 +77,7 @@ app.on('web-contents-created', (_, contents) => {
contents.session.setPermissionRequestHandler((webContents, permission, callback) => { contents.session.setPermissionRequestHandler((webContents, permission, callback) => {
const {origin} = new URL(webContents.getURL()); const {origin} = new URL(webContents.getURL());
const permissionGranted = !!ALLOWED_ORIGINS_AND_PERMISSIONS.get(origin)?.has(permission); const permissionGranted = !!ALLOWED_PERMISSIONS.includes(permission)
callback(permissionGranted); callback(permissionGranted);
if (!permissionGranted && import.meta.env.DEV) { if (!permissionGranted && import.meta.env.DEV) {
@ -106,7 +122,7 @@ app.on('web-contents-created', (_, contents) => {
*/ */
contents.on('will-attach-webview', (event, webPreferences, params) => { contents.on('will-attach-webview', (event, webPreferences, params) => {
const {origin} = new URL(params.src); const {origin} = new URL(params.src);
if (!ALLOWED_ORIGINS_AND_PERMISSIONS.has(origin)) { if (!ALLOWED_ORIGINS.has(origin)) {
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
console.warn(`A webview tried to attach ${params.src}, but was blocked.`); console.warn(`A webview tried to attach ${params.src}, but was blocked.`);

View File

@ -1,42 +0,0 @@
import { resolve } from 'path';
import {
writeFileSync,
readFileSync,
mkdirSync,
existsSync,
} from 'fs';
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');
// --- setup ---
if(!existsSync(cornerDataPath))
mkdirSync(cornerDataPath);
if(!existsSync(clientIdPath))
writeFileSync(clientIdPath, '');
// --- 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) {
if(!validUuid(id)) return false;
writeFileSync(clientIdPath, id);
return true;
}

View File

@ -2,5 +2,12 @@
* @module preload * @module preload
*/ */
export { getClientId, setClientId } from './clientId'; export {
export {versions} from './versions'; getClientId,
setClientId,
getHomeServer,
setHomeServer,
getSessionToken,
setSessionToken
} from './settings';
export {versions} from './versions';

View File

@ -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 ?? '');
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

View File

@ -3,11 +3,18 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<!-- <meta http-equiv="Content-Security-Policy" content="script-src 'self' blob:"> --> <!-- <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"> <meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>Vite App</title> <title>Vite App</title>
</head> </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"> <body style=" margin: 0px; overflow: hidden;">
<div id="app" style="width: 100vw; height: 100vh; background: #282a36"></div> <div id="app" style="width: 100vw; height: 100vh;"></div>
<div id="portal-root"></div>
<script src="./src/index.tsx" type="module"></script> <script src="./src/index.tsx" type="module"></script>
</body> </body>
</html> </html>

View File

@ -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>
</>
);
}

View File

@ -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>
)
)
}

View File

@ -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>;
}

View File

@ -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>
)
}

View File

@ -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>
);
}

View File

@ -0,0 +1,10 @@
import useSessionToken from "../hooks/useSessionToken"
export default function Logout() {
const { setSessionToken } = useSessionToken();
return (
<button onClick={() => setSessionToken(null)}>LOGOUT</button>
)
}

View File

@ -0,0 +1,11 @@
interface MarkdownInputProps {
}
export function MarkdownInput(props: MarkdownInputProps) {
return (
);
}

View File

@ -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;

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>;
}

View File

@ -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>;
}

View File

@ -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

View File

@ -0,0 +1,7 @@
import { useEffect } from "react";
export const useLog = (v: any, prefix = '') => {
useEffect(() => {
console.log(prefix, v);
}, [v]);
};

View File

@ -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>
}

View File

@ -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>
);
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
}

View File

@ -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>
)
}

View File

@ -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]);
}

View File

@ -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])
}

View File

@ -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])
}

View File

@ -0,0 +1,6 @@
import { useContext } from "react";
import { ChannelContext } from "../contexts/EphemeralState/EphemeralState";
export default function useChannel() {
return useContext(ChannelContext);
}

View File

@ -0,0 +1,7 @@
import { useContext } from "react";
import { ClientIdContext } from "../contexts/PersistentState/PersistentState";
export default function useClientId() {
return useContext(ClientIdContext);
}

View File

@ -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
}
}

View File

@ -0,0 +1,6 @@
import { useContext } from "react";
import { HomeServerContext } from "../contexts/PersistentState/PersistentState";
export default function useHomeServer() {
return useContext(HomeServerContext);
}

View File

@ -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];
}

View File

@ -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;
}

View File

@ -0,0 +1,6 @@
import { useContext } from "react";
import { SessionTokenContext } from "../contexts/PersistentState/PersistentState";
export default function useSessionToken() {
return useContext(SessionTokenContext);
}

View File

@ -1,8 +1,14 @@
import React, { createContext } from 'react'; import React from 'react';
import ReactDOM from 'react-dom'; import ReactDOM from 'react-dom/client';
import App from './pages/App'; import Sidebar from './components/TwoPanel';
import App from './App';
import { createPortal } from 'react-dom';
ReactDOM.render( const container = document.getElementById('app');
<App></App>, if(container !== null) {
document.getElementById('app'), const root = ReactDOM.createRoot(container);
); // const portal = createPortal()
root.render(<App></App>);
} else {
throw new Error('Failed to initialize app, container not found!');
}

View File

@ -1,73 +1,122 @@
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;
function startKeepalive() {
let socket: WebSocket | null = null; keepalive = setInterval(() => {
let connectionAttempts = 0; if(socket !== null) {
const url = 'wss://dev.valnet.xyz'; socket.send(JSON.stringify({
action: 'up',
let routers: any[] = []; data: {}
}))
const connect = async () => { } else {
try { stopKeepalive();
connectionAttempts ++; }
console.log('attempting api connection...'); }, 30_000);
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;
} }
socket.addEventListener('open', () => { function stopKeepalive() {
if(socket === null) return; if(keepalive !== null)
connectionAttempts = 0; clearInterval(keepalive);
// socket.send('Hello Server!'); }
});
const connect = async () => {
socket.addEventListener('message', (event) => {
const {action, data} = JSON.parse(event.data);
console.log('[IN]', action, data);
const routeFound = routers
.map(router => router(action, data))
.reduce((a, b) => a + b, 0);
if(routeFound === 0) {
console.warn(`route <${action}> not found`);
} else {
console.log(`routed to ${routeFound} elements`);
}
});
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) {
try { try {
await new Promise((resolve, reject) => { connectionAttempts ++;
socket?.addEventListener('open', resolve); socket = new WebSocket(url);
socket?.addEventListener('close', reject); } catch (e) {
}); if(destroy) return;
} catch(e) { if(connectionAttempts === 1)
connect();
else {
const seconds = 2 ** connectionAttempts;
console.log(`waiting ${seconds} seconds before reconnecting`);
setTimeout(connect, 1000 * seconds);
}
return; return;
} }
if(socket.readyState !== socket.OPEN) return;
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`);
}
});
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
} }
const message = JSON.stringify({ action, data });
socket.send(message);
} }
export function router(routes: any) { 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) { return function(route: string, data: any) {
if(route in routes) { if(route in routes) {
routes[route](data); routes[route](data);
@ -76,12 +125,4 @@ export function router(routes: any) {
return false; return false;
} }
}; };
}
export function registerRouter(router: any) {
routers.push(router);
}
export function unregisterRouter(router: any) {
routers = routers.filter(r => r !== router);
} }

View File

@ -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;

View File

@ -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)
}

View File

@ -1,16 +1,33 @@
import { useEffect } from 'react'; import { useContext, useEffect, useMemo } from 'react';
import { registerRouter, router, send, unregisterRouter } from './api'; import { ServerConnectionContext } from '../components/ServerConnection';
import useSessionToken from '../hooks/useSessionToken';
import { Router, router, RouterObject } from './api';
import { v4 } from 'uuid';
export function useApi(actions: Function | object, deps: any[]) { export function useApi(actions: Router | RouterObject = {}, deps: any[] = []) {
const connection = useContext(ServerConnectionContext);
const _router = typeof actions === 'object' ? router(actions) : actions; const _router = typeof actions === 'object' ? router(actions) : actions;
const { sessionToken } = useSessionToken();
const componentId = useMemo(() => { return v4() }, []);
useEffect(() => { useEffect(() => {
registerRouter(_router); connection.registerRouter(_router);
return () => { return () => {
unregisterRouter(_router); connection.unregisterRouter(_router);
}; };
}, deps); }, deps);
return { return {
send: send, 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
});
}
}; };
} }

View File

@ -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;

View File

@ -1,61 +0,0 @@
import { createContext, useEffect, useState } from 'react';
import Channels from './Channels';
import Chat from './Chat';
import { getClientId, setClientId } from '#preload';
import { useApi } from '../lib/useApi';
export const channelContext = createContext<{
channel: string | null,
setChannel: (uid: string) => void
}>({
channel: null,
setChannel: () => {},
});
export const clientIdContext = createContext<string | null>(null);
export default function App() {
const [channel, setChannel] = useState<string | null>(null);
const [clientId, setCachedClientId] = useState(getClientId());
const channelContextValue = { channel, setChannel };
// persist given clientId to disk
useEffect(() => {
if(clientId === null) return;
setClientId(clientId);
}, [clientId]);
const { send } = useApi({
'client:new'(data: string) {
setCachedClientId(data);
},
}, [setCachedClientId]);
useEffect(() => {
if(clientId !== null) return;
send('client:new');
}, [clientId]);
return (
<clientIdContext.Provider value={clientId}>
<channelContext.Provider value={channelContextValue}>
<div style={{
display: 'grid',
gridTemplateColumns: '200px 1fr',
gridTemplateRows: '1fr',
height: '100%',
}}>
<div style={{
background: '#21222c',
borderRight: '1px solid #bd93f9',
}}>
<Channels></Channels>
</div>
<div>
<Chat></Chat>
</div>
</div>
</channelContext.Provider>
</clientIdContext.Provider>
);
}

View File

@ -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>
);
}

View File

@ -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>
</>
)
}

View File

@ -1,20 +1,22 @@
import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import {
import { channelContext, clientIdContext } from './App'; useCallback,
useEffect,
useRef,
useState
} from 'react';
import { useApi } from '../lib/useApi'; import { useApi } from '../lib/useApi';
import type { IMessage } from './Message'; import type { IMessage } from './Message';
import NameTextbox from './NameTextbox'; 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 { interface IChannel {
uid: string; uid: string;
name: string; name: string;
} type: ChannelType;
function Hashmark() {
return <span style={{
fontWeight: 'bold',
marginRight: '8px',
marginLeft: '8px',
}}>#</span>;
} }
interface IUnreads { interface IUnreads {
@ -25,19 +27,21 @@ export default function Channels() {
const [channels, setChannels] = useState<IChannel[]>([]); const [channels, setChannels] = useState<IChannel[]>([]);
const [unreads, setUnreads] = useState<IUnreads>({}); const [unreads, setUnreads] = useState<IUnreads>({});
const {channel, setChannel} = useContext(channelContext); const { channel, setChannel } = useChannel();
const clientId = useContext(clientIdContext); const { clientId } = useClientId();
const { send } = useApi({ const { send } = useApi({
'channels:list'(data: IChannel[]) { 'channels:list'(data: any) {
setChannels(data); setChannels(data.channels);
}, },
'channel:add'(channel: IChannel) { 'channel:add'(channel: IChannel) {
setChannels([...channels, channel]); setChannels([...channels, channel]);
}, },
'message:message'(message: IMessage) { 'message:message'(message: IMessage) {
sfx.message();
if(channel === message.channel) return; if(channel === message.channel) return;
setUnreads({ setUnreads({
...unreads, ...unreads,
[message.channel]: (unreads[message.channel] ?? 0) + 1, [message.channel]: (unreads[message.channel] ?? 0) + 1,
@ -45,10 +49,6 @@ export default function Channels() {
}, },
}, [channels, unreads]); }, [channels, unreads]);
useEffect(() => {
console.log('unreads', unreads);
}, [unreads]);
useEffect(() => { useEffect(() => {
if(channels.length === 0) { if(channels.length === 0) {
send('channels:list'); send('channels:list');
@ -58,7 +58,7 @@ export default function Channels() {
useEffect(() => { useEffect(() => {
if(channels.length === 0) return; if(channels.length === 0) return;
if(channel !== null) return; if(channel !== null) return;
setChannel(channels[0].uid); setChannel(channels[0].uid, channels[0].type);
}, [channel, channels]); }, [channel, channels]);
useEffect(() => { useEffect(() => {
@ -71,7 +71,7 @@ export default function Channels() {
useEffect(() => { useEffect(() => {
if(clientId === null) return; if(clientId === null) return;
send('client:get', clientId); // send('client:get', { clientId });
}, [clientId]); }, [clientId]);
const textbox = useRef<HTMLInputElement>(null); const textbox = useRef<HTMLInputElement>(null);
@ -83,56 +83,23 @@ export default function Channels() {
}, []); }, []);
return ( return (
<> <div style={{
height: '100%',
background: 'var(--neutral-3)',
padding: '0px 8px',
overflowY: 'auto',
overflowX: 'hidden',
}}>
<br></br> <br></br>
{channels.map(c => ( {channels.map(c => (
<div key={c.uid} style={{ <Channel
margin: '8px 0px', key={c.uid}
color: channel === c.uid ? 'cyan' : 'inherit', uid={c.uid}
cursor: 'pointer', type={c.type}
}} onClick={() => { unread={unreads[c.uid] ?? 0}
setChannel(c.uid); name={c.name}
}}> ></Channel>
<Hashmark></Hashmark>
{(c.uid in unreads) && (unreads[c.uid] > 0) && (
<span style={{ paddingRight: '8px' }}>({unreads[c.uid]})</span>
)}
<span style={{
fontWeight: (unreads[c.uid] ?? 0) > 0 ? 'bold' : '300',
}}>
{c.name}
</span>
<a style={{
color: 'rgba(0, 100, 200, 1)',
marginLeft: '8px',
fontSize: '10px',
}} href="#" onClick={() => {}}>Delete</a>
</div>
))} ))}
<Hashmark></Hashmark><input </div>
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>
<NameTextbox></NameTextbox>
</>
); );
} }

View File

@ -1,9 +1,13 @@
import { useCallback, useContext, useEffect, useRef, useState } from 'react'; import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { v4 } from 'uuid'; import { v4 } from 'uuid';
import { useApi } from '../lib/useApi'; import { useApi } from '../lib/useApi';
import { channelContext, clientIdContext } from './App';
import type { IMessage} from './Message'; import type { IMessage} from './Message';
import { Message } 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, function createMessage(from: string, text: string,
channel: string, t = 0): IMessage { channel: string, t = 0): IMessage {
@ -18,128 +22,116 @@ function createMessage(from: string, text: string,
export default () => { export default () => {
const [messages, setMessages] = useState<IMessage[]>([]); const [messages, setMessages] = useState<IMessage[]>([]);
const [hist, setHist] = useState(false); const { sessionToken } = useSessionToken();
const textBoxRef = useRef<HTMLDivElement>(null); const PADDING = 8;
const { channel, setChannel } = useContext(channelContext); const { channel, setChannel } = useChannel();
const clientId = useContext(clientIdContext); const { clientId } = useClientId();
const { send } = useApi({ const { send } = useApi({
'message:message'(data: IMessage) { 'message:message'(data: IMessage) {
if(data.channel !== channel) return; if(data.channel !== channel) return;
setMessages([...messages, data]); setMessages(messages => ([...messages, data]));
}, },
'message:recent'(data: { messages: IMessage[] }) { 'message:recent'(data: { messages: IMessage[] }) {
setMessages(data.messages.reverse()); setMessages(data.messages.reverse());
}, },
}, [messages]); }, [channel]);
useEffect(() => { useEffect(() => {
console.log('sending recents request');
send('message:recent', { channel }); send('message:recent', { channel });
}, [channel]); }, [channel]);
const sendMessage = useCallback(() => { // const sendMessage = useCallback(() => {
if(textBoxRef.current === null) return; // if(textBoxRef.current === null) return;
if(channel === null) return; // if(channel === null) return;
if(clientId === null) return; // if(clientId === null) return;
send( // if(sessionToken === null) return;
'message:message', // send(
createMessage( // 'message:message',
clientId, // createMessage(
textBoxRef.current.innerText, // clientId,
channel, // textBoxRef.current.innerText,
), // channel,
); // )
textBoxRef.current.innerText = ''; // );
}, [channel]); // textBoxRef.current.innerText = '';
// }, [channel, sessionToken]);
const keyDown = useCallback((evt: any) => { // const keyDown = useCallback((evt: any) => {
if(evt.key === 'Enter') { // if(evt.key === 'Enter') {
sendMessage(); // sendMessage();
} // }
}, [sendMessage]); // }, [sendMessage]);
return ( return (
<> <div
<div style={{
style={{ height: '100%',
height: '100%', width: '100%',
width: '100%', display: 'grid',
display: 'grid', background: 'var(--neutral-4)',
gridTemplateColumns: '1fr 64px', gridTemplateColumns: `1fr min-content`,
gridTemplateRows: '1fr 64px', gridTemplateRows: `1fr min-content`,
gridTemplateAreas: '"content content" "message send"', gridTemplateAreas: '"content content" "message send"',
}} }}
> >
<div style={{ <div style={{
// borderBottom: '1px solid #bd93f9', // borderBottom: '1px solid #bd93f9',
gridArea: 'content', gridArea: 'content',
position: 'relative', position: 'relative',
}}> // borderBottom: '1px solid white'
<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={{ <div style={{
position: 'absolute',
bottom: '0px',
width: '100%', width: '100%',
height: '100%',
padding: '8px',
boxSizing: 'border-box',
}}> }}>
<div onClick={sendMessage} style={{ {messages.map(message => (
background: '#bd93f9', <Message key={message.uid} message={message}></Message>
width: '100%', ))}
height: '100%',
// borderRadius: '50%',
borderRadius: '8px',
cursor: 'pointer',
display: 'grid',
placeItems: 'center center',
fontSize: '32px',
}}>
</div>
</div> </div>
</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>
)
}

View File

@ -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} />
}

View File

@ -1,12 +1,9 @@
import { useContext, useEffect } from 'react';
import TimeAgo from 'react-timeago'; import TimeAgo from 'react-timeago';
import { ClientsListContext } from '../contexts/EphemeralState/ClientsListState';
import useHover from '../hooks/useHover';
export interface IMessage { export type IMessage = NewMessageResponse;
uid: string;
timestamp: number;
from: string;
text: string;
channel: string;
}
interface MessageProps { interface MessageProps {
message: IMessage message: IMessage
@ -14,53 +11,71 @@ interface MessageProps {
const firstLineIndent = '10px'; const firstLineIndent = '10px';
const multiLineIndent = '16px'; const multiLineIndent = '16px';
const rightMessagePagging = '16px'; const rightMessagePadding = '16px';
export function Message({ export function Message({
message, message,
}: MessageProps) { }: MessageProps) {
const { clientName } = useContext(ClientsListContext);
const [hoverRef, hover] = useHover<HTMLDivElement>();
return ( return (
<div ref={hoverRef} style={{
display: 'grid',
gridTemplateColumns: '4em 1fr',
width: '100%',
padding: '1px 0px',
position: 'relative',
}}>
<div style={{ <div style={{
display: 'grid',
gridTemplateColumns: '128px 1fr',
width: '100%', 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={{ <TimeAgo
fontStyle: 'italic', date={message.timestamp}
color: '#596793', formatter={(t, u) => u === 'second' ? 'Now' : ('' + t + u[0])}
textAlign: 'right', ></TimeAgo>
userSelect: 'none', </span>
marginRight: '16px', <div style={{
}}> // outline: '1px solid white',
<TimeAgo marginRight: '16px',
date={message.timestamp} position: 'relative',
formatter={(t, u) => u === 'second' ? 'Just Now' : ('' + t + u[0])} paddingLeft: '1em',
></TimeAgo> textIndent: '-1em',
</span> }}>
<span style={{ <div>
}}> <span style={{
<div style={{ fontWeight: '500',
fontWeight: 'bold', paddingRight: '8px',
float: 'left',
paddingRight: firstLineIndent,
// marginRight: '16px',
// height: '100%'
// borderBottom: '1px solid white'
}}> }}>
{message.from} {clientName[message.from]}
</div> </span>
<div style={{ <span style={{
marginRight: rightMessagePagging,
paddingLeft: multiLineIndent,
boxSizing: 'border-box',
position: 'relative',
}}> }}>
{message.text} {message.text}
</span>
</div>
{!!message.file && (
<div>
<img style={{
maxWidth: '100%',
maxHeight: '20vh',
}} src={message.file.url}></img>
</div> </div>
</span> )}
</div> </div>
</div>
); );
} }

View File

@ -5,18 +5,18 @@ import {
useRef, useRef,
useState, useState,
} from 'react'; } from 'react';
import { clientIdContext } from './App'; import useClientId from '../hooks/useClientId';
import { useApi } from '../lib/useApi'; import { useApi } from '../lib/useApi';
export default function NameTextbox() { export default function NameTextbox() {
const clientId = useContext(clientIdContext); const { clientId } = useClientId()
const [name, setName] = useState<string | null>(null); const [name, setName] = useState<string | null>(null);
const [inputElement, setInputElement] = useState<HTMLInputElement | null>(null); const [inputElement, setInputElement] = useState<HTMLInputElement | null>(null);
const { send } = useApi({ const { send } = useApi({
'client:get'(_name: string) { 'client:get'(data: any) {
setName(_name); setName(data.name);
}, },
}, [name, clientId]); }, [name, clientId]);
@ -33,7 +33,7 @@ export default function NameTextbox() {
useEffect(() => { useEffect(() => {
if(clientId === null) return; if(clientId === null) return;
if(inputElement === null) return; if(inputElement === null) return;
send('client:get', clientId); send('client:get', { clientId });
}, [inputElement, clientId]); }, [inputElement, clientId]);
return <input return <input

View File

@ -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>
// &nbsp;
// &nbsp;
// &nbsp;
// <a href="#" onClick={() => setReturning(false)}>Sign up</a>
// </div>
// ) : (
// <>
// <div>
// <a href="#" onClick={() => setReturning(true)}>
// Login
// </a>
// &nbsp;
// &nbsp;
// &nbsp;
// <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>
)
}

View File

@ -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>
)
}

View File

@ -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>
</>
)}
</>
);
};

View File

@ -8,7 +8,7 @@
"strict": true, "strict": true,
"isolatedModules": true, "isolatedModules": true,
"types" : ["node"], "types" : [],
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"#preload": [ "#preload": [

View File

@ -21,6 +21,7 @@ const config = {
}, },
base: '', base: '',
server: { server: {
host: true,
fs: { fs: {
strict: true, strict: true,
}, },
@ -40,7 +41,9 @@ const config = {
environment: 'happy-dom', environment: 'happy-dom',
}, },
plugins: [ plugins: [
react(), react({
fastRefresh: false
}),
renderer.vite({ renderer.vite({
preloadEntry: join(PACKAGE_ROOT, '../preload/src/index.ts'), preloadEntry: join(PACKAGE_ROOT, '../preload/src/index.ts'),
}), }),

View File

@ -1,4 +1,4 @@
DB_HOST=localhost DB_HOST=localhost
DB_USER=corner DB_USER=corner
DB_PASSWORD=corner DB_PASSWORD=corner
DB_DB=corner DB_DB=corner

View File

@ -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`);

View File

@ -0,0 +1,2 @@
ALTER TABLE `clients`
ADD `totp` varchar(16) COLLATE 'utf8mb4_general_ci' NULL;

View File

@ -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;

View File

@ -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;

View File

@ -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_HOST = 'localhost';
export const DB_USER = 'root'; export const DB_USER = 'root';
export const DB_PASSWORD = 'example'; export const DB_PASSWORD = 'example';
export const DB_NAME = 'corner'; export const DB_NAME = 'corner';
ensureDirSync(STORAGE_PATH);

View File

@ -46,6 +46,12 @@ const migrationConnection = createConnection({
multipleStatements: true, multipleStatements: true,
}); });
function keepAlive() {
connection.ping();
migrationConnection.ping();
}
setInterval(keepAlive, 60000); // ping to DB every minute
const connected: Promise<null> = new Promise((res, rej) => { const connected: Promise<null> = new Promise((res, rej) => {
migrationConnection.connect((err) => { migrationConnection.connect((err) => {
if(err === null) { if(err === null) {

View File

@ -1,23 +1,15 @@
import { connection } from './migrate'; import { connection } from './migrate';
export default function(sqlFile: any, ...args: any[]): Promise<any[] | null> {
const b64 = sqlFile.split('base64,')[1];
export default async function(a: any, ...opts: any[]): Promise<any[] | null> {
const b64 = a.split('base64,')[1];
const text = Buffer.from(b64, 'base64').toString(); const text = Buffer.from(b64, 'base64').toString();
try { return new Promise((resolve, reject) => {
return await new Promise((resolve, reject) => { connection.query(text, [...args], (err, results) => {
connection.query(text, [...opts], (err, results) => { if(!err) return resolve(results);
if(!err) return resolve(results); console.error(err.errno, err.sqlMessage);
console.error(err.errno, err.sqlMessage); console.error('--- Query ---');
console.error('--- Query ---'); console.error(err.sql?.substring(0, 10000));
console.error(err.sql); reject(err);
reject(err);
});
}); });
} catch(e) { });
return null;
}
// console.log(...opts)
} }

View File

@ -1 +1 @@
SELECT name FROM clients WHERE uid=? SELECT name, username, uid FROM clients WHERE uid=?

View File

@ -0,0 +1 @@
SELECT name, username, uid FROM clients WHERE username=?

View File

@ -0,0 +1 @@
SELECT totp FROM clients WHERE uid=?;

View File

@ -1 +1 @@
CALL new_client("Anonymous"); CALL new_client(?, ?);

View File

@ -1,3 +1,9 @@
INSERT INTO messages INSERT INTO messages (
(`text`, sender_uid, `uid`, `t_sent`, channel_uid) `text`,
VALUES ( ?, ?, ?, ?, ? ); sender_uid,
`uid`,
`t_sent`,
channel_uid,
attachment
)
VALUES ( ?, ?, ?, ?, ?, ? );

View File

@ -1,12 +1,15 @@
SELECT SELECT
messages.t_sent, messages.t_sent,
clients.name as 'from', clients.uid as 'from',
messages.`text` as 'text', messages.`text` as 'text',
messages.channel_uid, messages.channel_uid,
messages.uid as uid messages.uid as uid,
files.type as file_type,
files.uid as file_uid
FROM messages FROM messages
JOIN clients ON messages.sender_uid=clients.uid JOIN clients ON messages.sender_uid=clients.uid
LEFT JOIN files ON messages.attachment=files.uid
WHERE messages.channel_uid=? WHERE messages.channel_uid=?
ORDER BY -messages.t_sent ORDER BY -messages.t_sent
LIMIT 100; LIMIT 100;

View File

@ -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;

View File

@ -0,0 +1,3 @@
UPDATE sessions
SET expires=UNIX_TIMESTAMP() * 1000
WHERE token=?;

View File

@ -0,0 +1,2 @@
INSERT INTO sessions (client_uid, expires, token)
VALUES (?, ?, ?);

View File

@ -0,0 +1,3 @@
UPDATE clients
SET totp=?
WHERE uid=?;

View File

@ -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;

View File

@ -1,13 +1,25 @@
import router from './lib/router'; import router from './lib/router';
import { expose } from './lib/WebSocketServer'; import { expose, reply } from './lib/WebSocketServer';
// ws
import message from './routers/message'; import message from './routers/message';
import channel from './routers/channel'; import channel from './routers/channel';
import client from './routers/client'; 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({ const api = router({
up() { up() {
console.log(Date.now()); return reply({
time: Date.now()
});
}, },
message: message, message: message,
messages: message, messages: message,
@ -15,9 +27,26 @@ const api = router({
channels: channel, channels: channel,
client: client, client: client,
clients: 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);
// ------------- // -------------

View File

@ -1,14 +1,40 @@
import { WebSocketServer } from 'ws'; import { WebSocketServer, WebSocket } from 'ws';
import { inspect } from 'util'; import { inspect } from 'util';
import { validateSessionToken } from '../routers/session';
import chalk from 'chalk';
export function expose(router: Function, port: number) { function str2color(str: string) {
const wss = new WebSocketServer({ const v = str.split('').reduce((acc, val) => acc + val.charCodeAt(0), 0);
port: 3000, return (v % 213) + 17
}, () => { }
console.log('ws chat server started on dev.valnet.xyz');
}); function log(prefix: string, action: string, data: object) {
if(action === 'up') return;
wss.on('connection', (ws) => { 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) => { ws.on('message', async (str) => {
try { try {
const message = JSON.parse(str.toString()); const message = JSON.parse(str.toString());
@ -18,24 +44,31 @@ export function expose(router: Function, port: number) {
} }
const {action, data} = message; const {action, data} = message;
try { try {
console.log('[IN]', action, data); if(typeof data === 'object' && 'sessionToken' in data && data.sessionToken !== null) {
const _return = await (router(action, data) as unknown as Promise<any>); const auth = await validateSessionToken(data.sessionToken);
// console.log(_return); 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) { if(_return) {
try { try {
switch(_return.type) { switch(_return.type) {
case ResponseType.BROADCAST: { 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) { for(const client of wss.clients) {
send(client, action, _return.data); send(client, action, _return.data);
} }
break; break;
} }
case ResponseType.REPLY: { case ResponseType.REPLY: {
console.log('[OUT]', action, inspect(_return.data, { log(chalk.cyan('<<<'), action, _return.data);
depth: 0,
colors: true,
}));
send(ws, action, _return.data); send(ws, action, _return.data);
break; break;
} }
@ -53,7 +86,7 @@ export function expose(router: Function, port: number) {
console.error(e); console.error(e);
} }
}); });
}); }
} }
enum ResponseType { enum ResponseType {
@ -61,8 +94,17 @@ enum ResponseType {
REPLY REPLY
} }
function send(client: any, action: string, data?: any) { // 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=");
client.send(JSON.stringify({action, data})); // 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) { export function broadcast(data: any) {

View File

@ -0,0 +1,18 @@
INSERT INTO files (
uid,
author,
type,
data,
t_created,
t_uploaded,
t_deleted
)
VALUES (
?,
?,
?,
NULL,
?,
?,
NULL
)

View File

@ -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);
}

View File

@ -0,0 +1,18 @@
INSERT INTO files (
uid,
author,
type,
data,
t_created,
t_uploaded,
t_deleted
)
VALUES (
?,
?,
?,
?,
?,
?,
NULL
)

View File

@ -0,0 +1,7 @@
import query from "/@/db/query";
import sql from './addFileRaw.sql';
export default function addFile(uid: string, author: string, data: Buffer, type: string) {
const now = new Date().getTime();
return query(sql, uid, author, type, data, now, now);
}

View File

@ -0,0 +1,26 @@
import getAllDisplayNames from './get/all/displayNames';
import addFileRaw from './add/file/raw/addFileRaw';
import addFilePath from './add/file/path/addFilePath';
import getFileByUid from './get/file/by/uid';
const database = {
get: {
all: {
displayNames: getAllDisplayNames
},
file: {
by: {
uid: getFileByUid
}
}
},
add: {
file: {
raw: addFileRaw,
path: addFilePath
}
}
};
export default database;

View File

@ -0,0 +1,5 @@
SELECT
uid as clientId,
name as displayName
FROM clients
WHERE totp IS NOT NULL;

Some files were not shown because too many files have changed in this diff Show More