aboutsummaryrefslogtreecommitdiff
path: root/client
diff options
context:
space:
mode:
authorGravatar Moon Flower <mforoud86@gmail.com> 2026-02-27 23:37:44 +0330
committerGravatar Moon Flower <mforoud86@gmail.com> 2026-02-27 23:37:44 +0330
commit8ee3112ca09fd70a50c2159629b40700beb2d5c3 (patch)
tree42a8d3e5d7898e9d084f77e1345be270be0f5969 /client
parentsmall check (diff)
first client ver
Diffstat (limited to 'client')
-rw-r--r--client/.gitignore34
-rw-r--r--client/README.md15
-rw-r--r--client/bun.lock49
-rw-r--r--client/package.json19
-rw-r--r--client/public/blobs.ts154
-rw-r--r--client/public/chat.ts758
-rw-r--r--client/public/crypto.ts360
-rw-r--r--client/public/index.html77
-rw-r--r--client/public/stuff.ts58
-rw-r--r--client/public/style.css1164
-rw-r--r--client/server.ts8
-rw-r--r--client/tsconfig.json29
12 files changed, 2725 insertions, 0 deletions
diff --git a/client/.gitignore b/client/.gitignore
new file mode 100644
index 0000000..a14702c
--- /dev/null
+++ b/client/.gitignore
@@ -0,0 +1,34 @@
+# dependencies (bun install)
+node_modules
+
+# output
+out
+dist
+*.tgz
+
+# code coverage
+coverage
+*.lcov
+
+# logs
+logs
+_.log
+report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
+
+# dotenv environment variable files
+.env
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# caches
+.eslintcache
+.cache
+*.tsbuildinfo
+
+# IntelliJ based IDEs
+.idea
+
+# Finder (MacOS) folder config
+.DS_Store
diff --git a/client/README.md b/client/README.md
new file mode 100644
index 0000000..1e1303f
--- /dev/null
+++ b/client/README.md
@@ -0,0 +1,15 @@
+# client
+
+To install dependencies:
+
+```bash
+bun install
+```
+
+To run:
+
+```bash
+bun run
+```
+
+This project was created using `bun init` in bun v1.3.2. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
diff --git a/client/bun.lock b/client/bun.lock
new file mode 100644
index 0000000..dddd902
--- /dev/null
+++ b/client/bun.lock
@@ -0,0 +1,49 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "client",
+ "dependencies": {
+ "@noble/ciphers": "^2.1.1",
+ "@noble/curves": "^2.0.1",
+ "@noble/ed25519": "^3.0.0",
+ "@noble/hashes": "^2.0.1",
+ "@types/node-forge": "^1.3.14",
+ "aes-js": "^3.1.2",
+ "node-forge": "^1.3.3",
+ },
+ "devDependencies": {
+ "@types/bun": "latest",
+ },
+ "peerDependencies": {
+ "typescript": "^5",
+ },
+ },
+ },
+ "packages": {
+ "@noble/ciphers": ["@noble/ciphers@2.1.1", "", {}, "sha512-bysYuiVfhxNJuldNXlFEitTVdNnYUc+XNJZd7Qm2a5j1vZHgY+fazadNFWFaMK/2vye0JVlxV3gHmC0WDfAOQw=="],
+
+ "@noble/curves": ["@noble/curves@2.0.1", "", { "dependencies": { "@noble/hashes": "2.0.1" } }, "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw=="],
+
+ "@noble/ed25519": ["@noble/ed25519@3.0.0", "", {}, "sha512-QyteqMNm0GLqfa5SoYbSC3+Pvykwpn95Zgth4MFVSMKBB75ELl9tX1LAVsN4c3HXOrakHsF2gL4zWDAYCcsnzg=="],
+
+ "@noble/hashes": ["@noble/hashes@2.0.1", "", {}, "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw=="],
+
+ "@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
+
+ "@types/node": ["@types/node@25.3.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A=="],
+
+ "@types/node-forge": ["@types/node-forge@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-mhVF2BnD4BO+jtOp7z1CdzaK4mbuK0LLQYAvdOLqHTavxFNq4zA1EmYkpnFjP8HOUzedfQkRnp0E2ulSAYSzAw=="],
+
+ "aes-js": ["aes-js@3.1.2", "", {}, "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ=="],
+
+ "bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="],
+
+ "node-forge": ["node-forge@1.3.3", "", {}, "sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg=="],
+
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
+
+ "undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
+ }
+}
diff --git a/client/package.json b/client/package.json
new file mode 100644
index 0000000..c2b56b6
--- /dev/null
+++ b/client/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "client",
+ "private": true,
+ "devDependencies": {
+ "@types/bun": "latest"
+ },
+ "peerDependencies": {
+ "typescript": "^5"
+ },
+ "dependencies": {
+ "@noble/ciphers": "^2.1.1",
+ "@noble/curves": "^2.0.1",
+ "@noble/ed25519": "^3.0.0",
+ "@noble/hashes": "^2.0.1",
+ "@types/node-forge": "^1.3.14",
+ "aes-js": "^3.1.2",
+ "node-forge": "^1.3.3"
+ }
+}
diff --git a/client/public/blobs.ts b/client/public/blobs.ts
new file mode 100644
index 0000000..5ba99e9
--- /dev/null
+++ b/client/public/blobs.ts
@@ -0,0 +1,154 @@
+const TAU = Math.PI * 2
+
+type orb = {
+ color: string
+ cx: number
+ cy: number
+ rx: number
+ ry: number
+ fx: number
+ fy: number
+ px: number
+ py: number
+ sw: number
+ sh: number
+}
+export type html = HTMLElement & { _orbs: orb[], _seed: number }
+
+function initBlob(el: html) {
+ const colors = Array.from({ length: 8 }, (_, i) => el.getAttribute(`data-c${i + 1}`) || '#ffffff')
+ const seed = Math.random() * 1000
+
+ const orbs = colors.map((color, i) => ({
+ color,
+ cx: 15 + Math.random() * 70,
+ cy: 15 + Math.random() * 70,
+ rx: 8 + Math.random() * 14,
+ ry: 8 + Math.random() * 14,
+ fx: 0.018 + Math.random() * 0.022,
+ fy: 0.015 + Math.random() * 0.020,
+ px: Math.random() * TAU,
+ py: Math.random() * TAU,
+ sw: 70 + Math.random() * 30,
+ sh: 60 + Math.random() * 35,
+ }))
+
+ el._orbs = orbs
+ el._seed = seed
+}
+
+function renderBlob(el: html, t: number) {
+ const layers = el._orbs.map(o => {
+ const x = o.cx + Math.sin(TAU * o.fx * t + o.px) * o.rx
+ const y = o.cy + Math.cos(TAU * o.fy * t + o.py) * o.ry
+ return `radial-gradient(ellipse ${o.sw}% ${o.sh}% at ${x.toFixed(1)}% ${y.toFixed(1)}%, ${o.color} 0%, transparent 85%)`
+ })
+ const c0 = el._orbs[0]!.color, c1 = el._orbs[4]!.color
+ layers.push(`linear-gradient(135deg, ${c0} 0%, ${c1} 100%)`)
+ el.style.background = layers.join(',')
+}
+
+const blobs = Array.from(document.querySelectorAll('blob')) as html[]
+blobs.forEach(initBlob)
+
+let last = 0
+function tick(ts: number) {
+ const t = ts / 250
+ if (ts - last >= 40) {
+ blobs.forEach(b => renderBlob(b, t))
+ last = ts
+ }
+ requestAnimationFrame(tick)
+}
+requestAnimationFrame(tick)
+
+export function createBlob(seeds: [number, number, number, number, number, number, number, number]) {
+ function seedToColor(n: number) {
+ n = Math.abs(n) | 0
+ const r = (n * 1664525 + 1013904223) & 0xff
+ const g = (n * 22695477 + 1) & 0xff
+ const b = (n * 214013 + 2531011) & 0xff
+ return `#${[r, g, b].map(v => v.toString(16).padStart(2, '0')).join('')}`
+ }
+
+ const colors = seeds.map(seedToColor)
+
+ const blob = document.createElement('blob') as html
+ colors.forEach((c, i) => blob.setAttribute(`data-c${i + 1}`, c))
+ initBlob(blob)
+ blobs.push(blob)
+
+ return blob
+}
+
+export let id_list: Record<string, HTMLElement[]> = {}
+
+export function pubkeyToBlobs(pubkey: string): [number, number, number, number, number, number, number, number] {
+ let bytes: Uint8Array
+
+ const b64 = pubkey.replace(/-/g, '+').replace(/_/g, '/')
+ const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
+ bytes = Uint8Array.from(atob(padded), c => c.charCodeAt(0))
+
+ const step = Math.floor(bytes.length / 8)
+ return Array.from({ length: 8 }, (_, i) => bytes[i * step]!) as [number, number, number, number, number, number, number, number]
+}
+
+export const decoder = new TextDecoder()
+export const encoder = new TextEncoder()
+
+export function addStrings(left: string, right: string): string {
+ const l = encoder.encode(left)
+ const r = encoder.encode(right)
+ const result = new Uint8Array(Math.max(l.length, r.length))
+ for (let i = 0; i < result.length; i++) {
+ result[i] = (l[i] ?? 0) ^ (r[i] ?? 0)
+ }
+ return Array.from(result, b => b.toString(16).padStart(2, '0')).join('')
+}
+
+export function subtractStrings(value: string, key: string): string {
+ const v = new Uint8Array(value.match(/.{2}/g)!.map(b => parseInt(b, 16)))
+ const k = encoder.encode(key)
+ const result = new Uint8Array(Math.max(v.length, k.length))
+ for (let i = 0; i < result.length; i++) {
+ result[i] = (v[i] ?? 0) ^ (k[i] ?? 0)
+ }
+ return decoder.decode(result)
+}
+
+export let info: { keypair: { identity: string, cryptPrivate: string, signPrivate: string } } = {} as any
+
+export let followed_chats: string[] = JSON.parse(localStorage.getItem('followed_chats') ?? '["main"]')
+followed_chats = [...new Set(followed_chats)]
+
+// , seeds: [number, number, number, number, number, number, number, number]
+export function makeMessage(type: 'in' | 'out' | 'broadcast' | 'system', id: number, prefix: string, text: string, pubkey: string): HTMLElement {
+ const row = document.createElement('div')
+ row.className = 'msg-row'
+ const seeds: [number, number, number, number, number, number, number, number] = pubkey === '0' ? [27, 28, 32, 45, 49, 58, 91, 28] : pubkeyToBlobs(pubkey)
+
+ const blob = createBlob(seeds)
+ blob.addEventListener('click', () => {
+ if (confirm('do you want to open a direct message with this user?')) {
+ const chat = `@${pubkey}`
+ if (!followed_chats.includes(chat)) {
+ followed_chats.push(chat)
+ //@ts-expect-error
+ globalThis.refreshChatList()
+ }
+ //@ts-expect-error
+ globalThis.switchChat(chat)
+ }
+ })
+
+ const msg = document.createElement(`m-${type}`)
+ msg.innerHTML = `<span class="msg-prefix">${prefix}</span> ${text}`
+
+ row.appendChild(blob)
+ row.appendChild(msg)
+
+ if (!id_list[pubkey]) id_list[pubkey] = [row]
+ else id_list[pubkey].push(row)
+ return row
+} \ No newline at end of file
diff --git a/client/public/chat.ts b/client/public/chat.ts
new file mode 100644
index 0000000..4671a0c
--- /dev/null
+++ b/client/public/chat.ts
@@ -0,0 +1,758 @@
+console.clear()
+
+import * as crypt from './crypto.ts'
+import { addStrings, createBlob, decoder, encoder, followed_chats, id_list, info, makeMessage, pubkeyToBlobs, subtractStrings } from './blobs.ts'
+import { hideModal, setModalHTML, showModal } from './stuff.ts'
+
+function byteSlice(str: string, size: number) {
+ return decoder.decode(encoder.encode(str).slice(0, size))
+}
+
+function byteSize(str: string) {
+ return encoder.encode(str).length
+}
+
+const input = document.querySelector('input[type="text"]')! as HTMLInputElement
+const btn = document.querySelector('.send-btn')!
+const cont = document.querySelector('container')!
+
+const edot = document.querySelector('.dot')!
+const estatus = document.querySelector('.status')!
+
+function status(dot: 'live' | 'connecting' | 'initializing' | 'disconnected', text: string) {
+ edot.className = `dot dot-${dot}`
+ estatus.innerHTML = text
+}
+
+const socket = new WebSocket(`ws://${location.hostname}:10000/tcp/ws/localhost/5001`)
+socket.binaryType = 'arraybuffer'
+
+socket.addEventListener('error', e => {
+ console.error(e)
+ status('disconnected', 'error')
+})
+
+socket.addEventListener('close', e => {
+ console.error(e)
+ status('disconnected', 'disconnected')
+})
+
+let inbox: any[] = []
+
+socket.addEventListener('open', e => {
+ status('initializing', 'phase0:open')
+})
+
+socket.addEventListener('message', e => {
+ const data = e.data
+ inbox.push([Date.now(), data])
+})
+
+let ws_uuid: string
+let my_id: number
+let my_mode: 'tcp' | 'udp'
+
+let my_name: string | undefined = localStorage.getItem('name') ?? undefined
+
+async function spinSocket() {
+ while (socket.readyState !== socket.OPEN) {
+ await new Promise(r => setTimeout(r, 10))
+ }
+}
+
+async function setPassword(_password: string) {
+ if (byteSize(_password) > 8) console.warn('warning: password truncated to eight bytes')
+ const part1 = byteSlice(_password, 8)
+ const part2 = new Array(Math.abs(part1.length)).fill(String.fromCharCode(1)).join('')
+ const password = part2 + part1
+
+ await spinSocket()
+ socket.send('P' + password)
+}
+
+async function sendMessage(_password: string, message: string) {
+ if (byteSize(_password) > 8) console.warn('warning: password truncated to eight bytes')
+ const part1 = byteSlice(_password, 8)
+ const part2 = new Array(Math.abs(part1.length)).fill(String.fromCharCode(1)).join('')
+ const password = part2 + part1
+
+ await spinSocket()
+ socket.send(
+ 'M' +
+ password +
+ message +
+ '\n'
+ )
+}
+
+const msg_pass = '10000000'
+let msgHandler: ((time: number, packet: string) => Promise<boolean>) | undefined
+
+let nicknames: Record<string, string> = JSON.parse(localStorage.getItem('nicknames') ?? '{}')
+let pubkey_id_pairs: Record<number, string> = {}
+
+function shortenPubkey(key: string) {
+ return key.substring(0, 6) + key.substring(key.length - 4)
+}
+
+function systemMessage(text: string) {
+ cont.appendChild(makeMessage('system', 0, '', text, '0'))
+}
+
+async function parseTrio(trio: string) {
+ const first = trio.indexOf(':')
+ const second = trio.indexOf(':', first + 1)
+ if (first === -1 || second === -1) return undefined
+
+ const pubkey = trio.substring(0, first)
+ const signature = trio.substring(first + 1, second)
+ const data = trio.substring(second + 1).trim()
+
+ if (await crypt.verify(pubkey.substring(pubkey.length / 2), encoder.encode(data), signature)) return { pubkey, signature, data }
+ console.log('not legit :(', pubkey, signature, data)
+ return undefined
+}
+
+let chats: Record<string, HTMLElement[]> = {}
+let current_chat: string = localStorage.getItem('active_chat') ?? 'main'
+
+type StoredMessage = {
+ obj: Record<string, any>
+ meta: { id: number, pubkey: string, nickname: string }
+ direction: 'in' | 'out' | 'system'
+}
+let chatData: Record<string, StoredMessage[]> = JSON.parse(localStorage.getItem('chat_data') ?? '{}')
+
+function persistChats() {
+ localStorage.setItem('chat_data', JSON.stringify(chatData))
+}
+
+function addMessage(chat: string, stored: StoredMessage) {
+ if (!chats[chat]) chats[chat] = []
+ if (!chatData[chat]) chatData[chat] = []
+
+ chatData[chat]!.push(stored)
+ chatData[chat] = chatData[chat]!
+ persistChats()
+
+ const elm = renderMessage(stored)
+ chats[chat]!.push(elm)
+ return elm
+}
+
+function renderMessage(stored: StoredMessage) {
+ const { obj, meta, direction } = stored
+ const nick = nicknames[meta.pubkey] ?? meta.id
+ const prefix = direction === 'system' ? '' : `[<name>${nick}</name> ${shortenPubkey(meta.pubkey)}]`
+ return makeMessage(direction, meta.id, prefix, obj.text, meta.pubkey)
+}
+
+function switchChat(next_chat: string) {
+ current_chat = next_chat
+ cont.innerHTML = ''
+ if (chats[current_chat]) {
+ for (const elm of chats[current_chat]!) {
+ cont.appendChild(elm)
+ }
+ }
+
+ localStorage.setItem('active_chat', current_chat)
+
+ refreshChatList()
+}
+
+//@ts-expect-error
+globalThis.switchChat = switchChat
+
+const pendingPings = new Map<string, (pubkey: string) => void>()
+
+function ping(targetPubkey: string, timeoutMs = 5000): Promise<boolean> {
+ const nonce = crypt.toB64(crypto.getRandomValues(new Uint8Array(12)))
+ return new Promise(resolve => {
+ const timer = setTimeout(() => {
+ pendingPings.delete(nonce)
+ resolve(false)
+ }, timeoutMs)
+ pendingPings.set(nonce, () => {
+ clearTimeout(timer)
+ resolve(true)
+ })
+ sendMessage(msg_pass, `ping:${targetPubkey}:${nonce}`)
+ })
+}
+
+let CHUNK_SIZE = 1024
+const pendingReceives = new Map<string, { name: string, mime: string, total: number, chunks: Map<number, Uint8Array> }>()
+const pendingSends = new Map<string, { targetPubkey: string, chunks: Uint8Array[], onProgress?: (n: number, total: number) => void }>()
+
+async function sendFile(targetPubkey: string, file: File, onProgress?: (n: number, total: number) => void) {
+ const buf = await file.arrayBuffer()
+ const bytes = new Uint8Array(buf)
+ const chunks: Uint8Array[] = []
+ for (let i = 0; i < bytes.length; i += CHUNK_SIZE) {
+ chunks.push(bytes.slice(i, i + CHUNK_SIZE))
+ }
+
+ const id = crypt.toB64(crypto.getRandomValues(new Uint8Array(12)))
+ pendingSends.set(id, { targetPubkey, chunks, onProgress })
+
+ await sendMessage(msg_pass, `file:offer:${targetPubkey}:${await createTrio(JSON.stringify({
+ id, name: file.name, size: file.size, totalChunks: chunks.length, mime: file.type
+ }))}`)
+}
+
+async function flushChunks(id: string) {
+ const send = pendingSends.get(id)
+ if (!send) return
+ const { targetPubkey, chunks, onProgress } = send
+
+ for (let i = 0; i < chunks.length; i++) {
+ const encrypted = await crypt.encryptAsymmetric(targetPubkey.substring(0, targetPubkey.length / 2), chunks[i]!)
+ await sendMessage(msg_pass, `file:chunk:${targetPubkey}:${i}:${await createTrio(id + ':' + encrypted)}`)
+ onProgress?.(i + 1, chunks.length)
+ }
+
+ await sendMessage(msg_pass, `file:done:${await createTrio(id)}`)
+ pendingSends.delete(id)
+}
+
+const pendingOffers = new Map<string, { name: string, mime: string, size: number, totalChunks: number, senderPubkey: string }>()
+const TRANSFERS_CHAT = '__transfers__'
+
+document.querySelector('s-item[archived]')!.addEventListener('click', () => {
+ switchChat(TRANSFERS_CHAT)
+})
+
+async function chatHandler(time: number, packet: string) {
+ // console.log('catching all packets!', time, packet)
+
+ if (packet.startsWith('M')) {
+ packet = packet.substring(1)
+ const idstr = packet.split(':')[0]!
+ let id = Number(idstr)
+ if (isNaN(id)) id = 1 // fallback to some weird amount
+ packet = packet.substring(idstr.length + 1 + 1 + 1 + 10 + 1 + 8) // id : {T <timestamp> } password
+
+ if (packet.startsWith('pubkey:')) { // announcement
+ // no use for this currently
+ const pubkey = packet.substring(7).trim()
+ pubkey_id_pairs[id] = pubkey
+ // systemMessage(`${shortenPubkey(pubkey)} has joined the chat.`)
+ return true
+ }
+
+ if (packet.startsWith('ping:')) { // check if user is online
+ // anatomy --
+ // ping : target_pubkey : 16-char nonce
+ packet = packet.substring(5)
+ const target = packet.split(':')[0]
+ if (!target) return false
+ if (target !== info.keypair.identity) return true
+ packet = packet.substring(target.length + 1)
+ const nonce = packet.trim()
+ if (nonce.length !== 16) return false
+
+ await sendMessage(msg_pass, `pong:${await createTrio(nonce)}`)
+ return true
+ }
+
+ if (packet.startsWith('pong:')) {
+ // anatomy --
+ // pong : target_pubkey : 16-char nonce : signature
+ packet = packet.substring(5)
+ const pong = await parseTrio(packet)
+ if (!pong) return true
+
+ const nonce = pong.data.trim()
+ if (nonce.length !== 16) return true
+
+ pendingPings.get(nonce)?.(pong.pubkey)
+ pendingPings.delete(nonce)
+ return true
+ }
+
+ if (packet.startsWith('msg:')) { // actual message
+ // anatomy --
+ // msg : pubkey : signature : message
+ packet = packet.substring(4) // msg:
+ const msg = await parseTrio(packet)
+
+ if (msg) {
+ pubkey_id_pairs[id] = msg.pubkey
+
+ try {
+ const obj = JSON.parse(msg.data)
+ if (obj.chat.startsWith('@')) {
+ const sidekey = subtractStrings(obj.chat.substring(1), info.keypair.identity)
+ obj.chat = '@' + sidekey
+ obj.text = decoder.decode(await crypt.decryptAsymmetric(info.keypair.cryptPrivate, obj.text))
+ }
+
+ if (obj.text && obj.chat && followed_chats.includes(obj.chat)) {
+ if (!chats[obj.chat]) chats[obj.chat] = []
+ const msgobj = addMessage(obj.chat, {
+ obj,
+ meta: { id, pubkey: msg.pubkey, nickname: nicknames[msg.pubkey] ?? String(id) },
+ direction: 'in'
+ })
+ chats[obj.chat]!.push(msgobj)
+
+ if (obj.chat === current_chat) {
+ cont.appendChild(msgobj)
+ }
+ }
+ } catch (e) {
+ console.warn('invalid json', e)
+ }
+ }
+ }
+
+ if (packet.startsWith('nick:')) { // nickname announcement
+ // anatomy --
+ // nick : pubkey : signature : message
+ packet = packet.substring(5) // nick:
+ const nick = await parseTrio(packet)
+
+ if (nick) {
+ nick.data = nick.data.toLowerCase()
+
+ // skip fraudulent nickname override
+ const ind = Object.values(nicknames).findIndex(e => e === nick.data)
+ const existingKey = ind === -1 ? '' : Object.keys(nicknames)[ind]!
+ if (ind !== -1 && shortenPubkey(existingKey) === nick.pubkey) return true
+
+ nicknames[nick.pubkey] = nick.data
+ localStorage.setItem('nicknames', JSON.stringify(nicknames))
+ pubkey_id_pairs[id] = nick.pubkey
+
+ const nickname_elms = nick.pubkey ? id_list[nick.pubkey] : undefined
+ if (nickname_elms) {
+ for (const elm of nickname_elms) {
+ elm.querySelector('name')!.innerHTML = nick.data ?? id
+ }
+ }
+ }
+ }
+
+ if (packet.startsWith('file:offer:')) { // ogt file offer
+ packet = packet.substring(11)
+ const colon = packet.indexOf(':')
+ const target = packet.substring(0, colon)
+ if (target !== info.keypair.identity) return true
+ packet = packet.substring(colon + 1)
+
+ const msg = await parseTrio(packet)
+ if (!msg) return true
+ const { id, name, size, totalChunks, mime } = JSON.parse(msg.data)
+
+ pendingOffers.set(id, { name, mime, size, totalChunks, senderPubkey: msg.pubkey })
+
+ if (!chats[TRANSFERS_CHAT]) chats[TRANSFERS_CHAT] = []
+
+ const sender = nicknames[msg.pubkey] ?? shortenPubkey(msg.pubkey)
+ const row = makeMessage('system', 0, '', `๐Ÿ“ <b>${sender}</b> wants to send <b>${name}</b> (${(size / 1024).toFixed(1)} KB) โ€” <u style="cursor:pointer" data-transfer-id="${id}">accept or reject</u>`, '0')
+
+ row.querySelector('[data-transfer-id]')!.addEventListener('click', async () => {
+ const offer = pendingOffers.get(id)
+ if (!offer) {
+ // row.querySelector('[data-transfer-id]')!.textContent = '(already handled)'
+ return
+ }
+
+ const accept = confirm(`Accept "${offer.name}" (${(offer.size / 1024).toFixed(1)} KB) from ${nicknames[offer.senderPubkey] ?? shortenPubkey(offer.senderPubkey)}?`)
+ pendingOffers.delete(id)
+ row.querySelector('[data-transfer-id]')!.textContent = accept ? '(accepted)' : '(rejected)'
+ row.setAttribute('handled', 'true')
+ const badgeCount = chats[TRANSFERS_CHAT]!.filter(e => !e.hasAttribute('handled')).length
+ if (badgeCount === 0) document.querySelector('s-item[archived')!.removeAttribute('badge')
+ else document.querySelector('s-item[archived]')!.setAttribute('badge', badgeCount.toString())
+
+
+ if (!accept) {
+ await sendMessage(msg_pass, `file:abort:${await createTrio(id)}`)
+ return
+ }
+
+ pendingReceives.set(id, { name: offer.name, mime: offer.mime, total: offer.totalChunks, chunks: new Map() })
+ await sendMessage(msg_pass, `file:accept:${await createTrio(id)}`)
+ })
+
+ chats[TRANSFERS_CHAT]!.push(row)
+ document.querySelector('s-item[archived]')!.setAttribute('badge', chats[TRANSFERS_CHAT].filter(e => !e.hasAttribute('handled')).length.toString())
+ if (current_chat === TRANSFERS_CHAT) cont.appendChild(row)
+
+ return true
+ }
+
+ if (packet.startsWith('file:accept:')) { // file accepted, send contents
+ packet = packet.substring(12)
+ const msg = await parseTrio(packet)
+ if (!msg) return true
+ const id = msg.data.trim()
+ await flushChunks(id)
+ return true
+ }
+
+ if (packet.startsWith('file:chunk:')) { // got file chunk
+ packet = packet.substring(11)
+ const parts = packet.split(':')
+ const target = parts[0]!
+ if (target !== info.keypair.identity) return true
+ // skip target + index
+ const firstColon = packet.indexOf(':')
+ const secondColon = packet.indexOf(':', firstColon + 1)
+ const msg = await parseTrio(packet.substring(secondColon + 1))
+ if (!msg) return true
+ const colonIdx = msg.data.indexOf(':')
+ const id = msg.data.trim().substring(0, colonIdx)
+ const data = msg.data.substring(colonIdx + 1)
+
+ const receive = pendingReceives.get(id)
+ if (!receive) return true
+
+ const decrypted = await crypt.decryptAsymmetric(info.keypair.cryptPrivate, data)
+ const index = parseInt(parts[1]!)
+ receive.chunks.set(index, decrypted)
+ status('live', `receiving ${receive.name}: ${receive.chunks.size}/${receive.total}`)
+ return true
+ }
+
+ if (packet.startsWith('file:done:')) { // finished
+ packet = packet.substring(10)
+ const msg = await parseTrio(packet)
+ if (!msg) return true
+ const id = msg.data.trim()
+
+ const receive = pendingReceives.get(id)
+ if (!receive) return true
+
+ // assemble
+ const ordered = Array.from({ length: receive.total }, (_, i) => receive.chunks.get(i)!)
+ if (ordered.some(c => !c)) {
+ console.warn('file transfer incomplete')
+ return true
+ }
+
+ const total = ordered.reduce((s, c) => s + c.length, 0)
+ const merged = new Uint8Array(total)
+ let off = 0
+ for (const chunk of ordered) {
+ merged.set(chunk, off)
+ off += chunk.length
+ }
+
+ const blob = new Blob([merged], { type: receive.mime })
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = receive.name
+ a.click()
+ URL.revokeObjectURL(url)
+
+ pendingReceives.delete(id)
+ status('live', 'live')
+ return true
+ }
+
+ if (packet.startsWith('file:abort:')) {
+ packet = packet.substring(11)
+ const msg = await parseTrio(packet)
+ if (!msg) return true
+ const id = msg.data.trim()
+ pendingSends.delete(id)
+ pendingReceives.delete(id)
+ return true
+ }
+ }
+
+ return true
+}
+
+//@ts-expect-error yeah
+globalThis.refreshChatList = refreshChatList
+
+function refreshChatList() {
+ localStorage.setItem('followed_chats', JSON.stringify(followed_chats))
+
+ const c_channels = document.querySelector('s-channels')!
+ const c_directs = document.querySelector('s-directs')!
+ c_channels.innerHTML = ''
+ c_directs.innerHTML = ''
+ for (const chat of followed_chats) {
+ const dm = chat.startsWith('@')
+ const target = dm ? c_directs : c_channels
+ const elm = document.createElement('s-item')
+ elm.setAttribute(dm ? 'dm' : 'channel', '')
+ elm.innerHTML = dm ? (nicknames[chat.substring(1)] ?? `${shortenPubkey(chat.substring(1))}?`) : chat
+ if (chat === current_chat) elm.setAttribute('active', '')
+ elm.addEventListener('click', () => {
+ switchChat(chat)
+ })
+
+ target.appendChild(elm)
+ }
+
+ const transfersBtn = document.querySelector('s-item[archived]')!
+ transfersBtn.toggleAttribute('active', current_chat === TRANSFERS_CHAT)
+ transfersBtn.toggleAttribute('pending', pendingOffers.size > 0)
+}
+
+async function runSetup() {
+ status('live', 'setup1:keypair')
+ const keypairText = localStorage.getItem('keypair')
+ if (keypairText) {
+ console.log('user aready has a keypair.')
+ info.keypair = JSON.parse(keypairText)
+ } else {
+ console.log('generating keypair...')
+
+ let newpair: any
+ let confirmed = false
+
+ showModal()
+ async function checkPair() {
+ const pair1 = await crypt.generateEncryptionKeyPair()
+ const pair2 = await crypt.generateSigningKeyPair()
+
+ newpair = {
+ identity: pair1.publicKey + pair2.publicKey,
+ cryptPrivate: pair1.privateKey,
+ signPrivate: pair2.privateKey
+ }
+
+ setModalHTML('')
+
+ const blob = createBlob(pubkeyToBlobs(newpair.identity))
+ blob.style.transform = 'scale(150%)'
+ blob.style.marginLeft = '10px'
+ blob.addEventListener('click', checkPair)
+ const skeleton = document.createElement('div')
+
+ const header = document.createElement('div')
+ header.classList.add('modal-header')
+ header.innerHTML = '<span class="modal-title">generate a blob!</span>'
+
+ const body = document.createElement('div')
+ body.classList.add('modal-body')
+
+ const p3 = document.createElement('p')
+ p3.innerHTML = `this is a blob, the visual representation of your public key.<br>
+
+click on the blob until you're satisfied with the colors.<br><br>`
+
+ body.appendChild(p3)
+ body.appendChild(blob)
+
+ const btn = document.createElement('button')
+ btn.classList.add('btn')
+ btn.classList.add('btn-primary')
+ btn.innerHTML = 'done.'
+ btn.addEventListener('click', () => {
+ localStorage.setItem('keypair', JSON.stringify(newpair))
+ info.keypair = newpair
+ confirmed = true
+ hideModal()
+ })
+ body.appendChild(document.createElement('br'))
+ body.appendChild(document.createElement('br'))
+ body.appendChild(btn)
+
+ skeleton.appendChild(header)
+ skeleton.appendChild(body)
+
+ document.getElementById('modal-el')!.appendChild(skeleton)
+ }
+
+ await checkPair()
+ while (!confirmed) await new Promise(r => setTimeout(r, 100))
+ }
+
+ document.querySelector('s-avatar')!.appendChild(createBlob(pubkeyToBlobs(info.keypair.identity)))
+ document.querySelector('s-name')!.innerHTML = my_name ?? `id${my_id}`
+
+ msgHandler = chatHandler
+
+ status('live', 'setup2:subscribe')
+ await setPassword(msg_pass)
+ status('live', 'setup3:announce')
+ await sendMessage(msg_pass, 'pubkey:' + info.keypair.identity)
+ if (my_name) {
+ status('live', 'setup4:nickname')
+ await sendMessage(msg_pass, `nick:${await createTrio(my_name)}`)
+ }
+
+ status('live', 'setup5:chats')
+ refreshChatList()
+
+ status('live', 'setup6:history')
+ for (const [chat, messages] of Object.entries(chatData)) {
+ chats[chat] = messages.map(renderMessage)
+ }
+ switchChat(current_chat)
+
+ status('live', 'live')
+}
+
+async function inboxProcessor() {
+ if (inbox.length === 0) return
+ const [time, packet] = inbox.shift()
+ let text
+ if (typeof packet === 'string') {
+ if (packet.startsWith('ok_')) { // broadlayer uuid
+ ws_uuid = packet.substring(3)
+ console.log('phase 0: got uuid')
+ status('initializing', 'shake0:uuid')
+ return
+ }
+ text = packet
+ } else {
+ text = decoder.decode(packet)
+
+ if (text.startsWith('I')) {
+ const mode = text.startsWith('IT') ? 'tcp' : 'udp'
+ console.log('phase 1: connected on', mode, 'mode')
+ const idstr = text.substring(2).trim()
+ const id = parseInt(idstr)
+ if (isNaN(id)) {
+ console.warn('failed on nan id catch "' + text + '"')
+ return
+ }
+
+ my_id = id
+ my_mode = mode
+ CHUNK_SIZE = my_mode === 'tcp' ? 8192 : 1024
+ console.log('phase 1: got user id')
+ status('initializing', 'shake1:uid')
+ await runSetup()
+ return
+ }
+
+ if (text.startsWith('T')) {
+ console.log('phase 2: udp server sent timeout, broadlayer handles it')
+ status('initializing', 'shake2:udptime')
+ return
+ }
+
+ if (text.startsWith('m_ok') || text.startsWith('p_ok')) {
+ console.log('got an m_ok/p_ok, ignoring')
+ return
+ }
+ }
+
+ if (msgHandler && await msgHandler(time, text)) return
+
+ console.log('unknown/invalid packet', packet)
+}
+async function inboxProcessorLoop() {
+ const max = Math.max(inbox.length, 1)
+ for (let i = 0; i < max; i++) {
+ await inboxProcessor()
+ }
+ await new Promise(r => setTimeout(r, 100))
+ requestAnimationFrame(inboxProcessorLoop)
+}
+requestAnimationFrame(inboxProcessorLoop)
+
+// setPassword('hello')
+
+async function createTrio(text: string) {
+ return `${info.keypair.identity}:${await crypt.sign(info.keypair.signPrivate, encoder.encode(text))}:${text}`
+}
+
+async function _send() {
+ const val = input.value.trim()
+ if (!val) return
+
+ if (val.startsWith('/')) {
+ const cmd = val.substring(1)
+ if (cmd.startsWith('nick ')) {
+ const nick = cmd.substring(5).trim()
+
+ localStorage.setItem('name', nick)
+ my_name = nick
+ nicknames[info.keypair.identity] = nick
+
+ const nickname_elms = id_list[info.keypair.identity]
+ if (nickname_elms) {
+ for (const elm of nickname_elms) {
+ elm.querySelector('name')!.innerHTML = nick ?? my_id
+ }
+ }
+
+ document.querySelector('s-name')!.innerHTML = nick ?? `id${my_id}`
+
+ // announce
+ await sendMessage(msg_pass, `nick:${await createTrio(nick)}`)
+ systemMessage('successfully set your nickname.')
+ return
+ } else if (cmd.startsWith('join ')) {
+ const chat = cmd.substring(5).trim()
+ if (!followed_chats.includes(chat)) {
+ followed_chats.push(chat)
+ refreshChatList()
+ }
+
+ return
+ }
+
+ systemMessage('unknown command.')
+ return
+ }
+
+ let chat = current_chat
+ let text
+ if (chat.startsWith('@')) {
+ const sidekey = chat.substring(1)
+ chat = '@' + addStrings(sidekey, info.keypair.identity)
+ text = await crypt.encryptAsymmetric(sidekey.substring(0, sidekey.length / 2), encoder.encode(val))
+ } else text = val
+
+ await sendMessage(msg_pass, `msg:${await createTrio(JSON.stringify({
+ text,
+ chat,
+ timestamp: Date.now()
+ }))}`)
+
+ const msgobj = addMessage(current_chat, {
+ obj: { text: val, chat: current_chat },
+ meta: { id: my_id, pubkey: info.keypair.identity, nickname: my_name ?? String(my_id) },
+ direction: 'out'
+ })
+ cont.appendChild(msgobj)
+
+ if (!chats[current_chat]) chats[current_chat] = []
+ chats[current_chat]!.push(msgobj)
+}
+
+async function send() {
+ await _send()
+ cont.scrollTop = cont.scrollHeight
+ input.value = ''
+ input.focus()
+}
+
+btn.addEventListener('click', send)
+input.addEventListener('keydown', e => { if (e.key === 'Enter') send() })
+
+const fileBtn = document.querySelector('.file-btn')!
+
+fileBtn.addEventListener('click', () => {
+ if (!current_chat.startsWith('@')) {
+ systemMessage('file transfer is only available in direct messages.')
+ return
+ }
+
+ const input = document.createElement('input')
+ input.type = 'file'
+ input.addEventListener('change', async () => {
+ const file = input.files?.[0]
+ if (!file) return
+
+ const targetPubkey = current_chat.substring(1)
+ await sendFile(targetPubkey, file, (n, total) => {
+ status('live', `sending ${file.name}: ${n}/${total}`)
+ })
+ status('live', 'live')
+ })
+ input.click()
+}) \ No newline at end of file
diff --git a/client/public/crypto.ts b/client/public/crypto.ts
new file mode 100644
index 0000000..ab83517
--- /dev/null
+++ b/client/public/crypto.ts
@@ -0,0 +1,360 @@
+import { ed25519, x25519 } from '@noble/curves/ed25519.js'
+import { hkdf } from '@noble/hashes/hkdf.js'
+import { pbkdf2 } from '@noble/hashes/pbkdf2.js'
+import { sha256 } from '@noble/hashes/sha2.js'
+import { gcm } from '@noble/ciphers/aes.js'
+import { randomBytes as nodeRandomBytes } from 'node:crypto'
+
+const B64URL = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
+
+export function toB64(bytes: Uint8Array): string {
+ let out = ''
+ let i = 0
+ while (i < bytes.length) {
+ const a = bytes[i++]!
+ const b = bytes[i++] ?? 0
+ const c = bytes[i++] ?? 0
+ out += B64URL[a >> 2]
+ out += B64URL[((a & 3) << 4) | (b >> 4)]
+ out += B64URL[((b & 15) << 2) | (c >> 6)]
+ out += B64URL[c & 63]
+ }
+ const pad = bytes.length % 3
+ return pad ? out.slice(0, pad - 3) : out
+}
+
+export function fromB64(str: string): Uint8Array {
+ const lookup = new Uint8Array(128)
+ for (let i = 0; i < B64URL.length; i++) lookup[B64URL.charCodeAt(i)] = i
+ const pad = str.length % 4
+ if (pad) str += '='.repeat(4 - pad)
+ const out = new Uint8Array((str.length * 3) >> 2)
+ let pos = 0
+ for (let i = 0; i < str.length; i += 4) {
+ const a = lookup[str.charCodeAt(i)]!
+ const b = lookup[str.charCodeAt(i + 1)]!
+ const c = lookup[str.charCodeAt(i + 2)] ?? 0
+ const d = lookup[str.charCodeAt(i + 3)] ?? 0
+ out[pos++] = (a << 2) | (b >> 4)
+ out[pos++] = ((b & 15) << 4) | (c >> 2)
+ out[pos++] = ((c & 3) << 6) | d
+ }
+ return out.slice(0, pos - (str.endsWith('==') ? 2 : str.endsWith('=') ? 1 : 0))
+}
+
+function concat(...arrays: Uint8Array[]): Uint8Array {
+ const total = arrays.reduce((s, a) => s + a.length, 0)
+ const out = new Uint8Array(total)
+ let off = 0
+ for (const a of arrays) {
+ out.set(a, off)
+ off += a.length
+ }
+ return out
+}
+
+function toArrayBuffer(u8: Uint8Array): ArrayBuffer {
+ const copy = new Uint8Array(u8.length)
+ copy.set(u8)
+ return copy.buffer as ArrayBuffer
+}
+
+const encode = (s: string) => new TextEncoder().encode(s)
+export const decodeText = (b: Uint8Array) => new TextDecoder().decode(b)
+
+const _g = globalThis as Record<string, unknown>
+const hasWebCrypto: boolean =
+ typeof _g['crypto'] !== 'undefined' &&
+ typeof (_g['crypto'] as Record<string, unknown>)['subtle'] !== 'undefined'
+
+const subtle: SubtleCrypto | null = hasWebCrypto
+ ? (_g['crypto'] as { subtle: SubtleCrypto }).subtle
+ : null
+
+function randomBytes(n: number): Uint8Array {
+ if (hasWebCrypto) {
+ const buf = new Uint8Array(n);
+ (_g['crypto'] as Crypto).getRandomValues(buf)
+ return buf
+ }
+ return new Uint8Array(nodeRandomBytes(n))
+}
+
+const _wc: Record<string, boolean | undefined> = {}
+
+async function wcSupports(alg: string): Promise<boolean> {
+ if (typeof _wc[alg] === 'boolean') return _wc[alg]!
+ if (!subtle) return (_wc[alg] = false)
+ try {
+ switch (alg) {
+ case 'Ed25519':
+ await subtle.generateKey({ name: 'Ed25519' } as EcKeyGenParams, false, ['sign', 'verify'])
+ break
+ case 'X25519':
+ await subtle.generateKey({ name: 'X25519' } as EcKeyGenParams, false, ['deriveBits'])
+ break
+ case 'AES-GCM':
+ await subtle.generateKey({ name: 'AES-GCM', length: 256 } as AesKeyGenParams, false, ['encrypt', 'decrypt'])
+ break
+ case 'HKDF':
+ await subtle.importKey('raw', new ArrayBuffer(32), { name: 'HKDF' }, false, ['deriveBits'])
+ break
+ case 'PBKDF2':
+ await subtle.importKey('raw', toArrayBuffer(encode('probe')), { name: 'PBKDF2' }, false, ['deriveBits'])
+ break
+ default:
+ return (_wc[alg] = false)
+ }
+ return (_wc[alg] = true)
+ } catch {
+ return (_wc[alg] = false)
+ }
+}
+
+function toB64url(bytes: Uint8Array): string {
+ let s = ''
+ for (const b of bytes) s += String.fromCharCode(b)
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
+}
+
+function fromB64url(b64u: string): Uint8Array {
+ const b64 = b64u.replace(/-/g, '+').replace(/_/g, '/')
+ const padded = b64.padEnd(b64.length + ((4 - (b64.length % 4)) % 4), '=')
+ const bin = atob(padded)
+ return Uint8Array.from({ length: bin.length }, (_, i) => bin.charCodeAt(i))
+}
+
+export interface KeyPair {
+ privateKey: string
+ publicKey: string
+}
+
+export async function generateSigningKeyPair(): Promise<KeyPair> {
+ if (await wcSupports('Ed25519')) {
+ const kp = await subtle!.generateKey({ name: 'Ed25519' } as EcKeyGenParams, true, ['sign', 'verify']) as CryptoKeyPair
+ const privJwk = await subtle!.exportKey('jwk', kp.privateKey)
+ const pubJwk = await subtle!.exportKey('jwk', kp.publicKey)
+ return {
+ privateKey: toB64(fromB64url(privJwk.d!)),
+ publicKey: toB64(fromB64url(pubJwk.x!)),
+ }
+ }
+ const priv = ed25519.utils.randomSecretKey()
+ return { privateKey: toB64(priv), publicKey: toB64(ed25519.getPublicKey(priv)) }
+}
+
+export async function sign(privateKeyHex: string, message: Uint8Array): Promise<string> {
+ const priv = fromB64(privateKeyHex)
+ const pub = ed25519.getPublicKey(priv)
+
+ if (await wcSupports('Ed25519')) {
+ const key = await subtle!.importKey(
+ 'jwk',
+ { kty: 'OKP', crv: 'Ed25519', d: toB64url(priv), x: toB64url(pub), key_ops: ['sign'] } as JsonWebKey,
+ { name: 'Ed25519' } as EcKeyImportParams,
+ false,
+ ['sign']
+ )
+ const sig = await subtle!.sign({ name: 'Ed25519' } as Algorithm, key, toArrayBuffer(message))
+ return toB64(new Uint8Array(sig))
+ }
+
+ return toB64(ed25519.sign(message, priv))
+}
+
+export async function verify(
+ publicKeyHex: string,
+ message: Uint8Array,
+ signatureHex: string
+): Promise<boolean> {
+ const pub = fromB64(publicKeyHex)
+ const sig = fromB64(signatureHex)
+
+ if (await wcSupports('Ed25519')) {
+ const key = await subtle!.importKey(
+ 'jwk',
+ { kty: 'OKP', crv: 'Ed25519', x: toB64url(pub), key_ops: ['verify'] } as JsonWebKey,
+ { name: 'Ed25519' } as EcKeyImportParams,
+ false,
+ ['verify']
+ )
+ return subtle!.verify({ name: 'Ed25519' } as Algorithm, key, toArrayBuffer(sig), toArrayBuffer(message))
+ }
+
+ try { return ed25519.verify(sig, message, pub) } catch { return false }
+}
+
+const ASYM_INFO = encode('x25519-aes256gcm-v1')
+const IV_LEN = 12
+const TAG_LEN = 16
+
+export async function generateEncryptionKeyPair(): Promise<KeyPair> {
+ if (await wcSupports('X25519')) {
+ const kp = await subtle!.generateKey({ name: 'X25519' } as EcKeyGenParams, true, ['deriveBits']) as CryptoKeyPair
+ const privJwk = await subtle!.exportKey('jwk', kp.privateKey)
+ const pubJwk = await subtle!.exportKey('jwk', kp.publicKey)
+ return {
+ privateKey: toB64(fromB64url(privJwk.d!)),
+ publicKey: toB64(fromB64url(pubJwk.x!)),
+ }
+ }
+ const priv = x25519.utils.randomSecretKey()
+ return { privateKey: toB64(priv), publicKey: toB64(x25519.getPublicKey(priv)) }
+}
+
+async function dhSharedSecret(priv: Uint8Array, peerPub: Uint8Array): Promise<Uint8Array> {
+ if (await wcSupports('X25519')) {
+ const myPub = x25519.getPublicKey(priv)
+ const privKey = await subtle!.importKey(
+ 'jwk',
+ { kty: 'OKP', crv: 'X25519', d: toB64url(priv), x: toB64url(myPub) } as JsonWebKey,
+ { name: 'X25519' } as EcKeyImportParams,
+ false,
+ ['deriveBits']
+ )
+ const pubKey = await subtle!.importKey(
+ 'jwk',
+ { kty: 'OKP', crv: 'X25519', x: toB64url(peerPub) } as JsonWebKey,
+ { name: 'X25519' } as EcKeyImportParams,
+ false,
+ []
+ )
+ return new Uint8Array(
+ await subtle!.deriveBits({ name: 'X25519', public: pubKey } as EcdhKeyDeriveParams, privKey, 256)
+ )
+ }
+ return x25519.getSharedSecret(priv, peerPub)
+}
+
+async function hkdfDerive(ikm: Uint8Array, salt: Uint8Array, info: Uint8Array, outLen = 32): Promise<Uint8Array> {
+ if (await wcSupports('HKDF')) {
+ const ikmKey = await subtle!.importKey('raw', toArrayBuffer(ikm), { name: 'HKDF' }, false, ['deriveBits'])
+ return new Uint8Array(
+ await subtle!.deriveBits(
+ { name: 'HKDF', hash: 'SHA-256', salt: toArrayBuffer(salt), info: toArrayBuffer(info) } as HkdfParams,
+ ikmKey,
+ outLen * 8
+ )
+ )
+ }
+ return hkdf(sha256, ikm, salt, info, outLen)
+}
+
+export async function encryptAsymmetric(recipientPublicKeyHex: string, plaintext: Uint8Array): Promise<string> {
+ const recipientPub = fromB64(recipientPublicKeyHex)
+
+ let ephPriv: Uint8Array, ephPub: Uint8Array
+ if (await wcSupports('X25519')) {
+ const kp = await subtle!.generateKey({ name: 'X25519' } as EcKeyGenParams, true, ['deriveBits']) as CryptoKeyPair
+ const privJwk = await subtle!.exportKey('jwk', kp.privateKey)
+ const pubJwk = await subtle!.exportKey('jwk', kp.publicKey)
+ ephPriv = fromB64url(privJwk.d!)
+ ephPub = fromB64url(pubJwk.x!)
+ } else {
+ ephPriv = x25519.utils.randomSecretKey()
+ ephPub = x25519.getPublicKey(ephPriv)
+ }
+
+ const shared = await dhSharedSecret(ephPriv, recipientPub)
+ const aesKey = await hkdfDerive(shared, ephPub, ASYM_INFO, 32)
+ const iv = randomBytes(IV_LEN)
+ const enc = await aesEncrypt(aesKey, iv, plaintext)
+
+ return toB64(concat(ephPub, iv, enc))
+}
+
+export async function decryptAsymmetric(privateKeyHex: string, ciphertextHex: string): Promise<Uint8Array> {
+ const priv = fromB64(privateKeyHex)
+ const packed = fromB64(ciphertextHex)
+ const minLen = 32 + IV_LEN + TAG_LEN
+ if (packed.length < minLen)
+ throw new Error(`Ciphertext too short: ${packed.length} < ${minLen} bytes`)
+
+ let off = 0
+ const ephPub = packed.slice(off, (off += 32))
+ const iv = packed.slice(off, (off += IV_LEN))
+ const enc = packed.slice(off)
+
+ const shared = await dhSharedSecret(priv, ephPub)
+ const aesKey = await hkdfDerive(shared, ephPub, ASYM_INFO, 32)
+ return aesDecrypt(aesKey, iv, enc)
+}
+
+export async function generateSymmetricKey(): Promise<string> {
+ if (await wcSupports('AES-GCM')) {
+ const key = await subtle!.generateKey({ name: 'AES-GCM', length: 256 } as AesKeyGenParams, true, ['encrypt', 'decrypt']) as CryptoKey
+ return toB64(new Uint8Array(await subtle!.exportKey('raw', key)))
+ }
+ return toB64(randomBytes(32))
+}
+
+export async function encryptSymmetric(keyHex: string, plaintext: Uint8Array): Promise<string> {
+ const key = fromB64(keyHex)
+ if (key.length !== 32) throw new Error('AES key must be 32 bytes (64 hex chars)')
+ const iv = randomBytes(IV_LEN)
+ return toB64(concat(iv, await aesEncrypt(key, iv, plaintext)))
+}
+
+export async function decryptSymmetric(keyHex: string, ciphertextHex: string): Promise<Uint8Array> {
+ const key = fromB64(keyHex)
+ if (key.length !== 32) throw new Error('AES key must be 32 bytes (64 hex chars)')
+ const packed = fromB64(ciphertextHex)
+ if (packed.length < IV_LEN + TAG_LEN)
+ throw new Error(`Ciphertext too short: ${packed.length} < ${IV_LEN + TAG_LEN} bytes`)
+ return aesDecrypt(key, packed.slice(0, IV_LEN), packed.slice(IV_LEN))
+}
+
+export interface DerivedKey {
+ key: string
+ salt: string
+}
+
+export async function deriveKeyFromPassword(
+ password: string,
+ saltHex?: string,
+ iterations = 310_000
+): Promise<DerivedKey> {
+ const salt = saltHex ? fromB64(saltHex) : randomBytes(16)
+ const pass = encode(password)
+ let keyBytes: Uint8Array
+
+ if (await wcSupports('PBKDF2')) {
+ const baseKey = await subtle!.importKey('raw', toArrayBuffer(pass), { name: 'PBKDF2' }, false, ['deriveBits'])
+ keyBytes = new Uint8Array(
+ await subtle!.deriveBits(
+ { name: 'PBKDF2', hash: 'SHA-256', salt: toArrayBuffer(salt), iterations } as Pbkdf2Params,
+ baseKey,
+ 256
+ )
+ )
+ } else {
+ keyBytes = pbkdf2(sha256, pass, salt, { c: iterations, dkLen: 32 })
+ }
+ return { key: toB64(keyBytes), salt: toB64(salt) }
+}
+
+export async function deriveKeyHKDF(ikmHex: string, info: string, saltHex?: string): Promise<string> {
+ const ikm = fromB64(ikmHex)
+ const salt = saltHex ? fromB64(saltHex) : new Uint8Array(32)
+ return toB64(await hkdfDerive(ikm, salt, encode(info), 32))
+}
+
+async function aesEncrypt(key: Uint8Array, iv: Uint8Array, plaintext: Uint8Array): Promise<Uint8Array> {
+ if (await wcSupports('AES-GCM')) {
+ const ck = await subtle!.importKey('raw', toArrayBuffer(key), 'AES-GCM', false, ['encrypt'])
+ return new Uint8Array(
+ await subtle!.encrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) } as AesGcmParams, ck, toArrayBuffer(plaintext))
+ )
+ }
+ return gcm(key, iv).encrypt(plaintext)
+}
+
+async function aesDecrypt(key: Uint8Array, iv: Uint8Array, ct: Uint8Array): Promise<Uint8Array> {
+ if (await wcSupports('AES-GCM')) {
+ const ck = await subtle!.importKey('raw', toArrayBuffer(key), 'AES-GCM', false, ['decrypt'])
+ return new Uint8Array(
+ await subtle!.decrypt({ name: 'AES-GCM', iv: toArrayBuffer(iv) } as AesGcmParams, ck, toArrayBuffer(ct))
+ )
+ }
+ return gcm(key, iv).decrypt(ct)
+} \ No newline at end of file
diff --git a/client/public/index.html b/client/public/index.html
new file mode 100644
index 0000000..5ad6a1a
--- /dev/null
+++ b/client/public/index.html
@@ -0,0 +1,77 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>chatcast</title>
+ <link href="https://fonts.googleapis.com/css2?family=Berkeley+Mono:ital@0;1&family=Syne:wght@400;700&display=swap"
+ rel="stylesheet">
+ <link href="style.css" rel="stylesheet">
+</head>
+
+<body>
+
+ <div class="header">
+ <button id="sidebar-toggle" aria-label="Toggle sidebar" aria-expanded="false">
+ <svg viewBox="0 0 13 11" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
+ <rect y="0" width="13" height="1.3" rx="0.65" fill="currentColor" />
+ <rect y="4.85" width="13" height="1.3" rx="0.65" fill="currentColor" />
+ <rect y="9.7" width="13" height="1.3" rx="0.65" fill="currentColor" />
+ </svg>
+ </button>
+ <span class="header-text">chatcast</span>
+ <span class="header-badge"><span class="dot dot-live"></span><text class="status">idle</text></span>
+ </div>
+
+ <div id="sidebar-backdrop"></div>
+
+ <div class="layout">
+
+ <sidebar id="sidebar">
+ <s-head>
+ channels
+ <button id="sidebar-close" aria-label="close sidebar">โœ•</button>
+ </s-head>
+
+ <s-body>
+ <s-item archived>file transfers</s-item>
+
+ <s-cat>channels</s-cat>
+ <s-channels>
+ </s-channels>
+
+ <s-cat>direct messages</s-cat>
+ <s-directs>
+ </s-directs>
+ </s-body>
+
+ <s-foot>
+ <s-avatar></s-avatar>
+ <s-user>
+ <s-name>???</s-name>
+ <s-status>it's... you!</s-status>
+ </s-user>
+ </s-foot>
+ </sidebar>
+
+ <div class="main">
+ <container></container>
+ <box>
+ <button class="btn btn-primary file-btn">๐Ÿ“</button>
+ <input type="text" placeholder="type a message...">
+ <button class="send-btn">send โ†ต</button>
+ </box>
+ </div>
+
+ </div>
+
+ <div class="modal-backdrop" id="modal-backdrop">
+ <div class="modal" id="modal-el" role="dialog" aria-modal="true" tabindex="-1"></div>
+ </div>
+
+ <script src="chat.ts"></script>
+ <script src="stuff.ts"></script>
+</body>
+
+</html> \ No newline at end of file
diff --git a/client/public/stuff.ts b/client/public/stuff.ts
new file mode 100644
index 0000000..5d7e882
--- /dev/null
+++ b/client/public/stuff.ts
@@ -0,0 +1,58 @@
+export const sidebar = document.getElementById('sidebar')!
+export const backdrop = document.getElementById('sidebar-backdrop')!
+export const toggle = document.getElementById('sidebar-toggle')!
+export const close = document.getElementById('sidebar-close')!
+
+export const openSidebar = () => {
+ sidebar.classList.add('open')
+ backdrop.classList.add('open')
+ toggle.setAttribute('aria-expanded', 'true')
+ document.body.style.overflow = 'hidden'
+}
+export const closeSidebar = () => {
+ sidebar.classList.remove('open')
+ backdrop.classList.remove('open')
+ toggle.setAttribute('aria-expanded', 'false')
+ document.body.style.overflow = ''
+}
+
+toggle.addEventListener('click', () => sidebar.classList.contains('open') ? closeSidebar() : openSidebar())
+close.addEventListener('click', closeSidebar)
+backdrop.addEventListener('click', closeSidebar)
+document.addEventListener('keydown', e => { if (e.key === 'Escape') closeSidebar() })
+window.addEventListener('resize', () => { if (window.innerWidth > 680) closeSidebar() })
+
+document.querySelectorAll('s-item').forEach(item => {
+ item.addEventListener('click', function () {
+ document.querySelectorAll('s-item').forEach(i => i.removeAttribute('active'))
+ //@ts-expect-error
+ this.setAttribute('active', '')
+ if (window.innerWidth <= 680) closeSidebar()
+ })
+})
+
+export const showModal = () => {
+ document.getElementById('modal-backdrop')!.classList.add('modal-open')
+ document.getElementById('modal-el')!.focus()
+}
+export const hideModal = () => { document.getElementById('modal-backdrop')!.classList.remove('modal-open') }
+export const setModalHTML = (html: string) => {
+ document.getElementById('modal-el')!.innerHTML = html
+ showModal()
+}
+export const getModal = () => document.getElementById('modal-el')
+
+// document.getElementById('modal-backdrop')!.addEventListener('click', e => { if (e.target === document.getElementById('modal-backdrop')) hideModal() })
+
+export const createPillHTML = (label: string, html: string, fullWidth = false) =>
+ `<pill${fullWidth ? ' full' : ''}><p>${label}</p><elm>${html}</elm></pill>`
+
+document.addEventListener('DOMContentLoaded', () => {
+ document.querySelectorAll('input[type="range"]').forEach(slider => {
+ const valEl = slider.parentElement?.querySelector('val')
+ if (valEl) {
+ valEl.textContent = (slider as any).value
+ slider.addEventListener('input', () => valEl.textContent = (slider as any).value)
+ }
+ })
+}) \ No newline at end of file
diff --git a/client/public/style.css b/client/public/style.css
new file mode 100644
index 0000000..f2cbd93
--- /dev/null
+++ b/client/public/style.css
@@ -0,0 +1,1164 @@
+:root {
+ --bg: #0c0b0a;
+ --surface: #141311;
+ --surface-raise: #1a1815;
+ --border: #221f1c;
+ --border-bright: #312d29;
+ --fg: #f5f0ea;
+ --fg-dim: #6e6760;
+ --fg-dimmer: #2e2a26;
+ --in-color: #ff2eb8;
+ --in-glow: rgba(255, 46, 184, .10);
+ --out-color: #39ff7a;
+ --out-glow: rgba(57, 255, 122, .10);
+ --all-color: #ffcc00;
+ --all-glow: rgba(255, 204, 0, .10);
+ --accent: #ff5533;
+ --radius: 6px;
+ --font-mono: 'Berkeley Mono', 'Courier New', monospace;
+ --font-display: 'Syne', sans-serif;
+}
+
+*,
+*::before,
+*::after {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+}
+
+html,
+body {
+ background: var(--bg);
+ color: var(--fg);
+ font-family: var(--font-mono);
+ font-size: 13px;
+ line-height: 1.6;
+ height: 100%;
+}
+
+body {
+ display: flex;
+ flex-direction: column;
+ min-height: 100vh;
+}
+
+body::before {
+ content: '';
+ position: fixed;
+ inset: 0;
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.02'/%3E%3C/svg%3E");
+ pointer-events: none;
+ z-index: 0;
+}
+
+.header {
+ padding: 18px 24px 14px;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ border-bottom: 1px solid var(--border);
+ position: relative;
+ z-index: 400;
+}
+
+.header-text {
+ font-family: var(--font-mono);
+ font-weight: 700;
+ font-size: 15px;
+ letter-spacing: .08em;
+ text-transform: lowercase;
+}
+
+.header-badge {
+ margin-left: auto;
+ font-size: 10px;
+ color: var(--fg-dim);
+ letter-spacing: .12em;
+ display: flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.header-badge span {
+ display: inline-block;
+ width: 7px;
+ height: 7px;
+ border-radius: 50%;
+}
+
+.dot-live {
+ background: #ff5533;
+ box-shadow: 0 0 7px #ff5533;
+ animation: pulse 2s ease-in-out infinite;
+}
+
+.dot-connecting {
+ background: var(--all-color);
+ box-shadow: 0 0 7px var(--all-color);
+ animation: pulse .8s ease-in-out infinite;
+}
+
+.dot-initializing {
+ background: var(--in-color);
+ box-shadow: 0 0 7px var(--in-color);
+ animation: pulse 1.2s ease-in-out infinite;
+}
+
+.dot-disconnected {
+ background: #cc2233;
+ box-shadow: 0 0 7px #cc2233;
+}
+
+@keyframes pulse {
+
+ 0%,
+ 100% {
+ opacity: 1
+ }
+
+ 50% {
+ opacity: .3
+ }
+}
+
+#sidebar-toggle {
+ display: none;
+ align-items: center;
+ justify-content: center;
+ width: 30px;
+ height: 30px;
+ background: transparent;
+ border: none;
+ border-radius: var(--radius);
+ color: var(--fg-dim);
+ cursor: pointer;
+ flex-shrink: 0;
+ transition: color .15s, background .15s;
+}
+
+#sidebar-toggle:hover {
+ background: rgba(255, 255, 255, .05);
+ color: var(--fg);
+}
+
+#sidebar-toggle svg {
+ width: 13px;
+ height: 13px;
+ display: block;
+}
+
+.layout {
+ display: flex;
+ flex: 1;
+ min-height: 0;
+ overflow: hidden;
+ position: relative;
+}
+
+sidebar {
+ width: 22%;
+ min-width: 200px;
+ max-width: 280px;
+ background: var(--surface);
+ border-right: 1px solid var(--border);
+ display: flex;
+ flex-direction: column;
+ flex-shrink: 0;
+ overflow: hidden;
+ position: relative;
+ z-index: 503 !important;
+}
+
+sidebar::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: linear-gradient(90deg, transparent, rgba(255, 85, 51, .22) 50%, transparent);
+ pointer-events: none;
+}
+
+s-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 13px 14px 10px;
+ border-bottom: 1px solid var(--border);
+ flex-shrink: 0;
+ font-size: 10px;
+ letter-spacing: .16em;
+ text-transform: uppercase;
+ color: var(--fg-dim);
+}
+
+s-head button {
+ display: none;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ color: var(--fg-dim);
+ font-size: 13px;
+ cursor: pointer;
+ padding: 2px 6px;
+ line-height: 1;
+ transition: color .15s, border-color .15s, background .15s;
+}
+
+s-head button:hover {
+ color: var(--fg);
+ border-color: var(--border-bright);
+ background: rgba(255, 255, 255, .04);
+}
+
+s-body {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ overflow-y: auto;
+ padding: 10px 8px;
+ gap: 2px;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-bright) transparent;
+ padding-top: 20px;
+}
+
+s-body::-webkit-scrollbar {
+ width: 3px;
+}
+
+s-body::-webkit-scrollbar-thumb {
+ background: var(--border-bright);
+ border-radius: 2px;
+}
+
+s-cat {
+ display: block;
+ font-size: 9px;
+ letter-spacing: .18em;
+ text-transform: uppercase;
+ color: var(--fg-dimmer);
+ padding: 10px 8px 3px;
+}
+
+s-cat:first-child {
+ padding-top: 3px;
+}
+
+s-item {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 10px;
+ border-radius: var(--radius);
+ border: 1px solid transparent;
+ cursor: pointer;
+ color: var(--fg-dim);
+ font-size: 12px;
+ transition: background .12s, border-color .12s, color .12s;
+ user-select: none;
+ position: relative;
+}
+
+s-item::before {
+ font-size: 11px;
+ opacity: .55;
+ flex-shrink: 0;
+ width: 12px;
+ text-align: center;
+ transition: opacity .12s;
+}
+
+s-item[channel]::before {
+ content: '#';
+}
+
+s-item[dm]::before {
+ content: '@';
+}
+
+s-item[archived]::before {
+ content: 'ยท';
+}
+
+s-item[badge]::after {
+ content: attr(badge);
+ margin-left: auto;
+ font-size: 9px;
+ padding: 1px 6px;
+ border-radius: 10px;
+ background: rgba(255, 85, 51, .15);
+ border: 1px solid rgba(255, 85, 51, .25);
+ color: var(--accent);
+ text-shadow: 0 0 6px rgba(255, 85, 51, .4);
+ flex-shrink: 0;
+ align-self: center;
+}
+
+s-item:hover {
+ background: rgba(255, 255, 255, .03);
+ border-color: var(--border);
+ color: var(--fg);
+}
+
+s-item:hover::before {
+ opacity: 1;
+}
+
+s-item[active] {
+ background: rgba(255, 85, 51, .07);
+ border-color: rgba(255, 85, 51, .18);
+ color: var(--fg);
+}
+
+s-item[active]::before {
+ opacity: 1;
+ color: var(--accent);
+ text-shadow: 0 0 8px rgba(255, 85, 51, .6);
+}
+
+/* s-item[archived] {
+ opacity: .5;
+} */
+
+s-dot {
+ display: inline-block;
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ flex-shrink: 0;
+ margin-left: auto;
+ margin-right: 7px;
+ align-self: center;
+}
+
+s-dot[online] {
+ background: var(--out-color);
+ box-shadow: 0 0 5px rgba(57, 255, 122, .7);
+}
+
+s-dot[away] {
+ background: var(--all-color);
+ box-shadow: 0 0 5px rgba(255, 204, 0, .7);
+}
+
+s-dot[offline] {
+ background: var(--fg-dimmer);
+}
+
+s-foot {
+ display: flex;
+ align-items: center;
+ gap: 9px;
+ padding: 10px 12px;
+ border-top: 1px solid var(--border);
+ flex-shrink: 0;
+}
+
+s-avatar {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 28px;
+ border-radius: 5px;
+ background: linear-gradient(135deg, rgba(255, 85, 51, .3), rgba(255, 46, 184, .3));
+ border: 1px solid rgba(255, 85, 51, .2);
+ flex-shrink: 0;
+ font-size: 11px;
+ color: var(--accent);
+ text-shadow: 0 0 8px rgba(255, 85, 51, .6);
+
+ transform: scale(80%);
+}
+
+s-user {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+}
+
+s-name {
+ display: block;
+ font-size: 12px;
+ color: var(--fg);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+s-status {
+ display: block;
+ font-size: 10px;
+ color: var(--fg-dim);
+ letter-spacing: .06em;
+}
+
+.main {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+}
+
+container {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 16px;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-bright) transparent;
+}
+
+container::-webkit-scrollbar {
+ width: 4px;
+}
+
+container::-webkit-scrollbar-thumb {
+ background: var(--border-bright);
+ border-radius: 2px;
+}
+
+box {
+ flex-shrink: 0;
+ padding: 12px 16px 16px;
+ border-top: 1px solid var(--border);
+ display: flex;
+ gap: 8px;
+ align-items: center;
+}
+
+.msg-row {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ align-self: flex-start;
+ max-width: 85%;
+ animation: slideIn .2s ease-out both;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateY(5px)
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0)
+ }
+}
+
+m-in,
+m-out,
+m-all,
+m-system {
+ display: block;
+ padding: 9px 13px;
+ border-radius: var(--radius);
+ font-size: 13px;
+ line-height: 1.55;
+ border: 1px solid transparent;
+ transition: background .15s, border-color .15s;
+}
+
+m-in {
+ color: var(--in-color);
+ background: var(--in-glow);
+ border-color: rgba(255, 46, 184, .12);
+ text-shadow: 0 0 14px rgba(255, 46, 184, .55);
+}
+
+m-in:hover {
+ background: rgba(255, 46, 184, .15);
+ border-color: rgba(255, 46, 184, .25);
+}
+
+m-out {
+ color: var(--out-color);
+ background: var(--out-glow);
+ border-color: rgba(57, 255, 122, .12);
+ text-shadow: 0 0 14px rgba(57, 255, 122, .55);
+}
+
+m-out:hover {
+ background: rgba(57, 255, 122, .15);
+ border-color: rgba(57, 255, 122, .25);
+}
+
+m-all {
+ color: var(--all-color);
+ background: var(--all-glow);
+ border-color: rgba(255, 204, 0, .12);
+ border-style: dashed;
+ text-shadow: 0 0 14px rgba(255, 204, 0, .55);
+}
+
+m-all:hover {
+ background: rgba(255, 204, 0, .15);
+ border-color: rgba(255, 204, 0, .28);
+}
+
+m-system {
+ color: var(--fg-dim);
+ background: rgba(110, 103, 96, .07);
+ border-color: rgba(110, 103, 96, .10);
+}
+
+m-system:hover {
+ background: rgba(110, 103, 96, .12);
+ border-color: rgba(110, 103, 96, .20);
+}
+
+.msg-prefix {
+ opacity: .45;
+ font-size: 11px;
+ margin-right: 3px;
+}
+
+blob {
+ display: block;
+ width: 38px;
+ height: 38px;
+ min-width: 38px;
+ border-radius: var(--radius);
+ /* border: 1px solid red; */
+ overflow: hidden;
+ flex-shrink: 0;
+}
+
+/* .msg-row:has(m-in) blob {
+ border-color: rgba(255, 46, 184, .15);
+}
+
+.msg-row:has(m-out) blob {
+ border-color: rgba(57, 255, 122, .15);
+}
+
+.msg-row:has(m-all) blob {
+ border-color: rgba(255, 204, 0, .15);
+ border-style: dashed;
+} */
+
+h1,
+h2,
+h3,
+h4,
+h5,
+h6 {
+ font-family: var(--font-mono);
+ font-weight: 700;
+ line-height: 1.2;
+ letter-spacing: .04em;
+ color: var(--fg);
+}
+
+h1 {
+ font-size: 28px;
+ letter-spacing: .06em;
+ text-transform: lowercase;
+}
+
+h2 {
+ font-size: 20px;
+ letter-spacing: .05em;
+ text-transform: lowercase;
+}
+
+h3 {
+ font-size: 16px;
+}
+
+h4 {
+ font-size: 14px;
+ font-family: var(--font-mono);
+ letter-spacing: .08em;
+ text-transform: uppercase;
+ color: var(--fg-dim);
+}
+
+h5 {
+ font-size: 12px;
+ font-family: var(--font-mono);
+ letter-spacing: .10em;
+ text-transform: uppercase;
+ color: var(--fg-dim);
+}
+
+h6 {
+ font-size: 11px;
+ font-family: var(--font-mono);
+ letter-spacing: .12em;
+ text-transform: uppercase;
+ color: var(--fg-dimmer);
+}
+
+h1::after,
+h2::after {
+ content: '';
+ display: block;
+ margin-top: 6px;
+ height: 1px;
+ width: 2em;
+ background: var(--accent);
+ box-shadow: 0 0 8px var(--accent);
+}
+
+strong,
+b {
+ font-weight: 700;
+ color: var(--fg);
+ text-shadow: 0 0 10px rgba(245, 240, 234, .18);
+}
+
+a {
+ color: var(--accent);
+ text-decoration: none;
+ border-bottom: 1px dashed rgba(255, 85, 51, .4);
+ transition: color .15s, border-color .15s, text-shadow .15s;
+}
+
+a:hover {
+ color: #ff7755;
+ border-color: rgba(255, 119, 85, .7);
+ text-shadow: 0 0 10px rgba(255, 85, 51, .5);
+}
+
+a:visited {
+ color: #cc4422;
+ border-color: rgba(204, 68, 34, .4);
+}
+
+.btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ letter-spacing: .06em;
+ padding: 8px 16px;
+ border-radius: var(--radius);
+ border: 1px solid transparent;
+ cursor: pointer;
+ transition: background .15s, border-color .15s, transform .1s, box-shadow .15s;
+ white-space: nowrap;
+ user-select: none;
+ text-decoration: none;
+}
+
+.btn:active {
+ transform: scale(.97);
+}
+
+.btn:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.btn:disabled {
+ opacity: .32;
+ cursor: not-allowed;
+ pointer-events: none;
+ filter: grayscale(.5);
+}
+
+.btn-primary {
+ background: rgba(255, 85, 51, .12);
+ border-color: rgba(255, 85, 51, .4);
+ color: #ff7755;
+ text-shadow: 0 0 8px rgba(255, 85, 51, .5);
+}
+
+.btn-secondary {
+ background: rgba(49, 45, 41, .5);
+ border-color: var(--border-bright);
+ color: var(--fg-dim);
+}
+
+.btn-danger {
+ background: rgba(255, 34, 68, .10);
+ border-color: rgba(255, 34, 68, .35);
+ color: #ff4466;
+ text-shadow: 0 0 8px rgba(255, 34, 68, .4);
+}
+
+.btn-warn {
+ background: rgba(255, 170, 0, .10);
+ border-color: rgba(255, 170, 0, .35);
+ color: #ffbb33;
+ text-shadow: 0 0 8px rgba(255, 170, 0, .4);
+}
+
+.btn-success {
+ background: rgba(57, 255, 122, .08);
+ border-color: rgba(57, 255, 122, .30);
+ color: var(--out-color);
+ text-shadow: 0 0 8px rgba(57, 255, 122, .4);
+}
+
+.btn-primary:hover {
+ background: rgba(255, 85, 51, .22);
+ border-color: rgba(255, 85, 51, .7);
+ box-shadow: 0 0 12px rgba(255, 85, 51, .2);
+}
+
+.btn-secondary:hover {
+ background: rgba(49, 45, 41, .9);
+ border-color: rgba(110, 103, 96, .5);
+ color: var(--fg);
+}
+
+.btn-danger:hover {
+ background: rgba(255, 34, 68, .20);
+ border-color: rgba(255, 34, 68, .65);
+ box-shadow: 0 0 12px rgba(255, 34, 68, .2);
+}
+
+.btn-warn:hover {
+ background: rgba(255, 170, 0, .20);
+ border-color: rgba(255, 170, 0, .60);
+ box-shadow: 0 0 12px rgba(255, 170, 0, .2);
+}
+
+.btn-success:hover {
+ background: rgba(57, 255, 122, .16);
+ border-color: rgba(57, 255, 122, .55);
+ box-shadow: 0 0 12px rgba(57, 255, 122, .2);
+}
+
+.send-btn {
+ background: rgba(255, 85, 51, .08);
+ border: 1px solid rgba(255, 85, 51, .28);
+ border-radius: var(--radius);
+ color: #ff7755;
+ font-family: var(--font-mono);
+ font-size: 12px;
+ letter-spacing: .06em;
+ padding: 9px 16px;
+ cursor: pointer;
+ white-space: nowrap;
+ text-shadow: 0 0 8px rgba(255, 85, 51, .5);
+ transition: background .15s, border-color .15s, transform .1s;
+}
+
+.send-btn:hover {
+ background: rgba(255, 85, 51, .16);
+ border-color: rgba(255, 85, 51, .5);
+}
+
+.send-btn:active {
+ transform: scale(.97);
+}
+
+input[type="text"],
+input[type="email"],
+input[type="password"],
+input[type="search"],
+input[type="number"] {
+ background: var(--surface);
+ border: 1px solid var(--border-bright);
+ border-radius: var(--radius);
+ color: var(--fg);
+ font-family: var(--font-mono);
+ font-size: 13px;
+ padding: 9px 13px;
+ outline: none;
+ width: 100%;
+ transition: border-color .15s, box-shadow .15s, background .15s;
+}
+
+input::placeholder {
+ color: var(--fg-dimmer);
+}
+
+input:focus {
+ border-color: rgba(255, 85, 51, .45);
+ box-shadow: 0 0 0 3px rgba(255, 85, 51, .07);
+ background: var(--surface-raise);
+}
+
+input:disabled {
+ opacity: .35;
+ cursor: not-allowed;
+}
+
+textarea {
+ background: var(--surface);
+ border: 1px solid var(--border-bright);
+ border-radius: var(--radius);
+ color: var(--fg);
+ font-family: var(--font-mono);
+ font-size: 13px;
+ padding: 9px 13px;
+ outline: none;
+ width: 100%;
+ resize: none;
+ min-height: 80px;
+ transition: border-color .15s, box-shadow .15s, background .15s;
+}
+
+textarea::placeholder {
+ color: var(--fg-dimmer);
+}
+
+textarea:focus {
+ border-color: rgba(255, 85, 51, .45);
+ box-shadow: 0 0 0 3px rgba(255, 85, 51, .07);
+ background: var(--surface-raise);
+}
+
+textarea:disabled {
+ opacity: .35;
+ cursor: not-allowed;
+}
+
+field {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+
+field>label {
+ font-size: 10px;
+ letter-spacing: .12em;
+ text-transform: uppercase;
+ color: var(--fg-dim);
+ cursor: default;
+}
+
+label:has(input[type="radio"]),
+label:has(input[type="checkbox"]) {
+ display: inline-flex;
+ align-items: center;
+ gap: 9px;
+ cursor: pointer;
+ font-size: 13px;
+ color: var(--fg-dim);
+ user-select: none;
+ transition: color .15s;
+}
+
+label:has(input[type="radio"]):hover,
+label:has(input[type="checkbox"]):hover,
+label:has(input[type="radio"]:checked),
+label:has(input[type="checkbox"]:checked) {
+ color: var(--fg);
+}
+
+label>input[type="radio"],
+label>input[type="checkbox"] {
+ display: none;
+}
+
+indicator {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ border: 1px solid var(--border-bright);
+ background: var(--surface);
+ flex-shrink: 0;
+ transition: border-color .15s, box-shadow .15s;
+}
+
+indicator::after {
+ content: '';
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--accent);
+ box-shadow: 0 0 6px rgba(255, 85, 51, .8);
+ opacity: 0;
+ transform: scale(0);
+ transition: opacity .15s, transform .15s;
+}
+
+label:has(input[type="checkbox"]) indicator {
+ border-radius: 3px;
+}
+
+label:has(input[type="checkbox"]) indicator::after {
+ width: 5px;
+ height: 5px;
+ border-radius: 1px;
+}
+
+label:has(input[type="radio"]:checked) indicator,
+label:has(input[type="checkbox"]:checked) indicator {
+ border-color: rgba(255, 85, 51, .6);
+ box-shadow: 0 0 0 3px rgba(255, 85, 51, .08);
+}
+
+label:has(input[type="radio"]:checked) indicator::after,
+label:has(input[type="checkbox"]:checked) indicator::after {
+ opacity: 1;
+ transform: scale(1);
+}
+
+row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+val {
+ min-width: 32px;
+ text-align: right;
+ font-size: 11px;
+ color: var(--accent);
+ text-shadow: 0 0 6px rgba(255, 85, 51, .5);
+ letter-spacing: .06em;
+ flex-shrink: 0;
+}
+
+input[type="range"] {
+ -webkit-appearance: none;
+ appearance: none;
+ flex: 1;
+ height: 4px;
+ border-radius: 2px;
+ background: var(--border-bright);
+ outline: none;
+ cursor: pointer;
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--accent);
+ border: 1px solid rgba(255, 85, 51, .5);
+ box-shadow: 0 0 8px rgba(255, 85, 51, .55);
+ transition: box-shadow .15s, transform .1s;
+}
+
+input[type="range"]::-webkit-slider-thumb:active {
+ transform: scale(1.2);
+}
+
+input[type="range"]::-moz-range-thumb {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: var(--accent);
+ border: 1px solid rgba(255, 85, 51, .5);
+ box-shadow: 0 0 8px rgba(255, 85, 51, .55);
+ cursor: pointer;
+}
+
+pill {
+ display: inline-flex;
+ align-items: stretch;
+ border: 1px solid var(--border-bright);
+ border-radius: 40px;
+ overflow: hidden;
+ background: var(--surface);
+ font-size: 12px;
+ transition: border-color .15s;
+}
+
+pill[full] {
+ display: flex;
+ width: 100%;
+}
+
+pill:focus-within {
+ border-color: rgba(255, 85, 51, .4);
+ box-shadow: 0 0 0 3px rgba(255, 85, 51, .07);
+}
+
+pill>p {
+ display: flex;
+ align-items: center;
+ padding: 5px 12px 5px 13px;
+ color: var(--fg-dim);
+ letter-spacing: .08em;
+ font-size: 11px;
+ text-transform: uppercase;
+ border-right: 1px solid var(--border-bright);
+ white-space: nowrap;
+ background: rgba(49, 45, 41, .4);
+ margin: 0;
+}
+
+pill>elm {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 5px 13px 5px 11px;
+ color: var(--fg);
+ flex: 1;
+ min-width: 0;
+}
+
+pill>elm input,
+pill>elm select {
+ border: none;
+ background: transparent;
+ box-shadow: none;
+ padding: 0;
+ margin: 0;
+ font-size: 12px;
+ color: var(--fg);
+ width: 100%;
+ min-width: 0;
+ outline: none;
+}
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(12, 11, 10, .82);
+ backdrop-filter: blur(4px);
+ z-index: 1000;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity .2s ease;
+}
+
+.modal-backdrop.modal-open {
+ opacity: 1;
+ pointer-events: all;
+}
+
+.modal {
+ background: var(--surface);
+ border: 1px solid var(--border-bright);
+ border-radius: 8px;
+ min-width: 340px;
+ max-width: min(560px, calc(100vw - 40px));
+ max-height: calc(100vh - 80px);
+ overflow-y: auto;
+ position: relative;
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, .03), 0 24px 60px rgba(0, 0, 0, .6);
+ transform: translateY(10px) scale(.98);
+ transition: transform .22s ease;
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-bright) transparent;
+
+ padding: 10px;
+}
+
+.modal-backdrop.modal-open .modal {
+ transform: translateY(0) scale(1);
+}
+
+.modal::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 1px;
+ background: linear-gradient(90deg, transparent 10%, rgba(255, 85, 51, .3) 50%, transparent 90%);
+ pointer-events: none;
+}
+
+.modal-header {
+ display: flex;
+ align-items: center;
+ padding: 14px 18px;
+ border-bottom: 1px solid var(--border);
+ gap: 8px;
+}
+
+.modal-title {
+ font-family: var(--font-mono);
+ font-size: 14px;
+ font-weight: 700;
+ letter-spacing: .06em;
+ text-transform: lowercase;
+ flex: 1;
+}
+
+.modal-close {
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 4px;
+ color: var(--fg-dim);
+ font-size: 14px;
+ cursor: pointer;
+ padding: 2px 6px;
+ line-height: 1;
+ transition: color .15s, border-color .15s, background .15s;
+}
+
+.modal-close:hover {
+ color: var(--fg);
+ border-color: var(--border-bright);
+ background: rgba(255, 255, 255, .04);
+}
+
+.modal-body {
+ padding: 18px;
+}
+
+.modal-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: 8px;
+ padding: 14px 18px;
+ border-top: 1px solid var(--border);
+}
+
+#sidebar-backdrop {
+ display: none;
+ position: fixed;
+ inset: 0;
+ background: rgba(12, 11, 10, .8);
+ z-index: 500;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity .25s ease;
+ backdrop-filter: blur(4px);
+}
+
+#sidebar-backdrop.open {
+ opacity: 1;
+ pointer-events: all;
+}
+
+@media (max-width: 680px) {
+ #sidebar-toggle {
+ display: inline-flex;
+ }
+
+ s-head button {
+ display: block;
+ }
+
+ sidebar {
+ position: fixed;
+ top: 0;
+ left: 0;
+ bottom: 0;
+ width: 72% !important;
+ max-width: 320px;
+ transform: translateX(-110%);
+ transition: transform .28s cubic-bezier(.4, 0, .2, 1);
+ box-shadow: 8px 0 40px rgba(0, 0, 0, .5);
+ border-right: 1px solid var(--border-bright);
+ }
+
+ sidebar.open {
+ transform: translateX(0);
+ }
+
+ #sidebar-backdrop {
+ display: block;
+ }
+} \ No newline at end of file
diff --git a/client/server.ts b/client/server.ts
new file mode 100644
index 0000000..f5e5d5a
--- /dev/null
+++ b/client/server.ts
@@ -0,0 +1,8 @@
+import index from './public/index.html'
+
+Bun.serve({
+ port: 3000,
+ routes: {
+ '/': index
+ }
+}) \ No newline at end of file
diff --git a/client/tsconfig.json b/client/tsconfig.json
new file mode 100644
index 0000000..f8b2c4d
--- /dev/null
+++ b/client/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "compilerOptions": {
+ // Environment setup & latest features
+ "lib": ["ESNext", "dom"],
+ "target": "ESNext",
+ "module": "Preserve",
+ "moduleDetection": "force",
+ "jsx": "react-jsx",
+ "allowJs": true,
+
+ // Bundler mode
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "noEmit": true,
+
+ // Best practices
+ "strict": true,
+ "skipLibCheck": true,
+ "noFallthroughCasesInSwitch": true,
+ "noUncheckedIndexedAccess": true,
+ "noImplicitOverride": true,
+
+ // Some stricter flags (disabled by default)
+ "noUnusedLocals": false,
+ "noUnusedParameters": false,
+ "noPropertyAccessFromIndexSignature": false
+ }
+}