feat: add grid view for streams
This commit is contained in:
@@ -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 }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user