diff options
| author | 2026-02-27 23:37:44 +0330 | |
|---|---|---|
| committer | 2026-02-27 23:37:44 +0330 | |
| commit | 8ee3112ca09fd70a50c2159629b40700beb2d5c3 (patch) | |
| tree | 42a8d3e5d7898e9d084f77e1345be270be0f5969 /client/public/chat.ts | |
| parent | small check (diff) | |
first client ver
Diffstat (limited to 'client/public/chat.ts')
| -rw-r--r-- | client/public/chat.ts | 758 |
1 files changed, 758 insertions, 0 deletions
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 |
