import { useCallback, useEffect, useRef, useState } from 'react'; import { deleteField } from 'firebase/firestore'; import { Plus, X } from 'lucide-react'; import type { ChecklistItem, Particle } from '@/api/types'; import { particlePath, parseParticlePath, toFirestoreDocPath, type ParticlePath, } from '@/lib/particle-path'; import { updateParticle, updateParticleProperties, } from '@/lib/firestore-particles'; import { cn } from '@/lib/utils'; import { Checkbox } from '@/components/ui/checkbox'; import { Textarea } from '@/components/ui/textarea'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { HumanAvatar } from '@/components/human-avatar'; import { useNetwork } from '@/hooks/use-networks'; import { useFixedDwell } from '@/hooks/use-fixed-dwell'; import { useLiveDraftField } from '@/hooks/use-live-draft-field'; import { useSuspendPlayback } from '@/hooks/use-suspend-playback'; import { resolveHumanDisplay } from '@/lib/humans'; import { isTypingTarget } from '@/lib/keyboard'; import { KeyHint } from '@/components/key-hint'; type TaskParticle = Extract; interface TaskParticleViewProps { particle: TaskParticle; containerPath: ParticlePath; paused: boolean; onEnded: () => void; onProgress?: (ratio: number) => void; /** * Enable focus mode while a field is focused: dim and blur the surrounding * stream, and trap stream keys (Escape leaves the editor, navigation keys * stay put). Off in the standalone leaf view, which has no stream chrome. */ immersive?: boolean; } const DWELL_DURATION_S = 8; const UNASSIGNED = 'unassigned'; // Stream-navigation keys to swallow while editing so they can't pull focus to // another particle (only when focus isn't already in a text field). const NAV_KEYS = new Set([ 'ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'l', 'L', ]); function useParticleDocPath( containerPath: ParticlePath, particleId: string, ): string { const { networkId, segments } = parseParticlePath(containerPath); return toFirestoreDocPath(particlePath(networkId, [...segments, particleId])); } export function TaskParticleView({ particle, containerPath, paused, onEnded, onProgress, immersive = false, }: TaskParticleViewProps) { const { networkId } = parseParticlePath(containerPath); const network = useNetwork(networkId); const docPath = useParticleDocPath(containerPath, particle.id); const { title, notes, checklist = [], assigned_to, done, } = particle.properties; // Suspend playback while any field inside the card has focus so typing // doesn't race the dwell timer or get eaten by global key handlers. const [editing, setEditing] = useState(false); useSuspendPlayback(editing, `task-edit-${particle.id}`); const exitFocus = useCallback(() => { (document.activeElement as HTMLElement | null)?.blur(); }, []); // While editing, the card acts like the app's other focus overlays: a // capture-phase listener consumes Escape (which blurs the field — flushing // the draft and resuming playback) and swallows stream-navigation keys, so // the stream's own handlers never see them. Self-contained, so no editing // state has to be threaded back up to the stream. useEffect(() => { if (!immersive || !editing) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); exitFocus(); } else if (NAV_KEYS.has(e.key) && !isTypingTarget(e)) { // Arrows still move the caret inside text fields; only block them when // focus is on a non-text control (checkbox, assignee select). e.stopPropagation(); } }; window.addEventListener('keydown', onKeyDown, { capture: true }); return () => window.removeEventListener('keydown', onKeyDown, { capture: true }); }, [immersive, editing, exitFocus]); useFixedDwell({ id: particle.id, durationS: DWELL_DURATION_S, paused, onEnded, onProgress, }); const titleField = useLiveDraftField({ remoteValue: title, commit: (value) => updateParticleProperties<'task'>(docPath, { title: value }), }); const notesField = useLiveDraftField({ remoteValue: notes ?? '', commit: (value) => updateParticleProperties<'task'>(docPath, { notes: value }), }); // Checklist writes replace the whole array (merged against the latest live // value); concurrent edits to the same checklist are last-write-wins. const checklistRef = useRef(checklist); useEffect(() => { checklistRef.current = checklist; }, [checklist]); const writeChecklist = useCallback( (items: ChecklistItem[]) => { // Advance the local base before the write so a second edit issued before // the next snapshot composes on top of this one instead of dropping it. checklistRef.current = items; return updateParticleProperties<'task'>(docPath, { checklist: items }); }, [docPath], ); const handleToggleDone = useCallback( (checked: boolean) => updateParticleProperties<'task'>(docPath, { done: checked }), [docPath], ); const handleToggleItem = useCallback( (index: number, checked: boolean) => { const items = checklistRef.current.map((item, i) => i === index ? { ...item, done: checked } : item, ); void writeChecklist(items); }, [writeChecklist], ); const handleCommitItemText = useCallback( (index: number, text: string) => { const items = checklistRef.current.map((item, i) => i === index ? { ...item, text } : item, ); void writeChecklist(items); }, [writeChecklist], ); const handleRemoveItem = useCallback( (index: number) => { void writeChecklist(checklistRef.current.filter((_, i) => i !== index)); }, [writeChecklist], ); const handleAddItem = useCallback( (text: string) => { void writeChecklist([...checklistRef.current, { text, done: false }]); }, [writeChecklist], ); const handleAssign = useCallback( (value: string) => { if (value === UNASSIGNED) { void updateParticle(docPath, 'properties.assigned_to', deleteField()); } else { void updateParticleProperties<'task'>(docPath, { assigned_to: value }); } }, [docPath], ); const assignee = resolveHumanDisplay(assigned_to, network?.humans); const doneCount = checklist.filter((item) => item.done).length; return (
{immersive && editing && (
)}
setEditing(true)} onBlurCapture={(e) => { if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false); }} >
handleToggleDone(checked === true)} className="mt-1.5 size-5 rounded-full border-white/40 data-[state=checked]:border-emerald-500 data-[state=checked]:bg-emerald-500" aria-label={done ? 'Mark task as not done' : 'Mark task as done'} /> titleField.onChange(e.target.value)} onFocus={titleField.onFocus} onBlur={titleField.onBlur} placeholder="Task title" className={cn( 'w-full bg-transparent text-2xl font-semibold text-white outline-none placeholder:text-white/30', done && 'text-white/50 line-through', )} />