window.appMain = () => { const html = htm.bind(h); const AppContext = createContext(); localforage.config({ name: "WhichNot" }); navigator.storage.persist(); // Custom Markdown rendering marked.use({ renderer: { // Open all [external (TODO: exclude internal ones once implemented)] links in a new tab link({ href, title, tokens }) { const text = this.parser.parseInline(tokens); let out = `${text}`; return out; }, // image({ href, title, text }) { // allow embedding any media with ![]() // title = (title ? ` title="${escapeHtml(title)}"` : ''); // return ` // // ${text} // `; // } } }); const STRINGS = { "Notebook": { it: "Quaderno" }, "Copy": { it: "Copia" }, "Copy to Clipboard": { it: "Copia negli Appunti" }, "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 }, }, { text: "Official first release devlog post: https://octospacc.altervista.org/2025/04/21/whichnot-rilasciato-in-tarda-annunciata-app-di-note-come-messaggi/", created: "2025-04-21T21:00" }, { text: "For the greatest benefit of everyone's retinas, **OBSCURE MODE IS HERE!** Yes indeed, it's not just dark, but as a matter of fact obscure: it uses the cutting-edge [CSS `light-dark()` function](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value/light-dark) to ensure a pleasant experience for the users (including setting the colors automatically based on the browser's settings) and limited pain for the developer (me). 🌚\n\n![](https://windog.octt.eu.org/api/v1/FileProxy/?url=telegram:AgACAgEAAxkBAAIWzWgIq6JoJl57iYVamdd2TmtUYpVMAAJSrzEbpcRBRN2mi5RO7WqiAQADAgADeQADNgQ&type=image/jpeg×tamp=1745395090&token=hhwBcamZvd6KoSpTZbQi1j-N-7FbQprjv1UFHvozbcg=)", created: "2025-04-22T20:00", }, { text: "From the suffering I just felt now that I actually tried to use the app on mobile for a bit, **an hotfix is born**: while behavior on desktop remains unchanged, **pressing Enter in the message editing area on mobile now correctly makes a newline, instead of sending**, as one would expect from a chat UI. â†Šī¸", created: "2025-04-23T10:30", reactions: { "đŸ”Ĩ": true }, }, { text: "JUST IN: **the app is now officially released as blessed Free (Libre) Software under the terms of the AGPL-3.0 license**!!! Proprietarytards as well as OSS-LARPers could literally never. Official Git source repos are as follows: \n* https://gitlab.com/octospacc/WhichNot \n* https://github.com/octospacc/WhichNot", created: "2025-04-24T01:00", }, ], }, }; 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 genEcdsaP256 = async () => crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, 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 } }); const makeTextareaHeight = text => { let lines = text.split('\n').length; if (lines > 10) { lines = 10; } return `${lines + 2}em`; }; const textareaInputHandler = el => (el.style.minHeight = makeTextareaHeight(el.value)); 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, debugMode: false, }); const isFirstHashPush = useRef(true); 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); })(); }, []); const navigateHash = useCallback(() => { const params = new URLSearchParams(location.hash.slice(2)); const [notebookId, messageId] = (params.get('notebook') || '#').split('#'); setState(s => ({ ...s, selectedNotebookId: notebookId, scrollToMessageId: (notebookId && parseInt(messageId) || null), showNotebookSettings: (messageId === 'settings'), })); }, []); const pushHistory = useCallback(hash => { if (isFirstHashPush.current) { isFirstHashPush.current = false; } else { location.hash = hash; } }, []); // Listen for URL navigation useEffect(() => { navigateHash(); // Initial sync window.addEventListener('hashchange', navigateHash); return () => window.removeEventListener('hashchange', navigateHash); }, [navigateHash]); // Set URL navigation hashes useEffect(() => pushHistory(`#?${state.selectedNotebookId ? `notebook=${state.selectedNotebookId}` : ''}`), [state.selectedNotebookId, pushHistory]); // useEffect(() => (state.selectedNotebookId && pushHistory(`#?notebook=${state.selectedNotebookId}${state.showNotebookSettings ? '#settings' : ''}`)), [state.showNotebookSettings]); // 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 ecdsa = await genEcdsaP256(); 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()), // ecdsaPrivB64: await exportJWK(ecdsa.privateKey), ecdsaPubB64: await exportJWK(ecdsa.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; textareaInputHandler(messageInputRef.current); } } }, [state.editingMessage, state.selectedNotebookId, state.messages]); // Scroll to last sent messagge 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 ? (text !== message.text ? Date.now() : message.edited) : false), }; messageInputRef.current.value = ''; messageInputRef.current.style.minHeight = null; // 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')}