Merge branch 'dev'
This commit is contained in:
commit
b8b55054c5
|
|
@ -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