aboutsummaryrefslogtreecommitdiff
path: root/client/public/chat.ts
diff options
context:
space:
mode:
Diffstat (limited to 'client/public/chat.ts')
-rw-r--r--client/public/chat.ts758
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