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 { 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">×</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());
|
||||||
|
|
|
||||||
|
|
@ -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