From 9f6633bf1fab7644e69ea5c502f1b0862b3551d0 Mon Sep 17 00:00:00 2001 From: talksik Date: Mon, 30 Mar 2026 12:08:16 -0700 Subject: [PATCH] feat: add grid view for streams --- js/src/components/ui/toggle-group.tsx | 87 +++++++++++ .../features/compose/controls-indicator.tsx | 48 +++--- js/src/features/network-root.tsx | 44 +++++- .../features/particles/particle-grid-view.tsx | 44 ++++++ .../features/particles/particle-list-view.tsx | 107 ++----------- .../features/particles/particle-preview.tsx | 4 +- js/src/features/particles/stream-card.tsx | 145 ++++++++++++++++++ js/src/hooks/use-stream-autoplay.ts | 41 +++++ js/src/hooks/use-stream-particles.ts | 62 ++++++++ js/src/stores/view-mode-store.ts | 36 +++++ 10 files changed, 498 insertions(+), 120 deletions(-) create mode 100644 js/src/components/ui/toggle-group.tsx create mode 100644 js/src/features/particles/particle-grid-view.tsx create mode 100644 js/src/features/particles/stream-card.tsx create mode 100644 js/src/hooks/use-stream-autoplay.ts create mode 100644 js/src/hooks/use-stream-particles.ts create mode 100644 js/src/stores/view-mode-store.ts diff --git a/js/src/components/ui/toggle-group.tsx b/js/src/components/ui/toggle-group.tsx new file mode 100644 index 0000000..7a547b8 --- /dev/null +++ b/js/src/components/ui/toggle-group.tsx @@ -0,0 +1,87 @@ +import * as React from "react" +import { type VariantProps } from "class-variance-authority" +import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui" + +import { cn } from "@/lib/utils" +import { toggleVariants } from "@/components/ui/toggle" + +const ToggleGroupContext = React.createContext< + VariantProps & { + spacing?: number + orientation?: "horizontal" | "vertical" + } +>({ + size: "default", + variant: "default", + spacing: 0, + orientation: "horizontal", +}) + +function ToggleGroup({ + className, + variant, + size, + spacing = 0, + orientation = "horizontal", + children, + ...props +}: React.ComponentProps & + VariantProps & { + spacing?: number + orientation?: "horizontal" | "vertical" + }) { + return ( + + + {children} + + + ) +} + +function ToggleGroupItem({ + className, + children, + variant = "default", + size = "default", + ...props +}: React.ComponentProps & + VariantProps) { + const context = React.useContext(ToggleGroupContext) + + return ( + + {children} + + ) +} + +export { ToggleGroup, ToggleGroupItem } diff --git a/js/src/features/compose/controls-indicator.tsx b/js/src/features/compose/controls-indicator.tsx index 146b792..4e855b5 100644 --- a/js/src/features/compose/controls-indicator.tsx +++ b/js/src/features/compose/controls-indicator.tsx @@ -26,6 +26,30 @@ export default function ControlsIndicator({ back )} + Hold ` @@ -51,30 +75,6 @@ export default function ControlsIndicator({ {attachmentCount} {attachmentCount === 1 ? "attachment" : "attachments"} )} - ); } diff --git a/js/src/features/network-root.tsx b/js/src/features/network-root.tsx index 1b918cf..ebcf39f 100644 --- a/js/src/features/network-root.tsx +++ b/js/src/features/network-root.tsx @@ -1,9 +1,13 @@ import { useParams } from "react-router-dom"; +import { List, LayoutGrid } from "lucide-react"; import { particlePath } from "@/lib/particle-path"; import { ParticleListView } from "@/features/particles/particle-list-view"; +import { ParticleGridView } from "@/features/particles/particle-grid-view"; import { AutoplayOverlay } from "@/features/particles/autoplay-overlay"; import ControlsIndicator from "@/features/compose/controls-indicator"; import { ComposeOverlay } from "./compose/compose-overlay"; +import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; +import { useViewModeStore } from "@/stores/view-mode-store"; /** * Route-level component for /:networkId (index). @@ -12,14 +16,46 @@ import { ComposeOverlay } from "./compose/compose-overlay"; export default function NetworkRoot() { const { networkId } = useParams(); const path = particlePath(networkId!, []); + const viewMode = useViewModeStore((s) => s.viewMode); + const setViewMode = useViewModeStore((s) => s.setViewMode); return ( -
- +
+ {/* Scrollable content */} +
+ {viewMode === "list" ? ( + + ) : ( + + )} +
+ + {/* Fixed overlays */} +
+ { + if (v) setViewMode(v as "list" | "grid"); + }} + size="sm" + className="pointer-events-auto" + > + + + + + + + +
+ -
- +
+
+ +
); diff --git a/js/src/features/particles/particle-grid-view.tsx b/js/src/features/particles/particle-grid-view.tsx new file mode 100644 index 0000000..38e9a8a --- /dev/null +++ b/js/src/features/particles/particle-grid-view.tsx @@ -0,0 +1,44 @@ +import { useNavigate } from "react-router-dom"; +import { Radio } from "lucide-react"; +import { Progress } from "@/components/ui/progress"; +import type { ParticlePath } from "@/lib/particle-path"; +import { useStreamParticles } from "@/hooks/use-stream-particles"; +import { StreamCard } from "@/features/particles/stream-card"; + +interface ParticleGridViewProps { + path: ParticlePath; +} + +export function ParticleGridView({ path }: ParticleGridViewProps) { + const { streams, isLoading, networkId } = useStreamParticles(path); + const navigate = useNavigate(); + + if (isLoading) { + return ; + } + + if (streams.length === 0) { + return ( +
+ +

+ No recent streams yet. Start a conversation using the keyboard + shortcuts below. +

+
+ ); + } + + return ( +
+ {streams.map((stream) => ( + navigate(`/${networkId}/${stream.id}`)} + /> + ))} +
+ ); +} diff --git a/js/src/features/particles/particle-list-view.tsx b/js/src/features/particles/particle-list-view.tsx index 8809d00..86d7a60 100644 --- a/js/src/features/particles/particle-list-view.tsx +++ b/js/src/features/particles/particle-list-view.tsx @@ -1,6 +1,5 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo } from "react"; import { useNavigate } from "react-router-dom"; -import beepSound from "../../../assets/sound.wav"; import { Radio, MessageSquare, @@ -13,10 +12,9 @@ import { type LucideIcon, } from "lucide-react"; import { cn } from "@/lib/utils"; -import { useLiveParticleChildren, useLiveLatestChild } from "@/hooks/use-particle"; +import { useLiveLatestChild } from "@/hooks/use-particle"; import { useAuthStore } from "@/stores/auth-store"; import { - parseParticlePath, particlePath, type ParticlePath, } from "@/lib/particle-path"; @@ -27,10 +25,9 @@ import { Separator } from "@/components/ui/separator"; import { Progress } from "@/components/ui/progress"; import { Small } from "@/components/ui/typography"; import type { Particle, StreamProperties } from "@/api/types"; -import { useAutoplayStore } from "@/stores/autoplay-store"; -import { where, Timestamp } from "firebase/firestore"; -import { RECENCY_WINDOW_HOURS } from "@/lib/constants"; import { useNetwork } from "@/hooks/use-networks"; +import { useStreamParticles } from "@/hooks/use-stream-particles"; +import { useStreamAutoplay } from "@/hooks/use-stream-autoplay"; function getParticleTypeIcon(particle: Particle): LucideIcon { switch (particle.type) { @@ -91,33 +88,7 @@ function StreamRow({ const userId = user?.id ?? ""; const network = useNetwork(networkId); - // Autoplay: trigger only when latestChild *changes* to a new media particle, - // not on initial data load. We track the "settled" id — the first non-null value - // we see — and only autoplay on subsequent changes from that baseline. - const settledIdRef = useRef(undefined); - useEffect(() => { - if (!latestChild) return; - - // First real value: record it as baseline, don't autoplay - if (settledIdRef.current === undefined) { - settledIdRef.current = latestChild.id; - return; - } - - if (latestChild.id === settledIdRef.current) return; - settledIdRef.current = latestChild.id; - - if (latestChild.created_by_human_id === userId) return; - - if (latestChild.type === "text") { - new Audio(beepSound).play().catch(() => {}); - return; - } - - if (latestChild.type !== "media") return; - - useAutoplayStore.getState().play(latestChild, particle.id); - }, [latestChild?.id]); // eslint-disable-line react-hooks/exhaustive-deps + useStreamAutoplay(latestChild, particle); const isDM = particle.visible_to.length === 2 && @@ -238,23 +209,6 @@ function StreamRow({ ); } -// Generates the scopes for filtering particles to those that the user has access to -function useVisibilityScopes( - userId?: string, - networkId?: string, -) { - return useMemo(() => { - let scopes: string[] = []; - if (userId) { - scopes.push(`human:${userId}`); - } - if (networkId) { - scopes.push(`network:${networkId}`); - } - return scopes; - }, [userId, networkId]); -} - interface ParticleListViewProps { path: ParticlePath; } @@ -263,34 +217,9 @@ interface ParticleListViewProps { * List of stream particles for a container (network root, folder, etc.). */ export function ParticleListView({ path }: ParticleListViewProps) { - const { networkId } = parseParticlePath(path); - const user = useAuthStore((s) => s.user); - const visibilityScopes = useVisibilityScopes(user?.id, networkId); - - const [recencyCutoff, setRecencyCutoff] = useState(() => { - const d = new Date(); - d.setHours(d.getHours() - RECENCY_WINDOW_HOURS); - return Timestamp.fromDate(d); - }); - - // Refresh the cutoff every hour so streams don't vanish/appear stale - useEffect(() => { - const interval = setInterval(() => { - const d = new Date(); - d.setHours(d.getHours() - RECENCY_WINDOW_HOURS); - setRecencyCutoff(Timestamp.fromDate(d)); - }, 60 * 60 * 1000); - return () => clearInterval(interval); - }, []); - - const { children, isLoading } = useLiveParticleChildren(path, "last_child_created_at", "desc", visibilityScopes, undefined, undefined, where("last_child_created_at", ">=", recencyCutoff)); + const { streams, isLoading, networkId } = useStreamParticles(path); const navigate = useNavigate(); - const streams = useMemo( - () => children.filter((c) => c.type === "stream"), - [children], - ); - if (isLoading) { return ; } @@ -307,19 +236,17 @@ export function ParticleListView({ path }: ParticleListViewProps) { } return ( -
-
- {streams.map((stream, index) => ( -
- navigate(`/${networkId}/${stream.id}`)} - /> - {index < streams.length - 1 && } -
- ))} -
+
+ {streams.map((stream, index) => ( +
+ navigate(`/${networkId}/${stream.id}`)} + /> + {index < streams.length - 1 && } +
+ ))}
); } diff --git a/js/src/features/particles/particle-preview.tsx b/js/src/features/particles/particle-preview.tsx index 7b7d155..733d8e5 100644 --- a/js/src/features/particles/particle-preview.tsx +++ b/js/src/features/particles/particle-preview.tsx @@ -51,7 +51,7 @@ function MediaPreview({ particle }: { particle: Extract; + return ; } return ( @@ -102,7 +102,7 @@ function VideoThumbnail({ return (