From 09ff75ef6d2275081dff7927339a30d58359c009 Mon Sep 17 00:00:00 2001 From: octospacc Date: Thu, 1 May 2025 18:33:01 +0200 Subject: [PATCH] Lots of updates --- app.js | 412 ++++++++++++++++++++++++++++++++++++---------- index.html | 20 ++- service-worker.js | 70 ++++++++ 3 files changed, 411 insertions(+), 91 deletions(-) create mode 100644 service-worker.js diff --git a/app.js b/app.js index 490d8da..1b58777 100644 --- a/app.js +++ b/app.js @@ -40,8 +40,11 @@ const STRINGS = { "Send": { it: "Invia" }, "Save": { it: "Salva" }, "Delete": { it: "Elimina" }, + "Delete?": { it: "Eliminare?" }, "Cancel": { it: "Annulla" }, "Close": { it: "Chiudi" }, + "System": { it: "Sistema" }, + "Default": { it: "Predefinito" }, "Name": { it: "Nome" }, "Color": { it: "Colore" }, "Description": { it: "Descrizione" }, @@ -55,6 +58,13 @@ const STRINGS = { "No description": { it: "Nessuna descrizione" }, "No notes": { it: "Nessuna nota" }, "Info and Demo": { it: "Info e Demo" }, + "Aesthetics": { it: "Estetica" }, + "Color Scheme": { it: "Schema di Colori" }, + "Light": { it: "Chiaro" }, + "Dark": { it: "Scuro" }, + "Message Input Font": { it: "Font Input Messaggi" }, + "Size": { it: "Dimensione" }, + "Language": { it: "Lingua" }, }; STRINGS.get = (name, lang=navigator.language.split('-')[0]) => (STRINGS[name]?.[lang] || STRINGS[name]?.en || name); @@ -68,28 +78,74 @@ const NOTEBOOKS = { parseMode: "markdown", readonly: true, messages: [ - { - text: "**WhichNot is finally released and REAL!!!** BILLIONS MUST ENJOY!!!", + { 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/", + { 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=)", + { 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![](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. â†Šī¸", + { 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", + { text: "Yet another quick fix: since I've just now written the previous message, I've only now witnessed the tragic default state of **Markdown links; I adjusted the parser to make it so that external links open in a new tab**. â†—ī¸", + created: "2025-04-23T11:00", + reactions: { "đŸ”Ĩ": true }, + }, + // TODO post about URL hash navigation + { 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: +* https://gitlab.com/octospacc/WhichNot +* https://github.com/octospacc/WhichNot + `, created: "2025-04-24T01:00", }, + { text: ` +Some AMAZING (even if quick) **improvements have been made to text input fields!** +* The textarea for message input now dynamically resizes vertically to accomodate multiple lines of text, up to about 10 lines on screen (currently just by counting the newlines in the text). +* Fixed a bug for the reaction input area, where previously on mobile pressing Enter to confirm or close the input wouldn't register if the message to react was the very last one, and instead focus would be passed to the message input field below. +`, + created: "2025-04-25T02:00", + }, + { text: ` +Finally the supremacy of WhichNot really becomes clear as day: +Thanks to [service workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API), +**the app itself now gets cached locally after the first run, allowing it to be started faster and also work offline**, including if my server explodes!!! +This is truly THE GREATEST note-taking experience for everyone!!! đŸ•¸ī¸ + `, + created: "2025-04-30T10:00", + }, + { text: ` +Great, **STUPENDOUS KEYBOARD NAVIGATION IS HERE!** +Practically everything in the app is now usable with just the keyboard, after having implemented the most basic standard web accessibility practices. +* Modals and menus are automatically focused when they appear on screen, as they should. Also, all sections can now be closed by pressing ESC. +* The message input textarea is now always automatically focused when needed; right after opening a notebook, selecting reply or edit on a message, and so on. +* Messages themselves are now focusable, and pressing Enter on them brings up their context menu. The menu options themselves are now also selectable with Tab. +* When a message is scrolled to, it's focused to allow this keyboard interaction. Message reply indicators are now also clickable with the keyboard. + `, + created: "2025-05-01T15:00", + }, + { text: ` +It's also **just about time for īŧĄīŊ…īŊ“īŊ”īŊˆīŊ…īŊ”īŊ‰īŊƒīŊ“** (also called "UI")... More crazy options coming soon, but for now: +* The color scheme of the app can be overriden in the settings, from the default option of following system preferences to always light or dark. +* App language can now also be overridden, from the default of following system preferences to any of the supported languages. +* Font size and family of the message text input can be customized, respectively at will and by choosing from a list of the default categories. + `, + created: "2025-05-01T17:00", + }, ], }, }; @@ -110,28 +166,33 @@ const uuidv7 = () => { 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 genAesKey = async () => await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); +// const genEcdsaP256 = async () => await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); +const genHmacKey = async () => await crypto.subtle.generateKey({ name: 'HMAC', hash: 'SHA-256' }, true, ['sign']); +const exportJwk = async (key) => btoa(JSON.stringify(await crypto.subtle.exportKey('jwk', key))); +const importJwk = async (b64, alg, usages) => await crypto.subtle.importKey('jwk', JSON.parse(atob(b64)), alg, true, usages); +const randBytes = (len) => { + const bytes = new Uint8Array(len); + crypto.getRandomValues(bytes); + return bytes; +}; 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 }, + { 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 getAesRawKey = async (aesKeyB64) => await crypto.subtle.exportKey('raw', await importJwk(aesKeyB64, { name: 'AES-GCM' }, ['encrypt', 'decrypt'])); +// const getEcdsaSignKey = async (ecdsaPrivB64) => await importJwk(ecdsaPrivB64, { name: 'ECDSA', namedCurve: 'P-256' }, ['sign']); +// const signEcdsaSha256 = async (ecdsaPrivB64, data) => await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, await getEcdsaSignKey(ecdsaPrivB64), new TextEncoder().encode(data)); +// const getHmacRawKey = async (hmacKeyB64) => await crypto.subtle.exportKey('raw', await importJwk(hmacKeyB64, { name: 'HMAC', hash: 'SHA-256' }, ['sign'])); +const hmacSha256B64 = async (hmacKeyB64, msg) => await crypto.subtle.sign('HMAC', await importJwk(hmacKeyB64, { name: 'HMAC', hash: 'SHA-256' }, ['sign']), new TextEncoder().encode(msg)); const encryptMessage = async (message, rawKey) => { - const salt = randBytes(); + const salt = randBytes(12); 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)); @@ -156,7 +217,7 @@ 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'); @@ -173,8 +234,21 @@ const renderTextMessage = (text, notebook) => { const EMOJIS = ['📒','📓','📔','📕','📖','📗','📘','📙','📚','âœī¸','📝']; const randomEmoji = () => EMOJIS[Math.floor(Math.random() * EMOJIS.length)]; const randomColor = () => ('#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0')); +const deleteKeys = (obj, keys) => { + keys.forEach(key => (delete obj[key])); + return obj; +}; const closedContextMenu = s => ({ contextMenu: { ...s.contextMenu, visible: false } }); +const clickOnEnter = ev => (ev.key==='Enter' && ev.target.click()); +const focusElement = (target) => (typeof target==='string' ? document.querySelector(target) : target)?.focus(); +const scrollToMessage = messageId => { + const message = Array.from(document.querySelectorAll(`.Message[data-message-id${messageId ? `="${messageId}"` : ''}]`)).slice(-1)[0]; + if (message) { + message.scrollIntoView({ behavior: 'smooth', block: 'start' }); + messageId && message.focus({ preventScroll: true }); + } +}; const makeTextareaHeight = text => { let lines = text.split('\n').length; if (lines > 10) { @@ -186,7 +260,7 @@ const textareaInputHandler = el => (el.style.minHeight = makeTextareaHeight(el.v function App() { const [state, setState] = useState({ - notebooks: [], encrypteds: {}, messages: {}, + notebooks: [], encrypteds: {}, messages: {}, prefs: {}, selectedNotebookId: null, scrollToMessageId: null, showNotebookSettings: false, showAppSettings: false, createModal: false, dateTimeModal: null, @@ -200,9 +274,31 @@ function App() { const messageInputRef = useRef(); const [loading, setLoading] = useState(true); + // Get UI strings by user-set language + (get => (STRINGS.get = useCallback(((name, lang=state.prefs.language) => get(name, lang)), [state.prefs.language])))(STRINGS.get); + + const callApi = useCallback(async (method, path, notebookId, body) => { + try { + body &&= JSON.stringify(body); + const query = `${path}?time=${Date.now()}`; + //const signed = bufToB64(await signEcdsaSha256(getNotebook(notebookId).ecdsaPrivB64, `${method} ${query}|${body || ''}`)); + const hash = bufToB64(await hmacSha256B64(getNotebook(notebookId).hmacKeyB64, `${method} ${query}|${body || ''}`)); + const data = await (await fetch(`https://hlb0.octt.eu.org/WhichNot-API.php/${query}`, { method, body, headers: { /* 'X-Signed': signed */ 'X-Request-Hash': hash } })).json(); + if (data.error) { + console.error(data.error); + alert(data.error); + } + return data; + } catch (err) { + console.error(err); + alert(err); + } + }); + // Load data from storage useEffect(() => { (async () => { + const prefs = await localforage.getItem('preferences') || {}; const notebooksList = await localforage.getItem('notebooks') || []; const notebooks = []; const [messagesStore, encryptedsStore] = [{}, {}]; @@ -216,11 +312,12 @@ function App() { encryptedsStore[notebook.id] = encrypteds; messagesStore[notebook.id] = messages; })); - setState(s => ({ ...s, notebooks, encrypteds: encryptedsStore, messages: messagesStore })); + setState(s => ({ ...s, notebooks, encrypteds: encryptedsStore, messages: messagesStore, prefs })); setLoading(false); })(); }, []); + // TODO fix that messageId is not handled while the notebook is loading (the url hash gets overridden by the notebook loading itself) const navigateHash = useCallback(() => { const params = new URLSearchParams(location.hash.slice(2)); const [notebookId, messageId] = (params.get('notebook') || '#').split('#'); @@ -274,6 +371,13 @@ function App() { } }, [state.encrypteds]); + // Persist prefs + useEffect(() => { + if (!loading) { + localforage.setItem('preferences', state.prefs); + } + }, [state.prefs]); + // Close context on click-away useEffect(() => { const handler = ev => { @@ -288,18 +392,60 @@ function App() { return () => document.removeEventListener('click', handler); }, [state.contextMenu.visible]); + // Focus menus and modals on open + useEffect(() => { + if (state.showAppSettings) { + focusElement('.AppSettingsModal'); + } else if (state.showNotebookSettings) { + focusElement('.NotebookSettingsModal'); + } else if (state.contextMenu.visible) { + const menu = document.querySelector('.ContextMenu'); + if (menu) { + (menu.children.length > 1 ? menu : menu.children[0]).focus(); + }; + } else if (state.selectedNotebookId) { + messageInputRef.current?.focus(); + } + }, [state.showAppSettings, state.showNotebookSettings, state.contextMenu.visible, state.selectedNotebookId]); + + // Handle closables on ESC press + useEffect(() => { + const handler = ev => { + if (ev.key==='Escape') { + if (state.showAppSettings) { + setState(s => ({ ...s, showAppSettings: false })); + } else if (state.contextMenu.visible) { + setState(s => ({ ...s, ...closedContextMenu(s) })); + } else if (state.showNotebookSettings) { + setState(s => ({ ...s, showNotebookSettings: false })); + } else if (state.selectedNotebookId) { + setState(s => ({ ...s, selectedNotebookId: null })); + } + } + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [state.showAppSettings, state.showNotebookSettings, state.selectedNotebookId, state.contextMenu.visible]); + + // Set CSS theme + useEffect(() => (document.documentElement.dataset.theme = state.prefs.colorScheme), [state.prefs.colorScheme]); + + // Set message textarea CSS + useEffect(() => (messageInputRef.current && Object.assign(messageInputRef.current.style, state.prefs.messageInput)), [state.prefs.messageInput, state.selectedNotebookId, messageInputRef.current]); + 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 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), + aesKeyB64: await exportJwk(await genAesKey()), + ...(state.debugMode && { hmacKeyB64: await exportJwk(await genHmacKey()) }), + //...(state.debugMode && { ecdsaPrivB64: await exportJwk(ecdsa.privateKey), ecdsaPubB64: await exportJwk(ecdsa.publicKey) }), }; setState(s => ({ ...s, notebooks: [ ...s.notebooks, notebook ], @@ -315,29 +461,33 @@ function App() { // } }, [state.notebooks]); + const setNotebook = useCallback(newNotebook => setState(s => ({ ...s, notebooks: s.notebooks.map(oldNotebook => (oldNotebook.id===newNotebook.id ? newNotebook : oldNotebook)) }))); const getNotebook = useCallback(notebookId => (state.notebooks.find(notebook => (notebook.id === notebookId)) || NOTEBOOKS[notebookId]), [state.notebooks]); - const deleteNotebook = (notebookId) => { + const deleteNotebook = useCallback(async (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}`); - } - }; + setState(s => { + delete s.messages[notebookId]; + delete s.encrypteds[notebookId]; + return ({ ...s, + notebooks: s.notebooks.filter(notebook => (notebook.id !== notebookId)), + }); + }); + await Promise.all([ + localforage.removeItem(`notebooks/${notebookId}`), + localforage.removeItem(`messages/${notebookId}`), + ...messagesList.map(messageId => localforage.removeItem(`messages/${notebookId}/${messageId}`)), + ]); + }); + const upsyncNotebook = useCallback(notebookId => callApi('PUT', `notebook/${notebookId}`, notebookId, deleteKeys(getNotebook(notebookId), ['aesKeyB64']))); 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 saveMessage = async (notebookId, message) => await persistMessages(notebookId, { ...getMessages(notebookId), [message.id]: message }); + const deleteMessage = async (notebookId, messageId) => { const messages = getMessages(notebookId); delete messages[messageId]; - persistMessages(notebookId, messages); + await persistMessages(notebookId, messages); localforage.removeItem(`messages/${notebookId}/${messageId}`); }; const copyMessage = message => navigator.clipboard.writeText(message.text); @@ -353,7 +503,7 @@ function App() { messages: { ...s.messages, [notebookId]: messages }, })); // if (notebook.sourceType==='remote') { - // const priv = await importJWK(notebook.edPrivB64, { name: 'Ed25519', namedCurve: 'Ed25519' }, ['sign']), + // 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`, { @@ -363,7 +513,7 @@ function App() { // } }, [state.notebooks]); - const addReaction = useCallback(messageId => setState(s => ({ ...s, reactionInputFor: messageId })), []); + const addReaction = useCallback(messageId => setState(s => ({ ...s, reactionInputFor: messageId })), []); // TODO focus input box const confirmReaction = useCallback(async (messageId, emoji) => { setState(s => ({ ...s, reactionInputFor: null })); if (!emoji) return; @@ -393,10 +543,13 @@ function App() { textareaInputHandler(messageInputRef.current); } } - }, [state.editingMessage, state.selectedNotebookId, state.messages]); + }, [state.editingMessage, state.selectedNotebookId]); - // 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]); + // Focus reaction input area on click + useEffect(() => (state.reactionInputFor && focusElement(`.Message[data-message-id="${state.reactionInputFor}"] .ReactionInput`)), [state.reactionInputFor]); + + // Scroll to last sent messagge on opening notebook + useEffect(() => (!loading && state.selectedNotebookId!=null && state.scrollToMessageId==null && scrollToMessage()), [state.selectedNotebookId, loading]); const sendMessage = useCallback(async () => { const notebookId = state.selectedNotebookId; @@ -413,24 +566,31 @@ function App() { reactions: {}, }; } - message = { ...message, text, edited: (state.editingMessage!=null ? (text !== message.text ? Date.now() : message.edited) : false), }; - messageInputRef.current.value = ''; - messageInputRef.current.style.minHeight = null; + message = { ...message, text, edited: (state.editingMessage!=null ? (text !== message.text ? Date.now() : message.edited) : false) }; // 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 })); + await saveMessage(notebookId, message); + setState(s => ({ ...s, editingMessage: null, replyingTo: null })); + messageInputRef.current.value = ''; + messageInputRef.current.style.minHeight = null; + scrollToMessage(message.id); }, [state.selectedNotebookId, state.editingMessage, state.replyingTo, state.messages, state.notebooks]); + // Keep message input area focused + useEffect(() => { + if (state.selectedNotebookId && messageInputRef.current) { + messageInputRef.current.focus(); + } + }, [state.editingMessage, state.replyingTo, state.selectedNotebookId, loading, sendMessage]); + return html` <${AppContext.Provider} value=${{ - state, setState, createNotebook, - getNotebook, deleteNotebook, - getMessages, getMessage, - sendMessage, persistMessages, + state, setState, callApi, + createNotebook, getNotebook, setNotebook, deleteNotebook, upsyncNotebook, + getMessages, getMessage, sendMessage, persistMessages, saveMessage, deleteMessage, copyMessage, addReaction, confirmReaction, removeReaction, }}> @@ -483,13 +643,13 @@ function ChatScreen({messageInputRef}) { // Scroll on request useEffect(() => { if (state.scrollToMessageId != null) { - document.querySelector(`.Message[data-message-id="${state.scrollToMessageId}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + scrollToMessage(state.scrollToMessageId); 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"> +
setState(s => ({ ...s, showNotebookSettings: true }))} onKeyDown=${clickOnEnter} tabindex=0 role="button">
`} -

+ ${state.debugMode && html` +

Debug Experiments

+

+ setForm(f => ({ ...f, id: ev.target.value }))} /> + +

+

+ + +

+

+ + +

+

+ + +

+ ${accesses.map(access => html` +

+ ${access.id} + + +

+ `)} + + `}

${' '} ${' '} - ${' '}

`; @@ -742,12 +947,17 @@ function SearchModal() { 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 exportData = () => JSON.stringify({ + preferences: state.prefs, + 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, + prefs: obj.preferences, notebooks: obj.notebooks, encrypteds: Object.fromEntries(Object.entries(obj.messages).map(([notebookId, messages]) => ([notebookId, Object.fromEntries(messages.map(message => [message.id, message]))]))), })); @@ -762,21 +972,45 @@ function AppSettingsModal() { } }; return html` -
-

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

+
+
+

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

+ +
+

${STRINGS.get('Aesthetics')}

+

+

+ setState(s => ({ ...s, prefs: { ...s.prefs, messageInput: { ...s.prefs.messageInput, fontSize: (ev.target.value ? `${ev.target.value}pt` : '') } } }))} onInput=${ev => ev.target.dispatchEvent(new InputEvent('change'))} /> +

+

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

- +

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

-