mirror of
https://gitlab.com/octospacc/WhichNot.git
synced 2025-06-27 09:02:56 +02:00
Update
This commit is contained in:
91
index.html
91
index.html
@ -1325,12 +1325,18 @@ render(html`<${App}/>`, document.body);
|
||||
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; }
|
||||
.NotebookButton { width: 100%; padding: .75rem 1rem; background: none; border: none; cursor: pointer; text-align: left; }
|
||||
.NotebookButton:hover { background:#f5f5f5; }
|
||||
.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; }
|
||||
.NotebookDescription { font-size: .875rem; color: #555; 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 .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); }
|
||||
.ReactionInput { width: 2rem; padding: .1rem; font-size: 1rem; }
|
||||
.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; }
|
||||
.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:hover { background: #f5f5f5; }
|
||||
|
||||
@ -1389,8 +1399,25 @@ import { useState, useEffect, useCallback, useRef, useContext } from 'https://es
|
||||
import htm from 'https://esm.sh/htm';
|
||||
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 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 importJWK = async (b64, alg, usages) => crypto.subtle.importKey('jwk', JSON.parse(atob(b64)), alg, true, usages);
|
||||
const randBytes = (n=12) => {
|
||||
@ -1406,7 +1433,14 @@ const deriveMsgKey = async (rawKey, salt) => crypto.subtle.deriveKey(
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
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 getNotebook = (notebooks, id) => notebooks.find(notebook => (notebook.id === id));
|
||||
const closedContextMenu = (s) => ({ contextMenu: { ...s.contextMenu, visible: false } });
|
||||
|
||||
@ -1472,10 +1506,10 @@ function App() {
|
||||
}, [state.contextMenu.visible]);
|
||||
|
||||
const createNotebook = useCallback(async (type) => {
|
||||
let id = (type === 'local' ? crypto.randomUUID() : prompt('Remote ID:'));
|
||||
if (!id) return;
|
||||
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);
|
||||
let id = (type === 'local' ? generateUUID() : prompt('Remote ID:'));
|
||||
// if (!id) return;
|
||||
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 EMOJIS = ['📒','📓','📔','📕','📖','📗','📘','📙','📚','✏️','📝'];
|
||||
const randomEmoji = () => EMOJIS[Math.floor(Math.random() * EMOJIS.length)];
|
||||
const randomColor = () => ('#' + Math.floor(Math.random() * 0xFFFFFF).toString(16).padStart(6, '0'));
|
||||
@ -1484,7 +1518,7 @@ function App() {
|
||||
emoji: randomEmoji(), color: randomColor(),
|
||||
sourceType: type, description: '', parseMode: 'plaintext',
|
||||
nextMessageId: 1, created: now,
|
||||
aesKeyB64: aesB64, edPrivB64: privB64, edPubB64: pubB64,
|
||||
aesKeyB64: aesB64, // edPrivB64: privB64, edPubB64: pubB64,
|
||||
};
|
||||
setState(s => ({ ...s,
|
||||
notebooks: [ ...s.notebooks, notebook ],
|
||||
@ -1564,7 +1598,6 @@ function App() {
|
||||
if (!text) return;
|
||||
const arr = state.messages[nbId] || [];
|
||||
const notebook = getNotebook(state.notebooks, nbId);
|
||||
//console.log(state);
|
||||
let message = arr[state.editingMessage];
|
||||
if (!message) {
|
||||
message = {
|
||||
@ -1650,6 +1683,7 @@ function ChatScreen({inputRef}) {
|
||||
|
||||
// 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-msg-id="${state.scrollToMessage}"]`)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
setState(s => ({ ...s, scrollToMessage: null }));
|
||||
@ -1661,8 +1695,8 @@ function ChatScreen({inputRef}) {
|
||||
<div class="ChatScreen">
|
||||
<div class="ChatHeader" onClick=${() => setState(s => ({ ...s, showSettings: true }))}>
|
||||
<button class="BackButton"
|
||||
onClick=${e => {
|
||||
e.stopPropagation();
|
||||
onClick=${ev => {
|
||||
ev.stopPropagation();
|
||||
setState(s => ({ ...s, selectedNotebook: null }));
|
||||
}}>
|
||||
←
|
||||
@ -1670,8 +1704,8 @@ function ChatScreen({inputRef}) {
|
||||
<div class="NotebookEmoji" style=${{ background: notebook.color }}>${notebook.emoji}</div>
|
||||
<h3>${notebook.name}</h3>
|
||||
<button class="SearchButton"
|
||||
onClick=${e => {
|
||||
e.stopPropagation();
|
||||
onClick=${ev => {
|
||||
ev.stopPropagation();
|
||||
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 || ''}"
|
||||
</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">
|
||||
${m.reactions.map(r => html`
|
||||
<button onClick=${() => removeReaction(i,r)}>${r}</button>
|
||||
@ -1785,6 +1827,10 @@ function ContextMenu() {
|
||||
case 'cross-reply':
|
||||
setState(s => ({ ...s, ...closedContextMenu(s), crossReplyModal: true, crossReplySource: { notebook: nbId, id: msg.id }}));
|
||||
return;
|
||||
case 'copy':
|
||||
navigator.clipboard.writeText(msg.text);
|
||||
setState(s => ({ ...s, ...closedContextMenu(s) }));
|
||||
return;
|
||||
case 'edit':
|
||||
setState(s => ({ ...s, editingMessage: idx, ...closedContextMenu(s) }));
|
||||
return;
|
||||
@ -1800,11 +1846,12 @@ function ContextMenu() {
|
||||
};
|
||||
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('edit')}>Edit</div>
|
||||
<div class="ContextMenuItem" onClick=${() => handle('datetime')}>Set Date/Time</div>
|
||||
<div class="ContextMenuItem" onClick=${() => handle('delete')}>Delete</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('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>
|
||||
`;
|
||||
}
|
||||
@ -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>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 }))}>
|
||||
<!--<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/>
|
||||
</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>
|
||||
|
Reference in New Issue
Block a user