Files
llink/js/desktop/src/lib/firestore-particles.ts
T
Arjun Patel 095b9876f9 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
2026-06-12 12:26:49 -07:00

520 lines
16 KiB
TypeScript

import {
collection,
doc,
onSnapshot,
addDoc,
getDoc,
getDocs,
updateDoc,
query,
orderBy,
limit,
serverTimestamp,
where,
Timestamp,
arrayUnion,
arrayRemove,
FieldPath,
type DocumentData,
type FirestoreDataConverter,
type QueryDocumentSnapshot,
type SnapshotOptions,
type Unsubscribe,
QueryFieldFilterConstraint,
} from 'firebase/firestore';
import { firestoreDb } from '@/firebase';
import {
isContainerType,
ParticleSchema,
UnknownParticleSchema,
} from '@/api/types';
import type {
Particle,
ParticleType,
ParticlePropertiesMap,
Reactions,
} from '@/api/types';
// --- 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;
const deletedAt =
'deleted_at' in particle ? particle.deleted_at : undefined;
return {
...rest,
created_at: Timestamp.fromDate(created_at),
...(updated_at && { updated_at: Timestamp.fromDate(updated_at) }),
...(deletedAt && { deleted_at: Timestamp.fromDate(deletedAt) }),
};
},
fromFirestore(
snap: QueryDocumentSnapshot,
options?: SnapshotOptions,
): Particle {
const raw = snap.data(options);
if (typeof raw.type !== 'string') {
throw new Error(`Invalid particle type: ${raw.type}`);
}
const type = raw.type as ParticleType;
switch (type) {
case 'stream':
return ParticleSchema.parse({
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
? (raw.updated_at as Timestamp).toDate()
: undefined,
visible_to: raw.visible_to,
playback_markers: raw.playback_markers
? Object.fromEntries(
Object.entries(raw.playback_markers).map(([key, value]) => [
key,
(value as Timestamp).toDate(),
]),
)
: undefined,
last_child_created_at: raw.last_child_created_at
? (raw.last_child_created_at as Timestamp).toDate()
: undefined,
huddle_active_participants:
raw.huddle_active_participants ?? undefined,
});
case 'folder':
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
properties: raw.properties,
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,
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 'task':
case 'paper': {
const properties = coerceLeafPropertyDates(type, raw.properties);
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
properties,
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,
reactions: raw.reactions ?? undefined,
deleted_at: raw.deleted_at
? (raw.deleted_at as Timestamp).toDate()
: undefined,
deleted_by_human_id: raw.deleted_by_human_id ?? undefined,
});
}
default:
// 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) {
return doc(firestoreDb, path).withConverter(particleConverter);
}
function typedCollection(path: string) {
return collection(firestoreDb, path).withConverter(particleConverter);
}
// --- Exported operations ---
export function subscribeToParticle(
docPath: string,
onData: (particle: Particle | null) => void,
onError: (error: Error) => void,
): Unsubscribe {
return onSnapshot(
typedDoc(docPath),
(snap) => {
onData(snap.exists() ? safeData(snap) : null);
},
onError,
);
}
export async function getParticle(docPath: string): Promise<Particle | null> {
const doc = await getDoc(typedDoc(docPath));
if (!doc.exists()) {
return null;
}
return safeData(doc);
}
export interface GetParticleChildrenOptions {
orderByField: string;
orderDirection: 'asc' | 'desc';
}
export async function getParticleChildren(
collectionPath: string,
{
orderByField = 'created_at',
orderDirection = 'asc',
}: GetParticleChildrenOptions = {
orderByField: 'created_at',
orderDirection: 'asc',
},
): Promise<Particle[]> {
const q = query(
typedCollection(collectionPath),
orderBy(orderByField, orderDirection),
);
const snap = await getDocs(q);
return snap.docs.flatMap((d) => safeData(d) ?? []);
}
export interface SubscribeToParticleChildrenOptions {
onData: (children: Particle[]) => void;
onError: (error: Error) => void;
visibilityScopes?: string[];
orderByField?: string;
orderDirection?: 'asc' | 'desc';
onAdded?: (child: Particle) => void;
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
whereFilter?: QueryFieldFilterConstraint;
/** Optional cap on results. Applied after order/where constraints. */
limit?: number;
}
export function subscribeToParticleChildren(
collectionPath: string,
{
onData,
onError,
visibilityScopes = [],
orderByField = 'created_at',
orderDirection = 'desc',
onAdded,
onRemoved,
whereFilter,
limit: limitValue,
}: SubscribeToParticleChildrenOptions,
): Unsubscribe {
let q = query(
typedCollection(collectionPath),
orderBy(orderByField, orderDirection),
);
if (visibilityScopes.length > 0) {
q = query(q, where('visible_to', 'array-contains-any', visibilityScopes));
}
if (whereFilter) {
q = query(q, whereFilter);
}
if (limitValue !== undefined) {
q = query(q, limit(limitValue));
}
return onSnapshot(
q,
(snap) => {
const updatedChildren = snap.docs.flatMap((d) => safeData(d) ?? []);
onData(updatedChildren);
if (onAdded || onRemoved) {
for (const change of snap.docChanges()) {
const child = safeData(change.doc);
if (!child) continue;
if (change.type === 'added' && onAdded) onAdded(child);
if (change.type === 'removed' && onRemoved)
onRemoved(child, updatedChildren);
}
}
},
onError,
);
}
export function subscribeToLatestChild(
collectionPath: string,
onData: (child: Particle | null) => void,
onError: (error: Error) => void,
): Unsubscribe {
const q = query(
typedCollection(collectionPath),
orderBy('created_at', 'desc'),
limit(1),
);
return onSnapshot(
q,
(snap) => {
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,
type: T,
properties: ParticlePropertiesMap[T],
createdByHumanId: string,
// Must be passed for container types
visibleTo?: string[],
): Promise<string> {
if (isContainerType(type) && (!visibleTo || visibleTo.length === 0)) {
throw new Error(
`visibleTo is required for container type ${type} and cannot be empty`,
);
}
const createdAt = new Date();
const particle: Particle = ParticleSchema.parse({
id: '', // ignored by toFirestore, but needed to satisfy the type
type,
properties,
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;
}
export async function createStreamParticle(
collectionPath: string,
properties: ParticlePropertiesMap['stream'],
createdByHumanId: string,
visibleTo?: string[],
): Promise<string> {
if (!visibleTo || visibleTo.length === 0) {
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,
status: 'open',
created_at: createdAt,
created_by_human_id: createdByHumanId,
visible_to: visibleTo,
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,
properties: Partial<ParticlePropertiesMap[T]>,
): Promise<void> {
const particleRef = typedDoc(docPath);
// Take the partial and create a new object with dot notation
// e.g. { title: "New Title" } becomes { "properties.title": "New Title" }
const updatedProperties: Record<string, any> = {}; // eslint-disable-line @typescript-eslint/no-explicit-any
for (const key in properties) {
updatedProperties[`properties.${key}`] = properties[key];
}
await updateDoc(particleRef, {
...updatedProperties,
updated_at: serverTimestamp(),
});
}
// Edits the body of a text particle and stamps `properties.edited_at` so
// readers can see that the message was edited (distinct from `updated_at`,
// which is bumped by any write — visibility, reactions, etc.).
export async function editTextParticleContent(
docPath: string,
content: string,
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
'properties.content': content,
'properties.edited_at': serverTimestamp(),
updated_at: serverTimestamp(),
});
}
export async function updateParticleVisibleTo(
docPath: string,
visibleTo: string[],
): Promise<void> {
const particleRef = typedDoc(docPath);
const particle = await getParticle(docPath);
if (!particle) {
throw new Error(`Particle not found at path: ${docPath}`);
}
if (!isContainerType(particle.type)) {
throw new Error(
`Only container particles can have visible_to field. Particle at ${docPath} is of type ${particle.type}`,
);
}
await updateDoc(particleRef, {
visible_to: visibleTo,
updated_at: serverTimestamp(),
});
}
// CAUTION: use the other type safe update functions in most cases
// There is no checking whether this field actually exists on the particle type, so it can lead to inconsistent data if used incorrectly
export async function updateParticle(
docPath: string,
fieldName: string,
value: any, // eslint-disable-line @typescript-eslint/no-explicit-any
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
[fieldName]: value,
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
* bumped to an adjacent particle. Idempotent — re-calling on an already
* tombstoned doc just refreshes the timestamp.
*/
export async function softDeleteParticle(
docPath: string,
humanId: string,
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
deleted_at: serverTimestamp(),
deleted_by_human_id: humanId,
updated_at: serverTimestamp(),
});
}
export async function updateStreamPlaybackMarker(
docPath: string,
humanId: string,
playbackPositionAt: Date,
): Promise<void> {
const particleRef = typedDoc(docPath);
const markerField = `playback_markers.${humanId}`;
await updateDoc(particleRef, {
[markerField]: Timestamp.fromDate(playbackPositionAt),
updated_at: serverTimestamp(),
});
}
// Strip characters that Firestore's dotted field-path syntax treats specially.
// Using FieldPath bypasses the parser, but we also forbid these in stored keys
// so reaction maps stay portable across any future read path.
const RESERVED_REACTION_CHARS = /[~*/[\]]/g;
export function sanitizeReactionText(text: string): string {
return text.replace(RESERVED_REACTION_CHARS, '');
}
export async function toggleParticleReaction(
docPath: string,
emoji: string,
humanId: string,
currentReactions?: Reactions,
): Promise<void> {
const key = sanitizeReactionText(emoji);
if (!key) return;
const particleRef = typedDoc(docPath);
const alreadyReacted = currentReactions?.[key]?.includes(humanId) ?? false;
await updateDoc(
particleRef,
new FieldPath('reactions', key),
alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId),
'updated_at',
serverTimestamp(),
);
}