Allow exporting single notebooks and partial imports; Refactor main app structure; Add TypeScript config

This commit is contained in:
2025-05-30 21:00:27 +02:00
parent 09ff75ef6d
commit 3d78df31d6
16 changed files with 743 additions and 501 deletions

5
.gitignore vendored
View File

@ -1,3 +1,6 @@
localforage.min.js
marked.min.js
preact/
preact/
/src/app/index.html
/src/app/schema.json.js
/src/app/service-worker.js

21
build.sh Normal file
View File

@ -0,0 +1,21 @@
#!/bin/sh
set -e
cd ./src/app
echo 'window.SCHEMA=' > ./schema.json.js
cat ../schema.json >> ./schema.json.js
cp ../index.html ../service-worker.js ./
for lib in lib/*.js
do sed -i "s|</head>|<script src='./${lib}'></script></head>|" ./index.html
done
for mod in *.js
do sed -i "s|<body>|<body><script src='./${mod}'></script>|" ./index.html
done
for file in *.* lib/*.* lib/*/*.*
do sed -i "s|//files//|'/${file}', //files//|" ./service-worker.js
done

6
globals.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
declare var
marked, localforage, htm,
h, render, createContext,
useState, useEffect, useCallback, useRef, useContext,
ModalHeader, SchemaForm, SchemaField,
SCHEMA, appMain: any;

View File

