feat: triage streams with open, close (#121)

* refactor: use interfaces for function params

* cleanup message retention code

* support open / closed streams

- Tabs for viewing separately
- Context menu to close / open streams
- Update stream particle status field

* ui tweak

* show stream state in stream-view

* ui tweaks

* cleanup message retention from orion api
This commit was merged in pull request #121.
This commit is contained in:
Arjun Patel
2026-04-07 16:11:13 -07:00
committed by GitHub
parent 4a9cb7fc58
commit cc190ba85f
30 changed files with 669 additions and 402 deletions
@@ -1,17 +1,18 @@
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 type { StreamParticle } from "@/hooks/use-stream-particles";
import { StreamCard } from "@/features/particles/stream-card";
import { StreamContextMenu } from "@/features/particles/stream-context-menu";
interface ParticleGridViewProps {
path: ParticlePath;
streams: StreamParticle[];
networkId: string;
isLoading: boolean;
selectedIndex?: number | null;
}
export function ParticleGridView({ path, selectedIndex }: ParticleGridViewProps) {
const { streams, isLoading, networkId } = useStreamParticles(path);
export function ParticleGridView({ streams, networkId, isLoading, selectedIndex }: ParticleGridViewProps) {
const navigate = useNavigate();
if (isLoading) {
@@ -33,15 +34,16 @@ export function ParticleGridView({ path, selectedIndex }: ParticleGridViewProps)
return (
<div className="grid grid-cols-3 gap-3 px-3">
{streams.map((stream, index) => (
<StreamCard
key={stream.id}
ref={index === selectedIndex ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
particle={stream}
networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)}
isSelected={index === selectedIndex}
shortcutKey={index < 9 ? index + 1 : undefined}
/>
<StreamContextMenu key={stream.id} particle={stream} networkId={networkId}>
<StreamCard
ref={index === selectedIndex ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
particle={stream}
networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)}
isSelected={index === selectedIndex}
shortcutKey={index < 9 ? index + 1 : undefined}
/>
</StreamContextMenu>
))}
</div>
);
@@ -9,17 +9,13 @@ import {
FileText,
CircleCheck,
StickyNote,
Timer,
Headphones,
type LucideIcon,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useLiveLatestChild } from "@/hooks/use-particle";
import { useAuthStore } from "@/stores/auth-store";
import {
particlePath,
type ParticlePath,
} from "@/lib/particle-path";
import { particlePath } from "@/lib/particle-path";
import { getInitials } from "@/lib/utils";
import { RelativeTimestamp } from "@/components/relative-timestamp";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -27,10 +23,10 @@ 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 type { StreamParticle } from "@/hooks/use-stream-particles";
import { useNetwork } from "@/hooks/use-networks";
import { useExpiringSoon } from "@/hooks/use-expiring-soon";
import { useStreamParticles } from "@/hooks/use-stream-particles";
import { useStreamAutoplay } from "@/hooks/use-stream-autoplay";
import { StreamContextMenu } from "@/features/particles/stream-context-menu";
function getParticleTypeIcon(particle: Particle): LucideIcon {
switch (particle.type) {
@@ -97,11 +93,6 @@ function StreamRow({
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
const expiringSoon = useExpiringSoon(
particle.last_child_created_at,
network?.message_retention_hours ?? 24,
);
const hasActiveHuddle =
particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
const huddleCount = particle.huddle_active_participants?.length ?? 0;
@@ -154,7 +145,7 @@ function StreamRow({
const subtitle = latestChild
? getMessagePreview(latestChild)
: particle.properties.status;
: particle.properties.name;
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
@@ -201,9 +192,6 @@ function StreamRow({
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
</span>
)}
{expiringSoon && (
<Timer className="size-3 text-muted-foreground/60" />
)}
{latestChild && (
<Small
className={cn(
@@ -246,15 +234,16 @@ function StreamRow({
}
interface ParticleListViewProps {
path: ParticlePath;
streams: StreamParticle[];
networkId: string;
isLoading: boolean;
selectedIndex?: number | null;
}
/**
* List of stream particles for a container (network root, folder, etc.).
*/
export function ParticleListView({ path, selectedIndex }: ParticleListViewProps) {
const { streams, isLoading, networkId } = useStreamParticles(path);
export function ParticleListView({ streams, networkId, isLoading, selectedIndex }: ParticleListViewProps) {
const navigate = useNavigate();
if (isLoading) {
@@ -266,7 +255,7 @@ export function ParticleListView({ path, selectedIndex }: ParticleListViewProps)
<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.
No streams here. Start a conversation using the keyboard shortcuts below.
</p>
</div>
);
@@ -275,19 +264,20 @@ export function ParticleListView({ path, selectedIndex }: ParticleListViewProps)
return (
<div>
{streams.map((stream, index) => (
<div
key={stream.id}
ref={index === selectedIndex ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
>
<StreamRow
particle={stream}
networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)}
isSelected={index === selectedIndex}
shortcutKey={index < 9 ? index + 1 : undefined}
/>
{index < streams.length - 1 && <Separator className="px-4" />}
</div>
<StreamContextMenu key={stream.id} particle={stream} networkId={networkId}>
<div
ref={index === selectedIndex ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
>
<StreamRow
particle={stream}
networkId={networkId}
onClick={() => navigate(`/${networkId}/${stream.id}`)}
isSelected={index === selectedIndex}
shortcutKey={index < 9 ? index + 1 : undefined}
/>
{index < streams.length - 1 && <Separator className="px-4" />}
</div>
</StreamContextMenu>
))}
</div>
);
@@ -1,10 +1,10 @@
import { useParams } from "react-router-dom";
import { useLiveParticle } from "@/hooks/use-particle";
import { particlePath } from "@/lib/particle-path";
import { isContainerType } from "@/api/types";
import { StreamView } from "@/features/particles/stream-view";
import { FolderView } from "@/features/particles/folder-view";
import { ParticleListView } from "@/features/particles/particle-list-view";
/**
* Route-level component for /:networkId/*.
@@ -50,9 +50,6 @@ export default function ParticleViewResolver() {
case "folder":
return <FolderView folderParticle={particle} path={path} />;
default:
if (isContainerType(particle.type)) {
return <ParticleListView path={path} />;
}
return (
<div className="flex h-full items-center justify-center">
<p className="text-muted-foreground text-sm">
+1 -10
View File
@@ -1,12 +1,11 @@
import { forwardRef, useMemo } from "react";
import { Timer, Headphones } from "lucide-react";
import { Headphones } from "lucide-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 { useExpiringSoon } from "@/hooks/use-expiring-soon";
import { ParticlePreview } from "@/features/particles/particle-preview";
import { useNetwork } from "@/hooks/use-networks";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
@@ -29,11 +28,6 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function S
useStreamAutoplay(latestChild, particle, networkId, network ?? undefined);
const expiringSoon = useExpiringSoon(
particle.last_child_created_at,
network?.message_retention_hours ?? 24,
);
const hasActiveHuddle =
particle.huddle_active_participants && particle.huddle_active_participants.length > 0;
const huddleCount = particle.huddle_active_participants?.length ?? 0;
@@ -155,9 +149,6 @@ export const StreamCard = forwardRef<HTMLDivElement, StreamCardProps>(function S
<span className="text-[10px] font-medium text-red-400">{huddleCount}</span>
</span>
)}
{expiringSoon && (
<Timer className="size-3 text-muted-foreground/60" />
)}
{latestChild && (
<Small
className={cn(
@@ -0,0 +1,46 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { CircleCheckBig, CircleDot } from "lucide-react";
import { updateStreamStatus } from "@/lib/firestore-particles";
import { toFirestoreDocPath, particlePath } from "@/lib/particle-path";
import type { StreamParticle } from "@/hooks/use-stream-particles";
interface StreamContextMenuProps {
particle: StreamParticle;
networkId: string;
children: React.ReactNode;
}
export function StreamContextMenu({ particle, networkId, children }: StreamContextMenuProps) {
const isOpen = particle.status === "open";
const docPath = toFirestoreDocPath(particlePath(networkId, [particle.id]));
const toggleStatus = async () => {
await updateStreamStatus(docPath, isOpen ? "closed" : "open");
};
return (
<ContextMenu>
<ContextMenuTrigger asChild>{children}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem onSelect={toggleStatus}>
{isOpen ? (
<>
<CircleCheckBig className="size-4" />
Close stream
</>
) : (
<>
<CircleDot className="size-4 text-green-500" />
Open stream
</>
)}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
+53 -10
View File
@@ -3,7 +3,7 @@ import { useNavigate } from "react-router-dom";
import { useAuthStore } from "@/stores/auth-store";
import { apiClient } from "@/api/client";
import type { Particle } from "@/api/types";
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
import { parseParticlePath, particlePath, toFirestoreDocPath, type ParticlePath } from "@/lib/particle-path";
import { ComposeOverlay } from "@/features/compose/compose-overlay";
import { PlaybackPageIndicator } from "@/features/particles/playback-page-indicator";
import { MediaParticleView } from "@/features/particles/media-particle-view";
@@ -14,7 +14,14 @@ import ControlsIndicator from "@/features/compose/controls-indicator";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useNetwork, useNetworks } from "@/hooks/use-networks";
import { Button } from "@/components/ui/button";
import { Settings } from "lucide-react";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Settings, CircleCheckBig, CircleDot, EllipsisVertical } from "lucide-react";
import { updateStreamStatus } from "@/lib/firestore-particles";
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator } from "@/components/ui/breadcrumb";
import { WindowControls } from "@/components/window-controls";
import { RelativeTimestamp } from "@/components/relative-timestamp";
@@ -248,6 +255,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
networkId={networkId}
targetPath={path}
onActiveChange={setComposeActive}
disabled={streamParticle.status === "closed"}
/>
</div>
);
@@ -314,6 +322,7 @@ export function StreamView({ path, streamParticle }: StreamViewProps) {
targetPath={path}
onActiveChange={setComposeActive}
onParticleCreated={onLocalParticleCreated}
disabled={streamParticle.status === "closed"}
/>
{/* BottomBar */}
@@ -461,14 +470,48 @@ function TopBar({ networkId, particle, streamParticle }: { networkId: string; pa
</button>
)}
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
onClick={() => navigate("/settings")}
>
<Settings className="size-3.5" />
</Button>
{streamParticle.status === "closed" && (
<span className="no-drag flex items-center gap-1 rounded-full bg-white/10 px-2 py-0.5 text-xs text-muted-foreground backdrop-blur-sm">
<CircleCheckBig className="size-3" />
Closed
</span>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="no-drag text-muted-foreground"
>
<EllipsisVertical className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onSelect={async () => {
const docPath = toFirestoreDocPath(particlePath(networkId, [streamParticle.id]));
await updateStreamStatus(docPath, streamParticle.status === "open" ? "closed" : "open");
}}
>
{streamParticle.status === "open" ? (
<>
<CircleCheckBig className="size-4" />
Close stream
</>
) : (
<>
<CircleDot className="size-4 text-green-500" />
Open stream
</>
)}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => navigate("/settings")}>
<Settings className="size-4" />
Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}