From e793b4de907d53a132cff180ba280e3f1e0ebb25 Mon Sep 17 00:00:00 2001 From: octospacc Date: Wed, 23 Apr 2025 00:01:54 +0200 Subject: [PATCH] The update is plentiful this time --- .gitignore | 3 + app.js | 693 ++++++++++++++++++++++++++++++++++++++++++++ icon.png | Bin 0 -> 1412615 bytes index.html | 779 +++++--------------------------------------------- manifest.json | 10 + 5 files changed, 775 insertions(+), 710 deletions(-) create mode 100644 .gitignore create mode 100644 app.js create mode 100644 icon.png create mode 100644 manifest.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd6d1ed --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +localforage.min.js +marked.min.js +preact/ \ No newline at end of file diff --git a/app.js b/app.js new file mode 100644 index 0000000..07363b0 --- /dev/null +++ b/app.js @@ -0,0 +1,693 @@ +window.appMain = () => { + +const html = htm.bind(h); +const AppContext = createContext(); + +localforage.config({ name: "WhichNot" }); +navigator.storage.persist(); + +// marked.use({ renderer: { +// image({ href, title, text }) { // allow embedding any media with ![]() +// title = (title ? ` title="${escapeHtml(title)}"` : ''); +// return ` +// +// ${text} +// `; +// } +// } }); + +const STRINGS = { + "Notebook": { it: "Quaderno" }, + "Copy": { it: "Copia" }, + "Reply": { it: "Rispondi" }, + "Reply in Another Notebook": { it: "Rispondi in un Altro Quaderno" }, + "Reply to": { it: "Risposta a" }, + "Edit": { it: "Modifica" }, + "Edited": { it: "Modificato" }, + "Set Date/Time": { it: "Imposta Data/Ora" }, + "Send": { it: "Invia" }, + "Save": { it: "Salva" }, + "Delete": { it: "Elimina" }, + "Cancel": { it: "Annulla" }, + "Close": { it: "Chiudi" }, + "Name": { it: "Nome" }, + "Color": { it: "Colore" }, + "Description": { it: "Descrizione" }, + "Info/Settings": { it: "Info/Impostazioni" }, + "App Settings": { it: "Impostazioni App" }, + "Export Data": { it: "Esporta Dati" }, + "Import Data": { it: "Importa Dati" }, + "Paste JSON": { it: "Incolla JSON" }, + "Invalid data format": { it: "Formato dati invalido" }, + "Invalid JSON syntax": { it: "Sintassi JSON invalida" }, + "No description": { it: "Nessuna descrizione" }, + "No notes": { it: "Nessuna nota" }, + "Info and Demo": { it: "Info e Demo" }, +}; +STRINGS.get = (name, lang=navigator.language.split('-')[0]) => (STRINGS[name]?.[lang] || STRINGS[name]?.en || name); + +const UNSPECIFIEDS = { + parseMode: "plaintext", +}; +const NOTEBOOKS = { + "WhichNot": { + emoji: "â„šī¸", + description: STRINGS.get('Info and Demo'), + parseMode: "markdown", + readonly: true, + messages: [ + { + text: "**WhichNot is finally released and REAL!!!** BILLIONS MUST ENJOY!!!", + created: "2025-04-20T23:00", + reactions: { "💝": true }, + }, + ], + }, +}; +Object.entries(NOTEBOOKS).forEach(([name, values]) => (NOTEBOOKS[name] = { id: name, name, ...values, messages: values.messages.map((message, id) => ({ id, ...message })) })); + +const uuidv7 = () => { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + const time = BigInt(Date.now()); + bytes[0] = Number((time >> 40n) & 0xffn); + bytes[1] = Number((time >> 32n) & 0xffn); + bytes[2] = Number((time >> 24n) & 0xffn); + bytes[3] = Number((time >> 16n) & 0xffn); + bytes[4] = Number((time >> 8n) & 0xffn); + bytes[5] = Number(time & 0xffn); + bytes[6] = (bytes[6] & 0x0f) | 0x70; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const chars = Array.from(bytes).map(byte => byte.toString(16).padStart(2, '0')); + [10, 8, 6, 4].forEach(pos => chars.splice(pos, 0, '-')); + return chars.join(''); +} +const generateUUID = () => uuidv7(); // crypto.randomUUID(); +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 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) => 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 getAesRawKey = async (aesKeyB64) => await crypto.subtle.exportKey('raw', await importJWK(aesKeyB64, { name: 'AES-GCM' }, ['encrypt','decrypt'])); + +const encryptMessage = async (message, rawKey) => { + const salt = randBytes(); + const iv = randBytes(12); + const key = await deriveMsgKey(rawKey, salt); + const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(message.text)); + const encrypted = { ...message, + salt: bufToB64(salt), + iv: bufToB64(iv), + ciphertext: bufToB64(ct), + }; + delete encrypted.text; + return encrypted; +}; +const decryptMessage = async (encrypted, rawKey) => { + const salt = b64ToBuf(encrypted.salt); + const iv = b64ToBuf(encrypted.iv); + const key = await deriveMsgKey(rawKey, salt); + const ct = b64ToBuf(encrypted.ciphertext); + const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); + return { ...encrypted, text: new TextDecoder().decode(dec) }; +}; + +const escapeHtml = text => { + const node = document.createElement('p'); + node.appendChild(document.createTextNode(text)); + return node.innerHTML; +} +const makeParagraph = text => `

${text.replaceAll('\n', '
')}

` +const linkify = text => text.replace(/(\bhttps?:\/\/[^\s]+)/g, '$1'); +const getFirstLink = html => Object.assign(document.createElement('div'), { innerHTML: html }).querySelector('a[href]')?.getAttribute('href'); +const renderTextMessage = (text, notebook) => { + const parseMode = notebook.parseMode || UNSPECIFIEDS.parseMode; + switch (parseMode) { + case 'plaintext': + return makeParagraph(linkify(escapeHtml(text))); + case 'markdown': + return marked.parse(escapeHtml(text)); + } +}; + +const EMOJIS = ['📒','📓','📔','📕','📖','📗','📘','📙','📚','âœī¸','📝']; +const randomEmoji = () => EMOJIS[Math.floor(Math.random() * EMOJIS.length)]; +const randomColor = () => ('#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0')); + +const closedContextMenu = s => ({ contextMenu: { ...s.contextMenu, visible: false } }); + +function App() { + const [state, setState] = useState({ + notebooks: [], encrypteds: {}, messages: {}, + selectedNotebookId: null, scrollToMessageId: null, + showNotebookSettings: false, showAppSettings: false, + createModal: false, dateTimeModal: null, + crossReplyModal: false, crossReplySource: null, + contextMenu:{ visible: false, messageId: null, x: 0, y: 0 }, + searchModal: { visible: false, global: false, query: '' }, + editingMessage: null, replyingTo: null, reactionInputFor: null, + }); + const messageInputRef = useRef(); + const [loading, setLoading] = useState(true); + + // Load data from storage + useEffect(() => { + (async () => { + const notebooksList = await localforage.getItem('notebooks') || []; + const notebooks = []; + const [messagesStore, encryptedsStore] = [{}, {}]; + await Promise.all(notebooksList.map(async notebook => { + notebooks.push(notebook = await localforage.getItem(`notebooks/${notebook}`)); + const [messages, encrypteds] = [{}, {}]; + const messagesList = await localforage.getItem(`messages/${notebook.id}`); + const rawKey = await getAesRawKey(notebook.aesKeyB64); + await Promise.all(messagesList.map(async messageId => (encrypteds[messageId] = await localforage.getItem(`messages/${notebook.id}/${messageId}`)))); + await Promise.all(Object.values(encrypteds).map(async encrypted => (messages[encrypted.id] = await decryptMessage(encrypted, rawKey)))); + encryptedsStore[notebook.id] = encrypteds; + messagesStore[notebook.id] = messages; + })); + setState(s => ({ ...s, notebooks, encrypteds: encryptedsStore, messages: messagesStore })); + setLoading(false); + })(); + }, []); + + // Persist notebooks meta + useEffect(() => { + if (!loading) { + for (const notebook of state.notebooks) { + localforage.setItem(`notebooks/${notebook.id}`, notebook); + } + localforage.setItem('notebooks', state.notebooks.map(notebook => notebook.id)); + } + }, [state.notebooks]); + + // Persist encrypted store + useEffect(() => { + if (!loading) { + for (const notebookId in state.encrypteds) { + const messages = state.encrypteds[notebookId]; + if (!messages) return; + localforage.setItem(`messages/${notebookId}`, Object.keys(messages)); + for (const message of Object.values(messages)) { + localforage.setItem(`messages/${notebookId}/${message.id}`, message); + } + } + } + }, [state.encrypteds]); + + // Close context on click-away + useEffect(() => { + const handler = ev => { + if (state.contextMenu.visible) { + const menu = document.querySelector('.ContextMenu'); + if (menu && !menu.contains(ev.target)) { + setState(s => ({ ...s, ...closedContextMenu(s) })); + } + } + }; + document.addEventListener('click', handler); + return () => document.removeEventListener('click', handler); + }, [state.contextMenu.visible]); + + const createNotebook = useCallback(async (type) => { + let id = /* (type === 'local' ? */ generateUUID(); /* : prompt('Remote ID:')); */ + // if (!id) return; + const now = Date.now(); + // const ed = await genEd25519(); + const notebook = { + id, name: `${STRINGS.get('Notebook')} ${now}`, description: '', + emoji: randomEmoji(), color: randomColor(), + parseMode: "markdown", // sourceType: type, + nextMessageId: 1, created: now, + aesKeyB64: await exportJWK(await genAESKey()), // edPrivB64: await exportJWK(ed.privateKey), edPubB64: await exportJWK(ed.publicKey), + }; + setState(s => ({ ...s, + notebooks: [ ...s.notebooks, notebook ], + encrypteds: { ...s.encrypteds, [id]: {} }, + 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 }), + // }); + // } + }, [state.notebooks]); + + const getNotebook = useCallback(notebookId => (state.notebooks.find(notebook => (notebook.id === notebookId)) || NOTEBOOKS[notebookId]), [state.notebooks]); + const deleteNotebook = (notebookId) => { + const messagesList = Object.keys(getMessages(notebookId)); + setState(s => ({ ...s, + notebooks: s.notebooks.filter(notebook => (notebook.id !== notebookId)), + messages: { ...s.messages, [notebookId]: undefined }, + encrypteds: { ...s.encrypteds, [notebookId]: undefined }, + })); + localforage.removeItem(`notebooks/${notebookId}`); + localforage.removeItem(`messages/${notebookId}`); + for (const messageId of messagesList) { + localforage.removeItem(`messages/${notebookId}/${messageId}`); + } + }; + + const getMessages = useCallback((notebookId) => (state.messages[notebookId] || NOTEBOOKS[notebookId]?.messages || {}), [state.messages]); + const getMessage = useCallback((notebookId, messageId) => getMessages(notebookId)[messageId], [state.messages]); + + const saveMessage = (notebookId, message) => persistMessages(notebookId, { ...getMessages(notebookId), [message.id]: message }); + const deleteMessage = (notebookId, messageId) => { + const messages = getMessages(notebookId); + delete messages[messageId]; + persistMessages(notebookId, messages); + localforage.removeItem(`messages/${notebookId}/${messageId}`); + }; + const copyMessage = message => navigator.clipboard.writeText(message.text); + + const persistMessages = useCallback(async (notebookId, messages) => { + const notebook = getNotebook(notebookId); + // if (!notebook) return; + const rawKey = await getAesRawKey(notebook.aesKeyB64); + const encrypteds = {}; + await Promise.all(Object.values(messages).map(async message => (encrypteds[message.id] = await encryptMessage(message, rawKey)))); + setState(s => ({ ...s, + encrypteds: { ...s.encrypteds, [notebookId]: encrypteds }, + messages: { ...s.messages, [notebookId]: messages }, + })); + // 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(messageId => setState(s => ({ ...s, reactionInputFor: messageId })), []); + const confirmReaction = useCallback(async (messageId, emoji) => { + setState(s => ({ ...s, reactionInputFor: null })); + if (!emoji) return; + const notebookId = state.selectedNotebookId; + const message = getMessage(notebookId, messageId); + if (!(emoji in message.reactions)) { + message.reactions[emoji] = true; + saveMessage(notebookId, message); + } + }, [state.selectedNotebookId, state.messages, persistMessages]); + const removeReaction = useCallback(async (messageId, emoji) => { + const notebookId = state.selectedNotebookId; + const message = getMessage(notebookId, messageId); + if (emoji in message.reactions) { + delete message.reactions[emoji]; + saveMessage(notebookId, message); + } + }, [state.selectedNotebookId, state.messages, persistMessages]); + useEffect(() => setState(s => ({ ...s, reactionInputFor: null })), [state.selectedNotebookId]); + + // Editing effect: prefill textarea when entering edit mode + useEffect(() => { + if (state.editingMessage!=null && messageInputRef.current) { + const message = state.messages[state.selectedNotebookId]?.[state.editingMessage]; + if (message) { + messageInputRef.current.value = message.text; + } + } + }, [state.editingMessage, state.selectedNotebookId, state.messages]); + + useEffect(() => (state.scrollToMessageId==null && Array.from(document.querySelectorAll('.Message[data-message-id]')).slice(-1)[0]?.scrollIntoView({ behavior: 'smooth', block: 'start' })), [state.selectedNotebookId]); + + const sendMessage = useCallback(async () => { + const notebookId = state.selectedNotebookId; + //if (!notebookId) return; + const text = messageInputRef.current.value.trim(); + if (!text) return; + const notebook = getNotebook(notebookId); + let message = getMessage(notebookId, state.editingMessage); + if (!message) { + message = { + id: notebook.nextMessageId, + created: Date.now(), + replyTo: state.replyingTo, + reactions: {}, + }; + } + message = { ...message, text, edited: (state.editingMessage!=null ? Date.now() : false), }; + messageInputRef.current.value = ''; + // update nextMessageId if new + setState(s => ({ ...s, notebooks: s.notebooks.map(notebook => notebook.id===notebookId + ? { ...notebook, nextMessageId: (state.editingMessage==null ? notebook.nextMessageId+1 : notebook.nextMessageId) } + : notebook + ) })); + saveMessage(notebookId, message); + setState( s => ({ ...s, editingMessage: null, replyingTo: null })); + }, [state.selectedNotebookId, state.editingMessage, state.replyingTo, state.messages, state.notebooks]); + + return html` + <${AppContext.Provider} value=${{ + state, setState, createNotebook, + getNotebook, deleteNotebook, + getMessages, getMessage, + sendMessage, persistMessages, + saveMessage, deleteMessage, copyMessage, + addReaction, confirmReaction, removeReaction, + }}> +
+ <${ChatList} /> + <${ChatScreen} messageInputRef=${messageInputRef} /> + ${state.createModal && html`<${CreateModal} />`} + ${state.crossReplyModal && html`<${CrossReplyModal} />`} + ${state.showNotebookSettings && html`<${NotebookSettingsModal} />`} + ${state.showAppSettings && html`<${AppSettingsModal} />`} + ${state.contextMenu.visible && html`<${ContextMenu} />`} + ${state.dateTimeModal!==null && html`<${DateTimeModal} />`} + ${state.searchModal.visible && html`<${SearchModal} />`} +
+ + `; +} + +function ChatList() { + const {state, setState, getMessages} = useContext(AppContext); + const sortNotebook = (notebook) => Math.max(notebook.created, ...Object.values(getMessages(notebook.id) || []).map(message => message.created)); + return html` +
+
+ + + +
+ ${[ ...state.notebooks.sort((a, b) => (sortNotebook(b) - sortNotebook(a))), ...Object.values(NOTEBOOKS) ].map(notebook => html` + + `)} +
+ `; +} + +function ChatScreen({messageInputRef}) { + const {state, setState, sendMessage, getMessage, getMessages, getNotebook} = useContext(AppContext); + const notebook = getNotebook(state.selectedNotebookId); + if (!notebook) return null; + const messages = Object.values(getMessages(notebook.id)).sort((a, b) => (a.created - b.created)); + // Scroll on request + useEffect(() => { + if (state.scrollToMessageId != null) { + document.querySelector(`.Message[data-message-id="${state.scrollToMessageId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + setState(s => ({ ...s, scrollToMessageId: null })); + } + }, [state.scrollToMessageId, state.selectedNotebookId]); + return html` +
+
setState(s => ({ ...s, showNotebookSettings: true }))} onKeyDown=${ev => (ev.key==='Enter' && ev.target.click())} tabindex=0 role="button"> + +
${notebook.emoji}
+

${notebook.name}

+ +
+
+ ${messages.map(message => html`<${Message} message=${message} notebook=${notebook} />`)} +
+ ${!notebook.readonly && html`
+ ${state.replyingTo && html` +
+ ${STRINGS.get('Reply to')}: "${ + getMessage(state.replyingTo.notebookId, state.replyingTo.messageId)?.text || '' + }" + +
`} +

+

+

+ ${' '} + ${' '} + ${' '} +

+
+ `; +} + +function SearchModal() { + const {state, setState, getNotebook} = useContext(AppContext); + const {query, global} = state.searchModal; + const results = (global + ? state.notebooks.flatMap(notebook => (state.messages[notebook.id] || []).map(message => ({ ...message, notebook }))) + : (state.messages[state.selectedNotebookId] || []).map(message => ({ ...message, notebook: getNotebook(state.selectedNotebookId) })) + ).filter(message => message.text.toLowerCase().includes(query.toLowerCase())); + const select = (notebookId, messageId) => setState(s => ({ ...s, selectedNotebookId: notebookId, searchModal: { ...s.searchModal, visible: false }, scrollToMessageId: messageId })); + return html` +
+

${global ? 'Global' : 'Notebook'} Search

+ setState(s => ({ ...s, searchModal: { ...s.searchModal, query: ev.target.value }}))}/> + ${results.map(result => html` +
select(result.notebook.id, result.id)}> + ${global && html`
+
${result.notebook.emoji}
+ ${result.notebook.name} +
`} +
${result.text}
${new Date(result.created).toLocaleString()} +
+ `)} + +
+ `; +} + +function AppSettingsModal() { + const {state, setState} = useContext(AppContext); + const [importTxt, setImportTxt] = useState(''); + const exportData = () => JSON.stringify({ notebooks: state.notebooks, messages: Object.fromEntries(Object.entries(state.encrypteds).map(([key, values]) => ([key, Object.values(values)]))) }, null, 2); + const doImport = () => { + try { + const obj = JSON.parse(importTxt); + if (obj.notebooks && obj.messages) { + setState(s => ({ ...s, + notebooks: obj.notebooks, + encrypteds: Object.fromEntries(Object.entries(obj.messages).map(([notebookId, messages]) => ([notebookId, Object.fromEntries(messages.map(message => [message.id, message]))]))), + })); + // window.location.reload(); + setState(s => ({ ...s, showAppSettings:false })); + } else { + alert(STRINGS.get('Invalid data format')); + } + } catch (err) { + console.error(err); + alert(STRINGS.get('Invalid JSON syntax')); + } + }; + return html` +
+

${STRINGS.get('App Settings')}

+

${STRINGS.get('Export Data')}

+

${STRINGS.get('Import Data')}

+ -

Import Data