Files
backspace/packages/web/src/components/layout/TransferIndicator.tsx
T
Jannis Braun fb9fe326c4 feat(mobile): Wave 4 — RegisterPage responsive + TransferIndicator on settings + MessageInput sheets
- RegisterPage: scroll envelope, iOS-zoom-safe inputs, ≥44px tap targets, mobile-friendly invite chip + swatch row
- TransferIndicator: mounted via MobileScreenHeader.rightActions across every settings/instance screen and inline in MobileChatScreen; touch-close listener; viewport-safe panel width
- MessageInput popovers (emoji/GIF/mention) now route through new InputPopover wrapper — desktop popover, mobile bottom-sheet
- EmojiPicker mobile branch: dynamicWidth + scoped CSS (.emoji-picker-wrapper--mobile) so the <em-emoji-picker> custom element fills the sheet; bigger touch targets (perLine 8, button 40, emoji 28); desktop unchanged
- MessageInput trigger row: mobile-first sizing with md: desktop overrides (40×40 buttons + gap on mobile, 34×34 unchanged on desktop)
- Spec updates: docs/systems/auth.md (RegisterPage responsive contract), docs/systems/uploads.md (TransferIndicator mobile surfaces)
2026-05-07 22:58:57 +02:00

200 lines
8.1 KiB
TypeScript

