feat(files): @shade/files 0.3.0 — E2EE filesystem RPC primitive
Some checks failed
Test / test (push) Has been cancelled
Some checks failed
Test / test (push) Has been cancelled
M-Files-1..6 land the full files-RPC layer + everything 0.3.0 needs to
ship. Apps keep their own UI; this layer ships the typed RPC, the
streams bridge for content I/O, and production hooks (rate limit,
retention, fingerprint gate, metrics).
@shade/files (NEW)
- Standard ops: list/stat/mkdir/delete/move/read/write/getThumbnail with
Zod-validated wire schemas + clean user-handler types.
- Custom ops: typed via TypeScript declaration merging on CustomOpsMap
+ per-op Zod schemas; client.custom('app.foo', {...}) is fully typed.
- Content I/O: inline (≤ 256 KiB plaintext) base64-in-RPC; streams
(> 256 KiB) ride @shade/transfer via userMetadata.shadeFilesWriteId
/ shadeFilesReadStreamId correlation. Server-side TransformStream
bridges accept inbound transfers immediately (engine rejects chunks
that arrive before accept) and park the readable for the matching
RPC.
- Directory ops: walk(path, opts) async-iterable depth-first walker;
uploadDirectory()/downloadDirectory() with bounded concurrency pool
(default 4, cap 16), aggregated progress, abort.
- Production hooks (callback-based, vendor-neutral): rate-limit (op +
byte), idempotency cache (LRU + TTL + in-flight de-dupe), path
policy (traversal + percent-decode hardening), fingerprint gate
(required/optional/reject), pluggable Ed25519 sig verification with
±5 min replay window, onMetric sink (standard names).
- React hooks (subpath @shade/files/react): ShadeFilesProvider,
useShadeFiles, useFileList, useFileTransfer/Upload/Download.
- Shade.files.serve(handler) + Shade.files.client(peer) high-level
entrypoint in @shade/sdk; lazy + memoized; one handler per Shade.
Wire format bump
- @shade/proto wire VERSION 0x01 → 0x02. Length prefixes changed from
u16 to u32. The previous u16 silently truncated payloads above
64 KiB — a hard correctness ceiling that blocked inline file ops
up to 256 KiB. Wire-incompatible with 0.2.x peers; new sessions
only. Cross-platform Kotlin port (android/shade-android) updated to
match; test-vectors/wire-format.json regenerated.
Concurrency safety
- ShadeSessionManager.encrypt/.decrypt now run under per-peer mutex.
Concurrent decryptions of the same peer raced ratchet state
(manifested as sporadic "Failed to decrypt — wrong key or tampered
data" under load — surfaced once concurrent uploadDirectory pumped
many writes in flight). Encrypt was already serialized via
Shade.send's encryptChains; decrypt is now serialized at the
manager layer too.
@shade/streams extension
- StreamMetadata.userMetadata?: Record<string, string> for
application-level key/value pairs that round-trip verbatim through
stream-init plaintext. Used by @shade/files for write/read
correlation; available to any consumer.
@shade/sdk extension
- Shade.files getter (lazy + memoized).
- BackgroundHooks.onPruneFiles + periodic timer (default 5 min) +
BackgroundTasks.setHook(name, fn) for runtime hook registration.
Bundles in-flight 0.2.0 work
- packages/shade-streams/, packages/shade-transfer/, related
shade-sdk streams-bridge + shade-widgets transfer hooks were
uncommitted prior to this session. Including them keeps the
workspace consistent at 0.3.0 since @shade/files depends on them.
Tests
- 74 new tests in @shade/files (572 → 646 workspace pass; 0 fail;
3× stable). Coverage spans unit (inline-threshold + concurrency),
integration (read-write inline + streams up to 1 MiB, walk +
upload/download directory, custom-op, metrics, SDK namespace
end-to-end), and security (tampered-envelope sig verification,
replay window, fingerprint gate, rate-limit + quota).
Release artifacts
- All packages bumped to 0.3.0 via scripts/bump-version.ts.
- scripts/publish-all.ts PACKAGES updated with shade-files in
topological order (after shade-transfer, before shade-sdk).
- bun run publish:dry clean (14 packed, 0 failed).
- examples/08-files-browser/ — three-process CLI demo (prekey + Bob
server + Alice CLI) covering list/stat/mkdir/delete/upload/download.
- docs/files.md — full API + design doc.
- CHANGELOG.md 0.3.0 entry.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
65
packages/shade-widgets/src/ShadeRuntimeProvider.tsx
Normal file
65
packages/shade-widgets/src/ShadeRuntimeProvider.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React, { createContext, useContext, useMemo } from 'react';
|
||||
import type { Shade } from '@shade/sdk';
|
||||
import { resolveTheme, type ShadeTheme, type ThemeMode } from './theme.js';
|
||||
|
||||
export interface ShadeRuntimeContextValue {
|
||||
runtime: Shade;
|
||||
theme: ShadeTheme;
|
||||
}
|
||||
|
||||
const ShadeRuntimeContext = createContext<ShadeRuntimeContextValue | null>(null);
|
||||
|
||||
export interface ShadeRuntimeProviderProps {
|
||||
/** Initialized `Shade` instance (after `await shade.initialize()`). */
|
||||
runtime: Shade;
|
||||
/** Theme mode: `'dark'` (default), `'light'`, or `'auto'`. */
|
||||
themeMode?: ThemeMode;
|
||||
/** Optional theme overrides applied on top of the resolved base theme. */
|
||||
themeOverrides?: Partial<ShadeTheme>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* `ShadeRuntimeProvider` — root for the upload/download widget tree.
|
||||
*
|
||||
* Distinct from `ShadeProvider` (which targets the observer dashboard) —
|
||||
* this one wraps an actual `Shade` runtime so transfer hooks/components
|
||||
* can call `shade.upload(...)`, `shade.onIncomingTransfer(...)`, etc.
|
||||
*
|
||||
* Wrap your application root once; multiple uploaders/downloaders share
|
||||
* the same runtime instance.
|
||||
*/
|
||||
export function ShadeRuntimeProvider({
|
||||
runtime,
|
||||
themeMode = 'dark',
|
||||
themeOverrides,
|
||||
children,
|
||||
}: ShadeRuntimeProviderProps): React.ReactElement {
|
||||
const value = useMemo<ShadeRuntimeContextValue>(
|
||||
() => ({ runtime, theme: { ...resolveTheme(themeMode), ...themeOverrides } }),
|
||||
[runtime, themeMode, themeOverrides],
|
||||
);
|
||||
return (
|
||||
<ShadeRuntimeContext.Provider value={value}>{children}</ShadeRuntimeContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useShadeRuntime(): Shade {
|
||||
const ctx = useContext(ShadeRuntimeContext);
|
||||
if (ctx === null) {
|
||||
throw new Error(
|
||||
'useShadeRuntime must be used inside <ShadeRuntimeProvider runtime={shade}>',
|
||||
);
|
||||
}
|
||||
return ctx.runtime;
|
||||
}
|
||||
|
||||
export function useShadeRuntimeTheme(): ShadeTheme {
|
||||
const ctx = useContext(ShadeRuntimeContext);
|
||||
if (ctx === null) {
|
||||
throw new Error(
|
||||
'useShadeRuntimeTheme must be used inside <ShadeRuntimeProvider runtime={shade}>',
|
||||
);
|
||||
}
|
||||
return ctx.theme;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function ServerStatus(): React.ReactElement {
|
||||
label="Rate limited"
|
||||
value={stats.totalRateLimited}
|
||||
theme={theme}
|
||||
color={stats.totalRateLimited > 0 ? theme.danger : undefined}
|
||||
{...(stats.totalRateLimited > 0 ? { color: theme.danger } : {})}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { useShadeContext } from '../ShadeProvider.js';
|
||||
import type { ShadeTheme } from '../theme.js';
|
||||
|
||||
/**
|
||||
* Common widget shell — provides consistent border, padding, header.
|
||||
|
||||
115
packages/shade-widgets/src/components/transfer/DropZone.tsx
Normal file
115
packages/shade-widgets/src/components/transfer/DropZone.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
|
||||
|
||||
export interface DropZoneProps {
|
||||
/** Called when files are dropped or selected via the click-to-pick action. */
|
||||
onFiles: (files: File[]) => void;
|
||||
/** Allow multiple files (default: true). */
|
||||
multiple?: boolean;
|
||||
/** `<input type="file">` accept filter (e.g. `'.zip,application/zip'`). */
|
||||
accept?: string;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag-and-drop file zone built on native HTML5 DnD APIs. Falls back to a
|
||||
* native file picker when the user clicks or focuses-and-presses-Enter.
|
||||
*/
|
||||
export function DropZone({
|
||||
onFiles,
|
||||
multiple = true,
|
||||
accept,
|
||||
className,
|
||||
style,
|
||||
children,
|
||||
}: DropZoneProps): React.ReactElement {
|
||||
const theme = useShadeRuntimeTheme();
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
|
||||
const onDragOver = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const onDragLeave = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const onDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
if (files.length > 0) onFiles(files);
|
||||
},
|
||||
[onFiles],
|
||||
);
|
||||
|
||||
const onChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files === null ? [] : Array.from(e.target.files);
|
||||
if (files.length > 0) onFiles(files);
|
||||
e.target.value = ''; // allow re-picking the same file
|
||||
},
|
||||
[onFiles],
|
||||
);
|
||||
|
||||
const onClick = useCallback(() => {
|
||||
inputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
inputRef.current?.click();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const borderActive = theme.dropZoneBorderActive ?? theme.accent;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
onClick={onClick}
|
||||
onKeyDown={onKeyDown}
|
||||
style={{
|
||||
border: `2px dashed ${isDragging ? borderActive : theme.border}`,
|
||||
borderRadius: theme.radius,
|
||||
padding: 32,
|
||||
textAlign: 'center',
|
||||
background: isDragging ? theme.accentMuted : theme.bg,
|
||||
color: theme.textMuted,
|
||||
cursor: 'pointer',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
fontFamily: theme.font,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple={multiple}
|
||||
{...(accept !== undefined ? { accept } : {})}
|
||||
onChange={onChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
{children ?? (
|
||||
<span>
|
||||
{isDragging ? 'Slipp filer for å laste opp' : 'Dra og slipp filer her, eller klikk for å velge'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
|
||||
|
||||
export interface ETAReadoutProps {
|
||||
/** ETA in seconds. Undefined → unknown. */
|
||||
seconds: number | undefined;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function ETAReadout({
|
||||
seconds,
|
||||
className,
|
||||
style,
|
||||
}: ETAReadoutProps): React.ReactElement {
|
||||
const theme = useShadeRuntimeTheme();
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
style={{
|
||||
fontFamily: theme.fontMono,
|
||||
fontSize: 12,
|
||||
color: theme.textMuted,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{formatEta(seconds)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatEta(seconds: number | undefined): string {
|
||||
if (seconds === undefined || !Number.isFinite(seconds)) return 'ETA —';
|
||||
if (seconds < 1) return 'ETA <1s';
|
||||
if (seconds < 60) return `ETA ${Math.round(seconds)}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const remSeconds = Math.round(seconds % 60);
|
||||
if (minutes < 60) return `ETA ${minutes}m ${remSeconds}s`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remMinutes = minutes % 60;
|
||||
return `ETA ${hours}h ${remMinutes}m`;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React from 'react';
|
||||
import type { LaneProgress } from '@shade/sdk';
|
||||
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
|
||||
|
||||
export interface LaneIndicatorProps {
|
||||
lanes: LaneProgress[];
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function LaneIndicator({
|
||||
lanes,
|
||||
className,
|
||||
style,
|
||||
}: LaneIndicatorProps): React.ReactElement {
|
||||
const theme = useShadeRuntimeTheme();
|
||||
const active = theme.laneActive ?? theme.success;
|
||||
const idle = theme.laneIdle ?? theme.textDim;
|
||||
const error = theme.laneError ?? theme.danger;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label={`${lanes.length} lanes`}
|
||||
style={{ display: 'inline-flex', gap: 4, alignItems: 'center', ...style }}
|
||||
>
|
||||
{lanes.map((l) => {
|
||||
let color: string;
|
||||
if (l.state === 'error') color = error;
|
||||
else if (l.state === 'sending' || l.state === 'done') color = active;
|
||||
else color = idle;
|
||||
return (
|
||||
<span
|
||||
key={l.laneId}
|
||||
title={`lane ${l.laneId}: ${l.state}`}
|
||||
style={{
|
||||
width: 6,
|
||||
height: 6,
|
||||
borderRadius: 3,
|
||||
background: color,
|
||||
opacity: l.state === 'idle' ? 0.4 : 1,
|
||||
transition: 'background 0.2s',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
|
||||
|
||||
export interface ProgressBarProps {
|
||||
/** Progress in [0, 1]. Undefined → indeterminate. */
|
||||
percent?: number;
|
||||
height?: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function ProgressBar({
|
||||
percent,
|
||||
height = 6,
|
||||
className,
|
||||
style,
|
||||
}: ProgressBarProps): React.ReactElement {
|
||||
const theme = useShadeRuntimeTheme();
|
||||
const track = theme.progressTrack ?? theme.border;
|
||||
const fill = theme.progressFill ?? theme.accent;
|
||||
const indeterminate = theme.progressFillIndeterminate ?? theme.accentMuted;
|
||||
const isIndeterminate = percent === undefined;
|
||||
const widthPct = isIndeterminate ? 100 : Math.max(0, Math.min(1, percent)) * 100;
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
{...(percent !== undefined ? { 'aria-valuenow': Math.round(percent * 100) } : {})}
|
||||
style={{
|
||||
width: '100%',
|
||||
height,
|
||||
background: track,
|
||||
borderRadius: height / 2,
|
||||
overflow: 'hidden',
|
||||
position: 'relative',
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${widthPct}%`,
|
||||
height: '100%',
|
||||
background: isIndeterminate ? indeterminate : fill,
|
||||
transition: isIndeterminate ? undefined : 'width 0.18s linear',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import React from 'react';
|
||||
import type {
|
||||
IncomingTransfer,
|
||||
TransferHandle,
|
||||
TransferOutput,
|
||||
TransferProgress,
|
||||
} from '@shade/sdk';
|
||||
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
|
||||
import {
|
||||
useShadeDownload,
|
||||
type DownloadEntry,
|
||||
type UseShadeDownloadOptions,
|
||||
} from '../../useShadeDownload.js';
|
||||
import { TransferRow } from './TransferRow.js';
|
||||
|
||||
export interface ShadeDownloaderProps {
|
||||
/** Auto-accept incoming transfers via this callback (returns the output sink). */
|
||||
autoAccept?: UseShadeDownloadOptions['autoAccept'];
|
||||
/** Render the prompt for a pending (not-yet-accepted) transfer. */
|
||||
renderPending?: (args: {
|
||||
incoming: IncomingTransfer;
|
||||
accept: (output: TransferOutput) => Promise<TransferHandle>;
|
||||
decline: (reason?: string) => Promise<void>;
|
||||
}) => React.ReactNode;
|
||||
/** Render-prop for active transfer rows. */
|
||||
renderRow?: (args: {
|
||||
handle: TransferHandle;
|
||||
progress: TransferProgress | null;
|
||||
name: string;
|
||||
done: boolean;
|
||||
error: unknown;
|
||||
onDismiss: () => void;
|
||||
}) => React.ReactNode;
|
||||
/** Render-prop for the empty state (no pending or active transfers). */
|
||||
renderEmpty?: () => React.ReactNode;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite downloader: lists pending (awaiting accept) and active
|
||||
* (in-progress) transfers, with bundled defaults for both. Customize via
|
||||
* `renderPending` / `renderRow`.
|
||||
*/
|
||||
export function ShadeDownloader({
|
||||
autoAccept,
|
||||
renderPending,
|
||||
renderRow,
|
||||
renderEmpty,
|
||||
className,
|
||||
style,
|
||||
}: ShadeDownloaderProps): React.ReactElement {
|
||||
const theme = useShadeRuntimeTheme();
|
||||
const { pending, active, accept, decline, dismiss } = useShadeDownload(
|
||||
autoAccept !== undefined ? { autoAccept } : {},
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
fontFamily: theme.font,
|
||||
color: theme.text,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{pending.length === 0 && active.length === 0 ? (
|
||||
renderEmpty?.() ?? <span style={{ color: theme.textDim }}>Ingen overføringer</span>
|
||||
) : null}
|
||||
|
||||
{pending.map((entry) => {
|
||||
const acceptForOutput = (output: TransferOutput): Promise<TransferHandle> =>
|
||||
accept(entry.incoming.streamId, output);
|
||||
const declineWith = (reason?: string): Promise<void> =>
|
||||
decline(entry.incoming.streamId, reason);
|
||||
if (renderPending !== undefined) {
|
||||
return (
|
||||
<React.Fragment key={entry.incoming.streamId}>
|
||||
{renderPending({ incoming: entry.incoming, accept: acceptForOutput, decline: declineWith })}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<DefaultPendingRow
|
||||
key={entry.incoming.streamId}
|
||||
incoming={entry.incoming}
|
||||
onAccept={acceptForOutput}
|
||||
onDecline={() => void declineWith('user-decline')}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{active.map((entry: DownloadEntry) => {
|
||||
if (entry.handle === null) return null;
|
||||
const name = entry.incoming.metadata.name ?? entry.incoming.streamId.slice(0, 8);
|
||||
const dismissEntry = (): void => dismiss(entry.incoming.streamId);
|
||||
const handle = entry.handle;
|
||||
if (renderRow !== undefined) {
|
||||
return (
|
||||
<React.Fragment key={entry.incoming.streamId}>
|
||||
{renderRow({
|
||||
handle,
|
||||
progress: entry.progress,
|
||||
name,
|
||||
done: entry.done,
|
||||
error: entry.error,
|
||||
onDismiss: dismissEntry,
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TransferRow
|
||||
key={entry.incoming.streamId}
|
||||
handle={handle}
|
||||
progress={entry.progress}
|
||||
name={name}
|
||||
done={entry.done}
|
||||
error={entry.error}
|
||||
{...(entry.incoming.metadata.sizeBytes !== undefined
|
||||
? { bytesTotal: entry.incoming.metadata.sizeBytes }
|
||||
: {})}
|
||||
onDismiss={dismissEntry}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultPendingRow({
|
||||
incoming,
|
||||
onAccept,
|
||||
onDecline,
|
||||
}: {
|
||||
incoming: IncomingTransfer;
|
||||
onAccept: (output: TransferOutput) => Promise<TransferHandle>;
|
||||
onDecline: () => void;
|
||||
}): React.ReactElement {
|
||||
const theme = useShadeRuntimeTheme();
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
padding: 12,
|
||||
background: theme.bgElevated,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: theme.radius,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 13 }}>
|
||||
Innkommende overføring fra <strong>{incoming.from}</strong>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: theme.textMuted }}>
|
||||
{incoming.metadata.name ?? incoming.streamId}
|
||||
{incoming.metadata.sizeBytes !== undefined
|
||||
? ` · ${formatBytes(incoming.metadata.sizeBytes)}`
|
||||
: ''}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onAccept({ kind: 'buffer' })}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
background: theme.accent,
|
||||
color: theme.bg,
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Godta (i minne)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDecline}
|
||||
style={{
|
||||
padding: '4px 10px',
|
||||
background: theme.bg,
|
||||
color: theme.textMuted,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
Avslå
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(b: number): string {
|
||||
if (b < 1024) return `${b} B`;
|
||||
if (b < 1024 * 1024) return `${(b / 1024).toFixed(1)} KB`;
|
||||
if (b < 1024 * 1024 * 1024) return `${(b / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(b / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
171
packages/shade-widgets/src/components/transfer/ShadeUploader.tsx
Normal file
171
packages/shade-widgets/src/components/transfer/ShadeUploader.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import React from 'react';
|
||||
import type {
|
||||
TransferHandle,
|
||||
TransferOptions,
|
||||
TransferProgress,
|
||||
TransferResult,
|
||||
} from '@shade/sdk';
|
||||
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
|
||||
import { useShadeUpload, type UploadEntry } from '../../useShadeUpload.js';
|
||||
import { DropZone } from './DropZone.js';
|
||||
import { TransferRow } from './TransferRow.js';
|
||||
|
||||
export interface ShadeUploaderProps {
|
||||
/** Peer address to upload to. */
|
||||
to: string;
|
||||
multiple?: boolean;
|
||||
accept?: string;
|
||||
/** Override the default lane count (default 4 from `@shade/transfer`). */
|
||||
lanes?: number;
|
||||
/** Override per-chunk plaintext size. */
|
||||
chunkSize?: number;
|
||||
/** Extra options forwarded to `Shade.upload`. */
|
||||
uploadOptions?: Partial<TransferOptions>;
|
||||
/**
|
||||
* Render-prop for full UI replacement of the per-row entry. Returns whatever
|
||||
* you want to render in place of the default `TransferRow`.
|
||||
*/
|
||||
renderRow?: (args: {
|
||||
handle: TransferHandle;
|
||||
progress: TransferProgress | null;
|
||||
name: string;
|
||||
done: boolean;
|
||||
error: unknown;
|
||||
onDismiss: () => void;
|
||||
}) => React.ReactNode;
|
||||
/** Render-prop for the empty state (no uploads in progress). */
|
||||
renderEmpty?: () => React.ReactNode;
|
||||
/** Render-prop for the drop zone. */
|
||||
renderDropZone?: (props: { onFiles: (files: File[]) => void }) => React.ReactNode;
|
||||
onComplete?: (result: TransferResult) => void;
|
||||
onError?: (error: unknown) => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/**
|
||||
* Composite uploader: a drop zone + a list of in-flight transfers.
|
||||
*
|
||||
* Drop a file (or click to pick), and Shade handles encryption + chunking +
|
||||
* lanes + retry + integrity verification. Customize via the `renderRow`
|
||||
* render-prop or use the headless `useShadeUpload` hook directly.
|
||||
*/
|
||||
export function ShadeUploader({
|
||||
to,
|
||||
multiple = true,
|
||||
accept,
|
||||
lanes,
|
||||
chunkSize,
|
||||
uploadOptions,
|
||||
renderRow,
|
||||
renderEmpty,
|
||||
renderDropZone,
|
||||
onComplete,
|
||||
onError,
|
||||
className,
|
||||
style,
|
||||
}: ShadeUploaderProps): React.ReactElement {
|
||||
const theme = useShadeRuntimeTheme();
|
||||
const { upload, uploads, dismiss } = useShadeUpload();
|
||||
|
||||
const handleFiles = React.useCallback(
|
||||
async (files: File[]) => {
|
||||
for (const file of files) {
|
||||
try {
|
||||
const handle = await upload({
|
||||
to,
|
||||
input: file,
|
||||
...(lanes !== undefined ? { lanes } : {}),
|
||||
...(chunkSize !== undefined ? { chunkSize } : {}),
|
||||
...uploadOptions,
|
||||
metadata: {
|
||||
name: file.name,
|
||||
...(file.type !== '' ? { contentType: file.type } : {}),
|
||||
...uploadOptions?.metadata,
|
||||
},
|
||||
});
|
||||
if (onComplete !== undefined) {
|
||||
void handle
|
||||
.done()
|
||||
.then(onComplete)
|
||||
.catch(() => {
|
||||
/* error surfaced via onError */
|
||||
});
|
||||
}
|
||||
if (onError !== undefined) {
|
||||
void handle.done().catch(onError);
|
||||
}
|
||||
} catch (err) {
|
||||
onError?.(err);
|
||||
}
|
||||
}
|
||||
},
|
||||
[upload, to, lanes, chunkSize, uploadOptions, onComplete, onError],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 12,
|
||||
fontFamily: theme.font,
|
||||
color: theme.text,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{renderDropZone !== undefined ? (
|
||||
renderDropZone({ onFiles: handleFiles })
|
||||
) : (
|
||||
<DropZone onFiles={handleFiles} multiple={multiple} {...(accept !== undefined ? { accept } : {})} />
|
||||
)}
|
||||
{uploads.length === 0 ? (
|
||||
renderEmpty?.() ?? null
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
{uploads.map((entry: UploadEntry) => {
|
||||
const name = describeEntry(entry);
|
||||
const dismissEntry = (): void => dismiss(entry.handle.streamId);
|
||||
const cancelEntry = (): void => {
|
||||
void entry.handle.abort('user-cancel');
|
||||
};
|
||||
if (renderRow !== undefined) {
|
||||
return (
|
||||
<React.Fragment key={entry.handle.streamId}>
|
||||
{renderRow({
|
||||
handle: entry.handle,
|
||||
progress: entry.progress,
|
||||
name,
|
||||
done: entry.done,
|
||||
error: entry.error,
|
||||
onDismiss: dismissEntry,
|
||||
})}
|
||||
</React.Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<TransferRow
|
||||
key={entry.handle.streamId}
|
||||
handle={entry.handle}
|
||||
progress={entry.progress}
|
||||
name={name}
|
||||
done={entry.done}
|
||||
error={entry.error}
|
||||
{...(entry.progress?.bytesTotal !== undefined ? { bytesTotal: entry.progress.bytesTotal } : {})}
|
||||
onCancel={cancelEntry}
|
||||
onDismiss={dismissEntry}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function describeEntry(entry: UploadEntry): string {
|
||||
// The progress event includes bytesTotal but not the file name; we keep
|
||||
// a hint by reading the underlying stream metadata when available.
|
||||
return entry.handle.streamId.slice(0, 8);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react';
|
||||
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
|
||||
|
||||
export interface SpeedReadoutProps {
|
||||
bytesPerSecond: number;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export function SpeedReadout({
|
||||
bytesPerSecond,
|
||||
className,
|
||||
style,
|
||||
}: SpeedReadoutProps): React.ReactElement {
|
||||
const theme = useShadeRuntimeTheme();
|
||||
return (
|
||||
<span
|
||||
className={className}
|
||||
style={{
|
||||
fontFamily: theme.fontMono,
|
||||
fontSize: 12,
|
||||
color: theme.textMuted,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{formatBytesPerSecond(bytesPerSecond)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatBytesPerSecond(bps: number): string {
|
||||
if (!Number.isFinite(bps) || bps <= 0) return '— B/s';
|
||||
if (bps < 1024) return `${bps.toFixed(0)} B/s`;
|
||||
if (bps < 1024 * 1024) return `${(bps / 1024).toFixed(1)} KB/s`;
|
||||
if (bps < 1024 * 1024 * 1024) return `${(bps / (1024 * 1024)).toFixed(2)} MB/s`;
|
||||
return `${(bps / (1024 * 1024 * 1024)).toFixed(2)} GB/s`;
|
||||
}
|
||||
116
packages/shade-widgets/src/components/transfer/TransferRow.tsx
Normal file
116
packages/shade-widgets/src/components/transfer/TransferRow.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import type { TransferHandle, TransferProgress } from '@shade/sdk';
|
||||
import { useShadeRuntimeTheme } from '../../ShadeRuntimeProvider.js';
|
||||
import { ProgressBar } from './ProgressBar.js';
|
||||
import { SpeedReadout } from './SpeedReadout.js';
|
||||
import { ETAReadout } from './ETAReadout.js';
|
||||
import { LaneIndicator } from './LaneIndicator.js';
|
||||
|
||||
export interface TransferRowProps {
|
||||
handle: TransferHandle;
|
||||
progress: TransferProgress | null;
|
||||
/** Optional file name to display. */
|
||||
name?: string;
|
||||
/** Total bytes for this transfer (used to render percent when progress lacks it). */
|
||||
bytesTotal?: number;
|
||||
done?: boolean;
|
||||
error?: unknown;
|
||||
onCancel?: () => void;
|
||||
onDismiss?: () => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
/** Default UI for a single transfer. Replace via render-prop on the parent. */
|
||||
export function TransferRow({
|
||||
handle,
|
||||
progress,
|
||||
name,
|
||||
bytesTotal,
|
||||
done,
|
||||
error,
|
||||
onCancel,
|
||||
onDismiss,
|
||||
className,
|
||||
style,
|
||||
}: TransferRowProps): React.ReactElement {
|
||||
const theme = useShadeRuntimeTheme();
|
||||
const percent = progress?.percent ?? (bytesTotal !== undefined && progress !== null ? progress.bytesSent / bytesTotal : undefined);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : error !== undefined ? String(error) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
padding: 12,
|
||||
background: theme.bgElevated,
|
||||
border: `1px solid ${theme.border}`,
|
||||
borderRadius: theme.radius,
|
||||
fontFamily: theme.font,
|
||||
color: theme.text,
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span
|
||||
style={{
|
||||
flex: 1,
|
||||
fontSize: 13,
|
||||
color: errorMessage !== null ? theme.danger : theme.text,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
title={name ?? handle.streamId}
|
||||
>
|
||||
{name ?? handle.streamId}
|
||||
</span>
|
||||
{progress !== null ? (
|
||||
<LaneIndicator lanes={progress.lanes} />
|
||||
) : null}
|
||||
{!done && onCancel !== undefined ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
style={smallButton(theme.danger, theme.bg)}
|
||||
>
|
||||
Avbryt
|
||||
</button>
|
||||
) : null}
|
||||
{done && onDismiss !== undefined ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
style={smallButton(theme.textMuted, theme.bg)}
|
||||
>
|
||||
Lukk
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<ProgressBar {...(percent !== undefined ? { percent } : {})} />
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 8 }}>
|
||||
<SpeedReadout bytesPerSecond={progress?.bytesPerSecond ?? 0} />
|
||||
<ETAReadout {...(progress?.etaSeconds !== undefined ? { seconds: progress.etaSeconds } : { seconds: undefined })} />
|
||||
</div>
|
||||
{errorMessage !== null ? (
|
||||
<div style={{ fontSize: 12, color: theme.danger }}>{errorMessage}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function smallButton(fg: string, bg: string): React.CSSProperties {
|
||||
return {
|
||||
padding: '2px 8px',
|
||||
background: bg,
|
||||
color: fg,
|
||||
border: `1px solid ${fg}`,
|
||||
borderRadius: 4,
|
||||
fontSize: 11,
|
||||
cursor: 'pointer',
|
||||
};
|
||||
}
|
||||
@@ -16,3 +16,38 @@ 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';
|
||||
|
||||
// ─── Stream-transfer widgets (v0.2.0) ────────────────────
|
||||
export {
|
||||
ShadeRuntimeProvider,
|
||||
useShadeRuntime,
|
||||
useShadeRuntimeTheme,
|
||||
} from './ShadeRuntimeProvider.js';
|
||||
export type {
|
||||
ShadeRuntimeProviderProps,
|
||||
ShadeRuntimeContextValue,
|
||||
} from './ShadeRuntimeProvider.js';
|
||||
export { useShadeUpload } from './useShadeUpload.js';
|
||||
export type { UploadEntry, UseShadeUploadResult } from './useShadeUpload.js';
|
||||
export { useShadeDownload } from './useShadeDownload.js';
|
||||
export type {
|
||||
DownloadEntry,
|
||||
UseShadeDownloadOptions,
|
||||
UseShadeDownloadResult,
|
||||
} from './useShadeDownload.js';
|
||||
export { ShadeUploader } from './components/transfer/ShadeUploader.js';
|
||||
export type { ShadeUploaderProps } from './components/transfer/ShadeUploader.js';
|
||||
export { ShadeDownloader } from './components/transfer/ShadeDownloader.js';
|
||||
export type { ShadeDownloaderProps } from './components/transfer/ShadeDownloader.js';
|
||||
export { DropZone } from './components/transfer/DropZone.js';
|
||||
export type { DropZoneProps } from './components/transfer/DropZone.js';
|
||||
export { TransferRow } from './components/transfer/TransferRow.js';
|
||||
export type { TransferRowProps } from './components/transfer/TransferRow.js';
|
||||
export { ProgressBar } from './components/transfer/ProgressBar.js';
|
||||
export type { ProgressBarProps } from './components/transfer/ProgressBar.js';
|
||||
export { SpeedReadout, formatBytesPerSecond } from './components/transfer/SpeedReadout.js';
|
||||
export type { SpeedReadoutProps } from './components/transfer/SpeedReadout.js';
|
||||
export { ETAReadout, formatEta } from './components/transfer/ETAReadout.js';
|
||||
export type { ETAReadoutProps } from './components/transfer/ETAReadout.js';
|
||||
export { LaneIndicator } from './components/transfer/LaneIndicator.js';
|
||||
export type { LaneIndicatorProps } from './components/transfer/LaneIndicator.js';
|
||||
|
||||
@@ -21,6 +21,22 @@ export interface ShadeTheme {
|
||||
fontMono: string;
|
||||
radius: string;
|
||||
shadow: string;
|
||||
|
||||
// ─── Stream-transfer tokens (v0.2.0) ─────────────────────
|
||||
/** Background of progress-bar track. Default: `border`. */
|
||||
progressTrack?: string;
|
||||
/** Filled portion of the progress bar. Default: `accent`. */
|
||||
progressFill?: string;
|
||||
/** Animated fill for indeterminate progress. Default: `accentMuted`. */
|
||||
progressFillIndeterminate?: string;
|
||||
/** Border of an active drag-and-drop zone. Default: `accent`. */
|
||||
dropZoneBorderActive?: string;
|
||||
/** Lane indicator color when sending. Default: `success`. */
|
||||
laneActive?: string;
|
||||
/** Lane indicator color when idle. Default: `textDim`. */
|
||||
laneIdle?: string;
|
||||
/** Lane indicator color on error. Default: `danger`. */
|
||||
laneError?: string;
|
||||
}
|
||||
|
||||
export const darkTheme: ShadeTheme = {
|
||||
|
||||
161
packages/shade-widgets/src/useShadeDownload.ts
Normal file
161
packages/shade-widgets/src/useShadeDownload.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import type {
|
||||
IncomingTransfer,
|
||||
TransferHandle,
|
||||
TransferOutput,
|
||||
TransferProgress,
|
||||
} from '@shade/sdk';
|
||||
import { useShadeRuntime } from './ShadeRuntimeProvider.js';
|
||||
|
||||
export interface DownloadEntry {
|
||||
incoming: IncomingTransfer;
|
||||
handle: TransferHandle | null;
|
||||
progress: TransferProgress | null;
|
||||
error: unknown;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface UseShadeDownloadOptions {
|
||||
/**
|
||||
* If provided, accept transfers automatically with the supplied output
|
||||
* (called once per incoming). Useful for "auto-save to /uploads" servers.
|
||||
*/
|
||||
autoAccept?: (incoming: IncomingTransfer) => Promise<TransferOutput | null>;
|
||||
}
|
||||
|
||||
export interface UseShadeDownloadResult {
|
||||
/** Pending transfers awaiting accept/decline. */
|
||||
pending: DownloadEntry[];
|
||||
/** In-flight transfers being received. */
|
||||
active: DownloadEntry[];
|
||||
/** Manually accept a pending transfer with a chosen output. */
|
||||
accept: (streamId: string, output: TransferOutput) => Promise<TransferHandle>;
|
||||
/** Manually decline a pending transfer. */
|
||||
decline: (streamId: string, reason?: string) => Promise<void>;
|
||||
dismiss: (streamId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless hook for download tracking. Subscribes to incoming transfers
|
||||
* and exposes a list of pending and active downloads.
|
||||
*/
|
||||
export function useShadeDownload(
|
||||
options: UseShadeDownloadOptions = {},
|
||||
): UseShadeDownloadResult {
|
||||
const shade = useShadeRuntime();
|
||||
const [pending, setPending] = useState<DownloadEntry[]>([]);
|
||||
const [active, setActive] = useState<DownloadEntry[]>([]);
|
||||
|
||||
const trackHandle = useCallback(
|
||||
(incoming: IncomingTransfer, handle: TransferHandle) => {
|
||||
const entry: DownloadEntry = {
|
||||
incoming,
|
||||
handle,
|
||||
progress: null,
|
||||
error: null,
|
||||
done: false,
|
||||
};
|
||||
setPending((prev) => prev.filter((p) => p.incoming.streamId !== incoming.streamId));
|
||||
setActive((prev) => [entry, ...prev]);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const ev of handle.events) {
|
||||
if (ev.type === 'progress') {
|
||||
setActive((prev) =>
|
||||
prev.map((a) =>
|
||||
a.incoming.streamId === incoming.streamId ? { ...a, progress: ev.progress } : a,
|
||||
),
|
||||
);
|
||||
} else if (ev.type === 'complete') {
|
||||
setActive((prev) =>
|
||||
prev.map((a) =>
|
||||
a.incoming.streamId === incoming.streamId ? { ...a, done: true } : a,
|
||||
),
|
||||
);
|
||||
} else if (ev.type === 'error') {
|
||||
setActive((prev) =>
|
||||
prev.map((a) =>
|
||||
a.incoming.streamId === incoming.streamId
|
||||
? { ...a, error: ev.error, done: true }
|
||||
: a,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
setActive((prev) =>
|
||||
prev.map((a) =>
|
||||
a.incoming.streamId === incoming.streamId
|
||||
? { ...a, error: err, done: true }
|
||||
: a,
|
||||
),
|
||||
);
|
||||
}
|
||||
})();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubscribe: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const cleanup = await shade.onIncomingTransfer(async (incoming) => {
|
||||
if (options.autoAccept !== undefined) {
|
||||
const output = await options.autoAccept(incoming);
|
||||
if (output !== null) {
|
||||
const handle = await incoming.accept({ output });
|
||||
trackHandle(incoming, handle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const entry: DownloadEntry = {
|
||||
incoming,
|
||||
handle: null,
|
||||
progress: null,
|
||||
error: null,
|
||||
done: false,
|
||||
};
|
||||
setPending((prev) => [entry, ...prev]);
|
||||
});
|
||||
if (cancelled) cleanup();
|
||||
else unsubscribe = cleanup;
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe?.();
|
||||
};
|
||||
}, [shade, options.autoAccept, trackHandle]);
|
||||
|
||||
const accept = useCallback(
|
||||
async (streamId: string, output: TransferOutput): Promise<TransferHandle> => {
|
||||
const entry = pending.find((p) => p.incoming.streamId === streamId);
|
||||
if (entry === undefined) {
|
||||
throw new Error(`No pending transfer with streamId=${streamId}`);
|
||||
}
|
||||
const handle = await entry.incoming.accept({ output });
|
||||
trackHandle(entry.incoming, handle);
|
||||
return handle;
|
||||
},
|
||||
[pending, trackHandle],
|
||||
);
|
||||
|
||||
const decline = useCallback(
|
||||
async (streamId: string, reason?: string) => {
|
||||
const entry = pending.find((p) => p.incoming.streamId === streamId);
|
||||
if (entry !== undefined) {
|
||||
await entry.incoming.decline(reason);
|
||||
setPending((prev) => prev.filter((p) => p.incoming.streamId !== streamId));
|
||||
}
|
||||
},
|
||||
[pending],
|
||||
);
|
||||
|
||||
const dismiss = useCallback((streamId: string) => {
|
||||
setPending((prev) => prev.filter((p) => p.incoming.streamId !== streamId));
|
||||
setActive((prev) => prev.filter((a) => a.incoming.streamId !== streamId));
|
||||
}, []);
|
||||
|
||||
return { pending, active, accept, decline, dismiss };
|
||||
}
|
||||
90
packages/shade-widgets/src/useShadeUpload.ts
Normal file
90
packages/shade-widgets/src/useShadeUpload.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { TransferHandle, TransferOptions, TransferProgress } from '@shade/sdk';
|
||||
import { useShadeRuntime } from './ShadeRuntimeProvider.js';
|
||||
|
||||
export interface UploadEntry {
|
||||
handle: TransferHandle;
|
||||
progress: TransferProgress | null;
|
||||
error: unknown;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface UseShadeUploadResult {
|
||||
/** Start a new upload. Resolves once the handle is available. */
|
||||
upload: (opts: TransferOptions) => Promise<TransferHandle>;
|
||||
/** Currently-tracked uploads (most recent first). */
|
||||
uploads: UploadEntry[];
|
||||
/** Drop a finished/failed entry from the list. */
|
||||
dismiss: (streamId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Headless hook for upload tracking. Subscribes to progress events on each
|
||||
* started transfer and surfaces them as React state. Drop a custom UI on
|
||||
* top, or use the bundled `<ShadeUploader />` component.
|
||||
*/
|
||||
export function useShadeUpload(): UseShadeUploadResult {
|
||||
const shade = useShadeRuntime();
|
||||
const [uploads, setUploads] = useState<UploadEntry[]>([]);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const upload = useCallback(
|
||||
async (opts: TransferOptions) => {
|
||||
const handle = await shade.upload(opts);
|
||||
const entry: UploadEntry = { handle, progress: null, error: null, done: false };
|
||||
setUploads((prev) => [entry, ...prev]);
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const ev of handle.events) {
|
||||
if (!mountedRef.current) return;
|
||||
if (ev.type === 'progress') {
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.handle.streamId === handle.streamId ? { ...u, progress: ev.progress } : u,
|
||||
),
|
||||
);
|
||||
} else if (ev.type === 'complete') {
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.handle.streamId === handle.streamId ? { ...u, done: true } : u,
|
||||
),
|
||||
);
|
||||
} else if (ev.type === 'error') {
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.handle.streamId === handle.streamId
|
||||
? { ...u, error: ev.error, done: true }
|
||||
: u,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (!mountedRef.current) return;
|
||||
setUploads((prev) =>
|
||||
prev.map((u) =>
|
||||
u.handle.streamId === handle.streamId ? { ...u, error: err, done: true } : u,
|
||||
),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
return handle;
|
||||
},
|
||||
[shade],
|
||||
);
|
||||
|
||||
const dismiss = useCallback((streamId: string) => {
|
||||
setUploads((prev) => prev.filter((u) => u.handle.streamId !== streamId));
|
||||
}, []);
|
||||
|
||||
return { upload, uploads, dismiss };
|
||||
}
|
||||
Reference in New Issue
Block a user