cleanup folders and events, and condense changes

This commit is contained in:
Arjun Patel
2026-06-12 10:11:44 -07:00
parent 72caee8660
commit 22ba0410a5
19 changed files with 267 additions and 511 deletions
+23 -19
View File
@@ -162,14 +162,6 @@ export const TaskPropertiesSchema = z.object({
}); });
export type TaskProperties = z.infer<typeof TaskPropertiesSchema>; export type TaskProperties = z.infer<typeof TaskPropertiesSchema>;
export const EventPropertiesSchema = z.object({
title: z.string(),
start_at: z.coerce.date(),
end_at: z.coerce.date().optional(),
notes: z.string().optional(),
});
export type EventProperties = z.infer<typeof EventPropertiesSchema>;
export const PaperPropertiesSchema = z.object({ export const PaperPropertiesSchema = z.object({
title: z.string(), title: z.string(),
content: z.string(), content: z.string(),
@@ -210,7 +202,6 @@ export interface ParticlePropertiesMap {
file: FileProperties; file: FileProperties;
text: TextProperties; text: TextProperties;
task: TaskProperties; task: TaskProperties;
event: EventProperties;
paper: PaperProperties; paper: PaperProperties;
} }
@@ -227,6 +218,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
ParticleBaseSchema.extend({ ParticleBaseSchema.extend({
type: z.literal('stream'), type: z.literal('stream'),
properties: StreamPropertiesSchema, properties: StreamPropertiesSchema,
// Open/closed lifecycle. Optional because some streams were created while
// the field was dropped — treat missing as 'open' (see isStreamOpen).
status: z.enum(['open', 'closed']).optional(),
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John // e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:xywx"] - visible to everyone in the network // e.g. ["network:xywx"] - visible to everyone in the network
visible_to: z.array(z.string()), visible_to: z.array(z.string()),
@@ -271,12 +265,6 @@ export const ParticleSchema = z.discriminatedUnion('type', [
reactions: ReactionsSchema, reactions: ReactionsSchema,
...TombstoneFields, ...TombstoneFields,
}), }),
ParticleBaseSchema.extend({
type: z.literal('event'),
properties: EventPropertiesSchema,
reactions: ReactionsSchema,
...TombstoneFields,
}),
ParticleBaseSchema.extend({ ParticleBaseSchema.extend({
type: z.literal('paper'), type: z.literal('paper'),
properties: PaperPropertiesSchema, properties: PaperPropertiesSchema,
@@ -284,17 +272,28 @@ export const ParticleSchema = z.discriminatedUnion('type', [
}), }),
]); ]);
export type Particle = z.infer<typeof ParticleSchema>; // Placeholder for docs whose `type` this client version doesn't recognize
// (e.g. a newer client wrote a particle type we don't ship yet). The converter
// maps them here instead of throwing, so they degrade to an "unsupported"
// view rather than breaking the whole subscription.
export const UnknownParticleSchema = ParticleBaseSchema.extend({
type: z.literal('unknown'),
raw_type: z.string(),
});
export type UnknownParticle = z.infer<typeof UnknownParticleSchema>;
export type ParticleType = Particle['type']; export type Particle = z.infer<typeof ParticleSchema> | UnknownParticle;
/** Particle types this client can read and write — excludes 'unknown'. */
export type ParticleType = z.infer<typeof ParticleSchema>['type'];
/** Container types can have children subcollections */ /** Container types can have children subcollections */
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set([ export const CONTAINER_TYPES: ReadonlySet<Particle['type']> = new Set([
'stream', 'stream',
'folder', 'folder',
]); ]);
export function isContainerType(type: ParticleType): boolean { export function isContainerType(type: Particle['type']): boolean {
return CONTAINER_TYPES.has(type); return CONTAINER_TYPES.has(type);
} }
@@ -303,6 +302,11 @@ export function isParticleDeleted(particle: Particle): boolean {
return 'deleted_at' in particle && particle.deleted_at != null; return 'deleted_at' in particle && particle.deleted_at != null;
} }
/** Missing status counts as open (streams created while the field was dropped). */
export function isStreamOpen(stream: Extract<Particle, { type: 'stream' }>) {
return stream.status !== 'closed';
}
// --- LiveKit types --- // --- LiveKit types ---
export const GetLivekitTokenResponseSchema = z.object({ export const GetLivekitTokenResponseSchema = z.object({
@@ -20,9 +20,8 @@ import { ScreenSourcePicker } from '@/components/screen-source-picker';
import { KeyHint } from '@/components/key-hint'; import { KeyHint } from '@/components/key-hint';
import { TextComposeStep } from '@/features/compose/text-compose-step'; import { TextComposeStep } from '@/features/compose/text-compose-step';
import { TaskComposeStep } from '@/features/compose/task-compose-step'; import { TaskComposeStep } from '@/features/compose/task-compose-step';
import { EventComposeStep } from '@/features/compose/event-compose-step';
import { ConfigureContainerStep } from '@/features/compose/configure-container-step'; import { ConfigureContainerStep } from '@/features/compose/configure-container-step';
import type { TaskProperties, EventProperties } from '@/api/types'; import type { TaskProperties } from '@/api/types';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
import { useMediaSettingsStore } from '@/stores/media-settings-store'; import { useMediaSettingsStore } from '@/stores/media-settings-store';
import { useMediaDevicesStore } from '@/stores/media-devices-store'; import { useMediaDevicesStore } from '@/stores/media-devices-store';
@@ -44,15 +43,13 @@ export type ComposeStep =
| 'reviewing' | 'reviewing'
| 'typing' | 'typing'
| 'task' | 'task'
| 'event'
| 'configuring' | 'configuring'
| 'submitting'; | 'submitting';
type RecordingSource = 'media' | 'screen'; type RecordingSource = 'media' | 'screen';
type PendingArtifact = type PendingArtifact =
| { type: 'task'; properties: TaskProperties } | { type: 'task'; properties: TaskProperties };
| { type: 'event'; properties: EventProperties };
interface ComposeOverlayProps { interface ComposeOverlayProps {
networkId: string; networkId: string;
@@ -106,8 +103,7 @@ export function ComposeOverlay({
const recordStartRef = useRef(0); const recordStartRef = useRef(0);
const quotaExhaustedRef = useRef(quotaExhausted); const quotaExhaustedRef = useRef(quotaExhausted);
const recordingSourceRef = useRef(recordingSource); const recordingSourceRef = useRef(recordingSource);
// Task/event captured by their compose steps, created on submit. A ref (not
// state) so submit handlers can set it and create in the same tick.
const pendingArtifactRef = useRef<PendingArtifact | null>(null); const pendingArtifactRef = useRef<PendingArtifact | null>(null);
useEffect(() => { useEffect(() => {
quotaExhaustedRef.current = quotaExhausted; quotaExhaustedRef.current = quotaExhausted;
@@ -501,12 +497,8 @@ export function ComposeOverlay({
setStepSync('task'); setStepSync('task');
}, [guardIdle, setStepSync]); }, [guardIdle, setStepSync]);
const handleEventIntent = useCallback(() => {
if (!guardIdle()) return;
setStepSync('event');
}, [guardIdle, setStepSync]);
// Task/event submit: capture the artifact, then reuse the standard flow — // Artifact submit: capture the artifact, then reuse the standard flow —
// reply mode creates it under targetPath, root mode configures a stream // reply mode creates it under targetPath, root mode configures a stream
// that will hold it as its first child. // that will hold it as its first child.
const handleArtifactSubmit = useCallback( const handleArtifactSubmit = useCallback(
@@ -577,9 +569,6 @@ export function ComposeOverlay({
case 'task': case 'task':
handleTaskIntent(); handleTaskIntent();
break; break;
case 'event':
handleEventIntent();
break;
case 'stop': case 'stop':
handleStopIntent(); handleStopIntent();
break; break;
@@ -596,7 +585,6 @@ export function ComposeOverlay({
handleRecordIntent, handleRecordIntent,
handleTextIntent, handleTextIntent,
handleTaskIntent, handleTaskIntent,
handleEventIntent,
handleStopIntent, handleStopIntent,
handleCancelIntent, handleCancelIntent,
handleSendIntent, handleSendIntent,
@@ -612,7 +600,6 @@ export function ComposeOverlay({
if ( if (
currentStep === 'typing' || currentStep === 'typing' ||
currentStep === 'task' || currentStep === 'task' ||
currentStep === 'event' ||
currentStep === 'configuring' || currentStep === 'configuring' ||
currentStep === 'picking' currentStep === 'picking'
) { ) {
@@ -649,9 +636,6 @@ export function ComposeOverlay({
} else if (e.key === 'd' || e.key === 'D') { } else if (e.key === 'd' || e.key === 'D') {
e.preventDefault(); e.preventDefault();
handleTaskIntent(); handleTaskIntent();
} else if (e.key === 'e' || e.key === 'E') {
e.preventDefault();
handleEventIntent();
} }
break; break;
} }
@@ -720,7 +704,6 @@ export function ComposeOverlay({
handleRecordIntent, handleRecordIntent,
handleTextIntent, handleTextIntent,
handleTaskIntent, handleTaskIntent,
handleEventIntent,
handleStopIntent, handleStopIntent,
handleCancelIntent, handleCancelIntent,
handleSendIntent, handleSendIntent,
@@ -838,14 +821,6 @@ export function ComposeOverlay({
} }
/> />
)} )}
{step === 'event' && (
<EventComposeStep
onCancel={cancel}
onSubmit={(properties) =>
handleArtifactSubmit({ type: 'event', properties })
}
/>
)}
{!targetPath && step === 'configuring' && ( {!targetPath && step === 'configuring' && (
<ConfigureContainerStep <ConfigureContainerStep
networkId={networkId} networkId={networkId}
@@ -1,126 +0,0 @@
import { useCallback, useState } from 'react';
import type { EventProperties } from '@/api/types';
import { metaKey } from '@/lib/platform';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { KeyHint } from '@/components/key-hint';
import { toDatetimeLocalValue } from '@/features/particles/event-particle-view';
interface EventComposeStepProps {
onCancel: () => void;
onSubmit: (properties: EventProperties) => void;
}
function nextFullHour(): Date {
const date = new Date();
date.setMinutes(0, 0, 0);
date.setHours(date.getHours() + 1);
return date;
}
const dateInputClass =
'w-full rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white outline-none [color-scheme:dark] focus:border-white/30';
export function EventComposeStep({
onCancel,
onSubmit,
}: EventComposeStepProps) {
const [title, setTitle] = useState('');
const [startAt, setStartAt] = useState(() =>
toDatetimeLocalValue(nextFullHour()),
);
const [endAt, setEndAt] = useState('');
const [notes, setNotes] = useState('');
const handleSubmit = useCallback(() => {
const trimmed = title.trim();
if (!trimmed || !startAt) return;
onSubmit({
title: trimmed,
start_at: new Date(startAt),
...(endAt && { end_at: new Date(endAt) }),
...(notes.trim() && { notes: notes.trim() }),
});
}, [title, startAt, endAt, notes, onSubmit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
onCancel();
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
},
[onCancel, handleSubmit],
);
return (
<div
className="absolute inset-0 z-50 flex flex-col bg-black/90 pt-16"
onKeyDown={handleKeyDown}
tabIndex={-1}
>
<div className="mx-auto w-full max-w-sm space-y-4 px-6">
<div>
<Label className="mb-1 text-xs text-white/50">Event</Label>
<Input
type="text"
autoFocus
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="What's happening?"
className="border-white/10 bg-white/5 text-white placeholder-white/30 focus-visible:border-white/30 focus-visible:ring-0"
/>
</div>
<div className="flex gap-3">
<div className="flex-1">
<Label className="mb-1 text-xs text-white/50">Starts</Label>
<input
type="datetime-local"
value={startAt}
onChange={(e) => setStartAt(e.target.value)}
className={dateInputClass}
/>
</div>
<div className="flex-1">
<Label className="mb-1 text-xs text-white/50">Ends</Label>
<input
type="datetime-local"
value={endAt}
min={startAt}
onChange={(e) => setEndAt(e.target.value)}
className={dateInputClass}
/>
</div>
</div>
<div>
<Label className="mb-1 text-xs text-white/50">Notes</Label>
<Textarea
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder="Optional details…"
className="min-h-20 border-white/10 bg-white/5 text-white placeholder:text-white/30 focus-visible:border-white/30 focus-visible:ring-0 dark:bg-white/5"
/>
</div>
</div>
<div className="absolute bottom-8 left-0 right-0 flex items-center justify-center gap-4 text-sm text-white/50">
<KeyHint keys="Esc" onClick={onCancel} title="Cancel (or press Esc)">
cancel
</KeyHint>
<KeyHint
keys={`${metaKey}+Enter`}
onClick={handleSubmit}
title={`Create event (or press ${metaKey}+Enter)`}
>
create
</KeyHint>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { FolderIcon } from 'lucide-react'; import { CircleCheckBig, CircleDot, FolderIcon } from 'lucide-react';
import type { Particle } from '@/api/types'; import type { Particle } from '@/api/types';
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path'; import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
@@ -17,6 +17,7 @@ import { ConfigureContainerStep } from '@/features/compose/configure-container-s
import { useComposeIntentStore } from '@/stores/compose-intent-store'; import { useComposeIntentStore } from '@/stores/compose-intent-store';
import { VideoAudioToggle } from '@/components/video-audio-toggle'; import { VideoAudioToggle } from '@/components/video-audio-toggle';
import { KeyHint } from '@/components/key-hint'; import { KeyHint } from '@/components/key-hint';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
type ContainerKind = 'stream' | 'folder'; type ContainerKind = 'stream' | 'folder';
@@ -42,8 +43,28 @@ export function ContainerView({ path, folderParticle }: ContainerViewProps) {
const [composeActive, setComposeActive] = useState(false); const [composeActive, setComposeActive] = useState(false);
const [creating, setCreating] = useState<ContainerKind | null>(null); const [creating, setCreating] = useState<ContainerKind | null>(null);
const { items, isLoading, canLoadMore, loadMore } = const [searchParams, setSearchParams] = useSearchParams();
useContainerChildren(path); const statusTab: 'open' | 'closed' =
searchParams.get('status') === 'closed' ? 'closed' : 'open';
const setStatusTab = (next: 'open' | 'closed') => {
setSearchParams(
(prev) => {
const params = new URLSearchParams(prev);
params.set('status', next);
return params;
},
{ replace: true },
);
};
// Folders are shelved for now: the root lists streams only, split by
// open/closed status (server-side filter, like before folders). Folder
// containers keep the mixed-type child list so the recursive container
// model can be revived later.
const { items, isLoading, canLoadMore, loadMore } = useContainerChildren(
path,
{ streamStatus: isRoot ? statusTab : undefined },
);
const createParticle = useCreateParticle(); const createParticle = useCreateParticle();
const createStream = useCreateStreamParticle(); const createStream = useCreateStreamParticle();
@@ -61,10 +82,10 @@ export function ContainerView({ path, folderParticle }: ContainerViewProps) {
onOpen: handleOpen, onOpen: handleOpen,
}); });
// F creates a folder (root only — no sub-folders yet); N creates a stream // N creates a stream inside a folder. Root stream creation goes through the
// inside a folder. Root stream creation goes through the compose flow. // compose flow.
useEffect(() => { useEffect(() => {
if (composeActive || creating !== null) return; if (isRoot || composeActive || creating !== null) return;
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.defaultPrevented) return; if (e.defaultPrevented) return;
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
@@ -75,10 +96,7 @@ export function ContainerView({ path, folderParticle }: ContainerViewProps) {
) { ) {
return; return;
} }
if (isRoot && (e.key === 'f' || e.key === 'F')) { if (e.key === 'n' || e.key === 'N') {
e.preventDefault();
setCreating('folder');
} else if (!isRoot && (e.key === 'n' || e.key === 'N')) {
e.preventDefault(); e.preventDefault();
setCreating('stream'); setCreating('stream');
} }
@@ -123,6 +141,25 @@ export function ContainerView({ path, folderParticle }: ContainerViewProps) {
return ( return (
<div className="relative flex min-h-0 flex-1 flex-col"> <div className="relative flex min-h-0 flex-1 flex-col">
{isRoot && (
<div className="flex shrink-0 items-center border-b p-1">
<Tabs
value={statusTab}
onValueChange={(v) =>
setStatusTab(v === 'closed' ? 'closed' : 'open')
}
>
<TabsList>
<TabsTrigger value="open">
<CircleDot className="size-3 text-green-500" /> Open
</TabsTrigger>
<TabsTrigger value="closed">
<CircleCheckBig className="size-3" /> Closed
</TabsTrigger>
</TabsList>
</Tabs>
</div>
)}
{folderParticle && ( {folderParticle && (
<div className="flex shrink-0 items-center gap-2 border-b px-4 py-3"> <div className="flex shrink-0 items-center gap-2 border-b px-4 py-3">
<span className="flex size-7 items-center justify-center rounded-md bg-amber-500/15"> <span className="flex size-7 items-center justify-center rounded-md bg-amber-500/15">
@@ -144,9 +181,11 @@ export function ContainerView({ path, folderParticle }: ContainerViewProps) {
canLoadMore={canLoadMore} canLoadMore={canLoadMore}
onLoadMore={loadMore} onLoadMore={loadMore}
emptyMessage={ emptyMessage={
isRoot !isRoot
? 'No streams here. Start a conversation using the keyboard shortcuts below.' ? 'This folder is empty. Add something using the keyboard shortcuts below.'
: 'This folder is empty. Add something using the keyboard shortcuts below.' : statusTab === 'closed'
? 'No closed streams.'
: 'No streams here. Start a conversation using the keyboard shortcuts below.'
} }
/> />
</div> </div>
@@ -165,7 +204,6 @@ export function ContainerView({ path, folderParticle }: ContainerViewProps) {
<div className="pointer-events-auto"> <div className="pointer-events-auto">
<ContainerControls <ContainerControls
isRoot={isRoot} isRoot={isRoot}
onCreateFolder={() => setCreating('folder')}
onCreateStream={() => setCreating('stream')} onCreateStream={() => setCreating('stream')}
/> />
</div> </div>
@@ -185,11 +223,9 @@ export function ContainerView({ path, folderParticle }: ContainerViewProps) {
function ContainerControls({ function ContainerControls({
isRoot, isRoot,
onCreateFolder,
onCreateStream, onCreateStream,
}: { }: {
isRoot: boolean; isRoot: boolean;
onCreateFolder: () => void;
onCreateStream: () => void; onCreateStream: () => void;
}) { }) {
const requestIntent = useComposeIntentStore((s) => s.request); const requestIntent = useComposeIntentStore((s) => s.request);
@@ -219,22 +255,7 @@ function ContainerControls({
> >
task task
</KeyHint> </KeyHint>
<KeyHint {!isRoot && (
keys="E"
onClick={() => requestIntent('event')}
title="Create an event (or press E)"
>
event
</KeyHint>
{isRoot ? (
<KeyHint
keys="F"
onClick={onCreateFolder}
title="Create a folder (or press F)"
>
folder
</KeyHint>
) : (
<KeyHint <KeyHint
keys="N" keys="N"
onClick={onCreateStream} onClick={onCreateStream}
@@ -1,162 +0,0 @@
import { useCallback, useState } from 'react';
import { deleteField } from 'firebase/firestore';
import { CalendarDays } from 'lucide-react';
import type { 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 { Textarea } from '@/components/ui/textarea';
import { useFixedDwell } from '@/hooks/use-fixed-dwell';
import { useLiveDraftField } from '@/hooks/use-live-draft-field';
import { useSuspendPlayback } from '@/hooks/use-suspend-playback';
import { formatEventTime } from '@/lib/particle-display';
type EventParticle = Extract<Particle, { type: 'event' }>;
interface EventParticleViewProps {
particle: EventParticle;
containerPath: ParticlePath;
paused: boolean;
onEnded: () => void;
onProgress?: (ratio: number) => void;
}
const DWELL_DURATION_S = 8;
function pad(n: number): string {
return String(n).padStart(2, '0');
}
/** Date → value for <input type="datetime-local"> in the local timezone. */
export function toDatetimeLocalValue(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
date.getDate(),
)}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
const dateInputClass =
'rounded-md border border-white/15 bg-white/5 px-2 py-1 text-sm text-white/80 outline-none [color-scheme:dark] focus:border-white/40';
export function EventParticleView({
particle,
containerPath,
paused,
onEnded,
onProgress,
}: EventParticleViewProps) {
const { networkId, segments } = parseParticlePath(containerPath);
const docPath = toFirestoreDocPath(
particlePath(networkId, [...segments, particle.id]),
);
const { title, notes, start_at, end_at } = particle.properties;
const [editing, setEditing] = useState(false);
useSuspendPlayback(editing, `event-edit-${particle.id}`);
useFixedDwell({
id: particle.id,
durationS: DWELL_DURATION_S,
paused,
onEnded,
onProgress,
});
const titleField = useLiveDraftField({
remoteValue: title,
commit: (value) =>
updateParticleProperties<'event'>(docPath, { title: value }),
});
const notesField = useLiveDraftField({
remoteValue: notes ?? '',
commit: (value) =>
updateParticleProperties<'event'>(docPath, { notes: value }),
});
const handleStartChange = useCallback(
(value: string) => {
if (!value) return;
void updateParticleProperties<'event'>(docPath, {
start_at: new Date(value),
});
},
[docPath],
);
const handleEndChange = useCallback(
(value: string) => {
if (!value) {
void updateParticle(docPath, 'properties.end_at', deleteField());
} else {
void updateParticleProperties<'event'>(docPath, {
end_at: new Date(value),
});
}
},
[docPath],
);
return (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-b from-black/40 via-black/20 to-black/40 px-8 pt-[var(--stream-safe-top,2rem)] pb-[var(--stream-safe-bottom,2rem)]">
<div
className="flex max-h-full w-full max-w-[calc(var(--message-content-width)_+_var(--message-card-padding)*2)] flex-col gap-5 overflow-y-auto overscroll-contain rounded bg-white/10 p-[var(--message-card-padding)] backdrop-blur-md"
onFocusCapture={() => setEditing(true)}
onBlurCapture={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) setEditing(false);
}}
>
<div className="flex items-center gap-2 text-sm font-medium text-violet-300/90">
<CalendarDays className="size-4" />
{formatEventTime(start_at, end_at)}
</div>
<input
value={titleField.value}
onChange={(e) => titleField.onChange(e.target.value)}
onFocus={titleField.onFocus}
onBlur={titleField.onBlur}
placeholder="Event title"
className="w-full bg-transparent text-2xl font-semibold text-white outline-none placeholder:text-white/30"
/>
<div className="flex flex-wrap items-center gap-3 text-sm text-white/60">
<label className="flex items-center gap-2">
Starts
<input
type="datetime-local"
value={toDatetimeLocalValue(start_at)}
onChange={(e) => handleStartChange(e.target.value)}
className={dateInputClass}
/>
</label>
<label className="flex items-center gap-2">
Ends
<input
type="datetime-local"
value={end_at ? toDatetimeLocalValue(end_at) : ''}
onChange={(e) => handleEndChange(e.target.value)}
className={cn(dateInputClass, !end_at && 'text-white/40')}
/>
</label>
</div>
<Textarea
value={notesField.value}
onChange={(e) => notesField.onChange(e.target.value)}
onFocus={notesField.onFocus}
onBlur={notesField.onBlur}
placeholder="Add notes…"
className="min-h-16 resize-none border-none bg-transparent p-0 text-sm text-white/80 shadow-none placeholder:text-white/30 focus-visible:ring-0 dark:bg-transparent"
/>
</div>
</div>
);
}
@@ -31,7 +31,7 @@ export function FallbackParticleView({
); );
const meta = TYPE_META[particle.type] ?? { const meta = TYPE_META[particle.type] ?? {
icon: HelpCircleIcon, icon: HelpCircleIcon,
label: particle.type, label: particle.type === 'unknown' ? particle.raw_type : particle.type,
}; };
const Icon = meta.icon; const Icon = meta.icon;
const title = (() => { const title = (() => {
@@ -42,6 +42,8 @@ export function FallbackParticleView({
return particle.properties.filename; return particle.properties.filename;
case 'folder': case 'folder':
return particle.properties.name; return particle.properties.name;
case 'unknown':
return 'Not supported in this version of the app';
default: default:
return null; return null;
} }
@@ -1,4 +1,4 @@
import { useMemo, useRef, useEffect, memo, createElement } from 'react'; import { Fragment, useMemo, useRef, useEffect, memo, createElement } from 'react';
import { Headphones, Radio, FolderIcon } from 'lucide-react'; import { Headphones, Radio, FolderIcon } from 'lucide-react';
import { cn, getInitials } from '@/lib/utils'; import { cn, getInitials } from '@/lib/utils';
import { useLiveLatestChild } from '@/hooks/use-particle'; import { useLiveLatestChild } from '@/hooks/use-particle';
@@ -20,6 +20,7 @@ import { useNetwork } from '@/hooks/use-networks';
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay'; import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
import { useDownloadUrl } from '@/hooks/use-download-url'; import { useDownloadUrl } from '@/hooks/use-download-url';
import { getMessagePreview, getParticleTypeIcon } from '@/lib/particle-display'; import { getMessagePreview, getParticleTypeIcon } from '@/lib/particle-display';
import { StreamContextMenu } from '@/features/particles/stream-context-menu';
type StreamParticle = Particle & { type StreamParticle = Particle & {
type: 'stream'; type: 'stream';
@@ -444,9 +445,8 @@ export function ParticleChildrenList({
isSelected: index === selectedIndex, isSelected: index === selectedIndex,
shortcutKey: showShortcuts && index < 9 ? index + 1 : undefined, shortcutKey: showShortcuts && index < 9 ? index + 1 : undefined,
}; };
return ( const row = (
<div <div
key={item.id}
ref={(el) => { ref={(el) => {
rowRefs.current[index] = el; rowRefs.current[index] = el;
}} }}
@@ -461,6 +461,17 @@ export function ParticleChildrenList({
{index < items.length - 1 && <Separator className="px-4" />} {index < items.length - 1 && <Separator className="px-4" />}
</div> </div>
); );
return item.type === 'stream' ? (
<StreamContextMenu
key={item.id}
particle={item}
networkId={networkId}
>
{row}
</StreamContextMenu>
) : (
<Fragment key={item.id}>{row}</Fragment>
);
})} })}
{canLoadMore && onLoadMore && ( {canLoadMore && onLoadMore && (
<div className="flex justify-center p-3"> <div className="flex justify-center p-3">
@@ -6,13 +6,11 @@ import {
Video, Video,
Mic, Mic,
CircleCheck, CircleCheck,
CalendarDays,
BookOpen, BookOpen,
FileIcon, FileIcon,
FolderIcon, FolderIcon,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { formatEventTime } from '@/lib/particle-display';
export function ParticlePreview({ particle }: { particle: Particle }) { export function ParticlePreview({ particle }: { particle: Particle }) {
switch (particle.type) { switch (particle.type) {
@@ -22,8 +20,6 @@ export function ParticlePreview({ particle }: { particle: Particle }) {
return <MediaPreview particle={particle} />; return <MediaPreview particle={particle} />;
case 'task': case 'task':
return <TaskPreview particle={particle} />; return <TaskPreview particle={particle} />;
case 'event':
return <EventPreview particle={particle} />;
case 'paper': case 'paper':
return <PaperPreview particle={particle} />; return <PaperPreview particle={particle} />;
case 'file': case 'file':
@@ -161,23 +157,6 @@ function TaskPreview({
); );
} }
function EventPreview({
particle,
}: {
particle: Extract<Particle, { type: 'event' }>;
}) {
const { title, start_at, end_at } = particle.properties;
return (
<div className="flex h-full w-full flex-col items-center justify-center gap-2 bg-violet-500/10 p-4">
<CalendarDays className="h-6 w-6 text-violet-600/70 dark:text-violet-400/70" />
<p className="line-clamp-2 text-center text-sm font-medium">{title}</p>
<span className="text-muted-foreground text-center text-[10px]">
{formatEventTime(start_at, end_at)}
</span>
</div>
);
}
function PaperPreview({ function PaperPreview({
particle, particle,
}: { }: {
@@ -13,7 +13,6 @@ import { FolderView } from '@/features/particles/folder-view';
import { MediaParticleView } from '@/features/particles/media-particle-view'; import { MediaParticleView } from '@/features/particles/media-particle-view';
import { TextParticleView } from '@/features/particles/text-particle-view'; import { TextParticleView } from '@/features/particles/text-particle-view';
import { TaskParticleView } from '@/features/particles/task-particle-view'; import { TaskParticleView } from '@/features/particles/task-particle-view';
import { EventParticleView } from '@/features/particles/event-particle-view';
import { FallbackParticleView } from '@/features/particles/fallback-particle-view'; import { FallbackParticleView } from '@/features/particles/fallback-particle-view';
import { DeletedParticleView } from '@/features/particles/deleted-particle-view'; import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
@@ -131,15 +130,6 @@ function LeafParticleView({
onEnded={noop} onEnded={noop}
/> />
); );
case 'event':
return (
<EventParticleView
particle={particle}
containerPath={containerPath}
paused
onEnded={noop}
/>
);
default: default:
return ( return (
<FallbackParticleView particle={particle} networkId={networkId} /> <FallbackParticleView particle={particle} networkId={networkId} />
@@ -123,13 +123,6 @@ export function StreamViewControls({
> >
task task
</KeyHint> </KeyHint>
<KeyHint
keys="E"
onClick={() => requestIntent('event')}
title="Add an event (or press E)"
>
event
</KeyHint>
<KeyHint <KeyHint
keys="H" keys="H"
onClick={onOpenHuddle} onClick={onOpenHuddle}
@@ -0,0 +1,50 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from '@/components/ui/context-menu';
import { CircleCheckBig, CircleDot } from 'lucide-react';
import { updateStreamStatus } from '@/lib/firestore-particles';
import { toFirestoreDocPath, particlePath } from '@/lib/particle-path';
import { isStreamOpen, type Particle } from '@/api/types';
interface StreamContextMenuProps {
particle: Extract<Particle, { type: 'stream' }>;
networkId: string;
children: React.ReactNode;
}
export function StreamContextMenu({
particle,
networkId,
children,
}: StreamContextMenuProps) {
const isOpen = isStreamOpen(particle);
const docPath = toFirestoreDocPath(particlePath(networkId, [particle.id]));
const toggleStatus = async () => {
await updateStreamStatus(docPath, isOpen ? 'closed' : 'open');
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={toggleStatus}>
{isOpen ? (
<>
<CircleCheckBig className="size-4" />
Close stream
</>
) : (
<>
<CircleDot className="size-4 text-green-500" />
Open stream
</>
)}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
@@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { import {
CalendarDays,
CircleCheck, CircleCheck,
FileText, FileText,
Image, Image,
@@ -12,7 +11,6 @@ import { isParticleDeleted, type Human, type Particle } from '@/api/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useNetwork } from '@/hooks/use-networks'; import { useNetwork } from '@/hooks/use-networks';
import { resolveHumanDisplay } from '@/lib/humans'; import { resolveHumanDisplay } from '@/lib/humans';
import { formatEventTime } from '@/lib/particle-display';
import { RelativeTimestamp } from '@/components/relative-timestamp'; import { RelativeTimestamp } from '@/components/relative-timestamp';
import { HumanAvatar } from '@/components/human-avatar'; import { HumanAvatar } from '@/components/human-avatar';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
@@ -212,21 +210,6 @@ function ChatRowContent({ particle }: { particle: Particle }) {
</div> </div>
); );
} }
case 'event':
return (
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1.5 text-xs text-white/70">
<CalendarDays className="size-3.5 shrink-0 text-violet-300/80" />
<span className="truncate">{particle.properties.title}</span>
</span>
<span className="pl-5 text-[10px] text-white/40">
{formatEventTime(
particle.properties.start_at,
particle.properties.end_at,
)}
</span>
</div>
);
case 'paper': case 'paper':
return ( return (
<p className="truncate text-xs text-white/70"> <p className="truncate text-xs text-white/70">
@@ -2,7 +2,7 @@ import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { apiClient } from '@/api/client'; import { apiClient } from '@/api/client';
import { isParticleDeleted, type Particle } from '@/api/types'; import { isParticleDeleted, isStreamOpen, type Particle } from '@/api/types';
import { AvatarGroup } from '@/components/ui/avatar'; import { AvatarGroup } from '@/components/ui/avatar';
import { HumanAvatar } from '@/components/human-avatar'; import { HumanAvatar } from '@/components/human-avatar';
import { import {
@@ -25,7 +25,11 @@ import {
Lock, Lock,
Globe, Globe,
Trash2, Trash2,
CircleCheckBig,
CircleDot,
} from 'lucide-react'; } from 'lucide-react';
import { updateStreamStatus } from '@/lib/firestore-particles';
import { toFirestoreDocPath, particlePath } from '@/lib/particle-path';
import { RenameStreamOverlay } from '@/features/particles/rename-stream-overlay'; import { RenameStreamOverlay } from '@/features/particles/rename-stream-overlay';
import { DeleteParticleOverlay } from '@/features/particles/delete-particle-overlay'; import { DeleteParticleOverlay } from '@/features/particles/delete-particle-overlay';
import { StreamMembersOverlay } from '@/features/particles/stream-members-overlay'; import { StreamMembersOverlay } from '@/features/particles/stream-members-overlay';
@@ -143,6 +147,13 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
</button> </button>
)} )}
{!isStreamOpen(streamParticle) && (
<span className="no-drag text-muted-foreground flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs backdrop-blur-sm">
<CircleCheckBig className="size-3" />
Closed
</span>
)}
<MembersIndicator <MembersIndicator
networkId={networkId} networkId={networkId}
streamParticle={streamParticle} streamParticle={streamParticle}
@@ -166,6 +177,29 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
Rename stream Rename stream
</DropdownMenuItem> </DropdownMenuItem>
)} )}
<DropdownMenuItem
onSelect={async () => {
const docPath = toFirestoreDocPath(
particlePath(networkId, [streamParticle.id]),
);
await updateStreamStatus(
docPath,
isStreamOpen(streamParticle) ? 'closed' : 'open',
);
}}
>
{isStreamOpen(streamParticle) ? (
<>
<CircleCheckBig className="size-4" />
Close stream
</>
) : (
<>
<CircleDot className="size-4 text-green-500" />
Open stream
</>
)}
</DropdownMenuItem>
{canDeleteParticle && ( {canDeleteParticle && (
<DropdownMenuItem <DropdownMenuItem
onSelect={() => setDeleteOpen(true)} onSelect={() => setDeleteOpen(true)}
@@ -26,7 +26,6 @@ import {
} from '@/features/particles/media-particle-view'; } from '@/features/particles/media-particle-view';
import { TextParticleView } from '@/features/particles/text-particle-view'; import { TextParticleView } from '@/features/particles/text-particle-view';
import { TaskParticleView } from '@/features/particles/task-particle-view'; import { TaskParticleView } from '@/features/particles/task-particle-view';
import { EventParticleView } from '@/features/particles/event-particle-view';
import { FallbackParticleView } from '@/features/particles/fallback-particle-view'; import { FallbackParticleView } from '@/features/particles/fallback-particle-view';
import { DeletedParticleView } from '@/features/particles/deleted-particle-view'; import { DeletedParticleView } from '@/features/particles/deleted-particle-view';
import { import {
@@ -76,8 +75,7 @@ function getReactions(
if ( if (
particle.type === 'media' || particle.type === 'media' ||
particle.type === 'text' || particle.type === 'text' ||
particle.type === 'task' || particle.type === 'task'
particle.type === 'event'
) )
return particle.reactions; return particle.reactions;
return undefined; return undefined;
@@ -164,7 +162,6 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
{ keys: ['S'], description: 'Screen record' }, { keys: ['S'], description: 'Screen record' },
{ keys: ['T'], description: 'Text compose' }, { keys: ['T'], description: 'Text compose' },
{ keys: ['D'], description: 'New task' }, { keys: ['D'], description: 'New task' },
{ keys: ['E'], description: 'New event' },
{ keys: ['V'], description: 'Toggle video / audio' }, { keys: ['V'], description: 'Toggle video / audio' },
{ keys: ['H'], description: 'Join huddle' }, { keys: ['H'], description: 'Join huddle' },
], ],
@@ -457,17 +454,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
onProgress={setProgress} onProgress={setProgress}
/> />
); );
case 'event':
return (
<EventParticleView
key={particle.id}
particle={particle}
containerPath={path}
paused={paused}
onEnded={handleParticleEnded}
onProgress={setProgress}
/>
);
default: default:
return ( return (
<FallbackParticleView particle={particle} networkId={networkId} /> <FallbackParticleView particle={particle} networkId={networkId} />
+25 -5
View File
@@ -5,20 +5,32 @@ import { useAuthStore } from '@/stores/auth-store';
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path'; import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
import type { Particle } from '@/api/types'; import type { Particle } from '@/api/types';
const INITIAL_PAGE_SIZE = 24; const INITIAL_PAGE_SIZE = 9;
const PAGE_INCREMENT = 24; const PAGE_INCREMENT = 9;
// Stable references so the Firestore subscriptions don't re-attach per render. // Stable references so the Firestore subscriptions don't re-attach per render.
const OPEN_STATUS_FILTER = where('status', '==', 'open');
const CLOSED_STATUS_FILTER = where('status', '==', 'closed');
const CONTAINER_TYPE_FILTER = where('type', 'in', ['stream', 'folder']); const CONTAINER_TYPE_FILTER = where('type', 'in', ['stream', 'folder']);
const LEAF_TYPE_FILTER = where('type', 'in', [ const LEAF_TYPE_FILTER = where('type', 'in', [
'media', 'media',
'file', 'file',
'text', 'text',
'task', 'task',
'event',
'paper', 'paper',
]); ]);
interface UseContainerChildrenOptions {
/**
* When set, list only streams with this status, filtered server-side like
* the pre-folder query (reuses its composite indexes). Used by the network
* root while folders are shelved; folders keep the full mixed-type behavior
* so the container model can be picked back up later. Only streams carry
* `status`, so the filter excludes other types by itself.
*/
streamStatus?: 'open' | 'closed';
}
interface UseContainerChildrenResult { interface UseContainerChildrenResult {
/** All visible children, sorted by latest activity (then creation). */ /** All visible children, sorted by latest activity (then creation). */
items: Particle[]; items: Particle[];
@@ -47,6 +59,7 @@ function activityTime(particle: Particle): number {
*/ */
export function useContainerChildren( export function useContainerChildren(
path: ParticlePath, path: ParticlePath,
{ streamStatus }: UseContainerChildrenOptions = {},
): UseContainerChildrenResult { ): UseContainerChildrenResult {
const { networkId } = parseParticlePath(path); const { networkId } = parseParticlePath(path);
const userId = useAuthStore((s) => s.user?.id); const userId = useAuthStore((s) => s.user?.id);
@@ -60,17 +73,24 @@ export function useContainerChildren(
const [limit, setLimit] = useState(INITIAL_PAGE_SIZE); const [limit, setLimit] = useState(INITIAL_PAGE_SIZE);
const containerFilter = streamStatus
? streamStatus === 'open'
? OPEN_STATUS_FILTER
: CLOSED_STATUS_FILTER
: CONTAINER_TYPE_FILTER;
const { children: containers, isLoading: containersLoading } = const { children: containers, isLoading: containersLoading } =
useLiveParticleChildren(path, { useLiveParticleChildren(path, {
orderByField: 'last_child_created_at', orderByField: 'last_child_created_at',
orderDirection: 'desc', orderDirection: 'desc',
visibilityScopes, visibilityScopes,
whereFilter: CONTAINER_TYPE_FILTER, whereFilter: containerFilter,
limit, limit,
}); });
// Passing undefined disables the subscription entirely in streams-only mode.
const { children: leaves, isLoading: leavesLoading } = const { children: leaves, isLoading: leavesLoading } =
useLiveParticleChildren(path, { useLiveParticleChildren(streamStatus ? undefined : path, {
orderByField: 'created_at', orderByField: 'created_at',
orderDirection: 'desc', orderDirection: 'desc',
whereFilter: LEAF_TYPE_FILTER, whereFilter: LEAF_TYPE_FILTER,
+2 -2
View File
@@ -11,7 +11,7 @@ type StreamParticle = Particle & {
properties: StreamProperties; properties: StreamProperties;
}; };
const streamTypeFilter = where('type', '==', 'stream'); const openStatusFilter = where('status', '==', 'open');
/** /**
* Self-contained hook that syncs the macOS dock badge with the count of * Self-contained hook that syncs the macOS dock badge with the count of
@@ -37,7 +37,7 @@ export function useDockBadge(networkId: string | undefined) {
orderByField: 'last_child_created_at', orderByField: 'last_child_created_at',
orderDirection: 'desc', orderDirection: 'desc',
visibilityScopes, visibilityScopes,
whereFilter: streamTypeFilter, whereFilter: openStatusFilter,
}); });
const unseenCount = useMemo(() => { const unseenCount = useMemo(() => {
+49 -19
View File
@@ -23,7 +23,11 @@ import {
QueryFieldFilterConstraint, QueryFieldFilterConstraint,
} from 'firebase/firestore'; } from 'firebase/firestore';
import { firestoreDb } from '@/firebase'; import { firestoreDb } from '@/firebase';
import { isContainerType, ParticleSchema } from '@/api/types'; import {
isContainerType,
ParticleSchema,
UnknownParticleSchema,
} from '@/api/types';
import type { import type {
Particle, Particle,
ParticleType, ParticleType,
@@ -46,15 +50,6 @@ function coerceLeafPropertyDates(
edited_at: (properties.edited_at as Timestamp).toDate(), edited_at: (properties.edited_at as Timestamp).toDate(),
}; };
} }
if (type === 'event') {
return {
...properties,
start_at: (properties.start_at as Timestamp).toDate(),
end_at: properties.end_at
? (properties.end_at as Timestamp).toDate()
: undefined,
};
}
return properties; return properties;
} }
@@ -85,6 +80,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
id: snap.id, id: snap.id,
type: raw.type, type: raw.type,
properties: raw.properties, properties: raw.properties,
status: raw.status ?? undefined,
created_at: (raw.created_at as Timestamp).toDate(), created_at: (raw.created_at as Timestamp).toDate(),
created_by_human_id: raw.created_by_human_id, created_by_human_id: raw.created_by_human_id,
updated_at: raw.updated_at updated_at: raw.updated_at
@@ -124,7 +120,6 @@ const particleConverter: FirestoreDataConverter<Particle> = {
case 'file': case 'file':
case 'text': case 'text':
case 'task': case 'task':
case 'event':
case 'paper': { case 'paper': {
const properties = coerceLeafPropertyDates(type, raw.properties); const properties = coerceLeafPropertyDates(type, raw.properties);
return ParticleSchema.parse({ return ParticleSchema.parse({
@@ -144,11 +139,33 @@ const particleConverter: FirestoreDataConverter<Particle> = {
}); });
} }
default: default:
throw new Error(`Unknown particle type: ${type}`); // Forward compatibility: keep unrecognized types visible as
// placeholders instead of breaking the snapshot they arrive in.
return UnknownParticleSchema.parse({
id: snap.id,
type: 'unknown',
raw_type: raw.type,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_human_id: raw.created_by_human_id,
updated_at: raw.updated_at
? (raw.updated_at as Timestamp).toDate()
: undefined,
});
} }
}, },
}; };
// Corrupt docs (malformed base fields, schema parse failures) shouldn't take
// down a whole subscription — skip just the bad doc and keep the rest.
function safeData(snap: QueryDocumentSnapshot<Particle>): Particle | null {
try {
return snap.data();
} catch (err) {
console.warn(`Skipping unparseable particle at ${snap.ref.path}:`, err);
return null;
}
}
// --- Typed reference helpers --- // --- Typed reference helpers ---
function typedDoc(path: string) { function typedDoc(path: string) {
@@ -169,7 +186,7 @@ export function subscribeToParticle(
return onSnapshot( return onSnapshot(
typedDoc(docPath), typedDoc(docPath),
(snap) => { (snap) => {
onData(snap.exists() ? snap.data() : null); onData(snap.exists() ? safeData(snap) : null);
}, },
onError, onError,
); );
@@ -181,7 +198,7 @@ export async function getParticle(docPath: string): Promise<Particle | null> {
return null; return null;
} }
return doc.data(); return safeData(doc);
} }
export interface GetParticleChildrenOptions { export interface GetParticleChildrenOptions {
@@ -204,7 +221,7 @@ export async function getParticleChildren(
orderBy(orderByField, orderDirection), orderBy(orderByField, orderDirection),
); );
const snap = await getDocs(q); const snap = await getDocs(q);
return snap.docs.map((d) => d.data()); return snap.docs.flatMap((d) => safeData(d) ?? []);
} }
export interface SubscribeToParticleChildrenOptions { export interface SubscribeToParticleChildrenOptions {
@@ -250,14 +267,16 @@ export function subscribeToParticleChildren(
return onSnapshot( return onSnapshot(
q, q,
(snap) => { (snap) => {
const updatedChildren = snap.docs.map((d) => d.data()); const updatedChildren = snap.docs.flatMap((d) => safeData(d) ?? []);
onData(updatedChildren); onData(updatedChildren);
if (onAdded || onRemoved) { if (onAdded || onRemoved) {
for (const change of snap.docChanges()) { for (const change of snap.docChanges()) {
if (change.type === 'added' && onAdded) onAdded(change.doc.data()); const child = safeData(change.doc);
if (!child) continue;
if (change.type === 'added' && onAdded) onAdded(child);
if (change.type === 'removed' && onRemoved) if (change.type === 'removed' && onRemoved)
onRemoved(change.doc.data(), updatedChildren); onRemoved(child, updatedChildren);
} }
} }
}, },
@@ -278,7 +297,7 @@ export function subscribeToLatestChild(
return onSnapshot( return onSnapshot(
q, q,
(snap) => { (snap) => {
onData(snap.empty ? null : snap.docs[0].data()); onData(snap.empty ? null : safeData(snap.docs[0]));
}, },
onError, onError,
); );
@@ -351,6 +370,7 @@ export async function createStreamParticle(
id: '', id: '',
type: 'stream', type: 'stream',
properties, properties,
status: 'open',
created_at: createdAt, created_at: createdAt,
created_by_human_id: createdByHumanId, created_by_human_id: createdByHumanId,
visible_to: visibleTo, visible_to: visibleTo,
@@ -361,6 +381,16 @@ export async function createStreamParticle(
return ref.id; return ref.id;
} }
export async function updateStreamStatus(
docPath: string,
status: 'open' | 'closed',
): Promise<void> {
await updateDoc(typedDoc(docPath), {
status,
updated_at: serverTimestamp(),
});
}
// This allows updating properties without overwriting the entire properties object // This allows updating properties without overwriting the entire properties object
export async function updateParticleProperties<T extends ParticleType>( export async function updateParticleProperties<T extends ParticleType>(
docPath: string, docPath: string,
+7 -39
View File
@@ -1,8 +1,8 @@
import { import {
CalendarDays,
CircleCheck, CircleCheck,
FileText, FileText,
Folder, Folder,
HelpCircle,
Image, Image,
MessageSquare, MessageSquare,
Mic, Mic,
@@ -34,10 +34,10 @@ export function getParticleTypeIcon(particle: Particle): LucideIcon {
return FileText; return FileText;
case 'task': case 'task':
return CircleCheck; return CircleCheck;
case 'event':
return CalendarDays;
case 'paper': case 'paper':
return StickyNote; return StickyNote;
case 'unknown':
return HelpCircle;
} }
} }
@@ -63,13 +63,10 @@ export function getMessagePreview(particle: Particle): string {
return particle.properties.filename; return particle.properties.filename;
case 'task': case 'task':
return particle.properties.title; return particle.properties.title;
case 'event':
return `${particle.properties.title} · ${formatEventTime(
particle.properties.start_at,
particle.properties.end_at,
)}`;
case 'paper': case 'paper':
return particle.properties.title; return particle.properties.title;
case 'unknown':
return 'Unsupported particle';
} }
} }
@@ -80,8 +77,6 @@ export function getParticleDisplayName(particle: Particle): string {
return particle.properties.name; return particle.properties.name;
case 'task': case 'task':
return particle.properties.title; return particle.properties.title;
case 'event':
return particle.properties.title;
case 'paper': case 'paper':
return particle.properties.title; return particle.properties.title;
case 'file': case 'file':
@@ -90,35 +85,8 @@ export function getParticleDisplayName(particle: Particle): string {
return particle.properties.content.slice(0, 30); return particle.properties.content.slice(0, 30);
case 'media': case 'media':
return particle.type; return particle.type;
case 'unknown':
return 'Unsupported particle';
} }
} }
const DAY_FORMAT = new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
});
const TIME_FORMAT = new Intl.DateTimeFormat(undefined, {
hour: 'numeric',
minute: '2-digit',
});
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
/** "Wed, Jun 17 · 3:00 PM", "Wed, Jun 17 · 3:00 4:00 PM", or a full
* range when the event spans days. */
export function formatEventTime(start: Date, end?: Date): string {
const startDay = DAY_FORMAT.format(start);
const startTime = TIME_FORMAT.format(start);
if (!end) return `${startDay} · ${startTime}`;
if (isSameDay(start, end)) {
return `${startDay} · ${startTime} ${TIME_FORMAT.format(end)}`;
}
return `${startDay} · ${startTime} ${DAY_FORMAT.format(end)} · ${TIME_FORMAT.format(end)}`;
}
@@ -11,7 +11,6 @@ import { create } from 'zustand';
* | record | idle | start a media (camera/mic) recording | * | record | idle | start a media (camera/mic) recording |
* | text | idle | open the text compose step | * | text | idle | open the text compose step |
* | task | idle | open the task compose step | * | task | idle | open the task compose step |
* | event | idle | open the event compose step |
* | stop | recording | finish recording review | * | stop | recording | finish recording review |
* | cancel | recording, etc. | abort recording / discard review | * | cancel | recording, etc. | abort recording / discard review |
* | send | reviewing | submit the recorded particle | * | send | reviewing | submit the recorded particle |
@@ -24,7 +23,6 @@ export type ComposeIntent =
| 'record' | 'record'
| 'text' | 'text'
| 'task' | 'task'
| 'event'
| 'stop' | 'stop'
| 'cancel' | 'cancel'
| 'send'; | 'send';