step 2.5: streams list

This commit is contained in:
talksik
2026-04-29 11:24:29 -07:00
parent cd1837c5f1
commit 73cc65c5d1
14 changed files with 1816 additions and 28 deletions
@@ -0,0 +1,24 @@
import { useEffect, useState } from "react";
import { Text, type TextProps } from "react-native";
import { formatDistanceToNow } from "@/lib/time-utils";
const MINUTE_MS = 60_000;
interface RelativeTimestampProps extends Omit<TextProps, "children"> {
date: Date;
}
/**
* Re-renders once per minute so labels like "5m ago" stay accurate without
* any per-card timer wiring at the call site.
*/
export function RelativeTimestamp({ date, ...rest }: RelativeTimestampProps) {
const [, force] = useState(0);
useEffect(() => {
const interval = setInterval(() => force((n) => n + 1), MINUTE_MS);
return () => clearInterval(interval);
}, []);
return <Text {...rest}>{formatDistanceToNow(date)}</Text>;
}
@@ -0,0 +1,156 @@
import { memo, useMemo } from "react";
import { Pressable, Text, View } from "react-native";
import type { Particle, StreamProperties } from "@/api/types";
import { isParticleDeleted } from "@/api/types";
import { RelativeTimestamp } from "@/components/RelativeTimestamp";
import { useLiveLatestChild } from "@/hooks/use-particle";
import { useNetwork } from "@/hooks/use-networks";
import { particlePath } from "@/lib/particle-path";
import { cn, getInitials } from "@/lib/utils";
import { useAuthStore } from "@/stores/auth-store";
interface StreamCardProps {
particle: Particle & { type: "stream"; properties: StreamProperties };
networkId: string;
onPress: () => void;
}
/**
* Mobile counterpart of js/desktop/src/features/particles/stream-card.tsx —
* same data wiring (subscribe to the latest child for unread + initials),
* touch-tuned layout (single row, no preview thumbnail in v1).
*/
export const StreamCard = memo(function StreamCard({
particle,
networkId,
onPress,
}: StreamCardProps) {
const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath);
const userId = useAuthStore((s) => s.user?.id) ?? "";
const network = useNetwork(networkId);
const isDM =
particle.visible_to.length === 2 &&
particle.visible_to.every((v) => v.startsWith("human:"));
const initials = useMemo(() => {
if (isDM) {
const otherEntry = particle.visible_to.find(
(v) => v !== `human:${userId}`,
);
if (otherEntry) {
const otherId = otherEntry.replace("human:", "");
const otherHuman = network?.humans?.find((h) => h.id === otherId);
if (otherHuman) return getInitials(otherHuman.email);
}
}
if (latestChild) {
const creator = network?.humans?.find(
(h) => h.id === latestChild.created_by_human_id,
);
if (creator) return getInitials(creator.email);
}
return particle.properties.name.slice(0, 2).toUpperCase();
}, [
isDM,
particle.visible_to,
particle.properties.name,
userId,
latestChild,
network,
]);
const isUnseen = useMemo(() => {
if (!latestChild) return false;
const latestChildTimestamp = latestChild.created_at.getTime();
const userPlaybackPosition =
particle.playback_markers?.[userId]?.getTime() ?? 0;
return latestChildTimestamp > userPlaybackPosition;
}, [latestChild, particle.playback_markers, userId]);
const previewLabel = useMemo(() => {
if (!latestChild) return "No messages yet";
if (isParticleDeleted(latestChild)) return "Message deleted";
switch (latestChild.type) {
case "media":
return latestChild.properties.mime_type.startsWith("audio/")
? "Voice message"
: "Video message";
case "text":
return latestChild.properties.content;
case "file":
return latestChild.properties.filename;
case "quest":
return latestChild.properties.title;
case "paper":
return latestChild.properties.title;
default:
return "Update";
}
}, [latestChild]);
return (
<Pressable
onPress={onPress}
android_ripple={{ color: "rgba(0,0,0,0.05)" }}
className={cn(
"bg-card rounded-xl border px-3.5 py-3 flex-row items-center gap-3 active:bg-accent",
isUnseen ? "border-primary" : "border-border",
)}
>
<View
className={cn(
"h-10 w-10 items-center justify-center rounded-full",
isUnseen ? "bg-primary" : "bg-muted",
)}
>
<Text
className={cn(
"text-xs font-semibold",
isUnseen ? "text-primary-foreground" : "text-muted-foreground",
)}
>
{initials}
</Text>
</View>
<View className="flex-1">
<Text
numberOfLines={1}
className={cn(
"text-base",
isUnseen
? "text-foreground font-semibold"
: "text-foreground font-medium",
)}
>
{particle.properties.name}
</Text>
<Text
numberOfLines={1}
className="text-muted-foreground mt-0.5 text-sm"
>
{previewLabel}
</Text>
</View>
<View className="items-end gap-1">
{latestChild ? (
<RelativeTimestamp
date={latestChild.created_at}
className={cn(
"text-xs",
isUnseen ? "text-primary" : "text-muted-foreground",
)}
/>
) : null}
{isUnseen ? (
<View className="bg-primary h-2 w-2 rounded-full" />
) : null}
</View>
</Pressable>
);
});
@@ -1,34 +1,157 @@
import { Pressable, Text, View } from "react-native";
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
Text,
View,
} from "react-native";
import { SafeAreaView } from "react-native-safe-area-context";
import { useNetwork } from "@/hooks/use-networks";
import { toast } from "sonner-native";
import { toUserMessage } from "@/lib/errors";
import { particlePath } from "@/lib/particle-path";
import { useNetwork, useNetworks } from "@/hooks/use-networks";
import { useStreamParticles } from "@/hooks/use-stream-particles";
import type { RootStackScreenProps } from "@/navigation/types";
import { StreamCard } from "./StreamCard";
export function StreamListScreen({
route,
navigation,
}: RootStackScreenProps<"StreamList">) {
const network = useNetwork(route.params.networkId);
const { networkId } = route.params;
const network = useNetwork(networkId);
// Re-fetching the network list is the closest stand-in for a hard refresh —
// Firestore subscriptions are already realtime, so pull-to-refresh mainly
// reassures the user and re-tries network metadata.
const { refetch: refetchNetworks, isRefetching: isRefetchingNetworks } =
useNetworks();
const path = particlePath(networkId, []);
// v1: only show open streams. Closed streams are reachable on desktop —
// an "Archived" surface is a follow-up (PRD §20).
const { streams, isLoading, error } = useStreamParticles(path, {
status: "open",
});
return (
<SafeAreaView className="flex-1 bg-background" edges={["top"]}>
<View className="flex-row items-center px-3 py-3 border-b border-border">
<Pressable onPress={() => navigation.goBack()} className="px-2 py-1">
<Text className="text-foreground text-2xl"></Text>
</Pressable>
<Text
className="flex-1 text-center text-foreground text-base font-semibold"
numberOfLines={1}
>
{network?.name ?? "Streams"}
</Text>
<View className="w-8" />
</View>
<Header
title={network?.name ?? "Streams"}
onBack={() => navigation.goBack()}
/>
<View className="flex-1 items-center justify-center px-6">
<Text className="text-muted-foreground text-center">
Stream list coming in step 3.
</Text>
</View>
{error ? (
<ErrorState message={toUserMessage(error)} />
) : isLoading && streams.length === 0 ? (
<LoadingState />
) : streams.length === 0 ? (
<EmptyState />
) : (
<FlatList
data={streams}
keyExtractor={(s) => s.id}
contentContainerClassName="p-3 gap-2 pb-24"
refreshControl={
<RefreshControl
refreshing={isRefetchingNetworks}
onRefresh={() => refetchNetworks()}
/>
}
renderItem={({ item }) => (
<StreamCard
particle={item}
networkId={networkId}
onPress={() =>
navigation.navigate("StreamView", {
networkId,
streamId: item.id,
})
}
/>
)}
/>
)}
<ComposeFab
onPress={() => {
// Real compose flow lands in step 5.
toast("New stream — coming soon");
}}
/>
</SafeAreaView>
);
}
function Header({
title,
onBack,
}: {
title: string;
onBack: () => void;
}) {
return (
<View className="flex-row items-center px-3 py-3 border-b border-border">
<Pressable
onPress={onBack}
className="px-2 py-1"
accessibilityLabel="Back"
>
<Text className="text-foreground text-2xl"></Text>
</Pressable>
<Text
className="flex-1 text-center text-foreground text-base font-semibold"
numberOfLines={1}
>
{title}
</Text>
<View className="w-8" />
</View>
);
}
function LoadingState() {
return (
<View className="flex-1 items-center justify-center">
<ActivityIndicator />
</View>
);
}
function EmptyState() {
return (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-foreground text-lg font-medium text-center">
No streams yet.
</Text>
<Text className="text-muted-foreground mt-2 text-center">
Tap the button below to start one voice, video, or text.
</Text>
</View>
);
}
function ErrorState({ message }: { message: string }) {
return (
<View className="flex-1 items-center justify-center px-6">
<Text className="text-destructive text-center">{message}</Text>
<Text className="text-muted-foreground mt-2 text-center text-xs">
Streams reconnect automatically once the network is back.
</Text>
</View>
);
}
function ComposeFab({ onPress }: { onPress: () => void }) {
return (
<View className="absolute bottom-6 right-6">
<Pressable
onPress={onPress}
className="bg-primary h-14 w-14 items-center justify-center rounded-full active:opacity-80"
accessibilityLabel="New stream"
>
<Text className="text-primary-foreground text-3xl leading-none">+</Text>
</Pressable>
</View>
);
}
+30
View File
@@ -0,0 +1,30 @@
import { initializeApp } from "firebase/app";
import {
initializeAuth,
// `getReactNativePersistence` is documented Firebase RN setup but Firebase
// intentionally omits it from `firebase/auth`'s public type bundle (it
// would pollute web autocomplete). The runtime export exists on every
// platform; this is the workaround the Firebase docs themselves use.
// @ts-expect-error — RN-only symbol missing from public Firebase types.
getReactNativePersistence,
} from "firebase/auth";
import { initializeFirestore } from "firebase/firestore";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { appConfig } from "@/config/env";
export const firebaseApp = initializeApp(appConfig.firebase);
// AsyncStorage persists the Firebase auth token across cold starts. Without
// it, the user would have to re-sign-in to Firestore every launch even though
// the Orion bearer token is in SecureStore.
export const firebaseAuth = initializeAuth(firebaseApp, {
persistence: getReactNativePersistence(AsyncStorage),
});
// Firestore's default WebChannel transport often fails on cellular networks
// and behind aggressive proxies on iOS. Long-polling is the documented
// remedy for React Native and is also what the Firebase team recommends
// for mobile apps using the JS SDK.
export const firestoreDb = initializeFirestore(firebaseApp, {
experimentalAutoDetectLongPolling: true,
});
+186
View File
@@ -0,0 +1,186 @@
import { useState, useEffect } from "react";
import { useQuery } from "@tanstack/react-query";
import type { QueryFieldFilterConstraint } from "firebase/firestore";
import {
subscribeToParticle,
subscribeToParticleChildren,
subscribeToLatestChild,
getParticle,
getParticleChildren,
} from "@/lib/firestore-particles";
import type { Particle } from "@/api/types";
import {
type ParticlePath,
toFirestoreDocPath,
toFirestoreChildrenPath,
} from "@/lib/particle-path";
import { logError } from "@/lib/errors";
interface UseLiveParticleResult {
particle: Particle | null;
isLoading: boolean;
error: Error | null;
}
export function useLiveParticle(path: ParticlePath): UseLiveParticleResult {
const [particle, setParticle] = useState<Particle | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setIsLoading(true);
setError(null);
setParticle(null);
const docPath = toFirestoreDocPath(path);
const unsubscribe = subscribeToParticle(
docPath,
(data) => {
setParticle(data);
setIsLoading(false);
},
(err) => {
setError(err);
setIsLoading(false);
},
);
return unsubscribe;
}, [path]);
return { particle, isLoading, error };
}
interface UseLiveParticleChildrenResult {
children: Particle[];
isLoading: boolean;
error: Error | null;
}
interface UseLiveParticleChildrenParams {
orderByField?: string;
orderDirection?: "asc" | "desc";
visibilityScopes?: string[];
onAdded?: (child: Particle) => void;
onRemoved?: (child: Particle, updatedChildren: Particle[]) => void;
whereFilter?: QueryFieldFilterConstraint;
/** Optional cap on results. Changes trigger a re-subscription. */
limit?: number;
}
export function useLiveParticleChildren(
path: ParticlePath | undefined,
{
orderByField = "created_at",
orderDirection = "desc",
visibilityScopes,
onAdded,
onRemoved,
whereFilter,
limit,
}: UseLiveParticleChildrenParams = {},
): UseLiveParticleChildrenResult {
const [children, setChildren] = useState<Particle[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
if (!path) {
setChildren([]);
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
setChildren([]);
const collectionPath = toFirestoreChildrenPath(path);
const unsubscribe = subscribeToParticleChildren(collectionPath, {
onData: (data) => {
setChildren(data);
setIsLoading(false);
},
onError: (err) => {
logError(err, { scope: "firestore.particle-children", path });
setError(err);
setIsLoading(false);
},
visibilityScopes,
orderByField,
orderDirection,
onAdded,
onRemoved,
whereFilter,
limit,
});
return unsubscribe;
// The hook intentionally keys only on path/whereFilter/limit — desktop
// does the same. Visibility scope changes are absorbed by the active
// listener; reordering causes a re-subscription.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [path, whereFilter, limit]);
return { children, isLoading, error };
}
interface UseLiveLatestChildResult {
latestChild: Particle | null;
isLoading: boolean;
}
export function useLiveLatestChild(
path: ParticlePath,
): UseLiveLatestChildResult {
const [latestChild, setLatestChild] = useState<Particle | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
setLatestChild(null);
const unsubscribe = subscribeToLatestChild(
toFirestoreChildrenPath(path),
(data) => {
setLatestChild(data);
setIsLoading(false);
},
(err) => {
logError(err, { scope: "firestore.latest-child", path });
setIsLoading(false);
},
);
return unsubscribe;
}, [path]);
return { latestChild, isLoading };
}
export function useParticle(path?: ParticlePath) {
return useQuery({
queryKey: ["particle", path],
queryFn: async () => {
if (!path) return null;
const docPath = toFirestoreDocPath(path);
const particle = await getParticle(docPath);
return particle;
},
enabled: !!path,
});
}
export function useParticleChildren(path?: ParticlePath) {
return useQuery({
queryKey: ["particle-children", path],
queryFn: async () => {
if (!path) return [];
const collectionPath = toFirestoreChildrenPath(path);
return getParticleChildren(collectionPath);
},
enabled: !!path,
staleTime: 1000 * 60 * 5, // 5 min — attachments don't change
});
}
@@ -0,0 +1,95 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { where, type QueryFieldFilterConstraint } from "firebase/firestore";
import { useLiveParticleChildren } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
import type { Particle, StreamProperties } from "@/api/types";
export type StreamParticle = Particle & {
type: "stream";
properties: StreamProperties;
};
const CLOSED_INITIAL_PAGE_SIZE = 50;
const CLOSED_PAGE_INCREMENT = 50;
// Stable where-constraint references so the Firestore subscription only
// re-attaches when the tab actually changes, not on every render.
const OPEN_STATUS_FILTER = where("status", "==", "open");
const CLOSED_STATUS_FILTER = where("status", "==", "closed");
function useVisibilityScopes(userId?: string, networkId?: string) {
return useMemo(() => {
const scopes: string[] = [];
if (userId) scopes.push(`human:${userId}`);
if (networkId) scopes.push(`network:${networkId}`);
return scopes;
}, [userId, networkId]);
}
interface UseStreamParticlesOptions {
/**
* Which streams to subscribe to. Open streams are loaded in full (bounded
* by active work — full realtime coverage is needed for autoplay/huddles).
* Closed streams are paginated via `loadMore`.
*/
status: "open" | "closed";
}
interface UseStreamParticlesResult {
streams: StreamParticle[];
isLoading: boolean;
error: Error | null;
networkId: string;
/** True when more closed streams may exist beyond the current window. */
canLoadMore: boolean;
/** Extend the pagination window. No-op on the open tab. */
loadMore: () => void;
}
export function useStreamParticles(
path: ParticlePath,
{ status }: UseStreamParticlesOptions,
): UseStreamParticlesResult {
const { networkId } = parseParticlePath(path);
const user = useAuthStore((s) => s.user);
const visibilityScopes = useVisibilityScopes(user?.id, networkId);
const [closedLimit, setClosedLimit] = useState(CLOSED_INITIAL_PAGE_SIZE);
// Every time the user switches back to the closed tab, start with a fresh
// window. Avoids an ever-growing subscription across a long session.
useEffect(() => {
if (status === "closed") {
setClosedLimit(CLOSED_INITIAL_PAGE_SIZE);
}
}, [status]);
const whereFilter: QueryFieldFilterConstraint =
status === "open" ? OPEN_STATUS_FILTER : CLOSED_STATUS_FILTER;
const limit = status === "closed" ? closedLimit : undefined;
const { children, isLoading, error } = useLiveParticleChildren(path, {
orderByField: "last_child_created_at",
orderDirection: "desc",
visibilityScopes,
whereFilter,
limit,
});
const streams = useMemo(
() => children.filter((c): c is StreamParticle => c.type === "stream"),
[children],
);
// Heuristic: if we got back as many items as we asked for, assume there
// might be more. Clicking load-more when there are no more is a no-op.
const canLoadMore = status === "closed" && streams.length >= closedLimit;
const loadMore = useCallback(() => {
if (status !== "closed") return;
setClosedLimit((prev) => prev + CLOSED_PAGE_INCREMENT);
}, [status]);
return { streams, isLoading, error, networkId, canLoadMore, loadMore };
}
+435
View File
@@ -0,0 +1,435 @@
import {
collection,
doc,
onSnapshot,
addDoc,
getDoc,
getDocs,
updateDoc,
query,
orderBy,
limit,
serverTimestamp,
where,
Timestamp,
arrayUnion,
arrayRemove,
type DocumentData,
type FirestoreDataConverter,
type QueryDocumentSnapshot,
type SnapshotOptions,
type Unsubscribe,
type QueryFieldFilterConstraint,
} from "firebase/firestore";
import { firestoreDb } from "@/firebase";
import { isContainerType, ParticleSchema } from "@/api/types";
import type {
Particle,
ParticleType,
ParticlePropertiesMap,
Reactions,
} from "@/api/types";
// --- Converter ---
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,
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,
status: raw.status ?? 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,
});
case "media":
case "file":
case "text":
case "quest":
case "paper": {
// Firestore stores timestamps as `Timestamp`; zod expects `Date`. Text
// particles carry `properties.edited_at`, so coerce it if present.
const properties =
type === "text" && raw.properties?.edited_at
? {
...raw.properties,
edited_at: (raw.properties.edited_at as Timestamp).toDate(),
}
: 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:
throw new Error(`Unknown particle type: ${type}`);
}
},
};
// --- 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 async function getParticle(docPath: string): Promise<Particle | null> {
const docSnap = await getDoc(typedDoc(docPath));
if (!docSnap.exists()) {
return null;
}
return docSnap.data();
}
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.map((d) => d.data());
}
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.map((d) => d.data());
onData(updatedChildren);
if (onAdded || onRemoved) {
for (const change of snap.docChanges()) {
if (change.type === "added" && onAdded) onAdded(change.doc.data());
if (change.type === "removed" && onRemoved)
onRemoved(change.doc.data(), 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 : 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],
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 particle: Particle = ParticleSchema.parse({
id: "", // ignored by toFirestore, but needed to satisfy the type
type,
properties,
created_at: new Date(),
created_by_human_id: createdByHumanId,
...(visibleTo ? { visible_to: visibleTo } : {}),
});
const ref = await addDoc(typedCollection(collectionPath), particle);
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 particle: Particle = ParticleSchema.parse({
id: "",
type: "stream",
properties,
created_at: new Date(),
created_by_human_id: createdByHumanId,
visible_to: visibleTo,
status: "open",
});
const ref = await addDoc(typedCollection(collectionPath), particle);
return ref.id;
}
// 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, unknown> = {};
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: unknown,
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, {
[fieldName]: value,
updated_at: serverTimestamp(),
});
}
export async function updateStreamStatus(
docPath: string,
status: "open" | "closed",
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, { status, 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(),
});
}
export async function toggleParticleReaction(
docPath: string,
emoji: string,
humanId: string,
currentReactions?: Reactions,
): Promise<void> {
const particleRef = typedDoc(docPath);
const field = `reactions.${emoji}`;
const alreadyReacted = currentReactions?.[emoji]?.includes(humanId) ?? false;
await updateDoc(particleRef, {
[field]: alreadyReacted ? arrayRemove(humanId) : arrayUnion(humanId),
updated_at: serverTimestamp(),
});
}
+43
View File
@@ -0,0 +1,43 @@
import type { Human } from "@/api/types";
import { getInitials } from "@/lib/utils";
export const REMOVED_MEMBER_LABEL = "Removed member";
export const REMOVED_MEMBER_INITIALS = "";
export interface HumanDisplay {
/** True when the human was found in the provided list. */
exists: boolean;
/** Short name for inline text (e.g. message sender). */
displayName: string;
/** Full email or fallback label for tooltips. */
email: string;
/** Initials for avatar fallback. */
initials: string;
}
/**
* Resolve a human's display info by id, falling back consistently when the
* human has been removed from the network. Member content (particles, reactions,
* etc.) is retained after removal, so every render path needs a graceful fallback
* instead of leaking raw ids into the UI.
*/
export function resolveHumanDisplay(
humanId: string | null | undefined,
humans: Human[] | undefined,
): HumanDisplay {
const human = humanId ? humans?.find((h) => h.id === humanId) : undefined;
if (!human) {
return {
exists: false,
displayName: REMOVED_MEMBER_LABEL,
email: REMOVED_MEMBER_LABEL,
initials: REMOVED_MEMBER_INITIALS,
};
}
return {
exists: true,
displayName: human.email_prefix,
email: human.email,
initials: getInitials(human.email),
};
}
+70
View File
@@ -0,0 +1,70 @@
/**
* 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/children (collection)
* /net1/p1 → networks/net1/children/p1 (document)
* /net1/p1/p2 → networks/net1/children/p1/children/p2 (document)
*/
export function toFirestoreDocPath(path: ParticlePath): string {
const { networkId, segments } = parseParticlePath(path);
const base = `networks/${networkId}/children`;
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/children (root particles)
* /net1/p1 → networks/net1/children/p1/children
* /net1/p1/p2 → networks/net1/children/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`;
}
+20
View File
@@ -0,0 +1,20 @@
const MINUTE = 60;
const HOUR = 3600;
const DAY = 86400;
const WEEK = 604800;
const MONTH = 2592000;
const YEAR = 31536000;
export function formatDistanceToNow(date: Date | string): string {
const ms = typeof date === "string" ? new Date(date).getTime() : date.getTime();
const seconds = Math.floor((Date.now() - ms) / 1000);
if (seconds < 5) return "just now";
if (seconds < MINUTE) return `${seconds}s ago`;
if (seconds < HOUR) return `${Math.floor(seconds / MINUTE)}m ago`;
if (seconds < DAY) return `${Math.floor(seconds / HOUR)}h ago`;
if (seconds < WEEK) return `${Math.floor(seconds / DAY)}d ago`;
if (seconds < MONTH) return `${Math.floor(seconds / WEEK)}w ago`;
if (seconds < YEAR) return `${Math.floor(seconds / MONTH)}mo ago`;
return `${Math.floor(seconds / YEAR)}y ago`;
}
+15
View File
@@ -0,0 +1,15 @@
import { clsx, type ClassValue } from "clsx";
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();
}
export function removeDuplicates<T>(array: T[]): T[] {
return [...new Set(array)];
}
+16 -5
View File
@@ -1,15 +1,23 @@
import { create } from "zustand";
import {
signInWithCustomToken,
signOut as firebaseSignOut,
} from "firebase/auth";
import { apiClient } from "@/api/client";
import type { Human } from "@/api/types";
import { firebaseAuth } from "@/firebase";
import { logError, ApiError } from "@/lib/errors";
import { hydrateSession, useSessionStore } from "./session-store";
// TODO(step-3): once @react-native-firebase/auth lands, mirror desktop's
// signInToFirebase() — fetch /auth/firebase-token then signInWithCustomToken.
// Until then, Firestore reads will fail with permission errors; mobile only
// uses Orion REST until that wiring exists.
async function signInToFirebase(): Promise<void> {
return;
try {
const { token } = await apiClient.getFirebaseToken();
await signInWithCustomToken(firebaseAuth, token);
} catch (err) {
// Firestore subscriptions will fail until the next successful sign-in; the
// rest of the app keeps working against Orion. Sentry catches the failure.
logError(err, { scope: "auth.firebase" });
}
}
type AuthStatus = "idle" | "restoring" | "unauthenticated" | "authenticated";
@@ -98,6 +106,9 @@ export const useAuthStore = create<AuthState>((set) => ({
// Best-effort — sign out locally regardless.
logError(err, { scope: "auth.signOut" });
} finally {
await firebaseSignOut(firebaseAuth).catch((err) =>
logError(err, { scope: "auth.firebaseSignOut" }),
);
await useSessionStore.getState().clearToken();
set({
status: "unauthenticated",