This commit is contained in:
2025-04-21 22:48:03 +02:00
parent 076883ebf9
commit da96f61d28

View File

@ -1411,7 +1411,7 @@ const uuidv7 = () => {
bytes[5] = Number(timestamp & 0xffn); bytes[5] = Number(timestamp & 0xffn);
bytes[6] = (bytes[6] & 0x0f) | 0x70; bytes[6] = (bytes[6] & 0x0f) | 0x70;
bytes[8] = (bytes[8] & 0x3f) | 0x80; bytes[8] = (bytes[8] & 0x3f) | 0x80;
const chars = Array.from(bytes).map(b => b.toString(16).padStart(2, '0')); const chars = Array.from(bytes).map(byte => byte.toString(16).padStart(2, '0'));
[10, 8, 6, 4].forEach(pos => chars.splice(pos, 0, '-')); [10, 8, 6, 4].forEach(pos => chars.splice(pos, 0, '-'));
return chars.join(''); return chars.join('');
} }
@ -1432,16 +1432,39 @@ const deriveMsgKey = async (rawKey, salt) => crypto.subtle.deriveKey(
await crypto.subtle.importKey('raw', rawKey, 'HKDF', false, ['deriveKey']), await crypto.subtle.importKey('raw', rawKey, 'HKDF', false, ['deriveKey']),
{ name: 'AES-GCM', length: 256 }, { name: 'AES-GCM', length: 256 },
true, ['encrypt', 'decrypt']); true, ['encrypt', 'decrypt']);
const getAesRawKey = async (aesKeyB64) => await crypto.subtle.exportKey('raw', await importJWK(aesKeyB64, { name: 'AES-GCM' }, ['encrypt','decrypt']));
const escapeHtml = txt => { 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'); const node = document.createElement('p');
node.appendChild(document.createTextNode(txt)); node.appendChild(document.createTextNode(text));
return node.innerHTML; return node.innerHTML;
} }
const makeParagraph = txt => `<p>${txt.replaceAll('\n', '<br />')}</p>` const makeParagraph = text => `<p>${text.replaceAll('\n', '<br />')}</p>`
const linkify = txt => txt.replace(/(\bhttps?:\/\/[^\s]+)/g,'<a href="$1" target="_blank">$1</a>'); 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 getNotebook = (notebooks, id) => notebooks.find(notebook => (notebook.id === id));
const closedContextMenu = (s) => ({ contextMenu: { ...s.contextMenu, visible: false } }); const closedContextMenu = (s) => ({ contextMenu: { ...s.contextMenu, visible: false } });
function App() { function App() {
@ -1451,7 +1474,7 @@ function App() {
showSettings: false, showAppSettings: false, showSettings: false, showAppSettings: false,
createModal: false, dateTimeModal: null, createModal: false, dateTimeModal: null,
crossReplyModal: false, crossReplySource: null, crossReplyModal: false, crossReplySource: null,
contextMenu:{ visible: false, messageIndex: null, x: 0, y: 0 }, contextMenu:{ visible: false, messageId: null, x: 0, y: 0 },
searchModal: { visible: false, global: false, query: '' }, searchModal: { visible: false, global: false, query: '' },
editingMessage: null, replyingTo: null, reactionInputFor: null, editingMessage: null, replyingTo: null, reactionInputFor: null,
}); });
@ -1465,16 +1488,12 @@ function App() {
for (const notebook of raw) { for (const notebook of raw) {
const arr=JSON.parse(localStorage.getItem(`notebook-${notebook.id}`)) || []; const arr=JSON.parse(localStorage.getItem(`notebook-${notebook.id}`)) || [];
enc[notebook.id]=arr; enc[notebook.id]=arr;
const aes = await importJWK(notebook.aesKeyB64,{name:'AES-GCM'},['encrypt','decrypt']), const rawKey = await getAesRawKey(notebook.aesKeyB64);
rawKey = await crypto.subtle.exportKey('raw', aes), const plain = {}; // [];
plain = []; for (const e of Object.values(arr)) { // arr) {
for (const e of arr) { //plain.push({ ...e, text: new TextDecoder().decode(dec) });
const salt = b64ToBuf(e.salt), //plain.push(await decryptMessage(e, rawKey));
iv = b64ToBuf(e.iv), plain[e.id] = await decryptMessage(e, rawKey);
key = await deriveMsgKey(rawKey, salt),
ct = b64ToBuf(e.ciphertext),
dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
plain.push({ ...e, text: new TextDecoder().decode(dec) });
} }
msgs[notebook.id] = plain; msgs[notebook.id] = plain;
} }
@ -1522,8 +1541,8 @@ function App() {
}; };
setState(s => ({ ...s, setState(s => ({ ...s,
notebooks: [ ...s.notebooks, notebook ], notebooks: [ ...s.notebooks, notebook ],
encrypted: { ...s.encrypted, [id]: [] }, encrypted: { ...s.encrypted, [id]: {} }, // [] },
messages: { ...s.messages, [id]: [] }, messages: { ...s.messages, [id]: {} }, // [] },
createModal: false, createModal: false,
})); }));
// if (type==='remote') { // if (type==='remote') {
@ -1534,21 +1553,21 @@ function App() {
// } // }
}, [state.notebooks]); }, [state.notebooks]);
// Persist (encrypt & sync) const getNotebook = useCallback(notebookId => state.notebooks.find(notebook => (notebook.id === notebookId)), [state.notebooks]);
const getMessages = useCallback((notebookId, messageId) => {
const messages = state.messages[notebookId];
return (messageId ? messages[messageId] : messages);
// return (messageId ? messages.find(message => (message.id === messageId)) : messages);
}, [state.messages]);
const persistMessages = useCallback(async(nbId, plainArr) => { const persistMessages = useCallback(async(nbId, plainArr) => {
const notebook = getNotebook(state.notebooks, nbId); const notebook = getNotebook(nbId);
if (!notebook) return; if (!notebook) return;
const aes=await importJWK(notebook.aesKeyB64,{name:'AES-GCM'},['encrypt','decrypt']), const rawKey = await getAesRawKey(notebook.aesKeyB64);
rawKey=await crypto.subtle.exportKey('raw',aes), const encArr = {}; // [];
encArr=[]; for (const message of Object.values(plainArr)) { //plainArr){
for(const m of plainArr){ //encArr.push(await encryptMessage(message, rawKey));
const salt=randBytes(), iv=randBytes(12), encArr[message.id] = await encryptMessage(message, rawKey);
key=await deriveMsgKey(rawKey,salt),
ct=await crypto.subtle.encrypt({name:'AES-GCM',iv},key,new TextEncoder().encode(m.text));
encArr.push({ id:m.id, salt:bufToB64(salt), iv:bufToB64(iv),
ciphertext:bufToB64(ct), timestamp:m.timestamp,
edited:m.edited, replyTo:m.replyTo, reactions:m.reactions
});
} }
setState(s => ({ ...s, setState(s => ({ ...s,
encrypted: { ...s.encrypted, [nbId]: encArr }, encrypted: { ...s.encrypted, [nbId]: encArr },
@ -1587,7 +1606,7 @@ function App() {
if (message) { if (message) {
inputRef.current.value = message.text; inputRef.current.value = message.text;
} }
//console.log(state, message); console.log(state, message, state.messages[state.selectedNotebook]);
} }
}, [state.editingMessage, state.selectedNotebook, state.messages]); }, [state.editingMessage, state.selectedNotebook, state.messages]);
@ -1597,7 +1616,7 @@ function App() {
const text = inputRef.current.value.trim(); const text = inputRef.current.value.trim();
if (!text) return; if (!text) return;
const arr = state.messages[nbId] || []; const arr = state.messages[nbId] || [];
const notebook = getNotebook(state.notebooks, nbId); const notebook = getNotebook(nbId);
let message = arr[state.editingMessage]; let message = arr[state.editingMessage];
if (!message) { if (!message) {
message = { message = {
@ -1615,19 +1634,29 @@ function App() {
? { ...notebook, nextMessageId: (state.editingMessage==null ? notebook.nextMessageId+1 : notebook.nextMessageId) } ? { ...notebook, nextMessageId: (state.editingMessage==null ? notebook.nextMessageId+1 : notebook.nextMessageId) }
: notebook : notebook
) })); ) }));
const newArr = (state.editingMessage!=null // const newArr = (state.editingMessage!=null
? arr.map((msg, i) => (i===state.editingMessage ? message : msg)) // ? arr.map((msg, i) => (i===state.editingMessage ? message : msg))
: [...arr, message] // : { ...arr, [message.id]: message } // [...arr, message]
); // );
const newArr = arr;
newArr[message.id] = message;
// reset editing & replying // reset editing & replying
setState( s => ({ ...s, editingMessage: null, replyingTo: null })); setState( s => ({ ...s, editingMessage: null, replyingTo: null }));
await persistMessages(nbId, newArr); await persistMessages(nbId, newArr);
}, [state.selectedNotebook, state.editingMessage, state.replyingTo, state.messages, state.notebooks]); }, [state.selectedNotebook, state.editingMessage, state.replyingTo, state.messages, state.notebooks]);
const deleteMessage = (notebookId, messageId) => {
const messages = getMessages(notebookId);
delete messages[messageId];
persistMessages(notebookId, Object.values(messages));
// setState(s => ({ ...s, messages: { ...s.messages, [nbId]: messages } }));
};
return html` return html`
<${AppContext.Provider} value=${{ <${AppContext.Provider} value=${{
state, setState, createNotebook, state, setState, createNotebook,
sendMessage, persistMessages, getNotebook, getMessages,
sendMessage, deleteMessage, persistMessages,
addReaction, confirmReaction, removeReaction, addReaction, confirmReaction, removeReaction,
}}> }}>
<div class="App ${state.selectedNotebook ? 'show-chat' : ''}"> <div class="App ${state.selectedNotebook ? 'show-chat' : ''}">
@ -1676,21 +1705,22 @@ function ChatList() {
} }
function ChatScreen({inputRef}) { function ChatScreen({inputRef}) {
const { state, setState, sendMessage } = useContext(AppContext); const { state, setState, sendMessage, getNotebook } = useContext(AppContext);
const notebook = getNotebook(state.notebooks, state.selectedNotebook); const notebook = getNotebook(state.selectedNotebook);
let messages = state.messages[notebook?.id] || []; let messages = state.messages[notebook?.id] || [];
messages = [...messages].sort((a,b) => (a.timestamp - b.timestamp)); messages = /* [...messages] */ Object.values(messages).sort((a,b) => (a.timestamp - b.timestamp));
// Scroll on request // Scroll on request
useEffect(()=>{ useEffect(()=>{
// Array.from(document.querySelectorAll(`.Message[data-msg-id${state.scrollToMessage!=null ? `="${state.scrollToMessage}"` : ''}]`)).slice(-1)[0]?.scrollIntoView({ behavior: 'smooth', block: 'center' }); // 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) { if (state.scrollToMessage!=null) {
document.querySelector(`[data-msg-id="${state.scrollToMessage}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); document.querySelector(`[data-message-id="${state.scrollToMessage}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
setState(s => ({ ...s, scrollToMessage: null })); setState(s => ({ ...s, scrollToMessage: null }));
} }
}, [state.scrollToMessage, state.selectedNotebook]); }, [state.scrollToMessage, state.selectedNotebook]);
if (!notebook) return null; if (!notebook) return null;
return html` return html`
<div class="ChatScreen"> <div class="ChatScreen">
<div class="ChatHeader" onClick=${() => setState(s => ({ ...s, showSettings: true }))}> <div class="ChatHeader" onClick=${() => setState(s => ({ ...s, showSettings: true }))}>
@ -1712,8 +1742,8 @@ function ChatScreen({inputRef}) {
</button> </button>
</div> </div>
<div class="Messages"> <div class="Messages">
${messages.map((m,i) => html` ${messages.map(message => html`
<${Message} m=${m} i=${i} /> <${Message} message=${message} />
`)} `)}
</div> </div>
<div class="SendBar"> <div class="SendBar">
@ -1732,51 +1762,50 @@ function ChatScreen({inputRef}) {
`; `;
} }
function Message({m,i}) { function Message({message}) {
const { const {
state, setState, state, setState,
addReaction, confirmReaction, removeReaction addReaction, confirmReaction, removeReaction
} = useContext(AppContext); } = useContext(AppContext);
return html` return html`
<div class="Message" data-msg-id=${m.id} <div class="Message" data-message-id=${message.id}
onContextMenu=${event => { onContextMenu=${event => {
event.preventDefault(); event.preventDefault();
setState(s => ({ ...s, contextMenu: { visible: true, messageIndex: i, x: event.clientX, y: event.clientY } })); setState(s => ({ ...s, contextMenu: { visible: true, messageId: message.id, x: event.clientX, y: event.clientY } }));
}} }}
onTouchStart=${event => { onTouchStart=${event => {
event.preventDefault(); event.preventDefault();
const target = event.touches[0]; const target = event.touches[0];
setState(s => ({ ...s, contextMenu: { visible: true, messageIndex: i, x: target.clientX, y: target.clientY } })); setState(s => ({ ...s, contextMenu: { visible: true, messageId: message.id, x: target.clientX, y: target.clientY } }));
}}> }}>
${m.replyTo&&html` ${message.replyTo && html`
<div class="ReplyIndicator" <div class="ReplyIndicator"
onClick=${()=>setState(s=>({ onClick=${()=>setState(s=>({
...state, ...state,
selectedNotebook: m.replyTo.notebookId, selectedNotebook: message.replyTo.notebookId,
scrollToMessage: m.replyTo.id, scrollToMessage: message.replyTo.id,
}))}> }))}>
Reply to "${(state.messages[m.replyTo.notebookId] || []).find(x => x.id===m.replyTo.id)?.text || ''}" Reply to "${(state.messages[message.replyTo.notebookId] || []).find(x => x.id===message.replyTo.id)?.text || ''}"
</div>`} </div>`}
<div dangerouslySetInnerHTML=${{ __html: makeParagraph(linkify(escapeHtml(m.text))) }} /> <div dangerouslySetInnerHTML=${{ __html: makeParagraph(linkify(escapeHtml(message.text))) }} />
${(() => { ${(() => {
const text = m.text.toLowerCase(); if (isSimpleUrl(message.text)) {
if (!text.includes(' ') && (text.startsWith('http://') || text.startsWith('https://'))) {
return html`<div> return html`<div>
<iframe src=${m.text}></iframe> <iframe src=${message.text} sandbox></iframe>
</div>`; </div>`;
} }
})()} })()}
<div class="reactions"> <div class="reactions">
${m.reactions.map(r => html` ${message.reactions.map(r => html`
<button onClick=${() => removeReaction(i,r)}>${r}</button> <button onClick=${() => removeReaction(message.id, r)}>${r}</button>
`)} `)}
${state.reactionInputFor===i ${state.reactionInputFor===message.id
? html`<input class="ReactionInput" maxlength="2" autofocus ? html`<input class="ReactionInput" maxlength="2" autofocus
onKeyPress=${e=>e.key==='Enter'&&(confirmReaction(i,e.target.value), e.target.value='')} />` onKeyPress=${e=>e.key==='Enter'&&(confirmReaction(message.id, e.target.value), e.target.value='')} />`
: html`<button class="AddReactionBtn" onClick=${()=>addReaction(i)}></button>` : html`<button class="AddReactionBtn" onClick=${()=>addReaction(message.id)}></button>`
} }
</div> </div>
<div class="Timestamp">${new Date(m.timestamp).toLocaleString()}${m.edited ? ' (edited)' : ''}</div> <div class="Timestamp">${new Date(message.timestamp).toLocaleString()}${message.edited ? ' (edited)' : ''}</div>
</div> </div>
` `
} }
@ -1813,8 +1842,8 @@ function CrossReplyModal() {
} }
function ContextMenu() { function ContextMenu() {
const {state, setState, persistMessages} = useContext(AppContext); const {state, setState, deleteMessage, persistMessages} = useContext(AppContext);
const idx = state.contextMenu.messageIndex; const idx = state.contextMenu.messageId;
const nbId = state.selectedNotebook; const nbId = state.selectedNotebook;
const arr = state.messages[nbId] || []; const arr = state.messages[nbId] || [];
const msg = arr[idx]; const msg = arr[idx];
@ -1838,9 +1867,10 @@ function ContextMenu() {
setState(s => ({ ...s, dateTimeModal: idx, ...closedContextMenu(s) })); setState(s => ({ ...s, dateTimeModal: idx, ...closedContextMenu(s) }));
return; return;
case 'delete': case 'delete':
newArr=arr.filter((_,i)=>i!==idx); deleteMessage(nbId, idx);
persistMessages(nbId, newArr); // newArr=arr.filter((_,i)=>i!==idx);
setState(s => ({ ...s, messages: { ...s.messages, [nbId]: newArr }, ...closedContextMenu(s) })); // persistMessages(nbId, newArr);
setState(s => ({ ...s, /* messages: { ...s.messages, [nbId]: newArr }, */ ...closedContextMenu(s) }));
return; return;
} }
}; };
@ -1881,8 +1911,8 @@ function DateTimeModal() {
} }
function SettingsModal() { function SettingsModal() {
const {state, setState} = useContext(AppContext); const {state, setState, getNotebook} = useContext(AppContext);
const notebook = getNotebook(state.notebooks, state.selectedNotebook); const notebook = getNotebook(state.selectedNotebook);
const [form, setForm] = useState({ ...notebook }); const [form, setForm] = useState({ ...notebook });
const save = () => setState(s => ({ ...s, notebooks: s.notebooks.map(n => (n.id===notebook.id ? form : n)), showSettings: false })); const save = () => setState(s => ({ ...s, notebooks: s.notebooks.map(n => (n.id===notebook.id ? form : n)), showSettings: false }));
const del = () => { const del = () => {
@ -1916,11 +1946,11 @@ function SettingsModal() {
} }
function SearchModal() { function SearchModal() {
const {state, setState} = useContext(AppContext); const {state, setState, getNotebook} = useContext(AppContext);
const {query, global} = state.searchModal; const {query, global} = state.searchModal;
const results = (global const results = (global
? state.notebooks.flatMap(notebook => (state.messages[notebook.id] || []).map(message => ({ ...message, notebook }))) ? state.notebooks.flatMap(notebook => (state.messages[notebook.id] || []).map(message => ({ ...message, notebook })))
: (state.messages[state.selectedNotebook] || []).map(message => ({ ...message, notebook: getNotebook(state.notebooks, state.selectedNotebook) })) : (state.messages[state.selectedNotebook] || []).map(message => ({ ...message, notebook: getNotebook(state.selectedNotebook) }))
).filter(message => message.text.toLowerCase().includes(query.toLowerCase())); ).filter(message => message.text.toLowerCase().includes(query.toLowerCase()));
const select = (nbId, mId) => setState(s => ({ ...s, selectedNotebook: nbId, searchModal: { ...s.searchModal, visible: false }, scrollToMessage: mId })); const select = (nbId, mId) => setState(s => ({ ...s, selectedNotebook: nbId, searchModal: { ...s.searchModal, visible: false }, scrollToMessage: mId }));
return html` return html`