cleanup folders and events, and condense changes
This commit is contained in:
+23
-19
@@ -162,14 +162,6 @@ export const TaskPropertiesSchema = z.object({
|
||||
});
|
||||
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({
|
||||
title: z.string(),
|
||||
content: z.string(),
|
||||
@@ -210,7 +202,6 @@ export interface ParticlePropertiesMap {
|
||||
file: FileProperties;
|
||||
text: TextProperties;
|
||||
task: TaskProperties;
|
||||
event: EventProperties;
|
||||
paper: PaperProperties;
|
||||
}
|
||||
|
||||
@@ -227,6 +218,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal('stream'),
|
||||
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. ["network:xywx"] - visible to everyone in the network
|
||||
visible_to: z.array(z.string()),
|
||||
@@ -271,12 +265,6 @@ export const ParticleSchema = z.discriminatedUnion('type', [
|
||||
reactions: ReactionsSchema,
|
||||
...TombstoneFields,
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal('event'),
|
||||
properties: EventPropertiesSchema,
|
||||
reactions: ReactionsSchema,
|
||||
...TombstoneFields,
|
||||
}),
|
||||
ParticleBaseSchema.extend({
|
||||
type: z.literal('paper'),
|
||||
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 */
|
||||
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set([
|
||||
export const CONTAINER_TYPES: ReadonlySet<Particle['type']> = new Set([
|
||||
'stream',
|
||||
'folder',
|
||||
]);
|
||||
|
||||
export function isContainerType(type: ParticleType): boolean {
|
||||
export function isContainerType(type: Particle['type']): boolean {
|
||||
return CONTAINER_TYPES.has(type);
|
||||
}
|
||||
|
||||
@@ -303,6 +302,11 @@ export function isParticleDeleted(particle: Particle): boolean {
|
||||
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 ---
|
||||
|
||||
export const GetLivekitTokenResponseSchema = z.object({
|
||||
|
||||
@@ -20,9 +20,8 @@ import { ScreenSourcePicker } from '@/components/screen-source-picker';
|
||||
import { KeyHint } from '@/components/key-hint';
|
||||
import { TextComposeStep } from '@/features/compose/text-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 type { TaskProperties, EventProperties } from '@/api/types';
|
||||
import type { TaskProperties } from '@/api/types';
|
||||
import { apiClient } from '@/api/client';
|
||||
import { useMediaSettingsStore } from '@/stores/media-settings-store';
|
||||
import { useMediaDevicesStore } from '@/stores/media-devices-store';
|
||||
@@ -44,15 +43,13 @@ export type ComposeStep =
|
||||
| 'reviewing'
|
||||
| 'typing'
|
||||
| 'task'
|
||||
| 'event'
|
||||
| 'configuring'
|
||||
| 'submitting';
|
||||
|
||||
type RecordingSource = 'media' | 'screen';
|
||||
|
||||
type PendingArtifact =
|
||||
| { type: 'task'; properties: TaskProperties }
|
||||
| { type: 'event'; properties: EventProperties };
|
||||
| { type: 'task'; properties: TaskProperties };
|
||||
|
||||
interface ComposeOverlayProps {
|
||||
networkId: string;
|
||||
@@ -106,8 +103,7 @@ export function ComposeOverlay({
|
||||
const recordStartRef = useRef(0);
|
||||
const quotaExhaustedRef = useRef(quotaExhausted);
|
||||
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);
|
||||
useEffect(() => {
|
||||
quotaExhaustedRef.current = quotaExhausted;
|
||||
@@ -501,12 +497,8 @@ export function ComposeOverlay({
|
||||
setStepSync('task');
|
||||
}, [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
|
||||
// that will hold it as its first child.
|
||||
const handleArtifactSubmit = useCallback(
|
||||
@@ -577,9 +569,6 @@ export function ComposeOverlay({
|
||||
case 'task':
|
||||
handleTaskIntent();
|
||||
break;
|
||||
case 'event':
|
||||
handleEventIntent();
|
||||
break;
|
||||
case 'stop':
|
||||
handleStopIntent();
|
||||
break;
|
||||
@@ -596,7 +585,6 @@ export function ComposeOverlay({
|
||||
handleRecordIntent,
|
||||
handleTextIntent,
|
||||
handleTaskIntent,
|
||||
handleEventIntent,
|
||||
handleStopIntent,
|
||||
handleCancelIntent,
|
||||
handleSendIntent,
|
||||
@@ -612,7 +600,6 @@ export function ComposeOverlay({
|
||||
if (
|
||||
currentStep === 'typing' ||
|
||||
currentStep === 'task' ||
|
||||
currentStep === 'event' ||
|
||||
currentStep === 'configuring' ||
|
||||
currentStep === 'picking'
|
||||
) {
|
||||
@@ -649,9 +636,6 @@ export function ComposeOverlay({
|
||||
} else if (e.key === 'd' || e.key === 'D') {
|
||||
e.preventDefault();
|
||||
handleTaskIntent();
|
||||
} else if (e.key === 'e' || e.key === 'E') {
|
||||
e.preventDefault();
|
||||
handleEventIntent();
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -720,7 +704,6 @@ export function ComposeOverlay({
|
||||
handleRecordIntent,
|
||||
handleTextIntent,
|
||||
handleTaskIntent,
|
||||
handleEventIntent,
|
||||
handleStopIntent,
|
||||
handleCancelIntent,
|
||||
handleSendIntent,
|
||||
@@ -838,14 +821,6 @@ export function ComposeOverlay({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{step === 'event' && (
|
||||
<EventComposeStep
|
||||
onCancel={cancel}
|
||||
onSubmit={(properties) =>
|
||||
handleArtifactSubmit({ type: 'event', properties })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!targetPath && step === 'configuring' && (
|
||||
<ConfigureContainerStep
|
||||
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 { useNavigate } from 'react-router-dom';
|
||||
import { FolderIcon } from 'lucide-react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { CircleCheckBig, CircleDot, FolderIcon } from 'lucide-react';
|
||||
import type { Particle } from '@/api/types';
|
||||
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
|
||||
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 { VideoAudioToggle } from '@/components/video-audio-toggle';
|
||||
import { KeyHint } from '@/components/key-hint';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
|
||||
type ContainerKind = 'stream' | 'folder';
|
||||
|
||||
@@ -42,8 +43,28 @@ export function ContainerView({ path, folderParticle }: ContainerViewProps) {
|
||||
const [composeActive, setComposeActive] = useState(false);
|
||||
const [creating, setCreating] = useState<ContainerKind | null>(null);
|
||||
|
||||
const { items, isLoading, canLoadMore, loadMore } =
|
||||
useContainerChildren(path);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
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 createStream = useCreateStreamParticle();
|
||||
@@ -61,10 +82,10 @@ export function ContainerView({ path, folderParticle }: ContainerViewProps) {
|
||||
onOpen: handleOpen,
|
||||
});
|
||||
|
||||
// F creates a folder (root only — no sub-folders yet); N creates a stream
|
||||
// inside a folder. Root stream creation goes through the compose flow.
|
||||
// N creates a stream inside a folder. Root stream creation goes through the
|
||||
// compose flow.
|
||||
useEffect(() => {
|
||||
if (composeActive || creating !== null) return;
|
||||
if (isRoot || composeActive || creating !== null) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.defaultPrevented) return;
|
||||
const target = e.target as HTMLElement;
|
||||
@@ -75,10 +96,7 @@ export function ContainerView({ path, folderParticle }: ContainerViewProps) {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isRoot && (e.key === 'f' || e.key === 'F')) {
|
||||
e.preventDefault();
|
||||
setCreating('folder');
|
||||
} else if (!isRoot && (e.key === 'n' || e.key === 'N')) {
|
||||
if (e.key === 'n' || e.key === 'N') {
|
||||
e.preventDefault();
|
||||
setCreating('stream');
|
||||
}
|
||||
@@ -123,6 +141,25 @@ export function ContainerView({ path, folderParticle }: ContainerViewProps) {
|
||||
|
||||
return (
|
||||
<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 && (
|
||||
<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">
|
||||
@@ -144,9 +181,11 @@ export function ContainerView({ path, folderParticle }: ContainerViewProps) {
|
||||
canLoadMore={canLoadMore}
|
||||
onLoadMore={loadMore}
|
||||
emptyMessage={
|
||||
isRoot
|
||||
? 'No streams here. Start a conversation using the keyboard shortcuts below.'
|
||||
: 'This folder is empty. Add something using the keyboard shortcuts below.'
|
||||
!isRoot
|
||||
? '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>
|
||||
@@ -165,7 +204,6 @@ export function ContainerView({ path, folderParticle }: ContainerViewProps) {
|
||||
<div className="pointer-events-auto">
|
||||
<ContainerControls
|
||||
isRoot={isRoot}
|
||||
onCreateFolder={() => setCreating('folder')}
|
||||
onCreateStream={() => setCreating('stream')}
|
||||
/>
|
||||
</div>
|
||||
@@ -185,11 +223,9 @@ export function ContainerView({ path, folderParticle }: ContainerViewProps) {
|
||||
|
||||
function ContainerControls({
|
||||
isRoot,
|
||||
onCreateFolder,
|
||||
onCreateStream,
|
||||
}: {
|
||||
isRoot: boolean;
|
||||
onCreateFolder: () => void;
|
||||
onCreateStream: () => void;
|
||||
}) {
|
||||
const requestIntent = useComposeIntentStore((s) => s.request);
|
||||
@@ -219,22 +255,7 @@ function ContainerControls({
|
||||
>
|
||||
task
|
||||
</KeyHint>
|
||||
<KeyHint
|
||||
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>
|
||||
) : (
|
||||
{!isRoot && (
|
||||
<KeyHint
|
||||
keys="N"
|
||||
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] ?? {
|
||||
icon: HelpCircleIcon,
|
||||
label: particle.type,
|
||||
label: particle.type === 'unknown' ? particle.raw_type : particle.type,
|
||||
};
|
||||
const Icon = meta.icon;
|
||||
const title = (() => {
|
||||
@@ -42,6 +42,8 @@ export function FallbackParticleView({
|
||||
return particle.properties.filename;
|
||||
case 'folder':
|
||||
return particle.properties.name;
|
||||
case 'unknown':
|
||||
return 'Not supported in this version of the app';
|
||||
default:
|
||||
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 { cn, getInitials } from '@/lib/utils';
|
||||
import { useLiveLatestChild } from '@/hooks/use-particle';
|
||||
@@ -20,6 +20,7 @@ import { useNetwork } from '@/hooks/use-networks';
|
||||
import { useStreamAutoplay } from '@/hooks/use-stream-autoplay';
|
||||
import { useDownloadUrl } from '@/hooks/use-download-url';
|
||||
import { getMessagePreview, getParticleTypeIcon } from '@/lib/particle-display';
|
||||
import { StreamContextMenu } from '@/features/particles/stream-context-menu';
|
||||
|
||||
type StreamParticle = Particle & {
|
||||
type: 'stream';
|
||||
@@ -444,9 +445,8 @@ export function ParticleChildrenList({
|
||||
isSelected: index === selectedIndex,
|
||||
shortcutKey: showShortcuts && index < 9 ? index + 1 : undefined,
|
||||
};
|
||||
return (
|
||||
const row = (
|
||||
<div
|
||||
key={item.id}
|
||||
ref={(el) => {
|
||||
rowRefs.current[index] = el;
|
||||
}}
|
||||
@@ -461,6 +461,17 @@ export function ParticleChildrenList({
|
||||
{index < items.length - 1 && <Separator className="px-4" />}
|
||||
</div>
|
||||
);
|
||||
return item.type === 'stream' ? (
|
||||
<StreamContextMenu
|
||||
key={item.id}
|
||||
particle={item}
|
||||
networkId={networkId}
|
||||
>
|
||||
{row}
|
||||
</StreamContextMenu>
|
||||
) : (
|
||||
<Fragment key={item.id}>{row}</Fragment>
|
||||
);
|
||||
})}
|
||||
{canLoadMore && onLoadMore && (
|
||||
<div className="flex justify-center p-3">
|
||||
|
||||
@@ -6,13 +6,11 @@ import {
|
||||
Video,
|
||||
Mic,
|
||||
CircleCheck,
|
||||
CalendarDays,
|
||||
BookOpen,
|
||||
FileIcon,
|
||||
FolderIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatEventTime } from '@/lib/particle-display';
|
||||
|
||||
export function ParticlePreview({ particle }: { particle: Particle }) {
|
||||
switch (particle.type) {
|
||||
@@ -22,8 +20,6 @@ export function ParticlePreview({ particle }: { particle: Particle }) {
|
||||
return <MediaPreview particle={particle} />;
|
||||
case 'task':
|
||||
return <TaskPreview particle={particle} />;
|
||||
case 'event':
|
||||
return <EventPreview particle={particle} />;
|
||||
case 'paper':
|
||||
return <PaperPreview particle={particle} />;
|
||||
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({
|
||||
particle,
|
||||
}: {
|
||||
|
||||
@@ -13,7 +13,6 @@ import { FolderView } from '@/features/particles/folder-view';
|
||||
import { MediaParticleView } from '@/features/particles/media-particle-view';
|
||||
import { TextParticleView } from '@/features/particles/text-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 { DeletedParticleView } from '@/features/particles/deleted-particle-view';
|
||||
|
||||
@@ -131,15 +130,6 @@ function LeafParticleView({
|
||||
onEnded={noop}
|
||||
/>
|
||||
);
|
||||
case 'event':
|
||||
return (
|
||||
<EventParticleView
|
||||
particle={particle}
|
||||
containerPath={containerPath}
|
||||
paused
|
||||
onEnded={noop}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<FallbackParticleView particle={particle} networkId={networkId} />
|
||||
|
||||
@@ -123,13 +123,6 @@ export function StreamViewControls({
|
||||
>
|
||||
task
|
||||
</KeyHint>
|
||||
<KeyHint
|
||||
keys="E"
|
||||
onClick={() => requestIntent('event')}
|
||||
title="Add an event (or press E)"
|
||||
>
|
||||
event
|
||||
</KeyHint>
|
||||
<KeyHint
|
||||
keys="H"
|
||||
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 {
|
||||
CalendarDays,
|
||||
CircleCheck,
|
||||
FileText,
|
||||
Image,
|
||||
@@ -12,7 +11,6 @@ import { isParticleDeleted, type Human, type Particle } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useNetwork } from '@/hooks/use-networks';
|
||||
import { resolveHumanDisplay } from '@/lib/humans';
|
||||
import { formatEventTime } from '@/lib/particle-display';
|
||||
import { RelativeTimestamp } from '@/components/relative-timestamp';
|
||||
import { HumanAvatar } from '@/components/human-avatar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@@ -212,21 +210,6 @@ function ChatRowContent({ particle }: { particle: Particle }) {
|
||||
</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':
|
||||
return (
|
||||
<p className="truncate text-xs text-white/70">
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
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 { HumanAvatar } from '@/components/human-avatar';
|
||||
import {
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
Lock,
|
||||
Globe,
|
||||
Trash2,
|
||||
CircleCheckBig,
|
||||
CircleDot,
|
||||
} 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 { DeleteParticleOverlay } from '@/features/particles/delete-particle-overlay';
|
||||
import { StreamMembersOverlay } from '@/features/particles/stream-members-overlay';
|
||||
@@ -143,6 +147,13 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
</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
|
||||
networkId={networkId}
|
||||
streamParticle={streamParticle}
|
||||
@@ -166,6 +177,29 @@ export function TopBar({ networkId, particle, streamParticle }: TopBarProps) {
|
||||
Rename stream
|
||||
</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 && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setDeleteOpen(true)}
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
} from '@/features/particles/media-particle-view';
|
||||
import { TextParticleView } from '@/features/particles/text-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 { DeletedParticleView } from '@/features/particles/deleted-particle-view';
|
||||
import {
|
||||
@@ -76,8 +75,7 @@ function getReactions(
|
||||
if (
|
||||
particle.type === 'media' ||
|
||||
particle.type === 'text' ||
|
||||
particle.type === 'task' ||
|
||||
particle.type === 'event'
|
||||
particle.type === 'task'
|
||||
)
|
||||
return particle.reactions;
|
||||
return undefined;
|
||||
@@ -164,7 +162,6 @@ const STREAM_VIEW_KEYBINDINGS: KeybindingGroup[] = [
|
||||
{ keys: ['S'], description: 'Screen record' },
|
||||
{ keys: ['T'], description: 'Text compose' },
|
||||
{ keys: ['D'], description: 'New task' },
|
||||
{ keys: ['E'], description: 'New event' },
|
||||
{ keys: ['V'], description: 'Toggle video / audio' },
|
||||
{ keys: ['H'], description: 'Join huddle' },
|
||||
],
|
||||
@@ -457,17 +454,6 @@ function StreamViewInner({ path, streamParticle }: StreamViewProps) {
|
||||
onProgress={setProgress}
|
||||
/>
|
||||
);
|
||||
case 'event':
|
||||
return (
|
||||
<EventParticleView
|
||||
key={particle.id}
|
||||
particle={particle}
|
||||
containerPath={path}
|
||||
paused={paused}
|
||||
onEnded={handleParticleEnded}
|
||||
onProgress={setProgress}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<FallbackParticleView particle={particle} networkId={networkId} />
|
||||
|
||||
@@ -5,20 +5,32 @@ import { useAuthStore } from '@/stores/auth-store';
|
||||
import { parseParticlePath, type ParticlePath } from '@/lib/particle-path';
|
||||
import type { Particle } from '@/api/types';
|
||||
|
||||
const INITIAL_PAGE_SIZE = 24;
|
||||
const PAGE_INCREMENT = 24;
|
||||
const INITIAL_PAGE_SIZE = 9;
|
||||
const PAGE_INCREMENT = 9;
|
||||
|
||||
// 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 LEAF_TYPE_FILTER = where('type', 'in', [
|
||||
'media',
|
||||
'file',
|
||||
'text',
|
||||
'task',
|
||||
'event',
|
||||
'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 {
|
||||
/** All visible children, sorted by latest activity (then creation). */
|
||||
items: Particle[];
|
||||
@@ -47,6 +59,7 @@ function activityTime(particle: Particle): number {
|
||||
*/
|
||||
export function useContainerChildren(
|
||||
path: ParticlePath,
|
||||
{ streamStatus }: UseContainerChildrenOptions = {},
|
||||
): UseContainerChildrenResult {
|
||||
const { networkId } = parseParticlePath(path);
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
@@ -60,17 +73,24 @@ export function useContainerChildren(
|
||||
|
||||
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 } =
|
||||
useLiveParticleChildren(path, {
|
||||
orderByField: 'last_child_created_at',
|
||||
orderDirection: 'desc',
|
||||
visibilityScopes,
|
||||
whereFilter: CONTAINER_TYPE_FILTER,
|
||||
whereFilter: containerFilter,
|
||||
limit,
|
||||
});
|
||||
|
||||
// Passing undefined disables the subscription entirely in streams-only mode.
|
||||
const { children: leaves, isLoading: leavesLoading } =
|
||||
useLiveParticleChildren(path, {
|
||||
useLiveParticleChildren(streamStatus ? undefined : path, {
|
||||
orderByField: 'created_at',
|
||||
orderDirection: 'desc',
|
||||
whereFilter: LEAF_TYPE_FILTER,
|
||||
|
||||
@@ -11,7 +11,7 @@ type StreamParticle = Particle & {
|
||||
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
|
||||
@@ -37,7 +37,7 @@ export function useDockBadge(networkId: string | undefined) {
|
||||
orderByField: 'last_child_created_at',
|
||||
orderDirection: 'desc',
|
||||
visibilityScopes,
|
||||
whereFilter: streamTypeFilter,
|
||||
whereFilter: openStatusFilter,
|
||||
});
|
||||
|
||||
const unseenCount = useMemo(() => {
|
||||
|
||||
@@ -23,7 +23,11 @@ import {
|
||||
QueryFieldFilterConstraint,
|
||||
} from 'firebase/firestore';
|
||||
import { firestoreDb } from '@/firebase';
|
||||
import { isContainerType, ParticleSchema } from '@/api/types';
|
||||
import {
|
||||
isContainerType,
|
||||
ParticleSchema,
|
||||
UnknownParticleSchema,
|
||||
} from '@/api/types';
|
||||
import type {
|
||||
Particle,
|
||||
ParticleType,
|
||||
@@ -46,15 +50,6 @@ function coerceLeafPropertyDates(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -85,6 +80,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
||||
id: snap.id,
|
||||
type: raw.type,
|
||||
properties: raw.properties,
|
||||
status: raw.status ?? undefined,
|
||||
created_at: (raw.created_at as Timestamp).toDate(),
|
||||
created_by_human_id: raw.created_by_human_id,
|
||||
updated_at: raw.updated_at
|
||||
@@ -124,7 +120,6 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
||||
case 'file':
|
||||
case 'text':
|
||||
case 'task':
|
||||
case 'event':
|
||||
case 'paper': {
|
||||
const properties = coerceLeafPropertyDates(type, raw.properties);
|
||||
return ParticleSchema.parse({
|
||||
@@ -144,11 +139,33 @@ const particleConverter: FirestoreDataConverter<Particle> = {
|
||||
});
|
||||
}
|
||||
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 ---
|
||||
|
||||
function typedDoc(path: string) {
|
||||
@@ -169,7 +186,7 @@ export function subscribeToParticle(
|
||||
return onSnapshot(
|
||||
typedDoc(docPath),
|
||||
(snap) => {
|
||||
onData(snap.exists() ? snap.data() : null);
|
||||
onData(snap.exists() ? safeData(snap) : null);
|
||||
},
|
||||
onError,
|
||||
);
|
||||
@@ -181,7 +198,7 @@ export async function getParticle(docPath: string): Promise<Particle | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
return doc.data();
|
||||
return safeData(doc);
|
||||
}
|
||||
|
||||
export interface GetParticleChildrenOptions {
|
||||
@@ -204,7 +221,7 @@ export async function getParticleChildren(
|
||||
orderBy(orderByField, orderDirection),
|
||||
);
|
||||
const snap = await getDocs(q);
|
||||
return snap.docs.map((d) => d.data());
|
||||
return snap.docs.flatMap((d) => safeData(d) ?? []);
|
||||
}
|
||||
|
||||
export interface SubscribeToParticleChildrenOptions {
|
||||
@@ -250,14 +267,16 @@ export function subscribeToParticleChildren(
|
||||
return onSnapshot(
|
||||
q,
|
||||
(snap) => {
|
||||
const updatedChildren = snap.docs.map((d) => d.data());
|
||||
const updatedChildren = snap.docs.flatMap((d) => safeData(d) ?? []);
|
||||
onData(updatedChildren);
|
||||
|
||||
if (onAdded || onRemoved) {
|
||||
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)
|
||||
onRemoved(change.doc.data(), updatedChildren);
|
||||
onRemoved(child, updatedChildren);
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -278,7 +297,7 @@ export function subscribeToLatestChild(
|
||||
return onSnapshot(
|
||||
q,
|
||||
(snap) => {
|
||||
onData(snap.empty ? null : snap.docs[0].data());
|
||||
onData(snap.empty ? null : safeData(snap.docs[0]));
|
||||
},
|
||||
onError,
|
||||
);
|
||||
@@ -351,6 +370,7 @@ export async function createStreamParticle(
|
||||
id: '',
|
||||
type: 'stream',
|
||||
properties,
|
||||
status: 'open',
|
||||
created_at: createdAt,
|
||||
created_by_human_id: createdByHumanId,
|
||||
visible_to: visibleTo,
|
||||
@@ -361,6 +381,16 @@ export async function createStreamParticle(
|
||||
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
|
||||
export async function updateParticleProperties<T extends ParticleType>(
|
||||
docPath: string,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
CalendarDays,
|
||||
CircleCheck,
|
||||
FileText,
|
||||
Folder,
|
||||
HelpCircle,
|
||||
Image,
|
||||
MessageSquare,
|
||||
Mic,
|
||||
@@ -34,10 +34,10 @@ export function getParticleTypeIcon(particle: Particle): LucideIcon {
|
||||
return FileText;
|
||||
case 'task':
|
||||
return CircleCheck;
|
||||
case 'event':
|
||||
return CalendarDays;
|
||||
case 'paper':
|
||||
return StickyNote;
|
||||
case 'unknown':
|
||||
return HelpCircle;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,13 +63,10 @@ export function getMessagePreview(particle: Particle): string {
|
||||
return particle.properties.filename;
|
||||
case 'task':
|
||||
return particle.properties.title;
|
||||
case 'event':
|
||||
return `${particle.properties.title} · ${formatEventTime(
|
||||
particle.properties.start_at,
|
||||
particle.properties.end_at,
|
||||
)}`;
|
||||
case 'paper':
|
||||
return particle.properties.title;
|
||||
case 'unknown':
|
||||
return 'Unsupported particle';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +77,6 @@ export function getParticleDisplayName(particle: Particle): string {
|
||||
return particle.properties.name;
|
||||
case 'task':
|
||||
return particle.properties.title;
|
||||
case 'event':
|
||||
return particle.properties.title;
|
||||
case 'paper':
|
||||
return particle.properties.title;
|
||||
case 'file':
|
||||
@@ -90,35 +85,8 @@ export function getParticleDisplayName(particle: Particle): string {
|
||||
return particle.properties.content.slice(0, 30);
|
||||
case 'media':
|
||||
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 |
|
||||
* | text | idle | open the text compose step |
|
||||
* | task | idle | open the task compose step |
|
||||
* | event | idle | open the event compose step |
|
||||
* | stop | recording | finish recording → review |
|
||||
* | cancel | recording, etc. | abort recording / discard review |
|
||||
* | send | reviewing | submit the recorded particle |
|
||||
@@ -24,7 +23,6 @@ export type ComposeIntent =
|
||||
| 'record'
|
||||
| 'text'
|
||||
| 'task'
|
||||
| 'event'
|
||||
| 'stop'
|
||||
| 'cancel'
|
||||
| 'send';
|
||||
|
||||
Reference in New Issue
Block a user