From 0cc6024621d7dcd66b80dd6485fc75885984aa8a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 21 Jun 2026 01:59:43 +0000 Subject: [PATCH] =?UTF-8?q?mobile:=20task=20particles=20=E2=80=94=20view,?= =?UTF-8?q?=20edit,=20and=20compose=20(parity=20phase=203)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the task particle to parity with desktop's richer model: - Replace the thin `quest` schema with desktop's `task` model (ChecklistItem, TaskProperties: title/notes/checklist/assigned_to/done) in the discriminated union, the Firestore converter, and consumers (StreamCard, FallbackParticleView). - New TaskParticleView renders an editable card (round done checkbox, title, notes, checklist with add/toggle/edit/remove, assignee chips) persisting each edit to Firestore; an 8s dwell auto-advances and field focus suspends playback. Wired into StreamView's render switch. - Compose: a task button in the ComposeDock opens a TaskComposeSheet (createTaskParticle helper). Gated off in the new-stream flow, where a stream's first particle must be text or media. Note: particles are written client-side to Firestore, matching desktop; Orion's REST validator still only accepts `quest`, which is a pre-existing inconsistency to reconcile backend-side separately. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV --- js/mobile/src/api/types.ts | 22 +- .../src/features/compose/ComposeDock.tsx | 136 ++++-- .../src/features/compose/TaskComposeSheet.tsx | 102 +++++ .../stream-view/FallbackParticleView.tsx | 4 - .../src/features/stream-view/StreamView.tsx | 13 + .../features/stream-view/TaskParticleView.tsx | 400 ++++++++++++++++++ .../src/features/streams/NewStreamScreen.tsx | 1 + js/mobile/src/features/streams/StreamCard.tsx | 2 +- js/mobile/src/lib/firestore-particles.ts | 2 +- js/mobile/src/lib/upload.ts | 26 ++ 10 files changed, 659 insertions(+), 49 deletions(-) create mode 100644 js/mobile/src/features/compose/TaskComposeSheet.tsx create mode 100644 js/mobile/src/features/stream-view/TaskParticleView.tsx diff --git a/js/mobile/src/api/types.ts b/js/mobile/src/api/types.ts index ab63df4..2f11e8d 100644 --- a/js/mobile/src/api/types.ts +++ b/js/mobile/src/api/types.ts @@ -146,14 +146,21 @@ export const TextPropertiesSchema = z.object({ }); export type TextProperties = z.infer; -export const QuestPropertiesSchema = z.object({ +export const ChecklistItemSchema = z.object({ + text: z.string(), + done: z.boolean(), +}); +export type ChecklistItem = z.infer; + +export const TaskPropertiesSchema = z.object({ title: z.string(), - description: z.string(), - status: z.string().optional(), + notes: z.string().optional(), + checklist: z.array(ChecklistItemSchema).optional(), // humanId assigned_to: z.string().optional(), + done: z.boolean(), }); -export type QuestProperties = z.infer; +export type TaskProperties = z.infer; export const PaperPropertiesSchema = z.object({ title: z.string(), @@ -194,7 +201,7 @@ export interface ParticlePropertiesMap { media: MediaProperties; file: FileProperties; text: TextProperties; - quest: QuestProperties; + task: TaskProperties; paper: PaperProperties; } @@ -248,8 +255,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [ ...TombstoneFields, }), ParticleBaseSchema.extend({ - type: z.literal('quest'), - properties: QuestPropertiesSchema, + type: z.literal('task'), + properties: TaskPropertiesSchema, + reactions: ReactionsSchema, ...TombstoneFields, }), ParticleBaseSchema.extend({ diff --git a/js/mobile/src/features/compose/ComposeDock.tsx b/js/mobile/src/features/compose/ComposeDock.tsx index c706257..bf86906 100644 --- a/js/mobile/src/features/compose/ComposeDock.tsx +++ b/js/mobile/src/features/compose/ComposeDock.tsx @@ -1,6 +1,11 @@ import { useCallback, useEffect, useState } from 'react'; import { Pressable, Text, View } from 'react-native'; -import { Mic, Type as TypeIcon, Video as VideoIcon } from 'lucide-react-native'; +import { + ListTodo, + Mic, + Type as TypeIcon, + Video as VideoIcon, +} from 'lucide-react-native'; import * as Haptics from 'expo-haptics'; import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera'; import { toast } from 'sonner-native'; @@ -8,13 +13,18 @@ import { cn } from '@/lib/utils'; import { useEvent } from '@/hooks/use-event'; import { usePlaybackPauseStore } from '@/stores/playback-pause-store'; import { useAuthStore } from '@/stores/auth-store'; -import { createTextParticle, uploadMediaParticle } from '@/lib/upload'; +import { + createTaskParticle, + createTextParticle, + uploadMediaParticle, +} from '@/lib/upload'; import type { ParticlePath } from '@/lib/particle-path'; import { useStreamComposingBroadcastOptional, type ComposingMode, } from '@/features/stream-view/stream-presence-context'; import { TextComposeModal } from './TextComposeModal'; +import { TaskComposeSheet } from './TaskComposeSheet'; import { VideoRecordingOverlay } from './VideoRecordingOverlay'; import { AudioRecordingOverlay } from './AudioRecordingOverlay'; import { ReviewSheet } from './ReviewSheet'; @@ -48,6 +58,12 @@ interface ComposeDockProps { networkId: string; targetPath: ParticlePath; silentPresence?: boolean; + /** + * Whether to show the task compose button. Disabled in the new-stream flow, + * where a stream's first particle must be text or media (a task can't open a + * stream — it's added once the stream exists). + */ + allowTask?: boolean; submitMedia?: (params: SubmitMediaParams) => Promise; submitText?: (content: string) => Promise; /** @@ -63,6 +79,7 @@ export function ComposeDock({ networkId, targetPath, silentPresence = false, + allowTask = true, submitMedia, submitText: submitTextOverride, onParticleCreated, @@ -72,6 +89,7 @@ export function ComposeDock({ const [mode, setMode] = useState('video'); const [ui, setUi] = useState({ kind: 'idle' }); const [textOpen, setTextOpen] = useState(false); + const [taskOpen, setTaskOpen] = useState(false); const [camPerm, requestCamPerm] = useCameraPermissions(); const [micPerm, requestMicPerm] = useMicrophonePermissions(); @@ -79,13 +97,13 @@ export function ComposeDock({ // Tell StreamView to fully unmount its expo-video player while we record. // That player otherwise holds the iOS AVAudioSession and crashes the camera. const setComposing = usePlaybackPauseStore((s) => s.setComposing); - const isComposing = ui.kind !== 'idle' || textOpen; + const isComposing = ui.kind !== 'idle' || textOpen || taskOpen; useEffect(() => { setComposing(isComposing); return () => setComposing(false); }, [isComposing, setComposing]); - useComposingBroadcast({ ui, textOpen, silent: silentPresence }); + useComposingBroadcast({ ui, textOpen, taskOpen, silent: silentPresence }); const ensurePermissions = useCallback( async (forVideo: boolean): Promise => { @@ -187,6 +205,20 @@ export function ComposeDock({ void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); }); + const submitTask = useEvent( + async ({ title, notes }: { title: string; notes?: string }) => { + if (!userId) throw new Error('Not signed in.'); + const particleId = await createTaskParticle({ + targetPath, + title, + notes, + createdByHumanId: userId, + }); + onParticleCreated?.(particleId); + void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success); + }, + ); + const dockHidden = ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording'; @@ -196,27 +228,31 @@ export function ComposeDock({ - - setMode((m) => (m === 'video' ? 'audio' : 'video')) - } - disabled={ui.kind !== 'idle'} - accessibilityLabel={`Switch to ${ - mode === 'video' ? 'audio' : 'video' - } mode`} - className={cn( - 'h-11 w-11 items-center justify-center rounded-full bg-white/15', - ui.kind !== 'idle' && 'opacity-40', - )} - > - {mode === 'video' ? ( - - ) : ( - - )} - + {/* Left and right clusters flex equally so the record button stays + centered regardless of how many side controls are present. */} + + + setMode((m) => (m === 'video' ? 'audio' : 'video')) + } + disabled={ui.kind !== 'idle'} + accessibilityLabel={`Switch to ${ + mode === 'video' ? 'audio' : 'video' + } mode`} + className={cn( + 'h-11 w-11 items-center justify-center rounded-full bg-white/15', + ui.kind !== 'idle' && 'opacity-40', + )} + > + {mode === 'video' ? ( + + ) : ( + + )} + + Tap to record - setTextOpen(true)} - disabled={ui.kind !== 'idle'} - accessibilityLabel="Compose text" - className={cn( - 'h-11 w-11 items-center justify-center rounded-full bg-white/15', - ui.kind !== 'idle' && 'opacity-40', - )} - > - - + + {allowTask ? ( + setTaskOpen(true)} + disabled={ui.kind !== 'idle'} + accessibilityLabel="Create task" + className={cn( + 'h-11 w-11 items-center justify-center rounded-full bg-white/15', + ui.kind !== 'idle' && 'opacity-40', + )} + > + + + ) : null} + + setTextOpen(true)} + disabled={ui.kind !== 'idle'} + accessibilityLabel="Compose text" + className={cn( + 'h-11 w-11 items-center justify-center rounded-full bg-white/15', + ui.kind !== 'idle' && 'opacity-40', + )} + > + + + ) : null} @@ -277,6 +329,12 @@ export function ComposeDock({ onClose={() => setTextOpen(false)} onSubmit={submitText} /> + + setTaskOpen(false)} + onSubmit={submitTask} + /> ); } @@ -284,17 +342,23 @@ export function ComposeDock({ function useComposingBroadcast({ ui, textOpen, + taskOpen, silent, }: { ui: ComposeUiState; textOpen: boolean; + taskOpen: boolean; silent: boolean; }) { // null when the dock is rendered outside a stream (no presence provider). const broadcast = useStreamComposingBroadcastOptional(); const mode: ComposingMode | null = - ui.kind === 'recording' ? 'recording' : textOpen ? 'typing' : null; + ui.kind === 'recording' + ? 'recording' + : textOpen || taskOpen + ? 'typing' + : null; useEffect(() => { if (silent || !broadcast) return; diff --git a/js/mobile/src/features/compose/TaskComposeSheet.tsx b/js/mobile/src/features/compose/TaskComposeSheet.tsx new file mode 100644 index 0000000..f222b9f --- /dev/null +++ b/js/mobile/src/features/compose/TaskComposeSheet.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { Pressable, Text, TextInput, View } from 'react-native'; +import { toast } from 'sonner-native'; +import { BottomSheet } from '@/components/BottomSheet'; +import { toUserMessage } from '@/lib/errors'; +import { cn } from '@/lib/utils'; + +interface TaskComposeSheetProps { + open: boolean; + onClose: () => void; + /** Create the task. Must throw on failure so the sheet keeps the draft. */ + onSubmit: (input: { title: string; notes?: string }) => Promise; +} + +/** + * Quick task creator. Mirrors desktop's task-compose-step but pared to the + * essentials — title (required) plus optional notes. Checklist and assignee + * are added inline in the task card once it exists. + */ +export function TaskComposeSheet({ + open, + onClose, + onSubmit, +}: TaskComposeSheetProps) { + const [title, setTitle] = useState(''); + const [notes, setNotes] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const [prevOpen, setPrevOpen] = useState(open); + if (open !== prevOpen) { + setPrevOpen(open); + if (open) { + setTitle(''); + setNotes(''); + setSubmitting(false); + } + } + + const trimmedTitle = title.trim(); + const canSend = trimmedTitle.length > 0 && !submitting; + + const handleSubmit = async () => { + if (!canSend) return; + setSubmitting(true); + try { + await onSubmit({ + title: trimmedTitle, + notes: notes.trim() || undefined, + }); + onClose(); + } catch (err) { + toast.error(toUserMessage(err)); + setSubmitting(false); + } + }; + + return ( + + + New task + + + + + + + + {submitting ? 'Creating…' : 'Create task'} + + + + + ); +} diff --git a/js/mobile/src/features/stream-view/FallbackParticleView.tsx b/js/mobile/src/features/stream-view/FallbackParticleView.tsx index 51e6959..4ea69d1 100644 --- a/js/mobile/src/features/stream-view/FallbackParticleView.tsx +++ b/js/mobile/src/features/stream-view/FallbackParticleView.tsx @@ -3,7 +3,6 @@ import { Text, View } from 'react-native'; import { FileIcon, HelpCircle, - ScrollText, BookOpen, type LucideIcon, } from 'lucide-react-native'; @@ -12,7 +11,6 @@ import { useNetwork } from '@/hooks/use-networks'; import { resolveHumanDisplay } from '@/lib/humans'; const TYPE_META: Record = { - quest: { icon: ScrollText, label: 'Quest' }, paper: { icon: BookOpen, label: 'Paper' }, file: { icon: FileIcon, label: 'File' }, }; @@ -44,8 +42,6 @@ export function FallbackParticleView({ const Icon = meta.icon; const title = (() => { switch (particle.type) { - case 'quest': - return particle.properties.title; case 'paper': return particle.properties.title; case 'file': diff --git a/js/mobile/src/features/stream-view/StreamView.tsx b/js/mobile/src/features/stream-view/StreamView.tsx index e1e438e..d0e99d8 100644 --- a/js/mobile/src/features/stream-view/StreamView.tsx +++ b/js/mobile/src/features/stream-view/StreamView.tsx @@ -53,6 +53,7 @@ import { useStreamComposing, } from './stream-presence-context'; import { TextParticleView } from './TextParticleView'; +import { TaskParticleView } from './TaskParticleView'; import { MediaParticleView } from './MediaParticleView'; import { DeletedParticleView } from './DeletedParticleView'; import { FallbackParticleView } from './FallbackParticleView'; @@ -456,6 +457,18 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) { contentFit={videoFit} /> ); + case 'task': + return ( + + ); default: return ( ; + +interface TaskParticleViewProps { + particle: TaskParticle; + networkId: string; + streamId: string; + paused: boolean; + onEnded: () => void; + onProgress: (ratio: number) => void; +} + +const DWELL_DURATION_S = 8; +const TICK_MS = 100; + +/** + * Editable task card. Mirrors desktop's task-particle-view — round done + * checkbox + title, notes, a checklist, and an assignee picker — persisting + * each edit straight to Firestore. A fixed 8s dwell auto-advances the stream; + * focusing any field suspends playback so typing isn't raced by the timer. + */ +export function TaskParticleView({ + particle, + networkId, + streamId, + paused, + onEnded, + onProgress, +}: TaskParticleViewProps) { + const network = useNetwork(networkId); + const safe = useStreamSafeArea(); + const docPath = toFirestoreDocPath( + particlePath(networkId, [streamId, particle.id]), + ); + + const { + title, + notes, + checklist = [], + assigned_to, + done, + } = particle.properties; + + // Suspend the dwell timer whenever a field is focused so typing isn't + // interrupted by an auto-advance. Mirrors desktop's `editing` suspender. + const [editing, setEditing] = useState(false); + useSuspendPlayback(editing, `task-edit-${particle.id}`); + + // --- Fixed dwell (mirrors TextParticleView's interval cadence) --- + const elapsedRef = useRef(0); + useEffect(() => { + elapsedRef.current = 0; + onProgress(0); + }, [particle.id, onProgress]); + + useEffect(() => { + if (paused) return; + const interval = setInterval(() => { + elapsedRef.current += TICK_MS / 1000; + const ratio = Math.min(elapsedRef.current / DWELL_DURATION_S, 1); + onProgress(ratio); + if (ratio >= 1) { + clearInterval(interval); + onEnded(); + } + }, TICK_MS); + return () => clearInterval(interval); + }, [paused, onEnded, onProgress, particle.id]); + + // Checklist writes replace the whole array; concurrent edits are + // last-write-wins (same tradeoff desktop documents). Keep a ref so a second + // edit composes on the latest local base before the next snapshot arrives. + const checklistRef = useRef(checklist); + useEffect(() => { + checklistRef.current = checklist; + }, [checklist]); + + const writeChecklist = useCallback( + (items: ChecklistItem[]) => { + checklistRef.current = items; + return updateParticleProperties<'task'>(docPath, { checklist: items }); + }, + [docPath], + ); + + const handleToggleDone = useCallback( + () => updateParticleProperties<'task'>(docPath, { done: !done }), + [docPath, done], + ); + + const handleToggleItem = useCallback( + (index: number) => { + const items = checklistRef.current.map((item, i) => + i === index ? { ...item, done: !item.done } : 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( + (humanId: string | null) => { + if (!humanId) { + void updateParticle(docPath, 'properties.assigned_to', deleteField()); + } else { + void updateParticleProperties<'task'>(docPath, { + assigned_to: humanId, + }); + } + }, + [docPath], + ); + + const doneCount = checklist.filter((item) => item.done).length; + + return ( + + + {/* Title + done */} + + + {done ? : null} + + setEditing(true)} + onBlur={() => setEditing(false)} + onEndEditing={(e) => { + const value = e.nativeEvent.text; + if (value !== title) { + void updateParticleProperties<'task'>(docPath, { + title: value, + }); + } + }} + placeholder="Task title" + placeholderTextColor="rgba(255,255,255,0.3)" + multiline + className={cn( + 'flex-1 text-white text-2xl font-semibold', + done && 'text-white/50 line-through', + )} + /> + + + {/* Notes */} + setEditing(true)} + onBlur={() => setEditing(false)} + onEndEditing={(e) => { + const value = e.nativeEvent.text; + if (value !== (notes ?? '')) { + void updateParticleProperties<'task'>(docPath, { notes: value }); + } + }} + placeholder="Add notes…" + placeholderTextColor="rgba(255,255,255,0.3)" + multiline + className="text-white/80 text-base" + /> + + {/* Checklist */} + + {checklist.length > 0 ? ( + + {doneCount} / {checklist.length} done + + ) : null} + {checklist.map((item, index) => ( + handleToggleItem(index)} + onCommitText={(text) => handleCommitItemText(index, text)} + onRemove={() => handleRemoveItem(index)} + onFocusChange={setEditing} + /> + ))} + + + + {/* Assignee */} + + Assignee + + handleAssign(null)} + /> + {network?.humans?.map((human) => { + const display = resolveHumanDisplay(human.id, network?.humans); + return ( + handleAssign(human.id)} + avatarHumanId={human.id} + humans={network?.humans} + /> + ); + })} + + + + + ); +} + +function ChecklistItemRow({ + item, + onToggle, + onCommitText, + onRemove, + onFocusChange, +}: { + item: ChecklistItem; + onToggle: () => void; + onCommitText: (text: string) => void; + onRemove: () => void; + onFocusChange: (focused: boolean) => void; +}) { + return ( + + + {item.done ? : null} + + onFocusChange(true)} + onBlur={() => onFocusChange(false)} + onEndEditing={(e) => { + const value = e.nativeEvent.text; + if (value !== item.text) onCommitText(value); + }} + placeholder="Subtask" + placeholderTextColor="rgba(255,255,255,0.3)" + className={cn( + 'flex-1 text-white/90 text-sm', + item.done && 'text-white/40 line-through', + )} + /> + + + + + ); +} + +function AddChecklistItemRow({ + onAdd, + onFocusChange, +}: { + onAdd: (text: string) => void; + onFocusChange: (focused: boolean) => void; +}) { + const [draft, setDraft] = useState(''); + + const submit = () => { + const text = draft.trim(); + if (!text) return; + onAdd(text); + setDraft(''); + }; + + return ( + + + onFocusChange(true)} + onBlur={() => onFocusChange(false)} + onSubmitEditing={submit} + blurOnSubmit={false} + placeholder="Add subtask…" + placeholderTextColor="rgba(255,255,255,0.3)" + className="flex-1 text-white/70 text-sm" + /> + + ); +} + +function AssigneeChip({ + label, + selected, + onPress, + avatarHumanId, + humans, +}: { + label: string; + selected: boolean; + onPress: () => void; + avatarHumanId?: string; + humans?: import('@/api/types').Human[]; +}) { + return ( + + {avatarHumanId ? ( + + ) : null} + + {label} + + + ); +} diff --git a/js/mobile/src/features/streams/NewStreamScreen.tsx b/js/mobile/src/features/streams/NewStreamScreen.tsx index 9ca70f8..268ccf9 100644 --- a/js/mobile/src/features/streams/NewStreamScreen.tsx +++ b/js/mobile/src/features/streams/NewStreamScreen.tsx @@ -189,6 +189,7 @@ export function NewStreamScreen({ networkId={networkId} targetPath={placeholderPath} silentPresence + allowTask={false} submitMedia={submitMedia} submitText={submitText} /> diff --git a/js/mobile/src/features/streams/StreamCard.tsx b/js/mobile/src/features/streams/StreamCard.tsx index ce5d5cf..dcdaf2a 100644 --- a/js/mobile/src/features/streams/StreamCard.tsx +++ b/js/mobile/src/features/streams/StreamCard.tsx @@ -87,7 +87,7 @@ export const StreamCard = memo(function StreamCard({ return latestChild.properties.content; case 'file': return latestChild.properties.filename; - case 'quest': + case 'task': return latestChild.properties.title; case 'paper': return latestChild.properties.title; diff --git a/js/mobile/src/lib/firestore-particles.ts b/js/mobile/src/lib/firestore-particles.ts index a678b4c..b40754d 100644 --- a/js/mobile/src/lib/firestore-particles.ts +++ b/js/mobile/src/lib/firestore-particles.ts @@ -97,7 +97,7 @@ const particleConverter: FirestoreDataConverter = { case 'media': case 'file': case 'text': - case 'quest': + case 'task': case 'paper': { // Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text // particles carry `properties.edited_at`, so coerce it if present. diff --git a/js/mobile/src/lib/upload.ts b/js/mobile/src/lib/upload.ts index f4ea35d..06163df 100644 --- a/js/mobile/src/lib/upload.ts +++ b/js/mobile/src/lib/upload.ts @@ -103,6 +103,32 @@ export async function createTextParticle({ return createParticle(collectionPath, 'text', { content }, createdByHumanId); } +interface CreateTaskParticleParams { + targetPath: ParticlePath; + title: string; + notes?: string; + createdByHumanId: string; +} + +/** + * Create a `task` particle. Mirrors createTextParticle — the checklist and + * assignee are left empty and edited inline in the task card afterwards. + */ +export async function createTaskParticle({ + targetPath, + title, + notes, + createdByHumanId, +}: CreateTaskParticleParams): Promise { + const collectionPath = toFirestoreChildrenPath(targetPath); + return createParticle( + collectionPath, + 'task', + { title, done: false, ...(notes ? { notes } : {}) }, + createdByHumanId, + ); +} + function extensionFromMime(mime: string): string { if (mime === 'video/mp4') return '.mp4'; if (mime === 'video/quicktime') return '.mov';