feat: add grid view for streams

This commit is contained in:
talksik
2026-03-30 12:08:16 -07:00
parent 76c538dca2
commit 9f6633bf1f
10 changed files with 498 additions and 120 deletions
+87
View File
@@ -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<typeof toggleVariants> & {
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<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
orientation?: "horizontal" | "vertical"
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-vertical:flex-col data-vertical:items-stretch",
className
)}
{...props}
>
<ToggleGroupContext.Provider
value={{ variant, size, spacing, orientation }}
>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant = "default",
size = "default",
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
"shrink-0 group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+24 -24
View File
@@ -26,6 +26,30 @@ export default function ControlsIndicator({
back
</span>
)}
<button
type="button"
onClick={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video")
}
title={
recordingMode === "video"
? "Switch to audio-only"
: "Switch to video"
}
className="flex items-center gap-1 rounded bg-white/10 px-1.5 py-0.5 text-xs text-white/50 transition-colors hover:text-white/80"
>
{recordingMode === "video" ? (
<>
<Video className="size-3" />
Video
</>
) : (
<>
<Mic className="size-3" />
Audio
</>
)}
</button>
<span>
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
Hold `
@@ -51,30 +75,6 @@ export default function ControlsIndicator({
{attachmentCount} {attachmentCount === 1 ? "attachment" : "attachments"}
</button>
)}
<button
type="button"
onClick={() =>
setRecordingMode(recordingMode === "video" ? "audio" : "video")
}
title={
recordingMode === "video"
? "Switch to audio-only"
: "Switch to video"
}
className="flex items-center gap-1 rounded bg-white/10 px-1.5 py-0.5 text-xs text-white/50 transition-colors hover:text-white/80"
>
{recordingMode === "video" ? (
<>
<Video className="size-3" />
Video
</>
) : (
<>
<Mic className="size-3" />
Audio
</>
)}
</button>
</div>
);
}
+40 -4
View File
@@ -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 (
<div className="flex flex-col h-full relative">
<ParticleListView path={path} />
<div className="relative min-h-0 flex-1">
{/* Scrollable content */}
<div className="h-full overflow-y-auto overscroll-contain pt-10 pb-14">
{viewMode === "list" ? (
<ParticleListView path={path} />
) : (
<ParticleGridView path={path} />
)}
</div>
{/* Fixed overlays */}
<div className="pointer-events-none absolute inset-x-0 top-0 flex justify-end px-3 pt-2">
<ToggleGroup
type="single"
value={viewMode}
onValueChange={(v) => {
if (v) setViewMode(v as "list" | "grid");
}}
size="sm"
className="pointer-events-auto"
>
<ToggleGroupItem value="list" aria-label="List view">
<List className="size-3.5" />
</ToggleGroupItem>
<ToggleGroupItem value="grid" aria-label="Grid view">
<LayoutGrid className="size-3.5" />
</ToggleGroupItem>
</ToggleGroup>
</div>
<AutoplayOverlay networkId={networkId!} />
<ComposeOverlay networkId={networkId!} />
<div className="flex justify-center p-3">
<ControlsIndicator type={"new"} />
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-3">
<div className="pointer-events-auto">
<ControlsIndicator type={"new"} />
</div>
</div>
</div>
);
@@ -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 <Progress />;
}
if (streams.length === 0) {
return (
<div className="mx-auto flex h-full max-w-sm flex-col items-center justify-center gap-2 px-4 text-center">
<Radio className="text-muted-foreground size-8" />
<p className="text-muted-foreground text-sm">
No recent streams yet. Start a conversation using the keyboard
shortcuts below.
</p>
</div>
);
}
return (
<div className="grid grid-cols-2 gap-3 px-3">
{streams.map((stream) => (
<StreamCard
key={stream.id}
particle={stream}
networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)}
/>
))}
</div>
);
}
@@ -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<string | undefined>(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 <Progress />;
}
@@ -307,19 +236,17 @@ export function ParticleListView({ path }: ParticleListViewProps) {
}
return (
<div className="h-full overflow-clip">
<div className="py-1">
{streams.map((stream, index) => (
<div key={stream.id}>
<StreamRow
particle={stream}
networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)}
/>
{index < streams.length - 1 && <Separator className="mx-4" />}
</div>
))}
</div>
<div>
{streams.map((stream, index) => (
<div key={stream.id}>
<StreamRow
particle={stream}
networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)}
/>
{index < streams.length - 1 && <Separator className="mx-4" />}
</div>
))}
</div>
);
}
@@ -51,7 +51,7 @@ function MediaPreview({ particle }: { particle: Extract<Particle, { type: "media
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, "0")}`;
if (isVideo) {
return <VideoThumbnail particleId={particle.id} duration={durationLabel} />;
return <VideoThumbnail particleId={particle.properties.object_id} duration={durationLabel} />;
}
return (
@@ -102,7 +102,7 @@ function VideoThumbnail({
return (
<div className="relative h-full w-full bg-black">
<video
src={url}
src={`${url}#t=2`}
preload="metadata"
muted
playsInline
+145
View File
@@ -0,0 +1,145 @@
import { useMemo } from "react";
import { cn, getInitials } from "@/lib/utils";
import { useLiveLatestChild } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import { particlePath } from "@/lib/particle-path";
import type { Particle, StreamProperties } from "@/api/types";
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
import { ParticlePreview } from "@/features/particles/particle-preview";
import { useNetwork } from "@/hooks/use-networks";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { Small } from "@/components/ui/typography";
interface StreamCardProps {
particle: Particle & { type: "stream"; properties: StreamProperties };
networkId: string;
onClick: () => void;
}
export function StreamCard({ particle, networkId, onClick }: StreamCardProps) {
const streamPath = particlePath(networkId, [particle.id]);
const { latestChild } = useLiveLatestChild(streamPath);
const userId = useAuthStore((s) => s.user?.id) ?? "";
const network = useNetwork(networkId);
useStreamAutoplay(latestChild, particle);
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]);
// For media particles with a transcript, show it as an overlay on the preview
const transcript =
latestChild?.type === "media"
? latestChild.properties.transcript?.transcript
: undefined;
return (
<div
role="button"
tabIndex={0}
onClick={onClick}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") onClick();
}}
className={cn(
"cursor-pointer overflow-hidden rounded-xl ring-1 ring-foreground/10 transition-all hover:ring-foreground/20",
isUnseen && "ring-2 ring-primary",
)}
>
{/* Preview area */}
<div className="relative aspect-[4/3] overflow-hidden bg-muted">
{latestChild ? (
<ParticlePreview particle={latestChild} />
) : (
<div className="flex h-full w-full items-center justify-center">
<p className="text-muted-foreground text-xs italic">
No messages yet
</p>
</div>
)}
{/* Transcript overlay for media with transcripts */}
{transcript && (
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent px-2.5 pt-6 pb-2">
<p className="line-clamp-2 text-md leading-snug text-white/90">
{transcript}
</p>
</div>
)}
</div>
{/* Info bar */}
<div className="flex items-center gap-2 px-2.5 py-2">
<Avatar className={cn("size-6 shrink-0", isUnseen && "ring-2 ring-primary")}>
<AvatarFallback className="bg-primary/10 text-primary text-[10px] font-medium">
{initials}
</AvatarFallback>
</Avatar>
<Small
className={cn(
"min-w-0 truncate",
isUnseen
? "font-semibold text-foreground"
: "font-medium text-muted-foreground",
)}
>
{particle.properties.name}
</Small>
<div className="ml-auto flex shrink-0 items-center gap-1.5">
{latestChild && (
<Small
className={cn(
"text-[10px]",
isUnseen ? "text-primary" : "text-muted-foreground",
)}
>
<RelativeTimestamp date={latestChild.created_at} />
</Small>
)}
{isUnseen && (
<span className="size-2 shrink-0 rounded-full bg-primary" />
)}
</div>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useEffect, useRef } from "react";
import beepSound from "../../assets/sound.wav";
import type { Particle, StreamProperties } from "@/api/types";
import { useAuthStore } from "@/stores/auth-store";
import { useAutoplayStore } from "@/stores/autoplay-store";
/**
* Triggers autoplay when a stream's latest child changes to a new media particle.
* Plays a beep sound for new text particles from other users.
*/
export function useStreamAutoplay(
latestChild: Particle | null,
streamParticle: Particle & { type: "stream"; properties: StreamProperties },
) {
const userId = useAuthStore((s) => s.user?.id) ?? "";
const settledIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (!latestChild) return;
// First real value: record 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, streamParticle.id);
}, [latestChild?.id]); // eslint-disable-line react-hooks/exhaustive-deps
}
+62
View File
@@ -0,0 +1,62 @@
import { useEffect, useMemo, useState } from "react";
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";
import { where, Timestamp } from "firebase/firestore";
import { RECENCY_WINDOW_HOURS } from "@/lib/constants";
type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
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 UseStreamParticlesResult {
streams: StreamParticle[];
isLoading: boolean;
networkId: string;
}
export function useStreamParticles(path: ParticlePath): UseStreamParticlesResult {
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);
});
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 = useMemo(
() => children.filter((c): c is StreamParticle => c.type === "stream"),
[children],
);
return { streams, isLoading, networkId };
}
+36
View File
@@ -0,0 +1,36 @@
import { create } from "zustand";
type ViewMode = "list" | "grid";
const STORAGE_KEY = "llink:view-mode";
function loadViewMode(): ViewMode {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored === "grid") return "grid";
return "list";
} catch {
return "list";
}
}
function saveViewMode(mode: ViewMode) {
try {
localStorage.setItem(STORAGE_KEY, mode);
} catch {
// Storage unavailable
}
}
interface ViewModeState {
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
}
export const useViewModeStore = create<ViewModeState>((set) => ({
viewMode: loadViewMode(),
setViewMode: (mode) => {
saveViewMode(mode);
set({ viewMode: mode });
},
}));