support open / closed streams

- Tabs for viewing separately
- Context menu to close / open streams
- Update stream particle status field
This commit is contained in:
talksik
2026-04-07 15:09:22 -07:00
parent 5ec24d9542
commit ff6891f57e
14 changed files with 496 additions and 66 deletions
+2 -2
View File
@@ -80,7 +80,6 @@ export type DepotObject = z.infer<typeof DepotObjectSchema>;
export const StreamPropertiesSchema = z.object({
name: z.string(),
status: z.enum(["open", "closed"]),
description: z.string().optional(),
});
export type StreamProperties = z.infer<typeof StreamPropertiesSchema>;
@@ -174,7 +173,8 @@ const ParticleBaseSchema = z.object({
export const ParticleSchema = z.discriminatedUnion("type", [
ParticleBaseSchema.extend({
type: z.literal("stream"), properties: StreamPropertiesSchema,
type: z.literal("stream"),
properties: StreamPropertiesSchema,
// e.g. ["human:human_xxxx", "human:human_yyyy"] - visible only to Aron and John
// e.g. ["network:xywx"] - visible to everyone in the network
visible_to: z.array(z.string()),
+261
View File
@@ -0,0 +1,261 @@
import * as React from "react"
import { ContextMenu as ContextMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronRightIcon, CheckIcon } from "lucide-react"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger
data-slot="context-menu-trigger"
className={cn("select-none", className)}
{...props}
/>
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn("z-50 max-h-(--radix-context-menu-content-available-height) min-w-36 origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/context-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 focus:*:[svg]:text-accent-foreground data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn("z-50 min-w-32 origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-lg border bg-popover p-1 text-popover-foreground shadow-lg duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute right-2">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/context-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+90
View File
@@ -0,0 +1,90 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
@@ -303,7 +303,6 @@ export function ComposeOverlay({
networkId,
properties: {
name: streamName,
status: "open",
},
createdByHumanId: userId,
visibleTo,
+31 -16
View File
@@ -1,4 +1,4 @@
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 { particlePath } from "@/lib/particle-path";
@@ -6,6 +6,7 @@ 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 px-3 pt-2 pb-1">
<Tabs
value={statusTab}
onValueChange={(v) => setStatusTab(v as "open" | "closed")}
>
<TabsList>
<TabsTrigger value="open">Open</TabsTrigger>
<TabsTrigger value="closed">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">
{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">
+1 -1
View File
@@ -9,7 +9,7 @@ interface FolderViewProps {
}
export function FolderView({ path, folderParticle }: FolderViewProps) {
const { children, error, isLoading } = useLiveParticleChildren({ path });
const { children, error, isLoading } = useLiveParticleChildren(path);
const { networkId } = parseParticlePath(path);
return (
@@ -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>
);
@@ -15,10 +15,7 @@ import {
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";
@@ -26,9 +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 { 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) {
@@ -147,7 +145,7 @@ function StreamRow({
const subtitle = latestChild
? getMessagePreview(latestChild)
: particle.properties.status;
: particle.properties.name;
const TypeIcon = latestChild ? getParticleTypeIcon(latestChild) : Radio;
@@ -236,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) {
@@ -265,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">
@@ -0,0 +1,35 @@
import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuTrigger,
} from "@/components/ui/context-menu";
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 ? "Close stream" : "Open stream"}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
}
+2 -3
View File
@@ -1,5 +1,5 @@
import { useMutation } from "@tanstack/react-query";
import { createParticle } from "@/lib/firestore-particles";
import { createParticle, createStreamParticle } from "@/lib/firestore-particles";
import type { ParticleType, ParticlePropertiesMap } from "@/api/types";
import { particlePath, ParticlePath, toFirestoreChildrenPath, toFirestoreDocPath } from "@/lib/particle-path";
@@ -37,9 +37,8 @@ export function useCreateStreamParticle() {
mutationFn: async (params: CreateStreamParticleParams) => {
const path = particlePath(params.networkId, []);
const networkCollectionPath = toFirestoreChildrenPath(path);
return await createParticle(
return await createStreamParticle(
networkCollectionPath,
"stream",
params.properties,
params.createdByHumanId,
params.visibleTo,
+1 -1
View File
@@ -74,7 +74,7 @@ export function useLiveParticleChildren(
onAdded,
onRemoved,
whereFilter,
}: UseLiveParticleChildrenParams
}: UseLiveParticleChildrenParams = {}
): UseLiveParticleChildrenResult {
const [children, setChildren] = useState<Particle[]>([]);
const [isLoading, setIsLoading] = useState(true);
+1 -1
View File
@@ -4,7 +4,7 @@ import { useAuthStore } from "@/stores/auth-store";
import { parseParticlePath, type ParticlePath } from "@/lib/particle-path";
import type { Particle, StreamProperties } from "@/api/types";
type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
export type StreamParticle = Particle & { type: "stream"; properties: StreamProperties };
function useVisibilityScopes(userId?: string, networkId?: string) {
return useMemo(() => {
+32
View File
@@ -63,6 +63,7 @@ const particleConverter: FirestoreDataConverter<Particle> = {
: undefined,
last_child_created_at: raw.last_child_created_at ? (raw.last_child_created_at as Timestamp).toDate() : undefined,
huddle_active_participants: raw.huddle_active_participants ?? undefined,
status: raw.status ?? undefined,
});
case "folder":
return ParticleSchema.parse({
@@ -245,6 +246,29 @@ export async function createParticle<T extends ParticleType>(
return ref.id;
}
export async function createStreamParticle(
collectionPath: string,
properties: ParticlePropertiesMap["stream"],
createdByHumanId: string,
visibleTo?: string[],
): Promise<string> {
if (!visibleTo || visibleTo.length === 0) {
throw new Error("visibleTo is required for streams and cannot be empty");
}
const particle: Particle = ParticleSchema.parse({
id: "",
type: "stream",
properties,
created_at: new Date(),
created_by_human_id: createdByHumanId,
visible_to: visibleTo,
status: "open",
});
const ref = await addDoc(typedCollection(collectionPath), particle);
return ref.id;
}
// This allows updating properties without overwriting the entire properties object
export async function updateParticleProperties<T extends ParticleType>(
docPath: string,
@@ -298,6 +322,14 @@ export async function updateParticle(
});
}
export async function updateStreamStatus(
docPath: string,
status: "open" | "closed",
): Promise<void> {
const particleRef = typedDoc(docPath);
await updateDoc(particleRef, { status, updated_at: serverTimestamp() });
}
export async function updateStreamPlaybackMarker(
docPath: string,
humanId: string,