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
|
back
|
||||||
</span>
|
</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>
|
<span>
|
||||||
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
<kbd className="rounded bg-white/10 px-1.5 py-0.5 font-mono text-xs">
|
||||||
Hold `
|
Hold `
|
||||||
@@ -51,30 +75,6 @@ export default function ControlsIndicator({
|
|||||||
{attachmentCount} {attachmentCount === 1 ? "attachment" : "attachments"}
|
{attachmentCount} {attachmentCount === 1 ? "attachment" : "attachments"}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import { List, LayoutGrid } from "lucide-react";
|
||||||
import { particlePath } from "@/lib/particle-path";
|
import { particlePath } from "@/lib/particle-path";
|
||||||
import { ParticleListView } from "@/features/particles/particle-list-view";
|
import { ParticleListView } from "@/features/particles/particle-list-view";
|
||||||
|
import { ParticleGridView } from "@/features/particles/particle-grid-view";
|
||||||
import { AutoplayOverlay } from "@/features/particles/autoplay-overlay";
|
import { AutoplayOverlay } from "@/features/particles/autoplay-overlay";
|
||||||
import ControlsIndicator from "@/features/compose/controls-indicator";
|
import ControlsIndicator from "@/features/compose/controls-indicator";
|
||||||
import { ComposeOverlay } from "./compose/compose-overlay";
|
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).
|
* Route-level component for /:networkId (index).
|
||||||
@@ -12,14 +16,46 @@ import { ComposeOverlay } from "./compose/compose-overlay";
|
|||||||
export default function NetworkRoot() {
|
export default function NetworkRoot() {
|
||||||
const { networkId } = useParams();
|
const { networkId } = useParams();
|
||||||
const path = particlePath(networkId!, []);
|
const path = particlePath(networkId!, []);
|
||||||
|
const viewMode = useViewModeStore((s) => s.viewMode);
|
||||||
|
const setViewMode = useViewModeStore((s) => s.setViewMode);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full relative">
|
<div className="relative min-h-0 flex-1">
|
||||||
<ParticleListView path={path} />
|
{/* 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!} />
|
<AutoplayOverlay networkId={networkId!} />
|
||||||
<ComposeOverlay networkId={networkId!} />
|
<ComposeOverlay networkId={networkId!} />
|
||||||
<div className="flex justify-center p-3">
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center p-3">
|
||||||
<ControlsIndicator type={"new"} />
|
<div className="pointer-events-auto">
|
||||||
|
<ControlsIndicator type={"new"} />
|
||||||
|
</div>
|
||||||
</div>
|
</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 { useNavigate } from "react-router-dom";
|
||||||
import beepSound from "../../../assets/sound.wav";
|
|
||||||
import {
|
import {
|
||||||
Radio,
|
Radio,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
@@ -13,10 +12,9 @@ import {
|
|||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { cn } from "@/lib/utils";
|
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 { useAuthStore } from "@/stores/auth-store";
|
||||||
import {
|
import {
|
||||||
parseParticlePath,
|
|
||||||
particlePath,
|
particlePath,
|
||||||
type ParticlePath,
|
type ParticlePath,
|
||||||
} from "@/lib/particle-path";
|
} from "@/lib/particle-path";
|
||||||
@@ -27,10 +25,9 @@ import { Separator } from "@/components/ui/separator";
|
|||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Small } from "@/components/ui/typography";
|
import { Small } from "@/components/ui/typography";
|
||||||
import type { Particle, StreamProperties } from "@/api/types";
|
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 { useNetwork } from "@/hooks/use-networks";
|
||||||
|
import { useStreamParticles } from "@/hooks/use-stream-particles";
|
||||||
|
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
|
||||||
|
|
||||||
function getParticleTypeIcon(particle: Particle): LucideIcon {
|
function getParticleTypeIcon(particle: Particle): LucideIcon {
|
||||||
switch (particle.type) {
|
switch (particle.type) {
|
||||||
@@ -91,33 +88,7 @@ function StreamRow({
|
|||||||
const userId = user?.id ?? "";
|
const userId = user?.id ?? "";
|
||||||
const network = useNetwork(networkId);
|
const network = useNetwork(networkId);
|
||||||
|
|
||||||
// Autoplay: trigger only when latestChild *changes* to a new media particle,
|
useStreamAutoplay(latestChild, 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
|
|
||||||
|
|
||||||
const isDM =
|
const isDM =
|
||||||
particle.visible_to.length === 2 &&
|
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 {
|
interface ParticleListViewProps {
|
||||||
path: ParticlePath;
|
path: ParticlePath;
|
||||||
}
|
}
|
||||||
@@ -263,34 +217,9 @@ interface ParticleListViewProps {
|
|||||||
* List of stream particles for a container (network root, folder, etc.).
|
* List of stream particles for a container (network root, folder, etc.).
|
||||||
*/
|
*/
|
||||||
export function ParticleListView({ path }: ParticleListViewProps) {
|
export function ParticleListView({ path }: ParticleListViewProps) {
|
||||||
const { networkId } = parseParticlePath(path);
|
const { streams, isLoading, networkId } = useStreamParticles(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 navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const streams = useMemo(
|
|
||||||
() => children.filter((c) => c.type === "stream"),
|
|
||||||
[children],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <Progress />;
|
return <Progress />;
|
||||||
}
|
}
|
||||||
@@ -307,19 +236,17 @@ export function ParticleListView({ path }: ParticleListViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full overflow-clip">
|
<div>
|
||||||
<div className="py-1">
|
{streams.map((stream, index) => (
|
||||||
{streams.map((stream, index) => (
|
<div key={stream.id}>
|
||||||
<div key={stream.id}>
|
<StreamRow
|
||||||
<StreamRow
|
particle={stream}
|
||||||
particle={stream}
|
networkId={networkId}
|
||||||
networkId={networkId}
|
onClick={() => navigate(`/${networkId}/${stream.id}`)}
|
||||||
onClick={() => navigate(`/${networkId}/${stream.id}`)}
|
/>
|
||||||
/>
|
{index < streams.length - 1 && <Separator className="mx-4" />}
|
||||||
{index < streams.length - 1 && <Separator className="mx-4" />}
|
</div>
|
||||||
</div>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</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")}`;
|
const durationLabel = `${Math.floor(durationSec / 60)}:${String(durationSec % 60).padStart(2, "0")}`;
|
||||||
|
|
||||||
if (isVideo) {
|
if (isVideo) {
|
||||||
return <VideoThumbnail particleId={particle.id} duration={durationLabel} />;
|
return <VideoThumbnail particleId={particle.properties.object_id} duration={durationLabel} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -102,7 +102,7 @@ function VideoThumbnail({
|
|||||||
return (
|
return (
|
||||||
<div className="relative h-full w-full bg-black">
|
<div className="relative h-full w-full bg-black">
|
||||||
<video
|
<video
|
||||||
src={url}
|
src={`${url}#t=2`}
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
muted
|
muted
|
||||||
playsInline
|
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