diff --git a/nostr/package.json b/nostr/package.json new file mode 100644 index 0000000..f719f48 --- /dev/null +++ b/nostr/package.json @@ -0,0 +1,13 @@ +{ + "name": "@foldersync/nostr", + "version": "0.1.0", + "description": "Local-first browser KV/document store with dual folder + Nostr relay sync", + "type": "module", + "main": "src/index.ts", + "dependencies": { + "nostr-tools": "^2.10.4" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} diff --git a/nostr/src/collection.ts b/nostr/src/collection.ts new file mode 100644 index 0000000..af81363 --- /dev/null +++ b/nostr/src/collection.ts @@ -0,0 +1,137 @@ +import type { CollectionApi, IndexDefinition, StoreOptions } from './types.js'; +import type { IDBStore } from './idb-store.js'; +import type { SyncEngine } from './sync-engine.js'; + +export class Collection implements CollectionApi { + private readonly storeName: string; + private readonly indexes: IndexDefinition[]; + private indexesBuilt = false; + + constructor( + options: StoreOptions, + private idb: IDBStore, + private sync: SyncEngine, + ) { + this.storeName = options.name; + this.indexes = options.indexes ?? []; + this.sync.registerIndexes(this.storeName, this.indexes as IndexDefinition[]); + } + + /** Lazily rebuild indexes on first access. */ + private async ensureIndexes(): Promise { + if (this.indexesBuilt || this.indexes.length === 0) return; + await this.idb.rebuildIndexes( + this.storeName, + this.indexes as IndexDefinition[], + ); + this.indexesBuilt = true; + } + + async get(id: string): Promise { + const rec = await this.idb.getDoc(this.storeName, id); + if (!rec) return undefined; + return rec.data as T; + } + + async put(doc: T): Promise { + await this.ensureIndexes(); + const existing = await this.idb.getRawDoc(this.storeName, doc.id); + const rev = (existing?.rev ?? 0) + 1; + const ts = Date.now(); + // SyncEngine.persistEvent handles the IDB write + folder write + await this.sync.writeDoc(this.storeName, doc.id, doc, ts, rev); + } + + async delete(id: string): Promise { + await this.ensureIndexes(); + const existing = await this.idb.getRawDoc(this.storeName, id); + if (!existing || existing.deleted) return; + + const rev = existing.rev + 1; + const ts = Date.now(); + await this.sync.deleteDocEvent(this.storeName, id, ts, rev); + } + + async all(): Promise { + const docs = await this.idb.getAllDocs(this.storeName); + return docs.map((d) => d.data as T); + } + + async findByIndex(indexName: string, value: IDBValidKey): Promise { + await this.ensureIndexes(); + this.assertIndex(indexName); + const ids = await this.idb.findByIndex(this.storeName, indexName, value); + return this.fetchByIds(ids); + } + + async queryByIndex( + indexName: string, + range: { + gt?: IDBValidKey; + gte?: IDBValidKey; + lt?: IDBValidKey; + lte?: IDBValidKey; + }, + ): Promise { + await this.ensureIndexes(); + this.assertIndex(indexName); + + const lower = range.gte ?? range.gt; + const upper = range.lte ?? range.lt; + const lowerOpen = range.gte === undefined && range.gt !== undefined; + const upperOpen = range.lte === undefined && range.lt !== undefined; + + let idbRange: IDBKeyRange; + if (lower !== undefined && upper !== undefined) { + idbRange = IDBKeyRange.bound( + [this.storeName, indexName, lower], + [this.storeName, indexName, upper], + lowerOpen, + upperOpen, + ); + } else if (lower !== undefined) { + idbRange = IDBKeyRange.lowerBound( + [this.storeName, indexName, lower], + lowerOpen, + ); + } else if (upper !== undefined) { + idbRange = IDBKeyRange.upperBound( + [this.storeName, indexName, upper], + upperOpen, + ); + } else { + // No bounds: return all entries for this index + idbRange = IDBKeyRange.bound( + [this.storeName, indexName, -Infinity], + [this.storeName, indexName, []], + ); + } + + const ids = await this.idb.queryByIndex( + this.storeName, + indexName, + idbRange, + ); + return this.fetchByIds(ids); + } + + // ── Helpers ──────────────────────────────────────────────── + + private assertIndex(name: string): void { + if (!this.indexes.some((i) => i.name === name)) { + throw new Error( + `Index "${name}" is not defined on collection "${this.storeName}". ` + + `Defined indexes: ${this.indexes.map((i) => i.name).join(', ') || '(none)'}`, + ); + } + } + + private async fetchByIds(ids: string[]): Promise { + const results: T[] = []; + for (const id of ids) { + const doc = await this.idb.getDoc(this.storeName, id); + if (doc) results.push(doc.data as T); + } + return results; + } +} diff --git a/nostr/src/emitter.ts b/nostr/src/emitter.ts new file mode 100644 index 0000000..afddcad --- /dev/null +++ b/nostr/src/emitter.ts @@ -0,0 +1,33 @@ +import type { SyncDBEventName, SyncDBEventHandler } from './types.js'; + +export class Emitter { + private listeners = new Map>(); + + on(event: SyncDBEventName, handler: SyncDBEventHandler): () => void { + let set = this.listeners.get(event); + if (!set) { + set = new Set(); + this.listeners.set(event, set); + } + set.add(handler); + return () => { + set!.delete(handler); + }; + } + + emit(event: SyncDBEventName, ...args: unknown[]): void { + const set = this.listeners.get(event); + if (!set) return; + for (const fn of set) { + try { + fn(...args); + } catch { + // listener errors must not break the library + } + } + } + + removeAll(): void { + this.listeners.clear(); + } +} diff --git a/nostr/src/folder-store.ts b/nostr/src/folder-store.ts new file mode 100644 index 0000000..91970ee --- /dev/null +++ b/nostr/src/folder-store.ts @@ -0,0 +1,122 @@ +import type { SyncEvent } from './types.js'; +import { parseEventFilename } from './utils.js'; + +const EVENTS_DIR = 'events'; + +export class FolderStore { + private dirHandle: FileSystemDirectoryHandle | null = null; + + // ── Handle management ────────────────────────────────────── + + get hasHandle(): boolean { + return this.dirHandle !== null; + } + + getHandle(): FileSystemDirectoryHandle | null { + return this.dirHandle; + } + + setHandle(handle: FileSystemDirectoryHandle): void { + this.dirHandle = handle; + } + + async selectFolder(): Promise { + if (typeof window.showDirectoryPicker !== 'function') { + throw new Error( + 'File System Access API is not supported in this browser. ' + + 'Use a Chromium-based browser (Chrome, Edge, Brave, etc.).', + ); + } + this.dirHandle = await window.showDirectoryPicker({ + mode: 'readwrite', + }); + // Ensure events directory exists + await this.dirHandle.getDirectoryHandle(EVENTS_DIR, { create: true }); + return this.dirHandle; + } + + // ── Permission checks ───────────────────────────────────── + + async hasPermission(): Promise { + if (!this.dirHandle) return false; + try { + const perm = await this.dirHandle.queryPermission({ mode: 'readwrite' }); + return perm === 'granted'; + } catch { + return false; + } + } + + async requestPermission(): Promise { + if (!this.dirHandle) return false; + try { + const perm = await this.dirHandle.requestPermission({ + mode: 'readwrite', + }); + return perm === 'granted'; + } catch { + return false; + } + } + + // ── Write event file ────────────────────────────────────── + + async writeEvent(filename: string, event: SyncEvent): Promise { + const eventsDir = await this.getEventsDir(); + const fileHandle = await eventsDir.getFileHandle(filename, { + create: true, + }); + const writable = await fileHandle.createWritable(); + try { + await writable.write(JSON.stringify(event, null, 2)); + } finally { + await writable.close(); + } + } + + // ── Scan events ──────────────────────────────────────────── + + async scanEventFilenames(): Promise { + const eventsDir = await this.getEventsDir(); + const names: string[] = []; + + for await (const [name, handle] of eventsDir.entries()) { + if (handle.kind === 'file' && name.endsWith('.json')) { + if (parseEventFilename(name)) { + names.push(name); + } + } + } + + // Sort by (timestamp, full filename) for deterministic ordering + names.sort((a, b) => { + const pa = parseEventFilename(a)!; + const pb = parseEventFilename(b)!; + if (pa.ts !== pb.ts) return pa.ts - pb.ts; + return a.localeCompare(b); + }); + + return names; + } + + async readEventFile(filename: string): Promise { + const eventsDir = await this.getEventsDir(); + try { + const fh = await eventsDir.getFileHandle(filename); + const file = await fh.getFile(); + const text = await file.text(); + return JSON.parse(text) as SyncEvent; + } catch { + return null; + } + } + + // ── Internals ────────────────────────────────────────────── + + private async getEventsDir(): Promise { + if (!this.dirHandle) { + throw new Error('No folder selected. Call selectFolder() first.'); + } + return this.dirHandle.getDirectoryHandle(EVENTS_DIR, { create: true }); + } +} diff --git a/nostr/src/folder-sync-db.ts b/nostr/src/folder-sync-db.ts new file mode 100644 index 0000000..5c669a3 --- /dev/null +++ b/nostr/src/folder-sync-db.ts @@ -0,0 +1,210 @@ +import type { + OpenOptions, StoreOptions, CollectionApi, KVApi, + SyncDBEventName, SyncDBEventHandler, +} from './types.js'; +import { generateClientId } from './utils.js'; +import { Emitter } from './emitter.js'; +import { IDBStore } from './idb-store.js'; +import { FolderStore } from './folder-store.js'; +import { NostrTransport } from './nostr-transport.js'; +import { SyncEngine } from './sync-engine.js'; +import { KVStore } from './kv-store.js'; +import { Collection } from './collection.js'; + +const META_CLIENT_ID = 'clientId'; +const META_DIR_HANDLE = 'dirHandle'; +const META_NOSTR_SK = 'nostrSecretKey'; +const META_NOSTR_ROOM = 'nostrRoom'; + +const DEFAULT_RELAYS = [ + 'wss://relay.damus.io', + 'wss://nos.lol', + 'wss://relay.nostr.band', +]; + +export class FolderSyncDB { + private idb!: IDBStore; + private folderStore!: FolderStore; + private nostrTransport!: NostrTransport; + private syncEngine!: SyncEngine; + private emitter!: Emitter; + private autoSyncTimer: ReturnType | null = null; + private collections = new Map>(); + + /** Public KV API — available immediately after open(). */ + kv!: KVApi; + + // ── Construction (use static open()) ─────────────────────── + + private constructor() {} + + static async open(options?: OpenOptions): Promise { + const db = new FolderSyncDB(); + await db.init(options ?? {}); + return db; + } + + private async init(opts: OpenOptions): Promise { + const dbName = opts.dbName ?? 'FolderSyncDB'; + this.emitter = new Emitter(); + this.idb = await IDBStore.open(dbName); + this.folderStore = new FolderStore(); + + // ── Nostr transport setup ──────────────────────────────── + const relays = opts.relays ?? DEFAULT_RELAYS; + this.nostrTransport = new NostrTransport(relays); + + // Load or generate Nostr keypair + let skHex = await this.idb.getMeta(META_NOSTR_SK); + if (skHex) { + const sk = hexToBytes(skHex); + this.nostrTransport.setKeypair(sk); + } else { + const sk = this.nostrTransport.generateKeypair(); + await this.idb.setMeta(META_NOSTR_SK, bytesToHex(sk)); + } + + // ── Client ID ──────────────────────────────────────────── + let clientId = opts.clientId; + if (!clientId) { + clientId = await this.idb.getMeta(META_CLIENT_ID); + if (!clientId) { + clientId = generateClientId(); + await this.idb.setMeta(META_CLIENT_ID, clientId); + } + } else { + await this.idb.setMeta(META_CLIENT_ID, clientId); + } + + // ── Sync engine (dual transport) ───────────────────────── + this.syncEngine = new SyncEngine( + this.idb, this.folderStore, this.nostrTransport, + this.emitter, clientId, opts.conflictResolver, + ); + + this.kv = new KVStore(this.idb, this.syncEngine); + + // ── Restore folder handle ──────────────────────────────── + await this.tryRestoreHandle(); + + // ── Restore Nostr room ─────────────────────────────────── + const savedRoom = opts.roomKey ?? await this.idb.getMeta(META_NOSTR_ROOM); + if (savedRoom) { + await this.joinRoom(savedRoom); + } + + // ── Real-time push from Nostr triggers immediate sync ──── + this.nostrTransport.onNewEvent(() => { + this.sync().catch(() => {}); + }); + + // ── Auto-sync ──────────────────────────────────────────── + if (opts.autoSyncIntervalMs && opts.autoSyncIntervalMs > 0) { + this.autoSyncTimer = setInterval(() => { + this.sync().catch(() => {}); + }, opts.autoSyncIntervalMs); + } + } + + // ── Folder management (same as indexeddb variant) ────────── + + async selectFolder(): Promise { + const handle = await this.folderStore.selectFolder(); + await this.idb.setMeta(META_DIR_HANDLE, handle); + await this.sync(); + } + + async hasFolderAccess(): Promise { + return this.folderStore.hasPermission(); + } + + async requestFolderAccess(): Promise { + const granted = await this.folderStore.requestPermission(); + if (granted) await this.sync(); + return granted; + } + + // ── Nostr room management (NEW) ──────────────────────────── + + async joinRoom(roomKey: string): Promise { + await this.nostrTransport.joinRoom(roomKey); + await this.idb.setMeta(META_NOSTR_ROOM, roomKey); + this.emitter.emit('nostr:connected', { roomKey }); + await this.sync(); + } + + leaveRoom(): void { + this.nostrTransport.leaveRoom(); + this.emitter.emit('nostr:disconnected'); + } + + isConnected(): boolean { + return this.nostrTransport.isConnected; + } + + get currentRoom(): string | null { + return this.nostrTransport.currentRoom; + } + + // ── Sync (folder + Nostr) ────────────────────────────────── + + async sync(): Promise { + return this.syncEngine.sync(); + } + + // ── Collections ──────────────────────────────────────────── + + collection(options: StoreOptions): CollectionApi { + const cached = this.collections.get(options.name); + if (cached) return cached as Collection; + const col = new Collection(options, this.idb, this.syncEngine); + this.collections.set(options.name, col); + return col; + } + + // ── Events ───────────────────────────────────────────────── + + on(event: SyncDBEventName, handler: SyncDBEventHandler): () => void { + return this.emitter.on(event, handler); + } + + // ── Lifecycle ────────────────────────────────────────────── + + async close(): Promise { + if (this.autoSyncTimer !== null) { + clearInterval(this.autoSyncTimer); + this.autoSyncTimer = null; + } + this.nostrTransport.close(); + this.emitter.removeAll(); + this.collections.clear(); + this.idb.close(); + } + + // ── Internals ────────────────────────────────────────────── + + private async tryRestoreHandle(): Promise { + try { + const handle = await this.idb.getMeta(META_DIR_HANDLE); + if (!handle) return; + if (typeof handle.queryPermission !== 'function') return; + this.folderStore.setHandle(handle); + } catch { + // Handle was not restorable + } + } +} + +// ── Hex helpers ────────────────────────────────────────────── + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); +} + +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2); + for (let i = 0; i < hex.length; i += 2) { + bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16); + } + return bytes; +} diff --git a/nostr/src/fs-access.d.ts b/nostr/src/fs-access.d.ts new file mode 100644 index 0000000..b05135d --- /dev/null +++ b/nostr/src/fs-access.d.ts @@ -0,0 +1,88 @@ +/** + * Type declarations for the File System Access API. + * These APIs are available in Chromium-based browsers only. + * @see https://developer.mozilla.org/en-US/docs/Web/API/File_System_Access_API + */ + +interface FileSystemHandlePermissionDescriptor { + mode?: 'read' | 'readwrite'; +} + +interface FileSystemDirectoryHandle { + readonly kind: 'directory'; + readonly name: string; + + getDirectoryHandle( + name: string, + options?: { create?: boolean }, + ): Promise; + + getFileHandle( + name: string, + options?: { create?: boolean }, + ): Promise; + + entries(): AsyncIterableIterator< + [string, FileSystemDirectoryHandle | FileSystemFileHandle] + >; + + values(): AsyncIterableIterator< + FileSystemDirectoryHandle | FileSystemFileHandle + >; + + keys(): AsyncIterableIterator; + + queryPermission( + descriptor?: FileSystemHandlePermissionDescriptor, + ): Promise; + + requestPermission( + descriptor?: FileSystemHandlePermissionDescriptor, + ): Promise; +} + +interface FileSystemFileHandle { + readonly kind: 'file'; + readonly name: string; + getFile(): Promise; + createWritable( + options?: FileSystemCreateWritableOptions, + ): Promise; +} + +interface FileSystemCreateWritableOptions { + keepExistingData?: boolean; +} + +interface FileSystemWritableFileStream extends WritableStream { + write(data: BufferSource | Blob | string | WriteParams): Promise; + seek(position: number): Promise; + truncate(size: number): Promise; + close(): Promise; +} + +interface WriteParams { + type: 'write' | 'seek' | 'truncate'; + data?: BufferSource | Blob | string; + position?: number; + size?: number; +} + +interface ShowDirectoryPickerOptions { + id?: string; + mode?: 'read' | 'readwrite'; + startIn?: + | FileSystemDirectoryHandle + | 'desktop' + | 'documents' + | 'downloads' + | 'music' + | 'pictures' + | 'videos'; +} + +interface Window { + showDirectoryPicker( + options?: ShowDirectoryPickerOptions, + ): Promise; +} diff --git a/nostr/src/idb-store.ts b/nostr/src/idb-store.ts new file mode 100644 index 0000000..789de69 --- /dev/null +++ b/nostr/src/idb-store.ts @@ -0,0 +1,308 @@ +import type { + KVRecord, + DocRecord, + IdxEntry, + AppliedRecord, + MetaRecord, + IndexDefinition, +} from './types.js'; +import { extractIndexValue } from './utils.js'; + +// ── Store names ────────────────────────────────────────────── + +const S_KV = 'kv'; +const S_DOCS = 'docs'; +const S_IDX = 'idx'; +const S_APPLIED = 'applied'; +const S_META = 'meta'; + +const DB_VERSION = 1; + +// ── Promise wrappers for raw IDB ───────────────────────────── + +function reqP(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); +} + +function txDone(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error ?? new Error('Transaction aborted')); + }); +} + +// ── IDBStore ───────────────────────────────────────────────── + +export class IDBStore { + private constructor(private db: IDBDatabase) {} + + // ── Open ─────────────────────────────────────────────────── + + static open(name: string): Promise { + return new Promise((resolve, reject) => { + const req = indexedDB.open(name, DB_VERSION); + + req.onupgradeneeded = () => { + const db = req.result; + + // KV store + if (!db.objectStoreNames.contains(S_KV)) { + db.createObjectStore(S_KV, { keyPath: 'key' }); + } + + // Document store (compound primary key) + if (!db.objectStoreNames.contains(S_DOCS)) { + const docs = db.createObjectStore(S_DOCS, { + keyPath: ['store', 'id'], + }); + docs.createIndex('byStore', 'store', { unique: false }); + } + + // Secondary index entries + if (!db.objectStoreNames.contains(S_IDX)) { + const idx = db.createObjectStore(S_IDX, { + keyPath: ['store', 'indexName', 'id'], + }); + idx.createIndex('lookup', ['store', 'indexName', 'value'], { + unique: false, + }); + idx.createIndex('byDoc', ['store', 'id'], { unique: false }); + } + + // Applied event dedup + if (!db.objectStoreNames.contains(S_APPLIED)) { + db.createObjectStore(S_APPLIED, { keyPath: 'filename' }); + } + + // Metadata + if (!db.objectStoreNames.contains(S_META)) { + db.createObjectStore(S_META, { keyPath: 'key' }); + } + }; + + req.onsuccess = () => resolve(new IDBStore(req.result)); + req.onerror = () => reject(req.error); + }); + } + + // ── KV operations ────────────────────────────────────────── + + async getKV(key: string): Promise { + const tx = this.db.transaction(S_KV, 'readonly'); + return reqP(tx.objectStore(S_KV).get(key)); + } + + async putKV(record: KVRecord): Promise { + const tx = this.db.transaction(S_KV, 'readwrite'); + tx.objectStore(S_KV).put(record); + return txDone(tx); + } + + async deleteKV(key: string): Promise { + const tx = this.db.transaction(S_KV, 'readwrite'); + tx.objectStore(S_KV).delete(key); + return txDone(tx); + } + + async getAllKVKeys(): Promise { + const tx = this.db.transaction(S_KV, 'readonly'); + const records: KVRecord[] = await reqP(tx.objectStore(S_KV).getAll()); + return records.map((r) => r.key); + } + + async getAllKVEntries(): Promise { + const tx = this.db.transaction(S_KV, 'readonly'); + return reqP(tx.objectStore(S_KV).getAll()); + } + + // ── Doc operations ───────────────────────────────────────── + + async getDoc(store: string, id: string): Promise { + const tx = this.db.transaction(S_DOCS, 'readonly'); + const rec: DocRecord | undefined = await reqP( + tx.objectStore(S_DOCS).get([store, id]), + ); + if (rec && rec.deleted) return undefined; + return rec; + } + + async getRawDoc(store: string, id: string): Promise { + const tx = this.db.transaction(S_DOCS, 'readonly'); + return reqP(tx.objectStore(S_DOCS).get([store, id])); + } + + async putDoc( + record: DocRecord, + indexes: IndexDefinition[], + ): Promise { + const tx = this.db.transaction([S_DOCS, S_IDX], 'readwrite'); + const docsOS = tx.objectStore(S_DOCS); + const idxOS = tx.objectStore(S_IDX); + + // Remove old index entries for this doc (chain off getAll request) + const byDocIdx = idxOS.index('byDoc'); + const getOld = byDocIdx.getAll([record.store, record.id]); + + getOld.onsuccess = () => { + const oldEntries: IdxEntry[] = getOld.result; + for (const entry of oldEntries) { + idxOS.delete([entry.store, entry.indexName, entry.id]); + } + + // Write document + docsOS.put(record); + + // Create new index entries (only for non-deleted docs) + if (!record.deleted) { + for (const def of indexes) { + const val = extractIndexValue(record.data, def.fields as string[]); + if (val !== undefined) { + idxOS.put({ + store: record.store, + indexName: def.name, + id: record.id, + value: val, + } satisfies IdxEntry); + } + } + } + }; + + return txDone(tx); + } + + async deleteDoc( + store: string, + id: string, + ts: number, + rev: number, + indexes: IndexDefinition[], + ): Promise { + return this.putDoc( + { store, id, data: null, ts, rev, deleted: true }, + indexes, + ); + } + + async getAllDocs(store: string): Promise { + const tx = this.db.transaction(S_DOCS, 'readonly'); + const idx = tx.objectStore(S_DOCS).index('byStore'); + const all: DocRecord[] = await reqP(idx.getAll(store)); + return all.filter((r) => !r.deleted); + } + + async findByIndex( + store: string, + indexName: string, + value: IDBValidKey, + ): Promise { + const tx = this.db.transaction(S_IDX, 'readonly'); + const idx = tx.objectStore(S_IDX).index('lookup'); + const entries: IdxEntry[] = await reqP( + idx.getAll([store, indexName, value]), + ); + return entries.map((e) => e.id); + } + + async queryByIndex( + store: string, + indexName: string, + range: IDBKeyRange, + ): Promise { + const tx = this.db.transaction(S_IDX, 'readonly'); + const idx = tx.objectStore(S_IDX).index('lookup'); + const entries: IdxEntry[] = await reqP(idx.getAll(range)); + // Filter to only this store+index (the range may be broader) + return entries + .filter((e) => e.store === store && e.indexName === indexName) + .map((e) => e.id); + } + + async rebuildIndexes( + store: string, + indexes: IndexDefinition[], + ): Promise { + const docs = await this.getAllDocs(store); + const tx = this.db.transaction(S_IDX, 'readwrite'); + const idxOS = tx.objectStore(S_IDX); + + // Delete all existing index entries for this store + const byDocIdx = idxOS.index('byDoc'); + for (const doc of docs) { + const req = byDocIdx.getAll([store, doc.id]); + req.onsuccess = () => { + for (const entry of req.result as IdxEntry[]) { + idxOS.delete([entry.store, entry.indexName, entry.id]); + } + }; + } + + // Re-create all entries + for (const doc of docs) { + for (const def of indexes) { + const val = extractIndexValue(doc.data, def.fields as string[]); + if (val !== undefined) { + idxOS.put({ + store, + indexName: def.name, + id: doc.id, + value: val, + } satisfies IdxEntry); + } + } + } + + return txDone(tx); + } + + // ── Applied-event tracking ───────────────────────────────── + + async isEventApplied(filename: string): Promise { + const tx = this.db.transaction(S_APPLIED, 'readonly'); + const rec = await reqP(tx.objectStore(S_APPLIED).get(filename)); + return rec !== undefined; + } + + async markEventApplied(filename: string): Promise { + const tx = this.db.transaction(S_APPLIED, 'readwrite'); + tx.objectStore(S_APPLIED).put({ + filename, + appliedAt: Date.now(), + } satisfies AppliedRecord); + return txDone(tx); + } + + async getAppliedSet(): Promise> { + const tx = this.db.transaction(S_APPLIED, 'readonly'); + const all: AppliedRecord[] = await reqP( + tx.objectStore(S_APPLIED).getAll(), + ); + return new Set(all.map((r) => r.filename)); + } + + // ── Meta ─────────────────────────────────────────────────── + + async getMeta(key: string): Promise { + const tx = this.db.transaction(S_META, 'readonly'); + const rec: MetaRecord | undefined = await reqP( + tx.objectStore(S_META).get(key), + ); + return rec?.value as T | undefined; + } + + async setMeta(key: string, value: unknown): Promise { + const tx = this.db.transaction(S_META, 'readwrite'); + tx.objectStore(S_META).put({ key, value } satisfies MetaRecord); + return txDone(tx); + } + + // ── Lifecycle ────────────────────────────────────────────── + + close(): void { + this.db.close(); + } +} diff --git a/nostr/src/index.ts b/nostr/src/index.ts new file mode 100644 index 0000000..3e24a07 --- /dev/null +++ b/nostr/src/index.ts @@ -0,0 +1,11 @@ +export { FolderSyncDB } from './folder-sync-db.js'; +export type { + OpenOptions, + StoreOptions, + IndexDefinition, + SyncEvent, + KVApi, + CollectionApi, + SyncDBEventName, + SyncDBEventHandler, +} from './types.js'; diff --git a/nostr/src/kv-store.ts b/nostr/src/kv-store.ts new file mode 100644 index 0000000..82162d9 --- /dev/null +++ b/nostr/src/kv-store.ts @@ -0,0 +1,45 @@ +import type { KVApi } from './types.js'; +import type { IDBStore } from './idb-store.js'; +import type { SyncEngine } from './sync-engine.js'; + +export class KVStore implements KVApi { + constructor( + private idb: IDBStore, + private sync: SyncEngine, + ) {} + + async get(key: string): Promise { + const rec = await this.idb.getKV(key); + return rec?.value as T | undefined; + } + + async set(key: string, value: T): Promise { + const existing = await this.idb.getKV(key); + const rev = (existing?.rev ?? 0) + 1; + const ts = Date.now(); + // SyncEngine.persistEvent handles the IDB write + folder write + await this.sync.writeKV(key, value, ts, rev); + } + + async delete(key: string): Promise { + const existing = await this.idb.getKV(key); + if (!existing) return; + const rev = existing.rev + 1; + const ts = Date.now(); + await this.sync.deleteKV(key, ts, rev); + } + + async has(key: string): Promise { + const rec = await this.idb.getKV(key); + return rec !== undefined; + } + + async keys(): Promise { + return this.idb.getAllKVKeys(); + } + + async entries(): Promise> { + const records = await this.idb.getAllKVEntries(); + return records.map((r) => [r.key, r.value as T]); + } +} diff --git a/nostr/src/nostr-transport.ts b/nostr/src/nostr-transport.ts new file mode 100644 index 0000000..e8ed210 --- /dev/null +++ b/nostr/src/nostr-transport.ts @@ -0,0 +1,179 @@ +import type { SyncEvent } from './types.js'; + +/** + * Nostr relay transport layer. + * + * Implements the same event I/O interface as FolderStore so the SyncEngine + * can treat it as just another transport. + * + * Uses raw WebSocket + minimal Nostr protocol (NIP-01) to avoid + * heavy dependencies. Works from file:// origins. + */ + +const NOSTR_EVENT_KIND = 4078; // custom regular kind (stored by relays) + +// ── Minimal Nostr crypto (secp256k1 via nostr-tools) ───────── + +import { generateSecretKey, getPublicKey, finalizeEvent } from 'nostr-tools/pure'; +import { SimplePool, type SubCloser } from 'nostr-tools/pool'; +import type { Filter } from 'nostr-tools/filter'; + +export class NostrTransport { + private relays: string[]; + private pool: SimplePool; + private roomKey: string | null = null; + private secretKey: Uint8Array | null = null; + + /** Cached events keyed by our standard filename. */ + private eventCache = new Map(); + + /** Active relay subscription (closeable). */ + private sub: SubCloser | null = null; + + /** Callback fired when a new event arrives in real time. */ + private _onNewEvent?: () => void; + + constructor(relays: string[]) { + this.relays = relays; + this.pool = new SimplePool(); + } + + // ── Key management ────────────────────────────────────────── + + /** + * Set the keypair. Called by FolderSyncDB after loading from IDB + * (or generating a new one on first run). + */ + setKeypair(sk: Uint8Array): void { + this.secretKey = sk; + // Derive pubkey (used internally by finalizeEvent) + getPublicKey(sk); + } + + generateKeypair(): Uint8Array { + const sk = generateSecretKey(); + this.setKeypair(sk); + return sk; + } + + // ── Room management ───────────────────────────────────────── + + async joinRoom(roomKey: string): Promise { + if (this.roomKey === roomKey && this.sub) return; // already joined + this.leaveRoom(); + this.roomKey = roomKey; + + // Fetch existing events from relays + const fetchFilter: Filter = { + kinds: [NOSTR_EVENT_KIND], + '#channel': [roomKey], + limit: 5000, + }; + try { + const events = await this.pool.querySync(this.relays, fetchFilter as Filter); + for (const ev of events) { + this.cacheNostrEvent(ev); + } + } catch { + // relay might be unreachable — continue with empty cache + } + + // Subscribe for new real-time events + const subFilter: Filter = { + kinds: [NOSTR_EVENT_KIND], + '#channel': [roomKey], + since: Math.floor(Date.now() / 1000), + }; + this.sub = this.pool.subscribeMany(this.relays, subFilter, { + onevent: (ev) => { + const isNew = this.cacheNostrEvent(ev); + if (isNew) this._onNewEvent?.(); + }, + }); + } + + leaveRoom(): void { + if (this.sub) { + this.sub.close(); + this.sub = null; + } + this.roomKey = null; + this.eventCache.clear(); + } + + get isConnected(): boolean { + return this.roomKey !== null && this.sub !== null; + } + + get currentRoom(): string | null { + return this.roomKey; + } + + // ── Callback for real-time push ───────────────────────────── + + onNewEvent(cb: () => void): void { + this._onNewEvent = cb; + } + + // ── Event I/O (same interface as FolderStore) ─────────────── + + async writeEvent(filename: string, event: SyncEvent): Promise { + if (!this.roomKey || !this.secretKey) return; + + this.eventCache.set(filename, event); + + const nostrEvent = finalizeEvent({ + kind: NOSTR_EVENT_KIND, + created_at: Math.floor(Date.now() / 1000), + tags: [ + ['channel', this.roomKey], + ['filename', filename], + ], + content: JSON.stringify(event), + }, this.secretKey); + + // Publish to all relays, don't wait for all to confirm + try { + await Promise.any(this.pool.publish(this.relays, nostrEvent as any)); + } catch { + // All relays failed — event is still in local cache + } + } + + async scanEventFilenames(): Promise { + return Array.from(this.eventCache.keys()).sort(); + } + + async readEventFile(filename: string): Promise { + return this.eventCache.get(filename) ?? null; + } + + // ── Cleanup ───────────────────────────────────────────────── + + close(): void { + this.leaveRoom(); + this.pool.close(this.relays); + } + + // ── Internals ─────────────────────────────────────────────── + + /** + * Parse a Nostr event and cache it. Returns true if the event + * was new (not already cached). + */ + private cacheNostrEvent(nostrEvent: any): boolean { + try { + const filename = nostrEvent.tags?.find( + (t: string[]) => t[0] === 'filename', + )?.[1]; + if (!filename) return false; + if (this.eventCache.has(filename)) return false; + + const syncEvent = JSON.parse(nostrEvent.content) as SyncEvent; + this.eventCache.set(filename, syncEvent); + return true; + } catch { + return false; + } + } +} diff --git a/nostr/src/sync-engine.ts b/nostr/src/sync-engine.ts new file mode 100644 index 0000000..0f72bb3 --- /dev/null +++ b/nostr/src/sync-engine.ts @@ -0,0 +1,254 @@ +import type { SyncEvent, IndexDefinition, OpenOptions } from './types.js'; +import type { IDBStore } from './idb-store.js'; +import type { FolderStore } from './folder-store.js'; +import type { NostrTransport } from './nostr-transport.js'; +import type { Emitter } from './emitter.js'; +import { canonicalJson, sha256Hex, eventFilename } from './utils.js'; + +/** + * Dual-transport sync engine. + * + * On local write → IDB + folder (if connected) + Nostr (if joined) + * On sync() → import from folder + import from Nostr, deduplicate + */ +export class SyncEngine { + private clientId: string; + private conflictResolver?: OpenOptions['conflictResolver']; + private collectionIndexes = new Map(); + private syncing = false; + + constructor( + private idb: IDBStore, + private folder: FolderStore, + private nostr: NostrTransport, + private emitter: Emitter, + clientId: string, + conflictResolver?: OpenOptions['conflictResolver'], + ) { + this.clientId = clientId; + this.conflictResolver = conflictResolver; + } + + // ── Collection index registration ────────────────────────── + + registerIndexes(store: string, indexes: IndexDefinition[]): void { + this.collectionIndexes.set(store, indexes); + } + + getIndexes(store: string): IndexDefinition[] { + return this.collectionIndexes.get(store) ?? []; + } + + // ── Local writes (write to IDB + folder + Nostr) ─────────── + + async writeKV(key: string, value: unknown, ts: number, rev: number): Promise { + const event: SyncEvent = { + type: 'put', store: 'kv', key, ts, + clientId: this.clientId, data: value, rev, + }; + await this.persistEvent(event); + } + + async deleteKV(key: string, ts: number, rev: number): Promise { + const event: SyncEvent = { + type: 'delete', store: 'kv', key, ts, + clientId: this.clientId, rev, + }; + await this.persistEvent(event); + } + + async writeDoc(store: string, id: string, data: unknown, ts: number, rev: number): Promise { + const event: SyncEvent = { + type: 'put', store, key: id, id, ts, + clientId: this.clientId, data, rev, + }; + await this.persistEvent(event); + } + + async deleteDocEvent(store: string, id: string, ts: number, rev: number): Promise { + const event: SyncEvent = { + type: 'delete', store, key: id, id, ts, + clientId: this.clientId, rev, + }; + await this.persistEvent(event); + } + + // ── Core persist: IDB + folder + Nostr ───────────────────── + + private async persistEvent(event: SyncEvent): Promise { + const canonical = canonicalJson(event); + const hash = await sha256Hex(canonical); + const filename = eventFilename(event.ts, hash); + + // 1. Write to IDB (fast path) + await this.applyEvent(event); + await this.idb.markEventApplied(filename); + + // 2. Write to folder (if connected) + if (this.folder.hasHandle && (await this.folder.hasPermission())) { + try { + await this.folder.writeEvent(filename, event); + } catch (err) { + this.emitter.emit('folder:lost-permission', err); + } + } + + // 3. Publish to Nostr (if joined) + if (this.nostr.isConnected) { + try { + await this.nostr.writeEvent(filename, event); + } catch { + // Nostr publish failed — event is still in IDB + folder + } + } + + this.emitter.emit('change', { + type: event.type, store: event.store, + key: event.key, id: event.id, data: event.data, + }); + } + + // ── Sync: import from BOTH folder and Nostr ──────────────── + + async sync(): Promise { + if (this.syncing) return; + this.syncing = true; + this.emitter.emit('sync:start'); + + try { + const appliedSet = await this.idb.getAppliedSet(); + let importCount = 0; + + // ── Import from folder ───────────────────────────────── + const hasFolder = this.folder.hasHandle && (await this.folder.hasPermission()); + if (hasFolder) { + const folderFiles = await this.folder.scanEventFilenames(); + for (const name of folderFiles) { + if (appliedSet.has(name)) continue; + const event = await this.folder.readEventFile(name); + if (!event) continue; + + const hadConflict = await this.applyEventWithConflictCheck(event); + await this.idb.markEventApplied(name); + appliedSet.add(name); + importCount++; + + if (hadConflict) this.emitter.emit('conflict', { filename: name, event }); + this.emitter.emit('change', { + type: event.type, store: event.store, + key: event.key, id: event.id, data: event.data, source: 'folder', + }); + + // Bridge: push folder event to Nostr so remote devices get it + if (this.nostr.isConnected) { + this.nostr.writeEvent(name, event).catch(() => {}); + } + } + } + + // ── Import from Nostr ────────────────────────────────── + if (this.nostr.isConnected) { + const nostrFiles = await this.nostr.scanEventFilenames(); + for (const name of nostrFiles) { + if (appliedSet.has(name)) continue; + const event = await this.nostr.readEventFile(name); + if (!event) continue; + + const hadConflict = await this.applyEventWithConflictCheck(event); + await this.idb.markEventApplied(name); + appliedSet.add(name); + importCount++; + + if (hadConflict) this.emitter.emit('conflict', { filename: name, event }); + this.emitter.emit('change', { + type: event.type, store: event.store, + key: event.key, id: event.id, data: event.data, source: 'nostr', + }); + + // Bridge: persist Nostr event to folder so it survives offline + if (hasFolder) { + this.folder.writeEvent(name, event).catch(() => {}); + } + } + } + + // If neither transport is connected, nothing to do + if (!hasFolder && !this.nostr.isConnected) { + // No transport — sync is a no-op + } + + this.emitter.emit('sync:end', { imported: importCount }); + } catch (err) { + this.emitter.emit('sync:end', { error: err }); + } finally { + this.syncing = false; + } + } + + // ── Apply a single event to IDB ──────────────────────────── + + private async applyEvent(event: SyncEvent): Promise { + if (event.store === 'kv') { + if (event.type === 'put') { + await this.idb.putKV({ + key: event.key, value: event.data, + ts: event.ts, rev: event.rev ?? 0, + }); + } else { + await this.idb.deleteKV(event.key); + } + } else { + const indexes = this.getIndexes(event.store); + if (event.type === 'put') { + await this.idb.putDoc({ + store: event.store, id: event.id ?? event.key, + data: event.data, ts: event.ts, rev: event.rev ?? 0, + }, indexes); + } else { + await this.idb.deleteDoc( + event.store, event.id ?? event.key, + event.ts, event.rev ?? 0, indexes, + ); + } + } + } + + private async applyEventWithConflictCheck(event: SyncEvent): Promise { + let hadConflict = false; + + if (event.store === 'kv') { + const existing = await this.idb.getKV(event.key); + if (existing) { + if (event.ts > existing.ts) { + hadConflict = true; + } else if (event.ts === existing.ts) { + hadConflict = true; + if (this.conflictResolver) { + const resolved = this.conflictResolver(existing.value, event.data); + event = { ...event, data: resolved }; + } + } else { + return true; + } + } + } else { + const existing = await this.idb.getRawDoc(event.store, event.id ?? event.key); + if (existing && !existing.deleted) { + if (event.ts > existing.ts) { + hadConflict = true; + } else if (event.ts === existing.ts) { + hadConflict = true; + if (this.conflictResolver) { + const resolved = this.conflictResolver(existing.data, event.data); + event = { ...event, data: resolved }; + } + } else { + return true; + } + } + } + + await this.applyEvent(event); + return hadConflict; + } +} diff --git a/nostr/src/types.ts b/nostr/src/types.ts new file mode 100644 index 0000000..22b49f2 --- /dev/null +++ b/nostr/src/types.ts @@ -0,0 +1,112 @@ +// ── Event types ────────────────────────────────────────────── + +export type EventType = 'put' | 'delete'; + +export interface SyncEvent { + type: EventType; + store: string; // "kv" | collection name + key: string; + id?: string; // present for collection documents + ts: number; // millisecond Unix epoch + clientId: string; + data?: unknown; // absent on deletes + rev?: number; +} + +// ── Index / collection options ─────────────────────────────── + +export interface IndexDefinition { + name: string; + fields: (keyof T & string)[] | string[]; +} + +export interface StoreOptions { + name: string; + indexes?: IndexDefinition[]; +} + +export interface OpenOptions { + dbName?: string; + autoSyncIntervalMs?: number; + clientId?: string; + conflictResolver?: (current: unknown, incoming: unknown) => unknown; + + /** Nostr relay WebSocket URLs. Defaults to a set of popular public relays. */ + relays?: string[]; + /** If provided, automatically join this room on open. */ + roomKey?: string; +} + +// ── IDB record shapes ──────────────────────────────────────── + +export interface KVRecord { + key: string; + value: unknown; + ts: number; + rev: number; +} + +export interface DocRecord { + store: string; + id: string; + data: unknown; + ts: number; + rev: number; + deleted?: boolean; +} + +export interface IdxEntry { + store: string; + indexName: string; + id: string; + value: IDBValidKey; +} + +export interface AppliedRecord { + filename: string; + appliedAt: number; +} + +export interface MetaRecord { + key: string; + value: unknown; +} + +// ── Event emitter ──────────────────────────────────────────── + +export type SyncDBEventName = + | 'sync:start' + | 'sync:end' + | 'change' + | 'conflict' + | 'folder:lost-permission' + | 'nostr:connected' + | 'nostr:disconnected' + | 'nostr:event'; + +export type SyncDBEventHandler = (...args: unknown[]) => void; + +// ── KV API surface ─────────────────────────────────────────── + +export interface KVApi { + get(key: string): Promise; + set(key: string, value: T): Promise; + delete(key: string): Promise; + has(key: string): Promise; + keys(): Promise; + entries(): Promise>; +} + +// ── Collection API surface ─────────────────────────────────── + +export interface CollectionApi { + get(id: string): Promise; + put(doc: T): Promise; + delete(id: string): Promise; + all(): Promise; + findByIndex(indexName: string, value: IDBValidKey): Promise; + queryByIndex( + indexName: string, + range: { gt?: IDBValidKey; gte?: IDBValidKey; lt?: IDBValidKey; lte?: IDBValidKey }, + ): Promise; +} diff --git a/nostr/src/utils.ts b/nostr/src/utils.ts new file mode 100644 index 0000000..b691caa --- /dev/null +++ b/nostr/src/utils.ts @@ -0,0 +1,94 @@ +// ── Canonical JSON ─────────────────────────────────────────── + +function sortKeys(obj: unknown): unknown { + if (obj === null || obj === undefined || typeof obj !== 'object') return obj; + if (Array.isArray(obj)) return obj.map(sortKeys); + const sorted: Record = {}; + for (const key of Object.keys(obj as Record).sort()) { + sorted[key] = sortKeys((obj as Record)[key]); + } + return sorted; +} + +export function canonicalJson(obj: unknown): string { + return JSON.stringify(sortKeys(obj)); +} + +// ── Hashing ────────────────────────────────────────────────── + +export async function sha256Hex(data: string): Promise { + const buf = await crypto.subtle.digest( + 'SHA-256', + new TextEncoder().encode(data), + ); + return Array.from(new Uint8Array(buf)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +// ── Event filenames ────────────────────────────────────────── + +const HASH_PREFIX_LEN = 12; + +export function eventFilename(ts: number, fullHash: string): string { + return `${ts}_${fullHash.slice(0, HASH_PREFIX_LEN)}.json`; +} + +const EVENT_FILE_RE = /^(\d+)_([0-9a-f]+)\.json$/; + +export function parseEventFilename( + name: string, +): { ts: number; hash: string } | null { + const m = EVENT_FILE_RE.exec(name); + if (!m) return null; + return { ts: Number(m[1]), hash: m[2] }; +} + +// ── Client ID ──────────────────────────────────────────────── + +export function generateClientId(): string { + return crypto.randomUUID(); +} + +// ── Index value extraction ─────────────────────────────────── + +export function extractIndexValue( + data: unknown, + fields: string[], +): IDBValidKey | undefined { + if (!data || typeof data !== 'object') return undefined; + const rec = data as Record; + + if (fields.length === 1) { + const v = getNestedValue(rec, fields[0]); + if (v === undefined || v === null) return undefined; + return toIDBKey(v); + } + + const parts: IDBValidKey[] = []; + for (const f of fields) { + const v = getNestedValue(rec, f); + if (v === undefined || v === null) return undefined; + parts.push(toIDBKey(v)); + } + return parts; +} + +function getNestedValue(obj: Record, path: string): unknown { + let cur: unknown = obj; + for (const seg of path.split('.')) { + if (cur === null || cur === undefined || typeof cur !== 'object') { + return undefined; + } + cur = (cur as Record)[seg]; + } + return cur; +} + +function toIDBKey(v: unknown): IDBValidKey { + if (typeof v === 'string' || typeof v === 'number') return v; + if (v instanceof Date) return v; + if (Array.isArray(v)) return v.map(toIDBKey); + // Fallback: stringify + return String(v); +} diff --git a/nostr/tsconfig.json b/nostr/tsconfig.json new file mode 100644 index 0000000..ce2f8e2 --- /dev/null +++ b/nostr/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "outDir": "dist", + "rootDir": "src", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/paste/build.ts b/paste/build.ts index d102c54..270c391 100644 --- a/paste/build.ts +++ b/paste/build.ts @@ -20,7 +20,7 @@ import { readFileSync } from 'fs'; import { join, basename } from 'path'; -const variants = ['indexeddb', 'sqlite', 'nedb', 'sql-js'] as const; +const variants = ['indexeddb', 'sqlite', 'nedb', 'sql-js', 'nostr'] as const; for (const variant of variants) { console.log(`Building paste-${variant}...`); diff --git a/paste/nostr/app.ts b/paste/nostr/app.ts new file mode 100644 index 0000000..6203f59 --- /dev/null +++ b/paste/nostr/app.ts @@ -0,0 +1,70 @@ +import { FolderSyncDB } from '../../nostr/src/index.ts'; +import { initPasteApp } from '../shared.ts'; + +document.addEventListener('DOMContentLoaded', async () => { + const db = await FolderSyncDB.open({ autoSyncIntervalMs: 3000 }); + await initPasteApp(db as any, 'nostr'); + + // ── Nostr room UI ────────────────────────────────────────── + + const roomInput = document.querySelector('#room-input') as HTMLInputElement; + const joinBtn = document.querySelector('#join-room') as HTMLButtonElement; + const nostrStatus = document.querySelector('#nostr-status')!; + + function updateNostrUI(connected: boolean, room?: string) { + if (connected && room) { + nostrStatus.textContent = `Connected: ${room}`; + nostrStatus.className = 'status ok'; + joinBtn.textContent = 'Leave'; + roomInput.disabled = true; + } else { + nostrStatus.textContent = 'Not connected'; + nostrStatus.className = 'status'; + joinBtn.textContent = 'Join room'; + roomInput.disabled = false; + } + } + + joinBtn.addEventListener('click', async () => { + if (db.isConnected()) { + db.leaveRoom(); + updateNostrUI(false); + } else { + const key = roomInput.value.trim(); + if (!key) { + nostrStatus.textContent = 'Enter a room key'; + nostrStatus.className = 'status err'; + return; + } + try { + nostrStatus.textContent = 'Connecting...'; + nostrStatus.className = 'status'; + await db.joinRoom(key); + updateNostrUI(true, key); + } catch (e: unknown) { + nostrStatus.textContent = 'Error: ' + (e as Error).message; + nostrStatus.className = 'status err'; + } + } + }); + + roomInput.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter') joinBtn.click(); + }); + + // Restore saved room + if (db.isConnected()) { + updateNostrUI(true, db.currentRoom ?? undefined); + roomInput.value = db.currentRoom ?? ''; + } else { + updateNostrUI(false); + } + + db.on('nostr:connected', (data: any) => { + updateNostrUI(true, data?.roomKey); + }); + + db.on('nostr:disconnected', () => { + updateNostrUI(false); + }); +}); diff --git a/paste/nostr/index.html b/paste/nostr/index.html new file mode 100644 index 0000000..02abd57 --- /dev/null +++ b/paste/nostr/index.html @@ -0,0 +1,43 @@ + + + + + +paste — Nostr + Folder + + + +
+

paste nostr

+

type text + enter, or paste an image — use #hashtags to add tags

+
+ + +
+
+ + + +
+
+
+
+
+ + + +
+
+
+
+
+ + +