feat: initial conversational flow (#37)

* chore: only set visibility for container particles

* create reusable controls indicator for reply or new

* compress the size of top bar

* refactor: restructure state, routing, and more

* introduce stream compose flow

* feat: compose new stream full flow

* implement stream player

* fix: prevent redirect for signed object urls

* fix: implement stream playback cleaner structure

* refactor: layout file name

* feat: show stream name in breadcrumbs

* chore: tweak padding

* chore: adjust position of audio bars

* feat: show latest particle preview in stream list

* fix: remove console log

* refactor: reorder classes

* fix: avoid passing in updated_at to firestore particle

* refactor: extract properties for container particles to flat fields in firestore

* make the stream previews look alive

* feat: show audio bars during audio clip playback

* feat: order streams by last child creation

* feat: playback where I left off

* chore: remove unused store

* fix: recording mode not using shared state

* chore: clean unused variable

* remove unused imports

* fix: improve controls indicator immersion

* feat: show playback progress in bar & auto-play text

* feat: auto-exit stream on playback completion

* fix: jittery media playback progress

* fix: navigate during state change is invalid with react router

* fix: buggy exit progress when changing clips

* feat: add app icon

* update package.json info

* feat: only show streams visible to me

* feat: show seen indicator on particles

* fix: prevent unnecessary effects

* fix: play new particle after playback is ended

* use contols indicator for exit timer
This commit was merged in pull request #37.
This commit is contained in:
Arjun Patel
2026-03-19 16:29:40 -07:00
committed by GitHub
parent 990137b829
commit 4b66d8e185
48 changed files with 2112 additions and 1482 deletions
+161 -17
View File
@@ -3,10 +3,13 @@ import {
doc,
onSnapshot,
addDoc,
getDoc,
updateDoc,
query,
orderBy,
limit,
serverTimestamp,
where,
Timestamp,
type DocumentData,
type FirestoreDataConverter,
@@ -15,7 +18,7 @@ import {
type Unsubscribe,
} from "firebase/firestore";
import { firestoreDb } from "@/firebase";
import { ParticleSchema } from "@/api/types";
import { isContainerType, ParticleSchema } from "@/api/types";
import type { Particle, ParticleType, ParticlePropertiesMap } from "@/api/types";
// --- Converter ---
@@ -34,15 +37,56 @@ const particleConverter: FirestoreDataConverter<Particle> = {
options?: SnapshotOptions,
): Particle {
const raw = snap.data(options);
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
properties: raw.properties,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_email: raw.created_by_email,
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
visible_to: raw.visible_to,
});
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,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_email: raw.created_by_email,
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,
});
case "folder":
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
properties: raw.properties,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_email: raw.created_by_email,
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
visible_to: raw.visible_to,
});
case "media":
case "file":
case "text":
case "quest":
case "paper":
return ParticleSchema.parse({
id: snap.id,
type: raw.type,
properties: raw.properties,
created_at: (raw.created_at as Timestamp).toDate(),
created_by_email: raw.created_by_email,
updated_at: raw.updated_at ? (raw.updated_at as Timestamp).toDate() : undefined,
});
default:
throw new Error(`Unknown particle type: ${type}`);
}
},
};
@@ -72,12 +116,30 @@ export function subscribeToParticle(
);
}
export async function getParticle(docPath: string): Promise<Particle | null> {
const doc = await getDoc(typedDoc(docPath));
if (!doc.exists()) {
return null;
}
return doc.data();
}
export function subscribeToParticleChildren(
collectionPath: string,
onData: (children: Particle[]) => void,
onError: (error: Error) => void,
visibilityScopes: string[] = [],
orderByField: string = "created_at",
orderDirection: "asc" | "desc" = "desc",
): Unsubscribe {
const q = query(typedCollection(collectionPath), orderBy("created_at"));
let q = query(typedCollection(collectionPath), orderBy(orderByField, orderDirection));
if (visibilityScopes.length > 0) {
q = query(
q,
where("visible_to", "array-contains-any", visibilityScopes),
);
}
return onSnapshot(
q,
(snap) => {
@@ -87,31 +149,56 @@ export function subscribeToParticleChildren(
);
}
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 : snap.docs[0].data());
},
onError,
);
}
// 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],
createdByEmail: string,
visibleTo: 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 particle: Particle = ParticleSchema.parse({
id: "", // ignored by toFirestore, but needed to satisfy the type
type,
properties,
created_at: new Date(),
created_by_email: createdByEmail,
updated_at: null,
visible_to: visibleTo,
...(visibleTo ? { visible_to: visibleTo } : {}),
});
const ref = await addDoc(typedCollection(collectionPath), particle);
return ref.id;
}
// This allows updating properties without overwriting the entire properties object
export async function updateParticle<T extends ParticleType>(
export async function updateParticleProperties<T extends ParticleType>(
docPath: string,
properties: Partial<ParticlePropertiesMap[T]>,
visibleTo?: string[],
): Promise<void> {
const particleRef = typedDoc(docPath);
// Take the partial and create a new object with dot notation
@@ -123,6 +210,63 @@ export async function updateParticle<T extends ParticleType>(
await updateDoc(particleRef, {
...updatedProperties,
updated_at: serverTimestamp(),
...(visibleTo ? { visible_to: visibleTo } : {}),
});
}
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,
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
[fieldName]: value,
updated_at: serverTimestamp(),
});
}
export async function updateStreamLastChildAt(
docPath: string,
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
last_child_created_at: serverTimestamp(),
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(),
});
}
-23
View File
@@ -1,23 +0,0 @@
/**
* Map URL segments to Firestore paths.
*
* Firestore structure:
* networks/{networkId}/particles/{particleId}
* networks/{networkId}/particles/{particleId}/children/{childId}
* ...and so on for arbitrary depth.
*
* Examples:
* segments = [] → "networks/{nid}/particles"
* segments = ["p1"] → "networks/{nid}/particles/p1"
* segments = ["p1", "p2"] → "networks/{nid}/particles/p1/children/p2"
*/
export function firestorePath(networkId: string, segments: string[]): string {
const base = `networks/${networkId}/particles`;
if (segments.length === 0) return base;
const parts: string[] = [base, segments[0]];
for (let i = 1; i < segments.length; i++) {
parts.push("children", segments[i]);
}
return parts.join("/");
}
+67
View File
@@ -0,0 +1,67 @@
/**
* ParticlePath is a branded string type representing a URL-style path
* to a particle in the hierarchy: /{networkId}/{segment1}/{segment2}/...
*
* Using a branded type prevents accidentally passing raw strings where
* a validated particle path is expected.
*/
declare const __brand: unique symbol;
export type ParticlePath = string & { readonly [__brand]: true };
/**
* Construct a ParticlePath from a network ID and optional particle segments.
*
* @example
* particlePath("net1", []) // => "/net1"
* particlePath("net1", ["p1"]) // => "/net1/p1"
* particlePath("net1", ["p1","p2"])// => "/net1/p1/p2"
*/
export function particlePath(networkId: string, segments: string[] = []): ParticlePath {
return `/${[networkId, ...segments].join("/")}` as ParticlePath;
}
/**
* Parse a ParticlePath back into its network ID and particle segments.
*/
export function parseParticlePath(path: ParticlePath): {
networkId: string;
segments: string[];
} {
const parts = path.split("/").filter(Boolean);
return { networkId: parts[0], segments: parts.slice(1) };
}
/**
* Convert a ParticlePath to the Firestore document path for that particle.
*
* Firestore structure:
* /net1 → networks/net1/particles (collection)
* /net1/p1 → networks/net1/particles/p1 (document)
* /net1/p1/p2 → networks/net1/particles/p1/children/p2 (document)
*/
export function toFirestoreDocPath(path: ParticlePath): string {
const { networkId, segments } = parseParticlePath(path);
const base = `networks/${networkId}/particles`;
if (segments.length === 0) return base;
const parts: string[] = [base, segments[0]];
for (let i = 1; i < segments.length; i++) {
parts.push("children", segments[i]);
}
return parts.join("/");
}
/**
* Convert a ParticlePath to the Firestore collection path for its children.
*
* /net1 → networks/net1/particles (root particles)
* /net1/p1 → networks/net1/particles/p1/children
* /net1/p1/p2 → networks/net1/particles/p1/children/p2/children
*/
export function toFirestoreChildrenPath(path: ParticlePath): string {
const { segments } = parseParticlePath(path);
if (segments.length === 0) {
return toFirestoreDocPath(path);
}
return `${toFirestoreDocPath(path)}/children`;
}
+19
View File
@@ -0,0 +1,19 @@
const ADJECTIVES = [
"amber", "bold", "calm", "crisp", "dark", "eager", "faint", "gentle",
"hasty", "icy", "jade", "keen", "lush", "misty", "nimble", "opal",
"pale", "quiet", "rapid", "sharp", "taut", "vivid", "warm", "zesty",
"bright", "clear", "deep", "fresh", "grand", "swift",
];
const NOUNS = [
"arrow", "bloom", "cedar", "drift", "ember", "flint", "grove", "harbor",
"iris", "jewel", "knoll", "lake", "moss", "nova", "orbit", "petal",
"quartz", "ridge", "spark", "trail", "vale", "wave", "yarn", "zenith",
"brook", "cliff", "delta", "frost", "glow", "reef",
];
export function generateRandomName(): string {
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
return `${adj}-${noun}`;
}