Add onboarding wizard for teaching the record key #301
@@ -23,12 +23,18 @@ import {
|
||||
import { SoundEffectsProvider } from '@/lib/sound-effects/sound-effects-provider';
|
||||
import { platform } from '@/lib/platform';
|
||||
import { InAppAutoplayCard } from '@/components/in-app-autoplay-card';
|
||||
import { useOnboardingStore } from '@/stores/onboarding-store';
|
||||
import { OnboardingOverlay } from '@/features/onboarding/onboarding-overlay';
|
||||
|
||||
const queryClient = createQueryClient();
|
||||
|
||||
const App = () => {
|
||||
const status = useAuthStore((s) => s.status);
|
||||
const restoreSession = useAuthStore((s) => s.restoreSession);
|
||||
const hasCompletedOnboarding = useOnboardingStore(
|
||||
(s) => s.hasCompletedOnboarding,
|
||||
);
|
||||
const markOnboardingComplete = useOnboardingStore((s) => s.markComplete);
|
||||
|
||||
useEffect(() => {
|
||||
restoreSession();
|
||||
@@ -46,6 +52,10 @@ const App = () => {
|
||||
return <LoginPage />;
|
||||
}
|
||||
|
||||
if (!hasCompletedOnboarding) {
|
||||
return <OnboardingOverlay onComplete={markOnboardingComplete} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PusherProvider>
|
||||
<AuthenticatedApp />
|
||||
|
||||
@@ -29,7 +29,11 @@ import { useMediaDevices } from '@/hooks/use-media-devices';
|
||||
import { resolveEffectiveDeviceId } from '@/hooks/use-effective-device-id';
|
||||
import { useFileInput } from '@/hooks/use-file-input';
|
||||
import { createImageThumbnail } from '@/lib/image-thumbnail';
|
||||
import { MAX_ATTACHMENT_SIZE_BYTES, MAX_ATTACHMENTS } from '@/lib/constants';
|
||||
import {
|
||||
HOLD_THRESHOLD_MS,
|
||||
MAX_ATTACHMENT_SIZE_BYTES,
|
||||
MAX_ATTACHMENTS,
|
||||
} from '@/lib/constants';
|
||||
import type { PendingAttachment } from '@/features/compose/attachment-strip';
|
||||
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||
import { useComposeIntentStore } from '@/stores/compose-intent-store';
|
||||
@@ -59,8 +63,6 @@ interface ComposeOverlayProps {
|
||||
onParticleCreated?: (particleId: string) => void;
|
||||
}
|
||||
|
||||
const HOLD_THRESHOLD_MS = 250;
|
||||
|
||||
/**
|
||||
* Self-contained compose overlay. Each consumer renders its own instance
|
||||
* with props that determine the mode (new stream vs. reply).
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { OnboardingKeyboard } from './onboarding-keyboard';
|
||||
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
|
||||
import { useHoldGesture } from './use-hold-gesture';
|
||||
|
||||
/**
|
||||
* Visible hold duration for the lesson. Deliberately longer than the real
|
||||
* HOLD_THRESHOLD_MS (which is imperceptible) so the gesture is teachable.
|
||||
*/
|
||||
const ONBOARDING_HOLD_MS = 600;
|
||||
|
||||
export function HoldStep({ onAdvance }: { onAdvance: () => void }) {
|
||||
const { state, progress } = useHoldGesture({
|
||||
targetKey: '`',
|
||||
targetCode: 'Backquote',
|
||||
holdMs: ONBOARDING_HOLD_MS,
|
||||
});
|
||||
const done = state === 'success';
|
||||
|
||||
useEffect(() => {
|
||||
if (!done) return;
|
||||
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [done, onAdvance]);
|
||||
|
||||
return (
|
||||
<OnboardingStep
|
||||
title={done ? 'That is push-to-talk' : 'Hold to record'}
|
||||
subtitle={
|
||||
done
|
||||
? 'Hold ` whenever you want to speak, release when you are done.'
|
||||
: 'Press and hold the ` key, like a walkie-talkie.'
|
||||
}
|
||||
status={
|
||||
done ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
|
||||
<Check className="size-4" /> Nice.
|
||||
</span>
|
||||
) : state === 'holding' ? (
|
||||
'Keep holding…'
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<OnboardingKeyboard
|
||||
highlightKey="`"
|
||||
state={done ? 'success' : state === 'holding' ? 'active' : 'idle'}
|
||||
progress={progress}
|
||||
/>
|
||||
</OnboardingStep>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type KeyState = 'idle' | 'active' | 'success';
|
||||
|
||||
interface OnboardingKeyboardProps {
|
||||
/** The key to emphasise. Defaults to the record key. */
|
||||
highlightKey?: string;
|
||||
state: KeyState;
|
||||
/** Hold progress (0–1), fills the emphasised key from the bottom. */
|
||||
progress?: number;
|
||||
}
|
||||
|
||||
interface KeyCapProps {
|
||||
label: string;
|
||||
hero?: boolean;
|
||||
state?: KeyState;
|
||||
progress?: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function KeyCap({
|
||||
label,
|
||||
hero,
|
||||
state = 'idle',
|
||||
progress = 0,
|
||||
className,
|
||||
}: KeyCapProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative flex select-none items-center justify-center overflow-hidden rounded-lg border text-sm font-medium',
|
||||
hero ? 'h-14 w-14 text-xl' : 'h-14',
|
||||
!hero && 'border-border bg-muted text-muted-foreground/70',
|
||||
hero &&
|
||||
state === 'idle' &&
|
||||
'border-primary/40 bg-primary/10 text-foreground motion-safe:animate-pulse',
|
||||
hero &&
|
||||
state === 'active' &&
|
||||
'border-primary/60 bg-primary/20 text-foreground',
|
||||
hero &&
|
||||
state === 'success' &&
|
||||
'border-emerald-500/50 bg-emerald-500/15 text-emerald-700 dark:text-emerald-200',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{hero && state !== 'success' && progress > 0 && (
|
||||
<span
|
||||
className="pointer-events-none absolute inset-x-0 bottom-0 bg-primary/25"
|
||||
style={{ height: `${progress * 100}%` }}
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draws the record key in its physical context: Esc above, 1 to its right, and
|
||||
* Tab / Q on the row below — so the key is recognisable on a real keyboard.
|
||||
*/
|
||||
export function OnboardingKeyboard({
|
||||
highlightKey = '`',
|
||||
state,
|
||||
progress = 0,
|
||||
}: OnboardingKeyboardProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<KeyCap label="esc" className="w-14 text-xs" />
|
||||
<div className="flex gap-1.5">
|
||||
<KeyCap label={highlightKey} hero state={state} progress={progress} />
|
||||
<KeyCap label="1" className="w-14" />
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<KeyCap label="tab" className="w-20 text-xs" />
|
||||
<KeyCap label="Q" className="w-12" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { WindowControls } from '@/components/window-controls';
|
||||
import { KeyHint } from '@/components/key-hint';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { OnboardingKeyboard } from './onboarding-keyboard';
|
||||
import { OnboardingStep } from './onboarding-step';
|
||||
import { HoldStep } from './hold-step';
|
||||
import { ToggleStep } from './toggle-step';
|
||||
import { TapStep } from './tap-step';
|
||||
import { PermissionStep } from './permission-step';
|
||||
|
||||
const STEPS = ['welcome', 'hold', 'tap', 'toggle', 'permission'] as const;
|
||||
type Step = (typeof STEPS)[number];
|
||||
|
||||
/**
|
||||
* First-run wizard that teaches the record key. Rendered as a full-screen
|
||||
* takeover (above PusherProvider) so none of the stream/compose keyboard
|
||||
* handlers are mounted to compete for the same keys.
|
||||
*/
|
||||
export function OnboardingOverlay({ onComplete }: { onComplete: () => void }) {
|
||||
const [step, setStep] = useState<Step>('welcome');
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
const index = STEPS.indexOf(step);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setStep((cur) => STEPS[Math.min(STEPS.indexOf(cur) + 1, STEPS.length - 1)]);
|
||||
}, []);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
setStep((cur) => STEPS[Math.max(0, STEPS.indexOf(cur) - 1)]);
|
||||
}, []);
|
||||
|
||||
// Focus the panel on the gesture steps so window-level key handling has a
|
||||
// home; the static steps autofocus their primary button instead, which lets
|
||||
// Enter activate it natively (no duplicate window handler).
|
||||
useEffect(() => {
|
||||
if (step === 'hold' || step === 'toggle' || step === 'tap') {
|
||||
panelRef.current?.focus();
|
||||
}
|
||||
}, [step]);
|
||||
|
||||
// Esc skips the wizard from anywhere; ← (and Backspace) steps back.
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onComplete();
|
||||
} else if (e.key === 'Enter' && step === 'welcome') {
|
||||
e.preventDefault();
|
||||
goNext();
|
||||
} else if (
|
||||
e.key === 'ArrowLeft' ||
|
||||
e.key === 'Backspace' ||
|
||||
e.key === 'Delete'
|
||||
) {
|
||||
e.preventDefault();
|
||||
goBack();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [onComplete, goNext, step, goBack]);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background text-foreground">
|
||||
<div className="drag-region flex items-center justify-between border-b border-border px-3 py-1">
|
||||
<WindowControls />
|
||||
<KeyHint
|
||||
keys="Esc"
|
||||
onClick={onComplete}
|
||||
title="Skip onboarding (or press Esc)"
|
||||
className="no-drag text-xs text-muted-foreground"
|
||||
>
|
||||
to skip
|
||||
</KeyHint>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 items-center justify-center p-6">
|
||||
<div
|
||||
ref={panelRef}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-label="Welcome to llink"
|
||||
className="flex w-full max-w-md flex-col items-center gap-8 rounded-2xl border border-border bg-card p-10 outline-none"
|
||||
>
|
||||
{step === 'welcome' && (
|
||||
<OnboardingStep
|
||||
title="Welcome to llink"
|
||||
subtitle="To use llink, you must learn the keyboard shortcuts. Don't worry, once you learn them, you'll feel like your flowing."
|
||||
>
|
||||
<OnboardingKeyboard highlightKey="`" state="idle" />
|
||||
</OnboardingStep>
|
||||
)}
|
||||
{step === 'hold' && <HoldStep onAdvance={goNext} />}
|
||||
{step === 'toggle' && <ToggleStep onAdvance={goNext} />}
|
||||
{step === 'tap' && <TapStep onAdvance={goNext} />}
|
||||
{step === 'permission' && <PermissionStep onAdvance={onComplete} />}
|
||||
|
||||
<div className="flex items-center justify-center gap-1.5">
|
||||
{STEPS.map((s, i) => (
|
||||
<span
|
||||
key={s}
|
||||
className={cn(
|
||||
'h-1.5 rounded-full transition-all',
|
||||
i === index
|
||||
? 'w-6 bg-foreground'
|
||||
: i < index
|
||||
? 'w-1.5 bg-foreground/40'
|
||||
: 'w-1.5 bg-foreground/15',
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex min-h-5 items-center justify-center gap-4 text-xs text-muted-foreground">
|
||||
{index > 0 && (
|
||||
<KeyHint
|
||||
keys="←"
|
||||
onClick={goBack}
|
||||
title="Back (or press ←)"
|
||||
aria-label="Previous step"
|
||||
>
|
||||
back
|
||||
</KeyHint>
|
||||
)}
|
||||
{step === 'welcome' && (
|
||||
<KeyHint
|
||||
keys="Enter"
|
||||
onClick={goNext}
|
||||
title="Continue (or press Enter)"
|
||||
>
|
||||
continue
|
||||
</KeyHint>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
/** How long a success state lingers before the wizard auto-advances, in ms. */
|
||||
export const SUCCESS_DWELL_MS = 2000;
|
||||
|
||||
interface OnboardingStepProps {
|
||||
title: ReactNode;
|
||||
subtitle?: ReactNode;
|
||||
/** The interactive body (keyboard diagram, toggle, etc.). */
|
||||
children?: ReactNode;
|
||||
/** Live feedback line, announced to screen readers. */
|
||||
status?: ReactNode;
|
||||
/** Actions and hints below the body. */
|
||||
footer?: ReactNode;
|
||||
}
|
||||
|
||||
/** Shared layout so every onboarding step keeps the same vertical rhythm. */
|
||||
export function OnboardingStep({
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
status,
|
||||
footer,
|
||||
}: OnboardingStepProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-6 text-center">
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight text-foreground">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle && (
|
||||
<p className="mx-auto max-w-xs text-sm text-muted-foreground">
|
||||
{subtitle}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{children && (
|
||||
<div className="flex flex-col items-center gap-4 py-2">{children}</div>
|
||||
)}
|
||||
<p aria-live="polite" className="min-h-5 text-sm text-muted-foreground">
|
||||
{status}
|
||||
</p>
|
||||
{footer && (
|
||||
<div className="flex flex-col items-center gap-3">{footer}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
|
||||
import { useMediaDevices } from '@/hooks/use-media-devices';
|
||||
|
||||
export function PermissionStep({ onAdvance }: { onAdvance: () => void }) {
|
||||
const { permissionState, requestLabels } = useMediaDevices();
|
||||
const [requesting, setRequesting] = useState(false);
|
||||
const granted = permissionState === 'granted';
|
||||
const denied = permissionState === 'denied';
|
||||
|
||||
useEffect(() => {
|
||||
if (!granted) return;
|
||||
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [granted, onAdvance]);
|
||||
|
||||
const handleAllow = async () => {
|
||||
setRequesting(true);
|
||||
await requestLabels();
|
||||
setRequesting(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<OnboardingStep
|
||||
title="Allow mic and camera"
|
||||
subtitle="llink records audio and video. Grant access once so recording just works."
|
||||
status={
|
||||
granted ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
|
||||
<Check className="size-4" /> Access granted.
|
||||
</span>
|
||||
) : denied ? (
|
||||
<span className="text-muted-foreground">
|
||||
No access yet. You can enable it later in System Settings.
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
footer={
|
||||
granted ? null : denied ? (
|
||||
<Button variant="secondary" onClick={onAdvance} autoFocus>
|
||||
Continue anyway
|
||||
</Button>
|
||||
) : (
|
||||
<Button onClick={handleAllow} disabled={requesting} autoFocus>
|
||||
{requesting ? 'Requesting…' : 'Allow access'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { OnboardingKeyboard } from './onboarding-keyboard';
|
||||
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
|
||||
import { useTapGesture } from './use-tap-gesture';
|
||||
|
||||
export function TapStep({ onAdvance }: { onAdvance: () => void }) {
|
||||
const { taps, satisfied } = useTapGesture({
|
||||
targetKey: '`',
|
||||
targetCode: 'Backquote',
|
||||
taps: 2,
|
||||
});
|
||||
const recording = taps === 1;
|
||||
|
||||
useEffect(() => {
|
||||
if (!satisfied) return;
|
||||
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [satisfied, onAdvance]);
|
||||
|
||||
return (
|
||||
<OnboardingStep
|
||||
title={
|
||||
satisfied
|
||||
? 'That is tap to toggle'
|
||||
: recording
|
||||
? 'Recording…'
|
||||
: 'Tap to start and stop'
|
||||
}
|
||||
subtitle={
|
||||
satisfied
|
||||
? 'A quick tap starts recording, another tap finishes it.'
|
||||
: recording
|
||||
? 'Now tap ` again to stop.'
|
||||
: 'Prefer not to hold? Tap ` once to start recording.'
|
||||
}
|
||||
status={
|
||||
satisfied ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
|
||||
<Check className="size-4" /> Done.
|
||||
</span>
|
||||
) : recording ? (
|
||||
<span className="inline-flex items-center gap-2 text-muted-foreground">
|
||||
<span className="size-2.5 rounded-full bg-red-500 motion-safe:animate-pulse" />
|
||||
Recording
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<OnboardingKeyboard
|
||||
highlightKey="`"
|
||||
state={satisfied ? 'success' : recording ? 'active' : 'idle'}
|
||||
/>
|
||||
</OnboardingStep>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Check, Mic, Video } from 'lucide-react';
|
||||
import { OnboardingStep, SUCCESS_DWELL_MS } from './onboarding-step';
|
||||
import { useTapGesture } from './use-tap-gesture';
|
||||
import { VideoAudioToggle } from '@/components/video-audio-toggle';
|
||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||
|
||||
export function ToggleStep({ onAdvance }: { onAdvance: () => void }) {
|
||||
const recordingMode = useMediaSettingsStore((s) => s.recordingMode);
|
||||
const setRecordingMode = useMediaSettingsStore((s) => s.setRecordingMode);
|
||||
|
||||
const { satisfied } = useTapGesture({
|
||||
targetKey: 'v',
|
||||
onTap: () => {
|
||||
const current = useMediaSettingsStore.getState().recordingMode;
|
||||
setRecordingMode(current === 'video' ? 'audio' : 'video');
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!satisfied) return;
|
||||
const t = setTimeout(onAdvance, SUCCESS_DWELL_MS);
|
||||
return () => clearTimeout(t);
|
||||
}, [satisfied, onAdvance]);
|
||||
|
||||
// Teaching the toggle shouldn't permanently change the user's default mode.
|
||||
useEffect(() => {
|
||||
const original = useMediaSettingsStore.getState().recordingMode;
|
||||
return () => setRecordingMode(original);
|
||||
}, [setRecordingMode]);
|
||||
|
||||
const isVideo = recordingMode === 'video';
|
||||
|
||||
return (
|
||||
<OnboardingStep
|
||||
title={satisfied ? 'Audio or video, your call' : 'Switch audio and video'}
|
||||
subtitle={
|
||||
satisfied
|
||||
? 'Tap V before recording to choose how you show up.'
|
||||
: 'Tap the V key to switch between video and audio.'
|
||||
}
|
||||
status={
|
||||
satisfied ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-300">
|
||||
<Check className="size-4" />{' '}
|
||||
{isVideo ? 'Back to video.' : 'Audio it is.'}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
<div className="flex size-32 flex-col items-center justify-center gap-2 rounded-2xl border border-border bg-muted text-foreground">
|
||||
{isVideo ? <Video className="size-9" /> : <Mic className="size-9" />}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{isVideo ? 'Video' : 'Audio'}
|
||||
</span>
|
||||
</div>
|
||||
<VideoAudioToggle />
|
||||
</OnboardingStep>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { isTypingTarget } from '@/lib/keyboard';
|
||||
|
||||
export type HoldState = 'idle' | 'holding' | 'success';
|
||||
|
||||
interface UseHoldGestureOptions {
|
||||
/** Detected against KeyboardEvent.key. */
|
||||
targetKey: string;
|
||||
/** Optional KeyboardEvent.code fallback (e.g. 'Backquote'). */
|
||||
targetCode?: string;
|
||||
/** How long the key must be held before it counts, in ms. */
|
||||
holdMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects a deliberate press-and-hold of a single key. Success is driven by a
|
||||
* timer started on keydown rather than by keyup, so it still resolves if the OS
|
||||
* swallows the keyup (e.g. macOS press-and-hold accent popover). Releasing early
|
||||
* simply resets — a tutorial never fails the user.
|
||||
*/
|
||||
export function useHoldGesture({
|
||||
targetKey,
|
||||
targetCode,
|
||||
holdMs,
|
||||
}: UseHoldGestureOptions) {
|
||||
const [state, setState] = useState<HoldState>('idle');
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let rafId = 0;
|
||||
let startedAt = 0;
|
||||
let done = false;
|
||||
|
||||
const matches = (e: KeyboardEvent) =>
|
||||
e.key === targetKey || (targetCode != null && e.code === targetCode);
|
||||
|
||||
const tick = () => {
|
||||
const next = Math.min((Date.now() - startedAt) / holdMs, 1);
|
||||
setProgress(next);
|
||||
if (next >= 1) {
|
||||
done = true;
|
||||
setState('success');
|
||||
return;
|
||||
}
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (done || e.repeat || isTypingTarget(e) || !matches(e)) return;
|
||||
e.preventDefault();
|
||||
startedAt = Date.now();
|
||||
setState('holding');
|
||||
cancelAnimationFrame(rafId);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
const handleKeyUp = (e: KeyboardEvent) => {
|
||||
if (done || !matches(e)) return;
|
||||
cancelAnimationFrame(rafId);
|
||||
startedAt = 0;
|
||||
setProgress(0);
|
||||
setState('idle');
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
window.addEventListener('keyup', handleKeyUp);
|
||||
return () => {
|
||||
cancelAnimationFrame(rafId);
|
||||
window.removeEventListener('keydown', handleKeyDown);
|
||||
window.removeEventListener('keyup', handleKeyUp);
|
||||
};
|
||||
}, [targetKey, targetCode, holdMs]);
|
||||
|
||||
return { state, progress };
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { isTypingTarget } from '@/lib/keyboard';
|
||||
|
||||
interface UseTapGestureOptions {
|
||||
/** Detected against KeyboardEvent.key. */
|
||||
targetKey: string;
|
||||
/** Optional KeyboardEvent.code fallback (e.g. 'Backquote'). */
|
||||
targetCode?: string;
|
||||
/** Taps required before `satisfied` flips true. Defaults to 1. */
|
||||
taps?: number;
|
||||
/** Fires on each counted tap, with the new running count. */
|
||||
onTap?: (count: number) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts discrete taps of a single key, ignoring auto-repeat. Used both for the
|
||||
* one-tap audio/video toggle and the two-tap start/stop demonstration.
|
||||
*/
|
||||
export function useTapGesture({
|
||||
targetKey,
|
||||
targetCode,
|
||||
taps = 1,
|
||||
onTap,
|
||||
}: UseTapGestureOptions) {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
const onTapRef = useRef(onTap);
|
||||
useEffect(() => {
|
||||
onTapRef.current = onTap;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const matches = (e: KeyboardEvent) =>
|
||||
e.key === targetKey || (targetCode != null && e.code === targetCode);
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.repeat || isTypingTarget(e) || !matches(e)) return;
|
||||
e.preventDefault();
|
||||
setCount((prev) => {
|
||||
if (prev >= taps) return prev;
|
||||
const next = prev + 1;
|
||||
onTapRef.current?.(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [targetKey, targetCode, taps]);
|
||||
|
||||
return { taps: count, satisfied: count >= taps };
|
||||
}
|
||||
@@ -7,6 +7,12 @@ export const MAX_ATTACHMENTS = 10;
|
||||
/** Maximum recommended duration for a media (audio/video) recording, in seconds. */
|
||||
export const RECORDING_MAX_DURATION_SECONDS = 60;
|
||||
|
||||
/**
|
||||
* Minimum time the record key must be held to count as hold-to-record (vs. a
|
||||
* quick tap that toggles), in milliseconds.
|
||||
*/
|
||||
export const HOLD_THRESHOLD_MS = 250;
|
||||
|
||||
export const SUPPORT_EMAIL = 'team@flowylabs.ai';
|
||||
|
||||
export const PRIVACY_URL = 'https://flowylabs.ai/llink/privacy';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
const KEY = 'llink:onboarding-completed';
|
||||
|
||||
interface OnboardingState {
|
||||
hasCompletedOnboarding: boolean;
|
||||
markComplete: () => void;
|
||||
/** Clears the flag so the wizard runs again (handy for QA). */
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
export const useOnboardingStore = create<OnboardingState>((set) => ({
|
||||
hasCompletedOnboarding: localStorage.getItem(KEY) === 'true',
|
||||
markComplete: () => {
|
||||
localStorage.setItem(KEY, 'true');
|
||||
set({ hasCompletedOnboarding: true });
|
||||
},
|
||||
reset: () => {
|
||||
localStorage.removeItem(KEY);
|
||||
set({ hasCompletedOnboarding: false });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user