@ -1,153 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
if ('serviceWorker' in navigator && location.protocol === 'https:') {
navigator.serviceWorker.register('/service-worker.js', { scope: "/" });
}
</script>
<link rel="manifest" href="./manifest.json" />
<link rel="shortcut icon" href="./icon.png" type="image/png" sizes="1024x1024" />
<title>WhichNot</title>
<style>
:root {
--wapp-green: #00a884;
--header-bg: light-dark(#f0f2f5, #0f0d0a);
--main-bg: light-dark(white, #0f0d0a);
--bubble-bg: light-dark(white, #2b2a33);
--chat-bg: light-dark(#efeae2, #10151d);
--focus-bg: light-dark(#f5f5f5, #2b2a33);
}
* { box-sizing: border-box; }
html, body {
margin: 0; height: 100%;
font-family: Arial, sans-serif;
color-scheme: light dark;
}
html[data-theme="light"], html[data-theme="light"] body {
color-scheme: light;
}
html[data-theme="dark"], html[data-theme="dark"] body {
color-scheme: dark;
}
.App { display: flex; height: 100vh; }
.ChatList {
width: 30%; overflow-y: auto;
background: var(--main-bg); border-right: 1px solid #ddd;
}
.ChatList-header {
display: flex; justify-content: space-between; align-items: center;
padding: .75rem 1rem; height: 3.5rem; border-bottom: 1px solid #ddd;
}
.ChatList-header button { background: none; border: none; font-size: 1.25rem; cursor: pointer; }
.NotebookButton { width: 100%; padding: .75rem 1rem; background: none; border: none; cursor: pointer; text-align: left; }
.NotebookButton:hover { background: var(--focus-bg); }
.NotebookTitle { display: flex; align-items: center; gap: .5rem; }
.NotebookEmoji {
width: 1.5rem; height: 1.5rem;
min-width: 1.5rem; min-height: 1.5rem;
display: flex; align-items: center; justify-content: center;
border-radius: 50%; font-size: 1rem;
}
.NotebookName { margin: 0; font-size: 1rem; }
.NotebookDescription { font-size: .875rem; color: #555; margin: .25rem 0 0 2rem; }
.NotebookPreview { font-size: .875rem; color: #666; margin: .25rem 0 0 2rem; }
.NotebookDescription, .NotebookPreview, .ReplyPreviewText { text-overflow: ellipsis; overflow: hidden; text-wrap: nowrap; }
.ChatScreen { flex: 1; display: none; flex-direction: column; background: var(--chat-bg); }
.App.show-chat .ChatScreen { display: flex; }
.ChatHeader { background: var(--header-bg); padding: .5rem; height: 3.5rem; display: flex; align-items: center; gap: .5rem; border-bottom: 1px solid #ddd; cursor: pointer; }
.ChatHeader h3 { margin: 0; flex: 1; font-size: 1rem; }
.BackButton, .SearchButton { font-size: 1.5rem; padding: .25rem; background: none; border: none; cursor: pointer; }
.Messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: .5rem; }
.Message { background: var(--bubble-bg); padding: .5rem 1rem; border-radius: .5rem; max-width: 80%; word-break: break-word; margin: .5rem auto; position: relative; }
.Message .reactions { display: flex; gap: .25rem; margin-top: .25rem; }
.Message .reactions button { background: var(--chat-bg); border: none; border-radius: .25rem; padding: 0 .5rem; cursor: pointer; }
.Message iframe, .Message img, .Message object, .Message embed { border: none; max-width: 100%; }
.Message .embed, .Message .embed iframe { width: 100%; text-align: center; }
.AddReactionBtn { font-size: .9rem; background: none; border: none; cursor: pointer; color: var(--wapp-green); }
.ReactionInput { width: 2rem; padding: .1rem; font-size: 1rem; }
.Timestamp { font-size: .75rem; color: #666; margin-top: .25rem; text-align: right; }
.SendBar { display: flex; gap: .5rem; padding: 1rem; background: var(--main-bg); border-top: 1px solid #ddd; flex-direction: column; }
.ReplyPreview { background: var(--chat-bg); padding: .5rem; border-radius: .25rem; display: flex; justify-content: space-between; align-items: center; }
.EditArea { /* flex: 1; */ padding: .5rem; border: 1px solid #ddd; border-radius: .5rem; resize: none; height: 4em; }
.ContextMenu {
position: fixed; z-index: 1000; min-width: 140px;
background: var(--main-bg); border: 1px solid #ddd; border-radius: .25rem; box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.ContextMenuItem { padding: .5rem 1rem; cursor: pointer; }
.ContextMenuItem:hover { background: var(--focus-bg); }
button.ContextMenuItem { width: 100%; display: block; background: inherit; border: none; text-align: left; font-size: inherit; }
.DateTimeModal, .SearchModal, .AppSettingsModal, .CreateModal, .NotebookSettingsModal, .CrossReplyModal {
position: fixed; margin: auto; top: 0; bottom: 0; left: 0; right: 0; /* top: 50%; left: 50%; transform: translate(-50%,-50%); */
background: var(--main-bg); padding: 2rem; border-radius: .5rem; box-shadow: 0 0 1rem rgba(0,0,0,0.1);
max-height: 80vh; overflow-y: auto; width: 90%; max-width: 600px; z-index: 1001;
}
.SearchModal input, .AppSettingsModal textarea, .CreateModal input, .NotebookSettingsModal input, .NotebookSettingsModal textarea {
width: 100%; /* margin: .5rem 0; */ padding: .5rem; resize: vertical;
border: 1px solid #ddd; border-radius: .25rem;
}
.AppSettingsModal input[type="color"], .NotebookSettingsModal input[type="color"] { padding: revert; }
.SearchResult { padding: .5rem 0; border-bottom: 1px solid #eee; cursor: pointer; }
.SearchResult:hover { background: var(--focus-bg); }
.ModalHeader { display: flex; }
.ModalHeader h3 { flex: 1; }
.ModalHeader button { height: max-content; }
.ReplyIndicator {
border-left: 3px solid var(--wapp-green); padding-left: .5rem; margin-bottom: .5rem;
color: #666; font-size: .9em; cursor: pointer;
}
@media (max-width: 768px) {
.ChatList { width: 100%; }
.ChatScreen { width: 100%; display: none; }
.App.show-chat .ChatList { display: none; }
.App.show-chat .ChatScreen { display: flex; }
}
</style>
<script src="./localforage.min.js"></script>
<script src="./marked.min.js"></script>
</head>
<body>
<noscript><p>This application requires modern JavaScript.</p></noscript>
<script src="./app.js"></script>
<script type="module">
const libs = [
{
l: './preact/preact.js',
g: 'https://esm.sh/preact',
h: mod => {
const { h, render, createContext } = mod;
Object.assign(window, { h, render, createContext });
},
},
{
l: './preact/hooks.js',
g: 'https://esm.sh/preact/hooks',
h: mod => {
const { useState, useEffect, useCallback, useRef, useContext } = mod;
Object.assign(window, { useState, useEffect, useCallback, useRef, useContext });
},
},
{
l: './preact/htm.js',
g: 'https://esm.sh/htm',
h: mod => (window.htm = mod.default),
},
];
await Promise.all(libs.map(lib => {
const url = (location.protocol === 'file:' ? lib.g : lib.l);
return import(url).then(lib.h);
}));
appMain();
</script>
</body>
</html>

109
src/app/AppSettingsModal.js Normal file
View File

@ -0,0 +1,109 @@
function AppSettingsModal({ctx}) {
const {html, AppContext, STRINGS} = ctx;
const {state, setState, getNotebook} = useContext(AppContext);
const mapMessages = (messages, mapper) => Object.fromEntries(Object.entries(messages).map(([notebookId, messages]) => ([notebookId, mapper(messages)])));
const exportData = () => JSON.stringify({
preferences: state.prefs,
notebooks: state.notebooks,
messages: mapMessages(state.encrypteds, messages => Object.values(messages)),
}, null, 2);
const exportDataNotebook = (notebook) => JSON.stringify({
notebooks: [notebook],
messages: {[notebook.id]: Object.values(state.encrypteds[notebook.id])},
}, null, 2);
const [exportTxt, setExportTxt] = useState(exportData());
const [importTxt, setImportTxt] = useState('');
const [importReset, setImportReset] = useState(false);
const importData = () => {
try {
const obj = JSON.parse(importTxt);
// TODO: decrypt messages on import, otherwise the notebooks will appear empty until a reload
// TODO: delete duplicate notebooks when importing without reset
// TODO: block the app until import is complete
if (obj.notebooks && obj.messages) {
if (importReset) {
setState(s => ({ ...s,
prefs: obj.preferences || {},
notebooks: obj.notebooks,
encrypteds: mapMessages(obj.messages, messages => Object.fromEntries(messages.map(message => [message.id, message]))),
}));
} else {
setState(s => ({ ...s,
prefs: { ...s.prefs, ...obj.preferences },
notebooks: [ ...s.notebooks, ...obj.notebooks ],
encrypteds: { ...s.encrypteds, ...mapMessages(obj.messages, messages => Object.fromEntries(messages.map(message => [message.id, message]))) },
}));
}
setState(s => ({ ...s, showAppSettings: false }));
} else {
alert(STRINGS.get('Invalid data format'));
}
} catch (err) {
console.error(err);
alert(STRINGS.get('Invalid JSON syntax'));
}
};
const AestheticsSettings = () => html`
<h4>${STRINGS.get('Aesthetics')}</h4>
<p><label>${STRINGS.get('Color Scheme')}: <select value=${state.prefs.colorScheme} onChange=${ev => setState(s => ({ ...s, prefs: { ...s.prefs, colorScheme: ev.target.value } }))}>
<option value="system" default>Auto (${STRINGS.get('System')})</option>
<option value="light">${STRINGS.get('Light')}</option>
<option value="dark">${STRINGS.get('Dark')}</option>
</select></label></p>
<p>
<label>${STRINGS.get('Message Input Font')}:
</label> <select value=${state.prefs.messageInput?.fontFamily} onChange=${ev => setState(s => ({ ...s, prefs: { ...s.prefs, messageInput: { ...s.prefs.messageInput, fontFamily: ev.target.value } } }))}>
<option value="" default>${STRINGS.get('Default')} (Browser)</option>
<option value="monospace">Monospace</option>
<option value="serif">Serif</option>
<option value="sans-serif">Sans-Serif</option>
<option value="cursive">Cursive</option>
<option value="fantasy">Fantasy</option>
</select> <input type=number placeholder="${STRINGS.get('Size')} (pt)" min=6 max=20 value=${state.prefs.messageInput?.fontSize?.slice(0, -2)}
onChange=${ev => 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'))} />
</p>
<p><label>${STRINGS.get('Language')}: <select value=${state.prefs.language} onChange=${ev => setState(s => ({ ...s, prefs: { ...s.prefs, language: ev.target.value }}))}>
<option value="" default>Auto (${STRINGS.get('System')})</option>
<option value="en">🇬🇧 English</option>
<option value="it">🇮🇹 Italiano</option>
</select></label></p>
`;
return html`
<div class="AppSettingsModal" tabindex=-1>
<${ModalHeader} title="App Settings">
<button onClick=${() => setState(s => ({ ...s, showAppSettings: false }))}>${STRINGS.get('Close')}</button>
<//>
<${AestheticsSettings} />
<h4>${STRINGS.get('Export Data')}</h4>
<select onChange=${ev => setExportTxt(ev.target.value ? exportDataNotebook(getNotebook(ev.target.value)) : exportData())}>
<option value="">${STRINGS.get('Full')}</option>
${state.notebooks.map(notebook => html`<option value="${notebook.id}">${notebook.emoji} ${notebook.name}</option>`)}
</select>
<textarea readonly rows=8 onFocus=${ev => ev.target.setSelectionRange(0, -1)}>${exportTxt}</textarea>
<h4>${STRINGS.get('Import Data')}</h4>
<textarea rows=8 placeholder=${STRINGS.get('Paste JSON')} onInput=${ev => setImportTxt(ev.target.value)} />
<label><input type="checkbox" checked=${importReset} onChange=${ev => setImportReset(ev.target.checked)} />${STRINGS.get('Reset Data on Import')}</label>
<span> </span>
<button onClick=${importData}>${STRINGS.get('Import Data')}</button>
<!--
<h4>Other</h4>
<label><input type="checkbox" checked=${state.prefs.debugMode} onChange=${ev => setState(s => ({ ...s, prefs: { ...s.prefs, debugMode: ev.target.checked } }))} /> ⚠ Dangerous Experimental/Debug Features</label>
-->
</div>
`;
}

65
src/app/Message.js Normal file
View File

@ -0,0 +1,65 @@
function Message({message, notebook, ctx}) {
const {html, AppContext, STRINGS, UNSPECIFIEDS, escapeHtml, getFirstLink, makeParagraph, linkify, clickOnEnter} = ctx;
const {state, setState, getMessage, addReaction, confirmReaction, removeReaction} = useContext(AppContext);
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 rendered = renderTextMessage(message.text, notebook);
return html`
<div class="Message" data-message-id=${message.id} tabindex=0
onKeyDown=${ev => (ev.key==='Enter' && ev.target.dispatchEvent(new MouseEvent('contextmenu', { clientX: (window.innerWidth / 2), clientY: (window.innerHeight / 2) })))}
onContextMenu=${ev => {
ev.preventDefault();
setState(s => ({ ...s, contextMenu: { visible: true, messageId: message.id, x: ev.clientX, y: ev.clientY } }));
}}>
${message.replyTo && html`
<div class="ReplyIndicator" onKeyDown=${clickOnEnter} tabindex=0
onClick=${() => setState(s => ({ ...state,
selectedNotebookId: message.replyTo.notebookId,
scrollToMessageId: (message.replyTo.messageId || message.replyTo.id),
}))}>
${STRINGS.get('Reply to')}: "${
getMessage(message.replyTo.notebookId, (message.replyTo.messageId || message.replyTo.id))?.text || ''
}"
</div>`}
<div dangerouslySetInnerHTML=${{ __html: rendered }} />
${(() => {
const url = getFirstLink(rendered);
if (url) {
return html`<div class="embed">
<iframe src=${url} sandbox=""></iframe>
</div>`;
}
})()}
<div class="reactions">
${Object.keys(message.reactions || {}).map(reaction => html`
<button onClick=${() => removeReaction(message.id, reaction)} disabled=${notebook.readonly}>${reaction}</button>
`)}
${!notebook.readonly && (state.reactionInputFor===message.id
? html`<input type="text" class="ReactionInput" maxlength=2 autofocus enterkeyhint="done" onKeyDown=${ev => {
if (ev.key==='Enter') {
confirmReaction(message.id, ev.target.value);
ev.target.value = '';
}
}} />`
: html`<button class="AddReactionBtn" onClick=${() => addReaction(message.id)}></button>`
)}
</div>
<div>
<span class="Timestamp">${new Date(message.created).toLocaleString()}${message.edited ? ` (${STRINGS.get('Edited').toLowerCase()})` : ''}</span>
${state.prefs.debugMode && html`
<span> </span>
<span>${message.synced ? '✔' : '⏱'}</span>
`}
</div>
</div>
`
}

View File

@ -0,0 +1,105 @@
function NotebookSettingsModal({ctx}) {
const {html, AppContext, STRINGS, UNSPECIFIEDS, makeTextareaHeight, textareaInputHandler} = ctx;
const {state, setState, callApi, getNotebook, setNotebook, deleteNotebook, upsyncNotebook, getMessages, saveMessage} = useContext(AppContext);
const notebook = getNotebook(state.selectedNotebookId);
if (!notebook) return;
const [form, setForm] = useState({ ...notebook });
useEffect(() => setForm({ ...notebook }), [notebook.id]);
const save = () => {
setNotebook(form);
setState(s => ({ ...s, showNotebookSettings: false }));
};
const del = () => {
if (confirm(STRINGS.get('Delete?'))) {
// if (notebook.sourceType==='local') {
deleteNotebook(notebook.id);
setState(s => ({ ...s, selectedNotebookId: null, showNotebookSettings: false }));
}
};
const [accesses, setAccesses] = useState([]);
if (state.prefs.debugMode) {
useEffect(() => {
let cancelled = false;
setAccesses([]);
callApi('GET', `access/${notebook.id}`, notebook.id).then(data => (!cancelled && data?.accesses && setAccesses(Object.entries(data.accesses).map(([key, values]) => ({ id: key, ...values })))));
return () => { cancelled = true };
}, []);
}
return html`
<div class="NotebookSettingsModal" tabindex=-1>
<${ModalHeader} title="Info/Settings">
<button onClick=${() => setState(s => ({ ...s, showNotebookSettings: false }))}>${STRINGS.get('Close')}</button>
<//>
<${SchemaForm} schema=${SCHEMA.definitions.notebook}>
<p><label>
${STRINGS.get('Name')}:
<${SchemaField} type="text" name="name" value=${form.name} disabled=${notebook.readonly}
onChange=${ev => setForm(f => ({ ...f, name: ev.target.value }))} />
</label></p>
<p><label>
Emoji:
<${SchemaField} type="text" name="emoji" value=${form.emoji} maxLength=2
onChange=${ev => setForm(f => ({ ...f, emoji: ev.target.value }))} disabled=${notebook.readonly} />
</label></p>
<p><label>
${STRINGS.get('Color')}:
<input type="color" value=${form.color || 'transparent'} disabled=${notebook.readonly}
onChange=${ev => setForm(f => ({ ...f, color: ev.target.value }))} />
</label></p>
<p><label>
${STRINGS.get('Description')}:
<${SchemaField} type="textarea" name="description" style=${{ minHeight: makeTextareaHeight(form.description) }}
onChange=${ev => setForm(f => ({ ...f, description: ev.target.value }))}
onInput=${ev => textareaInputHandler(ev.target)} disabled=${notebook.readonly} value=${form.description} />
</label></p>
<p><label>Parse Mode: <select value=${form.parseMode || UNSPECIFIEDS.parseMode} onChange=${ev => setForm(f => ({ ...f, parseMode: ev.target.value }))} disabled=${notebook.readonly}>
<option value="plaintext">Plaintext</option>
<option value="markdown">Markdown</option>
</select></label></p>
<//>
${state.prefs.debugMode && html`
<h4>Debug Experiments</h4>
<p>
<input type="text" placeholder="Notebook ID" value=${form.id} onChange=${ev => setForm(f => ({ ...f, id: ev.target.value }))} />
<button onClick=${() => setState(s => ({ ...s, notebooks: [...s.notebooks, form], selectedNotebookId: null }))}>Move Notebook to ID</button>
</p>
<p>
<button onClick=${() => callApi('POST', 'notebook', notebook.id, notebook)}>Create Remote Notebook</button>
<button onClick=${() => callApi('DELETE', `notebook/${notebook.id}`, notebook.id)}>Delete Remote Notebook</button>
</p>
<p>
<button onClick=${() => upsyncNotebook(notebook.id)}>Upsync Notebook Details</button>
<button onClick=${() => callApi('GET', `notebook/${notebook.id}`, notebook.id).then(data => setNotebook({ ...notebook, ...data.notebook }))}>Downsync Notebook details</button>
</p>
<p>
<button onClick=${() => Object.values(getMessages(notebook.id)).map(message => callApi('PUT', `message/${notebook.id}/${message.id}`, notebook.id, message).then(data => saveMessage(notebook.id, { ...message, ...data.message })))}>Upsync All Messages</button>
<button onClick=${() => callApi('GET', `notebook/${notebook.id}`, notebook.id).then(data => data.messages.forEach(messageId => callApi('GET', `message/${notebook.id}/${messageId}`, notebook.id).then(data => saveMessage(notebook.id, data.message))))}>Downsync All Messages</button>
</p>
${accesses.map(access => html`
<p>
${access.id}
<button onClick=${() => callApi('DELETE', `access/${notebook.id}/${access.id}`, notebook.id)}>Delete</button>
<input type="text" disabled value="${location.href.split('#')[0]}#?notebook=${notebook.id}&key=${notebook.aesKeyB64}&token=${access.id}" />
</p>
`)}
<button onClick=${() => callApi('POST', `access/${notebook.id}`, notebook.id)}>Create New Access</button>
`}
<p>
${' '}<button onClick=${save} disabled=${notebook.readonly}>${STRINGS.get('Save')}</button>
${' '}<button onClick=${del} style="color:red" disabled=${notebook.readonly}>${STRINGS.get('Delete')}</button>
</p>
</div>
`;
}

132
src/app/SystemData.js Normal file
View File

@ -0,0 +1,132 @@
function initSystemData() {
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" },
"Delete?": { it: "Eliminare?" },
"Cancel": { it: "Annulla" },
"Close": { it: "Chiudi" },
"System": { it: "Sistema" },
"Default": { it: "Predefinito" },
"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" },
"Reset Data on Import": { it: "Resetta 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" },
"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" },
"Full": { it: "Completo" },
};
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![](https://windog.octt.eu.org/api/v1/FileProxy/?url=telegram:AgACAgEAAxkBAAIWzWgIq6JoJl57iYVamdd2TmtUYpVMAAJSrzEbpcRBRN2mi5RO7WqiAQADAgADeQADNgQ&type=image/jpeg&timestamp=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: "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",
},
],
},
};
Object.entries(NOTEBOOKS).forEach(([name, values]) => (NOTEBOOKS[name] = { id: name, name, ...values, messages: values.messages.map((message, id) => ({ id, ...message })) }));
return {STRINGS, UNSPECIFIEDS, NOTEBOOKS};
}

View File

@ -2,6 +2,7 @@ window.appMain = () => {
const html = htm.bind(h);
const AppContext = createContext();
const SchemaContext = createContext(null);
localforage.config({ name: "WhichNot" });
navigator.storage.persist();
@ -27,129 +28,7 @@ marked.use({ renderer: {
// }
} });
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" },
"Delete?": { it: "Eliminare?" },
"Cancel": { it: "Annulla" },
"Close": { it: "Chiudi" },
"System": { it: "Sistema" },
"Default": { it: "Predefinito" },
"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" },
"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);
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![](https://windog.octt.eu.org/api/v1/FileProxy/?url=telegram:AgACAgEAAxkBAAIWzWgIq6JoJl57iYVamdd2TmtUYpVMAAJSrzEbpcRBRN2mi5RO7WqiAQADAgADeQADNgQ&type=image/jpeg&timestamp=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: "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",
},
],
},
};
Object.entries(NOTEBOOKS).forEach(([name, values]) => (NOTEBOOKS[name] = { id: name, name, ...values, messages: values.messages.map((message, id) => ({ id, ...message })) }));
const {STRINGS, UNSPECIFIEDS, NOTEBOOKS} = initSystemData();
const uuidv7 = () => {
const bytes = new Uint8Array(16);
@ -167,9 +46,8 @@ const uuidv7 = () => {
[10, 8, 6, 4].forEach(pos => chars.splice(pos, 0, '-'));
return chars.join('');
};
const generateUUID = () => uuidv7(); // crypto.randomUUID();
const generateUUID = () => uuidv7();
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);
@ -186,9 +64,6 @@ const deriveMsgKey = async (rawKey, salt) => crypto.subtle.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 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) => {
@ -221,15 +96,6 @@ const escapeHtml = text => {
const makeParagraph = text => `<p>${text.replaceAll('\n', '<br />')}</p>`
const linkify = text => text.replace(/(\bhttps?:\/\/[^\s]+)/g, '<a href="$1" target="_blank">$1</a>');
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)];
@ -244,7 +110,7 @@ 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) {
if (message instanceof HTMLElement) {
message.scrollIntoView({ behavior: 'smooth', block: 'start' });
messageId && message.focus({ preventScroll: true });
}
@ -258,9 +124,15 @@ const makeTextareaHeight = text => {
};
const textareaInputHandler = el => (el.style.minHeight = makeTextareaHeight(el.value));
const ctx = {
html, AppContext, SchemaContext, STRINGS, UNSPECIFIEDS,
makeTextareaHeight, textareaInputHandler, clickOnEnter, escapeHtml, getFirstLink, makeParagraph, linkify,
};
function App() {
const [state, setState] = useState({
notebooks: [], encrypteds: {}, messages: {}, prefs: {},
notebooks: [], encrypteds: {}, messages: {},
prefs: { debugMode: false },
selectedNotebookId: null, scrollToMessageId: null,
showNotebookSettings: false, showAppSettings: false,
createModal: false, dateTimeModal: null,
@ -268,7 +140,6 @@ function App() {
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();
@ -279,11 +150,17 @@ function App() {
const callApi = useCallback(async (method, path, notebookId, body) => {
try {
body &&= JSON.stringify(body);
if (body) {
delete body.aesKeyB64;
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();
const data = await (await fetch(`https://whichnot.octt.eu.org/api/${query}`, { method, body, headers: {
"Accept": "application/json",
"Content-Type": "application/json",
"X-WhichNot-Request-Hash": hash,
} })).json();
if (data.error) {
console.error(data.error);
alert(data.error);
@ -312,11 +189,21 @@ function App() {
encryptedsStore[notebook.id] = encrypteds;
messagesStore[notebook.id] = messages;
}));
setState(s => ({ ...s, notebooks, encrypteds: encryptedsStore, messages: messagesStore, prefs }));
await setState(s => ({ ...s, notebooks, encrypteds: encryptedsStore, messages: messagesStore, prefs }));
setLoading(false);
})();
}, []);
useEffect(() => {
if (state.prefs.debugMode) {
state.notebooks.forEach(async notebook => {
if (!notebook.hmacKeyB64) {
setNotebook({ ...notebook, hmacKeyB64: await exportJwk(await genHmacKey()) });
}
});
}
}, [loading]);
// 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));
@ -401,6 +288,7 @@ function App() {
} else if (state.contextMenu.visible) {
const menu = document.querySelector('.ContextMenu');
if (menu) {
// @ts-ignore
(menu.children.length > 1 ? menu : menu.children[0]).focus();
};
} else if (state.selectedNotebookId) {
@ -444,7 +332,7 @@ function App() {
parseMode: "markdown", // sourceType: type,
nextMessageId: 1, created: now,
aesKeyB64: await exportJwk(await genAesKey()),
...(state.debugMode && { hmacKeyB64: await exportJwk(await genHmacKey()) }),
...(state.prefs.debugMode && { hmacKeyB64: await exportJwk(await genHmacKey()) }),
//...(state.debugMode && { ecdsaPrivB64: await exportJwk(ecdsa.privateKey), ecdsaPubB64: await exportJwk(ecdsa.publicKey) }),
};
setState(s => ({ ...s,
@ -599,8 +487,8 @@ function App() {
<${ChatScreen} messageInputRef=${messageInputRef} />
${state.createModal && html`<${CreateModal} />`}
${state.crossReplyModal && html`<${CrossReplyModal} />`}
${state.showNotebookSettings && html`<${NotebookSettingsModal} />`}
${state.showAppSettings && html`<${AppSettingsModal} />`}
${state.showNotebookSettings && html`<${NotebookSettingsModal} ctx=${ctx} />`}
${state.showAppSettings && html`<${AppSettingsModal} ctx=${ctx} />`}
${state.contextMenu.visible && html`<${ContextMenu} />`}
${state.dateTimeModal!==null && html`<${DateTimeModal} />`}
${state.searchModal.visible && html`<${SearchModal} />`}
@ -668,7 +556,7 @@ function ChatScreen({messageInputRef}) {
</button> -->
</div>
<div class="Messages">
${messages.map(message => html`<${Message} message=${message} notebook=${notebook} />`)}
${messages.map(message => html`<${Message} message=${message} notebook=${notebook} ctx=${ctx} />`)}
</div>
${!notebook.readonly && html`<div class="SendBar">
${state.replyingTo && html`
@ -693,57 +581,6 @@ function ChatScreen({messageInputRef}) {
`;
}
function Message({message, notebook}) {
const {
state, setState, getMessage, getNotebook,
addReaction, confirmReaction, removeReaction
} = useContext(AppContext);
const rendered = renderTextMessage(message.text, notebook);
return html`
<div class="Message" data-message-id=${message.id} tabindex=0
onKeyDown=${ev => (ev.key==='Enter' && ev.target.dispatchEvent(new MouseEvent('contextmenu', { clientX: (window.innerWidth / 2), clientY: (window.innerHeight / 2) })))}
onContextMenu=${ev => {
ev.preventDefault();
setState(s => ({ ...s, contextMenu: { visible: true, messageId: message.id, x: ev.clientX, y: ev.clientY } }));
}}>
${message.replyTo && html`
<div class="ReplyIndicator" onKeyDown=${clickOnEnter} tabindex=0
onClick=${() => setState(s => ({ ...state,
selectedNotebookId: message.replyTo.notebookId,
scrollToMessageId: (message.replyTo.messageId || message.replyTo.id),
}))}>
${STRINGS.get('Reply to')}: "${
getMessage(message.replyTo.notebookId, (message.replyTo.messageId || message.replyTo.id))?.text || ''
}"
</div>`}
<div dangerouslySetInnerHTML=${{ __html: rendered }} />
${(() => {
const url = getFirstLink(rendered);
if (url) {
return html`<div class="embed">
<iframe src=${url} sandbox=""></iframe>
</div>`;
}
})()}
<div class="reactions">
${Object.keys(message.reactions || {}).map(reaction => html`
<button onClick=${() => removeReaction(message.id, reaction)} disabled=${notebook.readonly}>${reaction}</button>
`)}
${!notebook.readonly && (state.reactionInputFor===message.id
? html`<input type="text" class="ReactionInput" maxlength=2 autofocus enterkeyhint="done" onKeyDown=${ev => {
if (ev.key==='Enter') {
confirmReaction(message.id, ev.target.value);
ev.target.value = '';
}
}} />`
: html`<button class="AddReactionBtn" onClick=${() => addReaction(message.id)}></button>`
)}
</div>
<div class="Timestamp">${new Date(message.created).toLocaleString()}${message.edited ? ` (${STRINGS.get('Edited').toLowerCase()})` : ''}</div>
</div>
`
}
function CreateModal() {
const {createNotebook, setState} = useContext(AppContext);
createNotebook('local');
@ -841,83 +678,6 @@ function DateTimeModal() {
`;
}
function NotebookSettingsModal() {
const {state, setState, callApi, getNotebook, setNotebook, deleteNotebook, upsyncNotebook, getMessages, saveMessage} = useContext(AppContext);
const notebook = getNotebook(state.selectedNotebookId);
if (!notebook) return;
const [form, setForm] = useState({ ...notebook });
useEffect(() => {
setForm({ ...notebook });
}, [notebook.id]);
const save = () => {
setNotebook(form);
setState(s => ({ ...s, showNotebookSettings: false }));
};
const del = () => {
if (confirm(STRINGS.get('Delete?'))) {
// if (notebook.sourceType==='local') {
deleteNotebook(notebook.id);
setState(s => ({ ...s, selectedNotebookId: null, showNotebookSettings: false }));
}
};
const [accesses, setAccesses] = useState([]);
if (state.debugMode) {
useEffect(() => {
let cancelled = false;
setAccesses([]);
callApi('GET', `access/${notebook.id}`, notebook.id).then(data => (!cancelled && data?.accesses && setAccesses(Object.entries(data.accesses).map(([key, values]) => ({ id: key, ...values })))));
return () => { cancelled = true };
}, []);
}
return html`
<div class="NotebookSettingsModal" tabindex=-1>
<div class="ModalHeader">
<h3>${STRINGS.get('Info/Settings')}</h3>
<button onClick=${() => setState(s => ({ ...s, showNotebookSettings: false }))}>${STRINGS.get('Close')}</button>
</div>
<p><label>${STRINGS.get('Name')}: <input type="text" value=${form.name} onChange=${ev => setForm(f => ({ ...f, name: ev.target.value }))} disabled=${notebook.readonly} /></label></p>
<p><label>Emoji: <input type="text" value=${form.emoji} maxLength=2 onChange=${ev => setForm(f => ({ ...f, emoji: ev.target.value }))} disabled=${notebook.readonly} /></label></p>
<p><label>${STRINGS.get('Color')}: <input type="color" value=${form.color || 'transparent'} onChange=${ev => setForm(f => ({ ...f, color: ev.target.value }))} disabled=${notebook.readonly} /></label></p>
<p><label>${STRINGS.get('Description')}: <textarea style=${{ minHeight: makeTextareaHeight(form.description) }} onChange=${ev => setForm(f => ({ ...f, description: ev.target.value }))} onInput=${ev => textareaInputHandler(ev.target)} disabled=${notebook.readonly}>${form.description}</textarea></label></p>
<p><label>Parse Mode: <select value=${form.parseMode || UNSPECIFIEDS.parseMode} onChange=${ev => setForm(f => ({ ...f, parseMode: ev.target.value }))} disabled=${notebook.readonly}>
<option value="plaintext">Plaintext</option>
<option value="markdown">Markdown</option>
</select></label></p>
${state.debugMode && html`
<h4>Debug Experiments</h4>
<p>
<input type="text" placeholder="Notebook ID" value=${form.id} onChange=${ev => setForm(f => ({ ...f, id: ev.target.value }))} />
<button onClick=${() => setState(s => ({ ...s, notebooks: [...s.notebooks, form], selectedNotebookId: null }))}>Move Notebook to ID</button>
</p>
<p>
<button onClick=${() => callApi('POST', 'notebook', notebook.id, notebook)}>Create Remote Notebook</button>
<button onClick=${() => callApi('DELETE', `notebook/${notebook.id}`, notebook.id)}>Delete Remote Notebook</button>
</p>
<p>
<button onClick=${() => upsyncNotebook(notebook.id)}>Upsync Notebook Details</button>
<button onClick=${() => callApi('GET', `notebook/${notebook.id}`, notebook.id).then(data => setNotebook({ ...notebook, ...data.notebook }))}>Downsync Notebook details</button>
</p>
<p>
<button onClick=${() => Object.values(getMessages(notebook.id)).map(message => callApi('PUT', `message/${notebook.id}/${message.id}`, notebook.id, message))}>Upsync All Messages</button>
<button onClick=${() => callApi('GET', `notebook/${notebook.id}`, notebook.id).then(data => data.messages.forEach(messageId => callApi('GET', `message/${notebook.id}/${messageId}`, notebook.id).then(data => saveMessage(notebook.id, data.message))))}>Downsync All Messages</button>
</p>
${accesses.map(access => html`
<p>
${access.id}
<button onClick=${() => callApi('DELETE', `access/${notebook.id}/${access.id}`, notebook.id)}>Delete</button>
<input type="text" disabled value="${location.href.split('#')[0]}#?notebook=${notebook.id}&key=${notebook.aesKeyB64}&token=${access.id}" />
</p>
`)}
<button onClick=${() => callApi('POST', `access/${notebook.id}`, notebook.id)}>Create New Access</button>
`}
<p>
${' '}<button onClick=${save} disabled=${notebook.readonly}>${STRINGS.get('Save')}</button>
${' '}<button onClick=${del} style="color:red" disabled=${notebook.readonly}>${STRINGS.get('Delete')}</button>
</p>
</div>
`;
}
function SearchModal() {
const {state, setState, getNotebook} = useContext(AppContext);
const {query, global} = state.searchModal;
@ -944,73 +704,25 @@ function SearchModal() {
`;
}
function AppSettingsModal() {
const {state, setState} = useContext(AppContext);
const [importTxt, setImportTxt] = useState('');
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]))]))),
}));
// 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`
<div class="AppSettingsModal" tabindex=-1>
<div class="ModalHeader">
<h3>${STRINGS.get('App Settings')}</h3>
<button onClick=${() => setState(s => ({ ...s, showAppSettings: false }))}>${STRINGS.get('Close')}</button>
</div>
<h4>${STRINGS.get('Aesthetics')}</h4>
<p><label>${STRINGS.get('Color Scheme')}: <select value=${state.prefs.colorScheme} onChange=${ev => setState(s => ({ ...s, prefs: { ...s.prefs, colorScheme: ev.target.value } }))}>
<option value="system" default>Auto (${STRINGS.get('System')})</option>
<option value="light">${STRINGS.get('Light')}</option>
<option value="dark">${STRINGS.get('Dark')}</option>
</select></label></p>
<p>
<label>${STRINGS.get('Message Input Font')}:</label> <select value=${state.prefs.messageInput?.fontFamily} onChange=${ev => setState(s => ({ ...s, prefs: { ...s.prefs, messageInput: { ...s.prefs.messageInput, fontFamily: ev.target.value } } }))}>
<option value="" default>${STRINGS.get('Default')} (Browser)</option>
<option value="monospace">Monospace</option>
<option value="serif">Serif</option>
<option value="sans-serif">Sans-Serif</option>
<option value="cursive">Cursive</option>
<option value="fantasy">Fantasy</option>
</select> <input type=number placeholder="${STRINGS.get('Size')} (pt)" min=6 max=20 value=${state.prefs.messageInput?.fontSize?.slice(0, -2)} onChange=${ev => 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'))} />
</p>
<p><label>${STRINGS.get('Language')}: <select value=${state.prefs.language} onChange=${ev => setState(s => ({ ...s, prefs: { ...s.prefs, language: ev.target.value }}))}>
<option value="" default>Auto (${STRINGS.get('System')})</option>
<option value="en">🇬🇧 English</option>
<option value="it">🇮🇹 Italiano</option>
</select></label></p>
<h4>${STRINGS.get('Export Data')}</h4>
<textarea readonly rows=8>${exportData()}</textarea>
<h4>${STRINGS.get('Import Data')}</h4>
<textarea rows=8 placeholder=${STRINGS.get('Paste JSON')} onInput=${ev => setImportTxt(ev.target.value)} />
<button onClick=${doImport}>${STRINGS.get('Import Data')}</button>
<!--<h4>Other</h4>
<label><input type="checkbox" checked=${state.debugMode} onChange=${ev => setState(s => ({ ...s, debugMode: ev.target.checked }))} /> Experimental/Debug features ${state.debugMode && html`(resets on restart)`}</label>-->
<br /><br />
</div>
`;
window.ModalHeader = function ModalHeader({title, children}) {
return html`<div class="ModalHeader">
<h3>${STRINGS.get(title)}</h3>
${children}
</div>`;
}
document.querySelector('body > noscript').remove();
window.SchemaForm = function SchemaForm({schema, children}) {
return html`<${SchemaContext.Provider} value=${schema}>${children}<//>`;
}
window.SchemaField = function SchemaField(props) {
const schema = useContext(SchemaContext).properties[props.name];
const elem = (props.type === 'textarea' ? html`<textarea><//>` : html`<input />`);
Object.assign(elem.props, props, schema);
return elem;
}
document.querySelector('body > noscript')?.remove();
render(html`<${App} />`, document.body);
};

View File

Before

Width:  |  Height:  |  Size: 1.3 MiB

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@ -1,8 +1,8 @@
{
"name": " WhichNot",
"short_name": "WhichNot",
"scope": "https://whichnot.octt.eu.org/",
"start_url": "https://whichnot.octt.eu.org/",
"scope": "/",
"start_url": "/",
"display": "standalone",
"icons": [
{ "src": "./icon.png", "type": "image/png", "sizes": "1024x1024" }

100
src/app/style.css Normal file
View File

@ -0,0 +1,100 @@
:root {
--wapp-green: #00a884;
--header-bg: light-dark(#f0f2f5, #0f0d0a);
--main-bg: light-dark(white, #0f0d0a);
--bubble-bg: light-dark(white, #2b2a33);
--chat-bg: light-dark(#efeae2, #10151d);
--focus-bg: light-dark(#f5f5f5, #2b2a33);
}
* { box-sizing: border-box; }
html, body {
margin: 0; height: 100%;
font-family: Arial, sans-serif;
color-scheme: light dark;
}
html[data-theme="light"], html[data-theme="light"] body {
color-scheme: light;
}
html[data-theme="dark"], html[data-theme="dark"] body {
color-scheme: dark;
}
.App { display: flex; height: 100vh; }
.ChatList {
width: 30%; overflow-y: auto;
background: var(--main-bg); border-right: 1px solid #ddd;
}
.ChatList-header {
display: flex; justify-content: space-between; align-items: center;
padding: .75rem 1rem; height: 3.5rem; border-bottom: 1px solid #ddd;
}
.ChatList-header button { background: none; border: none; font-size: 1.25rem; cursor: pointer; }
.NotebookButton { width: 100%; padding: .75rem 1rem; background: none; border: none; cursor: pointer; text-align: left; }
.NotebookButton:hover { background: var(--focus-bg); }
.NotebookTitle { display: flex; align-items: center; gap: .5rem; }
.NotebookEmoji {
width: 1.5rem; height: 1.5rem;
min-width: 1.5rem; min-height: 1.5rem;
display: flex; align-items: center; justify-content: center;
border-radius: 50%; font-size: 1rem;
}
.NotebookName { margin: 0; font-size: 1rem; }
.NotebookDescription { font-size: .875rem; color: #555; margin: .25rem 0 0 2rem; }
.NotebookPreview { font-size: .875rem; color: #666; margin: .25rem 0 0 2rem; }
.NotebookDescription, .NotebookPreview, .ReplyPreviewText { text-overflow: ellipsis; overflow: hidden; text-wrap: nowrap; }
.ChatScreen { flex: 1; display: none; flex-direction: column; background: var(--chat-bg); }
.App.show-chat .ChatScreen { display: flex; }
.ChatHeader { background: var(--header-bg); padding: .5rem; height: 3.5rem; display: flex; align-items: center; gap: .5rem; border-bottom: 1px solid #ddd; cursor: pointer; }
.ChatHeader h3 { margin: 0; flex: 1; font-size: 1rem; }
.BackButton, .SearchButton { font-size: 1.5rem; padding: .25rem; background: none; border: none; cursor: pointer; }
.Messages { flex: 1; overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: .5rem; }
.Message { background: var(--bubble-bg); padding: .5rem 1rem; border-radius: .5rem; max-width: 80%; word-break: break-word; margin: .5rem auto; position: relative; }
.Message .reactions { display: flex; gap: .25rem; margin-top: .25rem; }
.Message .reactions button { background: var(--chat-bg); border: none; border-radius: .25rem; padding: 0 .5rem; cursor: pointer; }
.Message iframe, .Message img, .Message object, .Message embed { border: none; max-width: 100%; }
.Message .embed, .Message .embed iframe { width: 100%; text-align: center; }
.AddReactionBtn { font-size: .9rem; background: none; border: none; cursor: pointer; color: var(--wapp-green); }
.ReactionInput { width: 2rem; padding: .1rem; font-size: 1rem; }
.Timestamp { font-size: .75rem; color: #666; margin-top: .25rem; text-align: right; }
.SendBar { display: flex; gap: .5rem; padding: 1rem; background: var(--main-bg); border-top: 1px solid #ddd; flex-direction: column; }
.ReplyPreview { background: var(--chat-bg); padding: .5rem; border-radius: .25rem; display: flex; justify-content: space-between; align-items: center; }
.EditArea { /* flex: 1; */ padding: .5rem; border: 1px solid #ddd; border-radius: .5rem; resize: none; height: 4em; }
.ContextMenu {
position: fixed; z-index: 1000; min-width: 140px;
background: var(--main-bg); border: 1px solid #ddd; border-radius: .25rem; box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.ContextMenuItem { padding: .5rem 1rem; cursor: pointer; }
.ContextMenuItem:hover { background: var(--focus-bg); }
button.ContextMenuItem { width: 100%; display: block; background: inherit; border: none; text-align: left; font-size: inherit; }
.DateTimeModal, .SearchModal, .AppSettingsModal, .CreateModal, .NotebookSettingsModal, .CrossReplyModal {
position: fixed; margin: auto; top: 0; bottom: 0; left: 0; right: 0; /* top: 50%; left: 50%; transform: translate(-50%,-50%); */
background: var(--main-bg); padding: 2rem; border-radius: .5rem; box-shadow: 0 0 1rem rgba(0,0,0,0.1);
max-height: 80vh; overflow-y: auto; width: 90%; max-width: 600px; z-index: 1001;
}
.SearchModal input, .AppSettingsModal textarea, .CreateModal input, .NotebookSettingsModal input, .NotebookSettingsModal textarea {
width: 100%; /* margin: .5rem 0; */ padding: .5rem; resize: vertical;
border: 1px solid #ddd; border-radius: .25rem;
}
.AppSettingsModal input[type="color"], .NotebookSettingsModal input[type="color"] { padding: revert; }
.SearchResult { padding: .5rem 0; border-bottom: 1px solid #eee; cursor: pointer; }
.SearchResult:hover { background: var(--focus-bg); }
.ModalHeader { display: flex; }
.ModalHeader h3 { flex: 1; }
.ModalHeader button { height: max-content; }
.ReplyIndicator {
border-left: 3px solid var(--wapp-green); padding-left: .5rem; margin-bottom: .5rem;
color: #666; font-size: .9em; cursor: pointer;
}
@media (max-width: 768px) {
.ChatList { width: 100%; }
.ChatScreen { width: 100%; display: none; }
.App.show-chat .ChatList { display: none; }
.App.show-chat .ChatScreen { display: flex; }
}

52
src/index.html Normal file
View File

@ -0,0 +1,52 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script>
if ('serviceWorker' in navigator && location.protocol === 'https:') {
navigator.serviceWorker.register('/service-worker.js', { scope: "/" });
}
</script>
<link rel="manifest" href="./manifest.json" />
<link rel="shortcut icon" href="./icon.png" type="image/png" sizes="1024x1024" />
<title>WhichNot</title>
<link rel="stylesheet" href="./style.css" />
</head>
<body>
<noscript><p>This application requires modern JavaScript.</p></noscript>
<script type="module">
// Define and load ESM libraries, and assign the needed modules to global scope
// Libs can't be loaded from file://, so a remote CDN must be used in that case
const libs = [
{
l: 'preact/preact.js',
g: 'https://esm.sh/preact',
h: mod => {
const { h, render, createContext } = mod;
Object.assign(window, { h, render, createContext });
},
},
{
l: 'preact/hooks.js',
g: 'https://esm.sh/preact/hooks',
h: mod => {
const { useState, useEffect, useCallback, useRef, useContext } = mod;
Object.assign(window, { useState, useEffect, useCallback, useRef, useContext });
},
},
{
l: 'preact/htm.js',
g: 'https://esm.sh/htm',
h: mod => (window.htm = mod.default),
},
];
await Promise.all(libs.map(lib => {
const url = (location.protocol === 'file:' ? lib.g : `./lib/${lib.l}`);
return import(url).then(lib.h);
}));
appMain();
</script>
</body>
</html>

77
src/schema.json Normal file
View File

@ -0,0 +1,77 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://whichnot.octt.eu.org/schema.json",
"title": "WhichNot",
"type": "object",
"oneOf": [
{
"properties": {
"type": { "const": "notebook" },
"data": { "$ref": "#/definitions/notebook" }
},
"required": ["type", "data"],
"additionalProperties": false
},
{
"properties": {
"type": { "const": "message" },
"data": { "$ref": "#/definitions/message" }
},
"required": ["type", "data"],
"additionalProperties": false
}
],
"definitions": {
"notebook": {
"type": "object",
"properties": {
"id": { "type": "string", "pattern": "^[0-9a-fA-F\\-]{36}$" },
"name": { "type": "string", "maxLength": 128 },
"emoji": { "type": "string", "pattern": "^..?$" },
"color": { "type": "string", "pattern": "^#[0-9A-Fa-f]{6}$" },
"description": { "type": "string", "maxLength": 1024 },
"parseMode": { "type": "string", "enum": ["plaintext", "markdown"] },
"created": { "type": "integer", "minimum": 0 },
"edited": { "type": "integer", "minimum": 0 },
"nextMessageId": { "type": "integer", "minimum": 1 },
"hmacKeyB64": { "type": "string", "contentEncoding": "base64", "maxLength": 256 }
},
"required": ["id", "created", "hmacKeyB64"],
"additionalProperties": false
},
"message": {
"type": "object",
"properties": {
"id": { "type": "integer", "minimum": 1 },
"created": { "type": "integer", "minimum": 0 },
"edited": { "type": "integer", "minimum": 0 },
"ciphertext": { "type": "string", "contentEncoding": "base64", "maxLength": 16384 },
"iv": { "type": "string", "contentEncoding": "base64", "maxLength": 16 },
"salt": { "type": "string", "contentEncoding": "base64", "maxLength": 16 },
"replyTo": {
"oneOf": [
{ "type": "null" },
{
"type": "object",
"properties": {
"notebookId": { "type": "string", "pattern": "^[0-9a-fA-F\\-]{36}$" },
"messageId": { "type": "integer", "minimum": 1 }
},
"required": ["notebookId", "messageId"],
"additionalProperties": false
}
]
},
"reactions": {
"type": "object",
"patternProperties": {
"^..?$": { "type": "boolean" }
},
"additionalProperties": false
}
},
"required": ["id", "created", "ciphertext", "iv", "salt"],
"additionalProperties": false
}
}
}

View File

@ -1,3 +1,4 @@
// @ts-nocheck
const cacheName = 'WhichNot/v1';
const cachables = {
"/": "networkFirst",
@ -47,10 +48,7 @@ self.addEventListener('install', (event) => {
self.skipWaiting();
event.waitUntil(
caches.open(cacheName).then((cache) => cache.addAll([
'/', '/index.html', '/manifest.json',
'/app.js', '/icon.png',
'/localforage.min.js', '/marked.min.js',
'/preact/preact.js', '/preact/hooks.js', '/preact/htm.js',
'/', //files//
])),
);
});

15
tsconfig.json Normal file
View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"checkJs": true,
"noEmit": true,
"module": "es2020",
"target": "esnext",
"strict": true,
"noImplicitAny": false,
"forceConsistentCasingInFileNames": true,
},
"include": [
"globals.d.ts",
"src/app/*.js",
],
}