diff --git a/js/desktop/src/api/types.ts b/js/desktop/src/api/types.ts index 90cfbe2..03807d1 100644 --- a/js/desktop/src/api/types.ts +++ b/js/desktop/src/api/types.ts @@ -244,6 +244,9 @@ export const ParticleSchema = z.discriminatedUnion('type', [ // e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John // e.g. ["network:123"] - visible to everyone in the network visible_to: z.array(z.string()), + // Set to created_at on creation, bumped when children are added — keeps + // folders present in activity-ordered container queries. + last_child_created_at: z.coerce.date().optional(), }), ParticleBaseSchema.extend({ type: z.literal('media'), diff --git a/js/desktop/src/features/particles/particle-view-resolver.tsx b/js/desktop/src/features/particles/particle-view-resolver.tsx index 2770522..bb3be17 100644 --- a/js/desktop/src/features/particles/particle-view-resolver.tsx +++ b/js/desktop/src/features/particles/particle-view-resolver.tsx @@ -7,6 +7,7 @@ import { useLiveParticle } from '@/hooks/use-particle'; import { particlePath, type ParticlePath } from '@/lib/particle-path'; import { Button } from '@/components/ui/button'; +import Layout from '@/features/layout'; import { StreamView } from '@/features/particles/stream-view'; import { FolderView } from '@/features/particles/folder-view'; import { MediaParticleView } from '@/features/particles/media-particle-view'; @@ -32,9 +33,11 @@ export default function ParticleViewResolver() { if (isLoading) { return ( -
-

Loading...

-
+ +
+

Loading...

+
+
); } @@ -42,21 +45,33 @@ export default function ParticleViewResolver() { // Errors here are almost always Firestore permission-denied — the user lost // access to the network or to a custom-visibility particle. The React Router // stays on the dead route, so without an explicit escape the user is stuck. - return ; + return ( + + + + ); } + // Streams render their own full-screen chrome; folders and leaves live + // inside the app Layout (breadcrumbs, full-height column) like the root. switch (particle.type) { case 'stream': return ; case 'folder': - return ; + return ( + + + + ); default: return ( - + + + ); } } @@ -133,7 +148,7 @@ function LeafParticleView({ })(); return ( -
+
{content}
); diff --git a/js/desktop/src/hooks/use-container-children.ts b/js/desktop/src/hooks/use-container-children.ts index 06e7462..81f8db6 100644 --- a/js/desktop/src/hooks/use-container-children.ts +++ b/js/desktop/src/hooks/use-container-children.ts @@ -28,7 +28,10 @@ interface UseContainerChildrenResult { } function activityTime(particle: Particle): number { - if (particle.type === 'stream' && particle.last_child_created_at) { + if ( + (particle.type === 'stream' || particle.type === 'folder') && + particle.last_child_created_at + ) { return particle.last_child_created_at.getTime(); } return particle.created_at.getTime(); diff --git a/js/desktop/src/lib/firestore-particles.ts b/js/desktop/src/lib/firestore-particles.ts index a0377c6..4bcf8ef 100644 --- a/js/desktop/src/lib/firestore-particles.ts +++ b/js/desktop/src/lib/firestore-particles.ts @@ -116,6 +116,9 @@ const particleConverter: FirestoreDataConverter = { ? (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': @@ -281,6 +284,25 @@ export function subscribeToLatestChild( ); } +// 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( collectionPath: string, @@ -296,15 +318,21 @@ export async function createParticle( ); } + 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; } @@ -318,15 +346,18 @@ 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(), + 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; }