1501 lines
51 KiB
TypeScript
1501 lines
51 KiB
TypeScript
import { type ReactNode, useState, useCallback, useMemo, useEffect, useRef, createContext, useContext } from 'react';
|
|
import { nip19, generateSecretKey, getPublicKey } from 'nostr-tools';
|
|
import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify';
|
|
import { useNostr } from '@nostrify/react';
|
|
import { DittoLogo } from '@/components/DittoLogo';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { type Theme, type ContentWarningPolicy } from '@/contexts/AppContext';
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
import { useInitialSync, type SyncPhase } from '@/hooks/useInitialSync';
|
|
import { useEncryptedSettings } from '@/hooks/useEncryptedSettings';
|
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
|
import { useNostrPublish } from '@/hooks/useNostrPublish';
|
|
import { useLoginActions } from '@/hooks/useLoginActions';
|
|
import { useUploadFile } from '@/hooks/useUploadFile';
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
import { useAuthors } from '@/hooks/useAuthors';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { toast } from '@/hooks/useToast';
|
|
import { cn } from '@/lib/utils';
|
|
import {
|
|
Check,
|
|
ChevronRight,
|
|
Eye,
|
|
EyeOff,
|
|
Shield,
|
|
Clapperboard,
|
|
BarChart3,
|
|
Palette,
|
|
Users,
|
|
Radio,
|
|
UserPlus,
|
|
Loader2,
|
|
Heart,
|
|
Download,
|
|
Upload,
|
|
ChevronDown,
|
|
ChevronUp,
|
|
Plus,
|
|
Trash2,
|
|
type LucideIcon,
|
|
} from 'lucide-react';
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible';
|
|
import { ChestIcon } from '@/components/icons/ChestIcon';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Onboarding context — lets any component trigger the signup onboarding
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface OnboardingContextValue {
|
|
startSignup: () => void;
|
|
}
|
|
|
|
const OnboardingContext = createContext<OnboardingContextValue>({
|
|
startSignup: () => {},
|
|
});
|
|
|
|
/** Call `startSignup()` from anywhere to open the full-screen signup onboarding. */
|
|
export function useOnboarding() {
|
|
return useContext(OnboardingContext);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// InitialSyncGate
|
|
// ---------------------------------------------------------------------------
|
|
|
|
interface InitialSyncGateProps {
|
|
children: ReactNode;
|
|
}
|
|
|
|
/**
|
|
* Gates the main app behind an initial sync / setup flow for logged-in users.
|
|
* - Logged-out users pass straight through.
|
|
* - Logged-in users see a sync spinner, then either proceed (settings found)
|
|
* or walk through a brief questionnaire (fresh account / new device with no settings).
|
|
* - Also provides `useOnboarding().startSignup()` for triggering signup from anywhere.
|
|
*/
|
|
export function InitialSyncGate({ children }: InitialSyncGateProps) {
|
|
const { user } = useCurrentUser();
|
|
const { phase, markComplete } = useInitialSync();
|
|
const [preloadApp, setPreloadApp] = useState(false);
|
|
const [signupActive, setSignupActive] = useState(false);
|
|
|
|
const startSignup = useCallback(() => setSignupActive(true), []);
|
|
|
|
const handleSignupComplete = useCallback(() => {
|
|
setSignupActive(false);
|
|
markComplete();
|
|
}, [markComplete]);
|
|
|
|
const contextValue = useMemo(() => ({ startSignup }), [startSignup]);
|
|
|
|
// Signup flow takes priority (doesn't require a logged-in user yet)
|
|
if (signupActive) {
|
|
return (
|
|
<OnboardingContext.Provider value={contextValue}>
|
|
{preloadApp && <div className="invisible">{children}</div>}
|
|
<SetupQuestionnaire
|
|
onComplete={handleSignupComplete}
|
|
onPreload={() => setPreloadApp(true)}
|
|
isSignup
|
|
/>
|
|
</OnboardingContext.Provider>
|
|
);
|
|
}
|
|
|
|
// Don't show sync/onboarding when logged out — just show the app
|
|
if (!user) {
|
|
return (
|
|
<OnboardingContext.Provider value={contextValue}>
|
|
{children}
|
|
</OnboardingContext.Provider>
|
|
);
|
|
}
|
|
|
|
// Normal logged-in sync flow
|
|
if (phase === 'syncing' || phase === 'found') {
|
|
return (
|
|
<OnboardingContext.Provider value={contextValue}>
|
|
<SyncScreen phase={phase} />
|
|
</OnboardingContext.Provider>
|
|
);
|
|
}
|
|
|
|
if (phase === 'not-found') {
|
|
return (
|
|
<OnboardingContext.Provider value={contextValue}>
|
|
{preloadApp && <div className="invisible">{children}</div>}
|
|
<SetupQuestionnaire
|
|
onComplete={markComplete}
|
|
onPreload={() => setPreloadApp(true)}
|
|
/>
|
|
</OnboardingContext.Provider>
|
|
);
|
|
}
|
|
|
|
// idle or complete -> show app
|
|
return (
|
|
<OnboardingContext.Provider value={contextValue}>
|
|
{children}
|
|
</OnboardingContext.Provider>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Sync Screen
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function SyncScreen({ phase }: { phase: SyncPhase }) {
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background">
|
|
<div className="flex flex-col items-center gap-8 px-6 text-center max-w-sm">
|
|
{/* Logo with gentle pulse */}
|
|
<div className="relative">
|
|
<div className="absolute inset-0 rounded-full bg-primary/10 animate-ping opacity-30" />
|
|
<DittoLogo size={72} className="relative" />
|
|
</div>
|
|
|
|
{/* Spinner */}
|
|
<div className="flex flex-col items-center gap-4">
|
|
<div className="relative w-10 h-10">
|
|
<div className="absolute inset-0 rounded-full border-[2.5px] border-primary/20" />
|
|
<div className="absolute inset-0 rounded-full border-[2.5px] border-transparent border-t-primary animate-spin" />
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<p className="text-sm font-medium text-foreground">
|
|
{phase === 'found' ? 'Settings restored' : 'Syncing your settings...'}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{phase === 'found'
|
|
? 'Welcome back! Loading your experience...'
|
|
: 'Checking for your preferences across devices'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{phase === 'syncing' && (
|
|
<div className="flex gap-1.5">
|
|
{[0, 1, 2].map((i) => (
|
|
<div
|
|
key={i}
|
|
className="w-1.5 h-1.5 rounded-full bg-primary/40 animate-pulse"
|
|
style={{ animationDelay: `${i * 200}ms` }}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{phase === 'found' && (
|
|
<div className="flex items-center gap-2 text-primary">
|
|
<Check className="w-4 h-4" />
|
|
<span className="text-sm font-medium">All set</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Setup Questionnaire
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const THEMES: { value: Theme; label: string; description: string; preview: string; splitPreview?: boolean }[] = [
|
|
{ value: 'system', label: 'System', description: 'Matches your device', preview: '', splitPreview: true },
|
|
{ value: 'dark', label: 'Dark', description: 'Deep purple dark theme', preview: 'bg-[hsl(228,20%,10%)]' },
|
|
{ value: 'light', label: 'Light', description: 'Clean and bright', preview: 'bg-white border border-border' },
|
|
{ value: 'black', label: 'Black', description: 'True OLED black', preview: 'bg-black' },
|
|
{ value: 'pink', label: 'Pink', description: 'Warm and playful', preview: 'bg-[hsl(330,100%,96%)]' },
|
|
];
|
|
|
|
interface ContentKind {
|
|
key: string;
|
|
label: string;
|
|
description: string;
|
|
icon: LucideIcon | React.ComponentType<{ className?: string }>;
|
|
sidebarKey: string;
|
|
feedKey: string;
|
|
}
|
|
|
|
const CONTENT_KINDS: ContentKind[] = [
|
|
{ key: 'vines', label: 'Vines', description: 'Short video clips', icon: Clapperboard, sidebarKey: 'showVines', feedKey: 'feedIncludeVines' },
|
|
{ key: 'streams', label: 'Streams', description: 'Live broadcasts', icon: Radio, sidebarKey: 'showStreams', feedKey: 'feedIncludeStreams' },
|
|
{ key: 'polls', label: 'Polls', description: 'Community polls', icon: BarChart3, sidebarKey: 'showPolls', feedKey: 'feedIncludePolls' },
|
|
{ key: 'packs', label: 'Follow Packs', description: 'Curated follow lists', icon: Users, sidebarKey: 'showPacks', feedKey: 'feedIncludePacks' },
|
|
{ key: 'colors', label: 'Colors', description: 'Color palette sharing', icon: Palette, sidebarKey: 'showColors', feedKey: 'feedIncludeColors' },
|
|
{ key: 'treasures', label: 'Treasures', description: 'Geocaching adventures', icon: ChestIcon, sidebarKey: 'showTreasures', feedKey: 'feedIncludeTreasureGeocaches' },
|
|
];
|
|
|
|
const CW_OPTIONS: { value: ContentWarningPolicy; label: string; description: string; icon: typeof Eye }[] = [
|
|
{ value: 'blur', label: 'Blur', description: 'Blur sensitive content until you tap', icon: Shield },
|
|
{ value: 'hide', label: 'Hide', description: 'Remove sensitive content entirely', icon: EyeOff },
|
|
{ value: 'show', label: 'Show', description: 'Display all content without warnings', icon: Eye },
|
|
];
|
|
|
|
/** Suggested follow packs shown to new users with empty follow lists. */
|
|
const SUGGESTED_PACKS: { kind: number; pubkey: string; identifier: string }[] = [
|
|
{ kind: 39089, pubkey: '932614571afcbad4d17a191ee281e39eebbb41b93fac8fd87829622aeb112f4d', identifier: 'k4p5w0n22suf' },
|
|
];
|
|
|
|
// Steps for signup (includes keygen + profile) vs. settings-only (existing login)
|
|
type SignupStep = 'keygen' | 'download' | 'profile';
|
|
type SettingsStep = 'welcome' | 'theme' | 'content' | 'safety' | 'follows' | 'outro';
|
|
type Step = SignupStep | SettingsStep;
|
|
|
|
const SIGNUP_STEPS: Step[] = ['keygen', 'download', 'profile', 'welcome', 'theme', 'content', 'safety', 'follows', 'outro'];
|
|
const SETTINGS_STEPS: Step[] = ['welcome', 'theme', 'content', 'safety', 'follows', 'outro'];
|
|
|
|
function SetupQuestionnaire({ onComplete, onPreload, isSignup = false }: {
|
|
onComplete: () => void;
|
|
onPreload: () => void;
|
|
isSignup?: boolean;
|
|
}) {
|
|
const { nostr } = useNostr();
|
|
const { updateConfig } = useAppContext();
|
|
const { user } = useCurrentUser();
|
|
const { updateSettings } = useEncryptedSettings();
|
|
const login = useLoginActions();
|
|
|
|
const steps = isSignup ? SIGNUP_STEPS : SETTINGS_STEPS;
|
|
|
|
const [step, setStep] = useState<Step>(steps[0]);
|
|
const [selectedTheme, setSelectedTheme] = useState<Theme>('dark');
|
|
const [selectedContent, setSelectedContent] = useState<Set<string>>(
|
|
new Set(['vines', 'streams']),
|
|
);
|
|
const [selectedCW, setSelectedCW] = useState<ContentWarningPolicy>('blur');
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [hasFollows, setHasFollows] = useState<boolean | null>(null);
|
|
|
|
// Signup-specific state
|
|
const [nsec, setNsec] = useState('');
|
|
|
|
const stepIndex = steps.indexOf(step);
|
|
const progress = ((stepIndex) / (steps.length - 1)) * 100;
|
|
|
|
const goTo = useCallback((target: Step) => setStep(target), []);
|
|
|
|
const next = useCallback(() => {
|
|
const i = steps.indexOf(step);
|
|
if (i < steps.length - 1) {
|
|
setStep(steps[i + 1]);
|
|
}
|
|
}, [step, steps]);
|
|
|
|
const back = useCallback(() => {
|
|
const i = steps.indexOf(step);
|
|
if (i > 0) {
|
|
setStep(steps[i - 1]);
|
|
}
|
|
}, [step, steps]);
|
|
|
|
const toggleContent = useCallback((key: string) => {
|
|
setSelectedContent((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(key)) {
|
|
next.delete(key);
|
|
} else {
|
|
next.add(key);
|
|
}
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Keygen handler
|
|
const handleGenerate = useCallback(() => {
|
|
const sk = generateSecretKey();
|
|
const encoded = nip19.nsecEncode(sk);
|
|
setNsec(encoded);
|
|
next();
|
|
}, [next]);
|
|
|
|
// Download + login handler
|
|
const handleDownloadAndLogin = useCallback(() => {
|
|
try {
|
|
const decoded = nip19.decode(nsec);
|
|
if (decoded.type !== 'nsec') throw new Error('Invalid nsec');
|
|
|
|
const pubkey = getPublicKey(decoded.data);
|
|
const npub = nip19.npubEncode(pubkey);
|
|
const filename = `nostr-${location.hostname.replaceAll(/\./g, '-')}-${npub.slice(5, 9)}.nsec.txt`;
|
|
|
|
const blob = new Blob([nsec], { type: 'text/plain; charset=utf-8' });
|
|
const url = globalThis.URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = filename;
|
|
a.style.display = 'none';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
globalThis.URL.revokeObjectURL(url);
|
|
document.body.removeChild(a);
|
|
|
|
// Log in with the new key
|
|
login.nsec(nsec);
|
|
next();
|
|
} catch {
|
|
toast({
|
|
title: 'Download failed',
|
|
description: 'Could not download the key file. Please copy it manually.',
|
|
variant: 'destructive',
|
|
});
|
|
}
|
|
}, [nsec, login, next]);
|
|
|
|
// Save settings and transition to the follows step (or outro if they have follows)
|
|
const handleSaveAndContinue = useCallback(async () => {
|
|
setIsSaving(true);
|
|
|
|
const feedSettings = {
|
|
showVines: selectedContent.has('vines'),
|
|
showPolls: selectedContent.has('polls'),
|
|
feedIncludePosts: true,
|
|
feedIncludeReposts: true,
|
|
feedIncludeArticles: false,
|
|
showArticles: false,
|
|
showTreasures: selectedContent.has('treasures'),
|
|
showTreasureGeocaches: true,
|
|
showTreasureFoundLogs: true,
|
|
showColors: selectedContent.has('colors'),
|
|
showPacks: selectedContent.has('packs'),
|
|
showStreams: selectedContent.has('streams'),
|
|
feedIncludeVines: selectedContent.has('vines'),
|
|
feedIncludePolls: selectedContent.has('polls'),
|
|
feedIncludeTreasureGeocaches: selectedContent.has('treasures'),
|
|
feedIncludeTreasureFoundLogs: selectedContent.has('treasures'),
|
|
feedIncludeColors: selectedContent.has('colors'),
|
|
feedIncludePacks: selectedContent.has('packs'),
|
|
feedIncludeStreams: selectedContent.has('streams'),
|
|
showDecks: selectedContent.has('decks'),
|
|
feedIncludeDecks: selectedContent.has('decks'),
|
|
};
|
|
|
|
updateConfig((current) => ({
|
|
...current,
|
|
theme: selectedTheme,
|
|
feedSettings,
|
|
contentWarningPolicy: selectedCW,
|
|
}));
|
|
|
|
if (user?.signer.nip44) {
|
|
try {
|
|
await updateSettings.mutateAsync({
|
|
theme: selectedTheme,
|
|
feedSettings,
|
|
contentWarningPolicy: selectedCW,
|
|
});
|
|
} catch (error) {
|
|
console.warn('Failed to save initial settings to Nostr:', error);
|
|
}
|
|
}
|
|
|
|
// Check if the user already has a follow list
|
|
let userHasFollows = false;
|
|
if (user) {
|
|
try {
|
|
const events = await nostr.query(
|
|
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
|
|
{ signal: AbortSignal.timeout(5000) },
|
|
);
|
|
if (events.length > 0) {
|
|
const pTags = events[0].tags.filter(([n]) => n === 'p');
|
|
userHasFollows = pTags.length > 0;
|
|
}
|
|
} catch {
|
|
userHasFollows = true;
|
|
}
|
|
}
|
|
|
|
setHasFollows(userHasFollows);
|
|
setIsSaving(false);
|
|
|
|
if (userHasFollows) {
|
|
goTo('outro');
|
|
} else {
|
|
goTo('follows');
|
|
}
|
|
}, [selectedTheme, selectedContent, selectedCW, updateConfig, updateSettings, user, nostr, goTo]);
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex flex-col bg-background">
|
|
{/* Progress bar */}
|
|
<div className="h-1 bg-muted">
|
|
<div
|
|
className="h-full bg-primary transition-all duration-500 ease-out"
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
|
|
{/* Content area */}
|
|
<div className="flex-1 flex items-center justify-center overflow-y-auto">
|
|
<div className="w-full max-w-md px-6 py-12">
|
|
{/* Signup steps */}
|
|
{step === 'keygen' && (
|
|
<KeygenStep onGenerate={handleGenerate} />
|
|
)}
|
|
|
|
{step === 'download' && (
|
|
<DownloadStep nsec={nsec} onDownload={handleDownloadAndLogin} />
|
|
)}
|
|
|
|
{step === 'profile' && (
|
|
<ProfileStep onNext={next} />
|
|
)}
|
|
|
|
{/* Settings steps */}
|
|
{step === 'welcome' && (
|
|
<WelcomeStep onNext={next} isSignup={isSignup} />
|
|
)}
|
|
|
|
{step === 'theme' && (
|
|
<ThemeStep
|
|
selected={selectedTheme}
|
|
onSelect={(t) => {
|
|
setSelectedTheme(t);
|
|
updateConfig((c) => ({ ...c, theme: t }));
|
|
}}
|
|
onNext={next}
|
|
onBack={back}
|
|
/>
|
|
)}
|
|
|
|
{step === 'content' && (
|
|
<ContentStep
|
|
selected={selectedContent}
|
|
onToggle={toggleContent}
|
|
onNext={next}
|
|
onBack={back}
|
|
/>
|
|
)}
|
|
|
|
{step === 'safety' && (
|
|
<SafetyStep
|
|
selected={selectedCW}
|
|
onSelect={setSelectedCW}
|
|
onNext={handleSaveAndContinue}
|
|
onBack={back}
|
|
isSaving={isSaving}
|
|
/>
|
|
)}
|
|
|
|
{step === 'follows' && hasFollows === false && (
|
|
<FollowsStep
|
|
onNext={(didFollow) => {
|
|
if (didFollow) onPreload();
|
|
goTo('outro');
|
|
}}
|
|
onBack={() => goTo('safety')}
|
|
/>
|
|
)}
|
|
|
|
{step === 'outro' && (
|
|
<OutroStep onComplete={onComplete} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Signup steps: Keygen, Download, Profile
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function KeygenStep({ onGenerate }: { onGenerate: () => void }) {
|
|
return (
|
|
<div className="flex flex-col items-center text-center gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
<DittoLogo size={80} />
|
|
|
|
<div className="space-y-3">
|
|
<h1 className="text-2xl font-bold tracking-tight">
|
|
Create your account
|
|
</h1>
|
|
<p className="text-muted-foreground text-sm leading-relaxed max-w-xs mx-auto">
|
|
Your identity on Nostr is a cryptographic key pair. We'll generate one for you now.
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
size="lg"
|
|
className="w-full max-w-xs gap-2 rounded-full h-12"
|
|
onClick={onGenerate}
|
|
>
|
|
Generate my keys
|
|
<ChevronRight className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function DownloadStep({ nsec, onDownload }: { nsec: string; onDownload: () => void }) {
|
|
const [showKey, setShowKey] = useState(false);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-right-4 duration-400">
|
|
<div className="space-y-2">
|
|
<h2 className="text-xl font-semibold tracking-tight">Save your secret key</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
This is your only way to access your account. Download it and keep it somewhere safe.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="relative">
|
|
<Input
|
|
type={showKey ? 'text' : 'password'}
|
|
value={nsec}
|
|
readOnly
|
|
className="pr-10 font-mono text-sm"
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="absolute right-0 top-0 h-full px-3 hover:bg-transparent"
|
|
onClick={() => setShowKey(!showKey)}
|
|
>
|
|
{showKey ? (
|
|
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="p-3 bg-amber-50 dark:bg-amber-950/20 rounded-lg border border-amber-200 dark:border-amber-800">
|
|
<p className="text-xs font-semibold text-amber-800 dark:text-amber-200 mb-1">
|
|
Important
|
|
</p>
|
|
<p className="text-xs text-amber-900 dark:text-amber-300">
|
|
This key is your only means of accessing your account. If you lose it, there is no way to recover it. Download it now to continue.
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
size="lg"
|
|
className="w-full gap-2 rounded-full h-12"
|
|
onClick={onDownload}
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
Download and continue
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ProfileStep({ onNext }: { onNext: () => void }) {
|
|
const { user } = useCurrentUser();
|
|
const queryClient = useQueryClient();
|
|
const { mutateAsync: publishEvent, isPending: isPublishing } = useNostrPublish();
|
|
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
|
|
const avatarFileInputRef = useRef<HTMLInputElement>(null);
|
|
const bannerFileInputRef = useRef<HTMLInputElement>(null);
|
|
const [moreOpen, setMoreOpen] = useState(false);
|
|
|
|
const [profileData, setProfileData] = useState({
|
|
name: '', about: '', picture: '', banner: '', website: '',
|
|
fields: [] as Array<{ label: string; value: string }>,
|
|
});
|
|
|
|
const handleImageUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>, field: 'picture' | 'banner') => {
|
|
const file = e.target.files?.[0];
|
|
if (!file) return;
|
|
e.target.value = '';
|
|
|
|
if (!file.type.startsWith('image/')) {
|
|
toast({ title: 'Invalid file type', description: 'Please select an image file.', variant: 'destructive' });
|
|
return;
|
|
}
|
|
if (file.size > 5 * 1024 * 1024) {
|
|
toast({ title: 'File too large', description: 'Image must be smaller than 5MB.', variant: 'destructive' });
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const tags = await uploadFile(file);
|
|
const url = tags[0]?.[1];
|
|
if (url) setProfileData((prev) => ({ ...prev, [field]: url }));
|
|
} catch {
|
|
toast({ title: 'Upload failed', description: 'Failed to upload image.', variant: 'destructive' });
|
|
}
|
|
}, [uploadFile]);
|
|
|
|
const handlePublishProfile = useCallback(async () => {
|
|
if (!user) return;
|
|
|
|
const metadata: Record<string, unknown> = {};
|
|
if (profileData.name) metadata.name = profileData.name;
|
|
if (profileData.about) metadata.about = profileData.about;
|
|
if (profileData.picture) metadata.picture = profileData.picture;
|
|
if (profileData.banner) metadata.banner = profileData.banner;
|
|
if (profileData.website) metadata.website = profileData.website;
|
|
const validFields = profileData.fields.filter((f) => f.label.trim() && f.value.trim());
|
|
if (validFields.length > 0) metadata.fields = validFields.map((f) => [f.label, f.value]);
|
|
|
|
if (Object.keys(metadata).length > 0) {
|
|
try {
|
|
await publishEvent({ kind: 0, content: JSON.stringify(metadata) });
|
|
// Update local caches so the profile is visible immediately
|
|
queryClient.invalidateQueries({ queryKey: ['logins'] });
|
|
queryClient.invalidateQueries({ queryKey: ['author', user.pubkey] });
|
|
} catch {
|
|
toast({ title: 'Profile failed', description: 'Your account was created but profile setup failed. You can update it later.', variant: 'destructive' });
|
|
}
|
|
}
|
|
|
|
onNext();
|
|
}, [user, profileData, publishEvent, queryClient, onNext]);
|
|
|
|
const addField = useCallback(() => {
|
|
setProfileData((prev) => ({ ...prev, fields: [...prev.fields, { label: '', value: '' }] }));
|
|
}, []);
|
|
|
|
const removeField = useCallback((index: number) => {
|
|
setProfileData((prev) => ({ ...prev, fields: prev.fields.filter((_, i) => i !== index) }));
|
|
}, []);
|
|
|
|
const updateField = useCallback((index: number, key: 'label' | 'value', val: string) => {
|
|
setProfileData((prev) => ({
|
|
...prev,
|
|
fields: prev.fields.map((f, i) => i === index ? { ...f, [key]: val } : f),
|
|
}));
|
|
}, []);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-right-4 duration-400">
|
|
<div className="flex items-center gap-4">
|
|
<img
|
|
src="/profile-intro.png"
|
|
alt=""
|
|
className="w-40 shrink-0 mix-blend-difference opacity-80"
|
|
/>
|
|
<div className="space-y-2">
|
|
<h2 className="text-xl font-semibold tracking-tight">Set up your profile</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Tell people a bit about yourself. You can always change this later.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={cn('space-y-4', isPublishing && 'opacity-50 pointer-events-none')}>
|
|
<div className="space-y-1.5">
|
|
<label htmlFor="onboard-name" className="text-sm font-medium">Display Name</label>
|
|
<Input
|
|
id="onboard-name"
|
|
value={profileData.name}
|
|
onChange={(e) => setProfileData((prev) => ({ ...prev, name: e.target.value }))}
|
|
placeholder="Your name"
|
|
disabled={isPublishing}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label htmlFor="onboard-about" className="text-sm font-medium">Bio</label>
|
|
<Textarea
|
|
id="onboard-about"
|
|
value={profileData.about}
|
|
onChange={(e) => setProfileData((prev) => ({ ...prev, about: e.target.value }))}
|
|
placeholder="Tell others about yourself..."
|
|
className="resize-none"
|
|
rows={3}
|
|
disabled={isPublishing}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label htmlFor="onboard-avatar" className="text-sm font-medium">Avatar</label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="onboard-avatar"
|
|
value={profileData.picture}
|
|
onChange={(e) => setProfileData((prev) => ({ ...prev, picture: e.target.value }))}
|
|
placeholder="https://example.com/avatar.jpg"
|
|
className="flex-1"
|
|
disabled={isPublishing}
|
|
/>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
ref={avatarFileInputRef}
|
|
onChange={(e) => handleImageUpload(e, 'picture')}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => avatarFileInputRef.current?.click()}
|
|
disabled={isUploading || isPublishing}
|
|
>
|
|
{isUploading ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Upload className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* More details */}
|
|
<Collapsible open={moreOpen} onOpenChange={setMoreOpen}>
|
|
<CollapsibleTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="flex items-center gap-1.5 text-sm font-medium text-primary hover:underline mx-auto"
|
|
>
|
|
More details
|
|
{moreOpen ? (
|
|
<ChevronUp className="w-3.5 h-3.5" />
|
|
) : (
|
|
<ChevronDown className="w-3.5 h-3.5" />
|
|
)}
|
|
</button>
|
|
</CollapsibleTrigger>
|
|
<CollapsibleContent className="space-y-4 pt-4 animate-in fade-in slide-in-from-top-2 duration-200">
|
|
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
Add a banner image, website, and custom profile fields. These help others learn more about you and are visible on your profile page.
|
|
</p>
|
|
|
|
<div className="space-y-1.5">
|
|
<label htmlFor="onboard-banner" className="text-sm font-medium">Banner</label>
|
|
<div className="flex gap-2">
|
|
<Input
|
|
id="onboard-banner"
|
|
value={profileData.banner}
|
|
onChange={(e) => setProfileData((prev) => ({ ...prev, banner: e.target.value }))}
|
|
placeholder="https://example.com/banner.jpg"
|
|
className="flex-1"
|
|
disabled={isPublishing}
|
|
/>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
className="hidden"
|
|
ref={bannerFileInputRef}
|
|
onChange={(e) => handleImageUpload(e, 'banner')}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => bannerFileInputRef.current?.click()}
|
|
disabled={isUploading || isPublishing}
|
|
>
|
|
{isUploading ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Upload className="w-4 h-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
{profileData.banner && (
|
|
<div className="h-20 w-full rounded-lg overflow-hidden border">
|
|
<img src={profileData.banner} alt="Banner preview" className="h-full w-full object-cover" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-1.5">
|
|
<label htmlFor="onboard-website" className="text-sm font-medium">Website</label>
|
|
<Input
|
|
id="onboard-website"
|
|
value={profileData.website}
|
|
onChange={(e) => setProfileData((prev) => ({ ...prev, website: e.target.value }))}
|
|
placeholder="https://yourwebsite.com"
|
|
disabled={isPublishing}
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<label className="text-sm font-medium">Profile Fields</label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addField}
|
|
className="h-7 text-xs gap-1"
|
|
disabled={isPublishing}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
Add
|
|
</Button>
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
Custom label/value pairs like social links, Bitcoin address, or anything else.
|
|
</p>
|
|
{profileData.fields.length > 0 && (
|
|
<div className="space-y-2 pt-1">
|
|
{profileData.fields.map((field, index) => (
|
|
<div key={index} className="grid grid-cols-[1fr,2fr,auto] gap-2 items-center">
|
|
<Input
|
|
value={field.label}
|
|
onChange={(e) => updateField(index, 'label', e.target.value)}
|
|
placeholder="Label"
|
|
className="h-8 text-sm"
|
|
disabled={isPublishing}
|
|
/>
|
|
<Input
|
|
value={field.value}
|
|
onChange={(e) => updateField(index, 'value', e.target.value)}
|
|
placeholder="Value or URL"
|
|
className="h-8 text-sm"
|
|
disabled={isPublishing}
|
|
/>
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => removeField(index)}
|
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
|
disabled={isPublishing}
|
|
>
|
|
<Trash2 className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={onNext}
|
|
className="flex-1 rounded-full h-11"
|
|
disabled={isPublishing}
|
|
>
|
|
Skip
|
|
</Button>
|
|
<Button
|
|
onClick={handlePublishProfile}
|
|
className="flex-1 rounded-full h-11 gap-1.5"
|
|
disabled={isPublishing || isUploading}
|
|
>
|
|
{isPublishing ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
<>
|
|
Continue
|
|
<ChevronRight className="w-4 h-4" />
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Settings steps
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function WelcomeStep({ onNext, isSignup = false }: { onNext: () => void; isSignup?: boolean }) {
|
|
return (
|
|
<div className="flex flex-col items-center text-center gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
<DittoLogo size={80} />
|
|
|
|
<div className="space-y-3">
|
|
<h1 className="text-2xl font-bold tracking-tight">
|
|
{isSignup ? 'Now, let\'s personalize' : 'Welcome to Ditto'}
|
|
</h1>
|
|
<p className="text-muted-foreground text-sm leading-relaxed max-w-xs mx-auto">
|
|
{isSignup
|
|
? 'A few quick preferences to make Ditto feel like yours. You can always change these later in Settings.'
|
|
: 'Let\'s personalize your experience. This only takes a moment and you can always change these later in Settings.'}
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
size="lg"
|
|
className="w-full max-w-xs gap-2 rounded-full h-12"
|
|
onClick={onNext}
|
|
>
|
|
{isSignup ? 'Continue' : 'Get started'}
|
|
<ChevronRight className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ThemeStep({
|
|
selected,
|
|
onSelect,
|
|
onNext,
|
|
onBack,
|
|
}: {
|
|
selected: Theme;
|
|
onSelect: (t: Theme) => void;
|
|
onNext: () => void;
|
|
onBack: () => void;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-8 animate-in fade-in slide-in-from-right-4 duration-400">
|
|
<div className="space-y-2">
|
|
<h2 className="text-xl font-semibold tracking-tight">Choose your look</h2>
|
|
<p className="text-sm text-muted-foreground">Pick a theme that feels right.</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3">
|
|
{THEMES.map((theme) => (
|
|
<button
|
|
key={theme.value}
|
|
type="button"
|
|
onClick={() => onSelect(theme.value)}
|
|
className={cn(
|
|
'group relative flex flex-col items-center gap-3 p-4 rounded-xl transition-all duration-200',
|
|
'hover:bg-muted/50',
|
|
selected === theme.value
|
|
? 'ring-2 ring-primary bg-primary/5'
|
|
: 'ring-1 ring-border',
|
|
)}
|
|
>
|
|
{theme.splitPreview ? (
|
|
<div className="w-14 h-14 rounded-full overflow-hidden transition-transform duration-200 group-hover:scale-110 flex">
|
|
<div className="w-1/2 h-full bg-white" />
|
|
<div className="w-1/2 h-full bg-[hsl(228,20%,10%)]" />
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={cn(
|
|
'w-14 h-14 rounded-full transition-transform duration-200 group-hover:scale-110',
|
|
theme.preview,
|
|
)}
|
|
/>
|
|
)}
|
|
<div className="space-y-0.5 text-center">
|
|
<p className="text-sm font-medium">{theme.label}</p>
|
|
<p className="text-xs text-muted-foreground">{theme.description}</p>
|
|
</div>
|
|
{selected === theme.value && (
|
|
<div className="absolute top-2 right-2">
|
|
<Check className="w-4 h-4 text-primary" />
|
|
</div>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<StepNav onBack={onBack} onNext={onNext} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ContentStep({
|
|
selected,
|
|
onToggle,
|
|
onNext,
|
|
onBack,
|
|
}: {
|
|
selected: Set<string>;
|
|
onToggle: (key: string) => void;
|
|
onNext: () => void;
|
|
onBack: () => void;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-8 animate-in fade-in slide-in-from-right-4 duration-400">
|
|
<div className="space-y-2">
|
|
<h2 className="text-xl font-semibold tracking-tight">What interests you?</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Enable content types to see in your sidebar and feed. You can change these anytime.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{CONTENT_KINDS.map((kind) => {
|
|
const isSelected = selected.has(kind.key);
|
|
return (
|
|
<button
|
|
key={kind.key}
|
|
type="button"
|
|
onClick={() => onToggle(kind.key)}
|
|
className={cn(
|
|
'w-full flex items-center gap-4 p-3.5 rounded-xl transition-all duration-200 text-left',
|
|
isSelected
|
|
? 'ring-2 ring-primary bg-primary/5'
|
|
: 'ring-1 ring-border hover:bg-muted/50',
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0',
|
|
isSelected ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground',
|
|
)}>
|
|
<kind.icon className="w-5 h-5" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium">{kind.label}</p>
|
|
<p className="text-xs text-muted-foreground">{kind.description}</p>
|
|
</div>
|
|
<div
|
|
className={cn(
|
|
'w-5 h-5 rounded-md flex items-center justify-center flex-shrink-0 transition-colors',
|
|
isSelected
|
|
? 'bg-primary text-primary-foreground'
|
|
: 'border border-input',
|
|
)}
|
|
>
|
|
{isSelected && <Check className="w-3.5 h-3.5" />}
|
|
</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<StepNav onBack={onBack} onNext={onNext} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SafetyStep({
|
|
selected,
|
|
onSelect,
|
|
onNext,
|
|
onBack,
|
|
isSaving,
|
|
}: {
|
|
selected: ContentWarningPolicy;
|
|
onSelect: (p: ContentWarningPolicy) => void;
|
|
onNext: () => void;
|
|
onBack: () => void;
|
|
isSaving: boolean;
|
|
}) {
|
|
return (
|
|
<div className="flex flex-col gap-8 animate-in fade-in slide-in-from-right-4 duration-400">
|
|
<div className="space-y-2">
|
|
<h2 className="text-xl font-semibold tracking-tight">Content safety</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Choose how to handle posts marked with content warnings.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
{CW_OPTIONS.map((option) => {
|
|
const Icon = option.icon;
|
|
const isSelected = selected === option.value;
|
|
return (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => onSelect(option.value)}
|
|
disabled={isSaving}
|
|
className={cn(
|
|
'w-full flex items-center gap-4 p-4 rounded-xl transition-all duration-200 text-left',
|
|
isSelected
|
|
? 'ring-2 ring-primary bg-primary/5'
|
|
: 'ring-1 ring-border hover:bg-muted/50',
|
|
isSaving && 'opacity-50 pointer-events-none',
|
|
)}
|
|
>
|
|
<div className={cn(
|
|
'w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0',
|
|
isSelected ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground',
|
|
)}>
|
|
<Icon className="w-5 h-5" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium">{option.label}</p>
|
|
<p className="text-xs text-muted-foreground">{option.description}</p>
|
|
</div>
|
|
{isSelected && (
|
|
<Check className="w-4 h-4 text-primary flex-shrink-0" />
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={onBack}
|
|
className="flex-1 rounded-full h-11"
|
|
disabled={isSaving}
|
|
>
|
|
Back
|
|
</Button>
|
|
<Button
|
|
onClick={onNext}
|
|
className="flex-1 rounded-full h-11 gap-1.5"
|
|
disabled={isSaving}
|
|
>
|
|
{isSaving ? (
|
|
<>
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
Saving...
|
|
</>
|
|
) : (
|
|
<>
|
|
Continue
|
|
<ChevronRight className="w-4 h-4" />
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Follow Packs Step
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Parse a follow pack event into structured data. */
|
|
function parsePackEvent(event: NostrEvent) {
|
|
const getTag = (name: string) => event.tags.find(([n]) => n === name)?.[1];
|
|
const title = getTag('title') || getTag('name') || 'Untitled Pack';
|
|
const description = getTag('description') || getTag('summary') || '';
|
|
const image = getTag('image') || getTag('thumb') || getTag('banner');
|
|
const pubkeys = event.tags.filter(([n]) => n === 'p').map(([, pk]) => pk);
|
|
|
|
return { title, description, image, pubkeys };
|
|
}
|
|
|
|
function FollowsStep({ onNext, onBack }: { onNext: (didFollow: boolean) => void; onBack: () => void }) {
|
|
const { nostr } = useNostr();
|
|
const { user } = useCurrentUser();
|
|
const { mutateAsync: publishEvent } = useNostrPublish();
|
|
|
|
const [packs, setPacks] = useState<NostrEvent[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [followedPacks, setFollowedPacks] = useState<Set<string>>(new Set());
|
|
const [followingPack, setFollowingPack] = useState<string | null>(null);
|
|
|
|
// Fetch the suggested follow packs
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
const fetchPacks = async () => {
|
|
try {
|
|
const filters = SUGGESTED_PACKS.map((p) => ({
|
|
kinds: [p.kind],
|
|
authors: [p.pubkey],
|
|
'#d': [p.identifier],
|
|
limit: 1,
|
|
}));
|
|
|
|
const events = await nostr.query(filters, { signal: AbortSignal.timeout(8000) });
|
|
if (!cancelled) {
|
|
setPacks(events);
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to fetch suggested follow packs:', error);
|
|
} finally {
|
|
if (!cancelled) setLoading(false);
|
|
}
|
|
};
|
|
|
|
fetchPacks();
|
|
return () => { cancelled = true; };
|
|
}, [nostr]);
|
|
|
|
const handleFollowAll = useCallback(async (pack: NostrEvent) => {
|
|
if (!user) return;
|
|
|
|
const packId = pack.id;
|
|
setFollowingPack(packId);
|
|
|
|
try {
|
|
const packPubkeys = pack.tags.filter(([n]) => n === 'p').map(([, pk]) => pk);
|
|
|
|
// Fetch current follow list
|
|
const followEvents: NostrEvent[] = await nostr.query(
|
|
[{ kinds: [3], authors: [user.pubkey], limit: 1 }],
|
|
{ signal: AbortSignal.timeout(10_000) },
|
|
).catch((): NostrEvent[] => []);
|
|
|
|
const latestEvent = followEvents.length > 0
|
|
? followEvents.reduce((latest, current) => current.created_at > latest.created_at ? current : latest)
|
|
: null;
|
|
|
|
const existingFollows = latestEvent
|
|
? latestEvent.tags.filter(([name]) => name === 'p').map(([, pk]) => pk)
|
|
: [];
|
|
|
|
const allFollows = [...new Set([...existingFollows, ...packPubkeys])];
|
|
|
|
await publishEvent({
|
|
kind: 3,
|
|
content: latestEvent?.content ?? '',
|
|
tags: allFollows.map((pk) => ['p', pk]),
|
|
});
|
|
|
|
setFollowedPacks((prev) => new Set([...prev, packId]));
|
|
} catch (error) {
|
|
console.error('Failed to follow pack:', error);
|
|
} finally {
|
|
setFollowingPack(null);
|
|
}
|
|
}, [user, nostr, publishEvent]);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-6 animate-in fade-in slide-in-from-right-4 duration-400">
|
|
<div className="space-y-2">
|
|
<h2 className="text-xl font-semibold tracking-tight">Find your people</h2>
|
|
<p className="text-sm text-muted-foreground">
|
|
Your feed is empty! Follow some people to get started. Here are some curated packs to help you find interesting voices.
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-3">
|
|
{loading ? (
|
|
Array.from({ length: SUGGESTED_PACKS.length }).map((_, i) => (
|
|
<PackCardSkeleton key={i} />
|
|
))
|
|
) : packs.length === 0 ? (
|
|
<p className="text-sm text-muted-foreground text-center py-6">
|
|
Couldn't load suggestions right now. You can find follow packs later in the app.
|
|
</p>
|
|
) : (
|
|
packs.map((pack) => (
|
|
<PackCard
|
|
key={pack.id}
|
|
event={pack}
|
|
isFollowed={followedPacks.has(pack.id)}
|
|
isFollowing={followingPack === pack.id}
|
|
onFollowAll={() => handleFollowAll(pack)}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={onBack}
|
|
className="flex-1 rounded-full h-11"
|
|
>
|
|
Back
|
|
</Button>
|
|
<Button
|
|
onClick={() => onNext(followedPacks.size > 0)}
|
|
className="flex-1 rounded-full h-11 gap-1.5"
|
|
>
|
|
{followedPacks.size > 0 ? 'Continue' : 'Skip for now'}
|
|
<ChevronRight className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Compact follow pack card for the onboarding flow. */
|
|
function PackCard({
|
|
event,
|
|
isFollowed,
|
|
isFollowing,
|
|
onFollowAll,
|
|
}: {
|
|
event: NostrEvent;
|
|
isFollowed: boolean;
|
|
isFollowing: boolean;
|
|
onFollowAll: () => void;
|
|
}) {
|
|
const { title, description, pubkeys } = useMemo(() => parsePackEvent(event), [event]);
|
|
|
|
// Show first 6 member avatars
|
|
const previewPubkeys = useMemo(() => pubkeys.slice(0, 6), [pubkeys]);
|
|
const { data: membersMap } = useAuthors(previewPubkeys);
|
|
|
|
return (
|
|
<div className="rounded-xl ring-1 ring-border overflow-hidden">
|
|
<div className="p-4 space-y-3">
|
|
{/* Title + member count */}
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h3 className="font-semibold text-sm leading-snug">{title}</h3>
|
|
{description && (
|
|
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{description}</p>
|
|
)}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground flex items-center gap-1 shrink-0 mt-0.5">
|
|
<Users className="w-3.5 h-3.5" />
|
|
{pubkeys.length}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Member avatar stack */}
|
|
<div className="flex items-center gap-1">
|
|
<div className="flex -space-x-2">
|
|
{previewPubkeys.map((pk) => {
|
|
const member = membersMap?.get(pk);
|
|
const name = member?.metadata?.name || genUserName(pk);
|
|
return (
|
|
<MiniAvatar
|
|
key={pk}
|
|
src={member?.metadata?.picture}
|
|
name={name}
|
|
/>
|
|
);
|
|
})}
|
|
</div>
|
|
{pubkeys.length > previewPubkeys.length && (
|
|
<span className="text-xs text-muted-foreground ml-1">
|
|
+{pubkeys.length - previewPubkeys.length} more
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Follow All button */}
|
|
<Button
|
|
className="w-full gap-2"
|
|
size="sm"
|
|
variant={isFollowed ? 'outline' : 'default'}
|
|
onClick={onFollowAll}
|
|
disabled={isFollowed || isFollowing}
|
|
>
|
|
{isFollowing ? (
|
|
<>
|
|
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
|
Following...
|
|
</>
|
|
) : isFollowed ? (
|
|
<>
|
|
<Check className="w-3.5 h-3.5" />
|
|
Added to your follows
|
|
</>
|
|
) : (
|
|
<>
|
|
<UserPlus className="w-3.5 h-3.5" />
|
|
Follow All ({pubkeys.length})
|
|
</>
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Author attribution */}
|
|
<AuthorAttribution pubkey={event.pubkey} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Small author attribution bar at the bottom of a pack card. */
|
|
function AuthorAttribution({ pubkey }: { pubkey: string }) {
|
|
const { data: authorData } = useAuthors([pubkey]);
|
|
const metadata: NostrMetadata | undefined = authorData?.get(pubkey)?.metadata;
|
|
const name = metadata?.name || genUserName(pubkey);
|
|
|
|
return (
|
|
<div className="px-4 py-2 bg-muted/30 border-t border-border flex items-center gap-2">
|
|
<MiniAvatar src={metadata?.picture} name={name} />
|
|
<span className="text-xs text-muted-foreground truncate">
|
|
by <span className="font-medium text-foreground">{name}</span>
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Tiny avatar used in pack member stacks. */
|
|
function MiniAvatar({ src, name }: { src?: string; name: string }) {
|
|
return (
|
|
<Avatar className="size-7 ring-2 ring-background">
|
|
<AvatarImage src={src} alt={name} />
|
|
<AvatarFallback className="bg-primary/15 text-primary text-[10px]">
|
|
{name[0]?.toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
);
|
|
}
|
|
|
|
function PackCardSkeleton() {
|
|
return (
|
|
<div className="rounded-xl ring-1 ring-border p-4 space-y-3">
|
|
<div className="flex items-start justify-between">
|
|
<div className="space-y-1.5 flex-1">
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-3 w-48" />
|
|
</div>
|
|
<Skeleton className="h-4 w-8" />
|
|
</div>
|
|
<div className="flex -space-x-2">
|
|
{Array.from({ length: 6 }).map((_, i) => (
|
|
<Skeleton key={i} className="size-7 rounded-full ring-2 ring-background" />
|
|
))}
|
|
</div>
|
|
<Skeleton className="h-8 w-full rounded-md" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Outro Step
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function OutroStep({ onComplete }: { onComplete: () => void }) {
|
|
return (
|
|
<div className="flex flex-col items-center text-center gap-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
|
<div className="relative">
|
|
<DittoLogo size={72} />
|
|
<div className="absolute -bottom-1 -right-1 bg-primary/10 rounded-full p-1.5">
|
|
<Heart className="w-5 h-5 text-primary fill-primary" />
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-3 max-w-xs">
|
|
<h2 className="text-2xl font-bold tracking-tight">
|
|
You're all set
|
|
</h2>
|
|
<p className="text-muted-foreground text-sm leading-relaxed">
|
|
That's it! Go find something wonderful, share something fun,
|
|
and make yourself at home.
|
|
</p>
|
|
</div>
|
|
|
|
<Button
|
|
size="lg"
|
|
className="w-full max-w-xs gap-2 rounded-full h-12"
|
|
onClick={onComplete}
|
|
>
|
|
Let's go
|
|
<ChevronRight className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared nav buttons
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function StepNav({
|
|
onBack,
|
|
onNext,
|
|
nextLabel = 'Continue',
|
|
}: {
|
|
onBack: () => void;
|
|
onNext: () => void;
|
|
nextLabel?: string;
|
|
}) {
|
|
return (
|
|
<div className="flex gap-3">
|
|
<Button
|
|
variant="ghost"
|
|
onClick={onBack}
|
|
className="flex-1 rounded-full h-11"
|
|
>
|
|
Back
|
|
</Button>
|
|
<Button
|
|
onClick={onNext}
|
|
className="flex-1 rounded-full h-11 gap-1.5"
|
|
>
|
|
{nextLabel}
|
|
<ChevronRight className="w-4 h-4" />
|
|
</Button>
|
|
</div>
|
|
);
|
|
}
|