feat: stream list view and tasks (#279)

* first attempt at stream sidebar, tasks, and events

* fix folder from root

* cleanup folders and events, and condense changes

* cleanup and add toggle for sidebar

* cleanup

* fix nits
This commit was merged in pull request #279.
This commit is contained in:
Arjun Patel
2026-06-12 12:26:49 -07:00
committed by GitHub
parent a358774106
commit 095b9876f9
37 changed files with 2384 additions and 791 deletions
+100 -31
View File
@@ -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,
@@ -33,6 +37,22 @@ import type {
// --- Converter ---
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Coerce the
// per-type property fields that carry timestamps.
function coerceLeafPropertyDates(
type: ParticleType,
properties: DocumentData | undefined,
): DocumentData | undefined {
if (!properties) return properties;
if (type === 'text' && properties.edited_at) {
return {
...properties,
edited_at: (properties.edited_at as Timestamp).toDate(),
};
}
return properties;
}
const particleConverter: FirestoreDataConverter<Particle> = {
toFirestore(particle: Particle): DocumentData {
const { id: _id, created_at, updated_at, ...rest } = particle;
@@ -60,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
@@ -79,7 +100,6 @@ const particleConverter: FirestoreDataConverter<Particle> = {
: undefined,
huddle_active_participants:
raw.huddle_active_participants ?? undefined,
status: raw.status ?? undefined,
});
case 'folder':
return ParticleSchema.parse({
@@ -92,21 +112,16 @@ const particleConverter: FirestoreDataConverter<Particle> = {
? (raw.updated_at as Timestamp).toDate()
: undefined,
visible_to: raw.visible_to,
last_child_created_at: raw.last_child_created_at
? (raw.last_child_created_at as Timestamp).toDate()
: undefined,
});
case 'media':
case 'file':
case 'text':
case 'quest':
case 'task':
case 'paper': {
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
// particles carry `properties.edited_at`, so coerce it if present.
const properties =
type === 'text' && raw.properties?.edited_at
? {
...raw.properties,
edited_at: (raw.properties.edited_at as Timestamp).toDate(),
}
: raw.properties;
const properties = coerceLeafPropertyDates(type, raw.properties);
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
@@ -124,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) {
@@ -149,7 +186,7 @@ export function subscribeToParticle(
return onSnapshot(
typedDoc(docPath),
(snap) => {
onData(snap.exists() ? snap.data() : null);
onData(snap.exists() ? safeData(snap) : null);
},
onError,
);
@@ -161,7 +198,7 @@ export async function getParticle(docPath: string): Promise<Particle | null> {
return null;
}
return doc.data();
return safeData(doc);
}
export interface GetParticleChildrenOptions {
@@ -184,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 {
@@ -230,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);
}
}
},
@@ -258,12 +297,31 @@ export function subscribeToLatestChild(
return onSnapshot(
q,
(snap) => {
onData(snap.empty ? null : snap.docs[0].data());
onData(snap.empty ? null : safeData(snap.docs[0]));
},
onError,
);
}
// Best-effort bump of the parent container's last_child_created_at when a
// child is created. The particle processor worker does this for stream
// parents, but skips folders, so the client keeps folder activity fresh
// itself. Same value semantics as the worker: the child's created_at, so it
// stays directly comparable with playback markers.
function bumpParentLastChildCreatedAt(
collectionPath: string,
childCreatedAt: Date,
): void {
const parentDocPath = collectionPath.replace(/\/children$/, '');
// The network root (networks/{id}) is not a particle doc — nothing to bump.
if (!parentDocPath.includes('/children/')) return;
updateDoc(doc(firestoreDb, parentDocPath), {
last_child_created_at: Timestamp.fromDate(childCreatedAt),
}).catch(() => {
// Non-fatal: ordering freshness only.
});
}
// This creates a new particle document with the given properties and returns its ID.
export async function createParticle<T extends ParticleType>(
collectionPath: string,
@@ -279,15 +337,21 @@ export async function createParticle<T extends ParticleType>(
);
}
const createdAt = new Date();
const particle: Particle = ParticleSchema.parse({
id: '', // ignored by toFirestore, but needed to satisfy the type
type,
properties,
created_at: new Date(),
created_at: createdAt,
created_by_human_id: createdByHumanId,
...(visibleTo ? { visible_to: visibleTo } : {}),
// Containers start with last_child_created_at = created_at so they appear
// in activity-ordered queries before they have any children (Firestore
// orderBy drops docs missing the field).
...(isContainerType(type) ? { last_child_created_at: createdAt } : {}),
});
const ref = await addDoc(typedCollection(collectionPath), particle);
bumpParentLastChildCreatedAt(collectionPath, createdAt);
return ref.id;
}
@@ -301,19 +365,32 @@ export async function createStreamParticle(
throw new Error('visibleTo is required for streams and cannot be empty');
}
const createdAt = new Date();
const particle: Particle = ParticleSchema.parse({
id: '',
type: 'stream',
properties,
created_at: new Date(),
status: 'open',
created_at: createdAt,
created_by_human_id: createdByHumanId,
visible_to: visibleTo,
status: 'open',
last_child_created_at: createdAt,
});
const ref = await addDoc(typedCollection(collectionPath), particle);
bumpParentLastChildCreatedAt(collectionPath, createdAt);
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,
@@ -382,14 +459,6 @@ export async function updateParticle(
});
}
export async function updateStreamStatus(
docPath: string,
status: 'open' | 'closed',
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, { status, updated_at: serverTimestamp() });
}
/**
* Soft-delete (tombstone) a non-container particle. The Firestore doc stays
* in place so concurrent viewers see the deletion inline rather than being
+91
View File
@@ -0,0 +1,91 @@
import {
CircleCheck,
FileText,
Folder,
HelpCircle,
Image,
MessageSquare,
Mic,
Radio,
StickyNote,
Trash2,
Video,
type LucideIcon,
} from 'lucide-react';
import { isParticleDeleted, type Particle } from '@/api/types';
export function getParticleTypeIcon(particle: Particle): LucideIcon {
if (isParticleDeleted(particle)) return Trash2;
switch (particle.type) {
case 'stream':
return Radio;
case 'folder':
return Folder;
case 'text':
return MessageSquare;
case 'media': {
const mime = particle.properties.mime_type;
if (mime.startsWith('video/')) return Video;
if (mime.startsWith('audio/')) return Mic;
if (mime.startsWith('image/')) return Image;
return Video;
}
case 'file':
return FileText;
case 'task':
return CircleCheck;
case 'paper':
return StickyNote;
case 'unknown':
return HelpCircle;
}
}
export function getMessagePreview(particle: Particle): string {
if (isParticleDeleted(particle)) return 'Deleted particle';
switch (particle.type) {
case 'stream':
case 'folder':
return particle.properties.name;
case 'text':
return particle.properties.content;
case 'media': {
const mime = particle.properties.mime_type;
if (mime.startsWith('image/')) return 'Photo';
if (mime.startsWith('video/') || mime.startsWith('audio/')) {
const transcriptText = particle.properties.transcript?.transcript;
if (transcriptText) return transcriptText;
return mime.startsWith('video/') ? 'Video clip' : 'Voice note';
}
return 'Media';
}
case 'file':
return particle.properties.filename;
case 'task':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'unknown':
return 'Unsupported particle';
}
}
export function getParticleDisplayName(particle: Particle): string {
switch (particle.type) {
case 'stream':
case 'folder':
return particle.properties.name;
case 'task':
return particle.properties.title;
case 'paper':
return particle.properties.title;
case 'file':
return particle.properties.filename;
case 'text':
return particle.properties.content.slice(0, 30);
case 'media':
return particle.type;
case 'unknown':
return 'Unsupported particle';
}
}