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:
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user