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:
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user