This commit is contained in:
2025-04-21 22:46:58 +02:00
parent c054921d71
commit 910953d9bd

View File

@ -1310,15 +1310,21 @@ render(html`<${App}/>`, document.body);
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WhichNot: WhatsAppstyle Notes</title> <title>WhichNot</title>
<style> <style>
:root { :root {
--whatsapp-green: #00a884; --whatsapp-green: #00a884;
--header-bg: #f0f2f5; --header-bg: #f0f2f5;
} }
body, html { margin: 0; height: 100%; font-family: Arial, sans-serif; } body, html {
margin: 0; height: 100%;
font-family: Arial, sans-serif;
}
.App { display: flex; height: 100vh; } .App { display: flex; height: 100vh; }
.ChatList { width: 30%; background: white; border-right:1px solid #ddd; overflow-y:auto; } .ChatList {
width: 30%; overflow-y: auto;
background: white; border-right: 1px solid #ddd;
}
.ChatList-header { display:flex; justify-content:space-between; align-items:center; padding:.75rem 1rem; border-bottom:1px solid #ddd; } .ChatList-header { display:flex; justify-content:space-between; align-items:center; padding:.75rem 1rem; border-bottom:1px solid #ddd; }
.ChatList-header button { background:none; border:none; font-size:1.25rem; cursor:pointer; } .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 { width:100%; padding:.75rem 1rem; background:none; border:none; cursor:pointer; text-align:left; }
@ -1351,13 +1357,14 @@ render(html`<${App}/>`, document.body);
.ContextMenuItem { padding:.5rem 1rem; cursor:pointer; } .ContextMenuItem { padding:.5rem 1rem; cursor:pointer; }
.ContextMenuItem:hover { background:#f5f5f5; } .ContextMenuItem:hover { background:#f5f5f5; }
.DateTimeModal, .SearchModal, .AppSettingsModal, .CreateModal, .CrossReplyModal { .DateTimeModal, .SearchModal, .AppSettingsModal, .CreateModal, .SettingsModal, .CrossReplyModal {
position: fixed; top: 50%; left: 50%; transform: translate(-50%,-50%); 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); 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; max-height: 80vh; overflow-y: auto; width: 90%; max-width: 400px; z-index: 1001;
} }
.SearchModal input, .AppSettingsModal textarea, .CreateModal input { .SearchModal input, .AppSettingsModal textarea, .CreateModal input, .SettingsModal input {
width:100%; margin:.5rem 0; padding:.5rem; border:1px solid #ddd; border-radius:.25rem; width: 100%; margin: .5rem 0; padding: .5rem;
border: 1px solid #ddd; border-radius: .25rem;
} }
.SearchResult { padding: .5rem 0; border-bottom: 1px solid #eee; cursor: pointer; } .SearchResult { padding: .5rem 0; border-bottom: 1px solid #eee; cursor: pointer; }
.SearchResult:hover { background: #f9f9f9; } .SearchResult:hover { background: #f9f9f9; }
@ -1386,19 +1393,18 @@ const genAESKey = async () => crypto.subtle.generateKey({ name: 'AES-GCM', lengt
const genEd25519 = async () => crypto.subtle.generateKey({ name: 'Ed25519', namedCurve: 'Ed25519' }, true, ['sign', 'verify']); 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 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 importJWK = async (b64, alg, usages) => crypto.subtle.importKey('jwk', JSON.parse(atob(b64)), alg, true, usages);
const randBytes = (n=12) => { const randBytes = (n=12) => {
const b = new Uint8Array(n); const b = new Uint8Array(n);
crypto.getRandomValues(b); crypto.getRandomValues(b);
return b; return b;
} }
const bufToB64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf))); const bufToB64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
const b64ToBuf = (str) => Uint8Array.from(atob(str),c=>c.charCodeAt(0)); const b64ToBuf = (str) => Uint8Array.from(atob(str), (c => c.charCodeAt(0)));
const deriveMsgKey = async (rawKey, salt) => crypto.subtle.deriveKey(
const deriveMsgKey = async (rawKey,salt) => { { name: 'HKDF', salt, info: new TextEncoder().encode('msg'), hash: 'SHA-256' },
const hk = await crypto.subtle.importKey('raw', rawKey, 'HKDF', false, ['deriveKey']); await crypto.subtle.importKey('raw', rawKey, 'HKDF', false, ['deriveKey']),
return crypto.subtle.deriveKey({name:'HKDF',salt,info:new TextEncoder().encode('msg'),hash:'SHA-256'}, hk, {name:'AES-GCM',length:256}, true, ['encrypt','decrypt']); { name: 'AES-GCM', length: 256 },
} true, ['encrypt', 'decrypt']);
const linkify = txt => txt.replace(/(\bhttps?:\/\/[^\s]+)/g,'<a href="$1" target="_blank">$1</a>'); const linkify = txt => txt.replace(/(\bhttps?:\/\/[^\s]+)/g,'<a href="$1" target="_blank">$1</a>');
const getNotebook = (notebooks, id) => notebooks.find(notebook => (notebook.id === id)); const getNotebook = (notebooks, id) => notebooks.find(notebook => (notebook.id === id));
@ -1407,13 +1413,13 @@ const closedContextMenu = (s) => ({ contextMenu: { ...s.contextMenu, visible: fa
function App() { function App() {
const [state,setState] = useState({ const [state,setState] = useState({
notebooks: [], encrypted: {}, messages: {}, notebooks: [], encrypted: {}, messages: {},
selectedNotebook:null, editingMessage:null, selectedNotebook: null, scrollToMessage: null,
showSettings: false, showAppSettings: false, showSettings: false, showAppSettings: false,
createModal:false, crossReplyModal:false, crossReplySource:null, createModal: false, dateTimeModal: null,
crossReplyModal: false, crossReplySource: null,
contextMenu:{ visible: false, messageIndex: null, x: 0, y: 0 }, contextMenu:{ visible: false, messageIndex: null, x: 0, y: 0 },
dateTimeModal:null, replyingTo:null,
searchModal: { visible: false, global: false, query: '' }, searchModal: { visible: false, global: false, query: '' },
scrollToMessage:null, reactionInputFor:null editingMessage: null, replyingTo: null, reactionInputFor: null,
}); });
const inputRef = useRef(); const inputRef = useRef();
@ -1423,17 +1429,18 @@ function App() {
enc={}, msgs={}; enc={}, msgs={};
(async () => { (async () => {
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 aes = await importJWK(notebook.aesKeyB64,{name:'AES-GCM'},['encrypt','decrypt']),
rawKey = await crypto.subtle.exportKey('raw', aes), rawKey = await crypto.subtle.exportKey('raw', aes),
plain = []; plain = [];
for (const e of arr) { for (const e of arr) {
const salt=b64ToBuf(e.salt), iv=b64ToBuf(e.iv), const salt = b64ToBuf(e.salt),
iv = b64ToBuf(e.iv),
key = await deriveMsgKey(rawKey, salt), key = await deriveMsgKey(rawKey, salt),
ct = b64ToBuf(e.ciphertext), ct = b64ToBuf(e.ciphertext),
dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct); dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, ct);
plain.push({...e,content:new TextDecoder().decode(dec)}); plain.push({ ...e, text: new TextDecoder().decode(dec) });
} }
msgs[notebook.id] = plain; msgs[notebook.id] = plain;
} }
@ -1464,17 +1471,20 @@ function App() {
return () => document.removeEventListener('click', handler); return () => document.removeEventListener('click', handler);
}, [state.contextMenu.visible]); }, [state.contextMenu.visible]);
// Create notebook
const createNotebook = useCallback(async (type) => { const createNotebook = useCallback(async (type) => {
let id = (type === 'local' ? crypto.randomUUID() : prompt('Remote ID:')); let id = (type === 'local' ? crypto.randomUUID() : prompt('Remote ID:'));
if (!id) return; if (!id) return;
const now=Date.now(), aes=await genAESKey(), ed=await genEd25519(); const now=Date.now(), aes=await genAESKey(), ed=await genEd25519();
const aesB64=await exportJWK(aes), privB64=await exportJWK(ed.privateKey), pubB64=await exportJWK(ed.publicKey); const aesB64=await exportJWK(aes), privB64=await exportJWK(ed.privateKey), pubB64=await exportJWK(ed.publicKey);
const notebook={ id, name:'New Notebook', emoji:'📒', const EMOJIS = ['📒','📓','📔','📕','📖','📗','📘','📙','📚','✏️','📝'];
color:'#'+Math.floor(Math.random()*0xFFFFFF).toString(16).padStart(6,'0'), const randomEmoji = () => EMOJIS[Math.floor(Math.random() * EMOJIS.length)];
const randomColor = () => ('#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0'));
const notebook = {
id, name: `Notebook ${now}`,
emoji: randomEmoji(), color: randomColor(),
sourceType: type, description: '', parseMode: 'plaintext', sourceType: type, description: '', parseMode: 'plaintext',
nextMessageId: 1, created: now, nextMessageId: 1, created: now,
aesKeyB64:aesB64, edPrivB64:privB64, edPubB64:pubB64 aesKeyB64: aesB64, edPrivB64: privB64, edPubB64: pubB64,
}; };
setState(s => ({ ...s, setState(s => ({ ...s,
notebooks: [ ...s.notebooks, notebook ], notebooks: [ ...s.notebooks, notebook ],
@ -1482,9 +1492,12 @@ function App() {
messages: { ...s.messages, [id]: [] }, messages: { ...s.messages, [id]: [] },
createModal: false, createModal: false,
})); }));
if(type==='remote'){ // if (type==='remote') {
await fetch(`/notebook/${id}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({publicKey:pubB64})}); // await fetch(`/notebook/${id}`, {
} // method: 'POST', headers: { 'Content-Type': 'application/json' },
// body: JSON.stringify({ publicKey: pubB64 }),
// });
// }
}, [state.notebooks]); }, [state.notebooks]);
// Persist (encrypt & sync) // Persist (encrypt & sync)
@ -1497,7 +1510,7 @@ function App() {
for(const m of plainArr){ for(const m of plainArr){
const salt=randBytes(), iv=randBytes(12), const salt=randBytes(), iv=randBytes(12),
key=await deriveMsgKey(rawKey,salt), key=await deriveMsgKey(rawKey,salt),
ct=await crypto.subtle.encrypt({name:'AES-GCM',iv},key,new TextEncoder().encode(m.content)); 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), encArr.push({ id:m.id, salt:bufToB64(salt), iv:bufToB64(iv),
ciphertext:bufToB64(ct), timestamp:m.timestamp, ciphertext:bufToB64(ct), timestamp:m.timestamp,
edited:m.edited, replyTo:m.replyTo, reactions:m.reactions edited:m.edited, replyTo:m.replyTo, reactions:m.reactions
@ -1507,15 +1520,15 @@ function App() {
encrypted: { ...s.encrypted, [nbId]: encArr }, encrypted: { ...s.encrypted, [nbId]: encArr },
messages: { ...s.messages, [nbId]: plainArr }, messages: { ...s.messages, [nbId]: plainArr },
})); }));
if(notebook.sourceType==='remote'){ // if (notebook.sourceType==='remote') {
const priv=await importJWK(notebook.edPrivB64,{name:'Ed25519',namedCurve:'Ed25519'},['sign']), // const priv=await importJWK(notebook.edPrivB64,{name:'Ed25519',namedCurve:'Ed25519'},['sign']),
payload=new TextEncoder().encode(JSON.stringify(encArr)), // payload=new TextEncoder().encode(JSON.stringify(encArr)),
sig=bufToB64(await crypto.subtle.sign('Ed25519',priv,payload)); // sig=bufToB64(await crypto.subtle.sign('Ed25519',priv,payload));
await fetch(`/notebook/${nbId}/sync`,{ // await fetch(`/notebook/${nbId}/sync`, {
method:'PUT',headers:{'Content-Type':'application/json'}, // method: 'PUT', headers: { 'Content-Type': 'application/json' },
body:JSON.stringify({encryptedArr:encArr,signature:sig,publicKey:notebook.edPubB64}) // body: JSON.stringify({ encryptedArr: encArr, signature: sig, publicKey: notebook.edPubB64 }),
}); // });
} // }
}, [state.notebooks]); }, [state.notebooks]);
const addReaction = useCallback(idx => setState(s => ({ ...s, reactionInputFor: idx })), []); const addReaction = useCallback(idx => setState(s => ({ ...s, reactionInputFor: idx })), []);
@ -1538,41 +1551,41 @@ function App() {
if (state.editingMessage!=null && inputRef.current) { if (state.editingMessage!=null && inputRef.current) {
const message = state.messages[state.selectedNotebook]?.[state.editingMessage]; const message = state.messages[state.selectedNotebook]?.[state.editingMessage];
if (message) { if (message) {
inputRef.current.value = message.content; inputRef.current.value = message.text;
} }
console.log(state, message); //console.log(state, message);
} }
}, [state.editingMessage, state.selectedNotebook, state.messages]); }, [state.editingMessage, state.selectedNotebook, state.messages]);
const sendMessage = useCallback(async () => { const sendMessage = useCallback(async () => {
const nbId = state.selectedNotebook; const nbId = state.selectedNotebook;
if (!nbId) return; if (!nbId) return;
const txt = inputRef.current.value.trim(); const text = inputRef.current.value.trim();
if (!txt) return; if (!text) return;
const arr = state.messages[nbId] || []; const arr = state.messages[nbId] || [];
const notebook = getNotebook(state.notebooks, nbId); const notebook = getNotebook(state.notebooks, nbId);
//console.log(state); //console.log(state);
const message = { let message = arr[state.editingMessage];
if (!message) {
message = {
id: notebook.nextMessageId, id: notebook.nextMessageId,
content: txt,
timestamp: Date.now(), timestamp: Date.now(),
edited: state.editingMessage!=null, edited: state.editingMessage!=null,
replyTo: state.replyingTo, replyTo: state.replyingTo,
reactions: [], reactions: [],
}; };
}
message = { ...message, text };
inputRef.current.value = ''; inputRef.current.value = '';
// update nextMessageId if new // update nextMessageId if new
setState(s => ({ ...s, notebooks: s.notebooks.map(notebook => notebook.id===nbId setState(s => ({ ...s, notebooks: s.notebooks.map(notebook => notebook.id===nbId
? { ...notebook, nextMessageId: (state.editingMessage==null ? notebook.nextMessageId+1 : notebook.nextMessageId) } ? { ...notebook, nextMessageId: (state.editingMessage==null ? notebook.nextMessageId+1 : notebook.nextMessageId) }
: notebook : notebook
) })); ) }));
// build new array const newArr = (state.editingMessage!=null
let newArr; ? arr.map((msg, i) => (i===state.editingMessage ? message : msg))
if(state.editingMessage!=null){ : [...arr, message]
newArr = arr.map((msg,i)=> i===state.editingMessage ? message : msg); );
} else {
newArr = [...arr, 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);
@ -1582,7 +1595,7 @@ function App() {
<${AppContext.Provider} value=${{ <${AppContext.Provider} value=${{
state, setState, createNotebook, state, setState, createNotebook,
sendMessage, persistMessages, sendMessage, persistMessages,
addReaction, confirmReaction, removeReaction addReaction, confirmReaction, removeReaction,
}}> }}>
<div class="App ${state.selectedNotebook ? 'show-chat' : ''}"> <div class="App ${state.selectedNotebook ? 'show-chat' : ''}">
<${ChatList} /> <${ChatList} />
@ -1601,6 +1614,7 @@ function App() {
function ChatList() { function ChatList() {
const {state,setState} = useContext(AppContext); const {state,setState} = useContext(AppContext);
const sortNotebook = (notebook) => Math.max(notebook.created, ...(state.messages[notebook.id] || []).map(message => message.timestamp));
return html` return html`
<div class="ChatList"> <div class="ChatList">
<div class="ChatList-header"> <div class="ChatList-header">
@ -1608,20 +1622,19 @@ function ChatList() {
<button onClick=${() => setState(s => ({ ...s, searchModal: { visible: true, global: true, query: '' } }))}>🔍</button> <button onClick=${() => setState(s => ({ ...s, searchModal: { visible: true, global: true, query: '' } }))}>🔍</button>
<button onClick=${() => setState(s => ({ ...s, showAppSettings: true }))}>⚙️</button> <button onClick=${() => setState(s => ({ ...s, showAppSettings: true }))}>⚙️</button>
</div> </div>
${state.notebooks.sort((a,b)=>{ ${state.notebooks.sort((a,b) => (sortNotebook(b) - sortNotebook(a))).map(notebook => html`
const ta=Math.max(a.created,...(state.messages[a.id]||[]).map(m=>m.timestamp));
const tb=Math.max(b.created,...(state.messages[b.id]||[]).map(m=>m.timestamp));
return tb-ta;
}).map(notebook=>html`
<button class="NotebookButton" key=${notebook.id} <button class="NotebookButton" key=${notebook.id}
onClick=${()=>setState(s=>({...s,selectedNotebook:notebook.id}))}> onClick=${()=>setState(s=>({...s,selectedNotebook:notebook.id}))}>
<div class="NotebookTitle"> <div class="NotebookTitle">
<div class="NotebookEmoji" style=${{background:notebook.color}}>${notebook.emoji}</div> <div class="NotebookEmoji" style=${{background:notebook.color}}>${notebook.emoji}</div>
<h4 class="NotebookName">${notebook.name}</h4> <h4 class="NotebookName">${notebook.name}</h4>
</div> </div>
<div class="NotebookDescription">${notebook.description||'<em>No description</em>'}</div> <div class="NotebookDescription">${notebook.description || 'No description'}</div>
<div class="NotebookPreview"> <div class="NotebookPreview">
${(()=>{const arr=state.messages[notebook.id]||[];return arr.length?arr[arr.length-1].content:'No messages';})()} ${(() => {
const arr = state.messages[notebook.id] || [];
return arr.length ? arr[arr.length-1].text : 'No messages';
})()}
</div> </div>
</button> </button>
`)} `)}
@ -1633,7 +1646,7 @@ function ChatScreen({inputRef}) {
const { state, setState, sendMessage } = useContext(AppContext); const { state, setState, sendMessage } = useContext(AppContext);
const notebook = getNotebook(state.notebooks, state.selectedNotebook); const notebook = getNotebook(state.notebooks, 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].sort((a,b) => (a.timestamp - b.timestamp));
// Scroll on request // Scroll on request
useEffect(()=>{ useEffect(()=>{
@ -1665,13 +1678,15 @@ function ChatScreen({inputRef}) {
</button> </button>
</div> </div>
<div class="Messages"> <div class="Messages">
${messages.map((m,i) => html`<${Message} m=${m} i=${i} />`)} ${messages.map((m,i) => html`
<${Message} m=${m} i=${i} />
`)}
</div> </div>
<div class="SendBar"> <div class="SendBar">
${state.replyingTo && html` ${state.replyingTo && html`
<div class="ReplyPreview"> <div class="ReplyPreview">
<span>Replying to: ${ <span>Replying to: ${
(state.messages[state.replyingTo.notebookId]||[]).find(x=>x.id===state.replyingTo.id)?.content||'' (state.messages[state.replyingTo.notebookId] || []).find(x => x.id===state.replyingTo.id)?.text || ''
}</span> }</span>
<button onClick=${() => setState(s => ({ ...s, replyingTo: null }))}>×</button> <button onClick=${() => setState(s => ({ ...s, replyingTo: null }))}>×</button>
</div>`} </div>`}
@ -1690,18 +1705,25 @@ function Message({m,i}) {
} = useContext(AppContext); } = useContext(AppContext);
return html` return html`
<div class="Message" data-msg-id=${m.id} <div class="Message" data-msg-id=${m.id}
onContextMenu=${e=>{e.preventDefault();setState(s=>({...s,contextMenu:{visible:true,messageIndex:i,x:e.clientX,y:e.clientY}}));}} onContextMenu=${event => {
onTouchStart=${e=>{e.preventDefault();const t=e.touches[0];setState(s=>({...s,contextMenu:{visible:true,messageIndex:i,x:t.clientX,y:t.clientY}}));}}> 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` ${m.replyTo&&html`
<div class="ReplyIndicator" <div class="ReplyIndicator"
onClick=${()=>setState(s=>({ onClick=${()=>setState(s=>({
...state, ...state,
selectedNotebook: m.replyTo.notebookId, selectedNotebook: m.replyTo.notebookId,
scrollToMessage:m.replyTo.id scrollToMessage: m.replyTo.id,
}))}> }))}>
Reply to "${(state.messages[m.replyTo.notebookId]||[]).find(x=>x.id===m.replyTo.id)?.content||''}" Reply to "${(state.messages[m.replyTo.notebookId] || []).find(x => x.id===m.replyTo.id)?.text || ''}"
</div>`} </div>`}
<div dangerouslySetInnerHTML=${{__html:linkify(m.content)}}/> <div dangerouslySetInnerHTML=${{__html:linkify(m.text)}}/>
<div class="reactions"> <div class="reactions">
${m.reactions.map(r => html` ${m.reactions.map(r => html`
<button onClick=${() => removeReaction(i,r)}>${r}</button> <button onClick=${() => removeReaction(i,r)}>${r}</button>
@ -1719,14 +1741,16 @@ function Message({m,i}) {
function CreateModal() { function CreateModal() {
const {createNotebook, setState} = useContext(AppContext); const {createNotebook, setState} = useContext(AppContext);
return html` createNotebook('local');
<div class="CreateModal"> return '';
<h3>Create Notebook</h3> // return html`
<button onClick=${() => createNotebook('local')}>Local Notebook</button> // <div class="CreateModal">
<button onClick=${() => createNotebook('remote')}>Remote Notebook</button> // <h3>Create Notebook</h3>
<button onClick=${() => setState(s => ({ ...s, createModal: false }))}>Cancel</button> // <button onClick=${() => createNotebook('local')}>Local Notebook</button>
</div> // <button onClick=${() => createNotebook('remote')}>Remote Notebook</button>
`; // <button onClick=${() => setState(s => ({ ...s, createModal: false }))}>Cancel</button>
// </div>
// `;
} }
function CrossReplyModal() { function CrossReplyModal() {
@ -1734,12 +1758,12 @@ function CrossReplyModal() {
return html` return html`
<div class="CrossReplyModal"> <div class="CrossReplyModal">
<h3>Reply in Another Notebook</h3> <h3>Reply in Another Notebook</h3>
${state.notebooks.filter(n=>n.id!==state.crossReplySource.notebook).map(n=>html` ${state.notebooks.filter(notebook => notebook.id!==state.crossReplySource.notebook).map(notebook => html`
<button onClick=${() => setState(s => ({ ...s, <button onClick=${() => setState(s => ({ ...s,
selectedNotebook: n.id, selectedNotebook: notebook.id,
replyingTo: { notebookId: s.crossReplySource.notebook, id: s.crossReplySource.id }, replyingTo: { notebookId: s.crossReplySource.notebook, id: s.crossReplySource.id },
crossReplyModal: false, crossReplyModal: false,
}))}>${n.emoji} ${n.name}</button> }))}>${notebook.emoji} ${notebook.name}</button>
`)} `)}
<button onClick=${() => setState(s => ({ ...s, crossReplyModal: false }))}>Cancel</button> <button onClick=${() => setState(s => ({ ...s, crossReplyModal: false }))}>Cancel</button>
</div> </div>
@ -1792,9 +1816,9 @@ function DateTimeModal() {
const [dt,setDt] = useState(''); const [dt,setDt] = useState('');
useEffect(() => (msg && setDt(new Date(msg.timestamp).toISOString().slice(0,16))), [msg]); useEffect(() => (msg && setDt(new Date(msg.timestamp).toISOString().slice(0,16))), [msg]);
const save=()=>{ const save=()=>{
const ts=new Date(dt).getTime(); const timestamp = new Date(dt).getTime();
if(!isNaN(ts)){ if (!isNaN(timestamp)) {
const newArr=arr.map((m,i)=>i===idx?{...m,timestamp:ts}:m); const newArr = arr.map((m,i) => i===idx ? { ...m, timestamp } : m);
persistMessages(nbId, newArr); persistMessages(nbId, newArr);
setState(s => ({ ...s, messages: { ...s.messages, [nbId]: newArr }, dateTimeModal: null })); setState(s => ({ ...s, messages: { ...s.messages, [nbId]: newArr }, dateTimeModal: null }));
} }
@ -1813,15 +1837,22 @@ function SettingsModal() {
const {state, setState} = useContext(AppContext); const {state, setState} = useContext(AppContext);
const notebook = getNotebook(state.notebooks, state.selectedNotebook); const notebook = getNotebook(state.notebooks, 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 = () => {
if (confirm('Delete?')) { if (confirm('Delete?')) {
if(notebook.sourceType==='local') localStorage.removeItem(`notebook-${notebook.id}`); if (notebook.sourceType==='local') {
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})); localStorage.removeItem(`notebook-${notebook.id}`);
}
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` return html`
<div class="CreateModal"> <div class="SettingsModal">
<h3>Settings</h3> <h3>Settings</h3>
<label>Name: <input value=${form.name} onChange=${e => setForm(f => ({ ...f, name: e.target.value }))}/></label><br/> <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>Emoji: <input value=${form.emoji} maxLength="2" onChange=${e => setForm(f => ({ ...f, emoji: e.target.value }))}/></label><br/>
@ -1843,7 +1874,7 @@ function 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.notebooks, state.selectedNotebook) }))
).filter(message => message.content.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`
<div class="SearchModal"> <div class="SearchModal">
@ -1855,7 +1886,7 @@ function SearchModal() {
<div class="NotebookEmoji" style=${{ background: result.notebook.color }}>${result.notebook.emoji}</div> <div class="NotebookEmoji" style=${{ background: result.notebook.color }}>${result.notebook.emoji}</div>
<strong>${result.notebook.name}</strong> <strong>${result.notebook.name}</strong>
</div>`} </div>`}
<div>${result.content}</div><em>${new Date(result.timestamp).toLocaleString()}</em> <div>${result.text}</div><em>${new Date(result.timestamp).toLocaleString()}</em>
</div> </div>
`)} `)}
<button onClick=${() => setState(s => ({ ...s, searchModal: { ...s.searchModal, visible: false }}))}>Close</button> <button onClick=${() => setState(s => ({ ...s, searchModal: { ...s.searchModal, visible: false }}))}>Close</button>