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
+12 -1
View File
@@ -23,6 +23,8 @@ interface ComposeOverlayProps {
targetPath?: ParticlePath;
onActiveChange?: (active: boolean) => void;
onParticleCreated?: (particleId: string) => void;
/** When true, composing is blocked (e.g. stream is closed). */
disabled?: boolean;
}
@@ -37,6 +39,7 @@ export function ComposeOverlay({
targetPath,
onActiveChange,
onParticleCreated,
disabled,
}: ComposeOverlayProps) {
const [step, setStep] = useState<ComposeStep>("idle");
const [error, setError] = useState<string | null>(null);
@@ -56,6 +59,8 @@ export function ComposeOverlay({
// Refs for synchronous reads in keyboard handlers
const stepRef = useRef(step);
const recordStartRef = useRef(0);
const disabledRef = useRef(disabled);
disabledRef.current = disabled;
const setStepSync = useCallback((next: ComposeStep) => {
stepRef.current = next;
@@ -303,7 +308,6 @@ export function ComposeOverlay({
networkId,
properties: {
name: streamName,
status: "open",
},
createdByHumanId: userId,
visibleTo,
@@ -342,6 +346,13 @@ export function ComposeOverlay({
switch (currentStep) {
case "idle": {
if (disabledRef.current) {
if ((e.key === "`" && !e.repeat) || e.key === "t" || e.key === "T") {
e.preventDefault();
toast.info("This stream is closed");
}
break;
}
if (e.key === "`" && !e.repeat) {
e.preventDefault();
recordStartRef.current = Date.now();
+1 -1
View File
@@ -91,7 +91,7 @@ function TopBar() {
const { data: particle } = useParticle(path);
return (
<div className="drag-region flex items-center gap-3 border-b px-3 py-1">
<div className="drag-region flex items-center gap-3 border-b px-3 py-1 bg-muted">
<WindowControls />
<Breadcrumb className="no-drag">
+32 -17
View File
@@ -1,11 +1,12 @@
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { List, LayoutGrid } from "lucide-react";
import { List, LayoutGrid, CircleDot, CircleCheckBig } 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 ControlsIndicator from "@/features/compose/controls-indicator";
import { ComposeOverlay } from "./compose/compose-overlay";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { useViewModeStore } from "@/stores/view-mode-store";
import { useStreamParticles } from "@/hooks/use-stream-particles";
@@ -24,13 +25,19 @@ export default function NetworkRoot() {
const viewMode = useViewModeStore((s) => s.viewMode);
const setViewMode = useViewModeStore((s) => s.setViewMode);
const { streams } = useStreamParticles(path);
const { streams, isLoading } = useStreamParticles(path);
const userId = useAuthStore((s) => s.user?.id);
useDockBadge(streams, userId);
const [composeActive, setComposeActive] = useState(false);
const [statusTab, setStatusTab] = useState<"open" | "closed">("open");
const filteredStreams = useMemo(
() => streams.filter((s) => s.status === statusTab),
[streams, statusTab],
);
const { selectedIndex } = useStreamKeyboardNav({
streams,
streams: filteredStreams,
viewMode,
enabled: !composeActive,
onNavigate: useCallback(
@@ -40,18 +47,18 @@ export default function NetworkRoot() {
});
return (
<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} selectedIndex={selectedIndex} />
) : (
<ParticleGridView path={path} selectedIndex={selectedIndex} />
)}
</div>
{/* Fixed overlays */}
<div className="pointer-events-none absolute inset-x-0 top-0 flex justify-end px-3 pt-2">
<div className="relative flex min-h-0 flex-1 flex-col">
{/* Top bar — stays in place */}
<div className="flex shrink-0 items-center justify-between p-1 border-b">
<Tabs
value={statusTab}
onValueChange={(v) => setStatusTab(v as "open" | "closed")}
>
<TabsList>
<TabsTrigger value="open"><CircleDot className="size-3 text-green-500" /> Open</TabsTrigger>
<TabsTrigger value="closed"><CircleCheckBig className="size-3" /> Closed</TabsTrigger>
</TabsList>
</Tabs>
<ToggleGroup
type="single"
value={viewMode}
@@ -59,7 +66,6 @@ export default function NetworkRoot() {
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" />
@@ -70,6 +76,15 @@ export default function NetworkRoot() {
</ToggleGroup>
</div>
{/* Scrollable content */}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain pb-14 py-2">
{viewMode === "list" ? (
<ParticleListView streams={filteredStreams} networkId={networkId!} isLoading={isLoading} selectedIndex={selectedIndex} />
) : (
<ParticleGridView streams={filteredStreams} networkId={networkId!} isLoading={isLoading} selectedIndex={selectedIndex} />
)}
</div>
<ComposeOverlay networkId={networkId!} onActiveChange={setComposeActive} />
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 flex justify-center p-3">
<div className="pointer-events-auto">
-62
View File
@@ -8,11 +8,9 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Slider } from "@/components/ui/slider";
import { Muted } from "@/components/ui/typography";
import { WindowControls } from "@/components/window-controls";
import { useNetworks } from "@/hooks/use-networks";
import { useSetMessageRetention } from "@/hooks/use-network-settings";
import {
useNetworkInvitations,
useInviteMembers,
@@ -150,54 +148,6 @@ function SettingsGroup({
);
}
function formatRetentionDays(hours: number): string {
const days = Math.round(hours / 24);
return days === 1 ? "1 day" : `${days} days`;
}
function EphemeralitySettings({ networkId, retentionHours }: { networkId: string; retentionHours: number }) {
const setRetention = useSetMessageRetention(networkId);
const [days, setDays] = useState(Math.round(retentionHours / 24));
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
// Sync local state if server value changes externally
useEffect(() => {
setDays(Math.round(retentionHours / 24));
}, [retentionHours]);
const handleChange = useCallback((value: number[]) => {
const newDays = value[0];
setDays(newDays);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setRetention.mutate(newDays * 24, {
onSuccess: () => toast.success("Retention window updated"),
onError: (err) => toast.error(err.message || "Failed to update retention"),
});
}, 500);
}, [setRetention]);
return (
<div className="flex flex-col gap-3 px-4 py-3">
<div className="flex items-baseline justify-between">
<p className="text-sm font-medium">Messages disappear after</p>
<p className="text-sm font-semibold">{formatRetentionDays(days * 24)}</p>
</div>
<Slider
min={1}
max={14}
step={1}
value={[days]}
onValueChange={handleChange}
/>
<Muted className="text-xs">
Older messages are no longer visible to anyone.
</Muted>
</div>
);
}
export default function NetworkSettingsPage() {
const navigate = useNavigate();
const { networkId } = useParams<{ networkId: string }>();
@@ -240,18 +190,6 @@ export default function NetworkSettingsPage() {
))}
</SettingsGroup>
{isAdmin && network && (
<>
<Separator className="mt-4" />
<SettingsGroup title="Ephemerality">
<EphemeralitySettings
networkId={networkId!}
retentionHours={network.message_retention_hours}
/>
</SettingsGroup>
</>
)}
<Separator className="mt-4" />
{isAdmin && network && (
@@ -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>
);
}