chore: integrate firestore for particles (#32)

* plumb for firestore

* chore: cleanup orion api to only include essentials

* setup boilerplate for data and rendering

This includes zod types creation for API response validation, and
exploration of path based resolution of rendering particles.

* chore: structure container particles for rendering children

* wire firestore crud for particles

* integrate visibility to particles

* docs: explain particle view resolver
This commit was merged in pull request #32.
This commit is contained in:
Arjun Patel
2026-03-17 19:52:31 -07:00
committed by GitHub
parent 377537b892
commit 51857bed63
25 changed files with 1585 additions and 2372 deletions
+27 -31
View File
@@ -1,39 +1,16 @@
import { useEffect } from "react";
import { HashRouter, Routes, Route } from "react-router-dom";
import { TooltipProvider } from "@/components/ui/tooltip";
import { useAppStore } from "@/stores/app-store";
import { useAuthStore } from "@/stores/auth-store";
import { LoginPage } from "@/features/auth/login-page";
import { StreamsPage } from "@/pages/streams-page";
import { StreamPlayerPage } from "@/pages/stream-player-page";
import { } from "@/firebase";
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
import PathResolver from "./pages/path-resolver";
function AuthenticatedApp() {
const fetchStartup = useAppStore((s) => s.fetchStartup);
const isLoading = useAppStore((s) => s.isLoading);
useEffect(() => {
fetchStartup();
}, [fetchStartup]);
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
);
}
return (
<TooltipProvider>
<HashRouter>
<Routes>
<Route path="/" element={<StreamsPage />} />
<Route path="/streams/:streamId" element={<StreamPlayerPage />} />
</Routes>
</HashRouter>
</TooltipProvider>
);
}
const queryClient = new QueryClient();
const App = () => {
const status = useAuthStore((s) => s.status);
@@ -58,4 +35,23 @@ const App = () => {
return <AuthenticatedApp />;
};
export default App;
function AuthenticatedApp() {
// NOTE: Hash router provides history, despite using catch-all
return (
<HashRouter>
<Routes>
<Route path="*" element={<PathResolver />} />
</Routes>
</HashRouter>
);
}
const AppWithProviders = () => (
<TooltipProvider>
<QueryClientProvider client={queryClient}>
<App />
</QueryClientProvider>
</TooltipProvider>
);
export default AppWithProviders;
+90 -91
View File
@@ -1,17 +1,19 @@
import { useSessionStore } from "@/stores/session-store";
import type { z } from "zod";
import {
DepotObjectSchema,
HumanSchema,
ListNetworksResponseSchema,
NetworkSchema,
PrepareUploadResponseSchema,
SignInResponseSchema,
} from "./types";
import type {
CreateStreamParticleRequest,
CreateStreamRequest,
Human,
MarkSeenBatchRequest,
AddMembersRequest,
CreateNetworkRequest,
PrepareUploadRequest,
PrepareUploadResponse,
RequestCodeRequest,
SignInRequest,
SignInResponse,
StartupResponse,
Stream,
StreamParticle,
} from "./types";
export class ApiError extends Error {
@@ -37,11 +39,11 @@ class ApiClient {
this.config = config;
}
private async request<T>(
private async fetch(
method: string,
path: string,
body?: unknown,
): Promise<T> {
): Promise<Response> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
@@ -67,109 +69,106 @@ class ApiClient {
throw new ApiError(response.status, text);
}
if (response.status === 204) {
return undefined as T;
}
return response;
}
return response.json() as Promise<T>;
private async request<T>(
schema: z.ZodType<T>,
method: string,
path: string,
body?: unknown,
): Promise<T> {
const response = await this.fetch(method, path, body);
const json = await response.json();
return schema.parse(json);
}
private async requestVoid(
method: string,
path: string,
body?: unknown,
): Promise<void> {
await this.fetch(method, path, body);
}
// --- Auth ---
async requestCode(data: RequestCodeRequest): Promise<void> {
await this.request<void>("POST", "/auth/request-code", data);
await this.requestVoid("POST", "/auth/request-code", data);
}
async signIn(data: SignInRequest): Promise<SignInResponse> {
return this.request<SignInResponse>("POST", "/auth/sign-in", data);
async signIn(data: SignInRequest) {
return this.request(SignInResponseSchema, "POST", "/auth/sign-in", data);
}
async me(): Promise<Human> {
return this.request<Human>("GET", "/auth/me");
async me() {
return this.request(HumanSchema, "GET", "/auth/me");
}
async signOut(): Promise<void> {
await this.request<void>("POST", "/auth/sign-out");
await this.requestVoid("POST", "/auth/sign-out");
}
// --- Startup ---
async startup(): Promise<StartupResponse> {
return this.request<StartupResponse>("GET", "/startup");
}
// --- Streams ---
async createStream(
networkId: string,
data: CreateStreamRequest,
): Promise<Stream> {
return this.request<Stream>(
"POST",
`/networks/${networkId}/streams`,
data,
// TODO: security: require passing in the particle id once api deprecates this
async getParticleDownloadUrl(objectId: string): Promise<string> {
const response = await this.fetch(
"GET",
`/particles/${objectId}/download`,
);
}
async createStreamParticle(
streamId: string,
data: CreateStreamParticleRequest,
): Promise<StreamParticle> {
return this.request<StreamParticle>(
"POST",
`/streams/${streamId}/particles`,
data,
);
}
// --- Particles ---
async markSeen(particleId: string): Promise<void> {
await this.request<void>("POST", `/particles/${particleId}/seen`);
}
async ackParticle(particleId: string): Promise<void> {
await this.request<void>("POST", `/particles/${particleId}/ack`);
}
async markSeenBatch(data: MarkSeenBatchRequest): Promise<void> {
await this.request<void>("POST", "/particles/seen", data);
}
async getParticleDownloadUrl(particleId: string): Promise<string> {
const token = this.config.getToken();
const headers: Record<string, string> = {};
if (token) {
headers["Authorization"] = `Bearer ${token}`;
}
const response = await fetch(
`${this.config.baseUrl}/particles/${particleId}/download`,
{ headers, redirect: "follow" },
);
if (response.status === 401) {
this.config.onUnauthorized();
throw new ApiError(401, "Unauthorized");
}
if (!response.ok) {
throw new ApiError(response.status, "Failed to get download URL");
}
return response.url;
}
// --- Depot ---
async prepareUpload(
data: PrepareUploadRequest,
): Promise<PrepareUploadResponse> {
return this.request<PrepareUploadResponse>("POST", "/depot/upload", data);
async prepareUpload(data: PrepareUploadRequest) {
return this.request(
PrepareUploadResponseSchema,
"POST",
"/depot/upload",
data,
);
}
async confirmUpload(objectId: string): Promise<void> {
await this.request<void>("POST", `/depot/objects/${objectId}/confirm`);
async confirmUpload(objectId: string) {
return this.request(
DepotObjectSchema,
"POST",
`/depot/objects/${objectId}/confirm`,
);
}
// --- Networks ---
async listNetworks() {
return this.request(
ListNetworksResponseSchema,
"GET",
"/networks",
);
}
async createNetwork(data: CreateNetworkRequest) {
return this.request(NetworkSchema, "POST", "/networks", data);
}
async getNetwork(id: string) {
return this.request(NetworkSchema, "GET", `/networks/${id}`);
}
async addMembers(networkId: string, data: AddMembersRequest): Promise<void> {
await this.requestVoid(
"POST",
`/networks/${networkId}/members`,
data,
);
}
async removeMember(networkId: string, email: string): Promise<void> {
await this.requestVoid(
"DELETE",
`/networks/${networkId}/members/${email}`,
);
}
}
+151 -145
View File
@@ -1,168 +1,174 @@
// --- Core entities ---
import { z } from "zod";
export interface Human {
id: string | null;
email: string;
email_prefix: string;
created_at: string | null;
}
export const HumanSchema = z.object({
id: z.string().nullable(),
created_at: z.coerce.date().nullable(),
email: z.string().email(),
email_prefix: z.string(),
});
export type StreamStatus = "open" | "closed" | "unspecified";
export type Human = z.infer<typeof HumanSchema>;
export interface AckInfo {
email: string;
acked_at: string;
}
export const NetworkSchema = z.object({
id: z.string(),
name: z.string(),
admin_human: HumanSchema,
humans: z.array(HumanSchema),
created_at: z.coerce.date(),
});
// --- Particle types ---
export type Network = z.infer<typeof NetworkSchema>;
export type ParticleType =
| "media"
| "text"
| "quest"
| "paper"
| "file"
| "folder";
export const ListNetworksResponseSchema = z.array(NetworkSchema);
export type ListNetworksResponse = z.infer<typeof ListNetworksResponseSchema>;
export interface MediaParticleData {
object_id: string;
duration_ms: number;
mime_type: string;
}
// --- Network request/response types ---
export interface TextParticleData {
content: string;
}
const CreateNetworkRequestSchema = z.object({
name: z.string(),
});
export type CreateNetworkRequest = z.infer<typeof CreateNetworkRequestSchema>;
export interface QuestParticleData {
title: string;
description: string;
status?: string;
assigned_to?: string;
due_date?: string;
}
export interface PaperParticleData {
title: string;
content: string;
}
export interface FileParticleData {
object_id: string;
filename: string;
mime_type: string;
size: number;
}
export interface FolderParticleData {
name: string;
color?: string;
}
export interface ParticleDataMap {
media: MediaParticleData;
text: TextParticleData;
quest: QuestParticleData;
paper: PaperParticleData;
file: FileParticleData;
folder: FolderParticleData;
}
export function getParticleData<T extends ParticleType>(
particle: StreamParticle,
type: T,
): ParticleDataMap[T] {
return particle.data as ParticleDataMap[T];
}
export interface StreamParticle {
id: string;
type: ParticleType;
data: unknown;
created_by_email: string;
seen: boolean;
acks: AckInfo[];
updated_at: string;
created_at: string;
}
export interface Stream {
id: string;
name: string;
description: string;
status: StreamStatus;
members?: string[];
particles: StreamParticle[];
unseen_count: number;
updated_at: string;
created_at: string;
}
export interface Network {
id: string;
name: string;
admin_human: Human;
humans: Human[];
open_stream_count: number;
open_stream_capacity: number;
created_at: string;
}
export interface NetworkWithStreams extends Network {
streams: Stream[];
}
const AddMembersRequestSchema = z.object({
email_addresses: z.array(z.string().email()),
});
export type AddMembersRequest = z.infer<typeof AddMembersRequestSchema>;
// --- Depot types ---
export interface PrepareUploadRequest {
network_id: string;
name: string;
content_type: string;
content_length: number;
const PrepareUploadRequestSchema = z.object({
network_id: z.string(),
name: z.string(),
content_type: z.string(),
content_length: z.number(),
});
export type PrepareUploadRequest = z.infer<typeof PrepareUploadRequestSchema>;
export const PrepareUploadResponseSchema = z.object({
object_id: z.string(),
upload_url: z.string(),
upload_headers: z.record(z.string(), z.string()),
});
export type PrepareUploadResponse = z.infer<typeof PrepareUploadResponseSchema>;
export const DepotObjectSchema = z.object({
id: z.string(),
name: z.string(),
content_type: z.string(),
content_length: z.number(),
contains_content: z.boolean(),
created_at: z.coerce.date(),
});
export type DepotObject = z.infer<typeof DepotObjectSchema>;
// --- Particle property schemas ---
export const StreamPropertiesSchema = z.object({
name: z.string(),
status: z.enum(["open", "closed"]),
description: z.string().optional(),
});
export type StreamProperties = z.infer<typeof StreamPropertiesSchema>;
export const FolderPropertiesSchema = z.object({
name: z.string(),
color: z.string().optional(),
});
export type FolderProperties = z.infer<typeof FolderPropertiesSchema>;
export const MediaPropertiesSchema = z.object({
object_id: z.string(),
mime_type: z.string(),
duration_ms: z.number(),
size_bytes: z.number(),
});
export type MediaProperties = z.infer<typeof MediaPropertiesSchema>;
export const FilePropertiesSchema = z.object({
object_id: z.string(),
filename: z.string(),
mime_type: z.string(),
size_bytes: z.number(),
});
export type FileProperties = z.infer<typeof FilePropertiesSchema>;
export const TextPropertiesSchema = z.object({
content: z.string(),
});
export type TextProperties = z.infer<typeof TextPropertiesSchema>;
export const QuestPropertiesSchema = z.object({
title: z.string(),
description: z.string(),
status: z.string().optional(),
assigned_to: z.string().email().optional(),
});
export type QuestProperties = z.infer<typeof QuestPropertiesSchema>;
export const PaperPropertiesSchema = z.object({
title: z.string(),
content: z.string(),
});
export type PaperProperties = z.infer<typeof PaperPropertiesSchema>;
export interface ParticlePropertiesMap {
stream: StreamProperties;
folder: FolderProperties;
media: MediaProperties;
file: FileProperties;
text: TextProperties;
quest: QuestProperties;
paper: PaperProperties;
}
export interface PrepareUploadResponse {
object_id: string;
upload_url: string;
upload_headers: Record<string, string>;
}
// --- Unified Particle types ---
// --- Stream mutation types ---
const ParticleBaseSchema = z.object({
id: z.string(),
created_at: z.coerce.date(),
created_by_email: z.string().email(),
updated_at: z.coerce.date().optional(),
// e.g. ["human:[email protected]", "human:[email protected]"] - visible only to Aron and John
// e.g. ["network:123"] - visible to everyone in the network
visible_to: z.array(z.string())
});
export interface CreateStreamRequest {
name: string;
description: string;
visibility: "network_all" | "custom";
member_emails?: string[];
}
export const ParticleSchema = z.discriminatedUnion("type", [
ParticleBaseSchema.extend({ type: z.literal("stream"), properties: StreamPropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("folder"), properties: FolderPropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("media"), properties: MediaPropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("file"), properties: FilePropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("text"), properties: TextPropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("quest"), properties: QuestPropertiesSchema }),
ParticleBaseSchema.extend({ type: z.literal("paper"), properties: PaperPropertiesSchema }),
]);
export interface CreateStreamParticleRequest {
type: ParticleType;
data: unknown;
}
export type Particle = z.infer<typeof ParticleSchema>;
export interface MarkSeenBatchRequest {
particle_ids: string[];
export type ParticleType = Particle["type"];
/** Container types can have children subcollections */
export const CONTAINER_TYPES: ReadonlySet<ParticleType> = new Set(["stream", "folder"]);
export function isContainerType(type: ParticleType): boolean {
return CONTAINER_TYPES.has(type);
}
// --- Auth types ---
export interface RequestCodeRequest {
email: string;
}
const RequestCodeRequestSchema = z.object({
email: z.string().email(),
});
export type RequestCodeRequest = z.infer<typeof RequestCodeRequestSchema>;
export interface SignInRequest {
email: string;
code: string;
}
const SignInRequestSchema = z.object({
email: z.string().email(),
code: z.string(),
});
export type SignInRequest = z.infer<typeof SignInRequestSchema>;
export interface SignInResponse {
human: Human;
token: string;
}
// --- Startup ---
export interface StartupResponse {
networks: NetworkWithStreams[];
}
export const SignInResponseSchema = z.object({
human: HumanSchema,
token: z.string(),
});
export type SignInResponse = z.infer<typeof SignInResponseSchema>;
+86
View File
@@ -0,0 +1,86 @@
import { useNavigate } from "react-router-dom";
import { LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Muted } from "@/components/ui/typography";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAuthStore } from "@/stores/auth-store";
import { WindowControls } from "@/components/window-controls";
import { useQuery } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
import { Progress } from "@/components/ui/progress";
export function NetworkSelector() {
const navigate = useNavigate();
const signOut = useAuthStore((s) => s.signOut);
const user = useAuthStore((s) => s.user);
const { data, isPending, error } = useQuery({
queryKey: ["networks"],
queryFn: () => apiClient.listNetworks(),
});
if (isPending) {
return <Progress />;
}
if (error) {
return (
<div className="flex min-h-screen items-center justify-center">
<p className="text-destructive text-sm">Failed to load networks</p>
<p>{error.message}</p>
</div>
);
}
if (data?.length === 0) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 px-4 text-center max-w-sm mx-auto">
<p className="text-muted-foreground">
You don't have access to any networks yet. Please email us to get started.
</p>
<a href="mailto:[email protected]" className="text-primary underline">
team@flowylabs.ai
</a>
</div>
);
}
return (
<div className="flex h-screen flex-col">
<div className="drag-region flex items-center gap-3 border-b px-4 py-3">
<WindowControls />
<div className="flex-1" />
{user && <Muted className="text-xs">{user.email_prefix}</Muted>}
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
onClick={signOut}
>
<LogOut className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex flex-1 items-center justify-center p-4">
<Select onValueChange={(value) => navigate(`/${value}`)}>
<SelectTrigger className="no-drag w-64">
<SelectValue placeholder="Select a network" />
</SelectTrigger>
<SelectContent>
{data?.map((network) => (
<SelectItem key={network.id} value={network.id}>
{network.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { Particle } from "@/api/types";
import { useParticleChildren } from "@/hooks/use-particle-children";
interface FolderViewProps {
folderParticle: Particle;
networkId: string;
particleSegments: string[];
}
export function FolderView({ networkId, particleSegments, folderParticle }: FolderViewProps) {
const { children, error, isLoading } = useParticleChildren(networkId, particleSegments);
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Folder view {networkId}/{particleSegments.join("/")}
</p>
</div>
);
}
@@ -0,0 +1,43 @@
import { useParticleChildren } from "@/hooks/use-particle-children";
interface ParticleListViewProps {
networkId: string;
particleSegments: string[];
}
/**
* Grid/list of child particles for a container (folder, stream root, or network root).
*/
export function ParticleListView({ networkId, particleSegments }: ParticleListViewProps) {
const { children, isLoading } = useParticleChildren(networkId, particleSegments);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">Loading particles...</p>
</div>
);
}
if (children.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">No particles yet</p>
</div>
);
}
return (
<div className="grid grid-cols-2 gap-3 p-4">
{children.map((child) => (
<div
key={child.id}
className="rounded-lg border p-3 text-sm"
>
<p className="font-medium">{child.id}</p>
<p className="text-muted-foreground text-xs">{child.type}</p>
</div>
))}
</div>
);
}
@@ -0,0 +1,65 @@
import { useParticle } from "@/hooks/use-particle";
import { StreamView } from "./stream-view";
import { FolderView } from "./folder-view";
import { ParticleListView } from "./particle-list-view";
import { isContainerType } from "@/api/types";
interface ParticleViewResolverProps {
networkId: string;
particleSegments: string[];
}
/**
* Resolves a particle by its path segments and renders the appropriate view
* based on particle type (e.g. stream would show clips in story mode, folder would list files, etc.)
*/
export function ParticleViewResolver({ networkId, particleSegments }: ParticleViewResolverProps) {
const { particle, isLoading, error } = useParticle(networkId, particleSegments);
if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">Loading...</p>
</div>
);
}
if (error) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-destructive text-sm">Failed to load particle</p>
</div>
);
}
// While the hook is stubbed, particle will be null — show a placeholder
if (!particle) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Particle: {particleSegments.join(" / ")}
</p>
</div>
);
}
switch (particle.type) {
case "stream":
return <StreamView streamParticle={particle} networkId={networkId} particleSegments={particleSegments} />;
case "folder":
return <FolderView folderParticle={particle} networkId={networkId} particleSegments={particleSegments} />;
default:
// For container types we haven't built a view for, fall back to list
if (isContainerType(particle.type)) {
return <ParticleListView networkId={networkId} particleSegments={particleSegments} />;
}
// Leaf particle — placeholder
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
{particle.type} particle: {particle.id}
</p>
</div>
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { Particle } from "@/api/types";
import { useParticleChildren } from "@/hooks/use-particle-children";
interface StreamViewProps {
streamParticle: Particle;
networkId: string;
particleSegments: string[];
}
export function StreamView({ networkId, particleSegments, streamParticle }: StreamViewProps) {
const { children, error, isLoading } = useParticleChildren(networkId, particleSegments);
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
Stream view {networkId}/{particleSegments.join("/")}
</p>
{isLoading && <p className="text-muted-foreground text-sm">Loading stream data...</p>}
{error && <p className="text-destructive text-sm">Failed to load stream data</p>}
{!isLoading && !error && (
<div className="mt-4">
<p className="text-sm font-medium">Stream Children:</p>
<ul className="list-disc list-inside">
{children.map((child) => (
<li key={child.id} className="text-sm">
{child.id} ({child.type})
</li>
))}
</ul>
</div>
)}
</div>
);
}
-98
View File
@@ -1,98 +0,0 @@
import { useNavigate } from "react-router-dom";
import { Badge } from "@/components/ui/badge";
import { Card } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { useAppStore } from "@/stores/app-store";
import { flattenStreams } from "@/lib/stream-utils";
import { formatDistanceToNow } from "@/lib/time-utils";
import { ParticlePreview } from "./particle-preview";
function getInitials(email: string): string {
const prefix = email.split("@")[0] ?? "";
return prefix.slice(0, 2).toUpperCase();
}
export function StreamList() {
const networks = useAppStore((s) => s.networks);
const selectedNetworkId = useAppStore((s) => s.selectedNetworkId);
const navigate = useNavigate();
const streams = flattenStreams(networks, selectedNetworkId);
if (streams.length === 0) {
return (
<div className="flex flex-1 items-center justify-center">
<p className="text-muted-foreground text-sm">No streams yet</p>
</div>
);
}
return (
<div className="grid grid-cols-2 gap-3 p-4">
{streams.map((stream) => {
const lastParticle =
stream.particles.length > 0
? stream.particles[stream.particles.length - 1]
: null;
const timeSource = lastParticle?.created_at ?? stream.updated_at;
const senderEmail = lastParticle?.created_by_email;
const senderPrefix = senderEmail?.split("@")[0];
return (
<Card
key={stream.id}
size="sm"
className="hover:bg-accent/50 cursor-pointer overflow-hidden transition-colors pt-0!"
onClick={() => navigate(`/streams/${stream.id}`)}
>
{/* Preview hero area */}
<div className="relative aspect-[4/3] overflow-hidden">
{lastParticle ? (
<ParticlePreview particle={lastParticle} />
) : (
<div className="flex h-full w-full items-center justify-center">
<p className="text-muted-foreground text-xs italic">
No messages yet
</p>
</div>
)}
{/* Unseen badge overlay */}
{stream.unseen_count > 0 && (
<Badge
variant="default"
className="absolute top-1.5 right-1.5 text-[10px]"
>
{stream.unseen_count}
</Badge>
)}
</div>
{/* Footer: avatar + stream info */}
<div className="flex items-center gap-2 px-3 py-2">
{senderEmail ? (
<Avatar size="sm">
<AvatarFallback className="text-[10px]">
{getInitials(senderEmail)}
</AvatarFallback>
</Avatar>
) : (
<div className="size-6 shrink-0" />
)}
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{stream.name}</p>
<p className="text-muted-foreground truncate text-[11px]">
{senderPrefix && <span>{senderPrefix}</span>}
{senderPrefix && timeSource && <span> &middot; </span>}
{timeSource && <span>{formatDistanceToNow(timeSource)}</span>}
</p>
</div>
</div>
</Card>
);
})}
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { initializeApp } from 'firebase/app';
import { initializeFirestore, persistentLocalCache, persistentMultipleTabManager } from "firebase/firestore";
const firebaseConfig = {
apiKey: "AIzaSyDF_fbk7tDY9tNxUiOwwi--2nYZWZygMGk",
appId: "1:1006580076785:web:e2a0736d60a78e02b15950",
authDomain: "flowy-dev-440017.firebaseapp.com",
messagingSenderId: "1006580076785",
projectId: "flowy-dev-440017",
storageBucket: "flowy-dev-440017.firebasestorage.app",
};
export const firebaseApp = initializeApp(firebaseConfig);
export const firestoreDb = initializeFirestore(firebaseApp,
{
localCache:
persistentLocalCache(/*settings*/{ tabManager: persistentMultipleTabManager() })
});
+24
View File
@@ -0,0 +1,24 @@
import { useMutation } from "@tanstack/react-query";
import { createParticle } from "@/lib/firestore-particles";
import type { ParticleType, ParticlePropertiesMap } from "@/api/types";
interface CreateParticleParams<T extends ParticleType = ParticleType> {
collectionPath: string;
type: T;
properties: ParticlePropertiesMap[T];
createdByEmail: string;
visibleTo: string[];
}
export function useCreateParticle() {
return useMutation({
mutationFn: (params: CreateParticleParams) =>
createParticle(
params.collectionPath,
params.type,
params.properties,
params.createdByEmail,
params.visibleTo,
),
});
}
+46
View File
@@ -0,0 +1,46 @@
import { useState, useEffect, useMemo } from "react";
import { subscribeToParticleChildren } from "@/lib/firestore-particles";
import { firestorePath } from "@/lib/firestore-paths";
import type { Particle } from "@/api/types";
interface UseParticleChildrenResult {
children: Particle[];
isLoading: boolean;
error: Error | null;
}
export function useParticleChildren(
networkId: string,
parentSegments: string[],
): UseParticleChildrenResult {
const [children, setChildren] = useState<Particle[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const collectionPath = useMemo(() => {
if (parentSegments.length === 0) return firestorePath(networkId, []);
return `${firestorePath(networkId, parentSegments)}/children`;
}, [networkId, parentSegments.join("/")]);
useEffect(() => {
setIsLoading(true);
setError(null);
setChildren([]);
const unsubscribe = subscribeToParticleChildren(
collectionPath,
(data) => {
setChildren(data);
setIsLoading(false);
},
(err) => {
setError(err);
setIsLoading(false);
},
);
return unsubscribe;
}, [collectionPath]);
return { children, isLoading, error };
}
+46
View File
@@ -0,0 +1,46 @@
import { useState, useEffect, useMemo } from "react";
import { subscribeToParticle } from "@/lib/firestore-particles";
import { firestorePath } from "@/lib/firestore-paths";
import type { Particle } from "@/api/types";
interface UseParticleResult {
particle: Particle | null;
isLoading: boolean;
error: Error | null;
}
export function useParticle(
networkId: string,
segments: string[],
): UseParticleResult {
const [particle, setParticle] = useState<Particle | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const path = useMemo(
() => firestorePath(networkId, segments),
[networkId, segments.join("/")],
);
useEffect(() => {
setIsLoading(true);
setError(null);
setParticle(null);
const unsubscribe = subscribeToParticle(
path,
(data) => {
setParticle(data);
setIsLoading(false);
},
(err) => {
setError(err);
setIsLoading(false);
},
);
return unsubscribe;
}, [path]);
return { particle, isLoading, error };
}
+128
View File
@@ -0,0 +1,128 @@
import {
collection,
doc,
onSnapshot,
addDoc,
updateDoc,
query,
orderBy,
serverTimestamp,
Timestamp,
type DocumentData,
type FirestoreDataConverter,
type QueryDocumentSnapshot,
type SnapshotOptions,
type Unsubscribe,
} from "firebase/firestore";
import { firestoreDb } from "@/firebase";
import { ParticleSchema } from "@/api/types";
import type { Particle, ParticleType, ParticlePropertiesMap } from "@/api/types";
// --- Converter ---
const particleConverter: FirestoreDataConverter<Particle> = {
toFirestore(particle: Particle): DocumentData {
const { id: _id, created_at, updated_at, ...rest } = particle;
return {
...rest,
created_at: Timestamp.fromDate(created_at),
...(updated_at && { updated_at: Timestamp.fromDate(updated_at) }),
};
},
fromFirestore(
snap: QueryDocumentSnapshot,
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,
});
},
};
// --- 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() ? snap.data() : null);
},
onError,
);
}
export function subscribeToParticleChildren(
collectionPath: string,
onData: (children: Particle[]) => void,
onError: (error: Error) => void,
): Unsubscribe {
const q = query(typedCollection(collectionPath), orderBy("created_at"));
return onSnapshot(
q,
(snap) => {
onData(snap.docs.map((d) => d.data()));
},
onError,
);
}
export async function createParticle<T extends ParticleType>(
collectionPath: string,
type: T,
properties: ParticlePropertiesMap[T],
createdByEmail: string,
visibleTo: string[],
): Promise<string> {
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,
});
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>(
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
// e.g. { title: "New Title" } becomes { "properties.title": "New Title" }
const updatedProperties: Record<string, any> = {};
for (const key in properties) {
updatedProperties[`properties.${key}`] = properties[key];
}
await updateDoc(particleRef, {
...updatedProperties,
updated_at: serverTimestamp(),
...(visibleTo ? { visible_to: visibleTo } : {}),
});
}
+23
View File
@@ -0,0 +1,23 @@
/**
* 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("/");
}
-37
View File
@@ -1,37 +0,0 @@
import type { NetworkWithStreams, Stream } from "@/api/types";
export interface FlatStream extends Stream {
networkId: string;
networkName: string;
}
export function flattenStreams(
networks: NetworkWithStreams[],
selectedNetworkId: string | null,
): FlatStream[] {
const filtered = selectedNetworkId
? networks.filter((n) => n.id === selectedNetworkId)
: networks;
const streams: FlatStream[] = filtered.flatMap((n) =>
n.streams.map((s) => ({
...s,
networkId: n.id,
networkName: n.name,
})),
);
return streams.sort((a, b) => {
const aTime = getLatestParticleTime(a);
const bTime = getLatestParticleTime(b);
return bTime - aTime;
});
}
function getLatestParticleTime(stream: Stream): number {
if (stream.particles.length === 0) {
return new Date(stream.updated_at).getTime() || 0;
}
const last = stream.particles[stream.particles.length - 1];
return new Date(last.created_at).getTime();
}
+5
View File
@@ -4,3 +4,8 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function getInitials(email: string): string {
const prefix = email.split("@")[0] ?? "";
return prefix.slice(0, 2).toUpperCase();
}
+33
View File
@@ -0,0 +1,33 @@
import { useLocation } from "react-router-dom";
import { NetworkSelector } from "@/features/network-selector";
import { ParticleListView } from "@/features/particles/particle-list-view";
import { ParticleViewResolver } from "@/features/particles/particle-view-resolver";
function parsePathSegments(path: string): string[] {
return path.split("/").filter(Boolean);
}
/**
* URL structure:
* / → network selector
* /:networkId → root particles for that network
* /:networkId/:p1/:p2/... → nested particle view (renders the parent which will use it's children)
*/
export default function PathResolver() {
const segments = parsePathSegments(useLocation().pathname);
// No segments → show network selector
if (segments.length === 0) {
return <NetworkSelector />;
}
const [networkId, ...particleSegments] = segments;
// /:networkId with no particle segments → root particle list
if (particleSegments.length === 0) {
return <ParticleListView networkId={networkId} particleSegments={[]} />;
}
// /:networkId/:p1/:p2/... → resolve and render the container particle
return <ParticleViewResolver networkId={networkId} particleSegments={particleSegments} />;
}
-83
View File
@@ -1,83 +0,0 @@
import { Plus, LogOut } from "lucide-react";
import { Button } from "@/components/ui/button";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Muted } from "@/components/ui/typography";
import { Progress } from "@/components/ui/progress";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useAppStore } from "@/stores/app-store";
import { useAuthStore } from "@/stores/auth-store";
import { StreamList } from "@/features/streams/stream-list";
import { CreateStreamDialog } from "@/features/streams/create-stream-dialog";
import { WindowControls } from "@/components/window-controls";
export function StreamsPage() {
const networks = useAppStore((s) => s.networks);
const selectedNetworkId = useAppStore((s) => s.selectedNetworkId);
const setSelectedNetwork = useAppStore((s) => s.setSelectedNetwork);
const signOut = useAuthStore((s) => s.signOut);
const user = useAuthStore((s) => s.user);
const selectedNetwork = selectedNetworkId
? networks.find((n) => n.id === selectedNetworkId)
: null;
return (
<div className="flex h-screen flex-col">
{/* Top bar — draggable for frameless window */}
<div className="drag-region flex items-center gap-3 border-b px-4 py-3">
<WindowControls />
<Select
value={selectedNetworkId ?? undefined}
onValueChange={(value) => setSelectedNetwork(value)}
>
<SelectTrigger className="no-drag">
<SelectValue placeholder="Select a network" />
</SelectTrigger>
<SelectContent>
{networks.map((network) => (
<SelectItem key={network.id} value={network.id}>
{network.name}
</SelectItem>
))}
</SelectContent>
</Select>
<div className="flex-1" />
{selectedNetworkId && (
<CreateStreamDialog networkId={selectedNetworkId}>
<Button variant="outline" size="sm" className="no-drag">
<Plus className="mr-1.5 h-3.5 w-3.5" />
New Stream
</Button>
</CreateStreamDialog>
)}
{user && (
<Muted className="text-xs">{user.email_prefix}</Muted>
)}
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
onClick={signOut}
>
<LogOut className="h-3.5 w-3.5" />
</Button>
</div>
{/* Stream list */}
<ScrollArea className="flex-1">
<StreamList />
</ScrollArea>
</div>
);
}
+6 -98
View File
@@ -1,107 +1,15 @@
import { create } from "zustand";
import { apiClient } from "@/api/client";
import type {
AckInfo,
NetworkWithStreams,
Stream,
StreamParticle,
} from "@/api/types";
/**
* Minimal app-level store. Navigation state is now URL-driven via PathResolver.
* Stream/particle state will move to Firestore hooks.
*/
interface AppState {
networks: NetworkWithStreams[];
selectedNetworkId: string | null;
isLoading: boolean;
fetchStartup: () => Promise<void>;
setSelectedNetwork: (id: string | null) => void;
addStream: (networkId: string, stream: Stream) => void;
addParticleToStream: (streamId: string, particle: StreamParticle) => void;
markParticlesSeen: (particleIds: string[]) => void;
ackParticle: (particleId: string, email: string) => void;
}
export const useAppStore = create<AppState>((set, get) => ({
networks: [],
export const useAppStore = create<AppState>((set) => ({
selectedNetworkId: null,
isLoading: false,
fetchStartup: async () => {
set({ isLoading: true });
try {
const data = await apiClient.startup();
const state = get();
const shouldAutoSelect =
!state.selectedNetworkId && data.networks.length > 0;
set({
networks: data.networks,
...(shouldAutoSelect
? { selectedNetworkId: data.networks[0].id }
: {}),
});
} finally {
set({ isLoading: false });
}
},
setSelectedNetwork: (id) => {
set({ selectedNetworkId: id });
},
addStream: (networkId, stream) => {
set({
networks: get().networks.map((n) =>
n.id === networkId ? { ...n, streams: [stream, ...n.streams] } : n,
),
});
},
addParticleToStream: (streamId, particle) => {
set({
networks: get().networks.map((n) => ({
...n,
streams: n.streams.map((s) =>
s.id === streamId
? { ...s, particles: [...s.particles, particle] }
: s,
),
})),
});
},
ackParticle: (particleId, email) => {
const ack: AckInfo = { email, acked_at: new Date().toISOString() };
set({
networks: get().networks.map((n) => ({
...n,
streams: n.streams.map((s) => ({
...s,
particles: s.particles.map((p) =>
p.id === particleId ? { ...p, acks: [...p.acks, ack] } : p,
),
})),
})),
});
},
markParticlesSeen: (particleIds) => {
const idSet = new Set(particleIds);
set({
networks: get().networks.map((n) => ({
...n,
streams: n.streams.map((s) => {
const unseenMarked = s.particles.filter(
(p) => !p.seen && idSet.has(p.id),
).length;
if (unseenMarked === 0) return s;
return {
...s,
unseen_count: Math.max(0, s.unseen_count - unseenMarked),
particles: s.particles.map((p) =>
idSet.has(p.id) ? { ...p, seen: true } : p,
),
};
}),
})),
});
},
setSelectedNetwork: (id) => set({ selectedNetworkId: id }),
}));