diff --git a/index.html b/index.html index edd6674..cdb03ea 100644 --- a/index.html +++ b/index.html @@ -1411,7 +1411,7 @@ const uuidv7 = () => { bytes[5] = Number(timestamp & 0xffn); bytes[6] = (bytes[6] & 0x0f) | 0x70; 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, '-')); return chars.join(''); } @@ -1432,16 +1432,39 @@ const deriveMsgKey = async (rawKey, salt) => crypto.subtle.deriveKey( 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 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'); - node.appendChild(document.createTextNode(txt)); + node.appendChild(document.createTextNode(text)); return node.innerHTML; } -const makeParagraph = txt => `

${txt.replaceAll('\n', '
')}

` -const linkify = txt => txt.replace(/(\bhttps?:\/\/[^\s]+)/g,'$1'); +const makeParagraph = text => `

${text.replaceAll('\n', '
')}

` +const linkify = text => text.replace(/(\bhttps?:\/\/[^\s]+)/g,'$1'); +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 } }); function App() { @@ -1451,7 +1474,7 @@ function App() { showSettings: false, showAppSettings: false, createModal: false, dateTimeModal: 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: '' }, editingMessage: null, replyingTo: null, reactionInputFor: null, }); @@ -1465,16 +1488,12 @@ function App() { for (const notebook of raw) { const arr=JSON.parse(localStorage.getItem(`notebook-${notebook.id}`)) || []; enc[notebook.id]=arr; - const aes = await importJWK(notebook.aesKeyB64,{name:'AES-GCM'},['encrypt','decrypt']), - rawKey = await crypto.subtle.exportKey('raw', aes), - plain = []; - for (const e of arr) { - const salt = b64ToBuf(e.salt), - iv = b64ToBuf(e.iv), - 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) }); + const rawKey = await getAesRawKey(notebook.aesKeyB64); + const plain = {}; // []; + 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); } msgs[notebook.id] = plain; } @@ -1522,8 +1541,8 @@ function App() { }; setState(s => ({ ...s, notebooks: [ ...s.notebooks, notebook ], - encrypted: { ...s.encrypted, [id]: [] }, - messages: { ...s.messages, [id]: [] }, + encrypted: { ...s.encrypted, [id]: {} }, // [] }, + messages: { ...s.messages, [id]: {} }, // [] }, createModal: false, })); // if (type==='remote') { @@ -1534,21 +1553,21 @@ function App() { // } }, [state.notebooks]); - // Persist (encrypt & sync) - const persistMessages = useCallback(async(nbId, plainArr)=>{ - const notebook = getNotebook(state.notebooks, nbId); + 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 notebook = getNotebook(nbId); if (!notebook) return; - const aes=await importJWK(notebook.aesKeyB64,{name:'AES-GCM'},['encrypt','decrypt']), - rawKey=await crypto.subtle.exportKey('raw',aes), - encArr=[]; - for(const m of plainArr){ - const salt=randBytes(), iv=randBytes(12), - 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 - }); + 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 }, @@ -1587,7 +1606,7 @@ function App() { if (message) { inputRef.current.value = message.text; } - //console.log(state, message); + console.log(state, message, state.messages[state.selectedNotebook]); } }, [state.editingMessage, state.selectedNotebook, state.messages]); @@ -1597,7 +1616,7 @@ function App() { const text = inputRef.current.value.trim(); if (!text) return; const arr = state.messages[nbId] || []; - const notebook = getNotebook(state.notebooks, nbId); + const notebook = getNotebook(nbId); let message = arr[state.editingMessage]; if (!message) { message = { @@ -1615,19 +1634,29 @@ function App() { ? { ...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] - ); + // 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 setState( s => ({ ...s, editingMessage: null, replyingTo: null })); await persistMessages(nbId, newArr); }, [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` <${AppContext.Provider} value=${{ state, setState, createNotebook, - sendMessage, persistMessages, + getNotebook, getMessages, + sendMessage, deleteMessage, persistMessages, addReaction, confirmReaction, removeReaction, }}>
@@ -1657,9 +1686,9 @@ function ChatList() {
${state.notebooks.sort((a,b) => (sortNotebook(b) - sortNotebook(a))).map(notebook => html`
- ${messages.map((m,i) => html` - <${Message} m=${m} i=${i} /> + ${messages.map(message => html` + <${Message} message=${message} /> `)}
@@ -1732,51 +1762,50 @@ function ChatScreen({inputRef}) { `; } -function Message({m,i}) { +function Message({message}) { const { state, setState, addReaction, confirmReaction, removeReaction } = useContext(AppContext); return html` -
{ - event.preventDefault(); - setState(s => ({ ...s, contextMenu: { visible: true, messageIndex: i, x: event.clientX, y: event.clientY } })); - }} - onTouchStart=${event => { - event.preventDefault(); - const target = event.touches[0]; - setState(s => ({ ...s, contextMenu: { visible: true, messageIndex: i, x: target.clientX, y: target.clientY } })); - }}> - ${m.replyTo&&html` +
{ + 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`
setState(s=>({ ...state, - selectedNotebook: m.replyTo.notebookId, - scrollToMessage: m.replyTo.id, + selectedNotebook: message.replyTo.notebookId, + 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 || ''}"
`} -
+
${(() => { - const text = m.text.toLowerCase(); - if (!text.includes(' ') && (text.startsWith('http://') || text.startsWith('https://'))) { + if (isSimpleUrl(message.text)) { return html`
- +
`; } })()}
- ${m.reactions.map(r => html` - + ${message.reactions.map(r => html` + `)} - ${state.reactionInputFor===i + ${state.reactionInputFor===message.id ? html`e.key==='Enter'&&(confirmReaction(i,e.target.value), e.target.value='')} />` - : html`` + onKeyPress=${e=>e.key==='Enter'&&(confirmReaction(message.id, e.target.value), e.target.value='')} />` + : html`` }
-
${new Date(m.timestamp).toLocaleString()}${m.edited ? ' (edited)' : ''}
+
${new Date(message.timestamp).toLocaleString()}${message.edited ? ' (edited)' : ''}
` } @@ -1813,8 +1842,8 @@ function CrossReplyModal() { } function ContextMenu() { - const {state, setState, persistMessages} = useContext(AppContext); - const idx = state.contextMenu.messageIndex; + const {state, setState, deleteMessage, persistMessages} = useContext(AppContext); + const idx = state.contextMenu.messageId; const nbId = state.selectedNotebook; const arr = state.messages[nbId] || []; const msg = arr[idx]; @@ -1838,9 +1867,10 @@ function ContextMenu() { setState(s => ({ ...s, dateTimeModal: idx, ...closedContextMenu(s) })); return; case 'delete': - newArr=arr.filter((_,i)=>i!==idx); - persistMessages(nbId, newArr); - setState(s => ({ ...s, messages: { ...s.messages, [nbId]: newArr }, ...closedContextMenu(s) })); + deleteMessage(nbId, idx); + // newArr=arr.filter((_,i)=>i!==idx); + // persistMessages(nbId, newArr); + setState(s => ({ ...s, /* messages: { ...s.messages, [nbId]: newArr }, */ ...closedContextMenu(s) })); return; } }; @@ -1881,8 +1911,8 @@ function DateTimeModal() { } function SettingsModal() { - const {state, setState} = useContext(AppContext); - const notebook = getNotebook(state.notebooks, state.selectedNotebook); + const {state, setState, getNotebook} = 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 = () => { @@ -1916,11 +1946,11 @@ function SettingsModal() { } function SearchModal() { - const {state, setState} = useContext(AppContext); + 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.notebooks, state.selectedNotebook) })) + : (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`