fix folder from root

This commit is contained in:
Arjun Patel
2026-06-11 22:47:25 -07:00
parent 1baae790c6
commit 72caee8660
4 changed files with 66 additions and 14 deletions
+3
View File
@@ -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. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:123"] - visible to everyone in the network // e.g. ["network:123"] - visible to everyone in the network
visible_to: z.array(z.string()), 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({ ParticleBaseSchema.extend({
type: z.literal('media'), type: z.literal('media'),
@@ -7,6 +7,7 @@ import { useLiveParticle } from '@/hooks/use-particle';
import { particlePath, type ParticlePath } from '@/lib/particle-path'; import { particlePath, type ParticlePath } from '@/lib/particle-path';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import Layout from '@/features/layout';
import { StreamView } from '@/features/particles/stream-view'; import { StreamView } from '@/features/particles/stream-view';
import { FolderView } from '@/features/particles/folder-view'; import { FolderView } from '@/features/particles/folder-view';
import { MediaParticleView } from '@/features/particles/media-particle-view'; import { MediaParticleView } from '@/features/particles/media-particle-view';
@@ -32,9 +33,11 @@ export default function ParticleViewResolver() {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex h-full items-center justify-center"> <Layout>
<p className="text-muted-foreground text-sm">Loading...</p> <div className="flex h-full items-center justify-center">
</div> <p className="text-muted-foreground text-sm">Loading...</p>
</div>
</Layout>
); );
} }
@@ -42,21 +45,33 @@ export default function ParticleViewResolver() {
// Errors here are almost always Firestore permission-denied — the user lost // Errors here are almost always Firestore permission-denied — the user lost
// access to the network or to a custom-visibility particle. The React Router // 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. // stays on the dead route, so without an explicit escape the user is stuck.
return <InaccessibleParticle />; return (
<Layout>
<InaccessibleParticle />
</Layout>
);
} }
// 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) { switch (particle.type) {
case 'stream': case 'stream':
return <StreamView streamParticle={particle} path={path} />; return <StreamView streamParticle={particle} path={path} />;
case 'folder': case 'folder':
return <FolderView folderParticle={particle} path={path} />; return (
<Layout>
<FolderView folderParticle={particle} path={path} />
</Layout>
);
default: default:
return ( return (
<LeafParticleView <Layout>
particle={particle} <LeafParticleView
containerPath={particlePath(networkId, segments.slice(0, -1))} particle={particle}
networkId={networkId} containerPath={particlePath(networkId, segments.slice(0, -1))}
/> networkId={networkId}
/>
</Layout>
); );
} }
} }
@@ -133,7 +148,7 @@ function LeafParticleView({
})(); })();
return ( return (
<div className="h-full bg-black text-white [--stream-safe-top:4rem] [--stream-safe-bottom:2rem]"> <div className="min-h-0 flex-1 bg-black text-white [--stream-safe-top:2rem] [--stream-safe-bottom:2rem]">
{content} {content}
</div> </div>
); );
@@ -28,7 +28,10 @@ interface UseContainerChildrenResult {
} }
function activityTime(particle: Particle): number { 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.last_child_created_at.getTime();
} }
return particle.created_at.getTime(); return particle.created_at.getTime();
+33 -2
View File
@@ -116,6 +116,9 @@ const particleConverter: FirestoreDataConverter<Particle> = {
? (raw.updated_at as Timestamp).toDate() ? (raw.updated_at as Timestamp).toDate()
: undefined, : undefined,
visible_to: raw.visible_to, 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 'media':
case 'file': 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. // This creates a new particle document with the given properties and returns its ID.
export async function createParticle<T extends ParticleType>( export async function createParticle<T extends ParticleType>(
collectionPath: string, collectionPath: string,
@@ -296,15 +318,21 @@ export async function createParticle<T extends ParticleType>(
); );
} }
const createdAt = new Date();
const particle: Particle = ParticleSchema.parse({ const particle: Particle = ParticleSchema.parse({
id: '', // ignored by toFirestore, but needed to satisfy the type id: '', // ignored by toFirestore, but needed to satisfy the type
type, type,
properties, properties,
created_at: new Date(), created_at: createdAt,
created_by_human_id: createdByHumanId, created_by_human_id: createdByHumanId,
...(visibleTo ? { visible_to: visibleTo } : {}), ...(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); const ref = await addDoc(typedCollection(collectionPath), particle);
bumpParentLastChildCreatedAt(collectionPath, createdAt);
return ref.id; return ref.id;
} }
@@ -318,15 +346,18 @@ export async function createStreamParticle(
throw new Error('visibleTo is required for streams and cannot be empty'); throw new Error('visibleTo is required for streams and cannot be empty');
} }
const createdAt = new Date();
const particle: Particle = ParticleSchema.parse({ const particle: Particle = ParticleSchema.parse({
id: '', id: '',
type: 'stream', type: 'stream',
properties, properties,
created_at: new Date(), created_at: createdAt,
created_by_human_id: createdByHumanId, created_by_human_id: createdByHumanId,
visible_to: visibleTo, visible_to: visibleTo,
last_child_created_at: createdAt,
}); });
const ref = await addDoc(typedCollection(collectionPath), particle); const ref = await addDoc(typedCollection(collectionPath), particle);
bumpParentLastChildCreatedAt(collectionPath, createdAt);
return ref.id; return ref.id;
} }