mirror of
https://gitlab.com/octospacc/WhichNot.git
synced 2025-06-27 09:02:56 +02:00
Update
This commit is contained in:
377
index.html
377
index.html
@ -1308,17 +1308,23 @@ render(html`<${App}/>`, document.body);
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>WhichNot: WhatsApp‑style Notes</title>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>WhichNot</title>
|
||||
<style>
|
||||
:root {
|
||||
--whatsapp-green: #00a884;
|
||||
--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; }
|
||||
.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 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; }
|
||||
@ -1351,27 +1357,28 @@ render(html`<${App}/>`, document.body);
|
||||
.ContextMenuItem { padding:.5rem 1rem; cursor:pointer; }
|
||||
.ContextMenuItem:hover { background:#f5f5f5; }
|
||||
|
||||
.DateTimeModal, .SearchModal, .AppSettingsModal, .CreateModal, .CrossReplyModal {
|
||||
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);
|
||||
max-height:80vh; overflow-y:auto; width:90%; max-width:400px; z-index:1001;
|
||||
.DateTimeModal, .SearchModal, .AppSettingsModal, .CreateModal, .SettingsModal, .CrossReplyModal {
|
||||
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);
|
||||
max-height: 80vh; overflow-y: auto; width: 90%; max-width: 400px; z-index: 1001;
|
||||
}
|
||||
.SearchModal input, .AppSettingsModal textarea, .CreateModal input {
|
||||
width:100%; margin:.5rem 0; padding:.5rem; border:1px solid #ddd; border-radius:.25rem;
|
||||
.SearchModal input, .AppSettingsModal textarea, .CreateModal input, .SettingsModal input {
|
||||
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:hover { background:#f9f9f9; }
|
||||
.SearchResult { padding: .5rem 0; border-bottom: 1px solid #eee; cursor: pointer; }
|
||||
.SearchResult:hover { background: #f9f9f9; }
|
||||
|
||||
.ReplyIndicator {
|
||||
border-left:3px solid var(--whatsapp-green); padding-left:.5rem; margin-bottom:.5rem;
|
||||
color:#666; font-size:.9em; cursor:pointer;
|
||||
border-left: 3px solid var(--whatsapp-green); padding-left: .5rem; margin-bottom: .5rem;
|
||||
color: #666; font-size: .9em; cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width:768px) {
|
||||
.ChatList { width:100%; }
|
||||
.ChatScreen { width:100%; display:none; }
|
||||
.App.show-chat .ChatList { display:none; }
|
||||
.App.show-chat .ChatScreen { display:flex; }
|
||||
.ChatList { width: 100%; }
|
||||
.ChatScreen { width: 100%; display: none; }
|
||||
.App.show-chat .ChatList { display: none; }
|
||||
.App.show-chat .ChatScreen { display: flex; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@ -1382,23 +1389,22 @@ import { useState, useEffect, useCallback, useRef, useContext } from 'https://es
|
||||
import htm from 'https://esm.sh/htm';
|
||||
const html = htm.bind(h), AppContext = createContext();
|
||||
|
||||
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 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 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 b = new Uint8Array(n);
|
||||
crypto.getRandomValues(b);
|
||||
return b;
|
||||
}
|
||||
const bufToB64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
|
||||
const b64ToBuf = (str) => Uint8Array.from(atob(str),c=>c.charCodeAt(0));
|
||||
|
||||
const deriveMsgKey = async (rawKey,salt) => {
|
||||
const hk = 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']);
|
||||
}
|
||||
const b64ToBuf = (str) => Uint8Array.from(atob(str), (c => c.charCodeAt(0)));
|
||||
const deriveMsgKey = async (rawKey, salt) => crypto.subtle.deriveKey(
|
||||
{ name: 'HKDF', salt, info: new TextEncoder().encode('msg'), hash: 'SHA-256' },
|
||||
await crypto.subtle.importKey('raw', rawKey, 'HKDF', false, ['deriveKey']),
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true, ['encrypt', 'decrypt']);
|
||||
|
||||
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));
|
||||
@ -1406,34 +1412,35 @@ const closedContextMenu = (s) => ({ contextMenu: { ...s.contextMenu, visible: fa
|
||||
|
||||
function App() {
|
||||
const [state,setState] = useState({
|
||||
notebooks:[], encrypted:{}, messages:{},
|
||||
selectedNotebook:null, editingMessage:null,
|
||||
showSettings:false, showAppSettings:false,
|
||||
createModal:false, crossReplyModal:false, crossReplySource:null,
|
||||
contextMenu:{visible:false,messageIndex:null,x:0,y:0},
|
||||
dateTimeModal:null, replyingTo:null,
|
||||
searchModal:{visible:false,global:false,query:''},
|
||||
scrollToMessage:null, reactionInputFor:null
|
||||
notebooks: [], encrypted: {}, messages: {},
|
||||
selectedNotebook: null, scrollToMessage: null,
|
||||
showSettings: false, showAppSettings: false,
|
||||
createModal: false, dateTimeModal: null,
|
||||
crossReplyModal: false, crossReplySource: null,
|
||||
contextMenu:{ visible: false, messageIndex: null, x: 0, y: 0 },
|
||||
searchModal: { visible: false, global: false, query: '' },
|
||||
editingMessage: null, replyingTo: null, reactionInputFor: null,
|
||||
});
|
||||
const inputRef = useRef();
|
||||
|
||||
// Load & decrypt
|
||||
useEffect(()=>{
|
||||
useEffect(() => {
|
||||
const raw=JSON.parse(localStorage.getItem('notebooks')) || [],
|
||||
enc={}, msgs={};
|
||||
(async()=>{
|
||||
for(const notebook of raw){
|
||||
const arr=JSON.parse(localStorage.getItem(`notebook-${notebook.id}`)||'[]');
|
||||
(async () => {
|
||||
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=[];
|
||||
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,content:new TextDecoder().decode(dec)});
|
||||
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) });
|
||||
}
|
||||
msgs[notebook.id] = plain;
|
||||
}
|
||||
@ -1464,17 +1471,20 @@ function App() {
|
||||
return () => document.removeEventListener('click', handler);
|
||||
}, [state.contextMenu.visible]);
|
||||
|
||||
// Create notebook
|
||||
const createNotebook = useCallback(async(type)=>{
|
||||
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);
|
||||
const notebook={ id, name:'New Notebook', emoji:'📒',
|
||||
color:'#'+Math.floor(Math.random()*0xFFFFFF).toString(16).padStart(6,'0'),
|
||||
sourceType:type, description:'', parseMode:'plaintext',
|
||||
nextMessageId:1, created:now,
|
||||
aesKeyB64:aesB64, edPrivB64:privB64, edPubB64:pubB64
|
||||
const EMOJIS = ['📒','📓','📔','📕','📖','📗','📘','📙','📚','✏️','📝'];
|
||||
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',
|
||||
nextMessageId: 1, created: now,
|
||||
aesKeyB64: aesB64, edPrivB64: privB64, edPubB64: pubB64,
|
||||
};
|
||||
setState(s => ({ ...s,
|
||||
notebooks: [ ...s.notebooks, notebook ],
|
||||
@ -1482,9 +1492,12 @@ function App() {
|
||||
messages: { ...s.messages, [id]: [] },
|
||||
createModal: false,
|
||||
}));
|
||||
if(type==='remote'){
|
||||
await fetch(`/notebook/${id}`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({publicKey:pubB64})});
|
||||
}
|
||||
// if (type==='remote') {
|
||||
// await fetch(`/notebook/${id}`, {
|
||||
// method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({ publicKey: pubB64 }),
|
||||
// });
|
||||
// }
|
||||
}, [state.notebooks]);
|
||||
|
||||
// Persist (encrypt & sync)
|
||||
@ -1497,7 +1510,7 @@ function App() {
|
||||
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.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),
|
||||
ciphertext:bufToB64(ct), timestamp:m.timestamp,
|
||||
edited:m.edited, replyTo:m.replyTo, reactions:m.reactions
|
||||
@ -1507,15 +1520,15 @@ function App() {
|
||||
encrypted: { ...s.encrypted, [nbId]: encArr },
|
||||
messages: { ...s.messages, [nbId]: plainArr },
|
||||
}));
|
||||
if(notebook.sourceType==='remote'){
|
||||
const priv=await importJWK(notebook.edPrivB64,{name:'Ed25519',namedCurve:'Ed25519'},['sign']),
|
||||
payload=new TextEncoder().encode(JSON.stringify(encArr)),
|
||||
sig=bufToB64(await crypto.subtle.sign('Ed25519',priv,payload));
|
||||
await fetch(`/notebook/${nbId}/sync`,{
|
||||
method:'PUT',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({encryptedArr:encArr,signature:sig,publicKey:notebook.edPubB64})
|
||||
});
|
||||
}
|
||||
// if (notebook.sourceType==='remote') {
|
||||
// const priv=await importJWK(notebook.edPrivB64,{name:'Ed25519',namedCurve:'Ed25519'},['sign']),
|
||||
// payload=new TextEncoder().encode(JSON.stringify(encArr)),
|
||||
// sig=bufToB64(await crypto.subtle.sign('Ed25519',priv,payload));
|
||||
// await fetch(`/notebook/${nbId}/sync`, {
|
||||
// method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({ encryptedArr: encArr, signature: sig, publicKey: notebook.edPubB64 }),
|
||||
// });
|
||||
// }
|
||||
}, [state.notebooks]);
|
||||
|
||||
const addReaction = useCallback(idx => setState(s => ({ ...s, reactionInputFor: idx })), []);
|
||||
@ -1538,41 +1551,41 @@ function App() {
|
||||
if (state.editingMessage!=null && inputRef.current) {
|
||||
const message = state.messages[state.selectedNotebook]?.[state.editingMessage];
|
||||
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]);
|
||||
|
||||
const sendMessage = useCallback(async () => {
|
||||
const nbId = state.selectedNotebook;
|
||||
if (!nbId) return;
|
||||
const txt = inputRef.current.value.trim();
|
||||
if (!txt) return;
|
||||
const text = inputRef.current.value.trim();
|
||||
if (!text) return;
|
||||
const arr = state.messages[nbId] || [];
|
||||
const notebook = getNotebook(state.notebooks, nbId);
|
||||
//console.log(state);
|
||||
const message = {
|
||||
id: notebook.nextMessageId,
|
||||
content: txt,
|
||||
timestamp: Date.now(),
|
||||
edited: state.editingMessage!=null,
|
||||
replyTo: state.replyingTo,
|
||||
reactions: [],
|
||||
};
|
||||
let message = arr[state.editingMessage];
|
||||
if (!message) {
|
||||
message = {
|
||||
id: notebook.nextMessageId,
|
||||
timestamp: Date.now(),
|
||||
edited: state.editingMessage!=null,
|
||||
replyTo: state.replyingTo,
|
||||
reactions: [],
|
||||
};
|
||||
}
|
||||
message = { ...message, text };
|
||||
inputRef.current.value = '';
|
||||
// update nextMessageId if new
|
||||
setState(s => ({ ...s, notebooks: s.notebooks.map(notebook => notebook.id===nbId
|
||||
? { ...notebook, nextMessageId: (state.editingMessage==null ? notebook.nextMessageId+1 : notebook.nextMessageId) }
|
||||
: notebook
|
||||
) }));
|
||||
// build new array
|
||||
let newArr;
|
||||
if(state.editingMessage!=null){
|
||||
newArr = arr.map((msg,i)=> i===state.editingMessage ? message : msg);
|
||||
} else {
|
||||
newArr = [...arr, message];
|
||||
}
|
||||
const newArr = (state.editingMessage!=null
|
||||
? arr.map((msg, i) => (i===state.editingMessage ? message : msg))
|
||||
: [...arr, message]
|
||||
);
|
||||
// reset editing & replying
|
||||
setState( s => ({ ...s, editingMessage: null, replyingTo: null }));
|
||||
await persistMessages(nbId, newArr);
|
||||
@ -1582,18 +1595,18 @@ function App() {
|
||||
<${AppContext.Provider} value=${{
|
||||
state, setState, createNotebook,
|
||||
sendMessage, persistMessages,
|
||||
addReaction, confirmReaction, removeReaction
|
||||
addReaction, confirmReaction, removeReaction,
|
||||
}}>
|
||||
<div class="App ${state.selectedNotebook?'show-chat':''}">
|
||||
<${ChatList}/>
|
||||
<${ChatScreen} inputRef=${inputRef}/>
|
||||
${state.createModal && html`<${CreateModal}/>`}
|
||||
${state.crossReplyModal && html`<${CrossReplyModal}/>`}
|
||||
${state.showSettings && html`<${SettingsModal}/>`}
|
||||
${state.showAppSettings && html`<${AppSettingsModal}/>`}
|
||||
${state.contextMenu.visible && html`<${ContextMenu}/>`}
|
||||
${state.dateTimeModal!==null && html`<${DateTimeModal}/>`}
|
||||
${state.searchModal.visible && html`<${SearchModal}/>`}
|
||||
<div class="App ${state.selectedNotebook ? 'show-chat' : ''}">
|
||||
<${ChatList} />
|
||||
<${ChatScreen} inputRef=${inputRef} />
|
||||
${state.createModal && html`<${CreateModal} />`}
|
||||
${state.crossReplyModal && html`<${CrossReplyModal} />`}
|
||||
${state.showSettings && html`<${SettingsModal} />`}
|
||||
${state.showAppSettings && html`<${AppSettingsModal} />`}
|
||||
${state.contextMenu.visible && html`<${ContextMenu} />`}
|
||||
${state.dateTimeModal!==null && html`<${DateTimeModal} />`}
|
||||
${state.searchModal.visible && html`<${SearchModal} />`}
|
||||
</div>
|
||||
<//>
|
||||
`;
|
||||
@ -1601,27 +1614,27 @@ function App() {
|
||||
|
||||
function ChatList() {
|
||||
const {state,setState} = useContext(AppContext);
|
||||
const sortNotebook = (notebook) => Math.max(notebook.created, ...(state.messages[notebook.id] || []).map(message => message.timestamp));
|
||||
return html`
|
||||
<div class="ChatList">
|
||||
<div class="ChatList-header">
|
||||
<button onClick=${()=>setState(s=>({...s,createModal:true}))}>+</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, createModal: true }))}>+</button>
|
||||
<button onClick=${() => setState(s => ({ ...s, searchModal: { visible: true, global: true, query: '' } }))}>🔍</button>
|
||||
<button onClick=${() => setState(s => ({ ...s, showAppSettings: true }))}>⚙️</button>
|
||||
</div>
|
||||
${state.notebooks.sort((a,b)=>{
|
||||
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`
|
||||
${state.notebooks.sort((a,b) => (sortNotebook(b) - sortNotebook(a))).map(notebook => html`
|
||||
<button class="NotebookButton" key=${notebook.id}
|
||||
onClick=${()=>setState(s=>({...s,selectedNotebook:notebook.id}))}>
|
||||
<div class="NotebookTitle">
|
||||
<div class="NotebookEmoji" style=${{background:notebook.color}}>${notebook.emoji}</div>
|
||||
<h4 class="NotebookName">${notebook.name}</h4>
|
||||
</div>
|
||||
<div class="NotebookDescription">${notebook.description||'<em>No description</em>'}</div>
|
||||
<div class="NotebookDescription">${notebook.description || 'No description'}</div>
|
||||
<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>
|
||||
</button>
|
||||
`)}
|
||||
@ -1633,7 +1646,7 @@ function ChatScreen({inputRef}) {
|
||||
const { state, setState, sendMessage } = useContext(AppContext);
|
||||
const notebook = getNotebook(state.notebooks, state.selectedNotebook);
|
||||
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
|
||||
useEffect(()=>{
|
||||
@ -1646,7 +1659,7 @@ function ChatScreen({inputRef}) {
|
||||
if (!notebook) return null;
|
||||
return html`
|
||||
<div class="ChatScreen">
|
||||
<div class="ChatHeader" onClick=${()=>setState(s=>({...s,showSettings:true}))}>
|
||||
<div class="ChatHeader" onClick=${() => setState(s => ({ ...s, showSettings: true }))}>
|
||||
<button class="BackButton"
|
||||
onClick=${e => {
|
||||
e.stopPropagation();
|
||||
@ -1665,18 +1678,20 @@ function ChatScreen({inputRef}) {
|
||||
</button>
|
||||
</div>
|
||||
<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 class="SendBar">
|
||||
${state.replyingTo && html`
|
||||
<div class="ReplyPreview">
|
||||
<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>
|
||||
<button onClick=${()=>setState(s=>({...s,replyingTo:null}))}>×</button>
|
||||
<button onClick=${() => setState(s => ({ ...s, replyingTo: null }))}>×</button>
|
||||
</div>`}
|
||||
<textarea ref=${inputRef} class="EditArea"
|
||||
onKeyPress=${e=>e.key==='Enter'&&!e.shiftKey&&sendMessage()}/>
|
||||
onKeyPress=${e => e.key==='Enter' && !e.shiftKey && sendMessage()}/>
|
||||
<button onClick=${sendMessage}>${state.editingMessage!=null?'Save':'Send'}</button>
|
||||
</div>
|
||||
</div>
|
||||
@ -1690,21 +1705,28 @@ function Message({m,i}) {
|
||||
} = useContext(AppContext);
|
||||
return html`
|
||||
<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}}));}}
|
||||
onTouchStart=${e=>{e.preventDefault();const t=e.touches[0];setState(s=>({...s,contextMenu:{visible:true,messageIndex:i,x:t.clientX,y:t.clientY}}));}}>
|
||||
onContextMenu=${event => {
|
||||
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`
|
||||
<div class="ReplyIndicator"
|
||||
onClick=${()=>setState(s=>({
|
||||
...state,
|
||||
selectedNotebook:m.replyTo.notebookId,
|
||||
scrollToMessage:m.replyTo.id
|
||||
selectedNotebook: m.replyTo.notebookId,
|
||||
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 dangerouslySetInnerHTML=${{__html:linkify(m.content)}}/>
|
||||
<div dangerouslySetInnerHTML=${{__html:linkify(m.text)}}/>
|
||||
<div class="reactions">
|
||||
${m.reactions.map(r=>html`
|
||||
<button onClick=${()=>removeReaction(i,r)}>${r}</button>
|
||||
${m.reactions.map(r => html`
|
||||
<button onClick=${() => removeReaction(i,r)}>${r}</button>
|
||||
`)}
|
||||
${state.reactionInputFor===i
|
||||
? html`<input class="ReactionInput" maxlength="2" autofocus
|
||||
@ -1718,28 +1740,30 @@ function Message({m,i}) {
|
||||
}
|
||||
|
||||
function CreateModal() {
|
||||
const {createNotebook,setState} = useContext(AppContext);
|
||||
return html`
|
||||
<div class="CreateModal">
|
||||
<h3>Create Notebook</h3>
|
||||
<button onClick=${() => createNotebook('local')}>Local Notebook</button>
|
||||
<button onClick=${() => createNotebook('remote')}>Remote Notebook</button>
|
||||
<button onClick=${() => setState(s => ({ ...s, createModal: false }))}>Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
const {createNotebook, setState} = useContext(AppContext);
|
||||
createNotebook('local');
|
||||
return '';
|
||||
// return html`
|
||||
// <div class="CreateModal">
|
||||
// <h3>Create Notebook</h3>
|
||||
// <button onClick=${() => createNotebook('local')}>Local Notebook</button>
|
||||
// <button onClick=${() => createNotebook('remote')}>Remote Notebook</button>
|
||||
// <button onClick=${() => setState(s => ({ ...s, createModal: false }))}>Cancel</button>
|
||||
// </div>
|
||||
// `;
|
||||
}
|
||||
|
||||
function CrossReplyModal() {
|
||||
const {state,setState} = useContext(AppContext);
|
||||
const {state, setState} = useContext(AppContext);
|
||||
return html`
|
||||
<div class="CrossReplyModal">
|
||||
<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,
|
||||
selectedNotebook: n.id,
|
||||
selectedNotebook: notebook.id,
|
||||
replyingTo: { notebookId: s.crossReplySource.notebook, id: s.crossReplySource.id },
|
||||
crossReplyModal: false,
|
||||
}))}>${n.emoji} ${n.name}</button>
|
||||
}))}>${notebook.emoji} ${notebook.name}</button>
|
||||
`)}
|
||||
<button onClick=${() => setState(s => ({ ...s, crossReplyModal: false }))}>Cancel</button>
|
||||
</div>
|
||||
@ -1747,7 +1771,7 @@ function CrossReplyModal() {
|
||||
}
|
||||
|
||||
function ContextMenu() {
|
||||
const {state,setState,persistMessages} = useContext(AppContext);
|
||||
const {state, setState, persistMessages} = useContext(AppContext);
|
||||
const idx = state.contextMenu.messageIndex;
|
||||
const nbId = state.selectedNotebook;
|
||||
const arr = state.messages[nbId] || [];
|
||||
@ -1776,75 +1800,82 @@ 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('edit')}>Edit</div>
|
||||
<div class="ContextMenuItem" onClick=${() => handle('datetime')}>Set Date/Time</div>
|
||||
<div class="ContextMenuItem" onClick=${() => handle('delete')}>Delete</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function DateTimeModal() {
|
||||
const {state,setState,persistMessages} = useContext(AppContext);
|
||||
const {state, setState, persistMessages} = useContext(AppContext);
|
||||
const idx=state.dateTimeModal, nbId=state.selectedNotebook;
|
||||
const arr=state.messages[nbId]||[], msg=arr[idx];
|
||||
const [dt,setDt]=useState('');
|
||||
const [dt,setDt] = useState('');
|
||||
useEffect(() => (msg && setDt(new Date(msg.timestamp).toISOString().slice(0,16))), [msg]);
|
||||
const save=()=>{
|
||||
const ts=new Date(dt).getTime();
|
||||
if(!isNaN(ts)){
|
||||
const newArr=arr.map((m,i)=>i===idx?{...m,timestamp:ts}:m);
|
||||
persistMessages(nbId,newArr);
|
||||
setState(s=>({...s,messages:{...s.messages,[nbId]:newArr},dateTimeModal:null}));
|
||||
const timestamp = new Date(dt).getTime();
|
||||
if (!isNaN(timestamp)) {
|
||||
const newArr = arr.map((m,i) => i===idx ? { ...m, timestamp } : m);
|
||||
persistMessages(nbId, newArr);
|
||||
setState(s => ({ ...s, messages: { ...s.messages, [nbId]: newArr }, dateTimeModal: null }));
|
||||
}
|
||||
};
|
||||
return html`
|
||||
<div class="DateTimeModal">
|
||||
<h3>Set Date/Time</h3>
|
||||
<input type="datetime-local" value=${dt} onChange=${e=>setDt(e.target.value)}/>
|
||||
<input type="datetime-local" value=${dt} onChange=${e => setDt(e.target.value)}/>
|
||||
<button onClick=${save}>Save</button>
|
||||
<button onClick=${()=>setState(s=>({...s,dateTimeModal:null}))}>Cancel</button>
|
||||
<button onClick=${() => setState(s => ({ ...s, dateTimeModal: null }))}>Cancel</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function SettingsModal() {
|
||||
const {state,setState} = useContext(AppContext);
|
||||
const {state, setState} = useContext(AppContext);
|
||||
const notebook = getNotebook(state.notebooks, 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=()=>{
|
||||
if(confirm('Delete?')){
|
||||
if(notebook.sourceType==='local') 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}));
|
||||
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 = () => {
|
||||
if (confirm('Delete?')) {
|
||||
if (notebook.sourceType==='local') {
|
||||
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`
|
||||
<div class="CreateModal">
|
||||
<div class="SettingsModal">
|
||||
<h3>Settings</h3>
|
||||
<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>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>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>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 }))}>
|
||||
<option value="plaintext">Plaintext</option>
|
||||
</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>
|
||||
<button onClick=${() => setState(s => ({ ...s, showSettings: false }))}>Close</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function SearchModal() {
|
||||
const {state,setState} = useContext(AppContext);
|
||||
const {query,global} = state.searchModal;
|
||||
const results=(global
|
||||
const {state, setState} = 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) }))
|
||||
).filter(message => message.content.toLowerCase().includes(query.toLowerCase()));
|
||||
const select = (nbId,mId) => setState(s => ({ ...s, selectedNotebook: nbId, searchModal: { ...s.searchModal, visible: false }, scrollToMessage: mId }));
|
||||
).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`
|
||||
<div class="SearchModal">
|
||||
<h3>${global ? 'Global' : 'Notebook'} Search</h3>
|
||||
@ -1855,10 +1886,10 @@ function SearchModal() {
|
||||
<div class="NotebookEmoji" style=${{ background: result.notebook.color }}>${result.notebook.emoji}</div>
|
||||
<strong>${result.notebook.name}</strong>
|
||||
</div>`}
|
||||
<div>${result.content}</div><em>${new Date(result.timestamp).toLocaleString()}</em>
|
||||
<div>${result.text}</div><em>${new Date(result.timestamp).toLocaleString()}</em>
|
||||
</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>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -1896,4 +1927,4 @@ function AppSettingsModal() {
|
||||
render(html`<${App}/>`, document.body);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
Reference in New Issue
Block a user