diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index 9338742..884c168 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -11,6 +11,8 @@ import type { TimelineEntry, SankeyData, MultichainData } from "../lib/data-tran import { loadD3, renderTimeline, renderFlowChart, renderSankey } from "../lib/wallet-viz"; import { DEMO_TIMELINE_DATA, DEMO_SANKEY_DATA, DEMO_MULTICHAIN_DATA } from "../lib/wallet-demo-data"; import { TourEngine } from "../../../shared/tour-engine"; +import { WalletLocalFirstClient } from "../local-first-client"; +import type { WalletDoc, WatchedAddress } from "../schemas"; interface ChainInfo { chainId: string; @@ -190,6 +192,11 @@ class FolkWalletViewer extends HTMLElement { { target: '.view-tab', title: "Dashboard Views", message: "Switch between balance view, transfer timeline, and flow visualisations for deeper analysis.", advanceOnClick: false }, ]; + // Multiplayer state + private lfClient: WalletLocalFirstClient | null = null; + private _lfcUnsub: (() => void) | null = null; + private watchedAddresses: WatchedAddress[] = []; + constructor() { super(); this.shadow = this.attachShadow({ mode: "open" }); @@ -215,6 +222,7 @@ class FolkWalletViewer extends HTMLElement { const params = new URLSearchParams(window.location.search); this.address = params.get("address") || ""; this.checkAuthState(); + this.initWalletSync(space); // Yield view is standalone — always force visualizer tab if (this.activeView === "yield") { @@ -262,6 +270,41 @@ class FolkWalletViewer extends HTMLElement { } catch {} } + private async initWalletSync(space: string) { + try { + this.lfClient = new WalletLocalFirstClient(space); + await this.lfClient.init(); + await this.lfClient.subscribe(); + + this._lfcUnsub = this.lfClient.onChange((doc) => { + this.extractWatchlist(doc); + this.render(); + }); + + const doc = this.lfClient.getDoc(); + if (doc) this.extractWatchlist(doc); + } catch (err) { + console.warn('[rWallet] Local-first init failed:', err); + } + } + + private extractWatchlist(doc: WalletDoc) { + this.watchedAddresses = doc.watchedAddresses + ? Object.values(doc.watchedAddresses).sort((a, b) => b.addedAt - a.addedAt) + : []; + } + + private addToWatchlist(address: string, chain: string, label: string) { + if (!this.lfClient) return; + const did = this.userDID || null; + this.lfClient.addWatchAddress(address, chain, label, did); + } + + private removeFromWatchlist(key: string) { + if (!this.lfClient) return; + this.lfClient.removeWatchAddress(key); + } + private getAuthToken(): string | null { try { const session = localStorage.getItem("encryptid_session"); @@ -1691,6 +1734,29 @@ class FolkWalletViewer extends HTMLElement { `; } + private renderWatchlist(): string { + if (this.watchedAddresses.length === 0 && !this.lfClient) return ''; + const isLive = this.lfClient?.isConnected ?? false; + return ` +
+
+ Shared Watchlist + ${isLive ? 'LIVE' : ''} + +
+ ${this.watchedAddresses.length === 0 + ? '
No watched addresses. Click "Watch" to add one.
' + : `
${this.watchedAddresses.map(w => { + const key = `${w.chain}:${w.address.toLowerCase()}`; + return `
+ ${this.esc(w.label || w.address.slice(0, 8) + '...')} + ${w.address.slice(0, 6)}...${w.address.slice(-4)} + +
`; + }).join('')}
`} +
`; + } + private renderTopTabBar(): string { return `
@@ -2253,6 +2319,7 @@ class FolkWalletViewer extends HTMLElement { ${this.renderFeatures()} ${this.renderExamples()} + ${this.renderWatchlist()} ${this.renderDashboard()} `; } @@ -2313,6 +2380,39 @@ class FolkWalletViewer extends HTMLElement { }); }); + // Watchlist: add + this.shadow.querySelector('[data-action="add-watch"]')?.addEventListener('click', () => { + const addr = prompt('Wallet address to watch:'); + if (!addr) return; + const label = prompt('Label (optional):') || ''; + const chain = this.selectedChain || '1'; + this.addToWatchlist(addr, chain, label); + }); + + // Watchlist: click to load + this.shadow.querySelectorAll('.watch-item').forEach(el => { + el.addEventListener('click', () => { + const addr = el.dataset.watchAddress; + if (!addr) return; + const input = this.shadow.querySelector('#address-input') as HTMLInputElement; + if (input) input.value = addr; + this.address = addr; + const url = new URL(window.location.href); + url.searchParams.set('address', addr); + window.history.replaceState({}, '', url.toString()); + this.detectChains(); + }); + }); + + // Watchlist: remove + this.shadow.querySelectorAll('.watch-remove').forEach(btn => { + btn.addEventListener('click', (e) => { + e.stopPropagation(); + const key = btn.dataset.watchKey; + if (key) this.removeFromWatchlist(key); + }); + }); + // Draw visualization if active if (this.activeView !== "balances" && this.activeView !== "yield" && this.hasData()) { requestAnimationFrame(() => this.drawActiveVisualization()); diff --git a/modules/rwallet/local-first-client.ts b/modules/rwallet/local-first-client.ts new file mode 100644 index 0000000..2d5b1f2 --- /dev/null +++ b/modules/rwallet/local-first-client.ts @@ -0,0 +1,121 @@ +/** + * rWallet Local-First Client + * + * Wraps the shared local-first stack for shared treasury management. + */ + +import { DocumentManager } from '../../shared/local-first/document'; +import type { DocumentId } from '../../shared/local-first/document'; +import { EncryptedDocStore } from '../../shared/local-first/storage'; +import { DocSyncManager } from '../../shared/local-first/sync'; +import { DocCrypto } from '../../shared/local-first/crypto'; +import { walletSchema, walletDocId } from './schemas'; +import type { WalletDoc, WatchedAddress, TxAnnotation } from './schemas'; + +export class WalletLocalFirstClient { + #space: string; + #documents: DocumentManager; + #store: EncryptedDocStore; + #sync: DocSyncManager; + #initialized = false; + + constructor(space: string, docCrypto?: DocCrypto) { + this.#space = space; + this.#documents = new DocumentManager(); + this.#store = new EncryptedDocStore(space, docCrypto); + this.#sync = new DocSyncManager({ + documents: this.#documents, + store: this.#store, + }); + this.#documents.registerSchema(walletSchema); + } + + get isConnected(): boolean { return this.#sync.isConnected; } + + async init(): Promise { + if (this.#initialized) return; + await this.#store.open(); + const cachedIds = await this.#store.listByModule('wallet', 'treasury'); + const cached = await this.#store.loadMany(cachedIds); + for (const [docId, binary] of cached) { + this.#documents.open(docId, walletSchema, binary); + } + await this.#sync.preloadSyncStates(cachedIds); + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${proto}//${location.host}/ws/${this.#space}`; + try { await this.#sync.connect(wsUrl, this.#space); } catch { console.warn('[WalletClient] Working offline'); } + this.#initialized = true; + } + + async subscribe(): Promise { + const docId = walletDocId(this.#space) as DocumentId; + let doc = this.#documents.get(docId); + if (!doc) { + const binary = await this.#store.load(docId); + doc = binary + ? this.#documents.open(docId, walletSchema, binary) + : this.#documents.open(docId, walletSchema); + } + await this.#sync.subscribe([docId]); + return doc ?? null; + } + + getDoc(): WalletDoc | undefined { + return this.#documents.get(walletDocId(this.#space) as DocumentId); + } + + onChange(cb: (doc: WalletDoc) => void): () => void { + return this.#sync.onChange(walletDocId(this.#space) as DocumentId, cb as (doc: any) => void); + } + + onConnect(cb: () => void): () => void { return this.#sync.onConnect(cb); } + + // ── Watchlist CRUD ── + + addWatchAddress(address: string, chain: string, label: string, addedBy: string | null): void { + const docId = walletDocId(this.#space) as DocumentId; + const key = `${chain}:${address.toLowerCase()}`; + this.#sync.change(docId, `Watch ${label || address}`, (d) => { + d.watchedAddresses[key] = { address, chain, label, addedBy, addedAt: Date.now() }; + }); + } + + removeWatchAddress(key: string): void { + const docId = walletDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Unwatch address`, (d) => { + delete d.watchedAddresses[key]; + }); + } + + // ── Annotations ── + + setAnnotation(txHash: string, note: string, authorDid: string): void { + const docId = walletDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Annotate tx`, (d) => { + d.annotations[txHash] = { txHash, note, authorDid, createdAt: Date.now() }; + }); + } + + removeAnnotation(txHash: string): void { + const docId = walletDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Remove annotation`, (d) => { + delete d.annotations[txHash]; + }); + } + + // ── Dashboard config ── + + updateConfig(config: Partial<{ defaultChain: string; displayCurrency: string; layout: string }>): void { + const docId = walletDocId(this.#space) as DocumentId; + this.#sync.change(docId, `Update config`, (d) => { + if (config.defaultChain) d.dashboardConfig.defaultChain = config.defaultChain; + if (config.displayCurrency) d.dashboardConfig.displayCurrency = config.displayCurrency; + if (config.layout) d.dashboardConfig.layout = config.layout; + }); + } + + async disconnect(): Promise { + await this.#sync.flush(); + this.#sync.disconnect(); + } +} diff --git a/modules/rwallet/schemas.ts b/modules/rwallet/schemas.ts new file mode 100644 index 0000000..9a00948 --- /dev/null +++ b/modules/rwallet/schemas.ts @@ -0,0 +1,83 @@ +/** + * rWallet Automerge document schemas. + * + * Stores shared watchlist addresses, transaction annotations, + * and dashboard configuration synced across space members. + * + * DocId format: {space}:wallet:treasury + */ + +import type { DocSchema } from '../../shared/local-first/document'; + +// ── Document types ── + +export interface WatchedAddress { + address: string; + chain: string; + label: string; + addedBy: string | null; + addedAt: number; +} + +export interface TxAnnotation { + txHash: string; + note: string; + authorDid: string; + createdAt: number; +} + +export interface DashboardConfig { + defaultChain: string; + displayCurrency: string; + layout: string; +} + +export interface WalletDoc { + meta: { + module: string; + collection: string; + version: number; + spaceSlug: string; + createdAt: number; + }; + watchedAddresses: Record; + annotations: Record; + dashboardConfig: DashboardConfig; +} + +// ── Schema registration ── + +export const walletSchema: DocSchema = { + module: 'wallet', + collection: 'treasury', + version: 1, + init: (): WalletDoc => ({ + meta: { + module: 'wallet', + collection: 'treasury', + version: 1, + spaceSlug: '', + createdAt: Date.now(), + }, + watchedAddresses: {}, + annotations: {}, + dashboardConfig: { + defaultChain: '1', + displayCurrency: 'USD', + layout: 'grid', + }, + }), + migrate: (doc: any, _fromVersion: number) => { + if (!doc.watchedAddresses) doc.watchedAddresses = {}; + if (!doc.annotations) doc.annotations = {}; + if (!doc.dashboardConfig) doc.dashboardConfig = { defaultChain: '1', displayCurrency: 'USD', layout: 'grid' }; + doc.meta.version = 1; + return doc; + }, +}; + +// ── Helpers ── + +export function walletDocId(space: string) { + return `${space}:wallet:treasury` as const; +}