Jason Tudisco 7077191c0c NIP-78 encrypted transport, instant UI, re-sync recovery
Nostr transport:
- Switch from kind 1 to NIP-78 kind 30078 (application-specific data)
- Relays store events persistently — no inventory protocol needed
- AES-256-GCM encryption via Web Crypto API (room key = shared secret)
- PBKDF2 key derivation (100k iterations) from room key
- Chunking for large events (images >48KB) with per-chunk encryption
- Auto re-publish missing local events on room join
- Manual "Re-sync" button for recovery of failed publishes

Performance (all variants):
- Emit 'change' immediately after local store write (IDB/NeDB/SQLite)
- Folder and Nostr writes run fire-and-forget in background
- Fast event hashing: fingerprint metadata only, skip full payload SHA-256
- Saving spinner in paste UI while write completes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 01:11:29 -06:00

336 lines
11 KiB
TypeScript

// ── Types ────────────────────────────────────────────────────
interface Paste {
id: string;
mimeType: string;
description: string;
tags: string[];
timestamp: number;
fileName: string;
fileData: string; // base64 (empty for text-only)
}
// Duck-typed DB interface — all three variants satisfy this
interface PasteDB {
selectFolder(): Promise<void>;
hasFolderAccess(): Promise<boolean>;
requestFolderAccess(): Promise<boolean>;
sync(): Promise<void>;
collection(opts: {
name: string;
indexes: { name: string; fields: string[] }[];
}): {
put(doc: Paste): Promise<void>;
all(): Promise<Paste[]>;
delete(id: string): Promise<void>;
};
on(event: string, handler: (...args: unknown[]) => void): () => void;
}
// ── Helpers ──────────────────────────────────────────────────
function genId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
function extractTags(text: string): string[] {
return text
.split(/\s+/)
.filter((w) => w.startsWith('#') && w.length > 1)
.map((w) => w.slice(1).replace(/[^a-zA-Z0-9_-]+$/, ''))
.filter((t) => t.length > 0);
}
function fileToBase64(file: Blob): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result as string;
resolve(result.split(',')[1] || '');
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
function escapeHtml(s: string): string {
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
function fmtTime(ts: number): string {
const d = new Date(ts);
const p = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`;
}
const pdfSvg =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><text x="12" y="17" text-anchor="middle" fill="currentColor" stroke="none" font-size="6" font-weight="bold" font-family="sans-serif">PDF</text></svg>';
const fileSvg =
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg>';
// ── Main init ────────────────────────────────────────────────
export async function initPasteApp(db: PasteDB, variantLabel: string) {
const pastes = db.collection({
name: 'pastes',
indexes: [{ name: 'byTimestamp', fields: ['timestamp'] }],
});
const $ = (s: string) => document.querySelector(s)!;
const input = $('#paste-input') as HTMLInputElement;
const statusEl = $('#status')!;
const itemsEl = $('#items')!;
const selectFolderBtn = $('#select-folder') as HTMLButtonElement;
const folderStatusEl = $('#folder-status')!;
const variantEl = $('#variant-label');
if (variantEl) variantEl.textContent = variantLabel;
// ── Folder management ────────────────────────────────────
function updateFolderUI(connected: boolean) {
if (connected) {
folderStatusEl.textContent = 'Folder connected';
folderStatusEl.className = 'status ok';
selectFolderBtn.textContent = 'Change folder';
} else {
folderStatusEl.textContent = 'No folder selected';
folderStatusEl.className = 'status';
selectFolderBtn.textContent = 'Select sync folder';
}
}
selectFolderBtn.addEventListener('click', async () => {
try {
await db.selectFolder();
updateFolderUI(true);
await refreshItems();
} catch (e: unknown) {
setStatus('Folder: ' + (e as Error).message, 'err');
}
});
// Check if we already have access from a previous session
const hasAccess = await db.hasFolderAccess();
if (hasAccess) {
const granted = await db.requestFolderAccess();
updateFolderUI(granted);
} else {
updateFolderUI(false);
}
// ── Saving spinner ─────────────────────────────────────────
const spinner = document.createElement('div');
spinner.className = 'saving-spinner hidden';
spinner.innerHTML = '<span class="spinner-dot"></span> Syncing...';
document.body.appendChild(spinner);
function showSaving(active: boolean) {
spinner.classList.toggle('hidden', !active);
}
// ── Status ───────────────────────────────────────────────
function setStatus(msg: string, type?: string) {
statusEl.textContent = msg;
statusEl.className = 'status' + (type ? ' ' + type : '');
if (type === 'ok')
setTimeout(() => {
statusEl.textContent = '';
statusEl.className = 'status';
}, 2500);
}
// ── Save text ────────────────────────────────────────────
async function pasteText(text: string) {
setStatus('Saving...');
try {
const paste: Paste = {
id: genId(),
mimeType: 'text/plain',
description: text,
tags: extractTags(text),
timestamp: Date.now(),
fileName: '',
fileData: '',
};
await pastes.put(paste);
setStatus('Saved', 'ok');
await refreshItems();
} catch (e: unknown) {
setStatus('Error: ' + (e as Error).message, 'err');
}
}
// ── Save file ────────────────────────────────────────────
async function pasteFile(
file: Blob,
description: string,
fileName: string,
) {
setStatus('Reading file...');
try {
const base64 = await fileToBase64(file);
const paste: Paste = {
id: genId(),
mimeType: file.type || 'application/octet-stream',
description,
tags: extractTags(description),
timestamp: Date.now(),
fileName,
fileData: base64,
};
// Show spinner — IDB write is fast, but hashing + transport are slow
showSaving(true);
await pastes.put(paste);
// UI refreshes immediately via 'change' event (IDB write triggers it)
// Transports (folder, Nostr) continue in background
showSaving(false);
setStatus('Saved', 'ok');
} catch (e: unknown) {
showSaving(false);
setStatus('Error: ' + (e as Error).message, 'err');
}
}
// ── Refresh & render ─────────────────────────────────────
async function refreshItems() {
try {
const all: Paste[] = await pastes.all();
all.sort((a, b) => b.timestamp - a.timestamp);
renderItems(all);
} catch (e: unknown) {
setStatus('Load error: ' + (e as Error).message, 'err');
}
}
function renderItems(items: Paste[]) {
if (!items || items.length === 0) {
itemsEl.innerHTML =
'<div class="empty">Nothing here yet. Type something or paste an image.</div>';
return;
}
itemsEl.innerHTML = items
.map((it) => {
const time = fmtTime(it.timestamp);
const shortId = it.id.substring(0, 12);
const isImage = it.mimeType.startsWith('image/');
const isPdf = it.mimeType === 'application/pdf';
const isText = it.mimeType.startsWith('text/');
let thumb = '';
if (isImage && it.fileData) {
const src = `data:${it.mimeType};base64,${it.fileData}`;
thumb = `<img class="item-thumb" src="${src}" alt="">`;
} else if (isPdf) {
thumb = `<div class="item-icon pdf">${pdfSvg}</div>`;
} else if (!isText && it.fileData) {
thumb = `<div class="item-icon file">${fileSvg}</div>`;
}
let content = '';
if (isImage && it.fileData) {
const src = `data:${it.mimeType};base64,${it.fileData}`;
content = `<div class="item-image"><img src="${src}" alt="pasted image" loading="lazy"></div>`;
}
if (it.description) {
content += `<div class="item-content">${escapeHtml(it.description)}</div>`;
}
let fileLink = '';
if (it.fileName && !isText) {
fileLink = `<div class="item-filename">${escapeHtml(it.fileName)}</div>`;
}
let tagsHtml = '';
if (it.tags && it.tags.length > 0) {
tagsHtml =
'<div class="item-tags">' +
it.tags
.map((t) => `<span class="tag">#${escapeHtml(t)}</span>`)
.join('') +
'</div>';
}
return (
`<div class="item">` +
thumb +
`<div class="item-body">` +
`<div class="item-meta">` +
`<span class="time">${time}</span>` +
`<span class="hash">${shortId}</span>` +
`<span>${escapeHtml(it.mimeType)}</span>` +
`</div>` +
fileLink +
content +
tagsHtml +
`</div></div>`
);
})
.join('');
}
// ── Event listeners ──────────────────────────────────────
input.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.key === 'Enter' && input.value.trim()) {
pasteText(input.value.trim());
input.value = '';
}
});
document.addEventListener('paste', (e: ClipboardEvent) => {
const items = e.clipboardData?.items;
if (!items) return;
for (const item of items) {
if (item.type.startsWith('image/')) {
e.preventDefault();
const blob = item.getAsFile();
if (blob) {
const ext =
blob.type === 'image/png'
? '.png'
: blob.type === 'image/jpeg'
? '.jpg'
: blob.type === 'image/gif'
? '.gif'
: blob.type === 'image/webp'
? '.webp'
: '.bin';
pasteFile(blob, input.value.trim(), 'clipboard' + ext);
input.value = '';
}
return;
}
}
});
const fileInput = $('#file-input') as HTMLInputElement;
($('#clip-btn') as HTMLElement).addEventListener('click', () =>
fileInput.click(),
);
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (file) {
pasteFile(file, input.value.trim(), file.name);
input.value = '';
fileInput.value = '';
}
});
// Live updates from sync
db.on('change', () => refreshItems());
// Initial load
await refreshItems();
}