From 910953d9bddea43950b0daf13dadf15db848baf5 Mon Sep 17 00:00:00 2001 From: octospacc Date: Mon, 21 Apr 2025 22:46:58 +0200 Subject: [PATCH] Update --- index.html | 377 +++++++++++++++++++++++++++++------------------------ 1 file changed, 204 insertions(+), 173 deletions(-) diff --git a/index.html b/index.html index e04355f..01baf00 100644 --- a/index.html +++ b/index.html @@ -1308,17 +1308,23 @@ render(html`<${App}/>`, document.body); - - - WhichNot: WhatsApp‑style Notes + + + WhichNot @@ -1382,23 +1389,22 @@ import { useState, useEffect, useCallback, useRef, useContext } from 'https://es import htm from 'https://esm.sh/htm'; const html = htm.bind(h), AppContext = createContext(); -const genAESKey = async () => crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt','decrypt']); -const genEd25519 = async () => crypto.subtle.generateKey({ name: 'Ed25519', namedCurve: 'Ed25519' }, true, ['sign','verify']); +const genAESKey = async () => crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); +const genEd25519 = async () => crypto.subtle.generateKey({ name: 'Ed25519', namedCurve: 'Ed25519' }, true, ['sign', 'verify']); const exportJWK = async (key) => btoa(JSON.stringify(await crypto.subtle.exportKey('jwk', key))); -const importJWK = async (b64,alg,usages) => crypto.subtle.importKey('jwk', JSON.parse(atob(b64)), alg, true, usages); - +const importJWK = async (b64, alg, usages) => crypto.subtle.importKey('jwk', JSON.parse(atob(b64)), alg, true, usages); const randBytes = (n=12) => { const b = new Uint8Array(n); crypto.getRandomValues(b); return b; } const bufToB64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); -const b64ToBuf = (str) => Uint8Array.from(atob(str),c=>c.charCodeAt(0)); - -const deriveMsgKey = async (rawKey,salt) => { - const hk = await crypto.subtle.importKey('raw', rawKey, 'HKDF', false, ['deriveKey']); - return crypto.subtle.deriveKey({name:'HKDF',salt,info:new TextEncoder().encode('msg'),hash:'SHA-256'}, hk, {name:'AES-GCM',length:256}, true, ['encrypt','decrypt']); -} +const b64ToBuf = (str) => Uint8Array.from(atob(str), (c => c.charCodeAt(0))); +const deriveMsgKey = async (rawKey, salt) => crypto.subtle.deriveKey( + { name: 'HKDF', salt, info: new TextEncoder().encode('msg'), hash: 'SHA-256' }, + await crypto.subtle.importKey('raw', rawKey, 'HKDF', false, ['deriveKey']), + { name: 'AES-GCM', length: 256 }, + true, ['encrypt', 'decrypt']); const linkify = txt => txt.replace(/(\bhttps?:\/\/[^\s]+)/g,'$1'); const getNotebook = (notebooks, id) => notebooks.find(notebook => (notebook.id === id)); @@ -1406,34 +1412,35 @@ const closedContextMenu = (s) => ({ contextMenu: { ...s.contextMenu, visible: fa function App() { const [state,setState] = useState({ - notebooks:[], encrypted:{}, messages:{}, - selectedNotebook:null, editingMessage:null, - showSettings:false, showAppSettings:false, - createModal:false, crossReplyModal:false, crossReplySource:null, - contextMenu:{visible:false,messageIndex:null,x:0,y:0}, - dateTimeModal:null, replyingTo:null, - searchModal:{visible:false,global:false,query:''}, - scrollToMessage:null, reactionInputFor:null + notebooks: [], encrypted: {}, messages: {}, + selectedNotebook: null, scrollToMessage: null, + showSettings: false, showAppSettings: false, + createModal: false, dateTimeModal: null, + crossReplyModal: false, crossReplySource: null, + contextMenu:{ visible: false, messageIndex: null, x: 0, y: 0 }, + searchModal: { visible: false, global: false, query: '' }, + editingMessage: null, replyingTo: null, reactionInputFor: null, }); const inputRef = useRef(); // Load & decrypt - useEffect(()=>{ + useEffect(() => { const raw=JSON.parse(localStorage.getItem('notebooks')) || [], enc={}, msgs={}; - (async()=>{ - for(const notebook of raw){ - const arr=JSON.parse(localStorage.getItem(`notebook-${notebook.id}`)||'[]'); + (async () => { + for (const notebook of raw) { + const arr=JSON.parse(localStorage.getItem(`notebook-${notebook.id}`)) || []; enc[notebook.id]=arr; - const aes=await importJWK(notebook.aesKeyB64,{name:'AES-GCM'},['encrypt','decrypt']), - rawKey=await crypto.subtle.exportKey('raw',aes), - plain=[]; + const aes = await importJWK(notebook.aesKeyB64,{name:'AES-GCM'},['encrypt','decrypt']), + rawKey = await crypto.subtle.exportKey('raw', aes), + plain = []; for (const e of arr) { - const salt=b64ToBuf(e.salt), iv=b64ToBuf(e.iv), - key=await deriveMsgKey(rawKey,salt), - ct=b64ToBuf(e.ciphertext), - dec=await crypto.subtle.decrypt({name:'AES-GCM',iv},key,ct); - plain.push({...e,content:new TextDecoder().decode(dec)}); + const salt = b64ToBuf(e.salt), + iv = b64ToBuf(e.iv), + key = await deriveMsgKey(rawKey, salt), + ct = b64ToBuf(e.ciphertext), + dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); + plain.push({ ...e, text: new TextDecoder().decode(dec) }); } msgs[notebook.id] = plain; } @@ -1464,17 +1471,20 @@ function App() { return () => document.removeEventListener('click', handler); }, [state.contextMenu.visible]); - // Create notebook - const createNotebook = useCallback(async(type)=>{ + const createNotebook = useCallback(async (type) => { let id = (type === 'local' ? crypto.randomUUID() : prompt('Remote ID:')); if (!id) return; const now=Date.now(), aes=await genAESKey(), ed=await genEd25519(); const aesB64=await exportJWK(aes), privB64=await exportJWK(ed.privateKey), pubB64=await exportJWK(ed.publicKey); - const notebook={ id, name:'New Notebook', emoji:'πŸ“’', - color:'#'+Math.floor(Math.random()*0xFFFFFF).toString(16).padStart(6,'0'), - sourceType:type, description:'', parseMode:'plaintext', - nextMessageId:1, created:now, - aesKeyB64:aesB64, edPrivB64:privB64, edPubB64:pubB64 + const EMOJIS = ['πŸ“’','πŸ““','πŸ“”','πŸ“•','πŸ“–','πŸ“—','πŸ“˜','πŸ“™','πŸ“š','✏️','πŸ“']; + const randomEmoji = () => EMOJIS[Math.floor(Math.random() * EMOJIS.length)]; + const randomColor = () => ('#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0')); + const notebook = { + id, name: `Notebook ${now}`, + emoji: randomEmoji(), color: randomColor(), + sourceType: type, description: '', parseMode: 'plaintext', + nextMessageId: 1, created: now, + aesKeyB64: aesB64, edPrivB64: privB64, edPubB64: pubB64, }; setState(s => ({ ...s, notebooks: [ ...s.notebooks, notebook ], @@ -1482,9 +1492,12 @@ function App() { messages: { ...s.messages, [id]: [] }, createModal: false, })); - if(type==='remote'){ - await fetch(`/notebook/${id}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({publicKey:pubB64})}); - } + // if (type==='remote') { + // await fetch(`/notebook/${id}`, { + // method: 'POST', headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify({ publicKey: pubB64 }), + // }); + // } }, [state.notebooks]); // Persist (encrypt & sync) @@ -1497,7 +1510,7 @@ function App() { for(const m of plainArr){ const salt=randBytes(), iv=randBytes(12), key=await deriveMsgKey(rawKey,salt), - ct=await crypto.subtle.encrypt({name:'AES-GCM',iv},key,new TextEncoder().encode(m.content)); + ct=await crypto.subtle.encrypt({name:'AES-GCM',iv},key,new TextEncoder().encode(m.text)); encArr.push({ id:m.id, salt:bufToB64(salt), iv:bufToB64(iv), ciphertext:bufToB64(ct), timestamp:m.timestamp, edited:m.edited, replyTo:m.replyTo, reactions:m.reactions @@ -1507,15 +1520,15 @@ function App() { encrypted: { ...s.encrypted, [nbId]: encArr }, messages: { ...s.messages, [nbId]: plainArr }, })); - if(notebook.sourceType==='remote'){ - const priv=await importJWK(notebook.edPrivB64,{name:'Ed25519',namedCurve:'Ed25519'},['sign']), - payload=new TextEncoder().encode(JSON.stringify(encArr)), - sig=bufToB64(await crypto.subtle.sign('Ed25519',priv,payload)); - await fetch(`/notebook/${nbId}/sync`,{ - method:'PUT',headers:{'Content-Type':'application/json'}, - body:JSON.stringify({encryptedArr:encArr,signature:sig,publicKey:notebook.edPubB64}) - }); - } + // if (notebook.sourceType==='remote') { + // const priv=await importJWK(notebook.edPrivB64,{name:'Ed25519',namedCurve:'Ed25519'},['sign']), + // payload=new TextEncoder().encode(JSON.stringify(encArr)), + // sig=bufToB64(await crypto.subtle.sign('Ed25519',priv,payload)); + // await fetch(`/notebook/${nbId}/sync`, { + // method: 'PUT', headers: { 'Content-Type': 'application/json' }, + // body: JSON.stringify({ encryptedArr: encArr, signature: sig, publicKey: notebook.edPubB64 }), + // }); + // } }, [state.notebooks]); const addReaction = useCallback(idx => setState(s => ({ ...s, reactionInputFor: idx })), []); @@ -1538,41 +1551,41 @@ function App() { if (state.editingMessage!=null && inputRef.current) { const message = state.messages[state.selectedNotebook]?.[state.editingMessage]; if (message) { - inputRef.current.value = message.content; + inputRef.current.value = message.text; } - console.log(state, message); + //console.log(state, message); } }, [state.editingMessage, state.selectedNotebook, state.messages]); const sendMessage = useCallback(async () => { const nbId = state.selectedNotebook; if (!nbId) return; - const txt = inputRef.current.value.trim(); - if (!txt) return; + const text = inputRef.current.value.trim(); + if (!text) return; const arr = state.messages[nbId] || []; const notebook = getNotebook(state.notebooks, nbId); //console.log(state); - const message = { - id: notebook.nextMessageId, - content: txt, - timestamp: Date.now(), - edited: state.editingMessage!=null, - replyTo: state.replyingTo, - reactions: [], - }; + let message = arr[state.editingMessage]; + if (!message) { + message = { + id: notebook.nextMessageId, + timestamp: Date.now(), + edited: state.editingMessage!=null, + replyTo: state.replyingTo, + reactions: [], + }; + } + message = { ...message, text }; inputRef.current.value = ''; // update nextMessageId if new setState(s => ({ ...s, notebooks: s.notebooks.map(notebook => notebook.id===nbId ? { ...notebook, nextMessageId: (state.editingMessage==null ? notebook.nextMessageId+1 : notebook.nextMessageId) } : notebook ) })); - // build new array - let newArr; - if(state.editingMessage!=null){ - newArr = arr.map((msg,i)=> i===state.editingMessage ? message : msg); - } else { - newArr = [...arr, message]; - } + const newArr = (state.editingMessage!=null + ? arr.map((msg, i) => (i===state.editingMessage ? message : msg)) + : [...arr, message] + ); // reset editing & replying setState( s => ({ ...s, editingMessage: null, replyingTo: null })); await persistMessages(nbId, newArr); @@ -1582,18 +1595,18 @@ function App() { <${AppContext.Provider} value=${{ state, setState, createNotebook, sendMessage, persistMessages, - addReaction, confirmReaction, removeReaction + addReaction, confirmReaction, removeReaction, }}> -
- <${ChatList}/> - <${ChatScreen} inputRef=${inputRef}/> - ${state.createModal && html`<${CreateModal}/>`} - ${state.crossReplyModal && html`<${CrossReplyModal}/>`} - ${state.showSettings && html`<${SettingsModal}/>`} - ${state.showAppSettings && html`<${AppSettingsModal}/>`} - ${state.contextMenu.visible && html`<${ContextMenu}/>`} - ${state.dateTimeModal!==null && html`<${DateTimeModal}/>`} - ${state.searchModal.visible && html`<${SearchModal}/>`} +
+ <${ChatList} /> + <${ChatScreen} inputRef=${inputRef} /> + ${state.createModal && html`<${CreateModal} />`} + ${state.crossReplyModal && html`<${CrossReplyModal} />`} + ${state.showSettings && html`<${SettingsModal} />`} + ${state.showAppSettings && html`<${AppSettingsModal} />`} + ${state.contextMenu.visible && html`<${ContextMenu} />`} + ${state.dateTimeModal!==null && html`<${DateTimeModal} />`} + ${state.searchModal.visible && html`<${SearchModal} />`}
`; @@ -1601,27 +1614,27 @@ function App() { function ChatList() { const {state,setState} = useContext(AppContext); + const sortNotebook = (notebook) => Math.max(notebook.created, ...(state.messages[notebook.id] || []).map(message => message.timestamp)); return html`
- - - + + +
- ${state.notebooks.sort((a,b)=>{ - const ta=Math.max(a.created,...(state.messages[a.id]||[]).map(m=>m.timestamp)); - const tb=Math.max(b.created,...(state.messages[b.id]||[]).map(m=>m.timestamp)); - return tb-ta; - }).map(notebook=>html` + ${state.notebooks.sort((a,b) => (sortNotebook(b) - sortNotebook(a))).map(notebook => html` `)} @@ -1633,7 +1646,7 @@ function ChatScreen({inputRef}) { const { state, setState, sendMessage } = useContext(AppContext); const notebook = getNotebook(state.notebooks, state.selectedNotebook); let messages = state.messages[notebook?.id] || []; - messages = [...messages].sort((a,b)=>a.timestamp-b.timestamp); + messages = [...messages].sort((a,b) => (a.timestamp - b.timestamp)); // Scroll on request useEffect(()=>{ @@ -1646,7 +1659,7 @@ function ChatScreen({inputRef}) { if (!notebook) return null; return html`
-
setState(s=>({...s,showSettings:true}))}> +
setState(s => ({ ...s, showSettings: true }))}>
- ${messages.map((m,i) => html`<${Message} m=${m} i=${i} />`)} + ${messages.map((m,i) => html` + <${Message} m=${m} i=${i} /> + `)}
${state.replyingTo && html`
Replying to: ${ - (state.messages[state.replyingTo.notebookId]||[]).find(x=>x.id===state.replyingTo.id)?.content||'' + (state.messages[state.replyingTo.notebookId] || []).find(x => x.id===state.replyingTo.id)?.text || '' } - +
`}