Files
website/src/apps/settings/index.tsx

610 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useRef, useState } from 'react';
import { Check, Plus, Trash2 } from 'lucide-react';
import { AppBody, AppLayout, AppToolbar } from '@/components/os/AppChrome';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Slider } from '@/components/ui/slider';
import { LoginArea } from '@/components/auth/LoginArea';
import { useAppContext } from '@/hooks/useAppContext';
import { useTheme } from '@/hooks/useTheme';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useDecodedImage } from '@/hooks/useDecodedImage';
import { useWindowManager } from '@/os/useWindowManager';
import { desktopApps } from '@/os/registry';
import { useIconLayout } from '@/os/useIconLayout';
import { useToast } from '@/hooks/useToast';
import { npubOf } from '@/lib/nostrUtils';
import { cn } from '@/lib/utils';
import {
CURATED_WALLPAPERS,
DEFAULT_CURATED_ID,
isSafeWallpaperUrl,
resolveCurated,
type WallpaperFit,
} from '@/lib/wallpaper';
import type { Theme } from '@/contexts/AppContext';
import type { AppProps } from '@/os/types';
const THEMES: { value: Theme; label: string }[] = [
{ value: 'light', label: 'Light' },
{ value: 'dark', label: 'Dark' },
{ value: 'system', label: 'System' },
];
export default function SettingsApp({ setTitle }: AppProps) {
useEffect(() => setTitle('Settings'), [setTitle]);
return (
<AppLayout>
<AppToolbar>
<span className="text-[13px] font-medium">Settings</span>
</AppToolbar>
<AppBody className="px-6 py-5">
<div className="mx-auto max-w-xl space-y-8">
<AccountSection />
<Separator />
<AppearanceSection />
<Separator />
<WallpaperSection />
<Separator />
<RelaySection />
<Separator />
<MediaSection />
<Separator />
<IconLayoutSection />
<Separator />
<SessionSection />
</div>
</AppBody>
</AppLayout>
);
}
function IconLayoutSection() {
const { toast } = useToast();
const appIds = desktopApps().map((app) => app.id);
// Reset uses a deterministic, measurement-free registry order. The desktop
// clamps it to its actual surface on render; mobile reflows into its columns.
const { reset } = useIconLayout(appIds, { columns: 8, rows: 16 });
const resetAndToast = (profile: 'desktop' | 'mobile' | 'both') => {
reset(profile);
toast({ title: profile === 'both' ? 'All icon layouts reset' : `${profile === 'desktop' ? 'Desktop' : 'Mobile'} icon layout reset` });
};
return (
<Section
title="Home screen layout"
description="Arrange icons by dragging them. On a keyboard, press Space to pick up an icon, use the arrow keys to move it, then press Enter to drop."
>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => resetAndToast('desktop')}>Reset desktop</Button>
<Button variant="outline" onClick={() => resetAndToast('mobile')}>Reset mobile</Button>
<Button variant="outline" onClick={() => resetAndToast('both')}>Reset both</Button>
</div>
</Section>
);
}
function Section({
title,
description,
children,
}: {
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<section className="space-y-3">
<div className="space-y-0.5">
<h2 className="text-sm font-semibold tracking-tight">{title}</h2>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children}
</section>
);
}
function AccountSection() {
const { user } = useCurrentUser();
return (
<Section title="Account" description="The Nostr identity this session signs with.">
{user ? (
<div className="flex items-center justify-between gap-3 rounded-lg border border-border p-3">
<p className="min-w-0 truncate font-mono text-xs">{npubOf(user.pubkey)}</p>
<LoginArea />
</div>
) : (
<div className="flex items-center justify-between gap-3 rounded-lg border border-dashed border-border p-3">
<p className="text-sm text-muted-foreground">Not signed in.</p>
<LoginArea />
</div>
)}
</Section>
);
}
function AppearanceSection() {
const { theme, setTheme } = useTheme();
return (
<Section title="Appearance" description="Applies to the desktop and every window.">
<div className="flex gap-2">
{THEMES.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setTheme(option.value)}
aria-pressed={theme === option.value}
className={cn(
'flex flex-1 items-center justify-center gap-1.5 rounded-lg border px-3 py-2 text-sm transition-colors',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
theme === option.value
? 'border-primary bg-accent text-accent-foreground'
: 'border-border hover:bg-muted',
)}
>
{theme === option.value && <Check className="size-3.5" aria-hidden />}
{option.label}
</button>
))}
</div>
</Section>
);
}
const MAX_WALLPAPER_FILE_BYTES = 5 * 1024 * 1024;
const MAX_WALLPAPER_DIMENSION = 6000;
const ALLOWED_WALLPAPER_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
export function WallpaperSection() {
const { config, updateConfig } = useAppContext();
const { user } = useCurrentUser();
const { mutateAsync: uploadFile, isPending: uploading } = useUploadFile();
const { toast } = useToast();
const fileInputRef = useRef<HTMLInputElement>(null);
const selection = config.wallpaper.selection;
const [urlDraft, setUrlDraft] = useState(selection.source === 'url' ? selection.url : '');
const [fit, setFit] = useState<WallpaperFit>(selection.source === 'url' ? selection.presentation.fit : 'cover');
const [dim, setDim] = useState(selection.source === 'url' ? selection.presentation.dim : 0);
const [pendingPreviewUrl, setPendingPreviewUrl] = useState<string | undefined>(undefined);
const preview = useDecodedImage(pendingPreviewUrl);
// Keep the draft/presentation fields in sync if the saved selection changes
// from elsewhere (e.g. the desktop context menu's curated shortcut, or
// another open Settings window), so this form never shows a stale value.
// Adjusted during render (mirroring useLocalStorage's "key changed" reset
// pattern) rather than in a useEffect, since setState synchronously at the
// top of an effect body is flagged by react-hooks/set-state-in-effect.
const [trackedSelection, setTrackedSelection] = useState(selection);
if (trackedSelection !== selection) {
setTrackedSelection(selection);
setUrlDraft(selection.source === 'url' ? selection.url : '');
setFit(selection.source === 'url' ? selection.presentation.fit : 'cover');
setDim(selection.source === 'url' ? selection.presentation.dim : 0);
setPendingPreviewUrl(undefined);
}
const applyCurated = (id: string) => {
updateConfig((current) => ({ ...current, wallpaper: { version: 1, selection: { source: 'curated', id } } }));
setPendingPreviewUrl(undefined);
toast({ title: `Wallpaper set to ${resolveCurated(id).name}` });
};
const requestPreview = () => {
const trimmed = urlDraft.trim();
if (!isSafeWallpaperUrl(trimmed)) {
toast({ title: 'Enter a valid https:// image URL', variant: 'destructive' });
return;
}
setPendingPreviewUrl(trimmed);
};
const applyUrl = () => {
const previewedUrl = preview.url;
if (preview.status !== 'ready' || !previewedUrl || previewedUrl !== urlDraft.trim()) {
toast({ title: 'Preview the image before saving it', variant: 'destructive' });
return;
}
updateConfig((current) => ({
...current,
wallpaper: { version: 1, selection: { source: 'url', url: previewedUrl, presentation: { fit, dim } } },
}));
toast({ title: 'Wallpaper saved' });
};
const onFileSelected = async (file: File | undefined) => {
if (fileInputRef.current) fileInputRef.current.value = '';
if (!file) return;
if (!ALLOWED_WALLPAPER_TYPES.has(file.type)) {
toast({ title: 'Unsupported image type', description: 'Use JPEG, PNG, WebP, or GIF.', variant: 'destructive' });
return;
}
if (file.size > MAX_WALLPAPER_FILE_BYTES) {
toast({ title: 'Image is too large', description: 'The wallpaper image must be 5 MB or smaller.', variant: 'destructive' });
return;
}
if (!user) {
toast({ title: 'Sign in required', description: 'Sign in to upload your own image as a wallpaper.', variant: 'destructive' });
return;
}
try {
const bitmap = await createImageBitmap(file);
const tooLarge = bitmap.width > MAX_WALLPAPER_DIMENSION || bitmap.height > MAX_WALLPAPER_DIMENSION;
if (tooLarge) {
bitmap.close();
toast({ title: 'Image dimensions too large', description: `Each side must be ${MAX_WALLPAPER_DIMENSION}px or smaller.`, variant: 'destructive' });
return;
}
// Re-encoding through a canvas drops EXIF and any other embedded
// metadata from the original file before it ever leaves the device.
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
const ctx = canvas.getContext('2d');
if (!ctx) throw new Error('This browser cannot process images.');
ctx.drawImage(bitmap, 0, 0);
bitmap.close();
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92));
if (!blob) throw new Error('Could not process that image.');
const sanitizedFile = new File([blob], 'wallpaper.jpg', { type: 'image/jpeg' });
const tags = await uploadFile(sanitizedFile);
const uploadedUrl = tags.find(([name, value]) => name === 'url' && value)?.[1];
if (!uploadedUrl || !isSafeWallpaperUrl(uploadedUrl)) throw new Error('Upload did not return a safe image URL.');
setUrlDraft(uploadedUrl);
setPendingPreviewUrl(uploadedUrl);
toast({ title: 'Uploaded', description: 'Preview it below, then save it as your wallpaper.' });
} catch (error) {
toast({
title: 'Could not upload that image',
description: error instanceof Error ? error.message : 'Please try a different file.',
variant: 'destructive',
});
}
};
return (
<Section
title="Wallpaper"
description="Personalize the desktop background. Curated patterns recolor with your theme and never leave the device; a custom image is saved only in this browser."
>
<RadioGroup
value={selection.source === 'curated' ? selection.id : ''}
onValueChange={applyCurated}
aria-label="Curated wallpapers"
className="grid grid-cols-2 gap-3 sm:grid-cols-3"
>
{CURATED_WALLPAPERS.map((option) => {
const active = selection.source === 'curated' && selection.id === option.id;
return (
<label
key={option.id}
htmlFor={`wallpaper-${option.id}`}
className={cn(
'flex cursor-pointer flex-col gap-2 rounded-lg border p-2 text-left transition-colors',
'focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-ring',
active ? 'border-primary bg-accent' : 'border-border hover:bg-muted',
)}
>
<div
className={cn('h-16 w-full rounded-md border border-border/60', option.className)}
aria-hidden="true"
/>
<div className="flex items-center gap-2">
<RadioGroupItem value={option.id} id={`wallpaper-${option.id}`} />
<p className="min-w-0 truncate text-xs font-medium">{option.name}</p>
{active && <Check className="ml-auto size-3.5 shrink-0 text-primary" aria-hidden />}
</div>
</label>
);
})}
</RadioGroup>
<div className="space-y-3 rounded-lg border border-border p-3">
<div className="space-y-0.5">
<Label htmlFor="wallpaper-url" className="text-sm">Custom image</Label>
<p className="text-xs text-muted-foreground">
Must be an https:// URL. Loading a remote image reveals your IP address and the
time you viewed it to whoever hosts it it is not previewed until you ask.
</p>
</div>
<div className="flex gap-2">
<Input
id="wallpaper-url"
value={urlDraft}
onChange={(event) => { setUrlDraft(event.target.value); setPendingPreviewUrl(undefined); }}
placeholder="https://example.com/wallpaper.jpg"
className="font-mono text-xs"
/>
<Button variant="outline" onClick={requestPreview} disabled={!urlDraft.trim()}>
Preview
</Button>
</div>
<div className="flex flex-wrap items-center gap-2">
<Button variant="outline" size="sm" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
{uploading ? 'Uploading…' : 'Upload an image'}
</Button>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="sr-only"
aria-label="Upload a local image to use as wallpaper"
onChange={(event) => { void onFileSelected(event.target.files?.[0]); }}
/>
{!user && (
<span className="text-xs text-muted-foreground">
Sign in to upload your own image (stored on your Blossom server, which may make it publicly accessible).
</span>
)}
</div>
<div aria-live="polite">
{preview.status === 'loading' && (
<div className="flex h-24 items-center justify-center rounded-md border border-dashed border-border text-xs text-muted-foreground">
Loading preview
</div>
)}
{preview.status === 'error' && (
<p className="text-xs text-destructive">
Couldnt load that image. Your current wallpaper has not changed.
</p>
)}
{preview.status === 'ready' && preview.url && (
<img
src={preview.url}
alt="Wallpaper preview"
className={cn(
'h-24 w-full rounded-md border border-border bg-muted',
fit === 'contain' ? 'object-contain' : 'object-cover',
)}
/>
)}
</div>
<div className="flex flex-wrap items-center gap-4">
<fieldset className="flex items-center gap-2">
<legend className="text-xs text-muted-foreground">Fit</legend>
<div className="flex overflow-hidden rounded-md border border-border">
{(['cover', 'contain'] as const).map((option) => (
<button
key={option}
type="button"
onClick={() => setFit(option)}
aria-pressed={fit === option}
className={cn(
'px-2 py-1 text-xs capitalize transition-colors',
'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring',
fit === option ? 'bg-accent text-accent-foreground' : 'hover:bg-muted',
)}
>
{option}
</button>
))}
</div>
</fieldset>
<div className="flex min-w-40 flex-1 items-center gap-2">
<Label htmlFor="wallpaper-dim" className="shrink-0 text-xs text-muted-foreground">
Dim {dim}%
</Label>
<Slider
id="wallpaper-dim"
value={[dim]}
min={0}
max={80}
step={5}
onValueChange={([value]) => setDim(value)}
aria-label="Overlay darkness"
/>
</div>
</div>
<div className="flex gap-2">
<Button onClick={applyUrl} disabled={preview.status !== 'ready'}>
Save wallpaper
</Button>
<Button variant="outline" onClick={() => applyCurated(DEFAULT_CURATED_ID)}>
Reset to default
</Button>
</div>
</div>
</Section>
);
}
function RelaySection() {
const { config, updateConfig } = useAppContext();
const { toast } = useToast();
const [draft, setDraft] = useState('');
const relays = config.relayMetadata.relays;
const addRelay = () => {
const value = draft.trim();
if (!value) return;
let url: string;
try {
const parsed = new URL(value.startsWith('ws') ? value : `wss://${value}`);
if (parsed.protocol !== 'wss:' && parsed.protocol !== 'ws:') throw new Error('bad protocol');
url = parsed.href;
} catch {
toast({ title: 'That is not a valid relay URL', variant: 'destructive' });
return;
}
if (relays.some((relay) => relay.url === url)) {
toast({ title: 'That relay is already in your list' });
return;
}
updateConfig((current) => ({
...current,
relayMetadata: {
relays: [...relays, { url, read: true, write: true }],
updatedAt: Math.floor(Date.now() / 1000),
},
}));
setDraft('');
};
const update = (url: string, patch: { read?: boolean; write?: boolean }) => {
updateConfig((current) => ({
...current,
relayMetadata: {
relays: relays.map((relay) => (relay.url === url ? { ...relay, ...patch } : relay)),
updatedAt: Math.floor(Date.now() / 1000),
},
}));
};
const remove = (url: string) => {
updateConfig((current) => ({
...current,
relayMetadata: {
relays: relays.filter((relay) => relay.url !== url),
updatedAt: Math.floor(Date.now() / 1000),
},
}));
};
return (
<Section
title="Relays"
description="Read relays supply your feeds; write relays receive what you publish."
>
<ul className="divide-y divide-border rounded-lg border border-border">
{relays.map((relay) => (
<li key={relay.url} className="flex items-center gap-3 px-3 py-2">
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{relay.url.replace(/^wss:\/\//, '').replace(/\/$/, '')}
</span>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Switch
checked={relay.read}
onCheckedChange={(checked) => update(relay.url, { read: checked })}
aria-label={`Read from ${relay.url}`}
/>
read
</label>
<label className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Switch
checked={relay.write}
onCheckedChange={(checked) => update(relay.url, { write: checked })}
aria-label={`Write to ${relay.url}`}
/>
write
</label>
<Button
variant="ghost"
size="icon"
className="size-7"
onClick={() => remove(relay.url)}
aria-label={`Remove ${relay.url}`}
>
<Trash2 className="size-3.5" aria-hidden />
</Button>
</li>
))}
{relays.length === 0 && (
<li className="px-3 py-4 text-center text-sm text-muted-foreground">
No relays. Add one below.
</li>
)}
</ul>
<div className="flex gap-2">
<Input
value={draft}
onChange={(event) => setDraft(event.target.value)}
onKeyDown={(event) => event.key === 'Enter' && addRelay()}
placeholder="wss://relay.example.com"
className="font-mono text-xs"
aria-label="New relay URL"
/>
<Button onClick={addRelay} className="gap-1.5" disabled={!draft.trim()}>
<Plus className="size-4" aria-hidden />
Add
</Button>
</div>
</Section>
);
}
function MediaSection() {
const { config, updateConfig } = useAppContext();
return (
<Section
title="Media servers"
description="Blossom servers store the images and files you upload."
>
<div className="flex items-center justify-between gap-4 rounded-lg border border-border p-3">
<div className="space-y-0.5">
<Label htmlFor="app-blossom" className="text-sm">
Include the app defaults
</Label>
<p className="text-xs text-muted-foreground">
Falls back to this apps servers alongside your own.
</p>
</div>
<Switch
id="app-blossom"
checked={config.useAppBlossomServers}
onCheckedChange={(checked) =>
updateConfig((current) => ({ ...current, useAppBlossomServers: checked }))
}
/>
</div>
<ul className="divide-y divide-border rounded-lg border border-border">
{config.blossomServerMetadata.servers.map((server) => (
<li key={server} className="truncate px-3 py-2 font-mono text-xs">
{server}
</li>
))}
</ul>
</Section>
);
}
function SessionSection() {
const { resetSession } = useWindowManager();
const { toast } = useToast();
return (
<Section
title="Session"
description="Your open windows and their positions are remembered between visits."
>
<Button
variant="outline"
onClick={() => {
resetSession();
toast({ title: 'Session reset' });
}}
>
Close everything and forget the layout
</Button>
</Section>
);
}