feat(observer): M-Obs 4-7 — widgets, dashboard, docs, integration example
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
M-Obs 4: @shade/widgets React library - ShadeProvider context with observer URL + token + theme - useShadeState (polling) + useShadeEvents (SSE) hooks - 7 widgets: IdentityCard, SessionList, PrekeyStock, RecentActivity, ServerStatus, FingerprintCompare, WidgetCatalog (meta-widget for user-selectable layout with localStorage persistence) - Self-contained CSS via inline styles, no external CSS conflicts - Light/dark/auto theme via tokens M-Obs 5: @shade/dashboard standalone SPA - Vite + React app composing all widgets into a full debugger layout - Login screen with token persistence to localStorage - Build script copies dist/ to @shade/observer/dist/ for embedded serving - 211 KB JS bundle (66 KB gzipped) M-Obs 6: Documentation + integration example - READMEs for @shade/observer and @shade/widgets - examples/06-observer-dashboard runnable demo: spins up prekey server + observer, runs Alice ↔ Bob conversation loop, dashboard at :3901 - Updated root README and docker-compose.yml with observer integration M-Obs 7: End-to-end verification - StateAggregator now drains buffered events on subscription, so identity.initialized fires before observer construction are still seen - Verified live: snapshot endpoint returns full state, dashboard serves, 401 without auth, sessions/messages/ratchet steps all tracked 220 tests passing, 0 failures. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
67
packages/shade-widgets/src/ShadeProvider.tsx
Normal file
67
packages/shade-widgets/src/ShadeProvider.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import { resolveTheme, type ShadeTheme, type ThemeMode } from './theme.js';
|
||||
|
||||
export interface ShadeContextValue {
|
||||
observerUrl: string;
|
||||
token: string;
|
||||
theme: ShadeTheme;
|
||||
pollIntervalMs: number;
|
||||
}
|
||||
|
||||
const ShadeContext = createContext<ShadeContextValue | null>(null);
|
||||
|
||||
export interface ShadeProviderProps {
|
||||
/** Base URL of the Shade observer endpoint, e.g. "https://shade.example.com/shade-observer" */
|
||||
observerUrl: string;
|
||||
/** Bearer token for the observer (matches SHADE_OBSERVER_TOKEN on the server) */
|
||||
token: string;
|
||||
/** Theme mode: dark, light, or auto (matches system preference). Default: dark. */
|
||||
themeMode?: ThemeMode;
|
||||
/** How often to poll /api/state in milliseconds. Default: 5000. */
|
||||
pollIntervalMs?: number;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* ShadeProvider — root context provider that all Shade widgets need.
|
||||
*
|
||||
* Wrap your dashboard or specific widget container with this and pass
|
||||
* the observer URL + token. All child widgets will share the connection.
|
||||
*
|
||||
* ```tsx
|
||||
* <ShadeProvider observerUrl="https://x.com/shade-observer" token={process.env.TOKEN!}>
|
||||
* <SessionList />
|
||||
* <PrekeyStock />
|
||||
* </ShadeProvider>
|
||||
* ```
|
||||
*/
|
||||
export function ShadeProvider({
|
||||
observerUrl,
|
||||
token,
|
||||
themeMode = 'dark',
|
||||
pollIntervalMs = 5000,
|
||||
children,
|
||||
}: ShadeProviderProps): React.ReactElement {
|
||||
const value = useMemo<ShadeContextValue>(
|
||||
() => ({
|
||||
observerUrl: observerUrl.replace(/\/$/, ''),
|
||||
token,
|
||||
theme: resolveTheme(themeMode),
|
||||
pollIntervalMs,
|
||||
}),
|
||||
[observerUrl, token, themeMode, pollIntervalMs],
|
||||
);
|
||||
|
||||
return <ShadeContext.Provider value={value}>{children}</ShadeContext.Provider>;
|
||||
}
|
||||
|
||||
/** Internal hook used by widgets to access the context. Throws if no provider. */
|
||||
export function useShadeContext(): ShadeContextValue {
|
||||
const ctx = useContext(ShadeContext);
|
||||
if (!ctx) {
|
||||
throw new Error(
|
||||
'Shade widgets must be wrapped in <ShadeProvider observerUrl="..." token="..." />',
|
||||
);
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
60
packages/shade-widgets/src/components/FingerprintCompare.tsx
Normal file
60
packages/shade-widgets/src/components/FingerprintCompare.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useShadeState } from '../useShadeState.js';
|
||||
import { useShadeContext } from '../ShadeProvider.js';
|
||||
import { WidgetShell } from './shared.js';
|
||||
|
||||
/**
|
||||
* FingerprintCompare — paste a fingerprint and check if it matches your own
|
||||
* or any active session.
|
||||
*/
|
||||
export function FingerprintCompare(): React.ReactElement {
|
||||
const { state } = useShadeState();
|
||||
const { theme } = useShadeContext();
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const normalized = input.replace(/\s+/g, ' ').trim();
|
||||
const yourFp = state?.identity.fingerprint?.replace(/\s+/g, ' ').trim();
|
||||
const matches = normalized && yourFp && normalized === yourFp;
|
||||
|
||||
return (
|
||||
<WidgetShell title="Verify fingerprint">
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<textarea
|
||||
placeholder="Paste a 60-digit safety number to compare with your own identity"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
background: theme.bg,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: 6,
|
||||
padding: 10,
|
||||
fontFamily: theme.fontMono,
|
||||
fontSize: 12,
|
||||
color: theme.text,
|
||||
resize: 'vertical',
|
||||
outline: 'none',
|
||||
boxSizing: 'border-box',
|
||||
}}
|
||||
/>
|
||||
{normalized && (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
borderRadius: 6,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
border: `1px solid ${matches ? theme.success : theme.danger}`,
|
||||
background: matches ? 'rgba(34, 197, 94, 0.08)' : 'rgba(239, 68, 68, 0.08)',
|
||||
color: matches ? theme.success : theme.danger,
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
{matches ? '✓ Matches your identity' : '✗ Does not match your identity'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
69
packages/shade-widgets/src/components/IdentityCard.tsx
Normal file
69
packages/shade-widgets/src/components/IdentityCard.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import React from 'react';
|
||||
import { useShadeState } from '../useShadeState.js';
|
||||
import { useShadeContext } from '../ShadeProvider.js';
|
||||
import { WidgetShell, formatRelative } from './shared.js';
|
||||
|
||||
/**
|
||||
* IdentityCard — shows your own Shade identity: fingerprint, registration ID,
|
||||
* when it was initialized and last rotated.
|
||||
*/
|
||||
export function IdentityCard(): React.ReactElement {
|
||||
const { state, loading, error } = useShadeState();
|
||||
const { theme } = useShadeContext();
|
||||
|
||||
return (
|
||||
<WidgetShell title="Identity">
|
||||
{loading && !state ? (
|
||||
<div style={{ color: theme.textMuted, fontSize: 13 }}>Loading…</div>
|
||||
) : error ? (
|
||||
<div style={{ color: theme.danger, fontSize: 13 }}>{error.message}</div>
|
||||
) : !state?.identity.fingerprint ? (
|
||||
<div style={{ color: theme.textMuted, fontSize: 13 }}>No identity initialized yet</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
fontFamily: theme.fontMono,
|
||||
fontSize: 12,
|
||||
color: theme.text,
|
||||
wordBreak: 'break-all',
|
||||
padding: 12,
|
||||
background: theme.bg,
|
||||
borderRadius: 6,
|
||||
border: `1px solid ${theme.border}`,
|
||||
lineHeight: 1.6,
|
||||
}}
|
||||
>
|
||||
{state.identity.fingerprint}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
marginTop: 12,
|
||||
fontSize: 11,
|
||||
color: theme.textMuted,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<span style={{ color: theme.textDim }}>Reg ID</span>{' '}
|
||||
<span style={{ fontFamily: theme.fontMono, color: theme.text }}>
|
||||
{state.identity.registrationId}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span style={{ color: theme.textDim }}>Initialized</span>{' '}
|
||||
{formatRelative(state.identity.lastInitialized)}
|
||||
</div>
|
||||
{state.identity.lastRotated && (
|
||||
<div>
|
||||
<span style={{ color: theme.textDim }}>Rotated</span>{' '}
|
||||
{formatRelative(state.identity.lastRotated)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
64
packages/shade-widgets/src/components/PrekeyStock.tsx
Normal file
64
packages/shade-widgets/src/components/PrekeyStock.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import React from 'react';
|
||||
import { useShadeState } from '../useShadeState.js';
|
||||
import { useShadeContext } from '../ShadeProvider.js';
|
||||
import { WidgetShell, formatRelative } from './shared.js';
|
||||
|
||||
/**
|
||||
* PrekeyStock — gauge showing one-time prekey count + signed prekey info.
|
||||
*/
|
||||
export function PrekeyStock({ lowThreshold = 5 }: { lowThreshold?: number }): React.ReactElement {
|
||||
const { state } = useShadeState();
|
||||
const { theme } = useShadeContext();
|
||||
|
||||
const remaining = state?.prekeys.oneTimeRemaining ?? 0;
|
||||
const isLow = remaining < lowThreshold;
|
||||
const color = isLow ? theme.warning : theme.success;
|
||||
|
||||
return (
|
||||
<WidgetShell title="Prekey stock">
|
||||
<div style={{ display: 'flex', alignItems: 'baseline', gap: 8, marginBottom: 12 }}>
|
||||
<div style={{ fontSize: 36, fontWeight: 700, color, fontFamily: theme.fontMono, lineHeight: 1 }}>
|
||||
{remaining}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: theme.textMuted }}>one-time prekeys</div>
|
||||
</div>
|
||||
{isLow && (
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: theme.warning,
|
||||
background: 'rgba(234, 179, 8, 0.1)',
|
||||
border: `1px solid ${theme.warning}`,
|
||||
borderRadius: 4,
|
||||
padding: '6px 8px',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
Low — replenish recommended (threshold: {lowThreshold})
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6, fontSize: 11, color: theme.textMuted }}>
|
||||
{state?.prekeys.signedPreKeyId != null && (
|
||||
<div>
|
||||
<span style={{ color: theme.textDim }}>Signed prekey ID</span>{' '}
|
||||
<span style={{ fontFamily: theme.fontMono, color: theme.text }}>
|
||||
{state.prekeys.signedPreKeyId}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{state?.prekeys.lastGenerated && (
|
||||
<div>
|
||||
<span style={{ color: theme.textDim }}>Last generated</span>{' '}
|
||||
{formatRelative(state.prekeys.lastGenerated)}
|
||||
</div>
|
||||
)}
|
||||
{state?.prekeys.lastConsumed && (
|
||||
<div>
|
||||
<span style={{ color: theme.textDim }}>Last consumed</span>{' '}
|
||||
{formatRelative(state.prekeys.lastConsumed)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
132
packages/shade-widgets/src/components/RecentActivity.tsx
Normal file
132
packages/shade-widgets/src/components/RecentActivity.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import React from 'react';
|
||||
import { useShadeEvents, type ShadeEventEnvelope } from '../useShadeEvents.js';
|
||||
import { useShadeContext } from '../ShadeProvider.js';
|
||||
import { WidgetShell, ConnectionStatus, formatRelative } from './shared.js';
|
||||
|
||||
/**
|
||||
* RecentActivity — live feed of all events flowing through the system.
|
||||
*/
|
||||
export function RecentActivity({ limit = 50 }: { limit?: number }): React.ReactElement {
|
||||
const { events, connected } = useShadeEvents(limit);
|
||||
const { theme } = useShadeContext();
|
||||
|
||||
// Show newest first
|
||||
const sorted = [...events].reverse();
|
||||
|
||||
return (
|
||||
<WidgetShell title="Recent activity" status={<ConnectionStatus connected={connected} />}>
|
||||
{sorted.length === 0 ? (
|
||||
<div style={{ color: theme.textMuted, fontSize: 13, padding: '16px 0', textAlign: 'center' }}>
|
||||
{connected ? 'Waiting for events…' : 'Not connected'}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 4,
|
||||
maxHeight: 320,
|
||||
overflowY: 'auto',
|
||||
}}
|
||||
>
|
||||
{sorted.map((e) => (
|
||||
<EventRow key={`${e.source}-${e.seq}`} event={e} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
|
||||
function EventRow({ event }: { event: ShadeEventEnvelope }): React.ReactElement {
|
||||
const { theme } = useShadeContext();
|
||||
const color = colorForEvent(event.name, theme);
|
||||
const summary = summaryForEvent(event);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '60px 1fr auto',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
padding: '6px 8px',
|
||||
background: theme.bg,
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
<span style={{ color: theme.textDim, fontFamily: theme.fontMono, fontSize: 10 }}>
|
||||
{formatRelative(event.timestamp)}
|
||||
</span>
|
||||
<span style={{ color, fontFamily: theme.fontMono, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
<strong>{event.name}</strong> <span style={{ color: theme.textMuted }}>{summary}</span>
|
||||
</span>
|
||||
<span
|
||||
style={{
|
||||
fontSize: 9,
|
||||
color: theme.textDim,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
padding: '1px 4px',
|
||||
background: theme.bgElevated,
|
||||
borderRadius: 2,
|
||||
}}
|
||||
>
|
||||
{event.source}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function colorForEvent(name: string, theme: any): string {
|
||||
if (name.startsWith('message.encrypted')) return theme.accent;
|
||||
if (name.startsWith('message.decrypted')) return theme.success;
|
||||
if (name.startsWith('ratchet')) return theme.accent;
|
||||
if (name.startsWith('identity')) return theme.warning;
|
||||
if (name.startsWith('trust')) return theme.warning;
|
||||
if (name.startsWith('server.rate_limited')) return theme.danger;
|
||||
return theme.textMuted;
|
||||
}
|
||||
|
||||
function summaryForEvent(e: ShadeEventEnvelope): string {
|
||||
const d = e.data;
|
||||
switch (e.name) {
|
||||
case 'identity.initialized':
|
||||
return `reg ${d.registrationId}`;
|
||||
case 'identity.rotated':
|
||||
return 'new identity';
|
||||
case 'session.created':
|
||||
return `${d.address} (${d.remoteIdentityKeyHash})`;
|
||||
case 'session.removed':
|
||||
return `${d.address}`;
|
||||
case 'message.encrypted':
|
||||
return `${d.address} #${d.counter} (${d.ciphertextSize}b)`;
|
||||
case 'message.decrypted':
|
||||
return `${d.address} #${d.counter} (${d.plaintextSize}b)`;
|
||||
case 'ratchet.dh_step':
|
||||
return `${d.address}`;
|
||||
case 'prekey.generated':
|
||||
return `+${d.count} (now ${d.totalAfter})`;
|
||||
case 'prekey.consumed':
|
||||
return `id ${d.keyId}`;
|
||||
case 'signed_prekey.rotated':
|
||||
return `${d.oldKeyId} → ${d.newKeyId}`;
|
||||
case 'trust.pinned':
|
||||
return `${d.address} (${d.identityKeyHash})`;
|
||||
case 'trust.changed':
|
||||
return `${d.address}`;
|
||||
case 'server.identity_registered':
|
||||
return `${d.address}`;
|
||||
case 'server.bundle_fetched':
|
||||
return `${d.address}${d.hadOneTimePreKey ? ' (with OTPK)' : ''}`;
|
||||
case 'server.prekeys_replenished':
|
||||
return `${d.address} +${d.count} (now ${d.totalAfter})`;
|
||||
case 'server.identity_deleted':
|
||||
return `${d.address}`;
|
||||
case 'server.rate_limited':
|
||||
return `${d.route}`;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
79
packages/shade-widgets/src/components/ServerStatus.tsx
Normal file
79
packages/shade-widgets/src/components/ServerStatus.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { useShadeState } from '../useShadeState.js';
|
||||
import { useShadeContext } from '../ShadeProvider.js';
|
||||
import { WidgetShell } from './shared.js';
|
||||
|
||||
/**
|
||||
* ServerStatus — prekey server stats: registered identities, fetches, etc.
|
||||
*/
|
||||
export function ServerStatus(): React.ReactElement {
|
||||
const { state } = useShadeState();
|
||||
const { theme } = useShadeContext();
|
||||
|
||||
const stats = state?.server;
|
||||
|
||||
return (
|
||||
<WidgetShell title="Prekey server">
|
||||
{!stats ? (
|
||||
<div style={{ color: theme.textMuted, fontSize: 13 }}>Loading…</div>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<Stat label="Registered" value={stats.registeredIdentities.length} theme={theme} />
|
||||
<Stat label="Bundle fetches" value={stats.totalBundleFetches} theme={theme} />
|
||||
<Stat label="Replenishes" value={stats.totalReplenishes} theme={theme} />
|
||||
<Stat
|
||||
label="Rate limited"
|
||||
value={stats.totalRateLimited}
|
||||
theme={theme}
|
||||
color={stats.totalRateLimited > 0 ? theme.danger : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Stat({
|
||||
label,
|
||||
value,
|
||||
theme,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
theme: any;
|
||||
color?: string;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 10,
|
||||
background: theme.bg,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 10, color: theme.textDim, textTransform: 'uppercase', letterSpacing: '0.05em' }}>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: color ?? theme.text,
|
||||
fontFamily: theme.fontMono,
|
||||
marginTop: 2,
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{value.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
88
packages/shade-widgets/src/components/SessionList.tsx
Normal file
88
packages/shade-widgets/src/components/SessionList.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import { useShadeState } from '../useShadeState.js';
|
||||
import { useShadeContext } from '../ShadeProvider.js';
|
||||
import { WidgetShell, formatRelative } from './shared.js';
|
||||
|
||||
/**
|
||||
* SessionList — table of all active Shade sessions with per-session stats.
|
||||
*/
|
||||
export function SessionList(): React.ReactElement {
|
||||
const { state, loading, error } = useShadeState();
|
||||
const { theme } = useShadeContext();
|
||||
|
||||
return (
|
||||
<WidgetShell
|
||||
title="Sessions"
|
||||
status={
|
||||
<span style={{ fontSize: 11, color: theme.textMuted }}>
|
||||
{state?.sessions.length ?? 0} active
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{loading && !state ? (
|
||||
<div style={{ color: theme.textMuted, fontSize: 13 }}>Loading…</div>
|
||||
) : error ? (
|
||||
<div style={{ color: theme.danger, fontSize: 13 }}>{error.message}</div>
|
||||
) : !state?.sessions.length ? (
|
||||
<div style={{ color: theme.textMuted, fontSize: 13, padding: '16px 0', textAlign: 'center' }}>
|
||||
No active sessions
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{state.sessions.map((s) => (
|
||||
<div
|
||||
key={s.address}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: 10,
|
||||
background: theme.bg,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: 6,
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: theme.text,
|
||||
fontWeight: 500,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{s.address}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
color: theme.textDim,
|
||||
fontFamily: theme.fontMono,
|
||||
marginTop: 2,
|
||||
}}
|
||||
>
|
||||
{s.remoteIdentityKeyHash}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 12, fontSize: 11, color: theme.textMuted, flexShrink: 0 }}>
|
||||
<div title="Messages sent">
|
||||
<span style={{ color: theme.accent }}>↑</span> {s.messageCountSent}
|
||||
</div>
|
||||
<div title="Messages received">
|
||||
<span style={{ color: theme.accent }}>↓</span> {s.messageCountReceived}
|
||||
</div>
|
||||
<div title="DH ratchet steps">
|
||||
<span style={{ color: theme.textDim }}>⟳</span> {s.dhRatchetSteps}
|
||||
</div>
|
||||
<div style={{ minWidth: 60, textAlign: 'right' }}>{formatRelative(s.lastActivity)}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</WidgetShell>
|
||||
);
|
||||
}
|
||||
156
packages/shade-widgets/src/components/WidgetCatalog.tsx
Normal file
156
packages/shade-widgets/src/components/WidgetCatalog.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useShadeContext } from '../ShadeProvider.js';
|
||||
import { IdentityCard } from './IdentityCard.js';
|
||||
import { SessionList } from './SessionList.js';
|
||||
import { PrekeyStock } from './PrekeyStock.js';
|
||||
import { RecentActivity } from './RecentActivity.js';
|
||||
import { ServerStatus } from './ServerStatus.js';
|
||||
import { FingerprintCompare } from './FingerprintCompare.js';
|
||||
|
||||
/**
|
||||
* WidgetCatalog — meta-widget that lets the user pick which Shade widgets
|
||||
* to display in their dashboard. Selection is persisted to localStorage
|
||||
* (or via onLayoutChange callback).
|
||||
*/
|
||||
|
||||
export type WidgetKey = 'identity' | 'sessions' | 'prekeys' | 'activity' | 'server' | 'fingerprint';
|
||||
|
||||
const ALL_WIDGETS: Record<WidgetKey, { label: string; component: React.ComponentType }> = {
|
||||
identity: { label: 'Identity', component: IdentityCard },
|
||||
sessions: { label: 'Sessions', component: SessionList },
|
||||
prekeys: { label: 'Prekey stock', component: PrekeyStock },
|
||||
activity: { label: 'Recent activity', component: RecentActivity },
|
||||
server: { label: 'Server status', component: ServerStatus },
|
||||
fingerprint: { label: 'Verify fingerprint', component: FingerprintCompare },
|
||||
};
|
||||
|
||||
export interface WidgetCatalogProps {
|
||||
/** Which widgets are available for selection. Default: all. */
|
||||
available?: WidgetKey[];
|
||||
/** Initial layout if nothing in localStorage. Default: identity, sessions, activity. */
|
||||
defaultLayout?: WidgetKey[];
|
||||
/** Optional callback when user changes the layout (for persistence) */
|
||||
onLayoutChange?: (layout: WidgetKey[]) => void;
|
||||
/** localStorage key to persist layout. Default: "shade-widget-layout" */
|
||||
storageKey?: string;
|
||||
}
|
||||
|
||||
export function WidgetCatalog({
|
||||
available = Object.keys(ALL_WIDGETS) as WidgetKey[],
|
||||
defaultLayout = ['identity', 'sessions', 'activity'],
|
||||
onLayoutChange,
|
||||
storageKey = 'shade-widget-layout',
|
||||
}: WidgetCatalogProps): React.ReactElement {
|
||||
const { theme } = useShadeContext();
|
||||
const [layout, setLayout] = useState<WidgetKey[]>(() => {
|
||||
if (typeof window === 'undefined') return defaultLayout;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(storageKey);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as WidgetKey[];
|
||||
if (Array.isArray(parsed)) return parsed.filter((k) => available.includes(k));
|
||||
}
|
||||
} catch {}
|
||||
return defaultLayout;
|
||||
});
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, JSON.stringify(layout));
|
||||
} catch {}
|
||||
onLayoutChange?.(layout);
|
||||
}, [layout, onLayoutChange, storageKey]);
|
||||
|
||||
function toggle(key: WidgetKey): void {
|
||||
setLayout((current) => {
|
||||
if (current.includes(key)) {
|
||||
return current.filter((k) => k !== key);
|
||||
}
|
||||
return [...current, key];
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="shade-widget-catalog" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: 12, color: theme.textMuted }}>
|
||||
{layout.length} widget{layout.length !== 1 ? 's' : ''} active
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setEditing(!editing)}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: `1px solid ${theme.border}`,
|
||||
color: theme.text,
|
||||
borderRadius: theme.radius,
|
||||
padding: '4px 12px',
|
||||
fontSize: 11,
|
||||
fontFamily: theme.font,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{editing ? 'Done' : 'Customize'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{editing && (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(140px, 1fr))',
|
||||
gap: 8,
|
||||
padding: 12,
|
||||
background: theme.bgElevated,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: theme.radius,
|
||||
}}
|
||||
>
|
||||
{available.map((key) => {
|
||||
const isActive = layout.includes(key);
|
||||
return (
|
||||
<label
|
||||
key={key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: '6px 8px',
|
||||
background: isActive ? theme.accentMuted : theme.bg,
|
||||
border: `1px solid ${isActive ? theme.accent : theme.border}`,
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
color: isActive ? theme.accent : theme.text,
|
||||
fontFamily: theme.font,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isActive}
|
||||
onChange={() => toggle(key)}
|
||||
style={{ accentColor: theme.accent }}
|
||||
/>
|
||||
{ALL_WIDGETS[key].label}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
{layout.map((key) => {
|
||||
const W = ALL_WIDGETS[key].component;
|
||||
return <W key={key} />;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
97
packages/shade-widgets/src/components/shared.tsx
Normal file
97
packages/shade-widgets/src/components/shared.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
import { useShadeContext } from '../ShadeProvider.js';
|
||||
import type { ShadeTheme } from '../theme.js';
|
||||
|
||||
/**
|
||||
* Common widget shell — provides consistent border, padding, header.
|
||||
* All widgets render inside one of these for visual consistency.
|
||||
*/
|
||||
export interface WidgetShellProps {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
status?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WidgetShell({ title, icon, status, children, className }: WidgetShellProps): React.ReactElement {
|
||||
const { theme } = useShadeContext();
|
||||
return (
|
||||
<div
|
||||
className={`shade-widget ${className ?? ''}`}
|
||||
style={{
|
||||
background: theme.bgElevated,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: theme.radius,
|
||||
padding: 16,
|
||||
fontFamily: theme.font,
|
||||
color: theme.text,
|
||||
boxShadow: theme.shadow,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 12,
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
|
||||
{icon}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 11,
|
||||
fontWeight: 600,
|
||||
letterSpacing: '0.06em',
|
||||
textTransform: 'uppercase',
|
||||
color: theme.accent,
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
{status}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatRelative(ts: number | null): string {
|
||||
if (!ts) return 'never';
|
||||
const diff = Date.now() - ts;
|
||||
if (diff < 1000) return 'just now';
|
||||
if (diff < 60_000) return `${Math.floor(diff / 1000)}s ago`;
|
||||
if (diff < 3600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86400_000) return `${Math.floor(diff / 3600_000)}h ago`;
|
||||
return `${Math.floor(diff / 86400_000)}d ago`;
|
||||
}
|
||||
|
||||
export function StatusDot({ color }: { color: string }): React.ReactElement {
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: '50%',
|
||||
background: color,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionStatus({ connected }: { connected: boolean }): React.ReactElement {
|
||||
const { theme } = useShadeContext();
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 11, color: theme.textMuted }}>
|
||||
<StatusDot color={connected ? theme.success : theme.danger} />
|
||||
{connected ? 'live' : 'offline'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
packages/shade-widgets/src/index.ts
Normal file
18
packages/shade-widgets/src/index.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export { ShadeProvider, useShadeContext } from './ShadeProvider.js';
|
||||
export { useShadeState } from './useShadeState.js';
|
||||
export { useShadeEvents } from './useShadeEvents.js';
|
||||
export { darkTheme, lightTheme, resolveTheme } from './theme.js';
|
||||
|
||||
export { IdentityCard } from './components/IdentityCard.js';
|
||||
export { SessionList } from './components/SessionList.js';
|
||||
export { PrekeyStock } from './components/PrekeyStock.js';
|
||||
export { RecentActivity } from './components/RecentActivity.js';
|
||||
export { ServerStatus } from './components/ServerStatus.js';
|
||||
export { FingerprintCompare } from './components/FingerprintCompare.js';
|
||||
export { WidgetCatalog } from './components/WidgetCatalog.js';
|
||||
|
||||
export type { ShadeProviderProps, ShadeContextValue } from './ShadeProvider.js';
|
||||
export type { ShadeState, UseShadeStateResult } from './useShadeState.js';
|
||||
export type { ShadeEventEnvelope, UseShadeEventsResult } from './useShadeEvents.js';
|
||||
export type { ShadeTheme, ThemeMode } from './theme.js';
|
||||
export type { WidgetCatalogProps, WidgetKey } from './components/WidgetCatalog.js';
|
||||
72
packages/shade-widgets/src/theme.ts
Normal file
72
packages/shade-widgets/src/theme.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Theme tokens for Shade widgets.
|
||||
*
|
||||
* Self-contained — no external CSS required. Each widget reads tokens
|
||||
* from the active theme via context.
|
||||
*/
|
||||
|
||||
export interface ShadeTheme {
|
||||
bg: string;
|
||||
bgElevated: string;
|
||||
border: string;
|
||||
text: string;
|
||||
textMuted: string;
|
||||
textDim: string;
|
||||
accent: string;
|
||||
accentMuted: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
danger: string;
|
||||
font: string;
|
||||
fontMono: string;
|
||||
radius: string;
|
||||
shadow: string;
|
||||
}
|
||||
|
||||
export const darkTheme: ShadeTheme = {
|
||||
bg: '#0a0a0a',
|
||||
bgElevated: '#161616',
|
||||
border: '#262626',
|
||||
text: '#e5e5e5',
|
||||
textMuted: '#a3a3a3',
|
||||
textDim: '#525252',
|
||||
accent: '#f7c948',
|
||||
accentMuted: 'rgba(247, 201, 72, 0.15)',
|
||||
success: '#22c55e',
|
||||
warning: '#eab308',
|
||||
danger: '#ef4444',
|
||||
font: 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif',
|
||||
fontMono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
radius: '8px',
|
||||
shadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||
};
|
||||
|
||||
export const lightTheme: ShadeTheme = {
|
||||
bg: '#ffffff',
|
||||
bgElevated: '#f5f5f5',
|
||||
border: '#e5e5e5',
|
||||
text: '#0a0a0a',
|
||||
textMuted: '#525252',
|
||||
textDim: '#a3a3a3',
|
||||
accent: '#ca8a04',
|
||||
accentMuted: 'rgba(202, 138, 4, 0.1)',
|
||||
success: '#16a34a',
|
||||
warning: '#ca8a04',
|
||||
danger: '#dc2626',
|
||||
font: 'system-ui, -apple-system, Segoe UI, Roboto, sans-serif',
|
||||
fontMono: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
|
||||
radius: '8px',
|
||||
shadow: '0 4px 12px rgba(0, 0, 0, 0.08)',
|
||||
};
|
||||
|
||||
export type ThemeMode = 'dark' | 'light' | 'auto';
|
||||
|
||||
export function resolveTheme(mode: ThemeMode): ShadeTheme {
|
||||
if (mode === 'dark') return darkTheme;
|
||||
if (mode === 'light') return lightTheme;
|
||||
// auto: use prefers-color-scheme
|
||||
if (typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: light)').matches) {
|
||||
return lightTheme;
|
||||
}
|
||||
return darkTheme;
|
||||
}
|
||||
62
packages/shade-widgets/src/useShadeEvents.ts
Normal file
62
packages/shade-widgets/src/useShadeEvents.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useShadeContext } from './ShadeProvider.js';
|
||||
|
||||
export interface ShadeEventEnvelope {
|
||||
source: 'client' | 'server';
|
||||
seq: number;
|
||||
timestamp: number;
|
||||
name: string;
|
||||
data: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface UseShadeEventsResult {
|
||||
events: ShadeEventEnvelope[];
|
||||
connected: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that subscribes to /api/events SSE stream and accumulates events.
|
||||
*
|
||||
* @param maxBuffer Max number of events to keep in memory (default: 200).
|
||||
* Older events are dropped.
|
||||
*/
|
||||
export function useShadeEvents(maxBuffer = 200): UseShadeEventsResult {
|
||||
const ctx = useShadeContext();
|
||||
const [events, setEvents] = useState<ShadeEventEnvelope[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const eventSourceRef = useRef<EventSource | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const url = `${ctx.observerUrl}/api/events?token=${encodeURIComponent(ctx.token)}`;
|
||||
const es = new EventSource(url);
|
||||
eventSourceRef.current = es;
|
||||
|
||||
es.addEventListener('open', () => setConnected(true));
|
||||
es.addEventListener('error', () => {
|
||||
setConnected(false);
|
||||
setError(new Error('SSE connection error'));
|
||||
});
|
||||
|
||||
es.addEventListener('shade', (msg) => {
|
||||
try {
|
||||
const event = JSON.parse((msg as MessageEvent).data) as ShadeEventEnvelope;
|
||||
setEvents((prev) => {
|
||||
const next = [...prev, event];
|
||||
return next.length > maxBuffer ? next.slice(-maxBuffer) : next;
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Shade] Failed to parse event:', err);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
eventSourceRef.current = null;
|
||||
setConnected(false);
|
||||
};
|
||||
}, [ctx.observerUrl, ctx.token, maxBuffer]);
|
||||
|
||||
return { events, connected, error };
|
||||
}
|
||||
75
packages/shade-widgets/src/useShadeState.ts
Normal file
75
packages/shade-widgets/src/useShadeState.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useShadeContext } from './ShadeProvider.js';
|
||||
|
||||
export interface ShadeState {
|
||||
identity: {
|
||||
fingerprint: string | null;
|
||||
registrationId: number | null;
|
||||
lastInitialized: number | null;
|
||||
lastRotated: number | null;
|
||||
};
|
||||
sessions: Array<{
|
||||
address: string;
|
||||
remoteIdentityKeyHash: string;
|
||||
messageCountSent: number;
|
||||
messageCountReceived: number;
|
||||
lastActivity: number;
|
||||
dhRatchetSteps: number;
|
||||
}>;
|
||||
prekeys: {
|
||||
oneTimeRemaining: number;
|
||||
lastGenerated: number | null;
|
||||
lastConsumed: number | null;
|
||||
signedPreKeyId: number | null;
|
||||
signedPreKeyLastRotated: number | null;
|
||||
};
|
||||
retiredIdentities: number;
|
||||
server: {
|
||||
registeredIdentities: string[];
|
||||
totalBundleFetches: number;
|
||||
totalReplenishes: number;
|
||||
totalDeleted: number;
|
||||
totalRateLimited: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface UseShadeStateResult {
|
||||
state: ShadeState | null;
|
||||
loading: boolean;
|
||||
error: Error | null;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that polls the observer's /api/state endpoint at the configured interval.
|
||||
*/
|
||||
export function useShadeState(): UseShadeStateResult {
|
||||
const ctx = useShadeContext();
|
||||
const [state, setState] = useState<ShadeState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
const fetchState = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(`${ctx.observerUrl}/api/state`, {
|
||||
headers: { Authorization: `Bearer ${ctx.token}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
const json = (await res.json()) as ShadeState;
|
||||
setState(json);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err as Error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [ctx.observerUrl, ctx.token]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchState();
|
||||
const interval = setInterval(fetchState, ctx.pollIntervalMs);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchState, ctx.pollIntervalMs]);
|
||||
|
||||
return { state, loading, error, refresh: fetchState };
|
||||
}
|
||||
Reference in New Issue
Block a user