095b9876f9
* 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
520 lines
16 KiB
TypeScript
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(),
|
|
);
|
|
}
|