mobile: task particles — view, edit, and compose (parity phase 3)
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) <[email protected]> Claude-Session: https://claude.ai/code/session_01LqGPzXQ1AA9CqYHgCgmbtV
This commit is contained in:
@@ -146,14 +146,21 @@ export const TextPropertiesSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
|
||||||
|
|
||||||
export const QuestPropertiesSchema = z.object({
|
export const ChecklistItemSchema = z.object({
|
||||||
|
text: z.string(),
|
||||||
|
done: z.boolean(),
|
||||||
|
});
|
||||||
|
export type ChecklistItem = z.infer<typeof ChecklistItemSchema>;
|
||||||
|
|
||||||
|
export const TaskPropertiesSchema = z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
description: z.string(),
|
notes: z.string().optional(),
|
||||||
status: z.string().optional(),
|
checklist: z.array(ChecklistItemSchema).optional(),
|
||||||
// humanId
|
// humanId
|
||||||
assigned_to: z.string().optional(),
|
assigned_to: z.string().optional(),
|
||||||
|
done: z.boolean(),
|
||||||
});
|
});
|
||||||
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
|
export type TaskProperties = z.infer<typeof TaskPropertiesSchema>;
|
||||||
|
|
||||||
export const PaperPropertiesSchema = z.object({
|
export const PaperPropertiesSchema = z.object({
|
||||||
title: z.string(),
|
title: z.string(),
|
||||||
@@ -194,7 +201,7 @@ export interface ParticlePropertiesMap {
|
|||||||
media: MediaProperties;
|
media: MediaProperties;
|
||||||
file: FileProperties;
|
file: FileProperties;
|
||||||
text: TextProperties;
|
text: TextProperties;
|
||||||
quest: QuestProperties;
|
task: TaskProperties;
|
||||||
paper: PaperProperties;
|
paper: PaperProperties;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,8 +255,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
|||||||
...TombstoneFields,
|
...TombstoneFields,
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
type: z.literal('quest'),
|
type: z.literal('task'),
|
||||||
properties: QuestPropertiesSchema,
|
properties: TaskPropertiesSchema,
|
||||||
|
reactions: ReactionsSchema,
|
||||||
...TombstoneFields,
|
...TombstoneFields,
|
||||||
}),
|
}),
|
||||||
ParticleBaseSchema.extend({
|
ParticleBaseSchema.extend({
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
import { Pressable, Text, View } from 'react-native';
|
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 * as Haptics from 'expo-haptics';
|
||||||
import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
|
import { useCameraPermissions, useMicrophonePermissions } from 'expo-camera';
|
||||||
import { toast } from 'sonner-native';
|
import { toast } from 'sonner-native';
|
||||||
@@ -8,13 +13,18 @@ import { cn } from '@/lib/utils';
|
|||||||
import { useEvent } from '@/hooks/use-event';
|
import { useEvent } from '@/hooks/use-event';
|
||||||
import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
|
import { usePlaybackPauseStore } from '@/stores/playback-pause-store';
|
||||||
import { useAuthStore } from '@/stores/auth-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 type { ParticlePath } from '@/lib/particle-path';
|
||||||
import {
|
import {
|
||||||
useStreamComposingBroadcastOptional,
|
useStreamComposingBroadcastOptional,
|
||||||
type ComposingMode,
|
type ComposingMode,
|
||||||
} from '@/features/stream-view/stream-presence-context';
|
} from '@/features/stream-view/stream-presence-context';
|
||||||
import { TextComposeModal } from './TextComposeModal';
|
import { TextComposeModal } from './TextComposeModal';
|
||||||
|
import { TaskComposeSheet } from './TaskComposeSheet';
|
||||||
import { VideoRecordingOverlay } from './VideoRecordingOverlay';
|
import { VideoRecordingOverlay } from './VideoRecordingOverlay';
|
||||||
import { AudioRecordingOverlay } from './AudioRecordingOverlay';
|
import { AudioRecordingOverlay } from './AudioRecordingOverlay';
|
||||||
import { ReviewSheet } from './ReviewSheet';
|
import { ReviewSheet } from './ReviewSheet';
|
||||||
@@ -48,6 +58,12 @@ interface ComposeDockProps {
|
|||||||
networkId: string;
|
networkId: string;
|
||||||
targetPath: ParticlePath;
|
targetPath: ParticlePath;
|
||||||
silentPresence?: boolean;
|
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<void>;
|
submitMedia?: (params: SubmitMediaParams) => Promise<void>;
|
||||||
submitText?: (content: string) => Promise<void>;
|
submitText?: (content: string) => Promise<void>;
|
||||||
/**
|
/**
|
||||||
@@ -63,6 +79,7 @@ export function ComposeDock({
|
|||||||
networkId,
|
networkId,
|
||||||
targetPath,
|
targetPath,
|
||||||
silentPresence = false,
|
silentPresence = false,
|
||||||
|
allowTask = true,
|
||||||
submitMedia,
|
submitMedia,
|
||||||
submitText: submitTextOverride,
|
submitText: submitTextOverride,
|
||||||
onParticleCreated,
|
onParticleCreated,
|
||||||
@@ -72,6 +89,7 @@ export function ComposeDock({
|
|||||||
const [mode, setMode] = useState<RecordingMode>('video');
|
const [mode, setMode] = useState<RecordingMode>('video');
|
||||||
const [ui, setUi] = useState<ComposeUiState>({ kind: 'idle' });
|
const [ui, setUi] = useState<ComposeUiState>({ kind: 'idle' });
|
||||||
const [textOpen, setTextOpen] = useState(false);
|
const [textOpen, setTextOpen] = useState(false);
|
||||||
|
const [taskOpen, setTaskOpen] = useState(false);
|
||||||
|
|
||||||
const [camPerm, requestCamPerm] = useCameraPermissions();
|
const [camPerm, requestCamPerm] = useCameraPermissions();
|
||||||
const [micPerm, requestMicPerm] = useMicrophonePermissions();
|
const [micPerm, requestMicPerm] = useMicrophonePermissions();
|
||||||
@@ -79,13 +97,13 @@ export function ComposeDock({
|
|||||||
// Tell StreamView to fully unmount its expo-video player while we record.
|
// Tell StreamView to fully unmount its expo-video player while we record.
|
||||||
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
|
// That player otherwise holds the iOS AVAudioSession and crashes the camera.
|
||||||
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
|
const setComposing = usePlaybackPauseStore((s) => s.setComposing);
|
||||||
const isComposing = ui.kind !== 'idle' || textOpen;
|
const isComposing = ui.kind !== 'idle' || textOpen || taskOpen;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setComposing(isComposing);
|
setComposing(isComposing);
|
||||||
return () => setComposing(false);
|
return () => setComposing(false);
|
||||||
}, [isComposing, setComposing]);
|
}, [isComposing, setComposing]);
|
||||||
|
|
||||||
useComposingBroadcast({ ui, textOpen, silent: silentPresence });
|
useComposingBroadcast({ ui, textOpen, taskOpen, silent: silentPresence });
|
||||||
|
|
||||||
const ensurePermissions = useCallback(
|
const ensurePermissions = useCallback(
|
||||||
async (forVideo: boolean): Promise<boolean> => {
|
async (forVideo: boolean): Promise<boolean> => {
|
||||||
@@ -187,6 +205,20 @@ export function ComposeDock({
|
|||||||
void Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success);
|
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 =
|
const dockHidden =
|
||||||
ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording';
|
ui.kind === 'review' || ui.kind === 'uploading' || ui.kind === 'recording';
|
||||||
|
|
||||||
@@ -196,27 +228,31 @@ export function ComposeDock({
|
|||||||
<View pointerEvents="box-none" className="absolute inset-x-0 bottom-0">
|
<View pointerEvents="box-none" className="absolute inset-x-0 bottom-0">
|
||||||
<View
|
<View
|
||||||
pointerEvents="box-none"
|
pointerEvents="box-none"
|
||||||
className="flex-row items-center justify-between px-8 pb-10"
|
className="flex-row items-center px-8 pb-10"
|
||||||
>
|
>
|
||||||
<Pressable
|
{/* Left and right clusters flex equally so the record button stays
|
||||||
onPress={() =>
|
centered regardless of how many side controls are present. */}
|
||||||
setMode((m) => (m === 'video' ? 'audio' : 'video'))
|
<View className="flex-1 flex-row items-center">
|
||||||
}
|
<Pressable
|
||||||
disabled={ui.kind !== 'idle'}
|
onPress={() =>
|
||||||
accessibilityLabel={`Switch to ${
|
setMode((m) => (m === 'video' ? 'audio' : 'video'))
|
||||||
mode === 'video' ? 'audio' : 'video'
|
}
|
||||||
} mode`}
|
disabled={ui.kind !== 'idle'}
|
||||||
className={cn(
|
accessibilityLabel={`Switch to ${
|
||||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
mode === 'video' ? 'audio' : 'video'
|
||||||
ui.kind !== 'idle' && 'opacity-40',
|
} mode`}
|
||||||
)}
|
className={cn(
|
||||||
>
|
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||||
{mode === 'video' ? (
|
ui.kind !== 'idle' && 'opacity-40',
|
||||||
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
)}
|
||||||
) : (
|
>
|
||||||
<Mic color="white" size={20} strokeWidth={1.6} />
|
{mode === 'video' ? (
|
||||||
)}
|
<VideoIcon color="white" size={20} strokeWidth={1.6} />
|
||||||
</Pressable>
|
) : (
|
||||||
|
<Mic color="white" size={20} strokeWidth={1.6} />
|
||||||
|
)}
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className="items-center">
|
<View className="items-center">
|
||||||
<Pressable
|
<Pressable
|
||||||
@@ -230,17 +266,33 @@ export function ComposeDock({
|
|||||||
<Text className="text-white/60 mt-2 text-xs">Tap to record</Text>
|
<Text className="text-white/60 mt-2 text-xs">Tap to record</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Pressable
|
<View className="flex-1 flex-row items-center justify-end gap-3">
|
||||||
onPress={() => setTextOpen(true)}
|
{allowTask ? (
|
||||||
disabled={ui.kind !== 'idle'}
|
<Pressable
|
||||||
accessibilityLabel="Compose text"
|
onPress={() => setTaskOpen(true)}
|
||||||
className={cn(
|
disabled={ui.kind !== 'idle'}
|
||||||
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
accessibilityLabel="Create task"
|
||||||
ui.kind !== 'idle' && 'opacity-40',
|
className={cn(
|
||||||
)}
|
'h-11 w-11 items-center justify-center rounded-full bg-white/15',
|
||||||
>
|
ui.kind !== 'idle' && 'opacity-40',
|
||||||
<TypeIcon color="white" size={20} strokeWidth={1.6} />
|
)}
|
||||||
</Pressable>
|
>
|
||||||
|
<ListTodo color="white" size={20} strokeWidth={1.6} />
|
||||||
|
</Pressable>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={() => 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',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TypeIcon color="white" size={20} strokeWidth={1.6} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -277,6 +329,12 @@ export function ComposeDock({
|
|||||||
onClose={() => setTextOpen(false)}
|
onClose={() => setTextOpen(false)}
|
||||||
onSubmit={submitText}
|
onSubmit={submitText}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<TaskComposeSheet
|
||||||
|
open={taskOpen}
|
||||||
|
onClose={() => setTaskOpen(false)}
|
||||||
|
onSubmit={submitTask}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -284,17 +342,23 @@ export function ComposeDock({
|
|||||||
function useComposingBroadcast({
|
function useComposingBroadcast({
|
||||||
ui,
|
ui,
|
||||||
textOpen,
|
textOpen,
|
||||||
|
taskOpen,
|
||||||
silent,
|
silent,
|
||||||
}: {
|
}: {
|
||||||
ui: ComposeUiState;
|
ui: ComposeUiState;
|
||||||
textOpen: boolean;
|
textOpen: boolean;
|
||||||
|
taskOpen: boolean;
|
||||||
silent: boolean;
|
silent: boolean;
|
||||||
}) {
|
}) {
|
||||||
// null when the dock is rendered outside a stream (no presence provider).
|
// null when the dock is rendered outside a stream (no presence provider).
|
||||||
const broadcast = useStreamComposingBroadcastOptional();
|
const broadcast = useStreamComposingBroadcastOptional();
|
||||||
|
|
||||||
const mode: ComposingMode | null =
|
const mode: ComposingMode | null =
|
||||||
ui.kind === 'recording' ? 'recording' : textOpen ? 'typing' : null;
|
ui.kind === 'recording'
|
||||||
|
? 'recording'
|
||||||
|
: textOpen || taskOpen
|
||||||
|
? 'typing'
|
||||||
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (silent || !broadcast) return;
|
if (silent || !broadcast) return;
|
||||||
|
|||||||
@@ -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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<BottomSheet open={open} onClose={onClose} avoidKeyboard maxHeight="70%">
|
||||||
|
<View className="px-5 pb-4">
|
||||||
|
<Text className="text-white text-lg font-semibold">New task</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={title}
|
||||||
|
onChangeText={setTitle}
|
||||||
|
placeholder="Task title"
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||||
|
autoFocus
|
||||||
|
editable={!submitting}
|
||||||
|
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-4"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
value={notes}
|
||||||
|
onChangeText={setNotes}
|
||||||
|
placeholder="Notes (optional)"
|
||||||
|
placeholderTextColor="rgba(255,255,255,0.4)"
|
||||||
|
multiline
|
||||||
|
editable={!submitting}
|
||||||
|
className="text-white text-base bg-white/10 rounded-xl px-4 py-3 mt-3 min-h-20"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
onPress={handleSubmit}
|
||||||
|
disabled={!canSend}
|
||||||
|
className={cn(
|
||||||
|
'mt-4 rounded-xl py-3 items-center',
|
||||||
|
canSend ? 'bg-white' : 'bg-white/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
'text-base font-semibold',
|
||||||
|
canSend ? 'text-black' : 'text-white/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{submitting ? 'Creating…' : 'Create task'}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</BottomSheet>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ import { Text, View } from 'react-native';
|
|||||||
import {
|
import {
|
||||||
FileIcon,
|
FileIcon,
|
||||||
HelpCircle,
|
HelpCircle,
|
||||||
ScrollText,
|
|
||||||
BookOpen,
|
BookOpen,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react-native';
|
} from 'lucide-react-native';
|
||||||
@@ -12,7 +11,6 @@ import { useNetwork } from '@/hooks/use-networks';
|
|||||||
import { resolveHumanDisplay } from '@/lib/humans';
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
|
|
||||||
const TYPE_META: Record<string, { icon: LucideIcon; label: string }> = {
|
const TYPE_META: Record<string, { icon: LucideIcon; label: string }> = {
|
||||||
quest: { icon: ScrollText, label: 'Quest' },
|
|
||||||
paper: { icon: BookOpen, label: 'Paper' },
|
paper: { icon: BookOpen, label: 'Paper' },
|
||||||
file: { icon: FileIcon, label: 'File' },
|
file: { icon: FileIcon, label: 'File' },
|
||||||
};
|
};
|
||||||
@@ -44,8 +42,6 @@ export function FallbackParticleView({
|
|||||||
const Icon = meta.icon;
|
const Icon = meta.icon;
|
||||||
const title = (() => {
|
const title = (() => {
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
case 'quest':
|
|
||||||
return particle.properties.title;
|
|
||||||
case 'paper':
|
case 'paper':
|
||||||
return particle.properties.title;
|
return particle.properties.title;
|
||||||
case 'file':
|
case 'file':
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
useStreamComposing,
|
useStreamComposing,
|
||||||
} from './stream-presence-context';
|
} from './stream-presence-context';
|
||||||
import { TextParticleView } from './TextParticleView';
|
import { TextParticleView } from './TextParticleView';
|
||||||
|
import { TaskParticleView } from './TaskParticleView';
|
||||||
import { MediaParticleView } from './MediaParticleView';
|
import { MediaParticleView } from './MediaParticleView';
|
||||||
import { DeletedParticleView } from './DeletedParticleView';
|
import { DeletedParticleView } from './DeletedParticleView';
|
||||||
import { FallbackParticleView } from './FallbackParticleView';
|
import { FallbackParticleView } from './FallbackParticleView';
|
||||||
@@ -456,6 +457,18 @@ function StreamViewInner({ streamParticle, path, onExit }: StreamViewProps) {
|
|||||||
contentFit={videoFit}
|
contentFit={videoFit}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'task':
|
||||||
|
return (
|
||||||
|
<TaskParticleView
|
||||||
|
key={particle.id}
|
||||||
|
particle={particle}
|
||||||
|
networkId={networkId}
|
||||||
|
streamId={streamParticle.id}
|
||||||
|
paused={paused}
|
||||||
|
onEnded={next}
|
||||||
|
onProgress={setProgress}
|
||||||
|
/>
|
||||||
|
);
|
||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
<FallbackParticleView
|
<FallbackParticleView
|
||||||
|
|||||||
@@ -0,0 +1,400 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Pressable, ScrollView, Text, TextInput, View } from 'react-native';
|
||||||
|
import { deleteField } from 'firebase/firestore';
|
||||||
|
import { Check, Plus, X } from 'lucide-react-native';
|
||||||
|
import type { ChecklistItem, Particle } from '@/api/types';
|
||||||
|
import { particlePath, toFirestoreDocPath } from '@/lib/particle-path';
|
||||||
|
import {
|
||||||
|
updateParticle,
|
||||||
|
updateParticleProperties,
|
||||||
|
} from '@/lib/firestore-particles';
|
||||||
|
import { useNetwork } from '@/hooks/use-networks';
|
||||||
|
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
|
||||||
|
import { resolveHumanDisplay } from '@/lib/humans';
|
||||||
|
import { Avatar } from '@/components/Avatar';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useStreamSafeArea } from './stream-safe-area';
|
||||||
|
|
||||||
|
type TaskParticle = Extract<Particle, { type: 'task' }>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View
|
||||||
|
className="flex-1 items-center justify-center px-6"
|
||||||
|
style={{ paddingTop: safe.top + 16, paddingBottom: safe.bottom + 16 }}
|
||||||
|
>
|
||||||
|
<ScrollView
|
||||||
|
className="max-h-full w-full max-w-xl rounded-2xl bg-white/10"
|
||||||
|
contentContainerClassName="px-5 py-5 gap-5"
|
||||||
|
showsVerticalScrollIndicator
|
||||||
|
indicatorStyle="white"
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* Title + done */}
|
||||||
|
<View className="flex-row items-start gap-3">
|
||||||
|
<Pressable
|
||||||
|
onPress={handleToggleDone}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityLabel={done ? 'Mark not done' : 'Mark done'}
|
||||||
|
className={cn(
|
||||||
|
'mt-1 h-6 w-6 items-center justify-center rounded-full border',
|
||||||
|
done ? 'bg-emerald-500 border-emerald-500' : 'border-white/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{done ? <Check color="white" size={14} strokeWidth={3} /> : null}
|
||||||
|
</Pressable>
|
||||||
|
<TextInput
|
||||||
|
defaultValue={title}
|
||||||
|
key={`title-${particle.id}`}
|
||||||
|
onFocus={() => 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',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<TextInput
|
||||||
|
defaultValue={notes ?? ''}
|
||||||
|
key={`notes-${particle.id}`}
|
||||||
|
onFocus={() => 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 */}
|
||||||
|
<View className="gap-2">
|
||||||
|
{checklist.length > 0 ? (
|
||||||
|
<Text className="text-white/40 text-xs">
|
||||||
|
{doneCount} / {checklist.length} done
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{checklist.map((item, index) => (
|
||||||
|
<ChecklistItemRow
|
||||||
|
key={`${particle.id}-item-${index}`}
|
||||||
|
item={item}
|
||||||
|
onToggle={() => handleToggleItem(index)}
|
||||||
|
onCommitText={(text) => handleCommitItemText(index, text)}
|
||||||
|
onRemove={() => handleRemoveItem(index)}
|
||||||
|
onFocusChange={setEditing}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<AddChecklistItemRow
|
||||||
|
onAdd={handleAddItem}
|
||||||
|
onFocusChange={setEditing}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Assignee */}
|
||||||
|
<View className="gap-2">
|
||||||
|
<Text className="text-white/40 text-xs">Assignee</Text>
|
||||||
|
<ScrollView
|
||||||
|
horizontal
|
||||||
|
showsHorizontalScrollIndicator={false}
|
||||||
|
contentContainerClassName="gap-2"
|
||||||
|
>
|
||||||
|
<AssigneeChip
|
||||||
|
label="Unassigned"
|
||||||
|
selected={!assigned_to}
|
||||||
|
onPress={() => handleAssign(null)}
|
||||||
|
/>
|
||||||
|
{network?.humans?.map((human) => {
|
||||||
|
const display = resolveHumanDisplay(human.id, network?.humans);
|
||||||
|
return (
|
||||||
|
<AssigneeChip
|
||||||
|
key={human.id}
|
||||||
|
label={display.displayName}
|
||||||
|
selected={assigned_to === human.id}
|
||||||
|
onPress={() => handleAssign(human.id)}
|
||||||
|
avatarHumanId={human.id}
|
||||||
|
humans={network?.humans}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChecklistItemRow({
|
||||||
|
item,
|
||||||
|
onToggle,
|
||||||
|
onCommitText,
|
||||||
|
onRemove,
|
||||||
|
onFocusChange,
|
||||||
|
}: {
|
||||||
|
item: ChecklistItem;
|
||||||
|
onToggle: () => void;
|
||||||
|
onCommitText: (text: string) => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
onFocusChange: (focused: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<View className="flex-row items-center gap-2.5">
|
||||||
|
<Pressable
|
||||||
|
onPress={onToggle}
|
||||||
|
hitSlop={6}
|
||||||
|
accessibilityLabel={
|
||||||
|
item.done ? 'Mark subtask not done' : 'Mark subtask done'
|
||||||
|
}
|
||||||
|
className={cn(
|
||||||
|
'h-5 w-5 items-center justify-center rounded border',
|
||||||
|
item.done ? 'bg-emerald-500 border-emerald-500' : 'border-white/30',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.done ? <Check color="white" size={12} strokeWidth={3} /> : null}
|
||||||
|
</Pressable>
|
||||||
|
<TextInput
|
||||||
|
defaultValue={item.text}
|
||||||
|
onFocus={() => 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',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPress={onRemove}
|
||||||
|
hitSlop={6}
|
||||||
|
accessibilityLabel="Remove subtask"
|
||||||
|
>
|
||||||
|
<X color="rgba(255,255,255,0.4)" size={14} />
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<View className="flex-row items-center gap-2.5">
|
||||||
|
<Plus color="rgba(255,255,255,0.3)" size={16} />
|
||||||
|
<TextInput
|
||||||
|
value={draft}
|
||||||
|
onChangeText={setDraft}
|
||||||
|
onFocus={() => 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"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AssigneeChip({
|
||||||
|
label,
|
||||||
|
selected,
|
||||||
|
onPress,
|
||||||
|
avatarHumanId,
|
||||||
|
humans,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
selected: boolean;
|
||||||
|
onPress: () => void;
|
||||||
|
avatarHumanId?: string;
|
||||||
|
humans?: import('@/api/types').Human[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
onPress={onPress}
|
||||||
|
className={cn(
|
||||||
|
'flex-row items-center gap-2 rounded-full px-3 py-2',
|
||||||
|
selected ? 'bg-white' : 'bg-white/10',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{avatarHumanId ? (
|
||||||
|
<Avatar humanId={avatarHumanId} humans={humans} size="xs" />
|
||||||
|
) : null}
|
||||||
|
<Text
|
||||||
|
className={cn(
|
||||||
|
'text-sm font-medium',
|
||||||
|
selected ? 'text-black' : 'text-white/80',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -189,6 +189,7 @@ export function NewStreamScreen({
|
|||||||
networkId={networkId}
|
networkId={networkId}
|
||||||
targetPath={placeholderPath}
|
targetPath={placeholderPath}
|
||||||
silentPresence
|
silentPresence
|
||||||
|
allowTask={false}
|
||||||
submitMedia={submitMedia}
|
submitMedia={submitMedia}
|
||||||
submitText={submitText}
|
submitText={submitText}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export const StreamCard = memo(function StreamCard({
|
|||||||
return latestChild.properties.content;
|
return latestChild.properties.content;
|
||||||
case 'file':
|
case 'file':
|
||||||
return latestChild.properties.filename;
|
return latestChild.properties.filename;
|
||||||
case 'quest':
|
case 'task':
|
||||||
return latestChild.properties.title;
|
return latestChild.properties.title;
|
||||||
case 'paper':
|
case 'paper':
|
||||||
return latestChild.properties.title;
|
return latestChild.properties.title;
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
|||||||
case 'media':
|
case 'media':
|
||||||
case 'file':
|
case 'file':
|
||||||
case 'text':
|
case 'text':
|
||||||
case 'quest':
|
case 'task':
|
||||||
case 'paper': {
|
case 'paper': {
|
||||||
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
|
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
|
||||||
// particles carry `properties.edited_at`, so coerce it if present.
|
// particles carry `properties.edited_at`, so coerce it if present.
|
||||||
|
|||||||
@@ -103,6 +103,32 @@ export async function createTextParticle({
|
|||||||
return createParticle(collectionPath, 'text', { content }, createdByHumanId);
|
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<string> {
|
||||||
|
const collectionPath = toFirestoreChildrenPath(targetPath);
|
||||||
|
return createParticle(
|
||||||
|
collectionPath,
|
||||||
|
'task',
|
||||||
|
{ title, done: false, ...(notes ? { notes } : {}) },
|
||||||
|
createdByHumanId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function extensionFromMime(mime: string): string {
|
function extensionFromMime(mime: string): string {
|
||||||
if (mime === 'video/mp4') return '.mp4';
|
if (mime === 'video/mp4') return '.mp4';
|
||||||
if (mime === 'video/quicktime') return '.mov';
|
if (mime === 'video/quicktime') return '.mov';
|
||||||
|
|||||||
Reference in New Issue
Block a user