import { useState, useRef, useEffect, useMemo } from 'react';
import { useTransferStore, type Transfer } from '../../stores/transferStore';
function fmt(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export function TransferIndicator() {
// Subscribe to the Map directly. `listVisible()` returns a fresh array each call,
// which would force a re-render on every store mutation. The Map reference only
// changes when transfers are added/removed/updated, so useMemo recomputes only
// when needed.
const transfersMap = useTransferStore((s) => s.transfers);
const visible = useMemo(
() => Array.from(transfersMap.values()).filter((t) => t.tray),
[transfersMap],
);
const pause = useTransferStore((s) => s.pauseUpload);
const resume = useTransferStore((s) => s.resumeUpload);
const abort = useTransferStore((s) => s.abortUpload);
const pauseDl = useTransferStore((s) => s.pauseDownload);
const resumeDl = useTransferStore((s) => s.resumeDownload);
const abortDl = useTransferStore((s) => s.abortDownload);
const remove = useTransferStore((s) => s.remove);
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
// Click-outside-to-close. iOS Safari does not synthesize `mousedown` from
// touch reliably, so we listen for `touchstart` alongside `mousedown` so the
// panel dismisses on the first tap on touch devices. Mirrors the pattern
// used in AudioInputSection.tsx.
useEffect(() => {
if (!open) return;
const onPointerDown = (e: MouseEvent | TouchEvent) => {
if (!ref.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onPointerDown);
document.addEventListener('touchstart', onPointerDown, { passive: true });
return () => {
document.removeEventListener('mousedown', onPointerDown);
document.removeEventListener('touchstart', onPointerDown);
};
}, [open]);
const active = visible.filter(
(t) => t.state === 'active' || t.state === 'paused' || t.state === 'queued',
);
const failed = visible.some((t) => t.state === 'failed');
const idle = visible.length === 0;
const badgeColor = failed ? 'bg-accent-rose' : 'bg-accent-amber';
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen((v) => !v)}
className={`relative w-8 h-8 flex items-center justify-center rounded-[6px] transition-colors ${
idle
? 'text-txt-tertiary/60 hover:text-txt-tertiary hover:bg-interactive-hover'
: 'text-txt-tertiary hover:text-txt-primary hover:bg-interactive-hover'
}`}
title="Transfers"
aria-label="Transfers"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 4v12m0 0l-4-4m4 4l4-4M4 20h16" />
</svg>
{active.length > 0 && (
<span className={`absolute -top-0.5 -right-0.5 ${badgeColor} text-black text-[10px] font-medium rounded-full px-1.5 leading-4 min-w-[16px] text-center`}>
{active.length}
</span>
)}
</button>
{open && (
// Desktop: anchor below the trigger via `absolute right-0 top-full`.
// Mobile (<768px): switch to `fixed` so the panel pins to the viewport
// right edge with an 8px margin instead of inheriting the trigger's
// x-position. This prevents left-edge clipping on narrow viewports
// (e.g. 320px) where the trigger sits well to the left of the
// viewport's right edge — `right-0` would otherwise anchor the
// panel to the trigger's right and let the 300px panel extend off
// the left of the viewport.
<div className="fixed right-2 top-14 md:absolute md:right-0 md:top-full md:mt-2 w-[min(300px,calc(100vw-16px))] glass z-50 rounded-lg overflow-hidden">
<div className="px-3 py-2 border-b border-border-soft text-xs flex justify-between items-center">
<span className="text-txt-secondary">
{visible.length} transfer{visible.length === 1 ? '' : 's'}
</span>
<button
onClick={() =>
visible
.filter((t) => t.state === 'completed' || t.state === 'aborted')
.forEach((t) => remove(t.id))
}
className="text-txt-tertiary hover:text-txt-primary transition-colors"
>
Clear completed
</button>
</div>
{visible.length === 0 && (
<div className="px-3 py-6 text-center text-txt-tertiary text-xs">
No active transfers.
</div>
)}
<div className="max-h-[400px] overflow-y-auto">
{visible.map((t) => (
<TransferRow
key={t.id}
transfer={t}
onPause={() => (t.type === 'upload' ? pause(t.id) : pauseDl(t.id))}
onResume={() => {
if (t.type === 'upload') void resume(t.id);
else void resumeDl(t.id);
}}
onAbort={() => (t.type === 'upload' ? abort(t.id) : abortDl(t.id))}
onDismiss={() => remove(t.id)}
/>
))}
</div>
</div>
)}
</div>
);
}
interface TransferRowProps {
transfer: Transfer;
onPause: () => void;
onResume: () => void;
onAbort: () => void;
onDismiss: () => void;
}
function TransferRow({ transfer, onPause, onResume, onAbort, onDismiss }: TransferRowProps) {
const pct =
transfer.progress.total > 0
? Math.min(100, Math.round((transfer.progress.loaded / transfer.progress.total) * 100))
: 0;
const barColor =
transfer.state === 'failed'
? 'bg-accent-rose'
: transfer.state === 'completed'
? 'bg-accent-mint'
: 'bg-accent-mint/70';
return (
<div className="px-3 py-2 border-b border-border-soft last:border-none">
<div className="flex items-center gap-2 text-[11px]">
<span className="text-txt-tertiary text-[10px]">
{transfer.type === 'upload' ? '↑' : '↓'}
</span>
<span className="text-txt-primary truncate flex-1">{transfer.file.name}</span>
</div>
<div className="mt-1 h-[3px] bg-surface-input rounded overflow-hidden">
<div className={`h-full ${barColor} transition-[width]`} style={{ width: `${pct}%` }} />
</div>
<div className="mt-1 flex justify-between items-center text-[10px] text-txt-tertiary gap-2">
<span className="truncate">
{transfer.state === 'completed'
? 'Done'
: transfer.state === 'failed'
? `Failed: ${transfer.error?.message ?? 'unknown'}`
: transfer.state === 'aborted'
? 'Aborted'
: `${fmt(transfer.progress.loaded)} / ${fmt(transfer.progress.total)}`}
</span>
<span className="flex gap-2 flex-shrink-0">
{transfer.state === 'active' && (
<button onClick={onPause} className="hover:text-txt-primary transition-colors">
Pause
</button>
)}
{transfer.state === 'paused' && (
<button onClick={onResume} className="hover:text-txt-primary transition-colors">
Resume
</button>
)}
{(transfer.state === 'active' ||
transfer.state === 'paused' ||
transfer.state === 'queued') && (
<button onClick={onAbort} className="hover:text-accent-rose transition-colors">
Abort
</button>
)}
{(transfer.state === 'completed' ||
transfer.state === 'failed' ||
transfer.state === 'aborted') && (
<button onClick={onDismiss} className="hover:text-txt-primary transition-colors">
Dismiss
</button>
)}
</span>
</div>
</div>
);
}