mirror of
https://gitlab.com/octospacc/WhichNot.git
synced 2025-06-27 09:02:56 +02:00
The update is plentiful this time
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
localforage.min.js
|
||||
marked.min.js
|
||||
preact/
|
693
app.js
Normal file
693
app.js
Normal file
@ -0,0 +1,693 @@
|
||||
window.appMain = () => {
|
||||
|
||||
const html = htm.bind(h);
|
||||
const AppContext = createContext();
|
||||
|
||||
localforage.config({ name: "WhichNot" });
|
||||
navigator.storage.persist();
|
||||
|
||||
// marked.use({ renderer: {
|
||||
// image({ href, title, text }) { // allow embedding any media with ![]()
|
||||
// title = (title ? ` title="${escapeHtml(title)}"` : '');
|
||||
// return `<object data="${href}" alt="${text}" title="${title}">
|
||||
// <embed src="${href}" alt="${text}" title="${title}" />
|
||||
// ${text}
|
||||
// </object>`;
|
||||
// }
|
||||
// } });
|
||||
|
||||
const STRINGS = {
|
||||
"Notebook": { it: "Quaderno" },
|
||||
"Copy": { it: "Copia" },
|
||||
"Reply": { it: "Rispondi" },
|
||||
"Reply in Another Notebook": { it: "Rispondi in un Altro Quaderno" },
|
||||
"Reply to": { it: "Risposta a" },
|
||||
"Edit": { it: "Modifica" },
|
||||
"Edited": { it: "Modificato" },
|
||||
"Set Date/Time": { it: "Imposta Data/Ora" },
|
||||
"Send": { it: "Invia" },
|
||||
"Save": { it: "Salva" },
|
||||
"Delete": { it: "Elimina" },
|
||||
"Cancel": { it: "Annulla" },
|
||||
"Close": { it: "Chiudi" },
|
||||
"Name": { it: "Nome" },
|
||||
"Color": { it: "Colore" },
|
||||
"Description": { it: "Descrizione" },
|
||||
"Info/Settings": { it: "Info/Impostazioni" },
|
||||
"App Settings": { it: "Impostazioni App" },
|
||||
"Export Data": { it: "Esporta Dati" },
|
||||
"Import Data": { it: "Importa Dati" },
|
||||
"Paste JSON": { it: "Incolla JSON" },
|
||||
"Invalid data format": { it: "Formato dati invalido" },
|
||||
"Invalid JSON syntax": { it: "Sintassi JSON invalida" },
|
||||
"No description": { it: "Nessuna descrizione" },
|
||||
"No notes": { it: "Nessuna nota" },
|
||||
"Info and Demo": { it: "Info e Demo" },
|
||||
};
|
||||
STRINGS.get = (name, lang=navigator.language.split('-')[0]) => (STRINGS[name]?.[lang] || STRINGS[name]?.en || name);
|
||||
|
||||
const UNSPECIFIEDS = {
|
||||
parseMode: "plaintext",
|
||||
};
|
||||
const NOTEBOOKS = {
|
||||
"WhichNot": {
|
||||
emoji: "ℹ️",
|
||||
description: STRINGS.get('Info and Demo'),
|
||||
parseMode: "markdown",
|
||||
readonly: true,
|
||||
messages: [
|
||||
{
|
||||
text: "**WhichNot is finally released and REAL!!!** BILLIONS MUST ENJOY!!!",
|
||||
created: "2025-04-20T23:00",
|
||||
reactions: { "💝": true },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
Object.entries(NOTEBOOKS).forEach(([name, values]) => (NOTEBOOKS[name] = { id: name, name, ...values, messages: values.messages.map((message, id) => ({ id, ...message })) }));
|
||||
|
||||
const uuidv7 = () => {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
const time = BigInt(Date.now());
|
||||
bytes[0] = Number((time >> 40n) & 0xffn);
|
||||
bytes[1] = Number((time >> 32n) & 0xffn);
|
||||
bytes[2] = Number((time >> 24n) & 0xffn);
|
||||
bytes[3] = Number((time >> 16n) & 0xffn);
|
||||
bytes[4] = Number((time >> 8n) & 0xffn);
|
||||
bytes[5] = Number(time & 0xffn);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const chars = Array.from(bytes).map(byte => byte.toString(16).padStart(2, '0'));
|
||||
[10, 8, 6, 4].forEach(pos => chars.splice(pos, 0, '-'));
|
||||
return chars.join('');
|
||||
}
|
||||
const generateUUID = () => uuidv7(); // crypto.randomUUID();
|
||||
const genAESKey = async () => crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
// const genEd25519 = async () => crypto.subtle.generateKey({ name: 'Ed25519', namedCurve: 'Ed25519' }, true, ['sign', 'verify']);
|
||||
const exportJWK = async (key) => btoa(JSON.stringify(await crypto.subtle.exportKey('jwk', key)));
|
||||
const importJWK = async (b64, alg, usages) => crypto.subtle.importKey('jwk', JSON.parse(atob(b64)), alg, true, usages);
|
||||
const randBytes = (n=12) => {
|
||||
const b = new Uint8Array(n);
|
||||
crypto.getRandomValues(b);
|
||||
return b;
|
||||
}
|
||||
const bufToB64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
const b64ToBuf = (str) => Uint8Array.from(atob(str), (c => c.charCodeAt(0)));
|
||||
const deriveMsgKey = async (rawKey, salt) => crypto.subtle.deriveKey(
|
||||
{ name: 'HKDF', salt, info: new TextEncoder().encode('msg'), hash: 'SHA-256' },
|
||||
await crypto.subtle.importKey('raw', rawKey, 'HKDF', false, ['deriveKey']),
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true, ['encrypt', 'decrypt']);
|
||||
const getAesRawKey = async (aesKeyB64) => await crypto.subtle.exportKey('raw', await importJWK(aesKeyB64, { name: 'AES-GCM' }, ['encrypt','decrypt']));
|
||||
|
||||
const encryptMessage = async (message, rawKey) => {
|
||||
const salt = randBytes();
|
||||
const iv = randBytes(12);
|
||||
const key = await deriveMsgKey(rawKey, salt);
|
||||
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(message.text));
|
||||
const encrypted = { ...message,
|
||||
salt: bufToB64(salt),
|
||||
iv: bufToB64(iv),
|
||||
ciphertext: bufToB64(ct),
|
||||
};
|
||||
delete encrypted.text;
|
||||
return encrypted;
|
||||
};
|
||||
const decryptMessage = async (encrypted, rawKey) => {
|
||||
const salt = b64ToBuf(encrypted.salt);
|
||||
const iv = b64ToBuf(encrypted.iv);
|
||||
const key = await deriveMsgKey(rawKey, salt);
|
||||
const ct = b64ToBuf(encrypted.ciphertext);
|
||||
const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
|
||||
return { ...encrypted, text: new TextDecoder().decode(dec) };
|
||||
};
|
||||
|
||||
const escapeHtml = text => {
|
||||
const node = document.createElement('p');
|
||||
node.appendChild(document.createTextNode(text));
|
||||
return node.innerHTML;
|
||||
}
|
||||
const makeParagraph = text => `<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)];
|
||||
const randomColor = () => ('#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0'));
|
||||
|
||||
const closedContextMenu = s => ({ contextMenu: { ...s.contextMenu, visible: false } });
|
||||
|
||||
function App() {
|
||||
const [state, setState] = useState({
|
||||
notebooks: [], encrypteds: {}, messages: {},
|
||||
selectedNotebookId: null, scrollToMessageId: null,
|
||||
showNotebookSettings: false, showAppSettings: false,
|
||||
createModal: false, dateTimeModal: null,
|
||||
crossReplyModal: false, crossReplySource: null,
|
||||
contextMenu:{ visible: false, messageId: null, x: 0, y: 0 },
|
||||
searchModal: { visible: false, global: false, query: '' },
|
||||
editingMessage: null, replyingTo: null, reactionInputFor: null,
|
||||
});
|
||||
const messageInputRef = useRef();
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Load data from storage
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const notebooksList = await localforage.getItem('notebooks') || [];
|
||||
const notebooks = [];
|
||||
const [messagesStore, encryptedsStore] = [{}, {}];
|
||||
await Promise.all(notebooksList.map(async notebook => {
|
||||
notebooks.push(notebook = await localforage.getItem(`notebooks/${notebook}`));
|
||||
const [messages, encrypteds] = [{}, {}];
|
||||
const messagesList = await localforage.getItem(`messages/${notebook.id}`);
|
||||
const rawKey = await getAesRawKey(notebook.aesKeyB64);
|
||||
await Promise.all(messagesList.map(async messageId => (encrypteds[messageId] = await localforage.getItem(`messages/${notebook.id}/${messageId}`))));
|
||||
await Promise.all(Object.values(encrypteds).map(async encrypted => (messages[encrypted.id] = await decryptMessage(encrypted, rawKey))));
|
||||
encryptedsStore[notebook.id] = encrypteds;
|
||||
messagesStore[notebook.id] = messages;
|
||||
}));
|
||||
setState(s => ({ ...s, notebooks, encrypteds: encryptedsStore, messages: messagesStore }));
|
||||
setLoading(false);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Persist notebooks meta
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
for (const notebook of state.notebooks) {
|
||||
localforage.setItem(`notebooks/${notebook.id}`, notebook);
|
||||
}
|
||||
localforage.setItem('notebooks', state.notebooks.map(notebook => notebook.id));
|
||||
}
|
||||
}, [state.notebooks]);
|
||||
|
||||
// Persist encrypted store
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
for (const notebookId in state.encrypteds) {
|
||||
const messages = state.encrypteds[notebookId];
|
||||
if (!messages) return;
|
||||
localforage.setItem(`messages/${notebookId}`, Object.keys(messages));
|
||||
for (const message of Object.values(messages)) {
|
||||
localforage.setItem(`messages/${notebookId}/${message.id}`, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [state.encrypteds]);
|
||||
|
||||
// Close context on click-away
|
||||
useEffect(() => {
|
||||
const handler = ev => {
|
||||
if (state.contextMenu.visible) {
|
||||
const menu = document.querySelector('.ContextMenu');
|
||||
if (menu && !menu.contains(ev.target)) {
|
||||
setState(s => ({ ...s, ...closedContextMenu(s) }));
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handler);
|
||||
return () => document.removeEventListener('click', handler);
|
||||
}, [state.contextMenu.visible]);
|
||||
|
||||
const createNotebook = useCallback(async (type) => {
|
||||
let id = /* (type === 'local' ? */ generateUUID(); /* : prompt('Remote ID:')); */
|
||||
// if (!id) return;
|
||||
const now = Date.now();
|
||||
// const ed = await genEd25519();
|
||||
const notebook = {
|
||||
id, name: `${STRINGS.get('Notebook')} ${now}`, description: '',
|
||||
emoji: randomEmoji(), color: randomColor(),
|
||||
parseMode: "markdown", // sourceType: type,
|
||||
nextMessageId: 1, created: now,
|
||||
aesKeyB64: await exportJWK(await genAESKey()), // edPrivB64: await exportJWK(ed.privateKey), edPubB64: await exportJWK(ed.publicKey),
|
||||
};
|
||||
setState(s => ({ ...s,
|
||||
notebooks: [ ...s.notebooks, notebook ],
|
||||
encrypteds: { ...s.encrypteds, [id]: {} },
|
||||
messages: { ...s.messages, [id]: {} },
|
||||
createModal: false,
|
||||
}));
|
||||
// if (type==='remote') {
|
||||
// await fetch(`/notebook/${id}`, {
|
||||
// method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({ publicKey: pubB64 }),
|
||||
// });
|
||||
// }
|
||||
}, [state.notebooks]);
|
||||
|
||||
const getNotebook = useCallback(notebookId => (state.notebooks.find(notebook => (notebook.id === notebookId)) || NOTEBOOKS[notebookId]), [state.notebooks]);
|
||||
const deleteNotebook = (notebookId) => {
|
||||
const messagesList = Object.keys(getMessages(notebookId));
|
||||
setState(s => ({ ...s,
|
||||
notebooks: s.notebooks.filter(notebook => (notebook.id !== notebookId)),
|
||||
messages: { ...s.messages, [notebookId]: undefined },
|
||||
encrypteds: { ...s.encrypteds, [notebookId]: undefined },
|
||||
}));
|
||||
localforage.removeItem(`notebooks/${notebookId}`);
|
||||
localforage.removeItem(`messages/${notebookId}`);
|
||||
for (const messageId of messagesList) {
|
||||
localforage.removeItem(`messages/${notebookId}/${messageId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getMessages = useCallback((notebookId) => (state.messages[notebookId] || NOTEBOOKS[notebookId]?.messages || {}), [state.messages]);
|
||||
const getMessage = useCallback((notebookId, messageId) => getMessages(notebookId)[messageId], [state.messages]);
|
||||
|
||||
const saveMessage = (notebookId, message) => persistMessages(notebookId, { ...getMessages(notebookId), [message.id]: message });
|
||||
const deleteMessage = (notebookId, messageId) => {
|
||||
const messages = getMessages(notebookId);
|
||||
delete messages[messageId];
|
||||
persistMessages(notebookId, messages);
|
||||
localforage.removeItem(`messages/${notebookId}/${messageId}`);
|
||||
};
|
||||
const copyMessage = message => navigator.clipboard.writeText(message.text);
|
||||
|
||||
const persistMessages = useCallback(async (notebookId, messages) => {
|
||||
const notebook = getNotebook(notebookId);
|
||||
// if (!notebook) return;
|
||||
const rawKey = await getAesRawKey(notebook.aesKeyB64);
|
||||
const encrypteds = {};
|
||||
await Promise.all(Object.values(messages).map(async message => (encrypteds[message.id] = await encryptMessage(message, rawKey))));
|
||||
setState(s => ({ ...s,
|
||||
encrypteds: { ...s.encrypteds, [notebookId]: encrypteds },
|
||||
messages: { ...s.messages, [notebookId]: messages },
|
||||
}));
|
||||
// if (notebook.sourceType==='remote') {
|
||||
// const priv = await importJWK(notebook.edPrivB64, { name: 'Ed25519', namedCurve: 'Ed25519' }, ['sign']),
|
||||
// payload = new TextEncoder().encode(JSON.stringify(encArr)),
|
||||
// sig = bufToB64(await crypto.subtle.sign('Ed25519', priv, payload));
|
||||
// await fetch(`/notebook/${nbId}/sync`, {
|
||||
// method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({ encryptedArr: encArr, signature: sig, publicKey: notebook.edPubB64 }),
|
||||
// });
|
||||
// }
|
||||
}, [state.notebooks]);
|
||||
|
||||
const addReaction = useCallback(messageId => setState(s => ({ ...s, reactionInputFor: messageId })), []);
|
||||
const confirmReaction = useCallback(async (messageId, emoji) => {
|
||||
setState(s => ({ ...s, reactionInputFor: null }));
|
||||
if (!emoji) return;
|
||||
const notebookId = state.selectedNotebookId;
|
||||
const message = getMessage(notebookId, messageId);
|
||||
if (!(emoji in message.reactions)) {
|
||||
message.reactions[emoji] = true;
|
||||
saveMessage(notebookId, message);
|
||||
}
|
||||
}, [state.selectedNotebookId, state.messages, persistMessages]);
|
||||
const removeReaction = useCallback(async (messageId, emoji) => {
|
||||
const notebookId = state.selectedNotebookId;
|
||||
const message = getMessage(notebookId, messageId);
|
||||
if (emoji in message.reactions) {
|
||||
delete message.reactions[emoji];
|
||||
saveMessage(notebookId, message);
|
||||
}
|
||||
}, [state.selectedNotebookId, state.messages, persistMessages]);
|
||||
useEffect(() => setState(s => ({ ...s, reactionInputFor: null })), [state.selectedNotebookId]);
|
||||
|
||||
// Editing effect: prefill textarea when entering edit mode
|
||||
useEffect(() => {
|
||||
if (state.editingMessage!=null && messageInputRef.current) {
|
||||
const message = state.messages[state.selectedNotebookId]?.[state.editingMessage];
|
||||
if (message) {
|
||||
messageInputRef.current.value = message.text;
|
||||
}
|
||||
}
|
||||
}, [state.editingMessage, state.selectedNotebookId, state.messages]);
|
||||
|
||||
useEffect(() => (state.scrollToMessageId==null && Array.from(document.querySelectorAll('.Message[data-message-id]')).slice(-1)[0]?.scrollIntoView({ behavior: 'smooth', block: 'start' })), [state.selectedNotebookId]);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
const notebookId = state.selectedNotebookId;
|
||||
//if (!notebookId) return;
|
||||
const text = messageInputRef.current.value.trim();
|
||||
if (!text) return;
|
||||
const notebook = getNotebook(notebookId);
|
||||
let message = getMessage(notebookId, state.editingMessage);
|
||||
if (!message) {
|
||||
message = {
|
||||
id: notebook.nextMessageId,
|
||||
created: Date.now(),
|
||||
replyTo: state.replyingTo,
|
||||
reactions: {},
|
||||
};
|
||||
}
|
||||
message = { ...message, text, edited: (state.editingMessage!=null ? Date.now() : false), };
|
||||
messageInputRef.current.value = '';
|
||||
// update nextMessageId if new
|
||||
setState(s => ({ ...s, notebooks: s.notebooks.map(notebook => notebook.id===notebookId
|
||||
? { ...notebook, nextMessageId: (state.editingMessage==null ? notebook.nextMessageId+1 : notebook.nextMessageId) }
|
||||
: notebook
|
||||
) }));
|
||||
saveMessage(notebookId, message);
|
||||
setState( s => ({ ...s, editingMessage: null, replyingTo: null }));
|
||||
}, [state.selectedNotebookId, state.editingMessage, state.replyingTo, state.messages, state.notebooks]);
|
||||
|
||||
return html`
|
||||
<${AppContext.Provider} value=${{
|
||||
state, setState, createNotebook,
|
||||
getNotebook, deleteNotebook,
|
||||
getMessages, getMessage,
|
||||
sendMessage, persistMessages,
|
||||
saveMessage, deleteMessage, copyMessage,
|
||||
addReaction, confirmReaction, removeReaction,
|
||||
}}>
|
||||
<div class="App ${state.selectedNotebookId ? 'show-chat' : ''}">
|
||||
<${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} />`}
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="ChatList">
|
||||
<div class="ChatList-header">
|
||||
<button onClick=${() => setState(s => ({ ...s, createModal: true }))}>+</button>
|
||||
<!-- <button onClick=${() => setState(s => ({ ...s, searchModal: { visible: true, global: true, query: '' } }))}>🔍</button> -->
|
||||
<button onClick=${() => setState(s => ({ ...s, showAppSettings: true }))}>⚙️</button>
|
||||
</div>
|
||||
${[ ...state.notebooks.sort((a, b) => (sortNotebook(b) - sortNotebook(a))), ...Object.values(NOTEBOOKS) ].map(notebook => html`
|
||||
<button class="NotebookButton" key=${notebook.id} onClick=${() => setState(s => ({ ...s, selectedNotebookId: notebook.id }))}>
|
||||
<div class="NotebookTitle">
|
||||
<div class="NotebookEmoji" style=${{ background: notebook.color }}>${notebook.emoji}</div>
|
||||
<h4 class="NotebookName">${notebook.name}</h4>
|
||||
</div>
|
||||
<div class="NotebookDescription">${escapeHtml(notebook.description) || html`<i>${STRINGS.get('No description')}</i>`}</div>
|
||||
<div class="NotebookPreview">
|
||||
${Object.values(getMessages(notebook.id)).slice(-1)[0]?.text || html`<i>${STRINGS.get('No notes')}</i>`}
|
||||
</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="ChatScreen">
|
||||
<div class="ChatHeader" onClick=${() => setState(s => ({ ...s, showNotebookSettings: true }))} onKeyDown=${ev => (ev.key==='Enter' && ev.target.click())} tabindex=0 role="button">
|
||||
<button class="BackButton"
|
||||
onClick=${ev => {
|
||||
ev.stopPropagation();
|
||||
setState(s => ({ ...s, selectedNotebookId: null, showNotebookSettings: false }));
|
||||
}}>
|
||||
←
|
||||
</button>
|
||||
<div class="NotebookEmoji" style=${{ background: notebook.color }}>${notebook.emoji}</div>
|
||||
<h3>${notebook.name}</h3>
|
||||
<!-- <button class="SearchButton"
|
||||
onClick=${ev => {
|
||||
ev.stopPropagation();
|
||||
setState(s => ({ ...s, searchModal: { visible: true, global: false, query: '' }}));
|
||||
}}>
|
||||
🔍
|
||||
</button> -->
|
||||
</div>
|
||||
<div class="Messages">
|
||||
${messages.map(message => html`<${Message} message=${message} notebook=${notebook} />`)}
|
||||
</div>
|
||||
${!notebook.readonly && html`<div class="SendBar">
|
||||
${state.replyingTo && html`
|
||||
<div class="ReplyPreview">
|
||||
<span>${STRINGS.get('Reply to')}: "${
|
||||
getMessage(state.replyingTo.notebookId, state.replyingTo.messageId)?.text || ''
|
||||
}"</span>
|
||||
<button onClick=${() => setState(s => ({ ...s, replyingTo: null }))}>×</button>
|
||||
</div>`}
|
||||
<textarea ref=${messageInputRef} class="EditArea" onKeyDown=${ev => ev.key==='Enter' && !ev.shiftKey && sendMessage()}/>
|
||||
<button onClick=${sendMessage}>${state.editingMessage!=null ? STRINGS.get('Save') : STRINGS.get('Send')}</button>
|
||||
</div>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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}
|
||||
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"
|
||||
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 class="ReactionInput" maxlength="2" autofocus onKeyPress=${e => e.key==='Enter' && (confirmReaction(message.id, e.target.value), e.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');
|
||||
return '';
|
||||
// return html`
|
||||
// <div class="CreateModal">
|
||||
// <h3>Create Notebook</h3>
|
||||
// <button onClick=${() => createNotebook('local')}>Local Notebook</button>
|
||||
// <button onClick=${() => createNotebook('remote')}>Remote Notebook</button>
|
||||
// <button onClick=${() => setState(s => ({ ...s, createModal: false }))}>Cancel</button>
|
||||
// </div>
|
||||
// `;
|
||||
}
|
||||
|
||||
function CrossReplyModal() {
|
||||
const {state, setState} = useContext(AppContext);
|
||||
return html`
|
||||
<div class="CrossReplyModal">
|
||||
<h3>${STRINGS.get('Reply in Another Notebook')}</h3>
|
||||
${state.notebooks.filter(notebook => notebook.id!==state.crossReplySource.notebookId).map(notebook => html`
|
||||
<button onClick=${() => setState(s => ({ ...s,
|
||||
selectedNotebookId: notebook.id,
|
||||
replyingTo: s.crossReplySource,
|
||||
crossReplyModal: false,
|
||||
}))}>${notebook.emoji} ${notebook.name}</button>
|
||||
`)}
|
||||
<button onClick=${() => setState(s => ({ ...s, crossReplyModal: false }))}>${STRINGS.get('Cancel')}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function ContextMenu() {
|
||||
const {state, setState, getNotebook, getMessage, copyMessage, deleteMessage} = useContext(AppContext);
|
||||
const messageId = state.contextMenu.messageId;
|
||||
const notebook = getNotebook(state.selectedNotebookId);
|
||||
const message = getMessage(notebook.id, messageId);
|
||||
const setFinalState = state => setState(s => ({ ...s, ...state, ...closedContextMenu(s) }));
|
||||
const handle = action => {
|
||||
switch (action) {
|
||||
case 'reply':
|
||||
return setFinalState({ replyingTo: { notebookId: notebook.id, messageId: message.id } });
|
||||
case 'cross-reply':
|
||||
return setFinalState({ crossReplyModal: true, crossReplySource: { notebookId: notebook.id, messageId: message.id } });
|
||||
case 'copy':
|
||||
copyMessage(message);
|
||||
return setFinalState();
|
||||
case 'edit':
|
||||
return setFinalState({ editingMessage: messageId });
|
||||
case 'datetime':
|
||||
return setFinalState({ dateTimeModal: messageId });
|
||||
case 'delete':
|
||||
deleteMessage(notebook.id, messageId);
|
||||
return setFinalState();
|
||||
}
|
||||
};
|
||||
return html`
|
||||
<div class="ContextMenu" style=${`left: ${state.contextMenu.x}px; top: ${state.contextMenu.y}px;`}>
|
||||
<div class="ContextMenuItem" onClick=${() => handle('copy')}>📜 ${STRINGS.get('Copy')}</div>
|
||||
${!notebook.readonly && html`
|
||||
<div class="ContextMenuItem" onClick=${() => handle('reply')}>🔁 ${STRINGS.get('Reply')}</div>
|
||||
<div class="ContextMenuItem" onClick=${() => handle('cross-reply')}>🔂 ${STRINGS.get('Reply in Another Notebook')}</div>
|
||||
<div class="ContextMenuItem" onClick=${() => handle('edit')}>📝 ${STRINGS.get('Edit')}</div>
|
||||
<div class="ContextMenuItem" onClick=${() => handle('datetime')}>⏰ ${STRINGS.get('Set Date/Time')}</div>
|
||||
<div class="ContextMenuItem" onClick=${() => handle('delete')}>❌ ${STRINGS.get('Delete')}</div>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function DateTimeModal() {
|
||||
const {state, setState, getMessage, saveMessage} = useContext(AppContext);
|
||||
const messageId = state.dateTimeModal;
|
||||
const notebookId = state.selectedNotebookId;
|
||||
const message = getMessage(notebookId, messageId);
|
||||
const [dt, setDt] = useState('');
|
||||
useEffect(() => (message && setDt(new Date(message.created).toISOString().slice(0, 16))), [message]);
|
||||
const save = () => {
|
||||
const timestamp = new Date(dt).getTime();
|
||||
if (!isNaN(timestamp)) {
|
||||
saveMessage(notebookId, { ...message, created: timestamp });
|
||||
setState(s => ({ ...s, dateTimeModal: null }));
|
||||
}
|
||||
};
|
||||
return html`
|
||||
<div class="DateTimeModal">
|
||||
<h3>${STRINGS.get('Set Date/Time')}</h3>
|
||||
<input type="datetime-local" value=${dt} onChange=${ev => setDt(ev.target.value)}/>
|
||||
<button onClick=${save}>${STRINGS.get('Save')}</button>
|
||||
<button onClick=${() => setState(s => ({ ...s, dateTimeModal: null }))}>${STRINGS.get('Cancel')}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function NotebookSettingsModal() {
|
||||
const {state, setState, getNotebook, deleteNotebook} = useContext(AppContext);
|
||||
const notebook = getNotebook(state.selectedNotebookId);
|
||||
if (!notebook) return;
|
||||
const [form, setForm] = useState({ ...notebook });
|
||||
useEffect(() => {
|
||||
setForm({ ...notebook });
|
||||
}, [notebook.id]);
|
||||
const save = () => setState(s => ({ ...s, notebooks: s.notebooks.map(n => (n.id===notebook.id ? form : n)), showNotebookSettings: false }));
|
||||
const del = () => {
|
||||
if (confirm('Delete?')) {
|
||||
// if (notebook.sourceType==='local') {
|
||||
deleteNotebook(notebook.id);
|
||||
setState(s => ({ ...s, selectedNotebookId: null, showNotebookSettings: false }));
|
||||
}
|
||||
};
|
||||
return html`
|
||||
<div class="NotebookSettingsModal">
|
||||
<h3>${STRINGS.get('Info/Settings')}</h3>
|
||||
<p><label>${STRINGS.get('Name')}: <input value=${form.name} onChange=${ev => setForm(f => ({ ...f, name: ev.target.value }))} disabled=${notebook.readonly} /></label></p>
|
||||
<p><label>Emoji: <input 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 onChange=${ev => setForm(f => ({ ...f, description: ev.target.value }))} 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>
|
||||
<p>
|
||||
${' '}<button onClick=${save} disabled=${notebook.readonly}>${STRINGS.get('Save')}</button>
|
||||
${' '}<button onClick=${del} style="color:red" disabled=${notebook.readonly}>${STRINGS.get('Delete')}</button>
|
||||
${' '}<button onClick=${() => setState(s => ({ ...s, showNotebookSettings: false }))}>${STRINGS.get('Close')}</button>
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="SearchModal">
|
||||
<h3>${global ? 'Global' : 'Notebook'} Search</h3>
|
||||
<input placeholder="Search..." value=${query} onInput=${ev => setState(s => ({ ...s, searchModal: { ...s.searchModal, query: ev.target.value }}))}/>
|
||||
${results.map(result => html`
|
||||
<div class="SearchResult" onClick=${() => select(result.notebook.id, result.id)}>
|
||||
${global && html`<div class="NotebookTitle">
|
||||
<div class="NotebookEmoji" style=${{ background: result.notebook.color }}>${result.notebook.emoji}</div>
|
||||
<strong>${result.notebook.name}</strong>
|
||||
</div>`}
|
||||
<div>${result.text}</div><em>${new Date(result.created).toLocaleString()}</em>
|
||||
</div>
|
||||
`)}
|
||||
<button onClick=${() => setState(s => ({ ...s, searchModal: { ...s.searchModal, visible: false }}))}>${STRINGS.get('Close')}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="AppSettingsModal">
|
||||
<h3>${STRINGS.get('App Settings')}</h3>
|
||||
<h4>${STRINGS.get('Export Data')}</h4><textarea readonly rows="8">${exportData()}</textarea>
|
||||
<h4>${STRINGS.get('Import Data')}</h4>
|
||||
<textarea rows="6" placeholder=${STRINGS.get('Paste JSON')} onInput=${ev => setImportTxt(ev.target.value)} />
|
||||
<button onClick=${doImport}>${STRINGS.get('Import Data')}</button>
|
||||
<br /><br />
|
||||
<button onClick=${() => setState(s => ({ ...s, showAppSettings:false }))}>${STRINGS.get('Close')}</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render(html`<${App} />`, document.body);
|
||||
|
||||
};
|
779
index.html
779
index.html
@ -1,80 +1,93 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="manifest" href="./manifest.json" />
|
||||
<link rel="shortcut icon" href="./icon.png" type="image/png" sizes="1024x1024" />
|
||||
<title>WhichNot</title>
|
||||
<style>
|
||||
:root {
|
||||
--whatsapp-green: #00a884;
|
||||
--header-bg: #f0f2f5;
|
||||
--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; }
|
||||
body, html {
|
||||
margin: 0; height: 100%;
|
||||
font-family: Arial, sans-serif;
|
||||
color-scheme: light dark;
|
||||
}
|
||||
.App { display: flex; height: 100vh; }
|
||||
.ChatList {
|
||||
width: 30%; overflow-y: auto;
|
||||
background: white; border-right: 1px solid #ddd;
|
||||
background: var(--main-bg); border-right: 1px solid #ddd;
|
||||
}
|
||||
.ChatList-header {
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
padding: .75rem 1rem; border-bottom: 1px solid #ddd;
|
||||
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:#f5f5f5; }
|
||||
.NotebookButton:hover { background: var(--focus-bg); }
|
||||
.NotebookTitle { display: flex; align-items: center; gap: .5rem; }
|
||||
.NotebookEmoji {
|
||||
width: 1.5rem; height: 1.5rem; display: flex;
|
||||
border-radius: 50%; align-items: center; justify-content: center; font-size: 1rem;
|
||||
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 { text-overflow: ellipsis; overflow: hidden; text-wrap: nowrap; }
|
||||
|
||||
.ChatScreen { flex: 1; display: none; flex-direction: column; background: #efeae2; }
|
||||
.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; display: flex; align-items: center; gap: .5rem; border-bottom: 1px solid #ddd; cursor: pointer; }
|
||||
.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: white; padding: .5rem 1rem; border-radius: .5rem; max-width: 70%; word-break: break-word; margin: .5rem auto; position: relative; }
|
||||
.Message { background: var(--bubble-bg); padding: .5rem 1rem; border-radius: .5rem; max-width: 70%; word-break: break-word; margin: .5rem auto; position: relative; }
|
||||
.Message .reactions { display: flex; gap: .25rem; margin-top: .25rem; }
|
||||
.Message .reactions button { background: #f5f5f5; border: none; border-radius: .25rem; padding: 0 .5rem; cursor: pointer; }
|
||||
.Message iframe { border: none; }
|
||||
.AddReactionBtn { font-size: .9rem; background: none; border: none; cursor: pointer; color: var(--whatsapp-green); }
|
||||
.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: white; border-top: 1px solid #ddd; flex-direction: column; }
|
||||
.ReplyPreview { background: #f5f5f5; padding: .5rem; border-radius: .25rem; display: flex; justify-content: space-between; align-items: center; }
|
||||
.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; }
|
||||
|
||||
.ContextMenu {
|
||||
position: fixed; z-index: 1000; min-width: 140px;
|
||||
background: white; border: 1px solid #ddd; border-radius: .25rem; box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
||||
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: #f5f5f5; }
|
||||
.ContextMenuItem:hover { background: var(--focus-bg); }
|
||||
|
||||
.DateTimeModal, .SearchModal, .AppSettingsModal, .CreateModal, .SettingsModal, .CrossReplyModal {
|
||||
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%);
|
||||
background: white; 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: 400px; z-index: 1001;
|
||||
.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, .SettingsModal input {
|
||||
width: 100%; margin: .5rem 0; padding: .5rem;
|
||||
.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: #f9f9f9; }
|
||||
.SearchResult:hover { background: var(--focus-bg); }
|
||||
|
||||
.ReplyIndicator {
|
||||
border-left: 3px solid var(--whatsapp-green); padding-left: .5rem; margin-bottom: .5rem;
|
||||
border-left: 3px solid var(--wapp-green); padding-left: .5rem; margin-bottom: .5rem;
|
||||
color: #666; font-size: .9em; cursor: pointer;
|
||||
}
|
||||
|
||||
@ -86,693 +99,39 @@
|
||||
}
|
||||
</style>
|
||||
<script src="./localforage.min.js"></script>
|
||||
<script src="./marked.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<script src="app.js"></script>
|
||||
<script type="module">
|
||||
import { h, render, createContext } from 'https://esm.sh/preact';
|
||||
import { useState, useEffect, useCallback, useRef, useContext } from 'https://esm.sh/preact/hooks';
|
||||
import htm from 'https://esm.sh/htm';
|
||||
const html = htm.bind(h), AppContext = createContext();
|
||||
localforage.config({ name: "WhichNot" });
|
||||
|
||||
const uuidv7 = () => {
|
||||
const bytes = new Uint8Array(16);
|
||||
crypto.getRandomValues(bytes);
|
||||
const timestamp = BigInt(Date.now());
|
||||
bytes[0] = Number((timestamp >> 40n) & 0xffn);
|
||||
bytes[1] = Number((timestamp >> 32n) & 0xffn);
|
||||
bytes[2] = Number((timestamp >> 24n) & 0xffn);
|
||||
bytes[3] = Number((timestamp >> 16n) & 0xffn);
|
||||
bytes[4] = Number((timestamp >> 8n) & 0xffn);
|
||||
bytes[5] = Number(timestamp & 0xffn);
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
const chars = Array.from(bytes).map(byte => byte.toString(16).padStart(2, '0'));
|
||||
[10, 8, 6, 4].forEach(pos => chars.splice(pos, 0, '-'));
|
||||
return chars.join('');
|
||||
}
|
||||
const generateUUID = () => uuidv7(); // crypto.randomUUID();
|
||||
const genAESKey = async () => crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
|
||||
// const genEd25519 = async () => crypto.subtle.generateKey({ name: 'Ed25519', namedCurve: 'Ed25519' }, true, ['sign', 'verify']);
|
||||
const exportJWK = async (key) => btoa(JSON.stringify(await crypto.subtle.exportKey('jwk', key)));
|
||||
const importJWK = async (b64, alg, usages) => crypto.subtle.importKey('jwk', JSON.parse(atob(b64)), alg, true, usages);
|
||||
const randBytes = (n=12) => {
|
||||
const b = new Uint8Array(n);
|
||||
crypto.getRandomValues(b);
|
||||
return b;
|
||||
}
|
||||
const bufToB64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
const b64ToBuf = (str) => Uint8Array.from(atob(str), (c => c.charCodeAt(0)));
|
||||
const deriveMsgKey = async (rawKey, salt) => crypto.subtle.deriveKey(
|
||||
{ name: 'HKDF', salt, info: new TextEncoder().encode('msg'), hash: 'SHA-256' },
|
||||
await crypto.subtle.importKey('raw', rawKey, 'HKDF', false, ['deriveKey']),
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true, ['encrypt', 'decrypt']);
|
||||
const getAesRawKey = async (aesKeyB64) => await crypto.subtle.exportKey('raw', await importJWK(aesKeyB64, { name: 'AES-GCM' }, ['encrypt','decrypt']));
|
||||
|
||||
const encryptMessage = async (message, rawKey) => {
|
||||
const salt = randBytes();
|
||||
const iv = randBytes(12);
|
||||
const key = await deriveMsgKey(rawKey, salt);
|
||||
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, new TextEncoder().encode(message.text));
|
||||
const encrypted = { ...message,
|
||||
salt: bufToB64(salt),
|
||||
iv: bufToB64(iv),
|
||||
ciphertext: bufToB64(ct),
|
||||
};
|
||||
delete encrypted.text;
|
||||
return encrypted;
|
||||
};
|
||||
const decryptMessage = async (encrypted, rawKey) => {
|
||||
const salt = b64ToBuf(encrypted.salt);
|
||||
const iv = b64ToBuf(encrypted.iv);
|
||||
const key = await deriveMsgKey(rawKey, salt);
|
||||
const ct = b64ToBuf(encrypted.ciphertext);
|
||||
const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
|
||||
return { ...encrypted, text: new TextDecoder().decode(dec) };
|
||||
};
|
||||
|
||||
const escapeHtml = text => {
|
||||
const node = document.createElement('p');
|
||||
node.appendChild(document.createTextNode(text));
|
||||
return node.innerHTML;
|
||||
}
|
||||
const makeParagraph = text => `<p>${text.replaceAll('\n', '<br />')}</p>`
|
||||
const linkify = text => text.replace(/(\bhttps?:\/\/[^\s]+)/g, '<a href="$1" target="_blank">$1</a>');
|
||||
const isSimpleUrl = text => (!(text = text.toLowerCase()).includes(' ') && (text.startsWith('http://') || text.startsWith('https://')));
|
||||
|
||||
const EMOJIS = ['📒','📓','📔','📕','📖','📗','📘','📙','📚','✏️','📝'];
|
||||
const randomEmoji = () => EMOJIS[Math.floor(Math.random() * EMOJIS.length)];
|
||||
const randomColor = () => ('#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0'));
|
||||
|
||||
const closedContextMenu = (s) => ({ contextMenu: { ...s.contextMenu, visible: false } });
|
||||
|
||||
function App() {
|
||||
const [state,setState] = useState({
|
||||
notebooks: [], encrypted: {}, messages: {},
|
||||
selectedNotebook: null, scrollToMessage: null,
|
||||
showSettings: false, showAppSettings: false,
|
||||
createModal: false, dateTimeModal: null,
|
||||
crossReplyModal: false, crossReplySource: null,
|
||||
contextMenu:{ visible: false, messageId: null, x: 0, y: 0 },
|
||||
searchModal: { visible: false, global: false, query: '' },
|
||||
editingMessage: null, replyingTo: null, reactionInputFor: null,
|
||||
});
|
||||
const messageInputRef = useRef();
|
||||
|
||||
// Load & decrypt
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const notebooksList = await localforage.getItem('notebooks') || [];
|
||||
const notebooks = [];
|
||||
const enc = {};
|
||||
const msgs = {};
|
||||
for (let notebook of notebooksList) {
|
||||
notebooks.push(notebook = await localforage.getItem(`notebooks/${notebook}`));
|
||||
// const arr = await localforage.getItem(`notebook-${notebook.id}`) || [];
|
||||
const arr = [];
|
||||
const messagesList = await localforage.getItem(`messages/${notebook.id}`);
|
||||
for (let messageId of messagesList) {
|
||||
arr[messageId] = await localforage.getItem(`messages/${notebook.id}/${messageId}`);
|
||||
}
|
||||
enc[notebook.id] = arr;
|
||||
const rawKey = await getAesRawKey(notebook.aesKeyB64);
|
||||
const plain = {}; // [];
|
||||
const promises = [];
|
||||
for (const e of Object.values(arr)) { // arr) {
|
||||
//plain.push({ ...e, text: new TextDecoder().decode(dec) });
|
||||
//plain.push(await decryptMessage(e, rawKey));
|
||||
//plain[e.id] = await decryptMessage(e, rawKey);
|
||||
promises.push(decryptMessage(e, rawKey).then(message => plain[e.id] = message));
|
||||
}
|
||||
await Promise.all(promises);
|
||||
msgs[notebook.id] = plain;
|
||||
}
|
||||
setState(s => ({ ...s, notebooks, encrypted: enc, messages: msgs }));
|
||||
})();
|
||||
}, []);
|
||||
|
||||
// Persist notebooks meta
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
for (const notebookId in state.encrypted) {
|
||||
// localforage.setItem(`notebook-${id}`, state.encrypted[id]);
|
||||
const messages = state.encrypted[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.encrypted]);
|
||||
|
||||
// Close context on click-away
|
||||
useEffect(() => {
|
||||
const handler = event => {
|
||||
if (state.contextMenu.visible) {
|
||||
const menu = document.querySelector('.ContextMenu');
|
||||
if (menu && !menu.contains(event.target)) {
|
||||
setState(s => ({ ...s, ...closedContextMenu(s) }));
|
||||
}
|
||||
}
|
||||
};
|
||||
document.addEventListener('click', handler);
|
||||
return () => document.removeEventListener('click', handler);
|
||||
}, [state.contextMenu.visible]);
|
||||
|
||||
const createNotebook = useCallback(async (type) => {
|
||||
let id = /* (type === 'local' ? */ generateUUID(); /* : prompt('Remote ID:')); */
|
||||
// if (!id) return;
|
||||
const now = Date.now();
|
||||
// const ed = await genEd25519();
|
||||
const notebook = {
|
||||
id, name: `Notebook ${now}`, description: '',
|
||||
emoji: randomEmoji(), color: randomColor(),
|
||||
// parseMode: 'plaintext', // sourceType: type,
|
||||
nextMessageId: 1, created: now,
|
||||
aesKeyB64: await exportJWK(await genAESKey()), // edPrivB64: await exportJWK(ed.privateKey), edPubB64: await exportJWK(ed.publicKey),
|
||||
};
|
||||
setState(s => ({ ...s,
|
||||
notebooks: [ ...s.notebooks, notebook ],
|
||||
encrypted: { ...s.encrypted, [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)), [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 },
|
||||
encrypted: { ...s.encrypted, [notebookId]: undefined },
|
||||
}));
|
||||
localforage.removeItem(`notebooks/${notebookId}`);
|
||||
localforage.removeItem(`messages/${notebookId}`);
|
||||
for (const messageId of messagesList) {
|
||||
localforage.removeItem(`messages/${notebookId}/${messageId}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getMessages = useCallback((notebookId /* , messageId */) => state.messages[notebookId], [state.messages]); // {
|
||||
// const messages = state.messages[notebookId];
|
||||
// return (messageId ? messages[messageId] : messages);
|
||||
// // return (messageId ? messages.find(message => (message.id === messageId)) : messages);
|
||||
// }, [state.messages]);
|
||||
const getMessage = useCallback((notebookId, messageId) => getMessages(notebookId)[messageId], [state.messages]);
|
||||
|
||||
const saveMessage = (notebookId, message) => persistMessages(notebookId, /* Object.values( */ { ...getMessages(notebookId), [message.id]: message } /* ) */);
|
||||
const deleteMessage = (notebookId, messageId) => {
|
||||
const messages = getMessages(notebookId);
|
||||
delete messages[messageId];
|
||||
persistMessages(notebookId, /* Object.values( */ messages /* ) */);
|
||||
localforage.removeItem(`messages/${notebookId}/${messageId}`);
|
||||
// setState(s => ({ ...s, messages: { ...s.messages, [nbId]: messages } }));
|
||||
};
|
||||
const copyMessage = message => navigator.clipboard.writeText(message.text);
|
||||
|
||||
const persistMessages = useCallback(async (nbId, plainArr) => {
|
||||
const notebook = getNotebook(nbId);
|
||||
if (!notebook) return;
|
||||
const rawKey = await getAesRawKey(notebook.aesKeyB64);
|
||||
const encArr = {}; // [];
|
||||
for (const message of Object.values(plainArr)) { //plainArr){
|
||||
//encArr.push(await encryptMessage(message, rawKey));
|
||||
encArr[message.id] = await encryptMessage(message, rawKey);
|
||||
}
|
||||
setState(s => ({ ...s,
|
||||
encrypted: { ...s.encrypted, [nbId]: encArr },
|
||||
messages: { ...s.messages, [nbId]: plainArr },
|
||||
}));
|
||||
// if (notebook.sourceType==='remote') {
|
||||
// const priv = await importJWK(notebook.edPrivB64, { name: 'Ed25519', namedCurve: 'Ed25519' }, ['sign']),
|
||||
// payload = new TextEncoder().encode(JSON.stringify(encArr)),
|
||||
// sig = bufToB64(await crypto.subtle.sign('Ed25519', priv, payload));
|
||||
// await fetch(`/notebook/${nbId}/sync`, {
|
||||
// method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({ encryptedArr: encArr, signature: sig, publicKey: notebook.edPubB64 }),
|
||||
// });
|
||||
// }
|
||||
}, [state.notebooks]);
|
||||
|
||||
const addReaction = useCallback(messageId => setState(s => ({ ...s, reactionInputFor: messageId })), []);
|
||||
const confirmReaction = useCallback(async (idx, emoji) => {
|
||||
if (!emoji) return;
|
||||
setState(s => ({ ...s, reactionInputFor: null }));
|
||||
const nbId = state.selectedNotebook;
|
||||
//const arr = state.messages[nbId] || [];
|
||||
//const newArr = arr.map((m, i) => i===idx ? { ...m, reactions: (m.reactions.includes(emoji) ? m.reactions : [...m.reactions, emoji]) } : m);
|
||||
//const newArr = getMessages(nbId);
|
||||
//const m = newArr[idx];
|
||||
//newArr[idx] = { ...m, reactions: (m.reactions.includes(emoji) ? m.reactions : [...m.reactions, emoji]) }
|
||||
//await persistMessages(nbId, newArr);
|
||||
const message = getMessage(nbId, idx);
|
||||
if (!(emoji in message.reactions)) { // (!message.reactions.includes(emoji)) {
|
||||
message.reactions[emoji] = true; // [...message.reactions, emoji];
|
||||
saveMessage(nbId, message);
|
||||
}
|
||||
setState(s => ({ ...s, reactionInputFor: null }));
|
||||
}, [state.selectedNotebook, state.messages, persistMessages]);
|
||||
const removeReaction = useCallback(async (idx, emoji) => {
|
||||
const nbId = state.selectedNotebook;
|
||||
//const arr = state.messages[nbId] || [];
|
||||
//const newArr = arr.map((m,i) => i===idx ? { ...m, reactions: m.reactions.filter(r => r!==emoji) } : m);
|
||||
//await persistMessages(nbId, newArr);
|
||||
const message = getMessage(nbId, idx);
|
||||
if (emoji in message.reactions) {
|
||||
delete message.reactions[emoji];
|
||||
saveMessage(nbId, message);
|
||||
}
|
||||
}, [state.selectedNotebook, state.messages, persistMessages]);
|
||||
|
||||
// Editing effect: prefill textarea when entering edit mode
|
||||
useEffect(() => {
|
||||
if (state.editingMessage!=null && messageInputRef.current) {
|
||||
const message = state.messages[state.selectedNotebook]?.[state.editingMessage];
|
||||
if (message) {
|
||||
messageInputRef.current.value = message.text;
|
||||
}
|
||||
}
|
||||
}, [state.editingMessage, state.selectedNotebook, state.messages]);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
const nbId = state.selectedNotebook;
|
||||
if (!nbId) return;
|
||||
const text = messageInputRef.current.value.trim();
|
||||
if (!text) return;
|
||||
//const arr = state.messages[nbId] || [];
|
||||
const notebook = getNotebook(nbId);
|
||||
let message = getMessage(nbId, state.editingMessage); // arr[state.editingMessage];
|
||||
if (!message) {
|
||||
message = {
|
||||
id: notebook.nextMessageId,
|
||||
created/*timestamp*/: Date.now(),
|
||||
edited: state.editingMessage!=null,
|
||||
replyTo: state.replyingTo,
|
||||
reactions: {}, // [],
|
||||
};
|
||||
}
|
||||
message = { ...message, text };
|
||||
messageInputRef.current.value = '';
|
||||
// update nextMessageId if new
|
||||
setState(s => ({ ...s, notebooks: s.notebooks.map(notebook => notebook.id===nbId
|
||||
? { ...notebook, nextMessageId: (state.editingMessage==null ? notebook.nextMessageId+1 : notebook.nextMessageId) }
|
||||
: notebook
|
||||
) }));
|
||||
// const newArr = (state.editingMessage!=null
|
||||
// ? arr.map((msg, i) => (i===state.editingMessage ? message : msg))
|
||||
// : { ...arr, [message.id]: message } // [...arr, message]
|
||||
// );
|
||||
//const newArr = arr;
|
||||
//newArr[message.id] = message;
|
||||
// reset editing & replying
|
||||
saveMessage(nbId, message);
|
||||
setState( s => ({ ...s, editingMessage: null, replyingTo: null }));
|
||||
//await persistMessages(nbId, newArr);
|
||||
}, [state.selectedNotebook, 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,
|
||||
}}>
|
||||
<div class="App ${state.selectedNotebook ? 'show-chat' : ''}">
|
||||
<${ChatList} />
|
||||
<${ChatScreen} messageInputRef=${messageInputRef} />
|
||||
${state.createModal && html`<${CreateModal} />`}
|
||||
${state.crossReplyModal && html`<${CrossReplyModal} />`}
|
||||
${state.showSettings && html`<${SettingsModal} />`}
|
||||
${state.showAppSettings && html`<${AppSettingsModal} />`}
|
||||
${state.contextMenu.visible && html`<${ContextMenu} />`}
|
||||
${state.dateTimeModal!==null && html`<${DateTimeModal} />`}
|
||||
${state.searchModal.visible && html`<${SearchModal} />`}
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
}
|
||||
|
||||
function ChatList() {
|
||||
const {state, setState, getMessages} = useContext(AppContext);
|
||||
const sortNotebook = (notebook) => Math.max(notebook.created, ...Object.values(getMessages(notebook.id) || []).map(message => message.created/*timestamp*/));
|
||||
return html`
|
||||
<div class="ChatList">
|
||||
<div class="ChatList-header">
|
||||
<button onClick=${() => setState(s => ({ ...s, createModal: true }))}>+</button>
|
||||
<!-- <button onClick=${() => setState(s => ({ ...s, searchModal: { visible: true, global: true, query: '' } }))}>🔍</button> -->
|
||||
<button onClick=${() => setState(s => ({ ...s, showAppSettings: true }))}>⚙️</button>
|
||||
</div>
|
||||
${state.notebooks.sort((a, b) => (sortNotebook(b) - sortNotebook(a))).map(notebook => html`
|
||||
<button class="NotebookButton" key=${notebook.id}
|
||||
onClick=${() => setState(s => ({ ...s, selectedNotebook: notebook.id }))}>
|
||||
<div class="NotebookTitle">
|
||||
<div class="NotebookEmoji" style=${{ background: notebook.color }}>${notebook.emoji}</div>
|
||||
<h4 class="NotebookName">${notebook.name}</h4>
|
||||
</div>
|
||||
<div class="NotebookDescription">${notebook.description || 'No description'}</div>
|
||||
<div class="NotebookPreview">
|
||||
${(() => {
|
||||
const arr = state.messages[notebook.id] || [];
|
||||
return arr.length ? arr[arr.length-1].text : 'No messages';
|
||||
})()}
|
||||
</div>
|
||||
</button>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function ChatScreen({ messageInputRef }) {
|
||||
const { state, setState, sendMessage, getMessage, getNotebook } = useContext(AppContext);
|
||||
const notebook = getNotebook(state.selectedNotebook);
|
||||
let messages = state.messages[notebook?.id] || [];
|
||||
messages = /* [...messages] */ Object.values(messages).sort((a,b) => (a.created/*timestamp*/ - b.created/*timestamp*/));
|
||||
|
||||
// Scroll on request
|
||||
useEffect(() => {
|
||||
// Array.from(document.querySelectorAll(`.Message[data-msg-id${state.scrollToMessage!=null ? `="${state.scrollToMessage}"` : ''}]`)).slice(-1)[0]?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
if (state.scrollToMessage!=null) {
|
||||
document.querySelector(`[data-message-id="${state.scrollToMessage}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setState(s => ({ ...s, scrollToMessage: null }));
|
||||
}
|
||||
}, [state.scrollToMessage, state.selectedNotebook]);
|
||||
|
||||
if (!notebook) return null;
|
||||
|
||||
return html`
|
||||
<div class="ChatScreen">
|
||||
<div class="ChatHeader" onClick=${() => setState(s => ({ ...s, showSettings: true }))}>
|
||||
<button class="BackButton"
|
||||
onClick=${ev => {
|
||||
ev.stopPropagation();
|
||||
setState(s => ({ ...s, selectedNotebook: null }));
|
||||
}}>
|
||||
←
|
||||
</button>
|
||||
<div class="NotebookEmoji" style=${{ background: notebook.color }}>${notebook.emoji}</div>
|
||||
<h3>${notebook.name}</h3>
|
||||
<!-- <button class="SearchButton"
|
||||
onClick=${ev => {
|
||||
ev.stopPropagation();
|
||||
setState(s => ({ ...s, searchModal: { visible: true, global: false, query: '' }}));
|
||||
}}>
|
||||
🔍
|
||||
</button> -->
|
||||
</div>
|
||||
<div class="Messages">
|
||||
${messages.map(message => html`
|
||||
<${Message} message=${message} />
|
||||
`)}
|
||||
</div>
|
||||
<div class="SendBar">
|
||||
${state.replyingTo && html`
|
||||
<div class="ReplyPreview">
|
||||
<span>Replying to: ${
|
||||
// (state.messages[state.replyingTo.notebookId] || []).find(x => x.id===state.replyingTo.id)?.text || ''
|
||||
getMessage(state.replyingTo.notebookId, state.replyingTo.id)?.text || ''
|
||||
}</span>
|
||||
<button onClick=${() => setState(s => ({ ...s, replyingTo: null }))}>×</button>
|
||||
</div>`}
|
||||
<textarea ref=${messageInputRef} class="EditArea"
|
||||
onKeyPress=${e => e.key==='Enter' && !e.shiftKey && sendMessage()}/>
|
||||
<button onClick=${sendMessage}>${state.editingMessage!=null?'Save':'Send'}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function Message({ message }) {
|
||||
const {
|
||||
state, setState, getMessage,
|
||||
addReaction, confirmReaction, removeReaction
|
||||
} = useContext(AppContext);
|
||||
return html`
|
||||
<div class="Message" data-message-id=${message.id}
|
||||
onContextMenu=${event => {
|
||||
event.preventDefault();
|
||||
setState(s => ({ ...s, contextMenu: { visible: true, messageId: message.id, x: event.clientX, y: event.clientY } }));
|
||||
}}
|
||||
onTouchStart=${event => {
|
||||
event.preventDefault();
|
||||
const target = event.touches[0];
|
||||
setState(s => ({ ...s, contextMenu: { visible: true, messageId: message.id, x: target.clientX, y: target.clientY } }));
|
||||
}}>
|
||||
${message.replyTo && html`
|
||||
<div class="ReplyIndicator"
|
||||
onClick=${() => setState(s => ({ ...state,
|
||||
selectedNotebook: message.replyTo.notebookId,
|
||||
scrollToMessage: message.replyTo.id,
|
||||
}))}>
|
||||
Reply to "${
|
||||
// (state.messages[message.replyTo.notebookId] || []).find(x => x.id===message.replyTo.id)?.text || ''
|
||||
getMessage(message.replyTo.notebookId, message.replyTo.id)?.text || ''
|
||||
}"
|
||||
</div>`}
|
||||
<div dangerouslySetInnerHTML=${{ __html: makeParagraph(linkify(escapeHtml(message.text))) }} />
|
||||
${(() => {
|
||||
if (isSimpleUrl(message.text)) {
|
||||
return html`<div>
|
||||
<iframe src=${message.text} sandbox></iframe>
|
||||
</div>`;
|
||||
}
|
||||
})()}
|
||||
<div class="reactions">
|
||||
${Object.keys(message.reactions).map(reaction => html`
|
||||
<button onClick=${() => removeReaction(message.id, reaction)}>${reaction}</button>
|
||||
`)}
|
||||
${state.reactionInputFor===message.id
|
||||
? html`<input class="ReactionInput" maxlength="2" autofocus
|
||||
onKeyPress=${e=>e.key==='Enter'&&(confirmReaction(message.id, e.target.value), e.target.value='')} />`
|
||||
: html`<button class="AddReactionBtn" onClick=${()=>addReaction(message.id)}>➕</button>`
|
||||
}
|
||||
</div>
|
||||
<div class="Timestamp">${new Date(message.created/*timestamp*/).toLocaleString()}${message.edited ? ' (edited)' : ''}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function CreateModal() {
|
||||
const {createNotebook, setState} = useContext(AppContext);
|
||||
createNotebook('local');
|
||||
return '';
|
||||
// return html`
|
||||
// <div class="CreateModal">
|
||||
// <h3>Create Notebook</h3>
|
||||
// <button onClick=${() => createNotebook('local')}>Local Notebook</button>
|
||||
// <button onClick=${() => createNotebook('remote')}>Remote Notebook</button>
|
||||
// <button onClick=${() => setState(s => ({ ...s, createModal: false }))}>Cancel</button>
|
||||
// </div>
|
||||
// `;
|
||||
}
|
||||
|
||||
function CrossReplyModal() {
|
||||
const {state, setState} = useContext(AppContext);
|
||||
return html`
|
||||
<div class="CrossReplyModal">
|
||||
<h3>Reply in Another Notebook</h3>
|
||||
${state.notebooks.filter(notebook => notebook.id!==state.crossReplySource.notebook).map(notebook => html`
|
||||
<button onClick=${() => setState(s => ({ ...s,
|
||||
selectedNotebook: notebook.id,
|
||||
replyingTo: { notebookId: s.crossReplySource.notebook, id: s.crossReplySource.id },
|
||||
crossReplyModal: false,
|
||||
}))}>${notebook.emoji} ${notebook.name}</button>
|
||||
`)}
|
||||
<button onClick=${() => setState(s => ({ ...s, crossReplyModal: false }))}>Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function ContextMenu() {
|
||||
const {state, setState, copyMessage, deleteMessage, persistMessages} = useContext(AppContext);
|
||||
const idx = state.contextMenu.messageId;
|
||||
const nbId = state.selectedNotebook;
|
||||
const arr = state.messages[nbId] || [];
|
||||
const msg = arr[idx];
|
||||
const handle = action => {
|
||||
let newArr;
|
||||
switch(action){
|
||||
case 'reply':
|
||||
setState(s => ({ ...s, replyingTo: { notebookId: nbId, id: msg.id }, ...closedContextMenu(s) }));
|
||||
return;
|
||||
case 'cross-reply':
|
||||
setState(s => ({ ...s, ...closedContextMenu(s), crossReplyModal: true, crossReplySource: { notebook: nbId, id: msg.id } }));
|
||||
return;
|
||||
case 'copy':
|
||||
copyMessage(msg);
|
||||
setState(s => ({ ...s, ...closedContextMenu(s) }));
|
||||
return;
|
||||
case 'edit':
|
||||
setState(s => ({ ...s, editingMessage: idx, ...closedContextMenu(s) }));
|
||||
return;
|
||||
case 'datetime':
|
||||
setState(s => ({ ...s, dateTimeModal: idx, ...closedContextMenu(s) }));
|
||||
return;
|
||||
case 'delete':
|
||||
deleteMessage(nbId, idx);
|
||||
// newArr=arr.filter((_,i)=>i!==idx);
|
||||
// persistMessages(nbId, newArr);
|
||||
setState(s => ({ ...s, /* messages: { ...s.messages, [nbId]: newArr }, */ ...closedContextMenu(s) }));
|
||||
return;
|
||||
}
|
||||
};
|
||||
return html`
|
||||
<div class="ContextMenu" style=${`left: ${state.contextMenu.x}px; top: ${state.contextMenu.y}px;`}>
|
||||
<div class="ContextMenuItem" onClick=${() => handle('reply')}>🔁 Reply</div>
|
||||
<!--<div class="ContextMenuItem" onClick=${() => handle('cross-reply')}>🔂 Reply in Another Notebook</div>-->
|
||||
<div class="ContextMenuItem" onClick=${() => handle('copy')}>📜 Copy</div>
|
||||
<div class="ContextMenuItem" onClick=${() => handle('edit')}>📝 Edit</div>
|
||||
<div class="ContextMenuItem" onClick=${() => handle('datetime')}>⏰ Set Date/Time</div>
|
||||
<div class="ContextMenuItem" onClick=${() => handle('delete')}>❌ Delete</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function DateTimeModal() {
|
||||
const {state, setState, persistMessages, saveMessage} = useContext(AppContext);
|
||||
const idx = state.dateTimeModal;
|
||||
const nbId = state.selectedNotebook;
|
||||
const arr = state.messages[nbId]||[];
|
||||
const msg = arr[idx];
|
||||
const [dt, setDt] = useState('');
|
||||
useEffect(() => (msg && setDt(new Date(msg.created/*timestamp*/).toISOString().slice(0, 16))), [msg]);
|
||||
const save = () => {
|
||||
const timestamp = new Date(dt).getTime();
|
||||
if (!isNaN(timestamp)) {
|
||||
// const newArr = arr.map((m,i) => i===idx ? { ...m, timestamp } : m);
|
||||
// persistMessages(nbId, newArr);
|
||||
// setState(s => ({ ...s, messages: { ...s.messages, [nbId]: newArr }, dateTimeModal: null }));
|
||||
saveMessage(nbId, { ...msg, created: timestamp });
|
||||
setState(s => ({ ...s, dateTimeModal: null }));
|
||||
}
|
||||
};
|
||||
return html`
|
||||
<div class="DateTimeModal">
|
||||
<h3>Set Date/Time</h3>
|
||||
<input type="datetime-local" value=${dt} onChange=${e => setDt(e.target.value)}/>
|
||||
<button onClick=${save}>Save</button>
|
||||
<button onClick=${() => setState(s => ({ ...s, dateTimeModal: null }))}>Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function SettingsModal() {
|
||||
const {state, setState, getNotebook, deleteNotebook} = useContext(AppContext);
|
||||
const notebook = getNotebook(state.selectedNotebook);
|
||||
const [form, setForm] = useState({ ...notebook });
|
||||
const save = () => setState(s => ({ ...s, notebooks: s.notebooks.map(n => (n.id===notebook.id ? form : n)), showSettings: false }));
|
||||
const del = () => {
|
||||
if (confirm('Delete?')) {
|
||||
// if (notebook.sourceType==='local') {
|
||||
// localforage.removeItem(`notebook-${notebook.id}`);
|
||||
deleteNotebook(notebook.id);
|
||||
setState(s => ({ ...s, selectedNotebook: null, showSettings: false }));
|
||||
// }
|
||||
// setState(s => ({ ...s,
|
||||
// notebooks: s.notebooks.filter(n => n.id!==notebook.id),
|
||||
// messages: { ...s.messages, [notebook.id]: undefined },
|
||||
// encrypted: { ...s.encrypted, [notebook.id]: undefined },
|
||||
// selectedNotebook: null, showSettings: false,
|
||||
// }));
|
||||
}
|
||||
};
|
||||
return html`
|
||||
<div class="SettingsModal">
|
||||
<h3>Settings</h3>
|
||||
<label>Name: <input value=${form.name} onChange=${e => setForm(f => ({ ...f, name: e.target.value }))}/></label><br/>
|
||||
<label>Emoji: <input value=${form.emoji} maxLength="2" onChange=${e => setForm(f => ({ ...f, emoji: e.target.value }))}/></label><br/>
|
||||
<label>Color: <input type="color" value=${form.color} onChange=${e => setForm(f => ({ ...f, color: e.target.value }))}/></label><br/>
|
||||
<label>Description: <input value=${form.description} onChange=${e => setForm(f => ({ ...f, description: e.target.value }))}/></label><br/>
|
||||
<!--
|
||||
<label>Parse Mode: <select value=${form.parseMode} onChange=${e => setForm(f => ({ ...f, parseMode: e.target.value }))}>
|
||||
<option value="plaintext">Plaintext</option>
|
||||
</select></label><br/><br/>
|
||||
-->
|
||||
<button onClick=${save}>Save</button>
|
||||
<button onClick=${del} style="color:red">Delete</button>
|
||||
<button onClick=${() => setState(s => ({ ...s, showSettings: false }))}>Close</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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.selectedNotebook] || []).map(message => ({ ...message, notebook: getNotebook(state.selectedNotebook) }))
|
||||
).filter(message => message.text.toLowerCase().includes(query.toLowerCase()));
|
||||
const select = (nbId, mId) => setState(s => ({ ...s, selectedNotebook: nbId, searchModal: { ...s.searchModal, visible: false }, scrollToMessage: mId }));
|
||||
return html`
|
||||
<div class="SearchModal">
|
||||
<h3>${global ? 'Global' : 'Notebook'} Search</h3>
|
||||
<input placeholder="Search..." value=${query} onInput=${e => setState(s => ({ ...s, searchModal: { ...s.searchModal, query: e.target.value }}))}/>
|
||||
${results.map(result => html`
|
||||
<div class="SearchResult" onClick=${() => select(result.notebook.id, result.id)}>
|
||||
${global && html`<div class="NotebookTitle">
|
||||
<div class="NotebookEmoji" style=${{ background: result.notebook.color }}>${result.notebook.emoji}</div>
|
||||
<strong>${result.notebook.name}</strong>
|
||||
</div>`}
|
||||
<div>${result.text}</div><em>${new Date(result.created/*timestamp*/).toLocaleString()}</em>
|
||||
</div>
|
||||
`)}
|
||||
<button onClick=${() => setState(s => ({ ...s, searchModal: { ...s.searchModal, visible: false }}))}>Close</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function AppSettingsModal() {
|
||||
const {state, setState} = useContext(AppContext);
|
||||
const [importTxt, setImportTxt] = useState('');
|
||||
const exportData = () => JSON.stringify({ notebooks: state.notebooks, /* encrypted */ messages: Object.fromEntries(Object.entries(state.encrypted).map(([key, values]) => ([key, Object.values(values)]))) }, null, 2);
|
||||
const doImport = () => {
|
||||
try {
|
||||
const obj = JSON.parse(importTxt);
|
||||
if (obj.notebooks && obj.messages /* encrypted */) {
|
||||
// localforage.setItem('notebooks', obj.notebooks);
|
||||
// Object.entries(obj.messages /* encrypted */).forEach(([id, arr]) => localforage.setItem(`notebook-${id}`, arr));
|
||||
setState(s => ({ ...s,
|
||||
notebooks: obj.notebooks,
|
||||
encrypted: Object.fromEntries(Object.entries(obj.messages).map(([notebookId, messages]) => ([notebookId, Object.fromEntries(messages.map(message => [message.id, message]))]))),
|
||||
}));
|
||||
window.location.reload();
|
||||
} else {
|
||||
alert('Invalid format');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Invalid JSON');
|
||||
}
|
||||
};
|
||||
return html`
|
||||
<div class="AppSettingsModal">
|
||||
<h3>App Settings</h3>
|
||||
<h4>Export Data</h4><textarea readonly rows="8">${exportData()}</textarea>
|
||||
<h4>Import Data</h4><textarea rows="6" placeholder="Paste JSON" onInput=${e => setImportTxt(e.target.value)} />
|
||||
<button onClick=${doImport}>Import</button>
|
||||
<br /><br />
|
||||
<button onClick=${() => setState(s => ({ ...s, showAppSettings:false }))}>Close</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render(html`<${App}/>`, document.body);
|
||||
const libs = [
|
||||
{
|
||||
l: './preact/preact.mjs',
|
||||
g: 'https://esm.sh/preact',
|
||||
h: mod => {
|
||||
const { h, render, createContext } = mod;
|
||||
Object.assign(window, { h, render, createContext });
|
||||
},
|
||||
},
|
||||
{
|
||||
l: './preact/hooks.mjs',
|
||||
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.mjs',
|
||||
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>
|
10
manifest.json
Normal file
10
manifest.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": " WhichNot",
|
||||
"short_name": "WhichNot",
|
||||
"scope": "https://whichnot.octt.eu.org/",
|
||||
"start_url": "https://whichnot.octt.eu.org/",
|
||||
"display": "standalone",
|
||||
"icons": [
|
||||
{ "src": "./icon.png", "type": "image/png", "sizes": "1024x1024" }
|
||||
]
|
||||
}
|
Reference in New Issue
Block a user