diff --git a/js/mobile/package.json b/js/mobile/package.json index 9a5d8ad..0513ca3 100644 --- a/js/mobile/package.json +++ b/js/mobile/package.json @@ -16,10 +16,12 @@ "@react-navigation/native": "^7.0.14", "@react-navigation/native-stack": "^7.2.0", "@tanstack/react-query": "^5.90.21", + "clsx": "^2.1.1", "expo": "~54.0.0", "expo-constants": "~18.0.13", "expo-secure-store": "~15.0.8", "expo-status-bar": "~3.0.9", + "firebase": "^12.10.0", "nativewind": "^4.1.23", "react": "19.1.0", "react-native": "0.81.5", @@ -30,6 +32,7 @@ "react-native-svg": "15.12.1", "react-native-worklets": "0.5.1", "sonner-native": "^0.21.0", + "tailwind-merge": "^3.5.0", "zod": "^4.3.6", "zustand": "^5.0.11" }, diff --git a/js/mobile/src/components/RelativeTimestamp.tsx b/js/mobile/src/components/RelativeTimestamp.tsx new file mode 100644 index 0000000..53d60f6 --- /dev/null +++ b/js/mobile/src/components/RelativeTimestamp.tsx @@ -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 { + 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 {formatDistanceToNow(date)}; +} diff --git a/js/mobile/src/features/streams/StreamCard.tsx b/js/mobile/src/features/streams/StreamCard.tsx new file mode 100644 index 0000000..09a0330 --- /dev/null +++ b/js/mobile/src/features/streams/StreamCard.tsx @@ -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 ( + + + + {initials} + + + + + + {particle.properties.name} + + + {previewLabel} + + + + + {latestChild ? ( + + ) : null} + {isUnseen ? ( + + ) : null} + + + ); +}); diff --git a/js/mobile/src/features/streams/StreamListScreen.tsx b/js/mobile/src/features/streams/StreamListScreen.tsx index 3bde6e9..5c1a534 100644 --- a/js/mobile/src/features/streams/StreamListScreen.tsx +++ b/js/mobile/src/features/streams/StreamListScreen.tsx @@ -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 ( - - navigation.goBack()} className="px-2 py-1"> - - - - {network?.name ?? "Streams"} - - - +
navigation.goBack()} + /> - - - Stream list — coming in step 3. - - + {error ? ( + + ) : isLoading && streams.length === 0 ? ( + + ) : streams.length === 0 ? ( + + ) : ( + s.id} + contentContainerClassName="p-3 gap-2 pb-24" + refreshControl={ + refetchNetworks()} + /> + } + renderItem={({ item }) => ( + + navigation.navigate("StreamView", { + networkId, + streamId: item.id, + }) + } + /> + )} + /> + )} + + { + // Real compose flow lands in step 5. + toast("New stream — coming soon"); + }} + /> ); } + +function Header({ + title, + onBack, +}: { + title: string; + onBack: () => void; +}) { + return ( + + + + + + {title} + + + + ); +} + +function LoadingState() { + return ( + + + + ); +} + +function EmptyState() { + return ( + + + No streams yet. + + + Tap the button below to start one — voice, video, or text. + + + ); +} + +function ErrorState({ message }: { message: string }) { + return ( + + {message} + + Streams reconnect automatically once the network is back. + + + ); +} + +function ComposeFab({ onPress }: { onPress: () => void }) { + return ( + + + + + + + ); +} diff --git a/js/mobile/src/firebase.ts b/js/mobile/src/firebase.ts new file mode 100644 index 0000000..a5bdba1 --- /dev/null +++ b/js/mobile/src/firebase.ts @@ -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, +}); diff --git a/js/mobile/src/hooks/use-particle.ts b/js/mobile/src/hooks/use-particle.ts new file mode 100644 index 0000000..f66c91d --- /dev/null +++ b/js/mobile/src/hooks/use-particle.ts @@ -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(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(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(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 + }); +} diff --git a/js/mobile/src/hooks/use-stream-particles.ts b/js/mobile/src/hooks/use-stream-particles.ts new file mode 100644 index 0000000..7085197 --- /dev/null +++ b/js/mobile/src/hooks/use-stream-particles.ts @@ -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 }; +} diff --git a/js/mobile/src/lib/firestore-particles.ts b/js/mobile/src/lib/firestore-particles.ts new file mode 100644 index 0000000..ce1d19e --- /dev/null +++ b/js/mobile/src/lib/firestore-particles.ts @@ -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 = { + 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 { + 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 { + 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( + collectionPath: string, + type: T, + properties: ParticlePropertiesMap[T], + createdByHumanId: string, + // Must be passed for container types + visibleTo?: string[], +): Promise { + 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 { + 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( + docPath: string, + properties: Partial, +): Promise { + 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 = {}; + 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 { + 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 { + 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 { + const particleRef = typedDoc(docPath); + await updateDoc(particleRef, { + [fieldName]: value, + updated_at: serverTimestamp(), + }); +} + +export async function updateStreamStatus( + docPath: string, + status: "open" | "closed", +): Promise { + 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 { + 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 { + 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 { + 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(), + }); +} diff --git a/js/mobile/src/lib/humans.ts b/js/mobile/src/lib/humans.ts new file mode 100644 index 0000000..543fdb6 --- /dev/null +++ b/js/mobile/src/lib/humans.ts @@ -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), + }; +} diff --git a/js/mobile/src/lib/particle-path.ts b/js/mobile/src/lib/particle-path.ts new file mode 100644 index 0000000..905e1fd --- /dev/null +++ b/js/mobile/src/lib/particle-path.ts @@ -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`; +} diff --git a/js/mobile/src/lib/time-utils.ts b/js/mobile/src/lib/time-utils.ts new file mode 100644 index 0000000..a4fd4a9 --- /dev/null +++ b/js/mobile/src/lib/time-utils.ts @@ -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`; +} diff --git a/js/mobile/src/lib/utils.ts b/js/mobile/src/lib/utils.ts new file mode 100644 index 0000000..b3229a2 --- /dev/null +++ b/js/mobile/src/lib/utils.ts @@ -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(array: T[]): T[] { + return [...new Set(array)]; +} diff --git a/js/mobile/src/stores/auth-store.ts b/js/mobile/src/stores/auth-store.ts index 6ede96d..c291498 100644 --- a/js/mobile/src/stores/auth-store.ts +++ b/js/mobile/src/stores/auth-store.ts @@ -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 { - 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((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", diff --git a/js/mobile/yarn.lock b/js/mobile/yarn.lock index 47f3ae3..28d46a0 100644 --- a/js/mobile/yarn.lock +++ b/js/mobile/yarn.lock @@ -1115,6 +1115,417 @@ chalk "^4.1.0" js-yaml "^4.1.0" +"@firebase/ai@2.11.1": + version "2.11.1" + resolved "https://registry.yarnpkg.com/@firebase/ai/-/ai-2.11.1.tgz#a78c8d8a8acc5261fb2e0fa0216209b43a57c6dc" + integrity sha512-WGTF81W3WBKJY+c7xqTzO15OGAkCAs8cpADqflAI0skhTZjIkhF0qyf55rq4Ctt6jKygkv99rPfMrjAHTgXaVQ== + dependencies: + "@firebase/app-check-interop-types" "0.3.3" + "@firebase/component" "0.7.2" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/analytics-compat@0.2.27": + version "0.2.27" + resolved "https://registry.yarnpkg.com/@firebase/analytics-compat/-/analytics-compat-0.2.27.tgz#58ef74a91930267923577f07ca371182d9f7ce2e" + integrity sha512-ZObpYpAxL6JfgH7GnvlDD0sbzGZ0o4nijV8skatV9ZX49hJtCYbFqaEcPYptT94rgX1KUoKEderC7/fa7hybtw== + dependencies: + "@firebase/analytics" "0.10.21" + "@firebase/analytics-types" "0.8.3" + "@firebase/component" "0.7.2" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/analytics-types@0.8.3": + version "0.8.3" + resolved "https://registry.yarnpkg.com/@firebase/analytics-types/-/analytics-types-0.8.3.tgz#d08cd39a6209693ca2039ba7a81570dfa6c1518f" + integrity sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg== + +"@firebase/analytics@0.10.21": + version "0.10.21" + resolved "https://registry.yarnpkg.com/@firebase/analytics/-/analytics-0.10.21.tgz#109d95d287acefe3d8276835291dbbcf4688c18c" + integrity sha512-j2y2q65BlgLGB5Pwjhv/Jopw2X/TBTzvAtI5z/DSp56U4wBj7LfhBfzbdCtFPges+Wz0g55GdoawXibOH5jGng== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/installations" "0.6.21" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/app-check-compat@0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@firebase/app-check-compat/-/app-check-compat-0.4.2.tgz#c0b3808ebe9a366d3b2cba295eb7a1587de66314" + integrity sha512-M91NhxqbSkI0ChkJWy69blC+rPr6HEgaeRllddSaU1pQ/7IiegeCQM9pPDIgvWnwnBSzKhUHpe6ro/jhJ+cvzw== + dependencies: + "@firebase/app-check" "0.11.2" + "@firebase/app-check-types" "0.5.3" + "@firebase/component" "0.7.2" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/app-check-interop-types@0.3.3": + version "0.3.3" + resolved "https://registry.yarnpkg.com/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz#ed9c4a4f48d1395ef378f007476db3940aa5351a" + integrity sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A== + +"@firebase/app-check-types@0.5.3": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@firebase/app-check-types/-/app-check-types-0.5.3.tgz#38ba954acf4bffe451581a32fffa20337f11d8e5" + integrity sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng== + +"@firebase/app-check@0.11.2": + version "0.11.2" + resolved "https://registry.yarnpkg.com/@firebase/app-check/-/app-check-0.11.2.tgz#c7f771c5222d77a810978081e7b493d3f5e8968f" + integrity sha512-jcXQVMHAQ5AEKzVD5C7s5fmAYeFOuN6lAJeNTgZK2B9aLnofWaJt8u1A8Idm8gpsBBYSaY3cVyeH5SWMOVPBLQ== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/app-compat@0.5.11": + version "0.5.11" + resolved "https://registry.yarnpkg.com/@firebase/app-compat/-/app-compat-0.5.11.tgz#4cb54f447cb03446465a05d00a71509b5f2ec620" + integrity sha512-KaACDjXkK5VLpI01vEs592R7/8s5DjFdIXfKoR385ly1SmK3Tu+jMHCIB4MsiY5jsez6v7VlEX/3rJ90dVkHyA== + dependencies: + "@firebase/app" "0.14.11" + "@firebase/component" "0.7.2" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/app-types@0.9.4": + version "0.9.4" + resolved "https://registry.yarnpkg.com/@firebase/app-types/-/app-types-0.9.4.tgz#e85864332b591db95a10620668405bef6906947b" + integrity sha512-crX9TA5SVYZwLPG7/R16IsH8FLlgkPXjJUVhsVpHVDSqJiq3D/NuFTM5ctxGTExXAOeIn//69tQw47CPerM8MQ== + dependencies: + "@firebase/logger" "0.5.0" + +"@firebase/app@0.14.11": + version "0.14.11" + resolved "https://registry.yarnpkg.com/@firebase/app/-/app-0.14.11.tgz#150f5f98299c24569fab8fbbffb7295075f526d7" + integrity sha512-yxADFW35LYkP8oSGobGsYIrI42I+GPCvKTNHx4meT9Yq3C950IVz1eANoBk822I9tbKv1wyv9P4Bv1G5TpucFw== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.15.0" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/auth-compat@0.6.5": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@firebase/auth-compat/-/auth-compat-0.6.5.tgz#d12d27a4c2230d3ee32ef5c200ebd3b9108528ca" + integrity sha512-IfVsafZ3QiXbsydXTP/XMI0wVYbJLI1rkb8Qqf03/h5FnL+upbbPOb+6Yj3RpcX+Y1iP5Uh18lxTHlXfbiyAow== + dependencies: + "@firebase/auth" "1.13.0" + "@firebase/auth-types" "0.13.0" + "@firebase/component" "0.7.2" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/auth-interop-types@0.2.4": + version "0.2.4" + resolved "https://registry.yarnpkg.com/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz#176a08686b0685596ff03d7879b7e4115af53de0" + integrity sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA== + +"@firebase/auth-types@0.13.0": + version "0.13.0" + resolved "https://registry.yarnpkg.com/@firebase/auth-types/-/auth-types-0.13.0.tgz#ae6e0015e3bd4bfe18edd0942b48a0a118a098d9" + integrity sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg== + +"@firebase/auth@1.13.0": + version "1.13.0" + resolved "https://registry.yarnpkg.com/@firebase/auth/-/auth-1.13.0.tgz#da83853b465c1ab4b638e542d78df6b3c4855a15" + integrity sha512-mKkSLNym3UbnnZ06dAmtqzp5EpPGCANGCZDJbkoR135aoUdKG6Aizwcnp29RzsQpwH0nmy5nay17Sfbsh9oY8A== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/component@0.7.2": + version "0.7.2" + resolved "https://registry.yarnpkg.com/@firebase/component/-/component-0.7.2.tgz#82a87848ad389d6037019745c973e4f2779a6983" + integrity sha512-iyVDGc6Vjx7Rm0cAdccLH/NG6fADsgJak/XW9IA2lPf8AjIlsemOpFGKczYyPHxm4rnKdR8z6sK4+KEC7NwmEg== + dependencies: + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/data-connect@0.6.0": + version "0.6.0" + resolved "https://registry.yarnpkg.com/@firebase/data-connect/-/data-connect-0.6.0.tgz#c4581d13685eccac724895b8c7292df4a24cd334" + integrity sha512-OiugPRcdlhqXF97oR9CjVObILmsWU0dFUS0gXNYEe4bDfpW8pZmQ5GqhIPPtLWbT/0W2lMJJD7VILFMk+xuHPg== + dependencies: + "@firebase/auth-interop-types" "0.2.4" + "@firebase/component" "0.7.2" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/database-compat@2.1.3": + version "2.1.3" + resolved "https://registry.yarnpkg.com/@firebase/database-compat/-/database-compat-2.1.3.tgz#19a512209afbba9710d7febc52cd875e1b239e3c" + integrity sha512-GMyfWjD8mehjg/QpNkY/tl9G/MoeugPeg91n9D0atggxbWuKF/2KhVPHZDH+XmoP0EKYqMWYTtKxBsaBaNKLYQ== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/database" "1.1.2" + "@firebase/database-types" "1.0.19" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/database-types@1.0.19": + version "1.0.19" + resolved "https://registry.yarnpkg.com/@firebase/database-types/-/database-types-1.0.19.tgz#0e59454ea764aa2617fb2a8ea94104c4cb1605ac" + integrity sha512-FqewjUZmV9LqFfuEnmgdcUpiOUz7qwLXxnm/H8BcMFEzQXtd1yyUDm8ex5VRad2nuTE+ahOuCjUAM/cyDncO+g== + dependencies: + "@firebase/app-types" "0.9.4" + "@firebase/util" "1.15.0" + +"@firebase/database@1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@firebase/database/-/database-1.1.2.tgz#cd99d7f205d7eee121e2c327bf89b42c3afa3776" + integrity sha512-lP96CMjMPy/+d1d9qaaHjHHdzdwvEOuyyLq9ehX89e2XMKwS1jHNzYBO+42bdSumuj5ukPbmnFtViZu8YOMT+w== + dependencies: + "@firebase/app-check-interop-types" "0.3.3" + "@firebase/auth-interop-types" "0.2.4" + "@firebase/component" "0.7.2" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.15.0" + faye-websocket "0.11.4" + tslib "^2.1.0" + +"@firebase/firestore-compat@0.4.8": + version "0.4.8" + resolved "https://registry.yarnpkg.com/@firebase/firestore-compat/-/firestore-compat-0.4.8.tgz#00651c9d01f940d9906b34ae2cc4229527f4b7f8" + integrity sha512-WK9NJRpnosGD2nuyjdr7K+Ht7AxRYJlTF62myI4rRA7ibJOosbecvjacR5oirJ7s1BgNS6qzcBw7n4fD3a5w1w== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/firestore" "4.14.0" + "@firebase/firestore-types" "3.0.3" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/firestore-types@3.0.3": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@firebase/firestore-types/-/firestore-types-3.0.3.tgz#7d0c3dd8850c0193d8f5ee0cc8f11961407742c1" + integrity sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q== + +"@firebase/firestore@4.14.0": + version "4.14.0" + resolved "https://registry.yarnpkg.com/@firebase/firestore/-/firestore-4.14.0.tgz#a17801fe2e8a0095fc655d38cd791c4a48058cb4" + integrity sha512-bZc6YOjRkMBVA16527tgzi6iN9n//xRB3Mmx/R+Gr6UAP/+xrIKOejQIcn1hh+tCzNT8jO0jI+kWox5J4tB/qQ== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.15.0" + "@firebase/webchannel-wrapper" "1.0.5" + "@grpc/grpc-js" "~1.9.0" + "@grpc/proto-loader" "^0.7.8" + tslib "^2.1.0" + +"@firebase/functions-compat@0.4.3": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@firebase/functions-compat/-/functions-compat-0.4.3.tgz#bb7e5b8330143db594466c1140267799cf41680d" + integrity sha512-BxkEwWgx1of0tKaao/r2VR6WBLk/RAiyztatiONPrPE8gkitFkOnOCxf8i9cUyA5hX5RGt5H30uNn25Q6QNEmQ== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/functions" "0.13.3" + "@firebase/functions-types" "0.6.3" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/functions-types@0.6.3": + version "0.6.3" + resolved "https://registry.yarnpkg.com/@firebase/functions-types/-/functions-types-0.6.3.tgz#f5faf770248b13f45d256f614230da6a11bfb654" + integrity sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg== + +"@firebase/functions@0.13.3": + version "0.13.3" + resolved "https://registry.yarnpkg.com/@firebase/functions/-/functions-0.13.3.tgz#e923cb6f6763531cbe909253155920abb4105f04" + integrity sha512-csO7ckK3SSs+NUZW1nms9EK7ckHe/1QOjiP8uAkCYa7ND18s44vjE9g3KxEeIUpyEPqZaX1EhJuFyZjHigAcYw== + dependencies: + "@firebase/app-check-interop-types" "0.3.3" + "@firebase/auth-interop-types" "0.2.4" + "@firebase/component" "0.7.2" + "@firebase/messaging-interop-types" "0.2.3" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/installations-compat@0.2.21": + version "0.2.21" + resolved "https://registry.yarnpkg.com/@firebase/installations-compat/-/installations-compat-0.2.21.tgz#c00e1e1b3957aff0957cff80a776109895328c54" + integrity sha512-zahIUkaVKbR8zmTeBHkdfaVl6JGWlhVoSjF7CVH33nFqD3SlPEpEEegn2GNT5iAfsVdtlCyJJ9GW4YKjq+RJKQ== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/installations" "0.6.21" + "@firebase/installations-types" "0.5.3" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/installations-types@0.5.3": + version "0.5.3" + resolved "https://registry.yarnpkg.com/@firebase/installations-types/-/installations-types-0.5.3.tgz#cac8a14dd49f09174da9df8ae453f9b359c3ef2f" + integrity sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA== + +"@firebase/installations@0.6.21": + version "0.6.21" + resolved "https://registry.yarnpkg.com/@firebase/installations/-/installations-0.6.21.tgz#38c9a5487c7ccc7dd4328736556afad8eebf453e" + integrity sha512-xGFGTeICJZ5vhrmmDukeczIcFULFXybojML2+QSDFoKj5A7zbGN7KzFGSKNhDkIxpjzsYG9IleJyUebuAcmqWA== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/util" "1.15.0" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/logger@0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@firebase/logger/-/logger-0.5.0.tgz#a9e55b1c669a0983dc67127fa4a5964ce8ed5e1b" + integrity sha512-cGskaAvkrnh42b3BA3doDWeBmuHFO/Mx5A83rbRDYakPjO9bJtRL3dX7javzc2Rr/JHZf4HlterTW2lUkfeN4g== + dependencies: + tslib "^2.1.0" + +"@firebase/messaging-compat@0.2.25": + version "0.2.25" + resolved "https://registry.yarnpkg.com/@firebase/messaging-compat/-/messaging-compat-0.2.25.tgz#1fd6f317d303dfab57ec51409eb41c89080f2dd6" + integrity sha512-eoOQqGLtRlseTdiemTN44LlHZpltK5gnhq8XVUuLgtIOG+odtDzrz2UoTpcJWSzaJQVxNLb/x9f39tHdDM4N4w== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/messaging" "0.12.25" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/messaging-interop-types@0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz#e647c9cd1beecfe6a6e82018a6eec37555e4da3e" + integrity sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q== + +"@firebase/messaging@0.12.25": + version "0.12.25" + resolved "https://registry.yarnpkg.com/@firebase/messaging/-/messaging-0.12.25.tgz#465135006a5d728efaeea1d35790f0e6e20a3e54" + integrity sha512-7RhDwoDHlOK1/ou0/LeubxmjcngsTjDdrY/ssg2vwAVpUuVAhQzQvuCAOYxcX5wNC1zCgQ54AP1vdngBwbCmOQ== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/installations" "0.6.21" + "@firebase/messaging-interop-types" "0.2.3" + "@firebase/util" "1.15.0" + idb "7.1.1" + tslib "^2.1.0" + +"@firebase/performance-compat@0.2.24": + version "0.2.24" + resolved "https://registry.yarnpkg.com/@firebase/performance-compat/-/performance-compat-0.2.24.tgz#1c970640119a8839f7447f624de365f150f21399" + integrity sha512-YRlejH8wLt7ThWao+HXoKUHUrZKGYq+otxkPS+8nuE5PeN1cBXX7NAJl9ueuUkBwMIrnKdnDqL/voHXxDAAt3g== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/logger" "0.5.0" + "@firebase/performance" "0.7.11" + "@firebase/performance-types" "0.2.3" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/performance-types@0.2.3": + version "0.2.3" + resolved "https://registry.yarnpkg.com/@firebase/performance-types/-/performance-types-0.2.3.tgz#5ce64e90fa20ab5561f8b62a305010cf9fab86fb" + integrity sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ== + +"@firebase/performance@0.7.11": + version "0.7.11" + resolved "https://registry.yarnpkg.com/@firebase/performance/-/performance-0.7.11.tgz#238a805f78c5411f1d41c4cdba7854ca4115a1b7" + integrity sha512-V3uAhrz7IYJuji+OgT3qYTGKxpek/TViXti9OSsUJ4AexZ3jQjYH5Yrn7JvBxk8MGiSLsC872hh+BxQiPZsm7g== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/installations" "0.6.21" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + web-vitals "^4.2.4" + +"@firebase/remote-config-compat@0.2.23": + version "0.2.23" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-compat/-/remote-config-compat-0.2.23.tgz#3614d83c1f22c68152793bbbf6965715e1716d07" + integrity sha512-4+KqRRHEUUmKT6tFmnpWATOsaFfmSuBs1jXH8JzVtMLEYqq/WS9IDM92OdefFDSrAA2xGd0WN004z8mKeIIscw== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/logger" "0.5.0" + "@firebase/remote-config" "0.8.2" + "@firebase/remote-config-types" "0.5.0" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/remote-config-types@0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@firebase/remote-config-types/-/remote-config-types-0.5.0.tgz#f0f503b32edda3384f5252f9900cd9613adbb99c" + integrity sha512-vI3bqLoF14L/GchtgayMiFpZJF+Ao3uR8WCde0XpYNkSokDpAKca2DxvcfeZv7lZUqkUwQPL2wD83d3vQ4vvrg== + +"@firebase/remote-config@0.8.2": + version "0.8.2" + resolved "https://registry.yarnpkg.com/@firebase/remote-config/-/remote-config-0.8.2.tgz#389d2b01d4d877c6cb13bf85692dfda45d61fe6d" + integrity sha512-5EXqOThV4upjK9D38d/qOSVwOqRhemlaOFk9vCkMNNALeIlwr+4pLjtLNo4qoY8etQmU/1q4aIATE9N8PFqg0g== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/installations" "0.6.21" + "@firebase/logger" "0.5.0" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/storage-compat@0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@firebase/storage-compat/-/storage-compat-0.4.2.tgz#866e3bfc4533510bc1d6207d10e9ef5748359cf4" + integrity sha512-R+aB38wxCH5zjIO/xu9KznI7fgiPuZAG98uVm1NcidHyyupGgIDLKigGmRGBZMnxibe/m2oxNKoZpfEbUX2aQQ== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/storage" "0.14.2" + "@firebase/storage-types" "0.8.3" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/storage-types@0.8.3": + version "0.8.3" + resolved "https://registry.yarnpkg.com/@firebase/storage-types/-/storage-types-0.8.3.tgz#2531ef593a3452fc12c59117195d6485c6632d3d" + integrity sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg== + +"@firebase/storage@0.14.2": + version "0.14.2" + resolved "https://registry.yarnpkg.com/@firebase/storage/-/storage-0.14.2.tgz#476bad2594ad26487b232620f0a23991d65ab69a" + integrity sha512-o/culaTeJ8GRpKXRJov21rux/n9dRaSOWLebyatFP2sqEdCxQPjVA1H9Z2fzYwQxMIU0JVmC7SPPmU11v7L6vQ== + dependencies: + "@firebase/component" "0.7.2" + "@firebase/util" "1.15.0" + tslib "^2.1.0" + +"@firebase/util@1.15.0": + version "1.15.0" + resolved "https://registry.yarnpkg.com/@firebase/util/-/util-1.15.0.tgz#783c1a67dc0690dbe3afca668174c11368843008" + integrity sha512-AmWf3cHAOMbrCPG4xdPKQaj5iHnyYfyLKZxwz+Xf55bqKbpAmcYifB4jQinT2W9XhDRHISOoPyBOariJpCG6FA== + dependencies: + tslib "^2.1.0" + +"@firebase/webchannel-wrapper@1.0.5": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.5.tgz#39cf5a600450cb42f1f0b507cc385459bf103b27" + integrity sha512-+uGNN7rkfn41HLO0vekTFhTxk61eKa8mTpRGLO0QSqlQdKvIoGAvLp3ppdVIWbTGYJWM6Kp0iN+PjMIOcnVqTw== + +"@grpc/grpc-js@~1.9.0": + version "1.9.15" + resolved "https://registry.yarnpkg.com/@grpc/grpc-js/-/grpc-js-1.9.15.tgz#433d7ac19b1754af690ea650ab72190bd700739b" + integrity sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ== + dependencies: + "@grpc/proto-loader" "^0.7.8" + "@types/node" ">=12.12.47" + +"@grpc/proto-loader@^0.7.8": + version "0.7.15" + resolved "https://registry.yarnpkg.com/@grpc/proto-loader/-/proto-loader-0.7.15.tgz#4cdfbf35a35461fc843abe8b9e2c0770b5095e60" + integrity sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ== + dependencies: + lodash.camelcase "^4.3.0" + long "^5.0.0" + protobufjs "^7.2.5" + yargs "^17.7.2" + "@isaacs/fs-minipass@^4.0.0": version "4.0.1" resolved "https://registry.yarnpkg.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz#2d59ae3ab4b38fb4270bfa23d30f8e2e86c7fe32" @@ -1275,6 +1686,59 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" + integrity sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ== + +"@protobufjs/base64@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/base64/-/base64-1.1.2.tgz#4c85730e59b9a1f1f349047dbf24296034bb2735" + integrity sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg== + +"@protobufjs/codegen@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@protobufjs/codegen/-/codegen-2.0.5.tgz#d9315ad7cf3f30aac70bda3c068443dc6f143659" + integrity sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g== + +"@protobufjs/eventemitter@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz#355cbc98bafad5978f9ed095f397621f1d066b70" + integrity sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q== + +"@protobufjs/fetch@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/fetch/-/fetch-1.1.0.tgz#ba99fb598614af65700c1619ff06d454b0d84c45" + integrity sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ== + dependencies: + "@protobufjs/aspromise" "^1.1.1" + "@protobufjs/inquire" "^1.1.0" + +"@protobufjs/float@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@protobufjs/float/-/float-1.0.2.tgz#5e9e1abdcb73fc0a7cb8b291df78c8cbd97b87d1" + integrity sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ== + +"@protobufjs/inquire@^1.1.0", "@protobufjs/inquire@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/inquire/-/inquire-1.1.1.tgz#6cb936f4ac50965230af1e9d0bbfd57ea3675aa4" + integrity sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew== + +"@protobufjs/path@^1.1.2": + version "1.1.2" + resolved "https://registry.yarnpkg.com/@protobufjs/path/-/path-1.1.2.tgz#6cc2b20c5c9ad6ad0dccfd21ca7673d8d7fbf68d" + integrity sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA== + +"@protobufjs/pool@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@protobufjs/pool/-/pool-1.1.0.tgz#09fd15f2d6d3abfa9b65bc366506d6ad7846ff54" + integrity sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw== + +"@protobufjs/utf8@^1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@protobufjs/utf8/-/utf8-1.1.1.tgz#eaee5900122c110a3dbcb728c0597014a2621774" + integrity sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg== + "@react-native-async-storage/async-storage@2.2.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz#a3aa565253e46286655560172f4e366e8969f5ad" @@ -1563,7 +2027,7 @@ dependencies: "@types/istanbul-lib-report" "*" -"@types/node@*": +"@types/node@*", "@types/node@>=12.12.47", "@types/node@>=13.7.0": version "25.6.0" resolved "https://registry.yarnpkg.com/@types/node/-/node-25.6.0.tgz#4e09bad9b469871f2d0f68140198cbd714f4edca" integrity sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ== @@ -2138,6 +2602,11 @@ clone@^1.0.2: resolved "https://registry.yarnpkg.com/clone/-/clone-1.0.4.tgz#da309cc263df15994c688ca902179ca3c7cd7c7e" integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + color-convert@^1.9.0: version "1.9.3" resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" @@ -2649,6 +3118,13 @@ fastq@^1.6.0: dependencies: reusify "^1.0.4" +faye-websocket@0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/faye-websocket/-/faye-websocket-0.11.4.tgz#7f0d9275cfdd86a1c963dc8b65fcc451edcbb1da" + integrity sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g== + dependencies: + websocket-driver ">=0.5.1" + fb-watchman@^2.0.0: version "2.0.2" resolved "https://registry.yarnpkg.com/fb-watchman/-/fb-watchman-2.0.2.tgz#e9524ee6b5c77e9e5001af0f85f3adbb8623255c" @@ -2694,6 +3170,40 @@ find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" +firebase@^12.10.0: + version "12.12.1" + resolved "https://registry.yarnpkg.com/firebase/-/firebase-12.12.1.tgz#4c5145ce819509b1e547d27aef584ab719809d29" + integrity sha512-ee7xA+bTJLfjB9BP/8FQr3EkxmpAAGc1lNc5QkWgTDpUw24HYXFPm7FEWRdLtGnygxIdYpFmepSc5VjkI6NHhw== + dependencies: + "@firebase/ai" "2.11.1" + "@firebase/analytics" "0.10.21" + "@firebase/analytics-compat" "0.2.27" + "@firebase/app" "0.14.11" + "@firebase/app-check" "0.11.2" + "@firebase/app-check-compat" "0.4.2" + "@firebase/app-compat" "0.5.11" + "@firebase/app-types" "0.9.4" + "@firebase/auth" "1.13.0" + "@firebase/auth-compat" "0.6.5" + "@firebase/data-connect" "0.6.0" + "@firebase/database" "1.1.2" + "@firebase/database-compat" "2.1.3" + "@firebase/firestore" "4.14.0" + "@firebase/firestore-compat" "0.4.8" + "@firebase/functions" "0.13.3" + "@firebase/functions-compat" "0.4.3" + "@firebase/installations" "0.6.21" + "@firebase/installations-compat" "0.2.21" + "@firebase/messaging" "0.12.25" + "@firebase/messaging-compat" "0.2.25" + "@firebase/performance" "0.7.11" + "@firebase/performance-compat" "0.2.24" + "@firebase/remote-config" "0.8.2" + "@firebase/remote-config-compat" "0.2.23" + "@firebase/storage" "0.14.2" + "@firebase/storage-compat" "0.4.2" + "@firebase/util" "1.15.0" + flow-enums-runtime@^0.0.6: version "0.0.6" resolved "https://registry.yarnpkg.com/flow-enums-runtime/-/flow-enums-runtime-0.0.6.tgz#5bb0cd1b0a3e471330f4d109039b7eba5cb3e787" @@ -2867,6 +3377,11 @@ http-errors@~2.0.1: statuses "~2.0.2" toidentifier "~1.0.1" +http-parser-js@>=0.5.1: + version "0.5.10" + resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.10.tgz#b3277bd6d7ed5588e20ea73bf724fcbe44609075" + integrity sha512-Pysuw9XpUq5dVc/2SMHpuTY01RFl8fttgcyunjL7eEMhGM3cI4eOmiCycJDVCo/7O7ClfQD3SaI6ftDzqOXYMA== + https-proxy-agent@^7.0.5: version "7.0.6" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" @@ -2875,6 +3390,11 @@ https-proxy-agent@^7.0.5: agent-base "^7.1.2" debug "4" +idb@7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/idb/-/idb-7.1.1.tgz#d910ded866d32c7ced9befc5bfdf36f572ced72b" + integrity sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ== + ieee754@^1.1.13: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" @@ -3332,6 +3852,11 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +lodash.camelcase@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" + integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== + lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -3349,6 +3874,11 @@ log-symbols@^2.2.0: dependencies: chalk "^2.0.1" +long@^5.0.0: + version "5.3.2" + resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" + integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== + loose-envify@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" @@ -4268,6 +4798,24 @@ prompts@^2.3.2: kleur "^3.0.3" sisteransi "^1.0.5" +protobufjs@^7.2.5: + version "7.5.6" + resolved "https://registry.yarnpkg.com/protobufjs/-/protobufjs-7.5.6.tgz#11af832ebc4b4326f658a5b1308e6141eb57edfd" + integrity sha512-M71sTMB146U3u0di3yup8iM+zv8yPRNQVr1KK4tyBitl3qFvEGucq/rGDRShD2rsJhtN02RJaJ7j5X5hmy8SJg== + dependencies: + "@protobufjs/aspromise" "^1.1.2" + "@protobufjs/base64" "^1.1.2" + "@protobufjs/codegen" "^2.0.5" + "@protobufjs/eventemitter" "^1.1.0" + "@protobufjs/fetch" "^1.1.0" + "@protobufjs/float" "^1.0.2" + "@protobufjs/inquire" "^1.1.1" + "@protobufjs/path" "^1.1.2" + "@protobufjs/pool" "^1.1.0" + "@protobufjs/utf8" "^1.1.1" + "@types/node" ">=13.7.0" + long "^5.0.0" + punycode@^2.1.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" @@ -4600,7 +5148,7 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" -safe-buffer@5.2.1: +safe-buffer@5.2.1, safe-buffer@>=5.1.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -4884,6 +5432,11 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== +tailwind-merge@^3.5.0: + version "3.5.0" + resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.5.0.tgz#06502f4496ba15151445d97d916a26564d50d1ca" + integrity sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A== + tailwindcss@^3.4.17: version "3.4.19" resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.19.tgz#af2a0a4ae302d52ebe078b6775e799e132500ee2" @@ -4999,6 +5552,11 @@ ts-interface-checker@^0.1.9: resolved "https://registry.yarnpkg.com/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz#784fd3d679722bc103b1b4b8030bcddb5db2a699" integrity sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA== +tslib@^2.1.0: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + type-detect@4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/type-detect/-/type-detect-4.0.8.tgz#7646fb5f18871cfbb7749e69bd39a6388eb7450c" @@ -5124,11 +5682,30 @@ wcwidth@^1.0.1: dependencies: defaults "^1.0.3" +web-vitals@^4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/web-vitals/-/web-vitals-4.2.4.tgz#1d20bc8590a37769bd0902b289550936069184b7" + integrity sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw== + webidl-conversions@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-5.0.0.tgz#ae59c8a00b121543a2acc65c0434f57b0fc11aff" integrity sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA== +websocket-driver@>=0.5.1: + version "0.7.4" + resolved "https://registry.yarnpkg.com/websocket-driver/-/websocket-driver-0.7.4.tgz#89ad5295bbf64b480abcba31e4953aca706f5760" + integrity sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg== + dependencies: + http-parser-js ">=0.5.1" + safe-buffer ">=5.1.0" + websocket-extensions ">=0.1.1" + +websocket-extensions@>=0.1.1: + version "0.1.4" + resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" + integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== + whatwg-fetch@^3.0.0: version "3.6.20" resolved "https://registry.yarnpkg.com/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz#580ce6d791facec91d37c72890995a0b48d31c70" @@ -5245,7 +5822,7 @@ yargs-parser@^21.1.1: resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-21.1.1.tgz#9096bceebf990d21bb31fa9516e0ede294a77d35" integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== -yargs@^17.6.2: +yargs@^17.6.2, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==