This commit is contained in:
2025-04-21 22:47:29 +02:00
parent 910953d9bd
commit 076883ebf9

View File

@ -1325,12 +1325,18 @@ render(html`<${App}/>`, document.body);
width: 30%; overflow-y: auto; width: 30%; overflow-y: auto;
background: white; border-right: 1px solid #ddd; 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; }
.NotebookButton:hover { background:#f5f5f5; } .NotebookButton:hover { background:#f5f5f5; }
.NotebookTitle { display: flex; align-items: center; gap: .5rem; } .NotebookTitle { display: flex; align-items: center; gap: .5rem; }
.NotebookEmoji { width:1.5rem; height:1.5rem; border-radius:50%; display:flex; align-items:center; justify-content:center; font-size:1rem; } .NotebookEmoji {
width: 1.5rem; height: 1.5rem; display: flex;
border-radius: 50%; align-items: center; justify-content: center; font-size: 1rem;
}
.NotebookName { margin: 0; font-size: 1rem; } .NotebookName { margin: 0; font-size: 1rem; }
.NotebookDescription { font-size: .875rem; color: #555; margin: .25rem 0 0 2rem; } .NotebookDescription { font-size: .875rem; color: #555; margin: .25rem 0 0 2rem; }
.NotebookPreview { font-size: .875rem; color: #666; margin: .25rem 0 0 2rem; } .NotebookPreview { font-size: .875rem; color: #666; margin: .25rem 0 0 2rem; }
@ -1345,6 +1351,7 @@ render(html`<${App}/>`, document.body);
.Message { background:white; padding:.5rem 1rem; border-radius:.5rem; max-width:70%; word-break:break-word; margin:.5rem auto; position:relative; } .Message { background:white; 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 { display:flex; gap:.25rem; margin-top:.25rem; }
.Message .reactions button { background: #f5f5f5; border: none; border-radius: .25rem; padding: 0 .5rem; cursor: pointer; } .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); } .AddReactionBtn { font-size: .9rem; background: none; border: none; cursor: pointer; color: var(--whatsapp-green); }
.ReactionInput { width: 2rem; padding: .1rem; font-size: 1rem; } .ReactionInput { width: 2rem; padding: .1rem; font-size: 1rem; }
.Timestamp { font-size: .75rem; color: #666; margin-top: .25rem; text-align: right; } .Timestamp { font-size: .75rem; color: #666; margin-top: .25rem; text-align: right; }
@ -1353,7 +1360,10 @@ render(html`<${App}/>`, document.body);
.ReplyPreview { background:#f5f5f5; padding:.5rem; border-radius:.25rem; display:flex; justify-content:space-between; align-items:center; } .ReplyPreview { background:#f5f5f5; 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; } .EditArea { flex:1; padding:.5rem; border:1px solid #ddd; border-radius:.5rem; resize:none; }
.ContextMenu { position:fixed; background:white; border:1px solid #ddd; border-radius:.25rem; box-shadow:0 2px 8px rgba(0,0,0,0.1); z-index:1000; min-width:140px; } .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);
}
.ContextMenuItem { padding: .5rem 1rem; cursor: pointer; } .ContextMenuItem { padding: .5rem 1rem; cursor: pointer; }
.ContextMenuItem:hover { background: #f5f5f5; } .ContextMenuItem:hover { background: #f5f5f5; }
@ -1389,8 +1399,25 @@ import { useState, useEffect, useCallback, useRef, useContext } from 'https://es
import htm from 'https://esm.sh/htm'; import htm from 'https://esm.sh/htm';
const html = htm.bind(h), AppContext = createContext(); const html = htm.bind(h), AppContext = createContext();
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(b => b.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 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 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) => {
@ -1406,7 +1433,14 @@ const deriveMsgKey = async (rawKey, salt) => crypto.subtle.deriveKey(
{ name: 'AES-GCM', length: 256 }, { name: 'AES-GCM', length: 256 },
true, ['encrypt', 'decrypt']); true, ['encrypt', 'decrypt']);
const escapeHtml = txt => {
const node = document.createElement('p');
node.appendChild(document.createTextNode(txt));
return node.innerHTML;
}
const makeParagraph = txt => `<p>${txt.replaceAll('\n', '<br />')}</p>`
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));
const closedContextMenu = (s) => ({ contextMenu: { ...s.contextMenu, visible: false } }); const closedContextMenu = (s) => ({ contextMenu: { ...s.contextMenu, visible: false } });
@ -1472,10 +1506,10 @@ function App() {
}, [state.contextMenu.visible]); }, [state.contextMenu.visible]);
const createNotebook = useCallback(async (type) => { const createNotebook = useCallback(async (type) => {
let id = (type === 'local' ? crypto.randomUUID() : prompt('Remote ID:')); let id = (type === 'local' ? generateUUID() : 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 EMOJIS = ['📒','📓','📔','📕','📖','📗','📘','📙','📚','✏️','📝']; const EMOJIS = ['📒','📓','📔','📕','📖','📗','📘','📙','📚','✏️','📝'];
const randomEmoji = () => EMOJIS[Math.floor(Math.random() * EMOJIS.length)]; const randomEmoji = () => EMOJIS[Math.floor(Math.random() * EMOJIS.length)];
const randomColor = () => ('#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0')); const randomColor = () => ('#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0'));
@ -1484,7 +1518,7 @@ function App() {
emoji: randomEmoji(), color: randomColor(), 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 ],
@ -1564,7 +1598,6 @@ function App() {
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(state.notebooks, nbId);
//console.log(state);
let message = arr[state.editingMessage]; let message = arr[state.editingMessage];
if (!message) { if (!message) {
message = { message = {
@ -1650,6 +1683,7 @@ function ChatScreen({inputRef}) {
// 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' });
if (state.scrollToMessage!=null) { if (state.scrollToMessage!=null) {
document.querySelector(`[data-msg-id="${state.scrollToMessage}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' }); document.querySelector(`[data-msg-id="${state.scrollToMessage}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
setState(s => ({ ...s, scrollToMessage: null })); setState(s => ({ ...s, scrollToMessage: null }));
@ -1661,8 +1695,8 @@ function ChatScreen({inputRef}) {
<div class="ChatScreen"> <div class="ChatScreen">
<div class="ChatHeader" onClick=${() => setState(s => ({ ...s, showSettings: true }))}> <div class="ChatHeader" onClick=${() => setState(s => ({ ...s, showSettings: true }))}>
<button class="BackButton" <button class="BackButton"
onClick=${e => { onClick=${ev => {
e.stopPropagation(); ev.stopPropagation();
setState(s => ({ ...s, selectedNotebook: null })); setState(s => ({ ...s, selectedNotebook: null }));
}}> }}>
@ -1670,8 +1704,8 @@ function ChatScreen({inputRef}) {
<div class="NotebookEmoji" style=${{ background: notebook.color }}>${notebook.emoji}</div> <div class="NotebookEmoji" style=${{ background: notebook.color }}>${notebook.emoji}</div>
<h3>${notebook.name}</h3> <h3>${notebook.name}</h3>
<button class="SearchButton" <button class="SearchButton"
onClick=${e => { onClick=${ev => {
e.stopPropagation(); ev.stopPropagation();
setState(s => ({ ...s, searchModal: { visible: true, global: false, query: '' }})); setState(s => ({ ...s, searchModal: { visible: true, global: false, query: '' }}));
}}> }}>
🔍 🔍
@ -1723,7 +1757,15 @@ function Message({m,i}) {
}))}> }))}>
Reply to "${(state.messages[m.replyTo.notebookId] || []).find(x => x.id===m.replyTo.id)?.text || ''}" Reply to "${(state.messages[m.replyTo.notebookId] || []).find(x => x.id===m.replyTo.id)?.text || ''}"
</div>`} </div>`}
<div dangerouslySetInnerHTML=${{__html:linkify(m.text)}}/> <div dangerouslySetInnerHTML=${{ __html: makeParagraph(linkify(escapeHtml(m.text))) }} />
${(() => {
const text = m.text.toLowerCase();
if (!text.includes(' ') && (text.startsWith('http://') || text.startsWith('https://'))) {
return html`<div>
<iframe src=${m.text}></iframe>
</div>`;
}
})()}
<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>
@ -1785,6 +1827,10 @@ function ContextMenu() {
case 'cross-reply': case 'cross-reply':
setState(s => ({ ...s, ...closedContextMenu(s), crossReplyModal: true, crossReplySource: { notebook: nbId, id: msg.id }})); setState(s => ({ ...s, ...closedContextMenu(s), crossReplyModal: true, crossReplySource: { notebook: nbId, id: msg.id }}));
return; return;
case 'copy':
navigator.clipboard.writeText(msg.text);
setState(s => ({ ...s, ...closedContextMenu(s) }));
return;
case 'edit': case 'edit':
setState(s => ({ ...s, editingMessage: idx, ...closedContextMenu(s) })); setState(s => ({ ...s, editingMessage: idx, ...closedContextMenu(s) }));
return; return;
@ -1800,11 +1846,12 @@ function ContextMenu() {
}; };
return html` return html`
<div class="ContextMenu" style=${`left: ${state.contextMenu.x}px; top: ${state.contextMenu.y}px;`}> <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('reply')}>🔁 Reply</div>
<div class="ContextMenuItem" onClick=${() => handle('cross-reply')}>Reply in Another Notebook</div> <div class="ContextMenuItem" onClick=${() => handle('cross-reply')}>🔂 Reply in Another Notebook</div>
<div class="ContextMenuItem" onClick=${() => handle('edit')}>Edit</div> <div class="ContextMenuItem" onClick=${() => handle('copy')}>📜 Copy</div>
<div class="ContextMenuItem" onClick=${() => handle('datetime')}>Set Date/Time</div> <div class="ContextMenuItem" onClick=${() => handle('edit')}>📝 Edit</div>
<div class="ContextMenuItem" onClick=${() => handle('delete')}>Delete</div> <div class="ContextMenuItem" onClick=${() => handle('datetime')}>⏰ Set Date/Time</div>
<div class="ContextMenuItem" onClick=${() => handle('delete')}>❌ Delete</div>
</div> </div>
`; `;
} }
@ -1858,9 +1905,9 @@ function SettingsModal() {
<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/>
<label>Color: <input type="color" value=${form.color} onChange=${e => setForm(f => ({ ...f, color: 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>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 }))}> <!--<label>Parse Mode: <select value=${form.parseMode} onChange=${e => setForm(f => ({ ...f, parseMode: e.target.value }))}>
<option value="plaintext">Plaintext</option> <option value="plaintext">Plaintext</option>
</select></label><br/><br/> </select></label><br/><br/>-->
<button onClick=${save}>Save</button> <button onClick=${save}>Save</button>
<button onClick=${del} style="color:red">Delete</button> <button onClick=${del} style="color:red">Delete</button>
<button onClick=${() => setState(s => ({ ...s, showSettings: false }))}>Close</button> <button onClick=${() => setState(s => ({ ...s, showSettings: false }))}>Close</button>