Merge branch 'dev'

This commit is contained in:
Jeff Emmett 2026-03-15 17:39:53 -07:00
commit b8b55054c5
3 changed files with 304 additions and 0 deletions

View File

@ -11,6 +11,8 @@ import type { TimelineEntry, SankeyData, MultichainData } from "../lib/data-tran
import { loadD3, renderTimeline, renderFlowChart, renderSankey } from "../lib/wallet-viz"; 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 { DEMO_TIMELINE_DATA, DEMO_SANKEY_DATA, DEMO_MULTICHAIN_DATA } from "../lib/wallet-demo-data";
import { TourEngine } from "../../../shared/tour-engine"; import { TourEngine } from "../../../shared/tour-engine";
import { WalletLocalFirstClient } from "../local-first-client";
import type { WalletDoc, WatchedAddress } from "../schemas";
interface ChainInfo { interface ChainInfo {
chainId: string; 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 }, { 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() { constructor() {
super(); super();
this.shadow = this.attachShadow({ mode: "open" }); this.shadow = this.attachShadow({ mode: "open" });
@ -215,6 +222,7 @@ class FolkWalletViewer extends HTMLElement {
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
this.address = params.get("address") || ""; this.address = params.get("address") || "";
this.checkAuthState(); this.checkAuthState();
this.initWalletSync(space);
// Yield view is standalone — always force visualizer tab // Yield view is standalone — always force visualizer tab
if (this.activeView === "yield") { if (this.activeView === "yield") {
@ -262,6 +270,41 @@ class FolkWalletViewer extends HTMLElement {
} catch {} } 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 { private getAuthToken(): string | null {
try { try {
const session = localStorage.getItem("encryptid_session"); const session = localStorage.getItem("encryptid_session");
@ -1691,6 +1734,29 @@ class FolkWalletViewer extends HTMLElement {
</div>`; </div>`;
} }
private renderWatchlist(): string {
if (this.watchedAddresses.length === 0 && !this.lfClient) return '';
const isLive = this.lfClient?.isConnected ?? false;
return `
<div class="watchlist" style="margin-top:1.5rem">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:0.75rem">
<span style="font-size:0.75rem;text-transform:uppercase;letter-spacing:0.05em;color:var(--rs-text-muted);font-weight:600">Shared Watchlist</span>
${isLive ? '<span style="font-size:0.65rem;padding:2px 6px;border-radius:999px;background:rgba(34,197,94,0.15);color:#22c55e;font-weight:500">LIVE</span>' : ''}
<button class="watch-add-btn" style="margin-left:auto;font-size:0.75rem;padding:3px 10px;border-radius:6px;border:1px solid var(--rs-border);background:var(--rs-bg-surface);color:var(--rs-text-secondary);cursor:pointer;font-family:inherit" data-action="add-watch">+ Watch</button>
</div>
${this.watchedAddresses.length === 0
? '<div style="text-align:center;padding:1rem;color:var(--rs-text-muted);font-size:0.8rem">No watched addresses. Click "Watch" to add one.</div>'
: `<div style="display:flex;flex-direction:column;gap:4px">${this.watchedAddresses.map(w => {
const key = `${w.chain}:${w.address.toLowerCase()}`;
return `<div class="watch-item" style="display:flex;align-items:center;gap:8px;padding:0.5rem 0.75rem;background:var(--rs-bg-surface);border:1px solid var(--rs-border);border-radius:8px;cursor:pointer" data-watch-address="${w.address}">
<span style="font-weight:600;font-size:0.875rem;color:var(--rs-text-primary);flex:1">${this.esc(w.label || w.address.slice(0, 8) + '...')}</span>
<span style="font-size:0.75rem;color:var(--rs-text-muted)">${w.address.slice(0, 6)}...${w.address.slice(-4)}</span>
<button class="watch-remove" data-watch-key="${key}" style="font-size:0.65rem;color:var(--rs-text-muted);border:none;background:none;cursor:pointer;padding:2px 4px">&times;</button>
</div>`;
}).join('')}</div>`}
</div>`;
}
private renderTopTabBar(): string { private renderTopTabBar(): string {
return ` return `
<div class="top-tabs"> <div class="top-tabs">
@ -2253,6 +2319,7 @@ class FolkWalletViewer extends HTMLElement {
${this.renderFeatures()} ${this.renderFeatures()}
${this.renderExamples()} ${this.renderExamples()}
${this.renderWatchlist()}
${this.renderDashboard()} ${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<HTMLElement>('.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<HTMLElement>('.watch-remove').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const key = btn.dataset.watchKey;
if (key) this.removeFromWatchlist(key);
});
});
// Draw visualization if active // Draw visualization if active
if (this.activeView !== "balances" && this.activeView !== "yield" && this.hasData()) { if (this.activeView !== "balances" && this.activeView !== "yield" && this.hasData()) {
requestAnimationFrame(() => this.drawActiveVisualization()); requestAnimationFrame(() => this.drawActiveVisualization());

View File

@ -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<void> {
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<WalletDoc>(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<WalletDoc | null> {
const docId = walletDocId(this.#space) as DocumentId;
let doc = this.#documents.get<WalletDoc>(docId);
if (!doc) {
const binary = await this.#store.load(docId);
doc = binary
? this.#documents.open<WalletDoc>(docId, walletSchema, binary)
: this.#documents.open<WalletDoc>(docId, walletSchema);
}
await this.#sync.subscribe([docId]);
return doc ?? null;
}
getDoc(): WalletDoc | undefined {
return this.#documents.get<WalletDoc>(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<WalletDoc>(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<WalletDoc>(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<WalletDoc>(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<WalletDoc>(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<WalletDoc>(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<void> {
await this.#sync.flush();
this.#sync.disconnect();
}
}

View File

@ -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<string, WatchedAddress>;
annotations: Record<string, TxAnnotation>;
dashboardConfig: DashboardConfig;
}
// ── Schema registration ──
export const walletSchema: DocSchema<WalletDoc> = {
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;
}