feat(rwallet): add multiplayer shared watchlist via Automerge CRDT
Shared wallet address watchlist syncs across space members. Click watched address to load it in the visualizer. Transaction annotations and dashboard config schema ready for future use. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
20c4a19e06
commit
c0ae0e9a53
|
|
@ -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 {
|
|||
</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">×</button>
|
||||
</div>`;
|
||||
}).join('')}</div>`}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
private renderTopTabBar(): string {
|
||||
return `
|
||||
<div class="top-tabs">
|
||||
|
|
@ -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<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
|
||||
if (this.activeView !== "balances" && this.activeView !== "yield" && this.hasData()) {
|
||||
requestAnimationFrame(() => this.drawActiveVisualization());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Reference in New Issue